[homeassistant] Add the light platform (#11354)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Egor Vorontsov
2026-10-18 10:02:31 -10:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] J. Nick Koston J. Nick Koston
parent e54d66e620
commit dd456ea0b2
13 changed files with 1109 additions and 3 deletions
+1
View File
@@ -279,6 +279,7 @@ esphome/components/hmac_sha256/* @dwmw2
esphome/components/hoermann_hcp/* @zweckj
esphome/components/homeassistant/* @esphome/core @OttoWinter
esphome/components/homeassistant/button/* @jesserockz
esphome/components/homeassistant/light/* @egormanga
esphome/components/homeassistant/number/* @landonr
esphome/components/homeassistant/select/* @jesserockz
esphome/components/homeassistant/switch/* @Links2004
+9 -1
View File
@@ -208,6 +208,7 @@ WIZARD_SCHEMA = cv.All(
WIZARD_INPUT_DOMAINS = (
"binary_sensor",
"button",
"light",
"number",
"select",
"sensor",
@@ -216,7 +217,14 @@ WIZARD_INPUT_DOMAINS = (
"text_sensor",
)
# Platforms that act on one family of Home Assistant domains, which the input's filters must stay within
WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text")
WIZARD_DOMAIN_LIMITED_PLATFORMS = (
"button",
"light",
"number",
"select",
"switch",
"text",
)
def wizard_input_ids(api_config: ConfigType) -> set[str]:
@@ -0,0 +1,11 @@
#include "homeassistant.h"
#include "esphome/core/progmem.h"
namespace esphome::homeassistant {
PROGMEM_STRING_TABLE(UnsetValues, "None", "unavailable", "unknown");
bool is_unset(StringRef value) { return progmem_string_table_find<UnsetValues>(value.c_str(), value.size()) >= 0; }
} // namespace esphome::homeassistant
@@ -7,6 +7,10 @@
namespace esphome::homeassistant {
/// True for what Home Assistant reports when there is no value: "None" for an attribute that is not
/// set, "unavailable" and "unknown" for the state of an entity that is not ready
bool is_unset(StringRef value);
/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the item early.
template<typename C> bool emit_utf8(uint32_t code_point, C &on_char) {
if (code_point == 0 || code_point > 0x10FFFF)
@@ -0,0 +1,81 @@
import esphome.codegen as cg
from esphome.components import light
import esphome.config_validation as cv
from esphome.const import (
CONF_DEFAULT_TRANSITION_LENGTH,
CONF_EFFECTS,
CONF_FLASH_TRANSITION_LENGTH,
CONF_GAMMA_CORRECT,
CONF_INITIAL_STATE,
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
)
from esphome.core import TimePeriod
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
validate_entity_domain,
)
CODEOWNERS = ["@egormanga"]
DEPENDENCIES = ["api"]
AUTO_LOAD = ["homeassistant"]
CONF_REPORT_TIMEOUT = "report_timeout"
SUPPORTED_DOMAINS = ["light"]
HomeassistantLight = homeassistant_ns.class_(
"HomeassistantLight", light.LightOutput, cg.Component
)
_RENDERED_BY_HOME_ASSISTANT = cv.invalid(
"Home Assistant renders this light and keeps its state; this option has no effect here"
)
CONFIG_SCHEMA = cv.All(
light.light_schema(HomeassistantLight, light.LightType.RGB)
.extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA)
.extend(
{
# A transition would be stepped over the API; the target is sent once instead
cv.Optional(
CONF_DEFAULT_TRANSITION_LENGTH, default="0s"
): cv.positive_time_period_milliseconds,
# Stored in a byte of seconds; a light polled by Home Assistant can take a minute
cv.Optional(CONF_REPORT_TIMEOUT, default="10s"): cv.All(
cv.positive_time_period_seconds,
cv.Range(min=TimePeriod(seconds=1), max=TimePeriod(seconds=255)),
),
**{
cv.Optional(key): _RENDERED_BY_HOME_ASSISTANT
for key in (
CONF_GAMMA_CORRECT,
CONF_EFFECTS,
CONF_FLASH_TRANSITION_LENGTH,
light.CONF_TRANSITION_STATE_PUBLISH_INTERVAL,
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
CONF_INITIAL_STATE,
)
},
}
)
.extend(cv.COMPONENT_SCHEMA),
validate_entity_domain("light", SUPPORTED_DOMAINS),
)
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
var = await light.new_light(config)
await cg.register_component(var, config)
cg.add(var.set_report_timeout(config[CONF_REPORT_TIMEOUT].total_seconds))
# 1 state + supported_color_modes, min/max_color_temp_kelvin, color_mode, brightness,
# color_temp_kelvin and rgb_color in HomeassistantLight::setup()
setup_home_assistant_entity(var, config, state_subscription_count=8)
@@ -0,0 +1,477 @@
#include "homeassistant_light.h"
#include <algorithm>
#include <cmath>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_server.h"
#include "../homeassistant.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant {
ESPHOME_LOG_TAG(TAG, "homeassistant.light");
static constexpr uint32_t ECHO_TIMEOUT_ID = 0;
using namespace esphome::light;
namespace {
// Home Assistant's color mode names, in flash on the ESP8266. The names from "hs" on are every
// color mode, all handled as RGB: Home Assistant reports rgb_color for all of them and derives
// the white channels from rgb_color itself.
PROGMEM_STRING_TABLE(ColorModeNames, "onoff", "brightness", "white", "color_temp", "hs", "xy", "rgb", "rgbw", "rgbww");
const uint8_t COLOR_MODES[] PROGMEM = {
static_cast<uint8_t>(ColorMode::ON_OFF),
static_cast<uint8_t>(ColorMode::BRIGHTNESS),
static_cast<uint8_t>(ColorMode::WHITE),
static_cast<uint8_t>(ColorMode::COLOR_TEMPERATURE),
};
optional<ColorMode> parse_color_mode(StringRef name) {
const int16_t i = progmem_string_table_find<ColorModeNames>(name.c_str(), name.size());
if (i < 0) {
return nullopt;
}
return i < sizeof(COLOR_MODES) ? static_cast<ColorMode>(progmem_read_byte(&COLOR_MODES[i])) : ColorMode::RGB;
}
/// Reads a Python tuple of three integers such as "(255, 0, 0)"; false for anything else
bool parse_rgb(const char *text, std::array<uint8_t, 3> &values) {
for (auto &value : values) {
while (*text == ' ' || *text == ',' || *text == '(') {
text++;
}
if (*text < '0' || *text > '9') {
return false;
}
uint16_t number = 0;
while (*text >= '0' && *text <= '9') {
number = number * 10 + (*text++ - '0');
if (number > 255) {
return false;
}
}
value = static_cast<uint8_t>(number);
}
while (*text == ' ' || *text == ',' || *text == ')') {
text++;
}
return *text == '\0';
}
} // namespace
void HomeassistantLight::setup() {
auto *server = api::global_api_server;
// Home Assistant sends these on subscribe and when they change, so a light that comes up late gets them
server->subscribe_home_assistant_state(this->entity_id_, "supported_color_modes", [this](StringRef value) {
if (is_unset(value)) {
return; // the light is unavailable; its traits stay
}
// A Python list of the mode names, as strings or as the enum members
ColorModeMask modes;
char name[ColorModeNames::MAX_LENGTH + 1];
size_t length = 0;
const bool valid = parse_python_list(
value,
[&](char c) {
if (length < sizeof(name)) {
name[length] = c;
}
length++;
},
[&]() {
auto mode = length <= ColorModeNames::MAX_LENGTH ? parse_color_mode(StringRef(name, length)) : nullopt;
if (mode.has_value()) {
modes.insert(*mode);
} else {
ESP_LOGW(TAG, "'%s': Unknown color mode in '%s'", this->entity_id_, value.c_str());
}
length = 0;
});
if (!valid) {
ESP_LOGW(TAG, "'%s': Can't parse 'supported_color_modes' value '%s'", this->entity_id_, value.c_str());
return;
}
if (modes.empty()) {
ESP_LOGW(TAG, "'%s': No color mode found in '%s', keeping the current ones", this->entity_id_, value.c_str());
return;
}
ESP_LOGD(TAG, "'%s': Supported color modes retrieved: %s", this->entity_id_, value.c_str());
this->traits_.set_supported_color_modes(modes);
// Attributes that arrived before the traits were validated against empty ones; apply them again
this->schedule_apply_();
});
// Home Assistant reports the temperature range in Kelvin, the light core works in mireds
server->subscribe_home_assistant_state(this->entity_id_, "min_color_temp_kelvin", [this](StringRef value) {
if (auto kelvin = this->parse_integer_("min_color_temp_kelvin", value, UINT16_MAX)) {
this->traits_.set_max_mireds(1e6f / *kelvin);
this->schedule_apply_();
}
});
server->subscribe_home_assistant_state(this->entity_id_, "max_color_temp_kelvin", [this](StringRef value) {
if (auto kelvin = this->parse_integer_("max_color_temp_kelvin", value, UINT16_MAX)) {
this->traits_.set_min_mireds(1e6f / *kelvin);
this->schedule_apply_();
}
});
// An attribute is "None" while the light is off or does not report it; the last value stays
server->subscribe_home_assistant_state(this->entity_id_, nullptr, [this](StringRef value) {
if (is_unset(value)) {
return; // normal while Home Assistant or the light is starting up or offline
}
auto on_off = parse_on_off(value.c_str());
if (on_off != PARSE_ON && on_off != PARSE_OFF) {
ESP_LOGW(TAG, "'%s': Can't parse '%s' as state!", this->entity_id_, value.c_str());
return;
}
const bool state = on_off == PARSE_ON;
this->on_report_(this->imported_.state != state);
this->imported_.state = state;
});
server->subscribe_home_assistant_state(this->entity_id_, "color_mode", [this](StringRef value) {
if (is_unset(value)) {
return;
}
auto mode = parse_color_mode(value);
if (!mode.has_value()) {
ESP_LOGE(TAG, "'%s': Can't parse 'color_mode' value '%s' as color mode!", this->entity_id_, value.c_str());
return;
}
this->on_report_(this->imported_.color_mode != *mode);
this->imported_.color_mode = *mode;
});
server->subscribe_home_assistant_state(this->entity_id_, "brightness", [this](StringRef value) {
if (auto brightness = this->parse_integer_("brightness", value, 255)) {
this->on_report_(this->imported_.brightness != *brightness);
this->imported_.brightness = *brightness;
}
});
server->subscribe_home_assistant_state(this->entity_id_, "color_temp_kelvin", [this](StringRef value) {
if (auto kelvin = this->parse_integer_("color_temp_kelvin", value, UINT16_MAX)) {
this->on_report_(this->imported_.color_temp_kelvin != *kelvin);
this->imported_.color_temp_kelvin = *kelvin;
}
});
server->subscribe_home_assistant_state(this->entity_id_, "rgb_color", [this](StringRef value) {
if (is_unset(value)) {
return;
}
std::array<uint8_t, 3> rgb;
if (!parse_rgb(value.c_str(), rgb)) {
ESP_LOGE(TAG, "'%s': Can't parse 'rgb_color' value '%s'!", this->entity_id_, value.c_str());
return;
}
this->on_report_(!this->imported_.has_rgb || this->imported_.rgb != rgb);
this->imported_.rgb = rgb;
this->imported_.has_rgb = true;
});
}
void HomeassistantLight::dump_config() {
ESP_LOGCONFIG(TAG,
"Homeassistant Light:\n"
" Entity ID: '%s'",
this->entity_id_);
}
optional<uint16_t> HomeassistantLight::parse_integer_(const char *name, StringRef value, uint16_t max) {
if (is_unset(value)) {
return nullopt;
}
auto number = parse_number<float>(value.c_str());
if (!number.has_value()) {
ESP_LOGE(TAG, "'%s': Can't convert '%s' value '%s' to number!", this->entity_id_, name, value.c_str());
return nullopt;
}
// Written so that NaN fails too
const bool in_range = *number >= 1.0f && *number <= max;
if (!in_range) {
ESP_LOGW(TAG, "'%s': '%s' value %.0f is out of range!", this->entity_id_, name, *number);
return nullopt;
}
return static_cast<uint16_t>(*number);
}
void HomeassistantLight::on_report_(bool changed) {
// A report that changed an attribute answers the change that armed the echo timeout; a repeated
// value (or a trait) does not, so a refused change still gets its warning
if (changed && this->echo_pending_) {
this->echo_pending_ = false;
this->cancel_timeout(ECHO_TIMEOUT_ID);
}
this->report_changed_ |= changed;
this->schedule_apply_();
}
void HomeassistantLight::schedule_apply_() {
// One update changes several attributes, each arriving as its own message; apply them as one call
if (!this->apply_scheduled_) {
this->apply_scheduled_ = true;
this->defer([this]() { this->apply_imported_(); });
}
}
void HomeassistantLight::apply_imported_() {
this->apply_scheduled_ = false;
// Nothing to apply before the first state; while a sent change waits for its report the reported
// state is the old one, so a trait that arrives then does not bring it back
if (!this->imported_.state.has_value() || this->echo_pending_) {
return;
}
// Attributes Home Assistant has not reported keep what the light has. An "off" report carries
// no levels, so the ones reported last stay with the light: a turn_on without a brightness
// then sends what Home Assistant last had, not a level that never reached it
const Attributes &imported = this->imported_;
LightColorValues values = this->state_->remote_values;
values.set_state(*imported.state);
if (imported.color_mode != ColorMode::UNKNOWN) {
values.set_color_mode(imported.color_mode);
}
if (imported.brightness != 0) {
values.set_brightness(imported.brightness / 255.0f);
}
// Home Assistant reports rgb_color in every mode and keeps the attributes of the other
// modes, so only the ones of the mode it reports are taken over
const ColorMode mode = values.get_color_mode();
if (mode == ColorMode::COLOR_TEMPERATURE) {
values.set_color_temperature_kelvin(imported.color_temp_kelvin); // ignores an unreported 0
} else if (mode == ColorMode::RGB) {
if (imported.has_rgb) {
values.set_red(imported.rgb[0] / 255.0f);
values.set_green(imported.rgb[1] / 255.0f);
values.set_blue(imported.rgb[2] / 255.0f);
}
values.set_color_brightness(1.0f); // brightness is the only level Home Assistant has
} else if (mode == ColorMode::WHITE) {
values.set_white(1.0f);
}
if (!this->state_->set_external_state(values)) {
// A report that changed something during a transition or flash is applied once it ends, in
// place of the values it ends at; a trait or a repeated report has nothing newer to say
if (this->state_->is_transitioning() && this->report_changed_) {
this->report_held_ = true;
this->held_hash_ = state_hash(this->state_->remote_values);
return;
}
// A local change still waits for LightState::loop() to send it. Home Assistant reports again
// once that change reaches it; when it does not, the echo timeout write_state() arms brings the
// light back to what was reported last
ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_);
return;
}
this->report_changed_ = false;
ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_);
}
uint32_t HomeassistantLight::state_hash(const LightColorValues &values) {
uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS, static_cast<uint8_t>(values.get_color_mode()));
for (const float channel :
{values.get_state(), values.get_brightness(), values.get_color_brightness(), values.get_red(),
values.get_green(), values.get_blue(), values.get_white(), values.get_cold_white(), values.get_warm_white()}) {
hash = fnv1a_hash_extend(hash, to_uint8_scale(channel));
}
return fnv1a_hash_extend(hash, static_cast<uint16_t>(values.get_color_temperature())); // mireds exceed a byte
}
void HomeassistantLight::update_state(LightState *state) {
// The light core's setup performs one call for the initial state; a second one before the first
// write (an on_boot automation) is a local change merged into that write, so it must be sent
if (this->initial_write_) {
if (this->initial_seen_) {
this->initial_write_ = false;
}
this->initial_seen_ = true;
}
// A call without a transition replaces whatever ran, so a report held for it is older than the call
if (!state->is_transitioning()) {
this->report_held_ = false;
}
}
bool HomeassistantLight::is_imported_(const Attributes &outgoing) const {
const Attributes &imported = this->imported_;
if (!imported.state.has_value() || !outgoing.state.has_value() || *imported.state != *outgoing.state) {
return false;
}
if (!*outgoing.state) {
return true;
}
if (imported.color_mode != outgoing.color_mode || imported.brightness != outgoing.brightness) {
return false;
}
// rgb_color stays reported in the other modes, so only the attributes of the mode count
switch (outgoing.color_mode) {
case ColorMode::COLOR_TEMPERATURE:
return imported.color_temp_kelvin == outgoing.color_temp_kelvin;
case ColorMode::RGB:
return imported.has_rgb && imported.rgb == outgoing.rgb;
default:
return true;
}
}
void HomeassistantLight::fall_back_() {
// Back to the state Home Assistant reported, which the light still has
if (!this->imported_.state.has_value()) {
ESP_LOGW(TAG, "'%s': Nothing reported yet to fall back to, the light keeps the unsent change", this->entity_id_);
return;
}
this->schedule_apply_();
}
void HomeassistantLight::write_state(LightState *state) {
// A reported state is taken over without a write, so every write is a local change, except
// the very first: the light core writes its initial state, which Home Assistant does not need
if (this->initial_write_) {
this->initial_write_ = false;
return;
}
if (state->is_transitioning()) {
// A transition or flash runs here alone; Home Assistant gets the state it ends at. A call that
// replaces the transition a report was held for retargets it and is newer than the report
if (this->report_held_ && state_hash(state->remote_values) != this->held_hash_) {
this->report_held_ = false;
}
this->transitioned_ = true;
return;
}
const bool held = this->report_held_;
const bool transitioned = this->transitioned_;
this->report_held_ = false;
this->transitioned_ = false;
this->report_changed_ = false; // this change is newer than any report so far, held or dropped
// Home Assistant changed meanwhile; its report is newer than what the transition ends at or
// what the flash goes back to
if (held) {
ESP_LOGD(TAG, "'%s': Applying the report that arrived during the transition", this->entity_id_);
this->schedule_apply_();
return;
}
// What Home Assistant holds after the change. It has one level, so the channel brightness of the
// mode folds into it
const LightColorValues &values = state->current_values;
Attributes outgoing;
outgoing.state = values.is_on();
if (values.is_on()) {
outgoing.color_mode = values.get_color_mode();
switch (outgoing.color_mode) {
case ColorMode::ON_OFF:
break;
case ColorMode::WHITE:
outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_white());
break;
case ColorMode::COLOR_TEMPERATURE:
outgoing.brightness = to_uint8_scale(values.get_brightness());
if (values.get_color_temperature() > 0.0f) {
// A mired value below the reported range could give more than 65535 K
outgoing.color_temp_kelvin = lroundf(std::min(values.get_color_temperature_kelvin(), 65535.0f));
}
break;
case ColorMode::RGB:
outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_color_brightness());
outgoing.rgb = {to_uint8_scale(values.get_red()), to_uint8_scale(values.get_green()),
to_uint8_scale(values.get_blue())};
outgoing.has_rgb = true;
break;
default:
outgoing.brightness = to_uint8_scale(values.get_brightness());
break;
}
}
// Home Assistant reports only what changed, so a change that leaves it as it is gets no report:
// nothing to send and no echo to wait for. A change still on its way may alter it first, though
if (!this->echo_pending_ && this->is_imported_(outgoing)) {
if (transitioned) {
// A flash ends where it began, and Home Assistant only ever gets the end
ESP_LOGW(TAG, "'%s': Ended at the state Home Assistant already has, nothing to send (a flash never is)",
this->entity_id_);
} else {
ESP_LOGD(TAG, "'%s': Home Assistant already has this state", this->entity_id_);
}
return;
}
if (!api::global_api_server->is_connected()) {
ESP_LOGE(TAG, "'%s': No clients connected to API server, change lost", this->entity_id_);
this->fall_back_();
return;
}
#ifdef USE_API_WIZARD_LINKED_INPUTS
if (this->entity_id_[0] == '\0') {
ESP_LOGW(TAG, "'%s': No entity ID set yet", state->get_name().c_str());
this->fall_back_();
return;
}
#endif
// In flash on the ESP8266 and encoded from there
static const char SERVICE_TURN_ON[] PROGMEM = "light.turn_on";
static const char SERVICE_TURN_OFF[] PROGMEM = "light.turn_off";
static const char ENTITY_ID_KEY[] PROGMEM = "entity_id";
static const char BRIGHTNESS_KEY[] PROGMEM = "brightness";
static const char WHITE_KEY[] PROGMEM = "white";
static const char COLOR_TEMP_KELVIN_KEY[] PROGMEM = "color_temp_kelvin";
static const char RGB_COLOR_KEY[] PROGMEM = "rgb_color";
// The request refers to these buffers until it is sent below
char level_buf[4]; // up to 255
char color_temp_buf[6]; // up to 65535
char color_buf[16];
api::HomeassistantActionRequest resp;
resp.data.init(3);
auto &entity_id = resp.data.emplace_back();
entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY);
entity_id.value = ProgmemStringRef(StringRef(this->entity_id_));
auto add_number = [&](ProgmemStringRef key, auto &buf, unsigned number) {
auto &entry = resp.data.emplace_back();
entry.key = key;
entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, sizeof(buf), 0, "%u", number)));
};
if (!*outgoing.state) {
resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_OFF);
} else {
resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_ON);
if (outgoing.color_mode != ColorMode::ON_OFF) {
// The level goes under "white" in that mode, which also selects the mode
add_number(outgoing.color_mode == ColorMode::WHITE ? ProgmemStringRef::from_lit(WHITE_KEY)
: ProgmemStringRef::from_lit(BRIGHTNESS_KEY),
level_buf, outgoing.brightness);
}
// Home Assistant converts the color to the light's own channels, which loses the white balance
// of a cold/warm white light, so the color goes only when it or the mode changed (or may have,
// while a change still waits for its report)
const Attributes &imported = this->imported_;
const bool new_color = this->echo_pending_ || outgoing.color_mode != imported.color_mode;
if (outgoing.color_temp_kelvin != 0 && (new_color || outgoing.color_temp_kelvin != imported.color_temp_kelvin)) {
add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, outgoing.color_temp_kelvin);
}
if (outgoing.has_rgb && (new_color || !imported.has_rgb || outgoing.rgb != imported.rgb)) {
// A list, which only a template renders as one
resp.data_template.init(1);
auto &color = resp.data_template.emplace_back();
color.key = ProgmemStringRef::from_lit(RGB_COLOR_KEY);
color.value =
ProgmemStringRef(StringRef(color_buf, buf_append_printf(color_buf, sizeof(color_buf), 0, "[%u,%u,%u]",
outgoing.rgb[0], outgoing.rgb[1], outgoing.rgb[2])));
}
}
api::global_api_server->send_homeassistant_action(resp);
// Home Assistant reports the change back in a moment, which cancels this; when it does not, the
// change got lost on the way (no subscriber yet, the entity unavailable, the action refused)
this->echo_pending_ = true;
this->set_timeout(ECHO_TIMEOUT_ID, this->report_timeout_s_ * 1000u, [this]() {
this->echo_pending_ = false;
ESP_LOGW(TAG, "'%s': Home Assistant did not report the change back", this->entity_id_);
this->fall_back_();
});
}
} // namespace esphome::homeassistant
@@ -0,0 +1,70 @@
#pragma once
#include <array>
#include "esphome/components/light/light_output.h"
#include "esphome/core/component.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant {
/// A light in Home Assistant, driven from here: its state and attributes are imported over the
/// native API, and every change made on this side is sent back as a light.turn_on or
/// light.turn_off action.
class HomeassistantLight final : public light::LightOutput, public Component {
public:
void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; }
/// How long a sent change may take to come back as a report before the light returns to the reported state
void set_report_timeout(uint8_t seconds) { this->report_timeout_s_ = seconds; }
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; }
light::LightTraits get_traits() override { return this->traits_; }
void setup_state(light::LightState *state) override { this->state_ = state; }
void update_state(light::LightState *state) override;
void write_state(light::LightState *state) override;
protected:
/// What Home Assistant reported last, in its own units. Each attribute arrives as its own
/// message and stays until the next report of it, so an update may change any subset; zero,
/// ColorMode::UNKNOWN and has_rgb say an attribute was never reported.
struct Attributes {
uint16_t color_temp_kelvin{0};
uint8_t brightness{0};
std::array<uint8_t, 3> rgb{};
light::ColorMode color_mode{light::ColorMode::UNKNOWN};
bool has_rgb{false};
optional<bool> state;
};
/// Parses a Home Assistant attribute as an integer from 1 to max, logging anything else
optional<uint16_t> parse_integer_(const char *name, StringRef value, uint16_t max);
/// A state or attribute report: answers a pending echo when it changed something, and schedules the apply
void on_report_(bool changed);
/// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call
static uint32_t state_hash(const light::LightColorValues &values);
/// Schedules apply_imported_() once for the attributes of one update
void schedule_apply_();
void apply_imported_();
/// True when Home Assistant reported exactly what a change would make of it
bool is_imported_(const Attributes &outgoing) const;
/// Returns the light to the reported state after a change that did not reach Home Assistant
void fall_back_();
const char *entity_id_{nullptr};
light::LightState *state_{nullptr};
light::LightTraits traits_{};
Attributes imported_{};
uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at
bool apply_scheduled_ : 1 {false};
bool initial_write_ : 1 {true}; // the light core's own first write is not a local change
bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state
bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back
bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends
bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent
bool report_changed_ : 1 {false}; // the reports since the last apply changed an attribute
uint8_t report_timeout_s_{10};
};
} // namespace esphome::homeassistant
+10
View File
@@ -303,6 +303,16 @@ def test_entity_id_is_optional_only_for_wizard_inputs(
), errors
def test_light_is_a_wizard_input(tmp_path: Path) -> None:
entities = "light:\n - platform: homeassistant\n id: ha_light\n"
errors = config_errors(
write_config(
tmp_path, ESP32_HEADER, wizard_inputs("- entity: ha_light"), entities
)
)
assert errors == []
def test_entity_id_is_optional_for_an_input_and_its_domain_is_checked(
tmp_path: Path,
) -> None:
@@ -63,3 +63,9 @@ button:
id: ha_button
name: HA Button
entity_id: button.restart
light:
- platform: homeassistant
id: ha_light
name: HA Light
entity_id: light.kitchen
@@ -15,12 +15,12 @@ def test_mixed_entities_state_subscription_count(
generate_main(DIR / "mixed_entities.yaml")
# sensor (2*1) + binary_sensor (1) + text_sensor (1) + switch (1)
# + select (2) + number (4) + text (4) + button (0) = 15
# + select (2) + number (4) + text (4) + button (0) + light (8) = 23
define_match = [
d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT"
]
assert len(define_match) == 1
assert str(define_match[0].value) == "15"
assert str(define_match[0].value) == "23"
assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines)
@@ -54,6 +54,12 @@ switch:
entity_id: switch.my_cool_switch
id: my_cool_switch
light:
- platform: homeassistant
entity_id: light.my_cool_light
id: ha_my_cool_light
report_timeout: 30s
binary_sensor:
- platform: homeassistant
entity_id: binary_sensor.hello_world
@@ -0,0 +1,39 @@
esphome:
name: ha-light
host:
api:
actions:
- action: set_force_off
variables:
value: bool
then:
- globals.set:
id: force_off
value: !lambda return value;
logger:
level: DEBUG
globals:
- id: force_off
type: bool
initial_value: "false"
light:
- platform: homeassistant
name: HA Light
id: ha_light
entity_id: light.kitchen
internal: false
report_timeout: 2s
on_turn_off:
- logger.log: "turned off"
on_turn_on:
# An automation reacting to a state reported by Home Assistant with a local change
- if:
condition:
lambda: return id(force_off);
then:
- light.turn_off: ha_light
@@ -0,0 +1,393 @@
"""Test the homeassistant light platform against a fake Home Assistant."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from aioesphomeapi import (
APIClient,
EntityState,
HomeassistantServiceCall,
LightColorCapability,
LightState,
UserService,
)
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
ENTITY = "light.kitchen"
SUBSCRIPTIONS = {
(ENTITY, None),
(ENTITY, "supported_color_modes"),
(ENTITY, "min_color_temp_kelvin"),
(ENTITY, "max_color_temp_kelvin"),
(ENTITY, "color_mode"),
(ENTITY, "brightness"),
(ENTITY, "color_temp_kelvin"),
(ENTITY, "rgb_color"),
}
COLOR_TEMPERATURE = (
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.COLOR_TEMPERATURE
)
RGB = (
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.RGB
)
@dataclass
class FakeHomeAssistant:
"""The light entity as the device sees it over the API, and what the device sends back."""
client: APIClient
key: int
services: list[UserService]
waiter: LineWaiter
calls: asyncio.Queue[HomeassistantServiceCall]
states: asyncio.Queue[LightState]
async def next_call(self) -> HomeassistantServiceCall:
return await asyncio.wait_for(self.calls.get(), timeout=5.0)
async def next_state(self, matches: Callable[[LightState], bool]) -> LightState:
async with asyncio.timeout(5.0):
while not matches(state := await self.states.get()):
pass
return state
def report(self, attribute: str | None, value: str) -> None:
self.client.send_home_assistant_state(ENTITY, attribute, value)
@asynccontextmanager
async def light_entity(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
modes: str,
) -> AsyncIterator[FakeHomeAssistant]:
"""Boots the device and answers its subscriptions with a light of the given color modes."""
loop = asyncio.get_running_loop()
waiter = LineWaiter()
subscribed: set[tuple[str, str | None]] = set()
all_subscribed = loop.create_future()
calls: asyncio.Queue[HomeassistantServiceCall] = asyncio.Queue()
states: asyncio.Queue[LightState] = asyncio.Queue()
def on_state_sub(entity_id: str, attribute: str | None) -> None:
subscribed.add((entity_id, attribute or None))
if not all_subscribed.done() and subscribed >= SUBSCRIPTIONS:
all_subscribed.set_result(None)
def on_state(state: EntityState) -> None:
if isinstance(state, LightState):
states.put_nowait(state)
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, services = await client.list_entities_services()
key = next(e.key for e in entities if e.name == "HA Light")
client.subscribe_service_calls(calls.put_nowait)
client.subscribe_states(on_state)
client.subscribe_home_assistant_states(on_state_sub)
try:
await asyncio.wait_for(all_subscribed, timeout=5.0)
except TimeoutError:
pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}")
ha = FakeHomeAssistant(client, key, services, waiter, calls, states)
ha.report("supported_color_modes", modes)
ha.report("min_color_temp_kelvin", "2000")
ha.report("max_color_temp_kelvin", "6500")
await waiter.wait_for("Supported color modes retrieved", timeout=5.0)
yield ha
@pytest.mark.asyncio
async def test_api_homeassistant_light(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Imported attributes, echo suppression and the actions sent for local changes."""
async with light_entity(
yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgb']"
) as ha:
set_force_off = next(s for s in ha.services if s.name == "set_force_off")
# Normal while Home Assistant starts, so no warning
ha.report(None, "unavailable")
ha.report(None, "unknown")
# Rejected, so the color stays unknown and the warnings name the attribute
ha.report("rgb_color", "(127.5, 64.0, 0.0)")
ha.report("rgb_color", "(0, 256, 0)")
ha.report("color_temp_kelvin", "0")
await ha.waiter.wait_for(
"'color_temp_kelvin' value 0 is out of range", timeout=5.0
)
assert sum("Can't parse 'rgb_color'" in line for line in ha.waiter.lines) == 2
assert not any("as state!" in line for line in ha.waiter.lines)
# A report arrives as several attribute messages and is applied as one change
ha.report(None, "on")
ha.report("color_mode", "rgb")
ha.report("brightness", "128")
ha.report("rgb_color", "(255, 0, 0)")
state = await ha.next_state(lambda s: s.state)
assert state.color_mode == RGB
assert state.brightness == pytest.approx(128 / 255)
assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0)
# A rejected color leaves the reported one untouched
ha.report("rgb_color", "(0, 256, 0)")
ha.report("brightness", "255")
state = await ha.next_state(lambda s: s.brightness == 1.0)
assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0)
# The attributes of other modes stay with their mode, whatever order they arrive in
ha.report("color_mode", "color_temp")
ha.report("color_temp_kelvin", "4000")
state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE)
assert state.color_temperature == 250.0
ha.report("rgb_color", "(0, 255, 0)")
ha.report("brightness", "200")
state = await ha.next_state(lambda s: s.brightness == pytest.approx(200 / 255))
assert state.color_mode == COLOR_TEMPERATURE
# Local changes go to Home Assistant with its attribute names and scales; the color
# temperature it reported is not repeated
ha.client.light_command(ha.key, brightness=0.5)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
assert not call.data_template
ha.client.light_command(ha.key, rgb=(0.0, 0.0, 1.0))
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
assert call.data_template == {"rgb_color": "[0,0,255]"}
# Home Assistant echoes the result; that is not sent back. Returning to the state it
# reported last is a local change and is sent.
ha.report(None, "off")
ha.report("brightness", "None")
await ha.next_state(lambda s: not s.state)
await ha.waiter.wait_for(
"turned off", timeout=5.0
) # a reported state runs the triggers too
ha.client.light_command(ha.key, state=True)
assert (await ha.next_call()).service == "light.turn_on"
ha.client.light_command(ha.key, state=False)
assert (await ha.next_call()).service == "light.turn_off"
# Without a report of the change within report_timeout, the light goes back to the reported state
ha.report(None, "on")
await ha.next_state(lambda s: s.state)
ha.client.light_command(ha.key, state=False)
assert (await ha.next_call()).service == "light.turn_off"
await ha.next_state(lambda s: s.state)
await ha.waiter.wait_for("did not report the change back", timeout=5.0)
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
# The fallback also drops the levels of the unsent change: the light keeps the brightness
# Home Assistant reported last, which a later turn_on without a brightness would send
ha.client.light_command(ha.key, state=True, brightness=0.4)
call = await ha.next_call()
assert call.data["brightness"] == "102"
state = await ha.next_state(lambda s: not s.state)
assert state.brightness == pytest.approx(200 / 255)
# An automation changing the light while a report is applied is a local change too
await ha.client.execute_service(set_force_off, {"value": True})
ha.report(None, "on")
assert (await ha.next_call()).service == "light.turn_off"
await ha.client.execute_service(set_force_off, {"value": False})
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
# A change that leaves Home Assistant as it is gets no report, so none is sent
ha.client.light_command(ha.key, state=False)
await ha.waiter.wait_for("already has this state", timeout=5.0)
# A trait that arrives while a change waits for its report is not that report, and does
# not bring the old state back early either
ha.client.light_command(ha.key, state=True)
assert (await ha.next_call()).service == "light.turn_on"
await ha.next_state(lambda s: s.state)
ha.report("max_color_temp_kelvin", "6500")
await ha.next_state(lambda s: not s.state)
assert (
sum("did not report the change back" in line for line in ha.waiter.lines)
== 3
)
# A report that arrives during a flash is applied when the flash ends instead of being undone
ha.client.light_command(ha.key, state=True, flash_length=1.0)
ha.report(None, "on")
ha.report("brightness", "77")
state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255))
assert state.state
await ha.waiter.wait_for("arrived during the transition", timeout=5.0)
# A trait or a repeated report during a local transition is nothing new, so the change the
# transition ends at is still sent
ha.client.light_command(ha.key, state=False, transition_length=1.0)
ha.report("max_color_temp_kelvin", "6500")
ha.report(None, "on")
assert (await ha.next_call()).service == "light.turn_off"
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
# A newer local command wins over a report held during a transition
ha.client.light_command(ha.key, state=True, transition_length=1.0)
ha.report("brightness", "50")
await asyncio.sleep(0.1) # held before the command arrives
ha.client.light_command(ha.key, state=True, brightness=1.0)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data["brightness"] == "255"
ha.report(None, "on")
ha.report("brightness", "255")
await ha.next_state(lambda s: s.state and s.brightness == 1.0)
# Also when it changes only the color, at the level the transition was heading for
ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0)
ha.report("brightness", "50")
await asyncio.sleep(0.1)
ha.client.light_command(ha.key, color_temperature=400.0)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {
"entity_id": ENTITY,
"brightness": "128",
"color_temp_kelvin": "2500",
}
ha.report("brightness", "128")
ha.report("color_temp_kelvin", "2500")
await ha.next_state(lambda s: s.color_temperature == 400.0)
# Or when it is a transition itself, replacing the one the report was held for
ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0)
ha.report("brightness", "50")
await asyncio.sleep(0.1)
ha.client.light_command(ha.key, color_temperature=250.0, transition_length=1.0)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {
"entity_id": ENTITY,
"brightness": "255",
"color_temp_kelvin": "4000",
}
ha.report("brightness", "255")
ha.report("color_temp_kelvin", "4000")
await ha.next_state(lambda s: s.color_temperature == 250.0)
# Or a transition back to where the one the report was held for started
ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0)
ha.report("brightness", "50")
await asyncio.sleep(0.1)
ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data["brightness"] == "255"
ha.report("brightness", "255")
await ha.next_state(lambda s: s.brightness == 1.0)
# And when it goes back to the state the transition started from
ha.client.light_command(ha.key, state=False)
assert (await ha.next_call()).service == "light.turn_off"
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
ha.client.light_command(ha.key, state=True, transition_length=1.0)
ha.report(None, "on")
ha.report("brightness", "60")
await asyncio.sleep(0.1)
ha.client.light_command(ha.key, state=False)
assert (await ha.next_call()).service == "light.turn_off"
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
# A flash ends where it began, which Home Assistant has, so nothing is sent and the user is told
ha.client.light_command(ha.key, state=True, flash_length=0.5)
await ha.waiter.wait_for("nothing to send (a flash never is)", timeout=5.0)
assert ha.calls.empty()
@pytest.mark.asyncio
@pytest.mark.shared_yaml("api_homeassistant_light")
async def test_api_homeassistant_light_rgbww(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A cold/warm white light keeps its white balance: the color goes only when it changes."""
async with light_entity(
yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgbww']"
) as ha:
# Home Assistant reports rgb_color in every color mode, so the light is an RGB one here
ha.report(None, "on")
ha.report("color_mode", "rgbww")
ha.report("brightness", "200")
ha.report("rgb_color", "(255, 180, 120)")
state = await ha.next_state(lambda s: s.state)
assert state.color_mode == RGB
assert state.brightness == pytest.approx(200 / 255)
assert (state.red, state.green, state.blue) == pytest.approx(
(1.0, 180 / 255, 120 / 255)
)
# A level change sends the level alone; Home Assistant derived the color it reports from
# the white balance and would derive a different balance from the color sent back
ha.client.light_command(ha.key, brightness=0.5)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
assert not call.data_template
ha.report("brightness", "128")
await ha.next_state(lambda s: s.brightness == pytest.approx(128 / 255))
# A new color goes along
ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0))
call = await ha.next_call()
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
assert call.data_template == {"rgb_color": "[0,255,0]"}
ha.report("rgb_color", "(0, 255, 0)")
await ha.next_state(lambda s: s.green == 1.0 and s.red == 0.0)
# Switching to color temperature sends it, and the level alone once there
ha.client.light_command(ha.key, color_temperature=250.0)
call = await ha.next_call()
assert call.data == {
"entity_id": ENTITY,
"brightness": "128",
"color_temp_kelvin": "4000",
}
assert not call.data_template
ha.report("color_mode", "color_temp")
ha.report("color_temp_kelvin", "4000")
state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE)
assert state.color_temperature == 250.0
ha.client.light_command(ha.key, brightness=1.0)
call = await ha.next_call()
assert call.data == {"entity_id": ENTITY, "brightness": "255"}
ha.report("brightness", "255")
await ha.next_state(lambda s: s.brightness == 1.0)
# Back to the color Home Assistant still reports: the mode changed, so the color is sent
ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0))
call = await ha.next_call()
assert call.data == {"entity_id": ENTITY, "brightness": "255"}
assert call.data_template == {"rgb_color": "[0,255,0]"}
ha.report("color_mode", "rgbww")
await ha.next_state(lambda s: s.color_mode == RGB)
assert ha.calls.empty()