diff --git a/wled00/bus_manager.cpp b/wled00/bus_manager.cpp index 01cfa93a4d..ebaec7613d 100644 --- a/wled00/bus_manager.cpp +++ b/wled00/bus_manager.cpp @@ -98,6 +98,43 @@ void Bus::calculateCCT(uint32_t c, uint8_t &ww, uint8_t &cw) { cw = (w * cw) / 255; } +// AI: below section was generated by an AI (Claude, Anthropic), reviewed by the contributor. +// Sources: Kelvin->RGB comes from colorKtoRGB() in colors.cpp (Tanner Helland's +// approximation, https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html); +// the Q15 reciprocal and (x+1)>>8 scaling follow the brightness scaling already used in this +// file. No external code was copied. +// 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) { + // The correction only makes sense for RGB+W buses with a single fixed white: + // dual-white CCT buses have a variable white point set via the CCT control, + // and buses without RGB or without W have nothing to correct. Force the + // feature off here (once, at configuration) so autoWhiteCalc's per-pixel + // path only has to test _whiteKelvin == 0. + if (_hasCCT || !_hasRgb || !_hasWhite) k = 0; + _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]; + } + // Precompute Q15 reciprocals of (_wX+1) so autoWhiteCalc's hot path has no + // per-pixel division and no branches. Scaling by (_wX+1)>>8 instead of + // _wX/255 follows the (x+1)*bri>>8 convention used elsewhere in this file, + // and the +1 keeps the divisor non-zero (so _wB==0 at/below 1900 K needs no + // guard). Range is 32768/256=128 .. 32768/1=32768, which fits uint16_t. + // floor() under-estimates the reciprocal, so the w cap derived from it can + // only be <= the exact value, never larger, keeping the subtraction + // underflow-safe (verified exhaustively over all channel/_wX pairs). + _rwR = WK_Q15_ONE / (_wR + 1U); + _rwG = WK_Q15_ONE / (_wG + 1U); + _rwB = WK_Q15_ONE / (_wB + 1U); +} +// 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; @@ -112,9 +149,42 @@ 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 || aWM != RGBW_MODE_AUTO_ACCURATE) { + // Fast path: per-bus W channel color temperature feature is off + // (setWhiteKelvin also forces it off for bus types that can't use it), + // or mode is BRIGHTER / DUAL-with-w==0, which never subtract W from RGB + // so the W-LED colour has nothing to correct. Identical to the + // pre-feature behavior: pick darkest RGB channel as W and (for ACCURATE) + // subtract it equally. Most strips never enable the feature, so this is + // the common default. + w = r < g ? (r < b ? r : b) : (g < b ? g : b); + if (aWM == RGBW_MODE_AUTO_ACCURATE) { r -= w; g -= w; b -= w; } } 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 (Claude, Anthropic), reviewed by the contributor. + // Sources: none external; the cap/subtract math was derived for this change and verified + // exhaustively on the host (all 2^24 colours x 1000..10000 K), see PR #5654. + // Per-channel cap path (feature on): pick the largest w whose W-LED + // contribution (w*(_wX+1))>>8 does not exceed the channel for every X + // in {R,G,B}, so subtracting that contribution can't underflow. The + // largest such w is ((x+1)*256-1)/(_wX+1); the division is done via the + // Q15 reciprocal of (_wX+1) precomputed in setWhiteKelvin (multiply + + // shift only, no per-pixel divide, no branches). The reciprocal is + // floor-biased so wMax never over-estimates. Max product + // (256*256-1)*32768 fits in 32 bits. A zero coefficient (_wB at/below + // 1900 K) gives reciprocal 32768 and a cap >= 255, i.e. that channel + // does not constrain w — same as the previous explicit guard. + unsigned wMaxR = (((r + 1U) << 8) - 1U) * _rwR >> WK_Q15_SHIFT; + unsigned wMaxG = (((g + 1U) << 8) - 1U) * _rwG >> WK_Q15_SHIFT; + unsigned wMaxB = (((b + 1U) << 8) - 1U) * _rwB >> WK_Q15_SHIFT; + 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 + 1U)) >> 8; // subtract W LED's R contribution + g -= (w * (_wG + 1U)) >> 8; // subtract W LED's G contribution + b -= (w * (_wB + 1U)) >> 8; // subtract W LED's B contribution + } + // AI: end } c = RGBW32(r, g, b, w); } @@ -1320,6 +1390,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 eeb10cff24..4111cb7315 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))) @@ -118,9 +119,19 @@ class Bus { , _NPBbri(255) , _start(start) , _len(std::max(len,(uint16_t)1)) + , _whiteKelvin(0) + , _rwR(WK_RECIP_NEUTRAL) // matches _wR=255 (kept consistent though unused while _whiteKelvin==0) + , _rwG(WK_RECIP_NEUTRAL) + , _rwB(WK_RECIP_NEUTRAL) + , _wR(255) + , _wG(255) + , _wB(255) , _reversed(reversed) , _valid(false) , _needsRefresh(refresh) + , _hasRgb(false) // derived constructors set the real capabilities; + , _hasWhite(false) // defaults keep setWhiteKelvin() well-defined for buses + , _hasCCT(false) // that don't set all three (BusPlaceholder, BusHub75Matrix) { _autoWhiteMode = Bus::hasWhite(type) ? aw : RGBW_MODE_MANUAL_ONLY; }; @@ -162,6 +173,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; } @@ -220,6 +233,20 @@ class Bus { uint8_t _autoWhiteMode; // global Auto White Calculation override uint16_t _start; uint16_t _len; + // Q15 fixed point used by the W Kelvin correction in autoWhiteCalc(): _rwX = WK_Q15_ONE / (_wX + 1), + // consumed as (value * _rwX) >> WK_Q15_SHIFT. WK_RECIP_NEUTRAL is the reciprocal for a neutral + // (255) coefficient, i.e. the W LED treated as pure white: 32768 / 256 = 128. + static constexpr unsigned WK_Q15_SHIFT = 15; + static constexpr uint16_t WK_Q15_ONE = 1u << WK_Q15_SHIFT; + static constexpr uint16_t WK_RECIP_NEUTRAL = WK_Q15_ONE / (255 + 1); + // 16-bit members grouped with _start/_len, 8-bit ones with the bools below, to avoid padding + uint16_t _whiteKelvin; // physical W channel color temperature in Kelvin (0 = neutral/legacy behavior) + uint16_t _rwR; // Q15 reciprocal of (_wR+1), i.e. WK_Q15_ONE/(_wR+1) in [128,32768], for autoWhiteCalc hot path + uint16_t _rwG; + uint16_t _rwB; + 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 +488,7 @@ struct BusConfig { uint8_t skipAmount; bool refreshReq; uint8_t autoWhite; + uint16_t whiteKelvin; // physical W channel color temperature 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 +497,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 e50ac3f90b..dcfca19905 100644 --- a/wled00/cfg.cpp +++ b/wled00/cfg.cpp @@ -246,6 +246,8 @@ 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; + int wkRaw = elm[F("wk")] | 0; // physical W channel color temperature in K + uint16_t whiteK = (wkRaw >= 1000 && wkRaw <= 10000) ? (uint16_t)wkRaw : 0; // 0 or out of range = off (same rule as set.cpp) uint8_t maPerLed = elm[F("ledma")] | LED_MILLIAMPS_DEFAULT; uint16_t maMax = elm[F("maxpwr")] | (total > 0 ? (ablMilliampsMax * length) / total : ablMilliampsMax); // 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) @@ -257,7 +259,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 } @@ -1011,6 +1013,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 446282777e..b690c160cf 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -51,6 +51,7 @@ d.max_gpio = 50; }, ()=>{ checkSi(); + wkInit(); setABL(); d.Sf.addEventListener("submit", trySubmit); if (d.um_p[0]==-1) d.um_p.shift(); @@ -204,6 +205,31 @@ }); 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 checkbox is UI-only (no + // name, so it is never submitted): the backend knows only WK, where + // 0 means off. 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 enabling from 0/blank/sub-min so the UI default matches + // the sRGB white point. + function wkChk(n) + { + const wke = gId("wke"+n), wk = d.Sf["WK"+n]; + if (!wke || !wk) return; + if (wke.checked && !(parseInt(wk.value, 10) >= 1000)) wk.value = 6500; + UI(); + } + // derive each bus's checkbox from the WK value loaded from the device (once, at load) + function wkInit() + { + d.Sf.querySelectorAll("input[name^=WK]").forEach((wk) => { + const wke = gId("wke"+wk.name.substring(2)); + if (wke) wke.checked = parseInt(wk.value, 10) > 0; + }); + } + // AI: end // enable and update LED Amps function enLA(s,n) { @@ -368,6 +394,42 @@ 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 + // The "Correct auto-white for W channel color temperature" control is + // only meaningful for true single-white RGBW buses (hasW && hasRGB && + // !hasCCT) AND when autoWhiteCalc uses the per-channel-cap path that + // consumes _wR/_wG/_wB — i.e. AW mode is Accurate (2). + // Brighter (1) and Dual (3, which is Brighter when manual w==0) never + // subtract W from RGB, so they stay on plain min(r,g,b) and the + // control is not offered for them. 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; + // The per-channel-cap path runs only in Accurate mode: Brighter, and Dual + // with w==0, never subtract W from RGB, so there is nothing to correct. + // The mode can be set per-bus (AW) or via the global override + // (AW; 255=Disabled). The per-bus AW selector stays visible even under a + // global override, so keep this control consistent with it: show when EITHER + // the per-bus mode OR the global override is Accurate. + const gAWel = d.Sf["AW"]; + const gAW = gAWel ? parseInt(gAWel.value) : 255; + const isCap = (m) => (m === 2); + const wkBox = gId("dig"+n+"wk"); + // only true single-white RGBW types: a fixed W-LED color temperature is + // meaningless for dual-white CCT buses (variable white point) and for + // non-RGB buses (nothing to derive the correction from) + if (wkBox) wkBox.style.display = (hasW(t) && hasRGB(t) && !hasCCT(t) && (isCap(awv) || isCap(gAW))) ? "inline" : "none"; + const wke = gId("wke"+n), wk = d.Sf["WK"+n], wkv = gId("dig"+n+"wkv"); + if (wke && wk) { + wk.disabled = !wke.checked; + if (wkv) wkv.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 @@ -588,7 +650,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) @@ -779,6 +841,17 @@ 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: below section was generated by an AI + // stored wk: 0 = feature off; normalise like the backend (1000..10000 else 0) + { + const wkChkEl = gId("wke"+i); + const wkEl = d.getElementsByName("WK"+i)[0]; + const raw = Number(v.wk); // no |0: that wraps at 2^32 before the range check + const wkv = (Number.isInteger(raw) && raw >= 1000 && raw <= 10000) ? raw : 0; + if (wkChkEl) wkChkEl.checked = wkv > 0; + if (wkEl) wkEl.value = wkv; + } + // 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; @@ -1068,7 +1141,7 @@

