diff --git a/CODEOWNERS b/CODEOWNERS index 1f1375ba968..61f1f4722cc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 8e5c808e24c..200c4f9f120 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -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]: diff --git a/esphome/components/homeassistant/homeassistant.cpp b/esphome/components/homeassistant/homeassistant.cpp new file mode 100644 index 00000000000..5d6a41c677b --- /dev/null +++ b/esphome/components/homeassistant/homeassistant.cpp @@ -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(value.c_str(), value.size()) >= 0; } + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/homeassistant.h b/esphome/components/homeassistant/homeassistant.h index 65eb6ef5fd2..580c1d89a90 100644 --- a/esphome/components/homeassistant/homeassistant.h +++ b/esphome/components/homeassistant/homeassistant.h @@ -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 bool emit_utf8(uint32_t code_point, C &on_char) { if (code_point == 0 || code_point > 0x10FFFF) diff --git a/esphome/components/homeassistant/light/__init__.py b/esphome/components/homeassistant/light/__init__.py new file mode 100644 index 00000000000..abb6b7289f7 --- /dev/null +++ b/esphome/components/homeassistant/light/__init__.py @@ -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) diff --git a/esphome/components/homeassistant/light/homeassistant_light.cpp b/esphome/components/homeassistant/light/homeassistant_light.cpp new file mode 100644 index 00000000000..6ebe0cadbfb --- /dev/null +++ b/esphome/components/homeassistant/light/homeassistant_light.cpp @@ -0,0 +1,477 @@ +#include "homeassistant_light.h" + +#include +#include + +#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(ColorMode::ON_OFF), + static_cast(ColorMode::BRIGHTNESS), + static_cast(ColorMode::WHITE), + static_cast(ColorMode::COLOR_TEMPERATURE), +}; + +optional parse_color_mode(StringRef name) { + const int16_t i = progmem_string_table_find(name.c_str(), name.size()); + if (i < 0) { + return nullopt; + } + return i < sizeof(COLOR_MODES) ? static_cast(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 &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(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 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 HomeassistantLight::parse_integer_(const char *name, StringRef value, uint16_t max) { + if (is_unset(value)) { + return nullopt; + } + auto number = parse_number(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(*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(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(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 diff --git a/esphome/components/homeassistant/light/homeassistant_light.h b/esphome/components/homeassistant/light/homeassistant_light.h new file mode 100644 index 00000000000..a7f316a22f8 --- /dev/null +++ b/esphome/components/homeassistant/light/homeassistant_light.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +#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 rgb{}; + light::ColorMode color_mode{light::ColorMode::UNKNOWN}; + bool has_rgb{false}; + optional state; + }; + + /// Parses a Home Assistant attribute as an integer from 1 to max, logging anything else + optional 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 diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index f55a6974e7f..d8ca84720ed 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -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: diff --git a/tests/component_tests/homeassistant/mixed_entities.yaml b/tests/component_tests/homeassistant/mixed_entities.yaml index 8c96e1ad521..da9246f2fea 100644 --- a/tests/component_tests/homeassistant/mixed_entities.yaml +++ b/tests/component_tests/homeassistant/mixed_entities.yaml @@ -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 diff --git a/tests/component_tests/homeassistant/test_state_subscription_count.py b/tests/component_tests/homeassistant/test_state_subscription_count.py index c3832369117..5e7afed1ade 100644 --- a/tests/component_tests/homeassistant/test_state_subscription_count.py +++ b/tests/component_tests/homeassistant/test_state_subscription_count.py @@ -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) diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 078036598a9..f39b3e1b2d2 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -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 diff --git a/tests/integration/fixtures/api_homeassistant_light.yaml b/tests/integration/fixtures/api_homeassistant_light.yaml new file mode 100644 index 00000000000..e2cf9e5fbe5 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_light.yaml @@ -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 diff --git a/tests/integration/test_api_homeassistant_light.py b/tests/integration/test_api_homeassistant_light.py new file mode 100644 index 00000000000..2cb9b4d6d05 --- /dev/null +++ b/tests/integration/test_api_homeassistant_light.py @@ -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()