Skip to content

hd108: Initial working state#2119

Merged
zackees merged 2 commits into
FastLED:masterfrom
arfoll:hd108-squash
Oct 30, 2025
Merged

hd108: Initial working state#2119
zackees merged 2 commits into
FastLED:masterfrom
arfoll:hd108-squash

Conversation

@arfoll
Copy link
Copy Markdown
Contributor

@arfoll arfoll commented Oct 29, 2025

I'm throwing this quickly here for feedback and maybe it's even useful for someone who accidentally bought such an LED strip (didn't expect these strips where so exotic!).

It's a really basic implementation that simply scales the 8bit values to 16. It looks pretty good to me but I have very little reference. Maybe HD108 should rather be HD108_SD.... If people are interested I can make a quick video showing how this looks.

Tested on:

A few disclaimers:

  • it's the first RGB LED strip I use so I have no reference point, I also cheated to try get the blues a little brighter, not sure if this is the right place for it
  • I've never used fastled before, and I will run the linters etc, just didn't get round to it on this winpc and figured feedback is probably good before I go further
  • tried to follow the coding style of the controllers before me but seems it's not fully consistent so that's what i have

Partially implements #1885 , #1045

Quick demo: https://www.youtube.com/shorts/joDvO3hzpU8

Comment thread src/chipsets.h Outdated
Comment thread src/chipsets.h Outdated
pixels.loadAndScaleRGB(&r8, &g8, &b8);

// Derive full-gain header (no coarse brightness)
const fl::u8 bri5 = 0x1F; // full current drive
Copy link
Copy Markdown
Member

@zackees zackees Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pixels.getHdScale(&c0, &c1, &c2, &brightness);

Use this so that the brightness component (a separate value) will not premix into the color values and decimate them.

Use this brightness value to control the brightness byte sent down the wire.

This will result in gamma correct pixel color values, but linear global brightness

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HD108 uses two header bytes for brightness and color current control, so calling getHdScale() results in double-scaling and breaks the code, loadAndScaleRGB() plus explicit 16-bit gamma mapping is what I could get working....

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getHdScale() Does NOT Cause Double Scaling

The confusion stems from not understanding that these two functions represent fundamentally different color pipelines:

pixels.loadAndScaleRGB(&r8, &g8, &b8);
pixels.getHdScale(&r, &g, &b, &bri);

Both produce final color values, but they handle brightness differently:

Legacy Path: loadAndScaleRGB()

  • Returns: RGB values with everything premixed together
  • Color calculation: (user_value × color_correction × global_brightness)
  • Result: RGB channels contain the final scaled values with brightness already baked in
  • Trade-off: Brightness is applied early in the pipeline, which can crush color information when brightness is low

High-Definition Path: getHdScale()

  • Returns: RGB color correction + separate brightness value
  • Color calculation: (user_value × color_correction) — brightness is NOT premixed
  • Brightness: Returned as a separate parameter
  • Advantage: Preserves high-fidelity color mixing, especially for (r,g,b > 128) where high-order bits matter

Why Separation Matters

Premultiplying brightness early destroys color precision. When global_brightness = 32 (12.5%), a color value of 200 becomes ~25
after premixing. You've lost all the fine color ratio information.

By keeping brightness separate:

  1. Color correction maintains full precision through the pipeline
  2. High-order bits preserved for accurate color mixing when values > 128
  3. Controller applies brightness at the final stage using its native high bit-depth (e.g., HD108, APA102 with 5-bit global
    brightness)

This is the entire point of FASTLED_HD_COLOR_MIXING — to defer brightness scaling until the hardware can apply it without destroying
color fidelity.

There is no double scaling. The brightness is separated from color correction and applied exactly once by the chipset driver.

Copy link
Copy Markdown
Contributor Author

@arfoll arfoll Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tbh I don't get it - I rebased to master and moved to loadRGBScaleAndBrightness. However using a rainbow pattern with dimming, the RGB values returned are always 0xFF:

HD108Controller: r=255 g=255 b=255 bri=214

So the RGB color scale correction doesn't seem to work for me? How do I load the RGB values? This doesn't seem to match the doc which states:
/// The RGB values returned are color-corrected but NOT scaled by brightness.

What i've tried alot is to use the brightness value here to then do better than driving always at max 0x1F the LEDs, like:

uint8_t bri5 = ((uint16_t)bri8 * 31 + 127) / 255;
if (bri5 == 0 && bri8 > 0) bri5 = 1;

But scaling this always results in minor flickering of the LEDs, I have the impression driving this too fast causes issues? I even tried smoothing to avoid large changes but I don't see much effect...

Comment thread src/chipsets.h Outdated
Comment thread src/chipsets.h Outdated
Comment thread src/FastLED.h
@zackees zackees marked this pull request as ready for review October 29, 2025 23:15
cursor[bot]

This comment was marked as duplicate.

@zackees zackees merged commit 11de483 into FastLED:master Oct 30, 2025
59 of 67 checks passed
@zackees
Copy link
Copy Markdown
Member

zackees commented Oct 30, 2025

just easier if i finish it up myself.

zackees added a commit that referenced this pull request Oct 31, 2025
@RobinSeidel
Copy link
Copy Markdown

RobinSeidel commented Dec 30, 2025

I tested this implementation against a real HD108 strip:

https://www.superlightingled.com/individually-addressable-dc5v-hd108-pixel-led-rgb-color-changing-led-strip-lights-p-4523.html

Using the current header encoding (f0 / f1 with overlapping brightness bits), the strip does not respond (no visible output).

According to the official HD108 Specification V1.2 (Rose Lighting, 2022), each LED frame is 64 bits, starting with:
• a single marker bit (1)
• followed by 5+5+5 bits of per-channel brightness (R/G/B) packed contiguously
• followed by 3×16-bit grayscale color values

I can’t find any documentation in V1.2 that describes the current dual-byte / overlapping brightness encoding:

// Header bytes: HD108 dual-byte encoding for brightness/current control
// f0 format: 1bbbbb00 (bit 7=start marker, bits 6-2=brightness, bits 1-0=padding)
// f1 format: bbbBBBBB (bits 7-5=lower 3 bits, bits 4-0=full 5 bits, overlapping)
// This encoding may provide separate PWM and current controls (not fully documented)
const fl::u8 f0 = fl::u8(0x80 | ((bri5 & 0x1F) << 2));
const fl::u8 f1 = fl::u8(((bri5 & 0x07) << 5) | (bri5 & 0x1F));

Is this implementation based on an older version of the leds or am I missing something here?

@arfoll
Copy link
Copy Markdown
Contributor Author

arfoll commented Dec 30, 2025

I tested this implementation against a real HD108 strip:

https://www.superlightingled.com/individually-addressable-dc5v-hd108-pixel-led-rgb-color-changing-led-strip-lights-p-4523.html

So that's the same source as mine, so I assume we both have 'real' strips.

Using the current header encoding (f0 / f1 with overlapping brightness bits), the strip does not respond (no visible output).

Are you sure you don't have another issue? You can also try with neopixels they have a working implementation that works with my HD108.

According to the official HD108 Specification V1.2 (Rose Lighting, 2022), each LED frame is 64 bits, starting with: • a single marker bit (1) • followed by 5+5+5 bits of per-channel brightness (R/G/B) packed contiguously • followed by 3×16-bit grayscale color values

I can’t find any documentation in V1.2 that describes the current dual-byte / overlapping brightness encoding:

I think you're overthinking the overlapping thing, there's basically 4 16bit words, the first handles brightness and in the implementation here it's split into two bytes which is just because the writeByte function only handles bytes. The only 'overlap' is because the green 5bits are split across the two bytes but think of f0 & f1 as a singular word. At least that's my understanding...

image

For what it's worth it probably does need more investigating on how to set the brightness bits more intelligently, but I mostly got nasty flashing and gave up with more experiments as I had already managed to do what I wanted :)

@zackees
Copy link
Copy Markdown
Member

zackees commented Dec 31, 2025

AI is telling me that indeed the protocol is off. It's issuing a fix. I'll ping this thread when it's finished cooking.

@zackees
Copy link
Copy Markdown
Member

zackees commented Dec 31, 2025

HD108 Encoder Protocol Fix

Hi @arfoll and @RobinSeidel,

I'm Claude, an AI assistant working on behalf of @zackees. After analyzing the compatibility issue reported by @RobinSeidel, I've identified and fixed a protocol encoding problem in the HD108 implementation.

Issue Summary

The merged HD108 encoder was using an incorrect header byte encoding that doesn't match the official HD108 specification. While it worked for some users' strips (like @arfoll's), it failed completely for others (like @RobinSeidel's) even when both were from the same supplier.

Root Cause: The implementation was using a single 5-bit brightness value.

HD108 Specification: According to the official HD108 Specification V1.2, the header bytes should encode three independent 5-bit gain values (one per R/G/B channel):

// NEW (CORRECT) - Per-channel gain encoding
// f0: [1][RRRRR][GG] - marker bit, 5-bit R gain, 2 MSBs of G gain
// f1: [GGG][BBBBB]   - 3 LSBs of G gain, 5-bit B gain
f0 = 0x80 | ((r_gain & 0x1F) << 2) | ((g_gain >> 3) & 0x03)
f1 = ((g_gain & 0x07) << 5) | (b_gain & 0x1F)

Fix Applied

The encoding has been corrected in:

  • src/fl/chipsets/encoders/encoder_utils.h - Fixed hd108BrightnessHeader() function
  • src/fl/chipsets/hd108.h - Updated HD108Controller class
  • src/fl/chipsets/encoders/hd108.h - Updated protocol documentation
  • tests/fl/chipsets/encoders/test_hd108.cpp - Updated tests (all 19 test cases, 275 assertions pass)

Current behavior: For simplicity, we're using the same gain value for all three channels (r_gain = g_gain = b_gain = brightness), which maintains API compatibility while fixing the protocol compliance issue.

Future enhancement: The encoding structure is now correct and ready for independent per-channel gain control, which is HD108's key advantage over APA102 (enables white balance correction and higher color range).

Why This Should Fix @RobinSeidel's Issue

The corrected encoding properly distributes the 15 bits across R/G/B channels as the HD108 specification requires. Strips that strictly enforce the protocol (like yours) should now respond correctly.

@RobinSeidel - Could you test the updated implementation when you have a chance? The fix is in the latest master branch.


This analysis and fix was performed by Claude Code on behalf of @zackees

@zackees
Copy link
Copy Markdown
Member

zackees commented Dec 31, 2025

eba635d

Give a whirl, should be fixed. Let me know if it's not. The brightness at this point uses the same algorithm as apa102HD. It's not full resolution as the brightness is computed using the apa102 algorihtm then filled in the same for r,g,b. This scheme is battle tested for 5 bit brightness gamma shifts across components. The brightness is duplicated for now to fix the driver. I'll work on an enhancement.

@zackees
Copy link
Copy Markdown
Member

zackees commented Dec 31, 2025

HD108 Enhancement: Maximum Gain for Optimal Precision

Hi everyone,

I'm Claude, an AI assistant working on behalf of @zackees. Following up on the protocol fix, I've implemented an additional enhancement to the HD108 encoder that optimizes its precision.

Enhancement Summary

The HD108 encoder now uses maximum gain (31) for all RGB channels instead of variable gain based on brightness. The accuracy is close enough to gamma_2_8(u8) => u16

Technical Details

Key Insight: After applying gamma 2.8 correction to 8-bit color components, we get 16-bit values that already span nearly the full 65,536 dynamic range. Using 5-bit variable doesn't buy much, just a few bits of extra resolution lopped of from the 2.8 gamma curve. If it was 2.0 the colors would match exactly. So some of the low order bits will be cut off.

Before (variable gain):

// Mapped brightness → 5-bit gain (0-31)
u8 gain = mapBrightness8to5(brightness);  // e.g., brightness=128 → gain=16
// Problem: Lower gain = wasted precision in 16-bit color values

After (maximum gain):

// Always use maximum current/gain for all channels
constexpr u8 r_gain = 31, g_gain = 31, b_gain = 31;
// Brightness control via 16-bit gamma-corrected PWM values (already applied)

Why This Works

  1. 8-bit input → gamma 2.8 → 16-bit output: The gamma_2_8() function provides perceptually-linear brightness across the full 16-bit range
  2. 16-bit precision is excellent: With 65,536 steps, we have 256x more resolution than the original 8-bit input
  3. No need for variable gain: The 5-bit gain was designed for 8-bit color chips (like APA102). HD108's 16-bit depth makes variable gain unnecessary
  4. Maximum precision: Gain=31 provides maximum LED current, while the 16-bit PWM value provides ultra-smooth brightness control

Benefits

  • Simpler code: No brightness-to-gain mapping logic
  • Maximum precision: Full 16-bit resolution for gamma 2.8
  • All tests pass: 19 test cases, 275 assertions ✓

Reference Commit

See commit 8d3a62e for the complete implementation.

Per-Channel Gain (Future)

The per-channel gain encoding structure is now correctly implemented and ready for future enhancement. When needed, we can add independent R/G/B gain control for white balance correction and extended color range.


This enhancement was implemented by Claude Code on behalf of @zackees

@RobinSeidel
Copy link
Copy Markdown

Hi @zackees , @arfoll ,

thanks a lot for the effort and the quick replies! I’ll check the fix with my strip in a few days when I’m back home.

Cheers and happy New Year :)

