diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 2854bc62d9..2ce152d1fe 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -24,46 +24,118 @@ template class ToggleAction : public Action { LightState *state_; }; +/** Compact light control action using per-field union storage. + * + * Each field stores either a constant value or a function pointer in the same + * word. A 2-bit-per-field type tag in a uint32_t tracks whether each field is + * unset, a constant, or a lambda (stateless function pointer — ESPHome codegen + * always produces empty-capture lambdas). + * + * Size: ~76 bytes on ESP32 (vs ~128 bytes with TemplatableValue per field). + * No per-field heap allocation. + */ template class LightControlAction : public Action { public: explicit LightControlAction(LightState *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(ColorMode, color_mode) - TEMPLATABLE_VALUE(bool, state) - TEMPLATABLE_VALUE(uint32_t, transition_length) - TEMPLATABLE_VALUE(uint32_t, flash_length) - TEMPLATABLE_VALUE(float, brightness) - TEMPLATABLE_VALUE(float, color_brightness) - TEMPLATABLE_VALUE(float, red) - TEMPLATABLE_VALUE(float, green) - TEMPLATABLE_VALUE(float, blue) - TEMPLATABLE_VALUE(float, white) - TEMPLATABLE_VALUE(float, color_temperature) - TEMPLATABLE_VALUE(float, cold_white) - TEMPLATABLE_VALUE(float, warm_white) - TEMPLATABLE_VALUE(uint32_t, effect) +// clang-format off +// Single field list — drives enum, setters, and play(). +#define LIGHT_CONTROL_FIELDS(X) \ + X(ColorMode, color_mode) \ + X(bool, state) \ + X(uint32_t, transition_length)\ + X(uint32_t, flash_length) \ + X(float, brightness) \ + X(float, color_brightness) \ + X(float, red) \ + X(float, green) \ + X(float, blue) \ + X(float, white) \ + X(float, color_temperature) \ + X(float, cold_white) \ + X(float, warm_white) \ + X(uint32_t, effect) + +#define LIGHT_CONTROL_SETTER_(type, name) \ + void set_##name(type v) { this->set_constant_(FIELD_##name, v); } \ + template void set_##name(F f) requires std::invocable { \ + this->template set_lambda_(FIELD_##name, std::forward(f)); \ + } + +#define APPLY_LIGHT_FIELD_(type, name) \ + if (auto t = this->get_field_type_(FIELD_##name); t) \ + call.set_##name(this->get_value_(FIELD_##name, t, x...)); + // clang-format on + + protected: + enum FieldIndex : uint8_t { +#define FIELD_ENUM_(type, name) FIELD_##name, + LIGHT_CONTROL_FIELDS(FIELD_ENUM_) +#undef FIELD_ENUM_ + NUM_FIELDS + }; + + /// 2-bit field type encoded in field_types_ + enum FieldType : uint8_t { TYPE_NONE = 0, TYPE_CONSTANT = 1, TYPE_LAMBDA = 2 }; + + /// Per-field storage: constant value or function pointer bytes in the same word. + /// All access is via memcpy to avoid type-punning and void*-to-function-pointer UB. + union FieldValue { + uint8_t raw[sizeof(void *)]; + }; + + FieldType get_field_type_(uint8_t field) const { + return static_cast((this->field_types_ >> (field * 2)) & 0x3); + } + + void set_field_type_(uint8_t field, FieldType type) { + uint32_t shift = field * 2; + this->field_types_ = (this->field_types_ & ~(0x3u << shift)) | (static_cast(type) << shift); + } + + template void set_constant_(uint8_t field, T value) { + static_assert(sizeof(T) <= sizeof(FieldValue)); + this->set_field_type_(field, TYPE_CONSTANT); + memcpy(&this->values_[field], &value, sizeof(T)); + } + + template void set_lambda_(uint8_t field, F f) { + // ESPHome codegen always produces stateless lambdas (empty capture list), + // which are convertible to function pointers. + static_assert(std::convertible_to, "LightControlAction only supports stateless lambdas"); + static_assert(sizeof(T(*)(Ts...)) <= sizeof(FieldValue)); + this->set_field_type_(field, TYPE_LAMBDA); + auto fn = static_cast(f); + memcpy(&this->values_[field], &fn, sizeof(fn)); + } + + template T get_value_(uint8_t field, FieldType type, const Ts &...x) const { + if (type == TYPE_CONSTANT) { + T value; + memcpy(&value, &this->values_[field], sizeof(T)); + return value; + } + // TYPE_LAMBDA — function pointer stored via memcpy + T (*fn)(Ts...); + memcpy(&fn, &this->values_[field], sizeof(fn)); + return fn(x...); + } + + LightState *parent_; + uint32_t field_types_{0}; ///< 2 bits per field + FieldValue values_[NUM_FIELDS]{}; + + public: + LIGHT_CONTROL_FIELDS(LIGHT_CONTROL_SETTER_) +#undef LIGHT_CONTROL_SETTER_ void play(const Ts &...x) override { auto call = this->parent_->make_call(); - call.set_color_mode(this->color_mode_.optional_value(x...)); - call.set_state(this->state_.optional_value(x...)); - call.set_brightness(this->brightness_.optional_value(x...)); - call.set_color_brightness(this->color_brightness_.optional_value(x...)); - call.set_red(this->red_.optional_value(x...)); - call.set_green(this->green_.optional_value(x...)); - call.set_blue(this->blue_.optional_value(x...)); - call.set_white(this->white_.optional_value(x...)); - call.set_color_temperature(this->color_temperature_.optional_value(x...)); - call.set_cold_white(this->cold_white_.optional_value(x...)); - call.set_warm_white(this->warm_white_.optional_value(x...)); - call.set_effect(this->effect_.optional_value(x...)); - call.set_flash_length(this->flash_length_.optional_value(x...)); - call.set_transition_length(this->transition_length_.optional_value(x...)); + LIGHT_CONTROL_FIELDS(APPLY_LIGHT_FIELD_) call.perform(); } - - protected: - LightState *parent_; +#undef APPLY_LIGHT_FIELD_ +#undef LIGHT_CONTROL_FIELDS }; template class DimRelativeAction : public Action { diff --git a/tests/integration/fixtures/light_control_action.yaml b/tests/integration/fixtures/light_control_action.yaml new file mode 100644 index 0000000000..66f0cf1873 --- /dev/null +++ b/tests/integration/fixtures/light_control_action.yaml @@ -0,0 +1,139 @@ +esphome: + name: light-control-action-test +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +globals: + - id: test_brightness + type: float + initial_value: "0.75" + +output: + - platform: template + id: test_red + type: float + write_action: + - lambda: "" + - platform: template + id: test_green + type: float + write_action: + - lambda: "" + - platform: template + id: test_blue + type: float + write_action: + - lambda: "" + - platform: template + id: test_cold_white + type: float + write_action: + - lambda: "" + - platform: template + id: test_warm_white + type: float + write_action: + - lambda: "" + +light: + - platform: rgbww + name: "Test Light" + id: test_light + red: test_red + green: test_green + blue: test_blue + cold_white: test_cold_white + warm_white: test_warm_white + cold_white_color_temperature: 6536 K + warm_white_color_temperature: 2000 K + effects: + - random: + name: "Test Effect" + transition_length: 10ms + update_interval: 10ms + +button: + # Test 1: light.turn_on with RGB constants + - platform: template + id: btn_turn_on_rgb + name: "Turn On RGB" + on_press: + - light.turn_on: + id: test_light + brightness: 1.0 + red: 0.0 + green: 0.0 + blue: 1.0 + + # Test 2: light.turn_off + - platform: template + id: btn_turn_off + name: "Turn Off" + on_press: + - light.turn_off: + id: test_light + + # Test 3: light.turn_on with color_temperature + - platform: template + id: btn_turn_on_ct + name: "Turn On CT" + on_press: + - light.turn_on: + id: test_light + color_temperature: 4000 K + brightness: 0.8 + + # Test 4: light.turn_on with effect + - platform: template + id: btn_turn_on_effect + name: "Turn On Effect" + on_press: + - light.turn_on: + id: test_light + effect: "Test Effect" + + # Test 5: light.turn_on with effect none to clear it + - platform: template + id: btn_clear_effect + name: "Clear Effect" + on_press: + - light.turn_on: + id: test_light + effect: "None" + + # Test 6: light.control with cold/warm white + - platform: template + id: btn_control_cw + name: "Control CW" + on_press: + - light.control: + id: test_light + cold_white: 0.9 + warm_white: 0.1 + + # Test 7: light.turn_on with lambda brightness (tests lambda path) + - platform: template + id: btn_lambda_brightness + name: "Lambda Brightness" + on_press: + - light.turn_on: + id: test_light + brightness: !lambda "return id(test_brightness);" + red: 1.0 + green: 0.0 + blue: 0.0 + + # Test 8: light.turn_on with transition_length + - platform: template + id: btn_turn_on_transition + name: "Turn On Transition" + on_press: + - light.turn_on: + id: test_light + brightness: 0.5 + transition_length: 0s + red: 0.5 + green: 0.5 + blue: 0.0 diff --git a/tests/integration/test_light_control_action.py b/tests/integration/test_light_control_action.py new file mode 100644 index 0000000000..9a5c16a04d --- /dev/null +++ b/tests/integration/test_light_control_action.py @@ -0,0 +1,95 @@ +"""Integration test for LightControlAction. + +Tests that light.turn_on, light.turn_off, and light.control automation actions +work correctly with the compact per-field union storage. Exercises both constant +value and lambda paths. +""" + +import asyncio +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LightControlAction with constants and lambdas.""" + async with run_compiled(yaml_config), api_client_connected() as client: + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + client.subscribe_states(on_state) + + # Get entities + entities = await client.list_entities_services() + light = next(e for e in entities[0] if e.object_id == "test_light") + buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} + + async def wait_for_state(key: int, timeout: float = 5.0) -> Any: + """Wait for a state change for the given entity key.""" + loop = asyncio.get_running_loop() + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + async def press_and_wait(button_name: str) -> Any: + """Press a button and wait for light state change.""" + btn = buttons[button_name] + client.button_command(btn.key) + return await wait_for_state(light.key) + + # Test 1: light.turn_on with RGB constants + state = await press_and_wait("Turn On RGB") + assert state.state is True + assert state.brightness == pytest.approx(1.0) + assert state.red == pytest.approx(0.0, abs=0.01) + assert state.green == pytest.approx(0.0, abs=0.01) + assert state.blue == pytest.approx(1.0, abs=0.01) + + # Test 2: light.turn_off + state = await press_and_wait("Turn Off") + assert state.state is False + + # Test 3: light.turn_on with color_temperature + state = await press_and_wait("Turn On CT") + assert state.state is True + assert state.brightness == pytest.approx(0.8) + assert state.color_temperature == pytest.approx(250.0) # 4000K = 250 mireds + + # Test 4: light.turn_on with effect + state = await press_and_wait("Turn On Effect") + assert state.effect == "Test Effect" + + # Test 5: Clear effect + state = await press_and_wait("Clear Effect") + assert state.effect == "None" + + # Test 6: light.control with cold/warm white + state = await press_and_wait("Control CW") + assert state.cold_white == pytest.approx(0.9, abs=0.1) + assert state.warm_white == pytest.approx(0.1, abs=0.1) + + # Test 7: light.turn_on with lambda brightness + # The global test_brightness is 0.75 + state = await press_and_wait("Lambda Brightness") + assert state.state is True + assert state.brightness == pytest.approx(0.75, abs=0.05) + assert state.red == pytest.approx(1.0, abs=0.01) + assert state.green == pytest.approx(0.0, abs=0.01) + assert state.blue == pytest.approx(0.0, abs=0.01) + + # Test 8: light.turn_on with transition_length and brightness + state = await press_and_wait("Turn On Transition") + assert state.state is True + assert state.brightness == pytest.approx(0.5)