From d7e45b4805368829b3581548c33a51213e4fcf0d Mon Sep 17 00:00:00 2001 From: Christian Kunis Date: Mon, 4 May 2026 14:41:47 -0400 Subject: [PATCH 1/8] Add per-bus W-channel CCT for accurate auto-white calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Auto-Calculate White "Accurate" mode subtracts the W channel value equally from R, G, B — which implicitly assumes the physical W LED emits RGB(255, 255, 255), i.e. the sRGB white point near D65 / ~6500 K. For 2700 K WW or 5000 K CW strips this shifts the resulting color visibly. Add a per-bus configurable white-LED color temperature (Kelvin) that feeds into autoWhiteCalc, so the W LED's actual R/G/B contribution is computed via colorKtoRGB and used to (a) cap the W channel without overflowing any RGB channel and (b) subtract the correct per-channel amount in ACCURATE mode. The feature is opt-in per bus via a UI checkbox; when off (the default, wk = 0) autoWhiteCalc behaves as before, so existing configs render identically. UI lives next to the per-bus "Auto-calculate W channel from RGB" selector and is only shown when AW mode is Brighter or Accurate. The Kelvin number input is disabled (and not submitted) until the user checks the enable box, at which point it defaults to 6500 K — matching the implicit legacy reference white point. https://claude.ai/code/session_019b31kdwp79ouA3gD5Tox9A Co-authored-by: Claude --- wled00/bus_manager.cpp | 36 ++++++++++++++++++++++++++++-- wled00/bus_manager.h | 15 ++++++++++++- wled00/cfg.cpp | 4 +++- wled00/data/settings_leds.htm | 42 ++++++++++++++++++++++++++++++++++- wled00/set.cpp | 6 ++++- wled00/xml.cpp | 4 ++++ 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/wled00/bus_manager.cpp b/wled00/bus_manager.cpp index 175d49ba9c..08ed860aad 100644 --- a/wled00/bus_manager.cpp +++ b/wled00/bus_manager.cpp @@ -98,6 +98,22 @@ void Bus::calculateCCT(uint32_t c, uint8_t &ww, uint8_t &cw) { cw = (w * cw) / 255; } +// AI: below section was generated by an AI +// recompute cached W-LED RGB equivalent when the configured Kelvin changes; +// 0 means "treat the W LED as neutral white" which preserves legacy behavior +// where autoWhiteCalc subtracted the same value from R, G, B. +void Bus::setWhiteKelvin(uint16_t k) { + _whiteKelvin = k; + if (k == 0) { + _wR = _wG = _wB = 255; // legacy: treat W as neutral + } else { + byte rgb[4]; + colorKtoRGB(k, rgb); + _wR = rgb[0]; _wG = rgb[1]; _wB = rgb[2]; + } +} +// AI: end + // calculates white channel and CCT values based on given settings uint32_t Bus::autoWhiteCalc(uint32_t c, uint8_t &ww, uint8_t &cw) const { unsigned aWM = _autoWhiteMode; @@ -113,8 +129,23 @@ uint32_t Bus::autoWhiteCalc(uint32_t c, uint8_t &ww, uint8_t &cw) const { } else if (aWM == RGBW_MODE_MAX) { w = r > g ? (r > b ? r : b) : (g > b ? g : b); // brightest RGB channel } else { - w = r < g ? (r < b ? r : b) : (g < b ? g : b); // darkest RGB channel - if (aWM == RGBW_MODE_AUTO_ACCURATE) { r -= w; g -= w; b -= w; } //subtract w in ACCURATE mode + // AI: below section was generated by an AI + // Per-channel cap: pick the largest w such that (w * _wX)/255 <= channel + // for every X in {R,G,B}, preventing underflow when subtracting the W + // LED's RGB contribution. When _whiteKelvin==0 the cached _wR/_wG/_wB + // are all 255, so this collapses to the legacy w = min(r,g,b). + unsigned wMaxR = _wR ? (r * 255U) / _wR : 255U; + unsigned wMaxG = _wG ? (g * 255U) / _wG : 255U; + unsigned wMaxB = _wB ? (b * 255U) / _wB : 255U; + unsigned wCap = wMaxR < wMaxG ? (wMaxR < wMaxB ? wMaxR : wMaxB) : (wMaxG < wMaxB ? wMaxG : wMaxB); + if (wCap > 255U) wCap = 255U; + w = wCap; + if (aWM == RGBW_MODE_AUTO_ACCURATE) { + r -= (w * _wR) / 255; // subtract W LED's R contribution + g -= (w * _wG) / 255; // subtract W LED's G contribution + b -= (w * _wB) / 255; // subtract W LED's B contribution + } + // AI: end } c = RGBW32(r, g, b, w); } @@ -1221,6 +1252,7 @@ int BusManager::add(const BusConfig &bc, bool placeholder) { } else { busses.push_back(make_unique(bc)); } + if (!busses.empty()) busses.back()->setWhiteKelvin(bc.whiteKelvin); return busses.size(); } diff --git a/wled00/bus_manager.h b/wled00/bus_manager.h index abfb08c81b..c84746b8db 100644 --- a/wled00/bus_manager.h +++ b/wled00/bus_manager.h @@ -58,6 +58,7 @@ make_unique(Args&&... args) //colors.cpp uint16_t approximateKelvinFromRGB(uint32_t rgb); +void colorKtoRGB(uint16_t kelvin, byte* rgb); #define GET_BIT(var,bit) (((var)>>(bit))&0x01) #define SET_BIT(var,bit) ((var)|=(uint16_t)(0x0001<<(bit))) @@ -121,6 +122,10 @@ class Bus { , _reversed(reversed) , _valid(false) , _needsRefresh(refresh) + , _whiteKelvin(0) + , _wR(255) + , _wG(255) + , _wB(255) { _autoWhiteMode = Bus::hasWhite(type) ? aw : RGBW_MODE_MANUAL_ONLY; }; @@ -162,6 +167,8 @@ class Bus { inline void setStart(uint16_t start) { _start = start; } inline void setAutoWhiteMode(uint8_t m) { if (m < 5) _autoWhiteMode = m; } inline uint8_t getAutoWhiteMode() const { return _autoWhiteMode; } + inline uint16_t getWhiteKelvin() const { return _whiteKelvin; } + void setWhiteKelvin(uint16_t k); inline size_t getNumberOfChannels() const { return hasWhite() + 3*hasRGB() + hasCCT(); } inline uint16_t getStart() const { return _start; } inline uint8_t getType() const { return _type; } @@ -221,6 +228,10 @@ class Bus { uint8_t _autoWhiteMode; // global Auto White Calculation override uint16_t _start; uint16_t _len; + uint16_t _whiteKelvin; // physical W-channel CCT in Kelvin (0 = neutral/legacy behavior) + uint8_t _wR; // cached W LED RGB equivalent (255,255,255 when _whiteKelvin==0) + uint8_t _wG; + uint8_t _wB; //struct { //using bitfield struct adds abour 250 bytes to binary size bool _reversed;// : 1; bool _valid;// : 1; @@ -461,6 +472,7 @@ struct BusConfig { uint8_t skipAmount; bool refreshReq; uint8_t autoWhite; + uint16_t whiteKelvin; // physical W-channel CCT in Kelvin (0 = neutral/legacy behavior) uint8_t pins[OUTPUT_MAX_PINS] = {255, 255, 255, 255, 255}; uint16_t frequency; uint8_t milliAmpsPerLed; @@ -469,13 +481,14 @@ struct BusConfig { uint8_t iType; // internal bus type (I_*) determined during memory estimation, used for bus creation String text; - BusConfig(uint8_t busType, uint8_t* ppins, uint16_t pstart, uint16_t len = 1, uint8_t pcolorOrder = COL_ORDER_GRB, bool rev = false, uint8_t skip = 0, byte aw=RGBW_MODE_MANUAL_ONLY, uint16_t clock_kHz=0U, uint8_t maPerLed=LED_MILLIAMPS_DEFAULT, uint16_t maMax=ABL_MILLIAMPS_DEFAULT, uint8_t driver=0, String sometext = "") + BusConfig(uint8_t busType, uint8_t* ppins, uint16_t pstart, uint16_t len = 1, uint8_t pcolorOrder = COL_ORDER_GRB, bool rev = false, uint8_t skip = 0, byte aw=RGBW_MODE_MANUAL_ONLY, uint16_t clock_kHz=0U, uint8_t maPerLed=LED_MILLIAMPS_DEFAULT, uint16_t maMax=ABL_MILLIAMPS_DEFAULT, uint8_t driver=0, String sometext = "", uint16_t whiteK=0) : count(std::max(len,(uint16_t)1)) , start(pstart) , colorOrder(pcolorOrder) , reversed(rev) , skipAmount(skip) , autoWhite(aw) + , whiteKelvin(whiteK) , frequency(clock_kHz) , milliAmpsPerLed(maPerLed) , milliAmpsMax(maMax) diff --git a/wled00/cfg.cpp b/wled00/cfg.cpp index 2e458e7da9..a9a9a42a47 100644 --- a/wled00/cfg.cpp +++ b/wled00/cfg.cpp @@ -238,6 +238,7 @@ bool deserializeConfig(JsonObject doc, bool fromFS) { bool refresh = elm["ref"] | false; uint16_t freqkHz = elm[F("freq")] | 0; // will be in kHz for DotStar and Hz for PWM uint8_t AWmode = elm[F("rgbwm")] | RGBW_MODE_MANUAL_ONLY; + uint16_t whiteK = elm[F("wk")] | 0; // physical W-channel CCT in K (0 = neutral/legacy) uint8_t maPerLed = elm[F("ledma")] | LED_MILLIAMPS_DEFAULT; uint16_t maMax = elm[F("maxpwr")] | (ablMilliampsMax * length) / total; // rough (incorrect?) per strip ABL calculation when no config exists // To disable brightness limiter we either set output max current to 0 or single LED current to 0 (we choose output max current) @@ -249,7 +250,7 @@ bool deserializeConfig(JsonObject doc, bool fromFS) { uint8_t driverType = elm[F("drv")] | 0; // 0=RMT (default), 1=I2S note: polybus may override this if driver is not available String host = elm[F("text")] | String(); - busConfigs.emplace_back(ledType, pins, start, length, colorOrder, reversed, skipFirst, AWmode, freqkHz, maPerLed, maMax, driverType, host); + busConfigs.emplace_back(ledType, pins, start, length, colorOrder, reversed, skipFirst, AWmode, freqkHz, maPerLed, maMax, driverType, host, whiteK); doInitBusses = true; // finalization done in beginStrip() if (!Bus::isVirtual(ledType)) s++; // have as many virtual buses as you want } @@ -999,6 +1000,7 @@ void serializeConfig(JsonObject root) { ins["type"] = bus->getType() & 0x7F; ins["ref"] = bus->isOffRefreshRequired(); ins[F("rgbwm")] = bus->getAutoWhiteMode(); + ins[F("wk")] = bus->getWhiteKelvin(); ins[F("freq")] = bus->getFrequency(); ins[F("maxpwr")] = bus->getMaxCurrent(); ins[F("ledma")] = bus->getLEDCurrent(); diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index f1471e9bf4..03a5adffd0 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -205,6 +205,20 @@ }); if (ppl) d.Sf.MA.value = sumMA; // populate UI ABL value if PPL used } + // AI: below section was generated by an AI + // Per-bus W-LED color temperature toggle. The number input is disabled + // (and hidden) when the checkbox is off, so it isn't submitted with + // the form — backend then sees no WK arg and stores wk=0 (legacy + // fast path). Seed the field to 6500 K when re-enabling from a blank + // or sub-min value so the UI default matches the sRGB white point. + function wkChk(n) + { + const wke = d.Sf["WKE"+n], wk = d.Sf["WK"+n]; + if (!wke || !wk) return; + if (wke.checked && parseInt(wk.value)<1900) wk.value = 6500; + UI(); + } + // AI: end // enable and update LED Amps function enLA(s,n) { @@ -369,6 +383,25 @@ gId("dig"+n+"s").style.display = (isVir(t) || isAna(t) || isHub75(t)) ? "none":"inline"; // hide skip 1st for virtual & analog gId("dig"+n+"f").style.display = (isDig(t) || (isPWM(t) && maxL>2048)) ? "inline":"none"; // hide refresh (PWM hijacks reffresh for dithering on ESP32) gId("dig"+n+"a").style.display = (hasW(t)) ? "inline":"none"; // auto calculate white + // AI: below section was generated by an AI + // W-channel CCT controls are only meaningful when autoWhiteCalc + // actually subtracts the W contribution — i.e. AW mode is + // Brighter (1) or Accurate (2). Hide the whole block otherwise. + // The number input is disabled (and not submitted) when the + // WKE checkbox is off, so the backend stores wk=0 and the + // legacy autoWhite path is used. + { + const awEl = d.Sf["AW"+n]; + const awv = awEl ? parseInt(awEl.value) : 0; + const wkBox = gId("dig"+n+"wk"); + if (wkBox) wkBox.style.display = (hasW(t) && (awv === 1 || awv === 2)) ? "inline" : "none"; + const wke = d.Sf["WKE"+n], wk = d.Sf["WK"+n]; + if (wke && wk) { + wk.disabled = !wke.checked; + wk.style.display = wke.checked ? "inline" : "none"; + } + } + // AI: end gId("dig"+n+"l").style.display = (isD2P(t) || isPWM(t)) ? "inline":"none"; // bus clock speed / PWM speed (relative) (not On/Off) gId("rev"+n).innerHTML = isAna(t) ? "Inverted output":"Reversed"; // change reverse text for analog else (rotated 180°) //gId("psd"+n).innerHTML = isAna(t) ? "Index:":"Start:"; // change analog start description @@ -593,7 +626,7 @@