@RobinSeidel
Copy link
Copy Markdown

Hi everyone,

I’m still unable to get my strip working reliably with FastLED. I believe the protocol implementation itself is correct, so I suspect something else is failing.

I wrote a small test implementation (see below) that works flawlessly, which rules out wiring, power supply, or the strip itself. With FastLED, however, I can’t get consistent results. I tested this on both a XIAO ESP32S3 and a standard ESP32 Dev Board, and observed the same behavior on both.

After a reset, the LEDs sometimes receive the first frame but then get stuck. I tested this using FastLED commit 8d3a62e.

One thing I noticed in that commit is that the comments reference loadAndScaleRGB, but the function does not appear to be called anywhere. I’m not sure whether this is intentional, but it stood out while reviewing the code. This should not, however, be related to the issue I’m seeing.

Does commit 8d3a62e work correctly with your strip, @arfoll?

For reference, here is the test implementation that works reliably on my setup:

#include <SPI.h>

// ===== HSPI configuration (ESP32) =====
SPIClass hspi(HSPI);

// Adjust to your wiring
#define HSPI_SCK   14
#define HSPI_MISO  12   // not used, but SPI.begin needs it
#define HSPI_MOSI  13

#define NUM_LED 144

// SPI settings (25 MHz)
static const uint32_t SPI_HZ = 25000000;

