[light] Add LightState::set_external_state() for outputs that mirror a light elsewhere (#20787)

This commit is contained in:
J. Nick Koston
2026-10-18 07:18:47 -10:00
committed by GitHub
parent a801e414a0
commit b2d6c78499
3 changed files with 207 additions and 0 deletions
+23
View File
@@ -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_) {
+5
View File
@@ -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
@@ -0,0 +1,179 @@
#include <gtest/gtest.h>
#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