From 3d093c0ae814a38187976e398ee6270770fe5f9b Mon Sep 17 00:00:00 2001 From: Pete Keen Date: Wed, 5 Aug 2026 07:57:59 -0400 Subject: [PATCH] [esp32_rmt_led_strip] Add RGBW channel ordering (#18028) --- .../esp32_rmt_led_strip/led_strip.cpp | 23 ++++++-- .../esp32_rmt_led_strip/led_strip.h | 7 +++ .../components/esp32_rmt_led_strip/light.py | 38 +++++++++++-- .../esp32_rmt_led_strip/common.yaml | 2 +- .../components/test_esp32_rmt_led_strip.py | 57 +++++++++++++++++++ 5 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/components/test_esp32_rmt_led_strip.py diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index ed2a8c5a68..95391ef100 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -255,11 +255,11 @@ light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index break; } uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; + uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, + return {this->buf_ + (index * multiplier) + r + (white <= r), + this->buf_ + (index * multiplier) + g + (white <= g), + this->buf_ + (index * multiplier) + b + (white <= b), this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, &this->effect_data_[index], &this->correction_}; @@ -295,11 +295,22 @@ void ESP32RMTLEDStripLightOutput::dump_config() { rgb_order = "UNKNOWN"; break; } + if (this->is_rgbw_ || this->is_wrgb_) { + char rgbw_order[5]; + uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; + uint8_t rgb_index = 0; + for (uint8_t i = 0; i < 4; i++) { + rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++]; + } + rgbw_order[4] = '\0'; + ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order); + } else { + ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order); + } ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index d7ba2aafbf..3e31309bff 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -52,6 +52,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_rgbw_order(uint8_t white_index) { + this->is_rgbw_ = true; + this->is_wrgb_ = false; + this->white_index_ = white_index; + } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -91,6 +96,8 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { uint16_t num_leds_; bool is_rgbw_{false}; bool is_wrgb_{false}; + // An index after the RGB channels makes offset adjustment a no-op for three-channel strips. + uint8_t white_index_{3}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 1c6943b003..2722a9b656 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -20,6 +20,7 @@ from esphome.const import ( CONF_RMT_SYMBOLS, CONF_USE_DMA, ) +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -62,6 +63,7 @@ CHIPSETS = { } CONF_IS_WRGB = "is_wrgb" +CONF_RGBW_ORDER = "rgbw_order" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -70,6 +72,26 @@ CONF_RESET_HIGH = "reset_high" CONF_RESET_LOW = "reset_low" +def _validate_rgbw_order(value: str) -> str: + value = cv.string(value).upper() + if len(value) != 4 or set(value) != set("RGBW"): + raise cv.Invalid("RGBW order must be a permutation of RGBW") + return value + + +def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]: + return rgbw_order.replace("W", ""), rgbw_order.index("W") + + +def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType: + if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]): + raise cv.Invalid( + f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or " + f"'{CONF_IS_WRGB}'" + ) + return config + + CONFIG_SCHEMA = cv.All( esp32.only_on_variant( unsupported=list(esp32_rmt.VARIANTS_NO_RMT), @@ -80,7 +102,8 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -130,6 +153,8 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER), + _validate_rgbw_order_exclusivity, ) @@ -173,9 +198,14 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None: + rgb_order, white_index = _split_rgbw_order(rgbw_order) + cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order])) + cg.add(var.set_rgbw_order(white_index)) + else: + cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) + cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index f3ee86bcce..701e513ebd 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -9,7 +9,7 @@ light: id: led_strip2 pin: ${pin2} num_leds: 60 - rgb_order: RGB + rgbw_order: RWGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/unit_tests/components/test_esp32_rmt_led_strip.py b/tests/unit_tests/components/test_esp32_rmt_led_strip.py new file mode 100644 index 0000000000..e2cb513e3b --- /dev/null +++ b/tests/unit_tests/components/test_esp32_rmt_led_strip.py @@ -0,0 +1,57 @@ +import pytest + +from esphome.components.esp32_rmt_led_strip.light import ( + CONF_IS_WRGB, + CONF_RGBW_ORDER, + _split_rgbw_order, + _validate_rgbw_order, + _validate_rgbw_order_exclusivity, +) +import esphome.config_validation as cv +from esphome.const import CONF_IS_RGBW + + +def test_validate_rgbw_order() -> None: + assert _validate_rgbw_order("rwgb") == "RWGB" + + +@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"]) +def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None: + with pytest.raises(cv.Invalid, match="permutation of RGBW"): + _validate_rgbw_order(rgbw_order) + + +@pytest.mark.parametrize( + ("rgbw_order", "expected"), + [ + ("WRGB", ("RGB", 0)), + ("RWGB", ("RGB", 1)), + ("GWRB", ("GRB", 1)), + ("RGBW", ("RGB", 3)), + ], +) +def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None: + assert _split_rgbw_order(rgbw_order) == expected + + +@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB]) +def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None: + with pytest.raises(cv.Invalid, match="cannot be used with"): + _validate_rgbw_order_exclusivity( + { + CONF_RGBW_ORDER: "RGBW", + CONF_IS_RGBW: conflict == CONF_IS_RGBW, + CONF_IS_WRGB: conflict == CONF_IS_WRGB, + } + ) + + +@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB]) +def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None: + config = { + CONF_RGBW_ORDER: "RGBW", + CONF_IS_RGBW: False, + CONF_IS_WRGB: False, + } + config[legacy_option] = False + assert _validate_rgbw_order_exclusivity(config) is config