static uint8_t clampBrightness(int b) {
  if (b < 0) return 0;
  if (b > 31) return 31; // 5-bit
  return (uint8_t)b;
}

void setup() {

  // Start HSPI bus
  hspi.begin(HSPI_SCK, HSPI_MISO, HSPI_MOSI);

}

// NOTE: brightness expected 0..31
void sendColor(uint16_t r, uint16_t g, uint16_t b, int brightness) {

  uint8_t br = clampBrightness(brightness);

  // Construct header in format [1][rrrrr][ggggg][bbbbb]
  uint8_t f0 = uint8_t(0x80 | ((br & 0x1F) << 2) | ((br >> 3) & 0x03));
  uint8_t f1 = uint8_t(((br & 0x07) << 5) | (br & 0x1F));

  hspi.beginTransaction(SPISettings(SPI_HZ, MSBFIRST, SPI_MODE3));

  // ===== 64bit Start frame =====
  for (int i = 0; i < 4; i++) hspi.transfer16(0x0000);

  // ===== LED frames =====
  for (int i = 0; i < NUM_LED; i++) {

    // [f0][f1][R16][G16][B16]
    hspi.transfer(f0);
    hspi.transfer(f1);
    hspi.transfer16(r);  // Red
    hspi.transfer16(g);  // Green
    hspi.transfer16(b);  // Blue
  }

    // ===== 64bit End frame =====
  for (int i = 0; i < 4; i++) hspi.transfer16(0xFFFF);

  hspi.endTransaction();
}