Reversed:

Skip first LEDs:

Off Refresh:
-

Auto-calculate W channel from RGB:
 
+

Auto-calculate W channel from RGB:
 
`; f.insertAdjacentHTML("beforeend", cn); // fill led types (credit @netmindz) @@ -784,6 +817,13 @@ d.getElementsByName("RF"+i)[0].checked = v.ref; d.getElementsByName("CV"+i)[0].checked = v.rev; d.getElementsByName("AW"+i)[0].value = v.rgbwm; + { // AI: derive WKE checkbox + WK seed from stored wk (0 = feature off) + const wkChkEl = d.getElementsByName("WKE"+i)[0]; + const wkEl = d.getElementsByName("WK"+i)[0]; + const wkv = parseInt(v.wk) | 0; + if (wkChkEl) wkChkEl.checked = wkv > 0; + if (wkEl) wkEl.value = wkv > 0 ? wkv : 6500; + } d.getElementsByName("WO"+i)[0].value = (v.order>>4) & 0x0F; d.getElementsByName("SP"+i)[0].value = v.freq; d.getElementsByName("LA"+i)[0].value = v.ledma; diff --git a/wled00/set.cpp b/wled00/set.cpp index fb516ac7d6..81273ab5a4 100644 --- a/wled00/set.cpp +++ b/wled00/set.cpp @@ -205,6 +205,7 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage) char sl[4] = "SL"; sl[2] = offset+s; sl[3] = 0; //skip first N LEDs char rf[4] = "RF"; rf[2] = offset+s; rf[3] = 0; //refresh required char aw[4] = "AW"; aw[2] = offset+s; aw[3] = 0; //auto white mode + char wk[4] = "WK"; wk[2] = offset+s; wk[3] = 0; //W-channel CCT (Kelvin) char wo[4] = "WO"; wo[2] = offset+s; wo[3] = 0; //channel swap char sp[4] = "SP"; sp[2] = offset+s; sp[3] = 0; //bus clock speed (DotStar & PWM) char la[4] = "LA"; la[2] = offset+s; la[3] = 0; //LED mA @@ -230,6 +231,9 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage) break; // no parameter } awmode = request->arg(aw).toInt(); + uint16_t whiteK = request->hasArg(wk) ? (uint16_t)request->arg(wk).toInt() : 0; + // Reject out-of-range or sub-1900K Kelvin values; 0 means "neutral/legacy" + if (whiteK != 0 && (whiteK < 1900 || whiteK > 10000)) whiteK = 0; uint16_t freq = request->arg(sp).toInt(); if (Bus::isPWM(type)) { switch (freq) { @@ -265,7 +269,7 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage) text = request->arg(hs).substring(0,31); // actual finalization is done in WLED::loop() (removing old busses and adding new) // this may happen even before this loop is finished so we do "doInitBusses" after the loop - busConfigs.emplace_back(type, pins, start, length, colorOrder | (channelSwap<<4), request->hasArg(cv), skip, awmode, freq, maPerLed, maMax, driverType, text); + busConfigs.emplace_back(type, pins, start, length, colorOrder | (channelSwap<<4), request->hasArg(cv), skip, awmode, freq, maPerLed, maMax, driverType, text, whiteK); busesChanged = true; } //doInitBusses = busesChanged; // we will do that below to ensure all input data is processed diff --git a/wled00/xml.cpp b/wled00/xml.cpp index 812ef8c207..76f79f5444 100644 --- a/wled00/xml.cpp +++ b/wled00/xml.cpp @@ -371,6 +371,8 @@ void getSettingsJS(byte subPage, Print& settingsScript) char sl[4] = "SL"; sl[2] = offset+s; sl[3] = 0; //skip 1st LED char rf[4] = "RF"; rf[2] = offset+s; rf[3] = 0; //off refresh char aw[4] = "AW"; aw[2] = offset+s; aw[3] = 0; //auto white mode + char wke[5] = "WKE"; wke[3] = offset+s; wke[4] = 0; //W-channel CCT enabled (UI checkbox) + char wk[4] = "WK"; wk[2] = offset+s; wk[3] = 0; //W-channel CCT (Kelvin) char wo[4] = "WO"; wo[2] = offset+s; wo[3] = 0; //swap channels char sp[4] = "SP"; sp[2] = offset+s; sp[3] = 0; //bus clock speed char la[4] = "LA"; la[2] = offset+s; la[3] = 0; //LED current @@ -392,6 +394,8 @@ void getSettingsJS(byte subPage, Print& settingsScript) printSetFormValue(settingsScript,sl,bus->skippedLeds()); printSetFormCheckbox(settingsScript,rf,bus->isOffRefreshRequired()); printSetFormValue(settingsScript,aw,bus->getAutoWhiteMode()); + printSetFormCheckbox(settingsScript,wke,bus->getWhiteKelvin() > 0); + printSetFormValue(settingsScript,wk,bus->getWhiteKelvin() > 0 ? bus->getWhiteKelvin() : 6500); printSetFormValue(settingsScript,wo,bus->getColorOrder() >> 4); unsigned speed = bus->getFrequency(); if (bus->isPWM()) { From 01fe430fca69109ea249ecaf68320b4156df8a35 Mon Sep 17 00:00:00 2001 From: Christian Kunis Date: Mon, 25 May 2026 15:42:21 -0400 Subject: [PATCH 2/8] Show W-LED CCT controls in DUAL auto-white mode too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DUAL mode (RGBW_MODE_DUAL) falls through to the per-channel-cap branch of autoWhiteCalc whenever the caller hasn't set the manual white value (w == 0) — the same path used by BRIGHTER and ACCURATE. The UI gate was only revealing the WKE checkbox and Kelvin input for modes 1 and 2, so users on DUAL had no way to configure the W-LED color temperature even though their output was affected by _wR/_wG/_wB. Include awv === 3 in the visibility condition and update the comment to reflect the actual code path in bus_manager.cpp. Co-authored-by: Claude --- wled00/data/settings_leds.htm | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index 03a5adffd0..36e66d2e51 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -385,16 +385,17 @@ gId("dig"+n+"a").style.display = (hasW(t)) ? "inline":"none"; // auto calculate white // AI: below section was generated by an AI // W-channel CCT controls are only meaningful when autoWhiteCalc - // actually subtracts the W contribution — i.e. AW mode is - // Brighter (1) or Accurate (2). Hide the whole block otherwise. - // The number input is disabled (and not submitted) when the - // WKE checkbox is off, so the backend stores wk=0 and the - // legacy autoWhite path is used. + // uses the per-channel-cap path that consumes _wR/_wG/_wB — + // i.e. AW mode is Brighter (1), Accurate (2), or Dual (3, where + // manual w==0 falls through to the Brighter path). Hide + // otherwise. The number input is disabled (and not submitted) + // when the WKE checkbox is off, so the backend stores wk=0 + // and the legacy autoWhite path is used. { const awEl = d.Sf["AW"+n]; const awv = awEl ? parseInt(awEl.value) : 0; const wkBox = gId("dig"+n+"wk"); - if (wkBox) wkBox.style.display = (hasW(t) && (awv === 1 || awv === 2)) ? "inline" : "none"; + if (wkBox) wkBox.style.display = (hasW(t) && (awv === 1 || awv === 2 || awv === 3)) ? "inline" : "none"; const wke = d.Sf["WKE"+n], wk = d.Sf["WK"+n]; if (wke && wk) { wk.disabled = !wke.checked; From 113b3dc78a33d8f698ee2e79dfe109425b6ee19e Mon Sep 17 00:00:00 2001 From: Christian Kunis Date: Mon, 25 May 2026 15:43:52 -0400 Subject: [PATCH 3/8] Add fast path to autoWhiteCalc when per-bus CCT feature is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-channel-cap branch added in the prior commit ran three integer divisions per pixel even when _whiteKelvin == 0 (the default), because the cached _wR/_wG/_wB values were always read as locals — the compiler could not constant-fold them. For RGBW strips this is a measurable hot- path regression vs the original min(r,g,b) implementation, paid by every user regardless of whether they enabled the feature. Split the else-branch in two: - _whiteKelvin == 0 (feature off, default): identical math to the pre-feature WLED code (w = min RGB, equal subtraction in ACCURATE). - _whiteKelvin > 0 (feature on): the per-channel-cap path that uses the cached W-LED RGB equivalent. Documents the underflow argument (floor division composes back through the subtraction) and the _wB == 0 case near 1900 K explicitly in the comments. Behavior is unchanged for both paths. Co-authored-by: Claude --- wled00/bus_manager.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/wled00/bus_manager.cpp b/wled00/bus_manager.cpp index 08ed860aad..41f3199218 100644 --- a/wled00/bus_manager.cpp +++ b/wled00/bus_manager.cpp @@ -128,12 +128,21 @@ uint32_t Bus::autoWhiteCalc(uint32_t c, uint8_t &ww, uint8_t &cw) const { //ignore auto-white calculation if w>0 and mode DUAL (DUAL behaves as BRIGHTER if w==0) } else if (aWM == RGBW_MODE_MAX) { w = r > g ? (r > b ? r : b) : (g > b ? g : b); // brightest RGB channel + } else if (_whiteKelvin == 0) { + // Fast path: per-bus W-LED CCT feature is off. Identical to the + // pre-feature behavior — pick darkest RGB channel as W and (for + // ACCURATE) subtract it equally. Avoids three divisions per pixel + // in the default case, since most strips never enable the feature. + w = r < g ? (r < b ? r : b) : (g < b ? g : b); + if (aWM == RGBW_MODE_AUTO_ACCURATE) { r -= w; g -= w; b -= w; } } else { // AI: below section was generated by an AI - // Per-channel cap: pick the largest w such that (w * _wX)/255 <= channel - // for every X in {R,G,B}, preventing underflow when subtracting the W - // LED's RGB contribution. When _whiteKelvin==0 the cached _wR/_wG/_wB - // are all 255, so this collapses to the legacy w = min(r,g,b). + // Per-channel cap path (feature on): pick the largest w such that + // (w * _wX)/255 <= channel for every X in {R,G,B}, preventing + // underflow when subtracting the W LED's RGB contribution. Floor + // division composes back through the subtract — i.e. + // floor((r*255)/_wR) * _wR <= r*255 — so the subtraction is safe. + // _wB can be 0 near 1900 K, hence the zero guards. unsigned wMaxR = _wR ? (r * 255U) / _wR : 255U; unsigned wMaxG = _wG ? (g * 255U) / _wG : 255U; unsigned wMaxB = _wB ? (b * 255U) / _wB : 255U; From cd00e562d5eaff00a7262da80c1388627745e8ea Mon Sep 17 00:00:00 2001 From: Christian Kunis Date: Thu, 28 May 2026 18:54:51 -0400 Subject: [PATCH 4/8] Refine W-channel CCT UI label and split onto separate lines Rename the enable checkbox label to "Tune RGB to W channel color temperature" so it mirrors the adjacent "Auto-calculate W channel from RGB" selector and reads as a refinement of it. Move the Kelvin number input out of the checkbox line into its own wrapper div (digwkv) with a dedicated "W channel color temperature:" label, matching the dominant WLED pattern of a checkbox revealing a sub-options block on the following line. UI() now toggles the wrapper's visibility instead of the bare input. Co-authored-by: Claude --- wled00/data/settings_leds.htm | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index 36e66d2e51..819a1966df 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -206,11 +206,13 @@ if (ppl) d.Sf.MA.value = sumMA; // populate UI ABL value if PPL used } // AI: below section was generated by an AI - // Per-bus W-LED color temperature toggle. The number input is disabled - // (and hidden) when the checkbox is off, so it isn't submitted with - // the form — backend then sees no WK arg and stores wk=0 (legacy - // fast path). Seed the field to 6500 K when re-enabling from a blank - // or sub-min value so the UI default matches the sRGB white point. + // Per-bus W-LED color temperature toggle. The Kelvin input lives in a + // wrapper div (digwkv) that UI() shows/hides based on the checkbox; + // the input itself is also disabled when off, so it isn't submitted + // with the form — backend then sees no WK arg and stores wk=0 + // (legacy fast path). Seed the field to 6500 K when re-enabling from + // a blank or sub-min value so the UI default matches the sRGB white + // point. function wkChk(n) { const wke = d.Sf["WKE"+n], wk = d.Sf["WK"+n]; @@ -387,19 +389,20 @@ // W-channel CCT controls are only meaningful when autoWhiteCalc // uses the per-channel-cap path that consumes _wR/_wG/_wB — // i.e. AW mode is Brighter (1), Accurate (2), or Dual (3, where - // manual w==0 falls through to the Brighter path). Hide - // otherwise. The number input is disabled (and not submitted) - // when the WKE checkbox is off, so the backend stores wk=0 - // and the legacy autoWhite path is used. + // manual w==0 falls through to the Brighter path). Hide the + // whole toggle otherwise. The Kelvin input lives in a child + // block that's shown only when the checkbox is on; the input + // is disabled (and so not submitted) when off, so the backend + // stores wk=0 and the legacy autoWhite path is used. { const awEl = d.Sf["AW"+n]; const awv = awEl ? parseInt(awEl.value) : 0; const wkBox = gId("dig"+n+"wk"); if (wkBox) wkBox.style.display = (hasW(t) && (awv === 1 || awv === 2 || awv === 3)) ? "inline" : "none"; - const wke = d.Sf["WKE"+n], wk = d.Sf["WK"+n]; + const wke = d.Sf["WKE"+n], wk = d.Sf["WK"+n], wkv = gId("dig"+n+"wkv"); if (wke && wk) { wk.disabled = !wke.checked; - wk.style.display = wke.checked ? "inline" : "none"; + if (wkv) wkv.style.display = wke.checked ? "inline" : "none"; } } // AI: end @@ -627,7 +630,7 @@

Reversed:

Skip first LEDs:

Off Refresh:
-

Auto-calculate W channel from RGB:
 
+

Auto-calculate W channel from RGB:
`; f.insertAdjacentHTML("beforeend", cn); // fill led types (credit @netmindz) From cd6bf0e5155b686d40b9a961521085165be67802 Mon Sep 17 00:00:00 2001 From: Christian Kunis Date: Thu, 28 May 2026 18:55:13 -0400 Subject: [PATCH 5/8] Lower W-channel CCT minimum from 1900 K to 1000 K Very warm white LEDs can read as neutral even when fed a fairly warm RGB mask, so users need to dial the configured temperature below the previous 1900 K floor to compensate. colorKtoRGB() is well-defined down to 1000 K (blue already clamps to 0 below 1900 K, green stays positive -> 255,68,0), and the per-channel-cap path's existing zero guards already handle the resulting zero channels. Lower the bound in the form validator (set.cpp), the number input's min attribute and the re-enable seed threshold (settings_leds.htm), and update the zero-guard comment in bus_manager.cpp to note that blue is zero across the whole sub-1900 K range now, not just near 1900 K. Co-authored-by: Claude --- wled00/bus_manager.cpp | 3 ++- wled00/data/settings_leds.htm | 4 ++-- wled00/set.cpp | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/wled00/bus_manager.cpp b/wled00/bus_manager.cpp index 41f3199218..4607ef8fef 100644 --- a/wled00/bus_manager.cpp +++ b/wled00/bus_manager.cpp @@ -142,7 +142,8 @@ uint32_t Bus::autoWhiteCalc(uint32_t c, uint8_t &ww, uint8_t &cw) const { // underflow when subtracting the W LED's RGB contribution. Floor // division composes back through the subtract — i.e. // floor((r*255)/_wR) * _wR <= r*255 — so the subtraction is safe. - // _wB can be 0 near 1900 K, hence the zero guards. + // _wB is 0 at/below 1900 K (and _wG could reach 0 at extreme lows), + // hence the per-channel zero guards. unsigned wMaxR = _wR ? (r * 255U) / _wR : 255U; unsigned wMaxG = _wG ? (g * 255U) / _wG : 255U; unsigned wMaxB = _wB ? (b * 255U) / _wB : 255U; diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index 819a1966df..3fb2d93ad7 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -217,7 +217,7 @@ { const wke = d.Sf["WKE"+n], wk = d.Sf["WK"+n]; if (!wke || !wk) return; - if (wke.checked && parseInt(wk.value)<1900) wk.value = 6500; + if (wke.checked && parseInt(wk.value)<1000) wk.value = 6500; UI(); } // AI: end @@ -630,7 +630,7 @@

Reversed:

Skip first LEDs:

Off Refresh:
-

Auto-calculate W channel from RGB:
+

Auto-calculate W channel from RGB:
`; f.insertAdjacentHTML("beforeend", cn); // fill led types (credit @netmindz) diff --git a/wled00/set.cpp b/wled00/set.cpp index 81273ab5a4..1c0f53ee04 100644 --- a/wled00/set.cpp +++ b/wled00/set.cpp @@ -232,8 +232,8 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage) } awmode = request->arg(aw).toInt(); uint16_t whiteK = request->hasArg(wk) ? (uint16_t)request->arg(wk).toInt() : 0; - // Reject out-of-range or sub-1900K Kelvin values; 0 means "neutral/legacy" - if (whiteK != 0 && (whiteK < 1900 || whiteK > 10000)) whiteK = 0; + // Reject out-of-range or sub-1000K Kelvin values; 0 means "neutral/legacy" + if (whiteK != 0 && (whiteK < 1000 || whiteK > 10000)) whiteK = 0; uint16_t freq = request->arg(sp).toInt(); if (Bus::isPWM(type)) { switch (freq) { From 5f61870d0779c5d0466e0c1e13cece6657601dac Mon Sep 17 00:00:00 2001 From: Christian Kunis Date: Fri, 29 May 2026 12:29:04 -0400 Subject: [PATCH 6/8] Seed W-channel CCT field when re-enabling from a blank value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wkChk() promised to reseed the Kelvin field to 6500 K "from a blank or sub-min value", but parseInt("") is NaN and NaN < 1000 is false, so a cleared field was never reseeded — contradicting the comment. Invert the test to !(parsed >= 1000) so NaN (blank/non-numeric) also seeds, and add an explicit radix per the review. Spotted by CodeRabbit on #5654. Co-authored-by: Claude --- wled00/data/settings_leds.htm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index 3fb2d93ad7..11609b1d43 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -217,7 +217,7 @@ { const wke = d.Sf["WKE"+n], wk = d.Sf["WK"+n]; if (!wke || !wk) return; - if (wke.checked && parseInt(wk.value)<1000) wk.value = 6500; + if (wke.checked && !(parseInt(wk.value, 10) >= 1000)) wk.value = 6500; UI(); } // AI: end From 5312bd0d48c71adaa44ce2bffc6dbca022bb6254 Mon Sep 17 00:00:00 2001 From: Christian Kunis Date: Fri, 29 May 2026 12:42:40 -0400 Subject: [PATCH 7/8] Relabel W-channel CCT checkbox to reflect what it actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Tune RGB to W channel color temperature" implied the setting adjusts RGB, but it primarily changes how the W value is calculated (in Brighter/Accurate/Dual) and only modifies RGB in Accurate mode — in Brighter/Dual the RGB channels are left untouched. Rename to "Correct auto-white for W channel color temperature", which is accurate across all modes and matches the sibling "Auto-calculate W channel from RGB". Co-authored-by: Claude --- wled00/data/settings_leds.htm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index 11609b1d43..58232c5e06 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -630,7 +630,7 @@

Reversed:

Skip first LEDs:

Off Refresh:
-

Auto-calculate W channel from RGB:
+

Auto-calculate W channel from RGB:
`; f.insertAdjacentHTML("beforeend", cn); // fill led types (credit @netmindz) From adefb08df83c845c668dd2bbe423866646a939aa Mon Sep 17 00:00:00 2001 From: Christian Kunis Date: Fri, 29 May 2026 13:36:35 -0400 Subject: [PATCH 8/8] Wrap loadCfg W-channel CCT block in standard AI markers The block that derives the WKE checkbox / WK seed from stored wk used a single inline "// AI:" comment instead of the start/end markers the other AI-generated sections use. Wrap it with the standard "// AI: below section was generated by an AI" / "// AI: end" pair for consistency. Comment-only; no behavior change. Spotted by CodeRabbit on #5654. Co-authored-by: Claude --- wled00/data/settings_leds.htm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index 58232c5e06..860c615396 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -821,13 +821,16 @@ d.getElementsByName("RF"+i)[0].checked = v.ref; d.getElementsByName("CV"+i)[0].checked = v.rev; d.getElementsByName("AW"+i)[0].value = v.rgbwm; - { // AI: derive WKE checkbox + WK seed from stored wk (0 = feature off) + // AI: below section was generated by an AI + // derive WKE checkbox + WK seed from stored wk (0 = feature off) + { const wkChkEl = d.getElementsByName("WKE"+i)[0]; const wkEl = d.getElementsByName("WK"+i)[0]; const wkv = parseInt(v.wk) | 0; if (wkChkEl) wkChkEl.checked = wkv > 0; if (wkEl) wkEl.value = wkv > 0 ? wkv : 6500; } + // AI: end d.getElementsByName("WO"+i)[0].value = (v.order>>4) & 0x0F; d.getElementsByName("SP"+i)[0].value = v.freq; d.getElementsByName("LA"+i)[0].value = v.ledma;