Color & White

White Balance correction:
Global override for Auto-calculate white: - diff --git a/wled00/set.cpp b/wled00/set.cpp index f68520a6d0..572b738f8e 100644 --- a/wled00/set.cpp +++ b/wled00/set.cpp @@ -226,6 +226,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 color temperature (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 @@ -251,6 +252,10 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage) break; // no parameter } awmode = request->arg(aw).toInt(); + // W channel color temperature: 0 or out of range (1000..10000 K) means off. + // Validate before narrowing so oversized values can't wrap into range. + long wkRaw = request->hasArg(wk) ? request->arg(wk).toInt() : 0; + uint16_t whiteK = (wkRaw >= 1000 && wkRaw <= 10000) ? (uint16_t)wkRaw : 0; uint16_t freq = request->arg(sp).toInt(); if (Bus::isPWM(type)) { switch (freq) { @@ -286,7 +291,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 e778d96a61..59bb5378e7 100644 --- a/wled00/xml.cpp +++ b/wled00/xml.cpp @@ -376,6 +376,7 @@ 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 wk[4] = "WK"; wk[2] = offset+s; wk[3] = 0; //W channel color temperature (Kelvin, 0 = off) 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 @@ -397,6 +398,7 @@ void getSettingsJS(byte subPage, Print& settingsScript) printSetFormValue(settingsScript,sl,bus->skippedLeds()); printSetFormCheckbox(settingsScript,rf,bus->isOffRefreshRequired()); printSetFormValue(settingsScript,aw,bus->getAutoWhiteMode()); + printSetFormValue(settingsScript,wk,bus->getWhiteKelvin()); printSetFormValue(settingsScript,wo,bus->getColorOrder() >> 4); unsigned speed = bus->getFrequency(); if (bus->isPWM()) {