diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 885440187f9..d8293338897 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -181,6 +181,29 @@ void LightState::loop() { } } +bool LightState::set_external_state(const LightColorValues &values) { + // Either would write its own values over the reported ones + if (this->next_write_ || this->transformer_ != nullptr) { + return false; + } + const bool had_effect = this->active_effect_index_ != 0; + this->stop_effect_(); // schedules no write, unlike a call that sets effect 0 + if (!had_effect && values == this->remote_values && values == this->current_values) { + return true; // nothing new to tell + } + this->current_values = values; + this->remote_values = values; + // Reached at once, as after an instant call, and told before the publish like there: an + // automation run by the publish may change the light again, and its own call reports that + if (this->target_state_reached_listeners_) { + for (auto *listener : *this->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } + } + this->publish_state(); + return true; +} + void LightState::publish_state() { if (this->remote_values_listeners_) { for (auto *listener : *this->remote_values_listeners_) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 2a99943e1df..946b4efae58 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -92,6 +92,11 @@ class LightState : public EntityBase, public Component { bool is_transitioning() const { return this->transformer_ != nullptr; } LightCall make_call(); + /// Take over the state of a light mirrored from elsewhere: current and target values at once, + /// published but not written or saved; a running effect stops. False while a write is pending or + /// a transition or flash runs. + bool set_external_state(const LightColorValues &values); + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Load state from preferences diff --git a/tests/components/light/test_light_set_external_state.cpp b/tests/components/light/test_light_set_external_state.cpp new file mode 100644 index 00000000000..624d374519a --- /dev/null +++ b/tests/components/light/test_light_set_external_state.cpp @@ -0,0 +1,179 @@ +#include + +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +// Counts the writes, like an output that mirrors a light elsewhere and sends each one on +class CountingOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + void write_state(LightState *state) override { this->writes++; } + int writes{0}; +}; + +class NoopEffect : public LightEffect { + public: + using LightEffect::LightEffect; + void apply() override {} +}; + +class ReachedListener : public LightTargetStateReachedListener { + public: + void on_light_target_state_reached() override { this->reached++; } + int reached{0}; +}; + +class PublishListener : public LightRemoteValuesListener { + public: + void on_light_remote_values_update() override { this->published++; } + int published{0}; +}; + +// Counts the target-reached calls that find the light off, as the on_turn_off trigger does +class OffReachedListener : public LightTargetStateReachedListener { + public: + explicit OffReachedListener(LightState *state) : state_(state) {} + void on_light_target_state_reached() override { this->reached_off += !this->state_->current_values.is_on(); } + int reached_off{0}; + + protected: + LightState *state_; +}; + +// An automation that turns the light off as soon as it is reported on +class TurnOffOnPublish : public LightRemoteValuesListener { + public: + explicit TurnOffOnPublish(LightState *state) : state_(state) {} + void on_light_remote_values_update() override { + if (this->state_->remote_values.is_on()) { + this->state_->make_call().set_state(false).perform(); + } + } + + protected: + LightState *state_; +}; + +LightColorValues on_at(float brightness) { + LightColorValues values; + values.set_color_mode(ColorMode::BRIGHTNESS); + values.set_state(true); + values.set_brightness(brightness); + return values; +} + +} // namespace + +TEST(LightStateExternal, TakesTheValuesWithoutAWrite) { + CountingOutput output; + LightState state(&output); + ReachedListener listener; + state.add_target_state_reached_listener(&listener); + state.loop(); // the initial write of a fresh state + const int writes = output.writes; + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(state.remote_values, on_at(0.5f)); + EXPECT_EQ(state.current_values, on_at(0.5f)); + EXPECT_EQ(listener.reached, 1); + state.loop(); + EXPECT_EQ(output.writes, writes); +} + +TEST(LightStateExternal, RefusesWhileALocalChangeWaitsToBeWritten) { + CountingOutput output; + LightState state(&output); + state.loop(); + + state.make_call().set_state(true).set_brightness(0.8f).perform(); + EXPECT_FALSE(state.set_external_state(on_at(0.5f))); + EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 0.8f); + state.loop(); // the local change is written + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); +} + +TEST(LightStateExternal, RefusesDuringATransition) { + CountingOutput output; + LightState state(&output); + state.loop(); + + state.make_call().set_state(true).set_brightness(1.0f).set_transition_length(1000).perform(); + state.loop(); + EXPECT_TRUE(state.is_transitioning()); + EXPECT_FALSE(state.set_external_state(on_at(0.5f))); +} + +TEST(LightStateExternal, StopsARunningEffect) { + CountingOutput output; + LightState state(&output); + NoopEffect effect("Noop"); + LightEffect *effects[] = {&effect}; + state.add_effects(effects, 1); + state.loop(); + + state.make_call().set_state(true).set_effect("Noop").perform(); + state.loop(); + ASSERT_EQ(state.get_current_effect_index(), 1u); + const auto writes = output.writes; + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(state.get_current_effect_index(), 0u); + EXPECT_EQ(state.remote_values, on_at(0.5f)); + state.loop(); + EXPECT_EQ(output.writes, writes); // the stopped effect schedules no write either +} + +TEST(LightStateExternal, PublishesWhenOnlyAnEffectStops) { + CountingOutput output; + LightState state(&output); + NoopEffect effect("Noop"); + LightEffect *effects[] = {&effect}; + state.add_effects(effects, 1); + PublishListener listener; + state.add_remote_values_listener(&listener); + state.loop(); + + state.make_call().set_state(true).set_effect("Noop").perform(); + state.loop(); + const int published = listener.published; + // The values stay, but the effect name clients show has to go + EXPECT_TRUE(state.set_external_state(state.remote_values)); + EXPECT_EQ(state.get_current_effect_index(), 0u); + EXPECT_EQ(listener.published, published + 1); +} + +TEST(LightStateExternal, AnAutomationRunByThePublishReportsItsOwnCall) { + CountingOutput output; + LightState state(&output); + OffReachedListener reached(&state); + state.add_target_state_reached_listener(&reached); + TurnOffOnPublish automation(&state); + state.add_remote_values_listener(&automation); + state.loop(); + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_FALSE(state.remote_values.is_on()); + EXPECT_EQ(reached.reached_off, 1); // once from the turn-off call, not again from the report +} + +TEST(LightStateExternal, SaysNothingWhenNothingChanges) { + CountingOutput output; + LightState state(&output); + ReachedListener listener; + state.add_target_state_reached_listener(&listener); + state.loop(); + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(listener.reached, 1); +} + +} // namespace esphome::light::testing