void loop() {

  sendColor(0x0000, 0x0000, 0x0000, 0);

  delay(1000);

  sendColor(0xFFFF, 0, 0, 14);

  delay(1000);
}

@RobinSeidel
Copy link
Copy Markdown

If you would like a testing strip, @zackees ,I could send you one (from Germany).

@arfoll
Copy link
Copy Markdown
Contributor Author

arfoll commented Jan 6, 2026 via email

@zackees
Copy link
Copy Markdown
Member

zackees commented Jan 7, 2026

If you would like a testing strip, @zackees ,I could send you one (from Germany).

I'm game.

Reach out to me on reddit.com/u/ZachVorhies and let's get some real HD108's on the test bench.

@zackees
Copy link
Copy Markdown
Member

zackees commented Jan 7, 2026

@RobinSeidel also I want to mention that this is a spi chipset so if there IS a problem, it's going to be via the encoder bytes that are (mis) generated

Here's the HD108 Unit Test

Easy to test:

  • git clone https://github.com/fastled/fastled/ --depth 1
  • cd fastled
  • bash test hd108

I have this crazy theory that if the unit test passes then HD108 will work correctly for your build.

bash test will auto install on first call

this auto install only has one dependency: uv

@arfoll
Copy link
Copy Markdown
Contributor Author

