Repository: FastLED/FastLED (2026-05-30T04:12:17Z)

Issue:
feat(rgbww): 5-channel RGB+WW (warm/cool white) support via Channels API (Phase 3 of #2545)
## Context

PR #2552 / #2554 landed the colorimetric RGB→RGBW path (4-channel). They also shipped a math-only helper `fl::colorimetric_detail::solve_rgbcct()` that produces 5 channels for RGB + warm-W + cool-W strips — but **nothing in the FastLED driver pipeline currently consumes that fifth channel**. Issue #2545 explicitly deferred Phase 3 ("layered simplex … track in a follow-up issue once the 4-channel path lands"). This is that follow-up.

The math is in-tree and validated. What's missing is the driver-layer plumbing: pixel struct, byte-count constant, encoder vtable slot, adapter, dispatcher, config wiring, and at least one chipset wrapper + example.

## Scope: Channels API only (deliberate)

Legacy `FastLED.addLeds<>()` controllers (`CPixelLEDController<RGB>`) **will not** gain RGBWW support. Each compile-time chipset instantiation pays a flash cost for the encoder vtable; adding a fifth slot for every controller doubles the encoder surface for a feature only a small percentage of users need. The **Channels API already** uses the right idioms for runtime-configurable per-channel pixel formats — see `fl::variant<ClocklessChipset, SpiChipsetConfig>` at `src/fl/channels/config.h:55` and `fl::variant<ConfigI2S, ConfigPdm>` in `src/fl/audio/input.h` — and `--gc-sections` keeps unused encoder TUs out of binaries that don't reference them.

**Concrete rule:** every change in this issue is gated behind the new compile flag `FASTLED_RGBWW` and applies to `src/fl/channels/...` and the encoder layer only. The legacy `CPixelLEDController<RGB>` subclasses are out of scope.

## Config-storage upgrade: `fl::variant<fl::Empty, Rgbw, Rgbww>`

Today, `ChannelOptions.mRgbw` at `src/fl/channels/options.h:34` is a plain `Rgbw`. The "off" state is encoded by the sentinel `Rgbw::rgbw_mode == RGBW_MODE::kRGBWInvalid`. That conflates two questions — "do we extract a white channel at all?" and "if yes, how?" — into one enum value, and leaves no room for a parallel 5-channel `Rgbww` configuration.

Replace it with:

```cpp
// src/fl/channels/options.h
struct ChannelOptions {
    // ...
    fl::variant<fl::Empty, Rgbw, Rgbww> mWhiteCfg;  // was: Rgbw mRgbw;
    // ...
};
```

Three semantics, cleanly separated:
- `holds_alternative<fl::Empty>()` — plain 3-channel RGB output. Replaces the `kRGBWInvalid` sentinel.
- `holds_alternative<Rgbw>()`     — 4-channel RGBW. Existing modes and encoder path.
- `holds_alternative<Rgbww>()`    — 5-channel RGB + warm-W + cool-W. New path.

`fl::variant<>` is already in-tree at `src/fl/stl/variant.h` (O(1) visitor dispatch); `fl::Empty` is used internally by `fl::Optional` for the "empty" alternative.

**Migration:** `setRgbw(...)` keeps working — it just becomes shorthand for `mWhiteCfg = rgbw_arg`. Existing callers don't change. A new `setRgbww(const Rgbww&)` setter is added. `getRgbw()` returns the `Rgbw` alternative if present, else `RgbwInvalid::value()` for compatibility.

## `Rgbww` struct sketch

Mirrors `Rgbw` but carries two CCTs and a profile:

```cpp
// src/fl/gfx/rgbww.h (new file)
struct Rgbww {
    explicit Rgbww(u16 warm = 2700, u16 cool = 6500,
                   RGBWW_MODE mode = RGBWW_MODE::kRGBWWColorimetric,
                   EOrderWW placement = EOrderWW::WWDefault) FL_NOEXCEPT;

    u16 warm_cct;                                // typical 2700
    u16 cool_cct;                                // typical 6500
    RGBWW_MODE rgbww_mode;
    EOrderWW w_placement;                        // 5! = 120 possible orderings; we ship the
                                                 // typical few and let users name their own
    const colorimetric_detail::RgbcctProfile* profile;  // nullptr -> kRgbwwDefaultProfile
};
```

`EOrderWW` is a parallel enum to `EOrderW` describing where the two W bytes sit inside the 5-byte output stream. Common chipsets use `RGBWwWc` or `GRBWwWc`; the enum covers the documented sub-set.

`RGBWW_MODE` mirrors `RGBW_MODE`:
- `kRGBWWInvalid` — kept for sentinel compatibility; the variant `Empty` alternative is the canonical "off"
- `kRGBWWColorimetric` — uses `solve_rgbcct` with strict sub-gamut math
- `kRGBWWColorimetricBoosted` — uses `solve_rgbcct` with wx_lp-style W maximization
- `kRGBWWUserFunction` — escape hatch (parallel to `kRGBWUserFunction`)
- (No `min(RGB)` / `ExactColors` mode — those don't generalize meaningfully to two whites; colorimetric is the only sensible path)

## Phased breakdown

### Phase A — Types only
- Add `Rgbww` struct, `RGBWW_MODE` enum, `EOrderWW` enum.
- Add `BYTES_PER_PIXEL_RGBWW = 5` to `src/fl/chipsets/encoders/encoder_constants.h`.
- Add `RGBWWPixel { u8 r, g, b, ww, wc; }` to a new `src/fl/gfx/rgbww_pixel.h` (mirroring `rgbw_pixel.h`).
- Migrate `ChannelOptions::mRgbw` → `ChannelOptions::mWhiteCfg`. Update `setRgbw` / `getRgbw` to wrap/unwrap the variant. Add `setRgbww` / `getRgbww`.
- Add the `FASTLED_RGBWW` compile gate; default OFF.
- **No runtime behavior change.** Library still compiles byte-for-byte equivalent without `FASTLED_RGBWW`.

### Phase B — Dispatch shells
- Add `rgb_2_rgbww_function` typedef (5 output pointers).
- Add `void rgb_2_rgbww(RGBWW_MODE, ...)` dispatcher to `src/fl/gfx/rgbww.h`.
- Stub each mode: emit `FL_WARN_ONCE` and output 5 zero bytes. (Real bodies arrive in Phase D.)
- Mirror the `FASTLED_SUPPRESS_COLORIMETRIC_FALLBACK_WARNING` pattern with `FASTLED_SUPPRESS_RGBWW_FALLBACK_WARNING`.
- Without `FASTLED_RGBWW`: dispatch falls through to all-zero output (RGBWW is a strict opt-in; nothing to fall back to).

### Phase C — Encoder pipeline
- Add `PixelControllerVtable::loadAndScaleRGBWW` (5 output `u8*` parameters) in `src/fl/chipsets/encoders/pixel_iterator.h:42–88`.
- Add `loadAndScaleRGBWWFunction` typedef and storage slot.
- Add `ScaledPixelIteratorRGBWW` (yields `array<u8, 5>`) in `src/fl/chipsets/encoders/pixel_iterator_adapters.h:119–187`.
- Add `rgbww_partial_reorder()` to handle the W-pair byte insertion.
- Add `PixelController::loadAndScaleRGBWW()` in `src/pixel_controller.h:589–627`.
- Update `ReorderingPixelIteratorAny` in `src/fl/channels/channel.cpp.hpp` to dispatch on `mWhiteCfg.holds_alternative<...>()`:
  - `Empty` → existing RGB path
  - `Rgbw` → existing RGBW path
  - `Rgbww` → new RGBWW path

### Phase D — Wire the math
- Connect `solve_rgbcct()` + `rgbcct_eta_for_cct()` (`src/fl/gfx/rgbw_colorimetric.h:144,211`) into the colorimetric `RGBWW_MODE` variants.
- Ship `kRgbwwDefaultProfile` — two `DiodeProfile`s for typical SK6812-style 2700K/6500K diodes.
- Add a profile-setter API: `set_rgbww_colorimetric_profile(const RgbcctProfile*)`.
- Honor `Rgbww::warm_cct` and `Rgbww::cool_cct` for the `eta` computation per-pixel.

### Phase E — First chipset wrapper + example
- Pick one SK6812-RGBWW-style 5-channel clockless chipset and add a Channels-API wrapper.
- Build the example inside the existing `examples/AutoResearch/AutoResearch.ino` harness (per CLAUDE.md: "On-device / hardware tests go through `examples/AutoResearch/AutoResearch.ino` — prefer reusing or augmenting its existing test harness rather than creating a new `.ino` in `examples/`"). Add `examples/AutoResearch/AutoResearchRgbww.h` analogous to `AutoResearchSimd.h`.

### Phase F — Power model
- `PowerModelRGBWW` exists at `src/power_mgt.h:88–126` but its `toRGB()` discards W/WW with the note *"Future API enhancement — not yet implemented in power calculations."* This phase makes it real.
- Account for warm-W and cool-W mW in the brightness limiter when `mWhiteCfg.holds_alternative<Rgbww>()`.

## Out of scope

- **Legacy `addLeds<>` / `CPixelLEDController<RGB>` controllers.** Channels API only — see scoping section above.
- **6+ channel support.** True RGBCCT + amber, violet, lime, etc. is a separate generalization; this issue is specifically for the 5-channel RGBWW case the existing `solve_rgbcct` math already solves.
- **Auto-CCT estimation from a single white point.** Users provide warm/cool CCTs explicitly.
- **Color-space transforms beyond native gamut** (Rec709, Rec2020). Native gamut input only; consistent with Phase 1.

## Acceptance criteria

Each phase has its own checkable list; high-level:

**Phase A**
- [ ] `Rgbww` / `RGBWW_MODE` / `EOrderWW` / `RGBWWPixel` defined behind `FASTLED_RGBWW`.
- [ ] `ChannelOptions::mWhiteCfg` variant migration complete.
- [ ] `setRgbw` / `getRgbw` backward-compatible; existing tests pass byte-for-byte.
- [ ] Sketches compile unchanged with `FASTLED_RGBWW` undefined.

**Phase B**
- [ ] `rgb_2_rgbww()` dispatcher present; stubs warn-once and zero-output.
- [ ] Public API `kRGBWWColorimetric` etc. linkable in user code without `FASTLED_RGBWW`.

**Phase C**
- [ ] `PixelControllerVtable` carries `loadAndScaleRGBWW`.
- [ ] `ScaledPixelIteratorRGBWW` emits 5 bytes per pixel in the documented order.
- [ ] `ReorderingPixelIteratorAny` correctly routes by variant alternative.
- [ ] Test: a Channel configured with `Rgbww` produces 5N bytes for N pixels.

**Phase D**
- [ ] `solve_rgbcct` is the per-pixel math for `kRGBWWColorimetric`.
- [ ] `set_rgbww_colorimetric_profile` works; default profile ships.
- [ ] Test: chromaticity preservation across the warm↔cool blend (mirrors the existing `solve_rgbcct` test in `tests/fl/gfx/rgbw_colorimetric.cpp`).

**Phase E**
- [ ] One RGBWW chipset wrapper compiles and runs through Channels.
- [ ] `AutoResearchRgbww.h` validates 5-channel output on real hardware.

**Phase F**
- [ ] `PowerModelRGBWW::toRGB()` accounts for both white channels.
- [ ] Brightness limiter respects RGBWW power budget.

## Related issues / PRs

- #2545 — parent specification (RGBW colorimetric).
- #2552 — Phase 1 + 2 implementation (merged): math, dispatch, CCT shift, LUT, **`solve_rgbcct` math helper** that this issue's encoder layer will consume.
- #2554 — CodeRabbit follow-up fixes (merged).
- `src/fl/gfx/rgbw_colorimetric.h` — already carries `RgbcctProfile`, `solve_rgbcct`, `rgbcct_eta_for_cct`. No changes needed; just call them.

## Critical files to touch (reference map)

| File | Role in this issue |
|------|--------------------|
| `src/fl/channels/options.h:34` | `mRgbw` field → variant migration target |
| `src/fl/channels/config.h:55` | Existing `ChipsetVariant` precedent |
| `src/fl/channels/channel.cpp.hpp` | `ReorderingPixelIteratorAny` variant dispatch |
| `src/fl/chipsets/encoders/pixel_iterator.h:42–88` | Vtable gains `loadAndScaleRGBWW` slot |
| `src/fl/chipsets/encoders/pixel_iterator_adapters.h:119–187` | `ScaledPixelIteratorRGBW` gains a 5-byte peer |
| `src/fl/chipsets/encoders/encoder_constants.h` | `BYTES_PER_PIXEL_RGBW` gains `BYTES_PER_PIXEL_RGBWW = 5` |
| `src/fl/gfx/rgbw.h` | `Rgbw` / `RGBW_MODE` patterns to mirror as `Rgbww` / `RGBWW_MODE` |
| `src/fl/gfx/rgbw_colorimetric.h:144,211` | `RgbcctProfile` + `solve_rgbcct` already shipped; new dispatch consumes them |
| `src/power_mgt.h:88–126` | `PowerModelRGBWW` stub completed in Phase F |
| `examples/AutoResearch/AutoResearch.ino` | Phase E hooks the new test mode in alongside existing AutoResearch harnesses |



Discussion:

Patch:
diff --git a/examples/RGBWW/RGBWW.ino b/examples/RGBWW/RGBWW.ino
new file mode 100644
index 00000000000..a636ac3977c
--- /dev/null
+++ b/examples/RGBWW/RGBWW.ino
@@ -0,0 +1,69 @@
+// FL_AGENT_ALLOW_NEW_EXAMPLE — first example sketch for the 5-channel
+// RGB + warm-W + cool-W path landed in PR #2560 (issue #2558).
+//
+// Add the following to platformio.ini for full colorimetric output:
+//     build_flags =
+//         -DFASTLED_RGBW_COLORIMETRIC=1     ; provides the underlying math
+//
+// Without that flag the channel falls back to 5 zero bytes per pixel + a
+// one-time startup warning. The example still compiles either way; the
+// 5-channel pipeline itself is always available — gc-sections drops it for
+// sketches that don't configure an Rgbww channel.
+
+#include <FastLED.h>
+#include "fl/channels/channel.h"
+#include "fl/channels/config.h"
+#include "fl/gfx/rgbww.h"
+
+#define NUM_LEDS 60
+#define DATA_PIN  3
+
+CRGB leds[NUM_LEDS];
+
+void setup() {
+    Serial.begin(115200);
+    delay(2000);  // Upload safety delay.
+
+    // ----- Channels API setup ------------------------------------------------
+    // ChannelOptions.mWhiteCfg is a fl::variant<fl::Empty, Rgbw, Rgbww>.
+    // Assigning Rgbww selects the 5-channel alternative; everything downstream
+    // (encoder, dispatch, driver) routes through the RGBWW pipeline.
+    fl::ChannelOptions opts;
+    opts.mCorrection = TypicalSMD5050;
+    opts.mWhiteCfg = fl::Rgbww(
+        /* warm_cct */    2700,
+        /* cool_cct */    6500,
+        /* rgbww_mode */  fl::RGBWW_MODE::kRGBWWColorimetric,
+        /* placement */   fl::EOrderWW::WwWcEnd     // RGB, then warm-W, cool-W
+    );
+
+    auto timing = fl::makeTimingConfig<fl::TIMING_WS2812_800KHZ>();
+    FastLED.add(fl::ChannelConfig(
+        DATA_PIN, timing,
+        fl::span<CRGB>(leds, NUM_LEDS),
+        GRB,
+        opts));
+
+    FastLED.setBrightness(128);
+}
+
+void loop() {
+    // Animate a slow warm <-> cool transition. The colorimetric solver
+    // chooses the warm/cool blend per pixel based on the input chromaticity,
+    // so writing CRGB stays the same as a normal sketch — only the wire
+    // format and color science change.
+    uint32_t ms = millis();
+    uint8_t blend = sin8(ms / 16);  // 0..255 triangle-ish
+
+    // Lerp between a warm white tint (255, 200, 130) and a cool one (180, 200, 255).
+    CRGB warm(255, 200, 130);
+    CRGB cool(180, 200, 255);
+    CRGB mixed;
+    mixed.r = lerp8by8(warm.r, cool.r, blend);
+    mixed.g = lerp8by8(warm.g, cool.g, blend);
+    mixed.b = lerp8by8(warm.b, cool.b, blend);
+
+    fill_solid(leds, NUM_LEDS, mixed);
+    FastLED.show();
+    delay(20);
+}
diff --git a/src/fl/channels/channel.cpp.hpp b/src/fl/channels/channel.cpp.hpp
index 4bc77efb722..985291b571c 100644
--- a/src/fl/channels/channel.cpp.hpp
+++ b/src/fl/channels/channel.cpp.hpp
@@ -29,6 +29,21 @@ namespace fl {
 
 namespace {
 
+/// @brief Apply the white-channel selection from ChannelOptions to a
+/// CLEDController. The variant alternative chooses RGB-only / RGBW / RGBWW
+/// without forcing every call site to duplicate the dispatch.
+inline void applyWhiteCfg(CLEDController& ctrl,
+                          const ChannelOptions& options) FL_NOEXCEPT {
+    if (auto* p = options.mWhiteCfg.ptr<Rgbww>()) {
+        ctrl.setRgbww(*p);
+    } else if (auto* p = options.mWhiteCfg.ptr<Rgbw>()) {
+        ctrl.setRgbw(*p);
+    } else {
+        // Empty alternative (or default-constructed) → plain RGB.
+        ctrl.clearWhiteChannel();
+    }
+}
+
 /// @brief Encapsulates pixel iterator construction with optional XYMap reordering
 class ReorderingPixelIteratorAny {
   private:
@@ -52,8 +67,9 @@ class ReorderingPixelIteratorAny {
         const XYMap* addressing,
         EOrder rgbOrder,
         Rgbw rgbw,
+        Rgbww rgbww,
         const fl::string& channelName)
-        : mPixelIterator(pixels, rgbOrder, rgbw) {
+        : mPixelIterator(pixels, rgbOrder, rgbw, rgbww) {
 
         // Apply addressing transformation if configured
         if (addressing) {
@@ -89,7 +105,7 @@ class ReorderingPixelIteratorAny {
             mAddressedController.emplace(
                 buffer.data(), buffer.size(),
                 pixels.mColorAdjustment, DISABLE_DITHER);
-            mPixelIterator = PixelIteratorAny(mAddressedController.value(), rgbOrder, rgbw);
+            mPixelIterator = PixelIteratorAny(mAddressedController.value(), rgbOrder, rgbw, rgbww);
         }
     }
 
@@ -180,7 +196,7 @@ Channel::Channel(const ChipsetVariant& chipset, fl::span<CRGB> leds,
     setCorrection(options.mCorrection);
     setTemperature(options.mTemperature);
     setDither(options.mDitherMode);
-    setRgbw(options.mRgbw);
+    applyWhiteCfg(*this, options);
 
     // Create ChannelData during construction with chipset variant
     mChannelData = ChannelData::create(mChipset);
@@ -206,7 +222,7 @@ Channel::Channel(int pin, const ChipsetTimingConfig& timing, fl::span<CRGB> leds
     setCorrection(options.mCorrection);
     setTemperature(options.mTemperature);
     setDither(options.mDitherMode);
-    setRgbw(options.mRgbw);
+    applyWhiteCfg(*this, options);
 
     // Create ChannelData during construction
     mChannelData = ChannelData::create(mChipset);
@@ -226,7 +242,7 @@ void Channel::applyConfig(const ChannelConfig& config) {
     setCorrection(config.options.mCorrection);
     setTemperature(config.options.mTemperature);
     setDither(config.options.mDitherMode);
-    setRgbw(config.options.mRgbw);
+    applyWhiteCfg(*this, config.options);
     auto& events = ChannelEvents::instance();
     events.onChannelConfigured(*this, config);
 }
@@ -286,6 +302,20 @@ void writeUCS7604(fl::vector_psram<u8>* data, PixelIterator& pixelIterator,
     bool is_rgbw = pixelIterator.get_rgbw().active();
     size_t num_leds = pixelIterator.size();
 
+    // (#2558) UCS7604 is a 4-channel chipset (always RGBW). If the user
+    // configured this channel for 5-channel RGBWW (warm-W + cool-W), the
+    // chipset can't carry the second W byte. Warn loudly — silently dropping
+    // the white channels would be very hard to diagnose. The pixel iterator
+    // continues to emit RGB-only bytes (is_rgbw=false) so the strip still
+    // lights up, just without the W diode.
+    if (pixelIterator.get_rgbww().active() && !is_rgbw) {
+        FL_WARN_ONCE("UCS7604 cannot carry 5-channel RGBWW — this chipset "
+                     "always emits 4-channel RGBW. The warm/cool white "
+                     "channels in your Rgbww config will be dropped. "
+                     "Set ChannelOptions.mWhiteCfg = Rgbw{...} instead to "
+                     "silence this warning.");
+    }
+
     // Encode into the data buffer
     encodeUCS7604(pixelIterator, num_leds, fl::back_inserter(*data),
                   mode, wire_current, is_rgbw, gamma8.get());
@@ -365,7 +395,12 @@ void Channel::showPixels(PixelController<RGB, 1, 0xFFFFFFFF> &pixels) {
     }
 
     // Build pixel iterator with optional addressing transformation
-    ReorderingPixelIteratorAny iterator(pixels, mScreenMap.getXYMap(), mRgbOrder, mSettings.mRgbw, mName);
+    // (#2558) Pass both Rgbw and Rgbww from the channel options; the iterator
+    // carries both, and the encoder dispatch below picks the right path based
+    // on which variant alternative ChannelOptions::mWhiteCfg holds.
+    ReorderingPixelIteratorAny iterator(pixels, mScreenMap.getXYMap(), mRgbOrder,
+                                        mSettings.rgbw(), mSettings.rgbww(),
+                                        mName);
     PixelIterator& pixelIterator = iterator.get();
 
     // Encode pixels based on chipset type
diff --git a/src/fl/channels/cled_controller.h b/src/fl/channels/cled_controller.h
index 6fa6bbd9347..e0877dffc12 100644
--- a/src/fl/channels/cled_controller.h
+++ b/src/fl/channels/cled_controller.h
@@ -75,10 +75,41 @@ class CLEDController {
     CLEDController& setRgbw(const Rgbw& arg = RgbwDefault::value()) FL_NOEXCEPT {
         // Note that at this time (Sept 13th, 2024) this is only implemented in the ESP32 driver
         // directly. For an emulated version please see RGBWEmulatedController in chipsets.h
-        mSettings.mRgbw = arg;
+        //
+        // (#2558) mSettings.mWhiteCfg is now a fl::variant<Empty, Rgbw, Rgbww>;
+        // assigning Rgbw selects the 4-channel alternative. The legacy
+        // "setRgbw(RgbwInvalid::value()) → disable" semantics are preserved
+        // by translating an inactive Rgbw into Empty so observers see the
+        // same "no white channel" state they did before the variant migration.
+        if (!arg.active()) {
+            mSettings.mWhiteCfg.reset();
+        } else {
+            mSettings.mWhiteCfg = arg;
+        }
         return *this;  // builder pattern.
     }
 
+    /// @brief Configure this channel for 5-channel RGBWW (RGB + warm-W + cool-W)
+    /// output. See issue #2558. Driver-side support arrives in later phases of
+    /// the RGBWW work; today this just sets the configuration alternative.
+    /// Symmetric with setRgbw: passing RgbwwInvalid::value() clears the channel
+    /// to plain RGB rather than storing an inactive Rgbww.
+    CLEDController& setRgbww(const Rgbww& arg = RgbwwDefault::value()) FL_NOEXCEPT {
+        if (!arg.active()) {
+            mSettings.mWhiteCfg.reset();
+        } else {
+            mSettings.mWhiteCfg = arg;
+        }
+        return *this;
+    }
+
+    /// @brief Reset this channel to plain 3-channel RGB (clears any RGBW/RGBWW
+    /// configuration). Equivalent to assigning an empty mWhiteCfg.
+    CLEDController& clearWhiteChannel() FL_NOEXCEPT {
+        mSettings.mWhiteCfg.reset();
+        return *this;
+    }
+
     void setEnabled(bool enabled) FL_NOEXCEPT { mEnabled = enabled; }
     bool getEnabled() FL_NOEXCEPT { return mEnabled; }
 
@@ -92,7 +123,14 @@ class CLEDController {
     //   Without CLEDController destructor virtual: 10666 bytes to binary.
     VIRTUAL_IF_NOT_AVR ~CLEDController() FL_NOEXCEPT;
 
-    Rgbw getRgbw() const FL_NOEXCEPT { return mSettings.mRgbw; }
+    /// @return The Rgbw configuration if this channel is in 4-channel mode,
+    /// otherwise RgbwInvalid::value(). Backward-compatible with the pre-#2558
+    /// API: callers that don't know about Rgbww see the same shape as before.
+    Rgbw getRgbw() const FL_NOEXCEPT { return mSettings.rgbw(); }
+
+    /// @return The Rgbww configuration if this channel is in 5-channel mode,
+    /// otherwise RgbwwInvalid::value().
+    Rgbww getRgbww() const FL_NOEXCEPT { return mSettings.rgbww(); }
 
     /// Initialize the LED controller
     virtual void init() FL_NOEXCEPT = 0;
diff --git a/src/fl/channels/options.h b/src/fl/channels/options.h
index 00e5d46aac5..0c11bf1ed87 100644
--- a/src/fl/channels/options.h
+++ b/src/fl/channels/options.h
@@ -6,7 +6,9 @@
 #include "dither_mode.h"
 #include "fl/channels/bus.h"
 #include "rgbw.h"  // IWYU pragma: keep
+#include "fl/gfx/rgbww.h"  // IWYU pragma: keep
 #include "fl/stl/optional.h"
+#include "fl/stl/variant.h"
 
 namespace fl {
 
@@ -22,6 +24,17 @@ namespace fl {
 ///     with the resolution hint (`fl::enableDrivers<fl::Bus::X>()` or
 ///     `FastLED.enableAllDrivers()`) and falls back to AUTO/priority.
 ///
+/// **White-channel configuration** (#2558). `mWhiteCfg` is a 3-way variant:
+///
+///   - `fl::Empty` (default) — plain 3-channel RGB output, no white extraction.
+///     Replaces the legacy `Rgbw{rgbw_mode=kRGBWInvalid}` sentinel.
+///   - `Rgbw` — 4-channel RGBW output. Mode + CCT inside the Rgbw value.
+///   - `Rgbww` — 5-channel RGB + warm-W + cool-W output. Mode + two CCTs +
+///     optional profile pointer inside the Rgbww value.
+///
+/// `setRgbw()` / `getRgbw()` on CLEDController remain backward-compatible —
+/// they wrap the variant. New 5-channel callers use `setRgbww()`/`getRgbww()`.
+///
 /// **Custom / third-party / mock drivers** whose names aren't in the
 /// `fl::Bus` enum should be bound either by priority (clear competing
 /// drivers via `clearAllDrivers()` and let the mock win priority dispatch)
@@ -31,9 +44,39 @@ struct ChannelOptions {
     CRGB mCorrection = UncorrectedColor;
     CRGB mTemperature = UncorrectedTemperature;
     fl::u8 mDitherMode = BINARY_DITHER;
-    Rgbw mRgbw = RgbwInvalid::value(); // RGBW is RGB by default
+    /// White-channel selection (variant): Empty = plain RGB, Rgbw = 4-channel,
+    /// Rgbww = 5-channel. Default-constructed = Empty.
+    fl::variant<fl::Empty, Rgbw, Rgbww> mWhiteCfg;
     Bus mBus = Bus::AUTO;              // Typed driver selection
     fl::optional<float> mGamma;        // Gamma correction (nullopt = use default 2.8)
+
+    /// @return The active Rgbw if mWhiteCfg holds one, else RgbwInvalid::value().
+    /// Backward-compat shim for code paths that pre-date the variant migration.
+    Rgbw rgbw() const FL_NOEXCEPT {
+        if (auto* p = mWhiteCfg.ptr<Rgbw>()) return *p;
+        return RgbwInvalid::value();
+    }
+
+    /// @return The active Rgbww if mWhiteCfg holds one, else RgbwwInvalid::value().
+    Rgbww rgbww() const FL_NOEXCEPT {
+        if (auto* p = mWhiteCfg.ptr<Rgbww>()) return *p;
+        return RgbwwInvalid::value();
+    }
+
+    /// True if this channel emits 4-channel RGBW. Requires both the Rgbw
+    /// variant alternative AND an active mode — a stored
+    /// `RgbwInvalid::value()` does not count, mirroring the legacy
+    /// `Rgbw::active()` semantics.
+    bool isRgbw() const FL_NOEXCEPT {
+        auto* p = mWhiteCfg.ptr<Rgbw>();
+        return p != nullptr && p->active();
+    }
+    /// True if this channel emits 5-channel RGBWW. Same active-flag check as
+    /// isRgbw() — a stored `RgbwwInvalid::value()` does not count.
+    bool isRgbww() const FL_NOEXCEPT {
+        auto* p = mWhiteCfg.ptr<Rgbww>();
+        return p != nullptr && p->active();
+    }
 };
 
 } // namespace fl
diff --git a/src/fl/chipsets/encoders/encoder_constants.h b/src/fl/chipsets/encoders/encoder_constants.h
index fdf463e6a9e..7574707dfa0 100644
--- a/src/fl/chipsets/encoders/encoder_constants.h
+++ b/src/fl/chipsets/encoders/encoder_constants.h
@@ -13,4 +13,8 @@ constexpr size_t BYTES_PER_PIXEL_RGB = 3;
 /// @brief Number of bytes per RGBW pixel (order-agnostic)
 constexpr size_t BYTES_PER_PIXEL_RGBW = 4;
 
+/// @brief Number of bytes per RGBWW pixel (order-agnostic, issue #2558).
+/// Layout: R, G, B, warm-W, cool-W in EOrder + EOrderWW order.
+constexpr size_t BYTES_PER_PIXEL_RGBWW = 5;
+
 } // namespace fl
diff --git a/src/fl/chipsets/encoders/pixel_iterator.h b/src/fl/chipsets/encoders/pixel_iterator.h
index c7e71bbf629..cd47b1daa42 100644
--- a/src/fl/chipsets/encoders/pixel_iterator.h
+++ b/src/fl/chipsets/encoders/pixel_iterator.h
@@ -8,6 +8,7 @@
 #include "fl/stl/iterator.h"
 #include "fl/stl/compiler_control.h"
 #include "fl/gfx/rgbw.h"
+#include "fl/gfx/rgbww.h"
 #include "crgb.h"
 #include "fl/math/intmap.h"
 #include "fl/chipsets/encoders/ws2801.h"
@@ -46,6 +47,16 @@ struct PixelControllerVtable {
     pc->loadAndScaleRGBW(rgbw, b0_out, b1_out, b2_out, b3_out);
   }
 
+  // 5-channel RGBWW path (issue #2558, Phase C of #2545). Same vtable
+  // pattern as loadAndScaleRGBW but produces 5 output bytes (RGB + warm-W
+  // + cool-W) per pixel in EOrder + EOrderWW wire order.
+  static void loadAndScaleRGBWW(void* pixel_controller, Rgbww rgbww,
+                                u8* b0_out, u8* b1_out, u8* b2_out,
+                                u8* b3_out, u8* b4_out) FL_NOEXCEPT {
+    PixelControllerT* pc = static_cast<PixelControllerT*>(pixel_controller);
+    pc->loadAndScaleRGBWW(rgbww, b0_out, b1_out, b2_out, b3_out, b4_out);
+  }
+
   static void loadAndScaleRGB(void* pixel_controller, u8* r_out, u8* g_out, u8* b_out) FL_NOEXCEPT {
     PixelControllerT* pc = static_cast<PixelControllerT*>(pixel_controller);
     pc->loadAndScaleRGB(r_out, g_out, b_out);
@@ -88,6 +99,7 @@ struct PixelControllerVtable {
 };
 
 typedef void (*loadAndScaleRGBWFunction)(void* pixel_controller, Rgbw rgbw, u8* b0_out, u8* b1_out, u8* b2_out, u8* b3_out);
+typedef void (*loadAndScaleRGBWWFunction)(void* pixel_controller, Rgbww rgbww, u8* b0_out, u8* b1_out, u8* b2_out, u8* b3_out, u8* b4_out);
 typedef void (*loadAndScaleRGBFunction)(void* pixel_controller, u8* r_out, u8* g_out, u8* b_out);
 // NOTE: loadAndScale_APA102_HDFunction removed - use fl::loadAndScale_APA102_HD<RGB_ORDER>() from apa102.h encoder
 // NOTE: loadAndScale_WS2816_HDFunction removed - use fl::loadAndScale_WS2816_HD<RGB_ORDER>() from ws2816.h encoder
@@ -109,8 +121,9 @@ typedef void (*getHdScaleFunction)(void* pixel_controller, u8* c0, u8* c1, u8* c
 class PixelIterator {
   public:
     template<typename PixelControllerT>
-    PixelIterator(PixelControllerT* pc, Rgbw rgbw) FL_NOEXCEPT
-         : mPixelController(pc), mRgbw(rgbw) {
+    PixelIterator(PixelControllerT* pc, Rgbw rgbw,
+                  Rgbww rgbww = RgbwwInvalid::value()) FL_NOEXCEPT
+         : mPixelController(pc), mRgbw(rgbw), mRgbww(rgbww) {
       // Manually build up a vtable.
       // Wait... what? Stupid nerds trying to show off how smart they are...
       // Why not just use a virtual function?!
@@ -137,6 +150,7 @@ class PixelIterator {
       // polymorphism by leveraging the C++ template system to ensure type safety.
       typedef PixelControllerVtable<PixelControllerT> Vtable;
       mLoadAndScaleRGBW = &Vtable::loadAndScaleRGBW;
+      mLoadAndScaleRGBWW = &Vtable::loadAndScaleRGBWW;
       mLoadAndScaleRGB = &Vtable::loadAndScaleRGB;
       // NOTE: mLoadAndScale_APA102_HD removed - use fl::loadAndScale_APA102_HD<RGB_ORDER>() from apa102.h encoder
       // NOTE: mLoadAndScale_WS2816_HD removed - use fl::loadAndScale_WS2816_HD<RGB_ORDER>() from ws2816.h encoder
@@ -154,6 +168,10 @@ class PixelIterator {
     void loadAndScaleRGBW(u8 *b0_out, u8 *b1_out, u8 *b2_out, u8 *w_out) FL_NOEXCEPT {
       mLoadAndScaleRGBW(mPixelController, mRgbw, b0_out, b1_out, b2_out, w_out);
     }
+    void loadAndScaleRGBWW(u8 *b0_out, u8 *b1_out, u8 *b2_out,
+                           u8 *b3_out, u8 *b4_out) FL_NOEXCEPT {
+      mLoadAndScaleRGBWW(mPixelController, mRgbww, b0_out, b1_out, b2_out, b3_out, b4_out);
+    }
     void loadAndScaleRGB(u8 *r_out, u8 *g_out, u8 *b_out) FL_NOEXCEPT {
       mLoadAndScaleRGB(mPixelController, r_out, g_out, b_out);
     }
@@ -166,6 +184,9 @@ class PixelIterator {
     void set_rgbw(Rgbw rgbw) FL_NOEXCEPT { mRgbw = rgbw; }
     Rgbw get_rgbw() const FL_NOEXCEPT { return mRgbw; }
 
+    void set_rgbww(Rgbww rgbww) FL_NOEXCEPT { mRgbww = rgbww; }
+    Rgbww get_rgbww() const FL_NOEXCEPT { return mRgbww; }
+
     #if FASTLED_HD_COLOR_MIXING
     void loadRGBScaleAndBrightness(u8* c0, u8* c1, u8* c2, u8* brightness) FL_NOEXCEPT {
       mLoadRGBScaleAndBrightness(mPixelController, c0, c1, c2, brightness);
@@ -180,7 +201,12 @@ class PixelIterator {
     template <typename CONTAINER_UIN8_T>
     void writeWS2812(CONTAINER_UIN8_T* out) FL_NOEXCEPT {
         auto back_ins = fl::back_inserter(*out);
-        if (mRgbw.active()) {
+        // (#2558) Dispatch order: RGBWW > RGBW > RGB. The variant migration
+        // makes these mutually exclusive — at most one alternative is active.
+        if (mRgbww.active()) {
+            auto range = makeScaledPixelRangeRGBWW(this);
+            encodeWS2812_RGBWW(range.first, range.second, back_ins);
+        } else if (mRgbw.active()) {
             auto range = makeScaledPixelRangeRGBW(this);
             encodeWS2812_RGBW(range.first, range.second, back_ins);
         } else {
@@ -343,7 +369,9 @@ class PixelIterator {
     // vtable emulation
     void* mPixelController = nullptr;
     Rgbw mRgbw;
+    Rgbww mRgbww;
     loadAndScaleRGBWFunction mLoadAndScaleRGBW = nullptr;
+    loadAndScaleRGBWWFunction mLoadAndScaleRGBWW = nullptr;
     loadAndScaleRGBFunction mLoadAndScaleRGB = nullptr;
     // NOTE: mLoadAndScale_APA102_HD removed - use fl::loadAndScale_APA102_HD<RGB_ORDER>() from apa102.h encoder
     // NOTE: mLoadAndScale_WS2816_HD removed - use fl::loadAndScale_WS2816_HD<RGB_ORDER>() from ws2816.h encoder
@@ -404,6 +432,25 @@ inline void ScaledPixelIteratorRGBW::advance() FL_NOEXCEPT {
     }
 }
 
+// ScaledPixelIteratorRGBWW implementation (issue #2558)
+inline void ScaledPixelIteratorRGBWW::advance() FL_NOEXCEPT {
+    if (!mPixels) {
+        mHasValue = false;
+        return;
+    }
+
+    if (mPixels->has(1)) {
+        u8 b0, b1, b2, b3, b4;
+        mPixels->loadAndScaleRGBWW(&b0, &b1, &b2, &b3, &b4);
+        mCurrent = array<u8, 5>{{b0, b1, b2, b3, b4}};  // Wire-order 5 bytes
+        mPixels->stepDithering();
+        mPixels->advanceData();
+        mHasValue = true;
+    } else {
+        mHasValue = false;
+    }
+}
+
 // ScaledPixelIteratorBrightness implementation
 inline void ScaledPixelIteratorBrightness::advance() FL_NOEXCEPT {
     if (!mPixels) {
diff --git a/src/fl/chipsets/encoders/pixel_iterator_adapters.h b/src/fl/chipsets/encoders/pixel_iterator_adapters.h
index 9dfdb407291..dec1ed82d19 100644
--- a/src/fl/chipsets/encoders/pixel_iterator_adapters.h
+++ b/src/fl/chipsets/encoders/pixel_iterator_adapters.h
@@ -186,6 +186,54 @@ class ScaledPixelIteratorRGBW {
     bool mHasValue;              ///< true if current pixel is valid
 };
 
+/// @brief Input iterator adapter yielding 5-byte RGBWW pixels (issue #2558).
+///
+/// Mirrors ScaledPixelIteratorRGBW but produces 5 bytes per pixel
+/// (R, G, B, warm-W, cool-W in EOrder + EOrderWW wire order).
+class ScaledPixelIteratorRGBWW {
+public:
+    using iterator_category = input_iterator_tag;
+    using value_type = array<u8, 5>;
+    using difference_type = ptrdiff_t;
+    using pointer = const array<u8, 5>*;
+    using reference = const array<u8, 5>&;
+
+    explicit ScaledPixelIteratorRGBWW(PixelIterator* pixels) FL_NOEXCEPT
+        : mPixels(pixels), mCurrent(), mHasValue(false) {
+        advance();
+    }
+
+    ScaledPixelIteratorRGBWW() FL_NOEXCEPT
+        : mPixels(nullptr), mCurrent(), mHasValue(false) {}
+
+    const array<u8, 5>& operator*() const FL_NOEXCEPT { return mCurrent; }
+    const array<u8, 5>* operator->() const FL_NOEXCEPT { return &mCurrent; }
+
+    ScaledPixelIteratorRGBWW& operator++() FL_NOEXCEPT { advance(); return *this; }
+    ScaledPixelIteratorRGBWW operator++(int) FL_NOEXCEPT {
+        ScaledPixelIteratorRGBWW tmp = *this;
+        advance();
+        return tmp;
+    }
+
+    bool operator==(const ScaledPixelIteratorRGBWW& other) const FL_NOEXCEPT {
+        if (!mHasValue && !other.mHasValue) return true;
+        if (mHasValue != other.mHasValue) return false;
+        return mPixels == other.mPixels;
+    }
+
+    bool operator!=(const ScaledPixelIteratorRGBWW& other) const FL_NOEXCEPT {
+        return !(*this == other);
+    }
+
+private:
+    void advance() FL_NOEXCEPT;
+
+    PixelIterator* mPixels;
+    array<u8, 5> mCurrent;
+    bool mHasValue;
+};
+
 /// @brief Input iterator adapter for PixelIterator yielding brightness values
 ///
 /// Extracts per-LED brightness values from PixelIterator for HD encoders.
@@ -362,6 +410,15 @@ makeScaledPixelRangeRGBW(PixelIterator* pixels) FL_NOEXCEPT {
     );
 }
 
+/// @brief Create RGBWW input iterator range from PixelIterator (issue #2558)
+inline pair<detail::ScaledPixelIteratorRGBWW, detail::ScaledPixelIteratorRGBWW>
+makeScaledPixelRangeRGBWW(PixelIterator* pixels) FL_NOEXCEPT {
+    return make_pair(
+        detail::ScaledPixelIteratorRGBWW(pixels),
+        detail::ScaledPixelIteratorRGBWW()  // End sentinel
+    );
+}
+
 /// @brief Create brightness input iterator range from PixelIterator
 /// @param pixels PixelIterator to wrap
 /// @return Pair of begin/end iterators
diff --git a/src/fl/chipsets/encoders/ws2812.h b/src/fl/chipsets/encoders/ws2812.h
index 4b6018e74ba..9d43e470a9f 100644
--- a/src/fl/chipsets/encoders/ws2812.h
+++ b/src/fl/chipsets/encoders/ws2812.h
@@ -19,6 +19,7 @@
 #include "fl/stl/array.h"
 #include "fl/chipsets/encoders/encoder_constants.h"
 #include "fl/gfx/rgbw.h"
+#include "fl/gfx/rgbww.h"
 #include "fl/stl/noexcept.h"
 
 namespace fl {
@@ -77,4 +78,21 @@ void encodeWS2812(InputIterator first, InputIterator last, OutputIterator out, c
     }
 }
 
+/// @brief Encode 5-byte pixel data in WS2812 format (issue #2558, RGBWW).
+/// @tparam InputIterator Iterator yielding fl::array<u8, 5> (5 bytes in wire order:
+///         R, G, B, warm-W, cool-W after EOrder + EOrderWW placement)
+/// @tparam OutputIterator Output iterator accepting uint8_t
+template <typename InputIterator, typename OutputIterator>
+void encodeWS2812_RGBWW(InputIterator first, InputIterator last, OutputIterator out) FL_NOEXCEPT {
+    while (first != last) {
+        const fl::array<u8, BYTES_PER_PIXEL_RGBWW>& pixel = *first;
+        *out++ = pixel[0];
+        *out++ = pixel[1];
+        *out++ = pixel[2];
+        *out++ = pixel[3];
+        *out++ = pixel[4];
+        ++first;
+    }
+}
+
 } // namespace fl
diff --git a/src/fl/gfx/_build.cpp.hpp b/src/fl/gfx/_build.cpp.hpp
index 8d30cd8ee01..f4d00be95b9 100644
--- a/src/fl/gfx/_build.cpp.hpp
+++ b/src/fl/gfx/_build.cpp.hpp
@@ -19,6 +19,7 @@
 #include "fl/gfx/rectangular_draw_buffer.cpp.hpp"
 #include "fl/gfx/rgbw.cpp.hpp"
 #include "fl/gfx/rgbw_colorimetric.cpp.hpp"
+#include "fl/gfx/rgbww.cpp.hpp"
 #include "fl/gfx/sample.cpp.hpp"
 #include "fl/gfx/splat.cpp.hpp"
 #include "fl/gfx/tile2x2.cpp.hpp"
diff --git a/src/fl/gfx/pixel_iterator_any.h b/src/fl/gfx/pixel_iterator_any.h
index 2729f66950a..8c8f7a425e7 100644
--- a/src/fl/gfx/pixel_iterator_any.h
+++ b/src/fl/gfx/pixel_iterator_any.h
@@ -9,6 +9,7 @@
 #include "fl/stl/optional.h"
 #include "fl/stl/vector.h"
 #include "rgbw.h"
+#include "fl/gfx/rgbww.h"
 
 namespace fl {
 
@@ -27,14 +28,24 @@ class PixelIteratorAny {
     /// @param controller Source PixelController (always RGB order)
     /// @param newOrder Desired color order (RGB, RBG, GRB, GBR, BRG, BGR)
     /// @param rgbw RGBW conversion settings
-    PixelIteratorAny(PixelController<RGB> &controller, EOrder newOrder, Rgbw rgbw): mRgbw(rgbw) {
+    PixelIteratorAny(PixelController<RGB> &controller, EOrder newOrder, Rgbw rgbw,
+                     Rgbww rgbww = RgbwwInvalid::value())
+        : mRgbw(rgbw), mRgbww(rgbww) {
         init(controller, newOrder);
     }
 
     template<typename PIXEL_CONTROLLER>
-    PixelIteratorAny(PIXEL_CONTROLLER &controller, EOrder newOrder, Rgbw rgbw): mRgbw(rgbw) {
-        PixelController<RGB> rgbController(controller);  // Normslize to RGB order.
-        init(controller, newOrder);  // now this switch statement just has to resolve the other half.
+    PixelIteratorAny(PIXEL_CONTROLLER &controller, EOrder newOrder, Rgbw rgbw,
+                     Rgbww rgbww = RgbwwInvalid::value())
+        : mRgbw(rgbw), mRgbww(rgbww) {
+        // (#2558) Bugfix surfaced by CodeRabbit on PR #2560: the previous
+        // implementation constructed rgbController for normalization but then
+        // called init(controller, ...) — passing the un-normalized original
+        // controller. init() takes a PixelController<RGB>&, so a non-RGB
+        // PIXEL_CONTROLLER would have failed to compile if this branch was
+        // ever instantiated; the bug stayed latent until now.
+        PixelController<RGB> rgbController(controller);  // Normalize to RGB order.
+        init(rgbController, newOrder);
     }
 
     /// @brief Get the type-erased PixelIterator
@@ -77,32 +88,34 @@ class PixelIteratorAny {
         // Step 2: Use visitor pattern to construct PixelIterator with correct pointer type
         // Note: fl::Optional::emplace takes a constructed object, not constructor args
         struct PixelIteratorInitVisitor {
-            PixelIteratorInitVisitor(Rgbw rgbw) : rgbw(rgbw) {}
+            PixelIteratorInitVisitor(Rgbw rgbw, Rgbww rgbww)
+                : rgbw(rgbw), rgbww(rgbww) {}
             fl::Optional<PixelIterator>* pixelIteratorPtr;
             Rgbw rgbw;
+            Rgbww rgbww;
 
             // Need concrete overloads for each type in the variant
             void accept(PixelController<RGB>& controller) {
-                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw));
+                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw, rgbww));
             }
             void accept(PixelController<RBG>& controller) {
-                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw));
+                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw, rgbww));
             }
             void accept(PixelController<GRB>& controller) {
-                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw));
+                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw, rgbww));
             }
             void accept(PixelController<GBR>& controller) {
-                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw));
+                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw, rgbww));
             }
             void accept(PixelController<BRG>& controller) {
-                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw));
+                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw, rgbww));
             }
             void accept(PixelController<BGR>& controller) {
-                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw));
+                pixelIteratorPtr->emplace(PixelIterator(&controller, rgbw, rgbww));
             }
         };
 
-        PixelIteratorInitVisitor visitor(mRgbw);
+        PixelIteratorInitVisitor visitor(mRgbw, mRgbww);
         visitor.pixelIteratorPtr = &mPixelIterator;
         mAnyController.visit(visitor);
     }
@@ -117,6 +130,7 @@ class PixelIteratorAny {
     fl::Optional<PixelIterator> mPixelIterator;
 
     Rgbw mRgbw;
+    Rgbww mRgbww;
 
     // XYMap for pixel addressing (optional) - contains embedded width/height
     fl::shared_ptr<const XYMap> mXyMap;
diff --git a/src/fl/gfx/rgbw.cpp.hpp b/src/fl/gfx/rgbw.cpp.hpp
index ef03e9d5906..0d55ed409e3 100644
--- a/src/fl/gfx/rgbw.cpp.hpp
+++ b/src/fl/gfx/rgbw.cpp.hpp
@@ -230,11 +230,13 @@ inline const colorimetric_detail::ProfileCache& get_cache(int cct) FL_NOEXCEPT {
 }
 
 // ===== LUT state (issue #2545 Phase 2) ==================================
-#if FASTLED_RGBW_COLORIMETRIC_LUT
-// The LUT itself is owned by the singleton via fl::unique_ptr — null when
-// disabled, non-null and fully built when enabled. The lookup code path
-// reads through .table->cells.get() and is guaranteed safe whenever
-// `enabled` is true.
+// Always-compiled when the outer FASTLED_RGBW_COLORIMETRIC gate is on. The
+// inner FASTLED_RGBW_COLORIMETRIC_LUT gate that used to wrap this block was
+// redundant — once you've opted into colorimetric math, the LUT state +
+// rebuild_lut_if_stale + build_lut reference are gc-section-droppable for
+// sketches that never call enable_rgbw_colorimetric_lut() (the `enabled`
+// flag stays false, the fl::unique_ptr<LutTable> stays empty, and only the
+// LutStateHolder singleton instance itself is retained — ~32 bytes).
 struct LutStateHolder {
     fl::unique_ptr<colorimetric_detail::LutTable> table;
     const DiodeProfile* built_for = nullptr;
@@ -256,7 +258,6 @@ inline void rebuild_lut_if_stale(LutStateHolder& s, int cct) FL_NOEXCEPT {
     s.built_for = active;
     s.built_cct = override_cct;
 }
-#endif  // FASTLED_RGBW_COLORIMETRIC_LUT
 } // namespace
 
 void rgb_2_rgbw_colorimetric(u16 w_color_temperature, u8 r,
@@ -276,7 +277,8 @@ void rgb_2_rgbw_colorimetric(u16 w_color_temperature, u8 r,
     const colorimetric_detail::ProfileCache& cache = get_cache(w_color_temperature);
     float rgbw[4];
 
-#if FASTLED_RGBW_COLORIMETRIC_LUT
+    // LUT fast path — gc-sections drops the branch + singleton + lookup_lut
+    // for sketches that never call enable_rgbw_colorimetric_lut().
     LutStateHolder& lut_state = fl::Singleton<LutStateHolder>::instance();
     if (lut_state.enabled) {
         rebuild_lut_if_stale(lut_state, w_color_temperature);
@@ -297,7 +299,6 @@ void rgb_2_rgbw_colorimetric(u16 w_color_temperature, u8 r,
         *out_w = colorimetric_detail::quantize_u8(rgbw[3]);
         return;
     }
-#endif
 
     const bool ok =
         colorimetric_detail::solve_strict_subgamut(cache, s_r, s_g, s_b, rgbw);
@@ -336,7 +337,6 @@ void rgb_2_rgbw_colorimetric_boosted(u16 w_color_temperature, u8 r,
 }
 
 bool enable_rgbw_colorimetric_lut(int grid_n) FL_NOEXCEPT {
-#if FASTLED_RGBW_COLORIMETRIC_LUT
     if (grid_n < 4) grid_n = 4;
     if (grid_n > 256) grid_n = 256;
     LutStateHolder& s = fl::Singleton<LutStateHolder>::instance();
@@ -346,29 +346,19 @@ bool enable_rgbw_colorimetric_lut(int grid_n) FL_NOEXCEPT {
     s.built_for = nullptr;
     s.built_cct = -1;
     return true;
-#else
-    (void)grid_n;
-    return false;
-#endif
 }
 
 void disable_rgbw_colorimetric_lut() FL_NOEXCEPT {
-#if FASTLED_RGBW_COLORIMETRIC_LUT
     LutStateHolder& s = fl::Singleton<LutStateHolder>::instance();
     s.enabled = false;
     s.table.reset();  // unique_ptr destructor frees the cells storage
     s.requested_grid_n = 0;
     s.built_for = nullptr;
     s.built_cct = 0;
-#endif
 }
 
 bool rgbw_colorimetric_lut_enabled() FL_NOEXCEPT {
-#if FASTLED_RGBW_COLORIMETRIC_LUT
     return fl::Singleton<LutStateHolder>::instance().enabled;
-#else
-    return false;
-#endif
 }
 
 #else  // FASTLED_RGBW_COLORIMETRIC
diff --git a/src/fl/gfx/rgbw.h b/src/fl/gfx/rgbw.h
index e1a73a33520..05b0e050033 100644
--- a/src/fl/gfx/rgbw.h
+++ b/src/fl/gfx/rgbw.h
@@ -61,10 +61,12 @@ const DiodeProfile* get_rgbw_colorimetric_profile() FL_NOEXCEPT;
 
 // Enable the 2D + 1D factored LUT path (issue #2545 Phase 2). When enabled,
 // the colorimetric modes use a bilinear-interpolated LUT instead of solving
-// per pixel. Requires FASTLED_RGBW_COLORIMETRIC_LUT to be defined; no-op
-// otherwise. `grid_n` controls the LUT edge length (8 KB at N=32, ~2 KB at
-// N=16, ~32 KB at N=64). LUT is rebuilt whenever the active profile or CCT
-// changes. Returns false on allocation failure.
+// per pixel. Available whenever FASTLED_RGBW_COLORIMETRIC=1 — no separate
+// FASTLED_RGBW_COLORIMETRIC_LUT flag exists; gc-sections drops the LUT path
+// for sketches that never call this. `grid_n` controls the LUT edge length
+// (8 KB at N=32, ~2 KB at N=16, ~32 KB at N=64). LUT is rebuilt whenever
+// the active profile or CCT changes. Returns false on allocation failure
+// (or always false when FASTLED_RGBW_COLORIMETRIC is undefined).
 bool enable_rgbw_colorimetric_lut(int grid_n) FL_NOEXCEPT;
 
 // Free the LUT and revert colorimetric calls to the closed-form solver.
diff --git a/src/fl/gfx/rgbww.cpp.hpp b/src/fl/gfx/rgbww.cpp.hpp
new file mode 100644
index 00000000000..f53c4fa8dff
--- /dev/null
+++ b/src/fl/gfx/rgbww.cpp.hpp
@@ -0,0 +1,320 @@
+/// @file rgbww.cpp.hpp
+/// Dispatch + implementations for the 5-channel RGB->RGBWW path
+/// (issue #2558, Phase 3 of #2545).
+///
+/// The colorimetric modes call `solve_rgbcct()` from
+/// fl/gfx/rgbw_colorimetric.h whenever FASTLED_RGBW_COLORIMETRIC is defined
+/// (that's the one flag that gates the underlying color math library — see
+/// PR #2552). Without it, the dispatch emits FL_WARN_ONCE and outputs five
+/// zero bytes. Suppress the warn-once with
+/// `-DFASTLED_SUPPRESS_RGBWW_FALLBACK_WARNING=1`.
+///
+/// There is intentionally no separate FASTLED_RGBWW gate: --gc-sections
+/// already drops every RGBWW symbol for sketches that never configure a
+/// channel with `mWhiteCfg = Rgbww{...}`, so a second compile flag would
+/// only add an extra define for users to remember without any flash savings.
+
+#include "fl/stl/stdint.h"
+
+#define FASTLED_INTERNAL
+#include "fl/system/fastled.h"
+
+#include "fl/gfx/rgbww.h"
+// rgbw_colorimetric.h carries the RgbcctProfile type definition that
+// kRgbwwDefaultProfile needs, plus the inline math primitives Phase D uses.
+// The non-inline solve_rgbcct symbol itself only exists when
+// FASTLED_RGBW_COLORIMETRIC is defined at library build time — see the gated
+// dispatch bodies further down.
+#include "fl/gfx/rgbw_colorimetric.h"
+#include "fl/log/log.h"
+#include "fl/stl/singleton.h"
+
+
+namespace fl {
+
+namespace {
+inline void zero_out(u8 *r, u8 *g, u8 *b, u8 *ww, u8 *wc) FL_NOEXCEPT {
+    *r = 0; *g = 0; *b = 0; *ww = 0; *wc = 0;
+}
+} // namespace
+
+// ===== Default profile + active-profile state ==============================
+//
+// kRgbwwDefaultProfile: two DiodeProfile entries derived from SK6812-RGBWW-
+// style datasheets. R/G/B chromaticities and luminances mirror
+// kRgbwDefaultProfile (the colorimetric 4-channel default); W vertex is
+// hardcoded to the Planckian xy at 2700K (warm) and 6500K (cool) so static
+// initialization stays trivial (no CCT conversion at init time).
+//   2700K Planckian xy ≈ (0.4600, 0.4107)  (matches cct_to_xy(2700) ± 0.002)
+//   6500K Planckian xy ≈ (0.3135, 0.3237)  (matches cct_to_xy(6500) ± 0.002)
+const colorimetric_detail::RgbcctProfile kRgbwwDefaultProfile = {
+    /* warm_path */ {
+        /* xy_r        */ { 0.700606f, 0.299300f },
+        /* xy_g        */ { 0.097940f, 0.831593f },
+        /* xy_b        */ { 0.129086f, 0.049450f },
+        /* xy_w (2700) */ { 0.460000f, 0.410700f },
+        /* lum_r       */ 0.10f,
+        /* lum_g       */ 0.37f,
+        /* lum_b       */ 0.08f,
+        /* lum_w       */ 1.00f,
+        /* nominal_cct */ 2700,
+    },
+    /* cool_path */ {
+        /* xy_r        */ { 0.700606f, 0.299300f },
+        /* xy_g        */ { 0.097940f, 0.831593f },
+        /* xy_b        */ { 0.129086f, 0.049450f },
+        /* xy_w (6500) */ { 0.313500f, 0.323700f },
+        /* lum_r       */ 0.10f,
+        /* lum_g       */ 0.37f,
+        /* lum_b       */ 0.08f,
+        /* lum_w       */ 1.00f,
+        /* nominal_cct */ 6500,
+    },
+};
+
+namespace {
+struct RgbwwColorimetricState {
+    const colorimetric_detail::RgbcctProfile* profile = nullptr;
+};
+} // namespace
+
+void set_rgbww_colorimetric_profile(const colorimetric_detail::RgbcctProfile* profile) FL_NOEXCEPT {
+    fl::Singleton<RgbwwColorimetricState>::instance().profile = profile;
+}
+
+const colorimetric_detail::RgbcctProfile* get_rgbww_colorimetric_profile() FL_NOEXCEPT {
+    const colorimetric_detail::RgbcctProfile* p =
+        fl::Singleton<RgbwwColorimetricState>::instance().profile;
+    return p != nullptr ? p : &kRgbwwDefaultProfile;
+}
+
+// User-installable RGB->RGBWW function pointer, held behind a lazy
+// Singleton<T> (same pattern as the rgb_2_rgbw user function — see #2424 for
+// the binary-bloat rationale). Default is nullptr; the user function path
+// emits zero output when nothing is installed.
+namespace {
+struct Rgb2RgbwwUserState {
+    rgb_2_rgbww_function fn = nullptr;
+};
+} // namespace
+
+void set_rgb_2_rgbww_function(rgb_2_rgbww_function func) FL_NOEXCEPT {
+    fl::Singleton<Rgb2RgbwwUserState>::instance().fn = func;
+}
+
+void rgbww_partial_reorder(EOrderWW ww_placement,
+                           u8 b0, u8 b1, u8 b2,
+                           u8 ww, u8 wc,
+                           u8 *out_b0, u8 *out_b1, u8 *out_b2,
+                           u8 *out_b3, u8 *out_b4) FL_NOEXCEPT {
+    // Five output slots: out[0..4].
+    u8 out[5];
+    const u8 enc = static_cast<u8>(ww_placement);
+    const u8 ww_idx = (enc >> 4) & 0x07;
+    const u8 wc_idx = enc & 0x07;
+    out[ww_idx] = ww;
+    out[wc_idx] = wc;
+    // Fill the three remaining slots with b0, b1, b2 in ascending index order.
+    u8 b_idx = 0;
+    const u8 rgb_bytes[3] = { b0, b1, b2 };
+    for (u8 k = 0; k < 5; ++k) {
+        if (k != ww_idx && k != wc_idx) {
+            out[k] = rgb_bytes[b_idx++];
+        }
+    }
+    *out_b0 = out[0];
+    *out_b1 = out[1];
+    *out_b2 = out[2];
+    *out_b3 = out[3];
+    *out_b4 = out[4];
+}
+
+void rgb_2_rgbww_user_function(const Rgbww& cfg,
+                               u8 r, u8 g, u8 b,
+                               u8 r_scale, u8 g_scale, u8 b_scale,
+                               u8 *out_r, u8 *out_g, u8 *out_b,
+                               u8 *out_ww, u8 *out_wc) FL_NOEXCEPT {
+    rgb_2_rgbww_function fn = fl::Singleton<Rgb2RgbwwUserState>::instance().fn;
+    if (fn == nullptr) {
+        // No user function installed — produce safe zero output.
+        zero_out(out_r, out_g, out_b, out_ww, out_wc);
+        return;
+    }
+    fn(cfg, r, g, b, r_scale, g_scale, b_scale,
+       out_r, out_g, out_b, out_ww, out_wc);
+}
+
+
+#if FASTLED_RGBW_COLORIMETRIC
+
+namespace {
+// Compute the warm/cool blend factor from the input chromaticity. Simple
+// x-based heuristic: warmer inputs (higher x, closer to warm white) → lower
+// eta (more warm-W); cooler inputs → higher eta. Mathematically not optimal
+// — a chromaticity-aware solver could place eta to minimize dE — but cheap,
+// monotonic, and good enough for the common ambilight / neutral-pastel case.
+inline float compute_eta_from_input(const colorimetric_detail::RgbcctProfile& profile,
+                                    float s_r, float s_g, float s_b) FL_NOEXCEPT {
+    // Reuse the warm path's RGB primaries to compute the input chromaticity.
+    float P_R[3], P_G[3], P_B[3];
+    colorimetric_detail::xyY_to_XYZ(profile.warm_path.xy_r[0], profile.warm_path.xy_r[1],
+                                    profile.warm_path.lum_r, P_R);
+    colorimetric_detail::xyY_to_XYZ(profile.warm_path.xy_g[0], profile.warm_path.xy_g[1],
+                                    profile.warm_path.lum_g, P_G);
+    colorimetric_detail::xyY_to_XYZ(profile.warm_path.xy_b[0], profile.warm_path.xy_b[1],
+                                    profile.warm_path.lum_b, P_B);
+    const float X = P_R[0]*s_r + P_G[0]*s_g + P_B[0]*s_b;
+    const float Y = P_R[1]*s_r + P_G[1]*s_g + P_B[1]*s_b;
+    const float Z = P_R[2]*s_r + P_G[2]*s_g + P_B[2]*s_b;
+    const float sum = X + Y + Z;
+    if (sum < 1e-9f) return 0.5f;
+    const float input_x = X / sum;
+    const float warm_x = profile.warm_path.xy_w[0];
+    const float cool_x = profile.cool_path.xy_w[0];
+    if (cool_x == warm_x) return 0.5f;
+    const float eta = (input_x - warm_x) / (cool_x - warm_x);
+    if (eta < 0.0f) return 0.0f;
+    if (eta > 1.0f) return 1.0f;
+    return eta;
+}
+// Resolve the per-call RgbcctProfile, honoring cfg.warm_cct / cool_cct.
+//
+// (#2558) CodeRabbit caught that the previous implementation ignored the
+// per-strip CCT fields whenever cfg.profile == nullptr — every Rgbww config
+// produced the same warm=2700/cool=6500 default. Now we shift the W vertex
+// of a per-call profile to whatever CCTs the user requested. When the
+// requested CCTs match the default profile's nominal_cct values exactly, we
+// short-circuit to the global default to avoid the per-pixel xy recompute.
+inline const colorimetric_detail::RgbcctProfile&
+resolve_active_rgbcct_profile(const Rgbww& cfg,
+                              colorimetric_detail::RgbcctProfile& scratch) FL_NOEXCEPT {
+    if (cfg.profile != nullptr) {
+        return *cfg.profile;
+    }
+    const colorimetric_detail::RgbcctProfile* base = get_rgbww_colorimetric_profile();
+    if (static_cast<int>(cfg.warm_cct) == base->warm_path.nominal_cct
+        && static_cast<int>(cfg.cool_cct) == base->cool_path.nominal_cct) {
+        return *base;
+    }
+    // Build a temp profile with W vertices at the requested CCTs.
+    scratch = *base;
+    if (static_cast<int>(cfg.warm_cct) != base->warm_path.nominal_cct) {
+        float xy[2];
+        colorimetric_detail::cct_to_xy(cfg.warm_cct, xy);
+        scratch.warm_path.xy_w[0] = xy[0];
+        scratch.warm_path.xy_w[1] = xy[1];
+        scratch.warm_path.nominal_cct = cfg.warm_cct;
+    }
+    if (static_cast<int>(cfg.cool_cct) != base->cool_path.nominal_cct) {
+        float xy[2];
+        colorimetric_detail::cct_to_xy(cfg.cool_cct, xy);
+        scratch.cool_path.xy_w[0] = xy[0];
+        scratch.cool_path.xy_w[1] = xy[1];
+        scratch.cool_path.nominal_cct = cfg.cool_cct;
+    }
+    return scratch;
+}
+
+} // namespace
+
+void rgb_2_rgbww_colorimetric(const Rgbww& cfg,
+                              u8 r, u8 g, u8 b,
+                              u8 r_scale, u8 g_scale, u8 b_scale,
+                              u8 *out_r, u8 *out_g, u8 *out_b,
+                              u8 *out_ww, u8 *out_wc) FL_NOEXCEPT {
+    r = scale8(r, r_scale);
+    g = scale8(g, g_scale);
+    b = scale8(b, b_scale);
+    if ((r | g | b) == 0) { zero_out(out_r, out_g, out_b, out_ww, out_wc); return; }
+
+    colorimetric_detail::RgbcctProfile scratch;
+    const colorimetric_detail::RgbcctProfile& profile =
+        resolve_active_rgbcct_profile(cfg, scratch);
+
+    const float s_r = r * (1.0f / 255.0f);
+    const float s_g = g * (1.0f / 255.0f);
+    const float s_b = b * (1.0f / 255.0f);
+    const float eta = compute_eta_from_input(profile, s_r, s_g, s_b);
+
+    float rgbww[5];
+    colorimetric_detail::solve_rgbcct(profile, s_r, s_g, s_b, eta, rgbww);
+
+    *out_r  = colorimetric_detail::quantize_u8(rgbww[0]);
+    *out_g  = colorimetric_detail::quantize_u8(rgbww[1]);
+    *out_b  = colorimetric_detail::quantize_u8(rgbww[2]);
+    *out_ww = colorimetric_detail::quantize_u8(rgbww[3]);
+    *out_wc = colorimetric_detail::quantize_u8(rgbww[4]);
+}
+
+void rgb_2_rgbww_colorimetric_boosted(const Rgbww& cfg,
+                                      u8 r, u8 g, u8 b,
+                                      u8 r_scale, u8 g_scale, u8 b_scale,
+                                      u8 *out_r, u8 *out_g, u8 *out_b,
+                                      u8 *out_ww, u8 *out_wc) FL_NOEXCEPT {
+    // Phase D MVP: the boosted variant uses the same RGBCCT line-blend as the
+    // strict path but skews eta toward 0.5 (equal warm+cool) so the combined
+    // W contribution is larger when the input chromaticity sits near neutral.
+    // A future revision can use a wx_lp-style maximization here.
+    r = scale8(r, r_scale);
+    g = scale8(g, g_scale);
+    b = scale8(b, b_scale);
+    if ((r | g | b) == 0) { zero_out(out_r, out_g, out_b, out_ww, out_wc); return; }
+
+    colorimetric_detail::RgbcctProfile scratch;
+    const colorimetric_detail::RgbcctProfile& profile =
+        resolve_active_rgbcct_profile(cfg, scratch);
+
+    const float s_r = r * (1.0f / 255.0f);
+    const float s_g = g * (1.0f / 255.0f);
+    const float s_b = b * (1.0f / 255.0f);
+    const float eta_chroma = compute_eta_from_input(profile, s_r, s_g, s_b);
+    // Push eta halfway toward 0.5 (equal blend) for more total W participation.
+    const float eta = 0.5f * eta_chroma + 0.5f * 0.5f;
+
+    float rgbww[5];
+    colorimetric_detail::solve_rgbcct(profile, s_r, s_g, s_b, eta, rgbww);
+
+    *out_r  = colorimetric_detail::quantize_u8(rgbww[0]);
+    *out_g  = colorimetric_detail::quantize_u8(rgbww[1]);
+    *out_b  = colorimetric_detail::quantize_u8(rgbww[2]);
+    *out_ww = colorimetric_detail::quantize_u8(rgbww[3]);
+    *out_wc = colorimetric_detail::quantize_u8(rgbww[4]);
+}
+
+#else  // FASTLED_RGBW_COLORIMETRIC
+
+// Stub path when the colorimetric math library is not compiled in. Emits
+// FL_WARN_ONCE (suppressible) and five zero bytes. Does not pull in the
+// solver, profile cache, or float math machinery — same gc-section behavior
+// as the rest of the colorimetric Phase 1 dispatch in PR #2552.
+void rgb_2_rgbww_colorimetric(const Rgbww& cfg,
+                              u8 r, u8 g, u8 b,
+                              u8 r_scale, u8 g_scale, u8 b_scale,
+                              u8 *out_r, u8 *out_g, u8 *out_b,
+                              u8 *out_ww, u8 *out_wc) FL_NOEXCEPT {
+    (void)cfg; (void)r; (void)g; (void)b;
+    (void)r_scale; (void)g_scale; (void)b_scale;
+#ifndef FASTLED_SUPPRESS_RGBWW_FALLBACK_WARNING
+    FL_WARN_ONCE("RGBWW: kRGBWWColorimetric requires FASTLED_RGBW_COLORIMETRIC=1 "
+                 "(the math library that provides solve_rgbcct). Outputting zeros.");
+#endif
+    zero_out(out_r, out_g, out_b, out_ww, out_wc);
+}
+
+void rgb_2_rgbww_colorimetric_boosted(const Rgbww& cfg,
+                                      u8 r, u8 g, u8 b,
+                                      u8 r_scale, u8 g_scale, u8 b_scale,
+                                      u8 *out_r, u8 *out_g, u8 *out_b,
+                                      u8 *out_ww, u8 *out_wc) FL_NOEXCEPT {
+    (void)cfg; (void)r; (void)g; (void)b;
+    (void)r_scale; (void)g_scale; (void)b_scale;
+#ifndef FASTLED_SUPPRESS_RGBWW_FALLBACK_WARNING
+    FL_WARN_ONCE("RGBWW: kRGBWWColorimetricBoosted requires FASTLED_RGBW_COLORIMETRIC=1. "
+                 "Outputting zeros.");
+#endif
+    zero_out(out_r, out_g, out_b, out_ww, out_wc);
+}
+
+#endif  // FASTLED_RGBW_COLORIMETRIC
+
+} // namespace fl
diff --git a/src/fl/gfx/rgbww.h b/src/fl/gfx/rgbww.h
new file mode 100644
index 00000000000..61ab264e6e7
--- /dev/null
+++ b/src/fl/gfx/rgbww.h
@@ -0,0 +1,199 @@
+/// @file rgbww.h
+/// 5-channel RGB + warm-W + cool-W (RGBWW / RGBCCT) configuration types
+/// (issue #2558, Phase 3 of #2545). Mirrors fl/gfx/rgbw.h but carries two
+/// CCTs for the layered solver. The actual rgb_2_rgbww dispatch and encoder
+/// pipeline arrive in later phases — this header defines TYPES ONLY so the
+/// rest of the codebase can reference Rgbww / RGBWW_MODE / EOrderWW.
+
+#pragma once
+
+#include "fl/stl/stdint.h"
+#include "fl/stl/compiler_control.h"
+#include "fl/stl/noexcept.h"
+
+namespace fl {
+
+// Forward decl — full definition in fl/gfx/rgbw_colorimetric.h. Avoiding the
+// transitive include keeps Rgbww's compile cost minimal in callers that don't
+// need the colorimetric solver.
+namespace colorimetric_detail {
+struct RgbcctProfile;
+}
+
+/// White-byte ordering for 5-channel RGBWW output. Two white bytes consume
+/// two of the five wire slots; RGB fills the remaining three in EOrder order.
+/// `WwWcEnd` (warm then cool, both at end) matches typical SK6812-RGBWW
+/// data sheets.
+enum class EOrderWW : fl::u8 {
+    WwWcEnd   = 0x34,   ///< RGB followed by warm-W, cool-W
+    WcWwEnd   = 0x43,   ///< RGB followed by cool-W, warm-W
+    WwWcStart = 0x01,   ///< warm-W, cool-W, then RGB
+    WcWwStart = 0x10,   ///< cool-W, warm-W, then RGB
+    WWDefault = WwWcEnd,
+};
+
+/// RGB -> RGBWW conversion modes (issue #2558, Phase 3 of #2545). All modes
+/// are colorimetric — there is no min(RGB)-style analog that generalizes to
+/// two whites. The real solver runs whenever `FASTLED_RGBW_COLORIMETRIC=1`
+/// (the same flag that gates the 4-channel colorimetric path); otherwise
+/// dispatch emits FL_WARN_ONCE and outputs zeros. No separate FASTLED_RGBWW
+/// flag exists — gc-sections drops unused RGBWW symbols for sketches that
+/// never configure a channel with `mWhiteCfg = Rgbww{...}`.
+enum class RGBWW_MODE : fl::u8 {
+    kRGBWWInvalid,
+    kRGBWWColorimetric,
+    kRGBWWColorimetricBoosted,
+    // IMPORTANT: kRGBWWUserFunction MUST remain the last enumerator. New
+    // modes are added immediately above this line so the user-function
+    // ordinal stays as the dispatch max value.
+    kRGBWWUserFunction,
+};
+
+/// Default warm / cool reference CCTs for built-in profiles.
+enum {
+    kRGBWWDefaultWarmCct = 2700,
+    kRGBWWDefaultCoolCct = 6500,
+};
+
+/// Per-strip RGBWW configuration. Parallels fl::Rgbw but carries the two
+/// reference CCTs the layered solver needs (warm-W and cool-W).
+struct Rgbww {
+    explicit Rgbww(fl::u16 warm = kRGBWWDefaultWarmCct,
+                   fl::u16 cool = kRGBWWDefaultCoolCct,
+                   RGBWW_MODE mode = RGBWW_MODE::kRGBWWColorimetric,
+                   EOrderWW placement = EOrderWW::WWDefault) FL_NOEXCEPT
+        : warm_cct(warm), cool_cct(cool),
+          rgbww_mode(mode), w_placement(placement),
+          profile(nullptr) {}
+
+    fl::u16 warm_cct;
+    fl::u16 cool_cct;
+    RGBWW_MODE rgbww_mode;
+    EOrderWW w_placement;
+    /// Optional override for the diode profile (warm + cool W chromaticity +
+    /// luminance). nullptr means "use kRgbwwDefaultProfile" — wired up in
+    /// Phase D. Storage is borrowed; caller must keep the profile alive for
+    /// the lifetime of any controller using this Rgbww.
+    const colorimetric_detail::RgbcctProfile* profile;
+
+    FASTLED_FORCE_INLINE bool active() const FL_NOEXCEPT {
+        return rgbww_mode != RGBWW_MODE::kRGBWWInvalid;
+    }
+};
+
+/// Sentinel: disables RGBWW (variant should hold fl::Empty instead, but this
+/// is kept for symmetry with RgbwInvalid).
+struct RgbwwInvalid : public Rgbww {
+    RgbwwInvalid() FL_NOEXCEPT : Rgbww() {
+        rgbww_mode = RGBWW_MODE::kRGBWWInvalid;
+    }
+    static Rgbww value() FL_NOEXCEPT { return RgbwwInvalid(); }
+};
+
+/// Default RGBWW configuration: colorimetric mode at warm=2700K / cool=6500K,
+/// default profile, end-aligned warm-then-cool W byte placement.
+struct RgbwwDefault : public Rgbww {
+    RgbwwDefault() FL_NOEXCEPT : Rgbww() {}
+    static Rgbww value() FL_NOEXCEPT { return RgbwwDefault(); }
+};
+
+// ===== rgb_2_rgbww dispatch surface (#2558) =================================
+//
+// Mirror of the rgb_2_rgbw dispatch family. Real-path implementations run
+// when FASTLED_RGBW_COLORIMETRIC=1 is set at library build time; without it,
+// the library ships stubs that emit FL_WARN_ONCE and output zeros.
+//
+// Signature note: unlike rgb_2_rgbw_function (scalar fields), the RGBWW
+// signature takes the Rgbww config struct by-ref to carry the two CCTs and
+// the optional profile pointer in one parameter.
+
+typedef void (*rgb_2_rgbww_function)(const Rgbww& cfg,
+                                     fl::u8 r, fl::u8 g, fl::u8 b,
+                                     fl::u8 r_scale, fl::u8 g_scale, fl::u8 b_scale,
+                                     fl::u8 *out_r, fl::u8 *out_g, fl::u8 *out_b,
+                                     fl::u8 *out_ww, fl::u8 *out_wc);
+
+/// Colorimetric strict sub-gamut solver for RGBWW (gist sec 5 + sec 11-12,
+/// using solve_rgbcct from rgbw_colorimetric.h). Requires FASTLED_RGBW_COLORIMETRIC;
+/// stub emits FL_WARN_ONCE + zeros otherwise.
+void rgb_2_rgbww_colorimetric(const Rgbww& cfg,
+                              fl::u8 r, fl::u8 g, fl::u8 b,
+                              fl::u8 r_scale, fl::u8 g_scale, fl::u8 b_scale,
+                              fl::u8 *out_r, fl::u8 *out_g, fl::u8 *out_b,
+                              fl::u8 *out_ww, fl::u8 *out_wc) FL_NOEXCEPT;
+
+/// Colorimetric white-overdrive solver for RGBWW (wx_lp_legacy + RGBCCT
+/// layered blend). Requires FASTLED_RGBW_COLORIMETRIC; stub emits FL_WARN_ONCE + zeros.
+void rgb_2_rgbww_colorimetric_boosted(const Rgbww& cfg,
+                                      fl::u8 r, fl::u8 g, fl::u8 b,
+                                      fl::u8 r_scale, fl::u8 g_scale, fl::u8 b_scale,
+                                      fl::u8 *out_r, fl::u8 *out_g, fl::u8 *out_b,
+                                      fl::u8 *out_ww, fl::u8 *out_wc) FL_NOEXCEPT;
+
+/// User-installable RGB->RGBWW function. Set via set_rgb_2_rgbww_function().
+/// Falls back to all-zero output when no user function has been installed.
+void rgb_2_rgbww_user_function(const Rgbww& cfg,
+                               fl::u8 r, fl::u8 g, fl::u8 b,
+                               fl::u8 r_scale, fl::u8 g_scale, fl::u8 b_scale,
+                               fl::u8 *out_r, fl::u8 *out_g, fl::u8 *out_b,
+                               fl::u8 *out_ww, fl::u8 *out_wc) FL_NOEXCEPT;
+
+void set_rgb_2_rgbww_function(rgb_2_rgbww_function func) FL_NOEXCEPT;
+
+// ===== Default RGBCCT profile + active-profile API (Phase D of #2558) =======
+//
+// Ships the default warm-W (2700K) + cool-W (6500K) profile used when an
+// Rgbww config carries `profile = nullptr`. Users with a colorimeter or
+// custom RGBWW chipset install their own via set_rgbww_colorimetric_profile().
+// Pointer is borrowed — caller keeps the profile alive for the lifetime of
+// any controller referencing it. Pass nullptr to revert to the default.
+
+extern const colorimetric_detail::RgbcctProfile kRgbwwDefaultProfile;
+
+void set_rgbww_colorimetric_profile(const colorimetric_detail::RgbcctProfile* profile) FL_NOEXCEPT;
+const colorimetric_detail::RgbcctProfile* get_rgbww_colorimetric_profile() FL_NOEXCEPT;
+
+/// @brief Dispatch RGB->RGBWW for a given mode.
+/// Reorder a 5-channel pixel given an EOrderWW placement. The three RGB bytes
+/// (b0, b1, b2) are assumed to already be in native LED RGB order — this
+/// function only handles the warm-W / cool-W insertion. Outputs the final
+/// 5-byte stream in wire order.
+///
+/// Encoding convention (matches EOrderWW enum values):
+///   high nibble = warm-W destination index (0..4)
+///   low  nibble = cool-W destination index (0..4)
+/// The remaining three indices receive b0, b1, b2 in ascending order.
+void rgbww_partial_reorder(EOrderWW ww_placement,
+                           fl::u8 b0, fl::u8 b1, fl::u8 b2,
+                           fl::u8 ww, fl::u8 wc,
+                           fl::u8 *out_b0, fl::u8 *out_b1, fl::u8 *out_b2,
+                           fl::u8 *out_b3, fl::u8 *out_b4) FL_NOEXCEPT;
+
+FASTLED_FORCE_INLINE void
+rgb_2_rgbww(const Rgbww& cfg,
+            fl::u8 r, fl::u8 g, fl::u8 b,
+            fl::u8 r_scale, fl::u8 g_scale, fl::u8 b_scale,
+            fl::u8 *out_r, fl::u8 *out_g, fl::u8 *out_b,
+            fl::u8 *out_ww, fl::u8 *out_wc) FL_NOEXCEPT {
+    switch (cfg.rgbww_mode) {
+    case RGBWW_MODE::kRGBWWInvalid:
+        // Inactive: emit zeros across all five channels.
+        *out_r = *out_g = *out_b = *out_ww = *out_wc = 0;
+        return;
+    case RGBWW_MODE::kRGBWWColorimetric:
+        rgb_2_rgbww_colorimetric(cfg, r, g, b, r_scale, g_scale, b_scale,
+                                 out_r, out_g, out_b, out_ww, out_wc);
+        return;
+    case RGBWW_MODE::kRGBWWColorimetricBoosted:
+        rgb_2_rgbww_colorimetric_boosted(cfg, r, g, b, r_scale, g_scale, b_scale,
+                                         out_r, out_g, out_b, out_ww, out_wc);
+        return;
+    case RGBWW_MODE::kRGBWWUserFunction:
+        rgb_2_rgbww_user_function(cfg, r, g, b, r_scale, g_scale, b_scale,
+                                  out_r, out_g, out_b, out_ww, out_wc);
+        return;
+    }
+    *out_r = *out_g = *out_b = *out_ww = *out_wc = 0;
+}
+
+} // namespace fl
diff --git a/src/fl/gfx/rgbww_pixel.h b/src/fl/gfx/rgbww_pixel.h
new file mode 100644
index 00000000000..318bed8d6fe
--- /dev/null
+++ b/src/fl/gfx/rgbww_pixel.h
@@ -0,0 +1,27 @@
+#pragma once
+
+/// @file rgbww_pixel.h
+/// @brief Raw 5-channel RGBWW pixel data structure for encoders
+///
+/// Mirrors RGBWPixel; consumed by encoder adapters that produce 5-byte
+/// output for RGB + warm-white + cool-white strips. Kept separate from
+/// rgbww.h to avoid pulling EOrderWW / RGBWW_MODE into encoder TUs that
+/// only need the raw byte container.
+
+#include "fl/stl/stdint.h"
+#include "fl/stl/noexcept.h"
+
+namespace fl {
+
+/// @brief Simple 5-channel pixel struct: red, green, blue, warm-white, cool-white.
+/// @note This represents raw RGBWW pixel data (5 bytes per pixel).
+/// @note Not to be confused with `Rgbww` which is a configuration struct.
+struct RGBWWPixel {
+    u8 r, g, b, ww, wc;
+
+    RGBWWPixel() FL_NOEXCEPT : r(0), g(0), b(0), ww(0), wc(0) {}
+    RGBWWPixel(u8 _r, u8 _g, u8 _b, u8 _ww, u8 _wc) FL_NOEXCEPT
+        : r(_r), g(_g), b(_b), ww(_ww), wc(_wc) {}
+};
+
+} // namespace fl
diff --git a/src/pixel_controller.h b/src/pixel_controller.h
index 7837e60f3df..9da750da648 100644
--- a/src/pixel_controller.h
+++ b/src/pixel_controller.h
@@ -15,7 +15,9 @@
 #include "platforms/is_platform.h"
 
 #include "rgbw.h"
+#include "fl/gfx/rgbww.h"
 #include "fl/gfx/five_bit_hd_gamma.h"
+#include "fl/log/log.h"
 #include "fl/stl/compiler_control.h"
 #include "fl/stl/static_assert.h"
 #include "fl/math/scale8.h"
@@ -623,6 +625,54 @@ struct PixelController {
             rgb.raw[b2_index],
             w,  // The white component is not ordered in this call.
             b0_out, b1_out, b2_out, b3_out);  // RGBW data now in total native led order.
+#endif
+    }
+
+    /// @brief Load + scale a single pixel to 5-channel RGBWW (issue #2558).
+    /// Mirrors loadAndScaleRGBW: applies the per-channel premix scale, dispatches
+    /// to rgb_2_rgbww for the colorimetric solve, then routes the five output
+    /// bytes through rgbww_partial_reorder for the wire-order placement.
+    FASTLED_FORCE_INLINE void loadAndScaleRGBWW(fl::Rgbww rgbww,
+                                                fl::u8 *b0_out, fl::u8 *b1_out,
+                                                fl::u8 *b2_out, fl::u8 *b3_out,
+                                                fl::u8 *b4_out) {
+#ifdef FL_IS_AVR
+        // AVR: float colorimetric math is too expensive on the 8-bit core;
+        // the strip will get RGB-only output with both white channels black.
+        // Surface this with a FL_WARN_ONCE so the silent dropout is visible
+        // when debugging — a user configuring RGBWW on an AVR target almost
+        // certainly didn't expect their warm/cool W channels to be inert.
+        FL_WARN_ONCE("RGBWW colorimetric is not supported on AVR — the warm "
+                     "and cool white channels will be black. Use an ESP32 / "
+                     "Teensy / RP2040 target for full RGBWW support.");
+        fl::u8 r_pre = loadAndScale0();
+        fl::u8 g_pre = loadAndScale1();
+        fl::u8 b_pre = loadAndScale2();
+        fl::rgbww_partial_reorder(
+            rgbww.w_placement,
+            r_pre, g_pre, b_pre,
+            0, 0,
+            b0_out, b1_out, b2_out, b3_out, b4_out);
+#else
+        const fl::u8 b0_index = RGB_BYTE0(RGB_ORDER);
+        const fl::u8 b1_index = RGB_BYTE1(RGB_ORDER);
+        const fl::u8 b2_index = RGB_BYTE2(RGB_ORDER);
+        CRGB rgb(mData[0], mData[1], mData[2]);
+        fl::u8 ww = 0;
+        fl::u8 wc = 0;
+        fl::rgb_2_rgbww(rgbww,
+                        rgb.r, rgb.g, rgb.b,
+                        mColorAdjustment.premixed.r,
+                        mColorAdjustment.premixed.g,
+                        mColorAdjustment.premixed.b,
+                        &rgb.r, &rgb.g, &rgb.b, &ww, &wc);
+        fl::rgbww_partial_reorder(
+            rgbww.w_placement,
+            rgb.raw[b0_index],
+            rgb.raw[b1_index],
+            rgb.raw[b2_index],
+            ww, wc,
+            b0_out, b1_out, b2_out, b3_out, b4_out);
 #endif
     }
 };
diff --git a/src/power_mgt.h b/src/power_mgt.h
index 923cdb91667..7fe60ea8b91 100644
--- a/src/power_mgt.h
+++ b/src/power_mgt.h
@@ -118,10 +118,37 @@ struct PowerModelRGBWW {
           white_mW(w), warm_white_mW(ww), dark_mW(d),
           exponent(e) {}
 
-    /// Convert to RGB model (extracts RGB components, preserves exponent)
-    /// @note Used internally until RGBWW power calculations are implemented
+    /// Convert to RGB model (folds W/WW power back into RGB so the brightness
+    /// limiter doesn't under-budget).
+    ///
+    /// Issue #2558 update: the white-channel mW values are no longer dropped.
+    /// They're distributed evenly across the three RGB channels, which:
+    ///   - matches the brightness limiter's assumption that each RGB byte has
+    ///     a fixed mW cost at full drive (the limiter caps total predicted
+    ///     mW against a budget),
+    ///   - over-estimates rather than under-estimates when only white
+    ///     channels are on (safer for power-limited supplies),
+    ///   - accepts that a fully RGBWW-aware brightness limiter (per-channel
+    ///     accounting using all 5 mW values directly) is the right long-term
+    ///     fix — see Phase G in issue #2558.
     constexpr PowerModelRGB toRGB() const {
-        return PowerModelRGB(red_mW, green_mW, blue_mW, dark_mW, exponent);
+        // Distribute white-channel mW evenly across R/G/B. Clamp to 255 (u8
+        // max) on overflow — a wrapping static_cast<u8> here would make the
+        // brightness limiter *less* conservative on the highest-draw configs
+        // (where it most needs to be conservative). The worst-case
+        // under-budget per channel from the floor division is 2 mW.
+        return PowerModelRGB(
+            static_cast<fl::u8>(
+                (red_mW + (white_mW + warm_white_mW) / 3) > 255
+                    ? 255 : (red_mW + (white_mW + warm_white_mW) / 3)),
+            static_cast<fl::u8>(
+                (green_mW + (white_mW + warm_white_mW) / 3) > 255
+                    ? 255 : (green_mW + (white_mW + warm_white_mW) / 3)),
+            static_cast<fl::u8>(
+                (blue_mW + (white_mW + warm_white_mW) / 3) > 255
+                    ? 255 : (blue_mW + (white_mW + warm_white_mW) / 3)),
+            dark_mW,
+            exponent);
     }
 };
 


Test patch:
diff --git a/tests/fastled_core.cpp b/tests/fastled_core.cpp
index 48d9c4e3a2e..f22b67ec5cc 100644
--- a/tests/fastled_core.cpp
+++ b/tests/fastled_core.cpp
@@ -873,7 +873,9 @@ FL_TEST_CASE("Channel::applyConfig updates reconfigurable fields") {
     fl::ChannelOptions opts;
     opts.mCorrection = TypicalSMD5050;
     opts.mDitherMode = BINARY_DITHER;
-    opts.mRgbw = fl::RgbwInvalid::value();
+    // (#2558) mWhiteCfg default-constructs to fl::Empty (plain RGB), same
+    // semantics as the legacy `mRgbw = RgbwInvalid::value()` sentinel.
+    opts.mWhiteCfg.reset();
 
     fl::ChannelConfig config1(5, timing, fl::span<CRGB>(leds1, 8), GRB, opts);
     auto channel = fl::Channel::create(config1);
@@ -896,7 +898,7 @@ FL_TEST_CASE("Channel::applyConfig updates reconfigurable fields") {
     opts2.mCorrection = Typical8mmPixel;
     opts2.mTemperature = CRGB(200, 180, 160);
     opts2.mDitherMode = DISABLE_DITHER;
-    opts2.mRgbw = fl::Rgbw(fl::kRGBWDefaultColorTemp, fl::RGBW_MODE::kRGBWExactColors);
+    opts2.mWhiteCfg = fl::Rgbw(fl::kRGBWDefaultColorTemp, fl::RGBW_MODE::kRGBWExactColors);
 
     fl::ChannelConfig config2(99, timing, fl::span<CRGB>(leds2, 16), BGR, opts2);
 
diff --git a/tests/fl/gfx/rgbww.cpp b/tests/fl/gfx/rgbww.cpp
new file mode 100644
index 00000000000..6e4b3cdb049
--- /dev/null
+++ b/tests/fl/gfx/rgbww.cpp
@@ -0,0 +1,193 @@
+// ok cpp include
+// Tests for the 5-channel RGBWW dispatch + helpers (issue #2558).
+//
+// The library build does not define FASTLED_RGBW_COLORIMETRIC by default, so
+// the colorimetric dispatch here exercises the stub path (warn-once + zero
+// output). Math-correctness of solve_rgbcct itself is covered by
+// tests/fl/gfx/rgbw_colorimetric.cpp. Encoder + variant migration coverage
+// is in tests/fastled_core.cpp.
+
+#include "test.h"
+
+#include "fl/gfx/rgbww.h"
+// Need the full RgbcctProfile definition for the profile get/set test;
+// rgbww.h only forward-declares it.
+#include "fl/gfx/rgbw_colorimetric.h"
+
+using namespace fl;
+
+
+FL_TEST_CASE("Rgbww: default-constructed has sane fields") {
+    Rgbww cfg;
+    FL_CHECK(cfg.warm_cct == kRGBWWDefaultWarmCct);
+    FL_CHECK(cfg.cool_cct == kRGBWWDefaultCoolCct);
+    FL_CHECK(cfg.rgbww_mode == RGBWW_MODE::kRGBWWColorimetric);
+    FL_CHECK(cfg.w_placement == EOrderWW::WWDefault);
+    FL_CHECK(cfg.profile == nullptr);
+    FL_CHECK(cfg.active());
+}
+
+
+FL_TEST_CASE("RgbwwInvalid sentinel: inactive") {
+    Rgbww inv = RgbwwInvalid::value();
+    FL_CHECK(inv.rgbww_mode == RGBWW_MODE::kRGBWWInvalid);
+    FL_CHECK(!inv.active());
+}
+
+
+FL_TEST_CASE("rgbww_partial_reorder: WwWcEnd places RGB-then-WwWc") {
+    u8 b0, b1, b2, b3, b4;
+    rgbww_partial_reorder(EOrderWW::WwWcEnd,
+                          10, 20, 30,    // RGB bytes (already RGB-ordered)
+                          77, 88,        // warm-W, cool-W
+                          &b0, &b1, &b2, &b3, &b4);
+    FL_CHECK(b0 == 10);
+    FL_CHECK(b1 == 20);
+    FL_CHECK(b2 == 30);
+    FL_CHECK(b3 == 77);   // warm-W
+    FL_CHECK(b4 == 88);   // cool-W
+}
+
+
+FL_TEST_CASE("rgbww_partial_reorder: WcWwEnd swaps the W pair") {
+    u8 b0, b1, b2, b3, b4;
+    rgbww_partial_reorder(EOrderWW::WcWwEnd,
+                          10, 20, 30,
+                          77, 88,
+                          &b0, &b1, &b2, &b3, &b4);
+    FL_CHECK(b0 == 10);
+    FL_CHECK(b1 == 20);
+    FL_CHECK(b2 == 30);
+    FL_CHECK(b3 == 88);   // cool-W first
+    FL_CHECK(b4 == 77);   // warm-W second
+}
+
+
+FL_TEST_CASE("rgbww_partial_reorder: WwWcStart places W pair before RGB") {
+    u8 b0, b1, b2, b3, b4;
+    rgbww_partial_reorder(EOrderWW::WwWcStart,
+                          10, 20, 30,
+                          77, 88,
+                          &b0, &b1, &b2, &b3, &b4);
+    FL_CHECK(b0 == 77);   // warm-W
+    FL_CHECK(b1 == 88);   // cool-W
+    FL_CHECK(b2 == 10);
+    FL_CHECK(b3 == 20);
+    FL_CHECK(b4 == 30);
+}
+
+
+FL_TEST_CASE("rgbww_partial_reorder: WcWwStart") {
+    u8 b0, b1, b2, b3, b4;
+    rgbww_partial_reorder(EOrderWW::WcWwStart,
+                          10, 20, 30,
+                          77, 88,
+                          &b0, &b1, &b2, &b3, &b4);
+    FL_CHECK(b0 == 88);
+    FL_CHECK(b1 == 77);
+    FL_CHECK(b2 == 10);
+    FL_CHECK(b3 == 20);
+    FL_CHECK(b4 == 30);
+}
+
+
+FL_TEST_CASE("rgb_2_rgbww dispatch: kRGBWWInvalid emits zeros") {
+    Rgbww cfg(2700, 6500, RGBWW_MODE::kRGBWWInvalid, EOrderWW::WwWcEnd);
+    u8 r_out = 99, g_out = 99, b_out = 99, ww_out = 99, wc_out = 99;
+    rgb_2_rgbww(cfg, 200, 100, 50, 255, 255, 255,
+                &r_out, &g_out, &b_out, &ww_out, &wc_out);
+    FL_CHECK(r_out == 0);
+    FL_CHECK(g_out == 0);
+    FL_CHECK(b_out == 0);
+    FL_CHECK(ww_out == 0);
+    FL_CHECK(wc_out == 0);
+}
+
+
+FL_TEST_CASE("rgb_2_rgbww dispatch: colorimetric paths link-safe") {
+    // Without FASTLED_RGBW_COLORIMETRIC the colorimetric dispatch outputs
+    // zeros + emits warn-once; with it on, the real solve_rgbcct runs.
+    // Either way the call must link, return safely, and produce valid bytes.
+    Rgbww cfg;
+    u8 r_out, g_out, b_out, ww_out, wc_out;
+    rgb_2_rgbww(cfg, 180, 180, 200, 255, 255, 255,
+                &r_out, &g_out, &b_out, &ww_out, &wc_out);
+    FL_CHECK(r_out <= 255);
+    FL_CHECK(g_out <= 255);
+    FL_CHECK(b_out <= 255);
+    FL_CHECK(ww_out <= 255);
+    FL_CHECK(wc_out <= 255);
+
+    Rgbww boosted = cfg;
+    boosted.rgbww_mode = RGBWW_MODE::kRGBWWColorimetricBoosted;
+    rgb_2_rgbww(boosted, 180, 180, 200, 255, 255, 255,
+                &r_out, &g_out, &b_out, &ww_out, &wc_out);
+    FL_CHECK(r_out <= 255);
+    FL_CHECK(g_out <= 255);
+    FL_CHECK(b_out <= 255);
+    FL_CHECK(ww_out <= 255);
+    FL_CHECK(wc_out <= 255);
+}
+
+
+FL_TEST_CASE("rgb_2_rgbww: user function path returns zero when nothing installed") {
+    Rgbww cfg(2700, 6500, RGBWW_MODE::kRGBWWUserFunction, EOrderWW::WwWcEnd);
+    u8 r_out = 99, g_out = 99, b_out = 99, ww_out = 99, wc_out = 99;
+    rgb_2_rgbww(cfg, 200, 100, 50, 255, 255, 255,
+                &r_out, &g_out, &b_out, &ww_out, &wc_out);
+    FL_CHECK(r_out == 0);
+    FL_CHECK(g_out == 0);
+    FL_CHECK(b_out == 0);
+    FL_CHECK(ww_out == 0);
+    FL_CHECK(wc_out == 0);
+}
+
+
+FL_TEST_CASE("rgb_2_rgbww: user function path dispatches to installed callback") {
+    static bool called = false;
+    static u8 captured_r = 0, captured_g = 0, captured_b = 0;
+    auto user_fn = [](const Rgbww& /*cfg*/,
+                      u8 r, u8 g, u8 b,
+                      u8 /*r_scale*/, u8 /*g_scale*/, u8 /*b_scale*/,
+                      u8* out_r, u8* out_g, u8* out_b,
+                      u8* out_ww, u8* out_wc) FL_NOEXCEPT {
+        called = true;
+        captured_r = r; captured_g = g; captured_b = b;
+        *out_r = 1; *out_g = 2; *out_b = 3; *out_ww = 4; *out_wc = 5;
+    };
+    set_rgb_2_rgbww_function(user_fn);
+
+    Rgbww cfg(2700, 6500, RGBWW_MODE::kRGBWWUserFunction, EOrderWW::WwWcEnd);
+    u8 r_out, g_out, b_out, ww_out, wc_out;
+    rgb_2_rgbww(cfg, 200, 100, 50, 255, 255, 255,
+                &r_out, &g_out, &b_out, &ww_out, &wc_out);
+    FL_CHECK(called);
+    FL_CHECK(captured_r == 200);
+    FL_CHECK(captured_g == 100);
+    FL_CHECK(captured_b == 50);
+    FL_CHECK(r_out == 1);
+    FL_CHECK(g_out == 2);
+    FL_CHECK(b_out == 3);
+    FL_CHECK(ww_out == 4);
+    FL_CHECK(wc_out == 5);
+
+    // Restore default state so subsequent tests aren't affected.
+    set_rgb_2_rgbww_function(nullptr);
+}
+
+
+FL_TEST_CASE("rgbww colorimetric profile get/set API") {
+    const colorimetric_detail::RgbcctProfile* before = get_rgbww_colorimetric_profile();
+    FL_CHECK(before != nullptr);
+    FL_CHECK(before == &kRgbwwDefaultProfile);
+
+    colorimetric_detail::RgbcctProfile custom = kRgbwwDefaultProfile;
+    custom.warm_path.lum_w = 0.5f;  // arbitrary distinguishing value
+    set_rgbww_colorimetric_profile(&custom);
+    const colorimetric_detail::RgbcctProfile* after = get_rgbww_colorimetric_profile();
+    FL_CHECK(after == &custom);
+    FL_CHECK_CLOSE(after->warm_path.lum_w, 0.5f, 1e-6f);
+
+    set_rgbww_colorimetric_profile(nullptr);
+    FL_CHECK(get_rgbww_colorimetric_profile() == &kRgbwwDefaultProfile);
+}
diff --git a/tests/power_mgt.cpp b/tests/power_mgt.cpp
index ea8561230ef..6e890279055 100644
--- a/tests/power_mgt.cpp
+++ b/tests/power_mgt.cpp
@@ -70,14 +70,17 @@ FL_TEST_CASE("PowerModelRGBW - toRGB conversion") {
     FL_CHECK(rgb.dark_mW == 5);
 }
 
-FL_TEST_CASE("PowerModelRGBWW - toRGB conversion") {
+FL_TEST_CASE("PowerModelRGBWW - toRGB conversion folds in W/WW power (#2558 Phase F)") {
     PowerModelRGBWW rgbww(85, 65, 85, 95, 95, 5);
     PowerModelRGB rgb = rgbww.toRGB();
 
-    // Only RGB + dark should be extracted
-    FL_CHECK(rgb.red_mW == 85);
-    FL_CHECK(rgb.green_mW == 65);
-    FL_CHECK(rgb.blue_mW == 85);
+    // (#2558) toRGB() now distributes the white-channel mW evenly across the
+    // three RGB channels so the brightness limiter doesn't under-budget when
+    // RGBWW strips are in use. Bonus per RGB channel = (white_mW + warm_white_mW) / 3.
+    const fl::u8 share = (95 + 95) / 3;  // 63
+    FL_CHECK(rgb.red_mW == 85 + share);
+    FL_CHECK(rgb.green_mW == 65 + share);
+    FL_CHECK(rgb.blue_mW == 85 + share);
     FL_CHECK(rgb.dark_mW == 5);
 }
 
@@ -104,15 +107,17 @@ FL_TEST_CASE("set_power_model - RGBW extracts RGB") {
     FL_CHECK(retrieved.dark_mW == 5);
 }
 
-FL_TEST_CASE("set_power_model - RGBWW extracts RGB") {
+FL_TEST_CASE("set_power_model - RGBWW folds in W/WW power (#2558 Phase F)") {
     PowerModelRGBWW rgbww(85, 65, 85, 95, 95, 5);
     set_power_model(rgbww);
 
-    // Should extract only RGB components
+    // (#2558) Mirror the new PowerModelRGBWW::toRGB() contract: white-channel
+    // power is distributed evenly across RGB rather than dropped.
+    const fl::u8 share = (95 + 95) / 3;
     PowerModelRGB retrieved = get_power_model();
-    FL_CHECK(retrieved.red_mW == 85);
-    FL_CHECK(retrieved.green_mW == 65);
-    FL_CHECK(retrieved.blue_mW == 85);
+    FL_CHECK(retrieved.red_mW == 85 + share);
+    FL_CHECK(retrieved.green_mW == 65 + share);
+    FL_CHECK(retrieved.blue_mW == 85 + share);
     FL_CHECK(retrieved.dark_mW == 5);
 }