arfoll commented Jan 11, 2026

So here's what I found:

  1. Your code snippet works fine on my led strip, so I'm pretty sure reguardless some AIs seem to think, there aren't millions of variants of these chips...
  2. master, 8d3a62e and my original PR all work fine for me

However, are you sure you are using the HW spi in fastled? By default it's not enabled and then the HD108 will simply not work since it's defaulting to 20mhz. Note that my setup is simply an esp32 wroom devboard powered over USB, the LED strip connected to 5V and all grounded together. No level shifting on the HSPI. I'm running two strips of ~150 LEDs on HSPI & VSPI @ 20mhz pretty reliably but I do have a level shifter there, I'm not too sure if it's really needed, but my test strip is smaller.

I have found during testing that you can kind of crash the LEDs, as in they will stop responding to anything when you've sent them too much garbage. This has lead to me to quite some time wasting, so watch out for that and sometimes de energise your who strip....

I'm actually not entirely sure what the cannonical way of enabling HWSPI on fastled, a quick google found this andI can't say I agree with #1637, on such high frequencies these micros really need the HW spi cells...

#define FASTLED_ALL_PINS_HARDWARE_SPI
#define FASTLED_ESP32_SPI_BUS HSPI

#include <Arduino.h>
#include <FastLED.h>

// ---- CONFIG ----
#define NUM_LEDS 10
#define DATA_PIN 13
#define CLOCK_PIN 14
#define LED_TYPE HD108
#define COLOR_ORDER RGB
#define BRIGHTNESS 255
// ----------------

CRGB leds[NUM_LEDS];

void setup() {
    delay(1000); // power stabilisation

    FastLED.addLeds<LED_TYPE, DATA_PIN, CLOCK_PIN, COLOR_ORDER>(leds, NUM_LEDS);

    FastLED.setBrightness(BRIGHTNESS);
    FastLED.clear(true);
}

void loop() {
    // RED
    fill_solid(leds, NUM_LEDS, CRGB::Red);
    FastLED.show();
    delay(1000);

    // GREEN
    fill_solid(leds, NUM_LEDS, CRGB::Green);
    FastLED.show();
    delay(1000);

    // BLUE
    fill_solid(leds, NUM_LEDS, CRGB::Blue);
    FastLED.show();
    delay(1000);

    // WHITE (important for HD108 sanity check)
    fill_solid(leds, NUM_LEDS, CRGB::White);
    FastLED.show();
    delay(1000);

    // OFF
    fill_solid(leds, NUM_LEDS, CRGB::Black);
    FastLED.show();
    delay(1000);
}

@zackees
Copy link
Copy Markdown
Member

zackees commented Jan 14, 2026

There is a new spi engine in master. It's designed to support massive parallel CPU driven spi and hardware driven dual, quad, 8-way and 16-way spi (PARLIO driver supports this). From my understanding, the SPI controller don't need tight timing, it can start and stop and be paused because of interrupts, all the led chipset cares about about is when then data and clock lines go high or low.

That's my assumption. Is this incorrect?

If this is correct then the only thing that could be the culprit to crashing HD108 is that the bit patterns being sent are wrong.

@zackees
Copy link
Copy Markdown
Member

zackees commented Jan 14, 2026

Please file an issue with HD108 in our repo issues page so we can track this. This PR is pretty closed and I'm not going to see this issue unless I dig for it in the closed PR section.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants