From e04064753095ae44036661dab13344209856be04 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:46:36 +1300 Subject: [PATCH] [homeassistant] Add text, select and button platforms (#19964) --- CODEOWNERS | 3 + .../homeassistant/button/__init__.py | 37 +++ .../button/homeassistant_button.cpp | 47 ++++ .../button/homeassistant_button.h | 21 ++ .../homeassistant/select/__init__.py | 54 ++++ .../select/homeassistant_select.cpp | 258 ++++++++++++++++++ .../select/homeassistant_select.h | 47 ++++ .../components/homeassistant/text/__init__.py | 37 +++ .../homeassistant/text/homeassistant_text.cpp | 107 ++++++++ .../homeassistant/text/homeassistant_text.h | 28 ++ .../homeassistant/button_invalid.yaml | 18 ++ .../homeassistant/button_valid.yaml | 23 ++ .../homeassistant/select_invalid.yaml | 18 ++ .../homeassistant/select_valid.yaml | 25 ++ .../homeassistant/test_button.py | 34 +++ .../homeassistant/test_select.py | 36 +++ .../homeassistant/test_text.py | 37 +++ .../homeassistant/text_invalid.yaml | 18 ++ .../homeassistant/text_valid.yaml | 23 ++ tests/components/homeassistant/common.yaml | 31 ++- .../homeassistant/test.bk72xx-ard.yaml | 4 + .../homeassistant/test.esp32-idf.yaml | 4 + .../homeassistant/test.esp8266-ard.yaml | 4 + tests/components/homeassistant/test.host.yaml | 2 + .../homeassistant/test.ln882x-ard.yaml | 4 + .../homeassistant/test.rp2040-ard.yaml | 4 + .../fixtures/api_homeassistant_entities.yaml | 62 +++++ .../test_api_homeassistant_entities.py | 213 +++++++++++++++ 28 files changed, 1195 insertions(+), 4 deletions(-) create mode 100644 esphome/components/homeassistant/button/__init__.py create mode 100644 esphome/components/homeassistant/button/homeassistant_button.cpp create mode 100644 esphome/components/homeassistant/button/homeassistant_button.h create mode 100644 esphome/components/homeassistant/select/__init__.py create mode 100644 esphome/components/homeassistant/select/homeassistant_select.cpp create mode 100644 esphome/components/homeassistant/select/homeassistant_select.h create mode 100644 esphome/components/homeassistant/text/__init__.py create mode 100644 esphome/components/homeassistant/text/homeassistant_text.cpp create mode 100644 esphome/components/homeassistant/text/homeassistant_text.h create mode 100644 tests/component_tests/homeassistant/button_invalid.yaml create mode 100644 tests/component_tests/homeassistant/button_valid.yaml create mode 100644 tests/component_tests/homeassistant/select_invalid.yaml create mode 100644 tests/component_tests/homeassistant/select_valid.yaml create mode 100644 tests/component_tests/homeassistant/test_button.py create mode 100644 tests/component_tests/homeassistant/test_select.py create mode 100644 tests/component_tests/homeassistant/test_text.py create mode 100644 tests/component_tests/homeassistant/text_invalid.yaml create mode 100644 tests/component_tests/homeassistant/text_valid.yaml create mode 100644 tests/components/homeassistant/test.host.yaml create mode 100644 tests/integration/fixtures/api_homeassistant_entities.yaml create mode 100644 tests/integration/test_api_homeassistant_entities.py diff --git a/CODEOWNERS b/CODEOWNERS index 43034ce308..1695e633ef 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -246,8 +246,11 @@ esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2 esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter +esphome/components/homeassistant/button/* @jesserockz esphome/components/homeassistant/number/* @landonr +esphome/components/homeassistant/select/* @jesserockz esphome/components/homeassistant/switch/* @Links2004 +esphome/components/homeassistant/text/* @jesserockz esphome/components/honeywell_hih_i2c/* @Benichou34 esphome/components/honeywellabp/* @RubyBailey esphome/components/honeywellabp2_i2c/* @jpfaff diff --git a/esphome/components/homeassistant/button/__init__.py b/esphome/components/homeassistant/button/__init__.py new file mode 100644 index 0000000000..660c59164e --- /dev/null +++ b/esphome/components/homeassistant/button/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["api"] + +SUPPORTED_DOMAINS = [ + "button", + "input_button", +] + +HomeassistantButton = homeassistant_ns.class_( + "HomeassistantButton", button.Button, cg.Component +) + +CONFIG_SCHEMA = cv.All( + button.button_schema(HomeassistantButton) + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("button", SUPPORTED_DOMAINS), +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + var = await button.new_button(config) + await cg.register_component(var, config) + setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp new file mode 100644 index 0000000000..d8d2faa37a --- /dev/null +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -0,0 +1,47 @@ +#include "homeassistant_button.h" + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/log.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +static const char *const TAG = "homeassistant.button"; + +void HomeassistantButton::dump_config() { + LOG_BUTTON("", "Homeassistant Button", this); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); +} + +float HomeassistantButton::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } + +void HomeassistantButton::press_action() { + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "No clients connected to API server"); + return; + } + + static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press"); + static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press"); + static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + static constexpr char INPUT_PREFIX[] = "input_"; + + api::HomeassistantActionRequest resp; + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = SERVICE_INPUT_BUTTON; + } else { + resp.service = SERVICE_BUTTON; + } + + resp.data.init(1); + auto &entity_id_kv = resp.data.emplace_back(); + entity_id_kv.key = ENTITY_ID_KEY; + entity_id_kv.value = StringRef(this->entity_id_); + + api::global_api_server->send_homeassistant_action(resp); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/button/homeassistant_button.h b/esphome/components/homeassistant/button/homeassistant_button.h new file mode 100644 index 0000000000..cc7bbddfef --- /dev/null +++ b/esphome/components/homeassistant/button/homeassistant_button.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "esphome/core/component.h" + +namespace esphome::homeassistant { + +class HomeassistantButton final : public button::Button, public Component { + public: + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + + void dump_config() override; + float get_setup_priority() const override; + + protected: + void press_action() override; + + const char *entity_id_{nullptr}; +}; + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py new file mode 100644 index 0000000000..8be0a4545c --- /dev/null +++ b/esphome/components/homeassistant/select/__init__.py @@ -0,0 +1,54 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["api"] + +CONF_MAX_OPTIONS = "max_options" +CONF_OPTIONS_BUFFER_SIZE = "options_buffer_size" + +SUPPORTED_DOMAINS = [ + "input_select", + "select", +] + +HomeassistantSelect = homeassistant_ns.class_( + "HomeassistantSelect", select.Select, cg.Component +) + +CONFIG_SCHEMA = cv.All( + select.select_schema(HomeassistantSelect) + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend( + { + cv.Optional(CONF_MAX_OPTIONS, default=16): cv.int_range(min=1, max=255), + cv.Optional(CONF_OPTIONS_BUFFER_SIZE, default=256): cv.int_range( + min=2, max=4096 + ), + } + ) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("select", SUPPORTED_DOMAINS), +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + # Options come from Home Assistant at runtime, into storage reserved during setup + var = await select.new_select( + config, + config[CONF_MAX_OPTIONS], + config[CONF_OPTIONS_BUFFER_SIZE], + options=[], + ) + await cg.register_component(var, config) + setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp new file mode 100644 index 0000000000..82571c768d --- /dev/null +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -0,0 +1,258 @@ +#include "homeassistant_select.h" + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +static const char *const TAG = "homeassistant.select"; + +namespace { + +/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the option early. +template bool emit_utf8(uint32_t code_point, C &on_char) { + if (code_point == 0 || code_point > 0x10FFFF) + return false; + if (code_point < 0x80) { + on_char(static_cast(code_point)); + } else if (code_point < 0x800) { + on_char(static_cast(0xC0 | (code_point >> 6))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else if (code_point < 0x10000) { + on_char(static_cast(0xE0 | (code_point >> 12))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else { + on_char(static_cast(0xF0 | (code_point >> 18))); + on_char(static_cast(0x80 | ((code_point >> 12) & 0x3F))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } + return true; +} + +/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as + * `['Low', "It's high", 'caf\xe9']`. + * + * Calls on_char for each decoded byte of an option and on_option at the end of each option. Returns false + * on malformed input; the callbacks may already have run by then. + */ +template bool parse_option_list(StringRef src, C &&on_char, O &&on_option) { + const char *pos = src.c_str(); + const char *const end = pos + src.size(); + auto skip_spaces = [&]() { + while (pos < end && *pos == ' ') + pos++; + }; + + skip_spaces(); + if (pos == end || *pos != '[') + return false; + pos++; + skip_spaces(); + if (pos < end && *pos == ']') { + pos++; + } else { + while (true) { + skip_spaces(); + if (pos == end || (*pos != '\'' && *pos != '"')) + return false; + const char quote = *pos++; + while (true) { + if (pos == end) + return false; + const char c = *pos++; + if (c == quote) + break; + if (c != '\\') { + on_char(c); + continue; + } + if (pos == end) + return false; + const char escape = *pos++; + switch (escape) { + case '\\': + case '\'': + case '"': + on_char(escape); + break; + case 'n': + on_char('\n'); + break; + case 'r': + on_char('\r'); + break; + case 't': + on_char('\t'); + break; + case 'x': + case 'u': + case 'U': { + const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8); + if (static_cast(end - pos) < digits) + return false; + auto code_point = parse_hex(pos, digits); + if (!code_point.has_value() || !emit_utf8(*code_point, on_char)) + return false; + pos += digits; + break; + } + default: + return false; + } + } + on_option(); + skip_spaces(); + if (pos == end) + return false; + const char separator = *pos++; + if (separator == ']') + break; + if (separator != ',') + return false; + } + } + skip_spaces(); + return pos == end; +} + +} // namespace + +void HomeassistantSelect::setup() { + this->options_buffer_ = std::make_unique(this->options_buffer_size_); + this->option_list_ = std::make_unique(this->max_options_); + + // Subscribe to the options first: Home Assistant answers subscriptions in order, so the options are + // known by the time the first state arrives. + api::global_api_server->subscribe_home_assistant_state( + this->entity_id_, "options", [this](StringRef options) { this->options_changed_(options); }); + api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr, + [this](StringRef state) { this->state_changed_(state); }); +} + +void HomeassistantSelect::options_changed_(StringRef options) { + // First pass: validate and measure the new options, and look for the active option among them, before + // anything is overwritten. A list that does not fit leaves the current options in place. + const char *active = this->has_state() ? this->option_at(this->active_index_) : nullptr; + size_t count = 0; + size_t bytes = 0; + size_t match_len = 0; + bool matching = active != nullptr; + optional new_active; + bool valid = parse_option_list( + options, + [&](char c) { + bytes++; + if (matching && active[match_len] == c) { + match_len++; + } else { + matching = false; + } + }, + [&]() { + bytes++; // NUL terminator + if (matching && active[match_len] == '\0' && !new_active.has_value()) + new_active = count; + count++; + match_len = 0; + matching = active != nullptr; + }); + if (!valid) { + ESP_LOGW(TAG, "'%s': Can't parse options %s", this->entity_id_, options.c_str()); + return; + } + if (count > this->max_options_) { + ESP_LOGE(TAG, "'%s': %zu options exceed max_options (%u)", this->entity_id_, count, this->max_options_); + return; + } + if (bytes > this->options_buffer_size_) { + ESP_LOGE(TAG, "'%s': Options need %zu bytes, more than options_buffer_size (%u)", this->entity_id_, bytes, + this->options_buffer_size_); + return; + } + + // Second pass: the input is known to be valid and to fit, so write it out + char *buffer = this->options_buffer_.get(); + size_t offset = 0; + size_t option_start = 0; + size_t index = 0; + parse_option_list( + options, [&](char c) { buffer[offset++] = c; }, + [&]() { + buffer[offset++] = '\0'; + this->option_list_[index++] = buffer + option_start; + option_start = offset; + }); + // The traits only point at the list, which this select owns; set_options() would copy it to the heap + this->traits.set_options_static(this->option_list_.get(), count); + + // The active option may have moved to another index, or may be gone + if (new_active.has_value()) { + this->active_index_ = *new_active; + } else { + this->set_has_state(false); + } + ESP_LOGD(TAG, "'%s': Got %zu options", this->entity_id_, count); +} + +void HomeassistantSelect::state_changed_(StringRef state) { + auto index = this->index_of(state.c_str(), state.size()); + if (!index.has_value()) { + ESP_LOGW(TAG, "'%s': State '%s' is not one of the options", this->entity_id_, state.c_str()); + return; + } + if (this->has_state() && this->active_index_ == *index) + return; + ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_, state.c_str()); + this->publish_state(*index); +} + +void HomeassistantSelect::dump_config() { + LOG_SELECT("", "Homeassistant Select", this); + ESP_LOGCONFIG(TAG, + " Entity ID: '%s'\n" + " Max options: %u\n" + " Options buffer size: %u", + this->entity_id_, this->max_options_, this->options_buffer_size_); +} + +float HomeassistantSelect::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } + +void HomeassistantSelect::control(size_t index) { + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "No clients connected to API server"); + return; + } + + static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option"); + static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option"); + static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + static constexpr auto OPTION_KEY = StringRef::from_lit("option"); + static constexpr char INPUT_PREFIX[] = "input_"; + + api::HomeassistantActionRequest resp; + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = SERVICE_INPUT_SELECT; + } else { + resp.service = SERVICE_SELECT; + } + + resp.data.init(2); + auto &entity_id = resp.data.emplace_back(); + entity_id.key = ENTITY_ID_KEY; + entity_id.value = StringRef(this->entity_id_); + + auto &option = resp.data.emplace_back(); + option.key = OPTION_KEY; + option.value = StringRef(this->option_at(index)); + + api::global_api_server->send_homeassistant_action(resp); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/select/homeassistant_select.h b/esphome/components/homeassistant/select/homeassistant_select.h new file mode 100644 index 0000000000..843e07e60e --- /dev/null +++ b/esphome/components/homeassistant/select/homeassistant_select.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +/** Mirrors a Home Assistant `select` or `input_select` entity. + * + * The options are read from the entity's `options` attribute at runtime. They are stored in a buffer of + * `options_buffer_size` bytes and a list of at most `max_options` entries, both reserved in setup(), so a + * change of options never allocates. + */ +class HomeassistantSelect final : public select::Select, public Component { + public: + HomeassistantSelect(uint8_t max_options, uint16_t options_buffer_size) + : options_buffer_size_(options_buffer_size), max_options_(max_options) {} + + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + + protected: + void options_changed_(StringRef options); + void state_changed_(StringRef state); + + void control(size_t index) override; + + const char *entity_id_{nullptr}; + + private: + // The traits point at option_list_, whose entries point into options_buffer_; both are sized once in + // setup() and must match options_buffer_size_ and max_options_ + std::unique_ptr options_buffer_; + std::unique_ptr option_list_; + uint16_t options_buffer_size_; + uint8_t max_options_; +}; + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/text/__init__.py b/esphome/components/homeassistant/text/__init__.py new file mode 100644 index 0000000000..6ca920bac7 --- /dev/null +++ b/esphome/components/homeassistant/text/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import text +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["api"] + +SUPPORTED_DOMAINS = [ + "input_text", + "text", +] + +HomeassistantText = homeassistant_ns.class_( + "HomeassistantText", text.Text, cg.Component +) + +CONFIG_SCHEMA = cv.All( + text.text_schema(HomeassistantText, mode="TEXT") + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("text", SUPPORTED_DOMAINS), +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + var = await text.new_text(config) + await cg.register_component(var, config) + setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp new file mode 100644 index 0000000000..a8fabd5e14 --- /dev/null +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -0,0 +1,107 @@ +#include "homeassistant_text.h" + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +static const char *const TAG = "homeassistant.text"; + +void HomeassistantText::state_changed_(StringRef state) { + if (state == this->state) { + return; + } + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, state.c_str()); + this->publish_state(state.c_str(), state.size()); +} + +void HomeassistantText::min_retrieved_(StringRef min) { + auto min_value = parse_number(min.c_str()); + if (!min_value.has_value()) { + ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_, min.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Min retrieved: %s", this->entity_id_, min.c_str()); + this->traits.set_min_length(min_value.value()); +} + +void HomeassistantText::max_retrieved_(StringRef max) { + auto max_value = parse_number(max.c_str()); + if (!max_value.has_value()) { + ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_, max.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Max retrieved: %s", this->entity_id_, max.c_str()); + this->traits.set_max_length(max_value.value()); +} + +void HomeassistantText::mode_retrieved_(StringRef mode) { + if (mode == "text") { + this->traits.set_mode(text::TEXT_MODE_TEXT); + } else if (mode == "password") { + this->traits.set_mode(text::TEXT_MODE_PASSWORD); + } else { + ESP_LOGW(TAG, "'%s': Unknown 'mode' value '%s'", this->entity_id_, mode.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Mode retrieved: %s", this->entity_id_, mode.c_str()); +} + +void HomeassistantText::setup() { + api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr, + [this](StringRef state) { this->state_changed_(state); }); + + api::global_api_server->get_home_assistant_state(this->entity_id_, "min", + [this](StringRef min) { this->min_retrieved_(min); }); + api::global_api_server->get_home_assistant_state(this->entity_id_, "max", + [this](StringRef max) { this->max_retrieved_(max); }); + api::global_api_server->get_home_assistant_state(this->entity_id_, "mode", + [this](StringRef mode) { this->mode_retrieved_(mode); }); + // The "pattern" attribute is not fetched: Home Assistant sends "None" when it is unset, + // and a runtime pattern would need its own buffer. +} + +void HomeassistantText::dump_config() { + LOG_TEXT("", "Homeassistant Text", this); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); +} + +float HomeassistantText::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } + +void HomeassistantText::control(const std::string &value) { + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "No clients connected to API server"); + return; + } + + static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value"); + static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value"); + static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + static constexpr auto VALUE_KEY = StringRef::from_lit("value"); + static constexpr char INPUT_PREFIX[] = "input_"; + + api::HomeassistantActionRequest resp; + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = SERVICE_INPUT_TEXT; + } else { + resp.service = SERVICE_TEXT; + } + + resp.data.init(2); + auto &entity_id_kv = resp.data.emplace_back(); + entity_id_kv.key = ENTITY_ID_KEY; + entity_id_kv.value = StringRef(this->entity_id_); + + auto &value_kv = resp.data.emplace_back(); + value_kv.key = VALUE_KEY; + value_kv.value = StringRef(value.data(), value.size()); + + api::global_api_server->send_homeassistant_action(resp); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/text/homeassistant_text.h b/esphome/components/homeassistant/text/homeassistant_text.h new file mode 100644 index 0000000000..978dfdae01 --- /dev/null +++ b/esphome/components/homeassistant/text/homeassistant_text.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/components/text/text.h" +#include "esphome/core/component.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +class HomeassistantText final : public text::Text, public Component { + public: + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + + protected: + void state_changed_(StringRef state); + void min_retrieved_(StringRef min); + void max_retrieved_(StringRef max); + void mode_retrieved_(StringRef mode); + + void control(const std::string &value) override; + + const char *entity_id_{nullptr}; +}; + +} // namespace esphome::homeassistant diff --git a/tests/component_tests/homeassistant/button_invalid.yaml b/tests/component_tests/homeassistant/button_invalid.yaml new file mode 100644 index 0000000000..f2d488ebb8 --- /dev/null +++ b/tests/component_tests/homeassistant/button_invalid.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +button: + - platform: homeassistant + name: HA button + entity_id: sensor.example diff --git a/tests/component_tests/homeassistant/button_valid.yaml b/tests/component_tests/homeassistant/button_valid.yaml new file mode 100644 index 0000000000..12676e32dd --- /dev/null +++ b/tests/component_tests/homeassistant/button_valid.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +button: + - platform: homeassistant + id: ha_button + name: HA button + entity_id: button.example + - platform: homeassistant + id: ha_input_button + name: HA input_button + entity_id: input_button.example diff --git a/tests/component_tests/homeassistant/select_invalid.yaml b/tests/component_tests/homeassistant/select_invalid.yaml new file mode 100644 index 0000000000..18b7887f7f --- /dev/null +++ b/tests/component_tests/homeassistant/select_invalid.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +select: + - platform: homeassistant + name: HA select + entity_id: sensor.example diff --git a/tests/component_tests/homeassistant/select_valid.yaml b/tests/component_tests/homeassistant/select_valid.yaml new file mode 100644 index 0000000000..7ca96ed203 --- /dev/null +++ b/tests/component_tests/homeassistant/select_valid.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +select: + - platform: homeassistant + id: ha_select + name: HA select + entity_id: select.example + - platform: homeassistant + id: ha_input_select + name: HA input_select + entity_id: input_select.example + max_options: 4 + options_buffer_size: 64 diff --git a/tests/component_tests/homeassistant/test_button.py b/tests/component_tests/homeassistant/test_button.py new file mode 100644 index 0000000000..eef541144f --- /dev/null +++ b/tests/component_tests/homeassistant/test_button.py @@ -0,0 +1,34 @@ +"""Tests for the homeassistant button platform.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + +DIR = Path(__file__).parent + + +def test_button_valid_domains(generate_main: Callable[[str | Path], str]) -> None: + """Both button and input_button entities are accepted and wired up.""" + main_cpp = generate_main(DIR / "button_valid.yaml") + + assert "homeassistant::HomeassistantButton" in main_cpp + assert 'ha_button->set_entity_id("button.example");' in main_cpp + assert 'ha_input_button->set_entity_id("input_button.example");' in main_cpp + assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines) + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + + +def test_button_invalid_domain(capsys: pytest.CaptureFixture[str]) -> None: + """An entity from an unsupported domain is rejected.""" + CORE.config_path = DIR / "button_invalid.yaml" + result = read_config({}) + + assert result is None + assert ( + "Entity ID sensor.example is not supported by the button platform." + in capsys.readouterr().out + ) diff --git a/tests/component_tests/homeassistant/test_select.py b/tests/component_tests/homeassistant/test_select.py new file mode 100644 index 0000000000..3fc78a6693 --- /dev/null +++ b/tests/component_tests/homeassistant/test_select.py @@ -0,0 +1,36 @@ +"""Tests for the homeassistant select platform.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + +DIR = Path(__file__).parent + + +def test_select_valid_domains(generate_main: Callable[[str | Path], str]) -> None: + """Both select and input_select entities are accepted and wired up.""" + main_cpp = generate_main(DIR / "select_valid.yaml") + + # Default and configured option storage are passed to the constructor + assert "new(ha_select) homeassistant::HomeassistantSelect(16, 256);" in main_cpp + assert "new(ha_input_select) homeassistant::HomeassistantSelect(4, 64);" in main_cpp + assert 'ha_select->set_entity_id("select.example");' in main_cpp + assert 'ha_input_select->set_entity_id("input_select.example");' in main_cpp + assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines) + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + + +def test_select_invalid_domain(capsys: pytest.CaptureFixture[str]) -> None: + """An entity from an unsupported domain is rejected.""" + CORE.config_path = DIR / "select_invalid.yaml" + result = read_config({}) + + assert result is None + assert ( + "Entity ID sensor.example is not supported by the select platform." + in capsys.readouterr().out + ) diff --git a/tests/component_tests/homeassistant/test_text.py b/tests/component_tests/homeassistant/test_text.py new file mode 100644 index 0000000000..181e0e4e42 --- /dev/null +++ b/tests/component_tests/homeassistant/test_text.py @@ -0,0 +1,37 @@ +"""Tests for the homeassistant text platform.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + +DIR = Path(__file__).parent + + +def test_text_valid_domains(generate_main: Callable[[str | Path], str]) -> None: + """Both text and input_text entities are accepted and wired up.""" + main_cpp = generate_main(DIR / "text_valid.yaml") + + assert "homeassistant::HomeassistantText" in main_cpp + assert 'ha_text->set_entity_id("text.example");' in main_cpp + assert 'ha_input_text->set_entity_id("input_text.example");' in main_cpp + assert "ha_text->traits.set_mode(text::TEXT_MODE_TEXT);" in main_cpp + assert "ha_text->traits.set_min_length(0);" in main_cpp + assert "ha_text->traits.set_max_length(255);" in main_cpp + assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines) + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + + +def test_text_invalid_domain(capsys: pytest.CaptureFixture[str]) -> None: + """An entity from an unsupported domain is rejected.""" + CORE.config_path = DIR / "text_invalid.yaml" + result = read_config({}) + + assert result is None + assert ( + "Entity ID sensor.example is not supported by the text platform." + in capsys.readouterr().out + ) diff --git a/tests/component_tests/homeassistant/text_invalid.yaml b/tests/component_tests/homeassistant/text_invalid.yaml new file mode 100644 index 0000000000..ba78393151 --- /dev/null +++ b/tests/component_tests/homeassistant/text_invalid.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +text: + - platform: homeassistant + name: HA text + entity_id: sensor.example diff --git a/tests/component_tests/homeassistant/text_valid.yaml b/tests/component_tests/homeassistant/text_valid.yaml new file mode 100644 index 0000000000..dc1e507a13 --- /dev/null +++ b/tests/component_tests/homeassistant/text_valid.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +text: + - platform: homeassistant + id: ha_text + name: HA text + entity_id: text.example + - platform: homeassistant + id: ha_input_text + name: HA input_text + entity_id: input_text.example diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 9bc0defae2..078036598a 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -26,10 +26,6 @@ esphome: variables: my_variable: !lambda "return id(ha_hello_world_temperature).state;" -wifi: - ssid: MySSID - password: password1 - api: switch: @@ -75,6 +71,33 @@ number: entity_id: input_number.hello_world id: ha_hello_world_input_number +select: + - platform: homeassistant + entity_id: select.hello_world + id: ha_hello_world_select + - platform: homeassistant + entity_id: input_select.hello_world + id: ha_hello_world_input_select + max_options: 8 + options_buffer_size: 128 + +text: + - platform: homeassistant + entity_id: text.hello_world + id: ha_hello_world_text_input + - platform: homeassistant + entity_id: input_text.hello_world + id: ha_hello_world_input_text + mode: password + +button: + - platform: homeassistant + entity_id: button.hello_world + id: ha_hello_world_button + - platform: homeassistant + entity_id: input_button.hello_world + id: ha_hello_world_input_button + sensor: - platform: homeassistant entity_id: sensor.hello_world diff --git a/tests/components/homeassistant/test.bk72xx-ard.yaml b/tests/components/homeassistant/test.bk72xx-ard.yaml index 25cb37a0b4..ee840547bc 100644 --- a/tests/components/homeassistant/test.bk72xx-ard.yaml +++ b/tests/components/homeassistant/test.bk72xx-ard.yaml @@ -1,2 +1,6 @@ packages: common: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/homeassistant/test.esp32-idf.yaml b/tests/components/homeassistant/test.esp32-idf.yaml index 25cb37a0b4..ee840547bc 100644 --- a/tests/components/homeassistant/test.esp32-idf.yaml +++ b/tests/components/homeassistant/test.esp32-idf.yaml @@ -1,2 +1,6 @@ packages: common: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/homeassistant/test.esp8266-ard.yaml b/tests/components/homeassistant/test.esp8266-ard.yaml index 25cb37a0b4..ee840547bc 100644 --- a/tests/components/homeassistant/test.esp8266-ard.yaml +++ b/tests/components/homeassistant/test.esp8266-ard.yaml @@ -1,2 +1,6 @@ packages: common: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/homeassistant/test.host.yaml b/tests/components/homeassistant/test.host.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/homeassistant/test.host.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/homeassistant/test.ln882x-ard.yaml b/tests/components/homeassistant/test.ln882x-ard.yaml index 25cb37a0b4..ee840547bc 100644 --- a/tests/components/homeassistant/test.ln882x-ard.yaml +++ b/tests/components/homeassistant/test.ln882x-ard.yaml @@ -1,2 +1,6 @@ packages: common: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/homeassistant/test.rp2040-ard.yaml b/tests/components/homeassistant/test.rp2040-ard.yaml index 25cb37a0b4..ee840547bc 100644 --- a/tests/components/homeassistant/test.rp2040-ard.yaml +++ b/tests/components/homeassistant/test.rp2040-ard.yaml @@ -1,2 +1,6 @@ packages: common: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/integration/fixtures/api_homeassistant_entities.yaml b/tests/integration/fixtures/api_homeassistant_entities.yaml new file mode 100644 index 0000000000..85d73bacb9 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_entities.yaml @@ -0,0 +1,62 @@ +esphome: + name: ha-entities + +host: + +api: + actions: + - action: dump_select + variables: + tag: string + then: + - logger.log: + format: "dump %s: has_state=%d index=%d option='%s'" + args: + - tag.c_str() + - id(ha_select).has_state() + - (int) id(ha_select).active_index().value_or(-1) + - id(ha_select).current_option().c_str() + +logger: + level: DEBUG + +select: + - platform: homeassistant + name: HA Select + id: ha_select + entity_id: select.mode + internal: false + max_options: 4 + options_buffer_size: 40 + on_value: + - logger.log: + format: "select value '%s' at %u" + args: [x.c_str(), (unsigned) i] + - platform: homeassistant + name: HA Input Select + entity_id: input_select.colour + internal: false + +text: + - platform: homeassistant + name: HA Text + entity_id: text.note + internal: false + on_value: + - logger.log: + format: "text value '%s'" + args: [x.c_str()] + - platform: homeassistant + name: HA Input Text + entity_id: input_text.name + internal: false + +button: + - platform: homeassistant + name: HA Button + entity_id: button.doorbell + internal: false + - platform: homeassistant + name: HA Input Button + entity_id: input_button.reset + internal: false diff --git a/tests/integration/test_api_homeassistant_entities.py b/tests/integration/test_api_homeassistant_entities.py new file mode 100644 index 0000000000..a45454f614 --- /dev/null +++ b/tests/integration/test_api_homeassistant_entities.py @@ -0,0 +1,213 @@ +"""Test the homeassistant select, text and button platforms against a fake Home Assistant.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +from aioesphomeapi import APIClient, HomeassistantServiceCall, UserService +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + +SUBSCRIPTIONS = { + ("select.mode", "options"), + ("select.mode", None), + ("input_select.colour", "options"), + ("input_select.colour", None), + ("text.note", None), + ("text.note", "min"), + ("text.note", "max"), + ("text.note", "mode"), + ("input_text.name", None), + ("input_text.name", "min"), + ("input_text.name", "max"), + ("input_text.name", "mode"), +} + +# Each is rejected by the options parser, leaving the previous options in place +MALFORMED_OPTIONS = ( + "None", + "", + "[", + "['a'", + "['a',", + "['a' 'b']", + "['a'] x", + "[1]", + "['a\\q']", + "['a\\", + "['\\x4']", + "['\\x0']", + "['\\x00']", + "['\\U00110000']", +) + + +@pytest.mark.asyncio +async def test_api_homeassistant_entities( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Options, state and actions of the homeassistant select, text and button platforms.""" + 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() + + 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) + + async def next_call() -> HomeassistantServiceCall: + return await asyncio.wait_for(calls.get(), timeout=5.0) + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + keys = {entity.name: entity.key for entity in entities} + dump_select = next(s for s in services if s.name == "dump_select") + client.subscribe_service_calls(calls.put_nowait) + 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}") + + await _check_select(client, waiter, keys, dump_select, next_call) + await _check_text(client, waiter, keys, next_call) + + client.button_command(keys["HA Button"]) + call = await next_call() + assert call.service == "button.press" + assert call.data == {"entity_id": "button.doorbell"} + client.button_command(keys["HA Input Button"]) + call = await next_call() + assert call.service == "input_button.press" + assert call.data == {"entity_id": "input_button.reset"} + + assert calls.empty() + + +async def _check_select( + client: APIClient, + waiter: LineWaiter, + keys: dict[str, int], + dump_select: UserService, + next_call: Callable[[], Awaitable[HomeassistantServiceCall]], +) -> None: + async def dump(tag: str) -> str: + await client.execute_service(dump_select, {"tag": tag}) + return await waiter.wait_for(f"dump {tag}:", timeout=5.0) + + # Built with str() exactly as Home Assistant does, so every escape it emits is covered + options = ["Low", "It's", "café\t\x7f\\", "\u200b\U000e0001\r\n'\""] + client.send_home_assistant_state("select.mode", "options", str(options)) + await waiter.wait_for("'select.mode': Got 4 options", timeout=5.0) + + client.send_home_assistant_state("select.mode", "", "It's") + await waiter.wait_for("select value 'It's' at 1", timeout=5.0) + assert "has_state=1 index=1 option='It's'" in await dump("first") + + # Decoded options round trip through select.select_option unchanged + for option in options[2:]: + client.select_command(keys["HA Select"], option) + call = await next_call() + assert call.service == "select.select_option" + assert call.data == {"entity_id": "select.mode", "option": option} + + # The active option keeps its state when it moves; double quoted options parse too + client.send_home_assistant_state("select.mode", "options", "[\"It's\", 'L\\\"ow']") + await waiter.wait_for("'select.mode': Got 2 options", timeout=5.0) + assert "has_state=1 index=0 option='It's'" in await dump("moved") + client.select_command(keys["HA Select"], 'L"ow') + assert (await next_call()).data["option"] == 'L"ow' + + # Dropping the active option clears the state + client.send_home_assistant_state( + "select.mode", "options", "[ 'Low' , 'Max' , 'High' ]" + ) + await waiter.wait_for("'select.mode': Got 3 options", timeout=5.0) + assert "has_state=0" in await dump("dropped") + + client.send_home_assistant_state("select.mode", "", "unavailable") + await waiter.wait_for("State 'unavailable' is not one of the options", timeout=5.0) + + # Rejected lists keep the previous options + client.send_home_assistant_state("select.mode", "options", str(list("abcde"))) + await waiter.wait_for("5 options exceed max_options (4)", timeout=5.0) + client.send_home_assistant_state("select.mode", "options", str(["x" * 50])) + await waiter.wait_for( + "Options need 51 bytes, more than options_buffer_size (40)", timeout=5.0 + ) + for malformed in MALFORMED_OPTIONS: + client.send_home_assistant_state("select.mode", "options", malformed) + client.send_home_assistant_state("select.mode", "", "Max") + await waiter.wait_for("select value 'Max' at 1", timeout=5.0) + assert sum("Can't parse options" in line for line in waiter.lines) == len( + MALFORMED_OPTIONS + ) + + # Repeating the active state does not publish again + client.send_home_assistant_state("select.mode", "", "Max") + client.send_home_assistant_state("select.mode", "options", "[]") + await waiter.wait_for("'select.mode': Got 0 options", timeout=5.0) + assert sum("select value 'Max'" in line for line in waiter.lines) == 1 + assert "has_state=0 index=-1" in await dump("empty") + + client.send_home_assistant_state( + "input_select.colour", "options", "['Red', 'Green']" + ) + await waiter.wait_for("'input_select.colour': Got 2 options", timeout=5.0) + client.select_command(keys["HA Input Select"], "Green") + call = await next_call() + assert call.service == "input_select.select_option" + assert call.data == {"entity_id": "input_select.colour", "option": "Green"} + + +async def _check_text( + client: APIClient, + waiter: LineWaiter, + keys: dict[str, int], + next_call: Callable[[], Awaitable[HomeassistantServiceCall]], +) -> None: + client.send_home_assistant_state("text.note", "min", "2") + client.send_home_assistant_state("text.note", "max", "10") + client.send_home_assistant_state("text.note", "mode", "password") + await waiter.wait_for("'text.note': Mode retrieved: password", timeout=5.0) + + client.send_home_assistant_state("input_text.name", "min", "abc") + client.send_home_assistant_state("input_text.name", "max", "x") + client.send_home_assistant_state("input_text.name", "mode", "fancy") + await waiter.wait_for( + "'input_text.name': Unknown 'mode' value 'fancy'", timeout=5.0 + ) + await waiter.wait_for("Can't convert 'min' value 'abc'", timeout=5.0) + await waiter.wait_for("Can't convert 'max' value 'x'", timeout=5.0) + client.send_home_assistant_state("input_text.name", "mode", "text") + await waiter.wait_for("'input_text.name': Mode retrieved: text", timeout=5.0) + + client.send_home_assistant_state("text.note", "", "hello") + client.send_home_assistant_state("text.note", "", "hello") + client.send_home_assistant_state("text.note", "", "there") + await waiter.wait_for("text value 'there'", timeout=5.0) + assert sum("text value 'hello'" in line for line in waiter.lines) == 1 + + # The limits from Home Assistant apply: too short and too long are refused + client.text_command(keys["HA Text"], "a") + client.text_command(keys["HA Text"], "abcdefghijk") + client.text_command(keys["HA Text"], "fine") + call = await next_call() + assert call.service == "text.set_value" + assert call.data == {"entity_id": "text.note", "value": "fine"} + + client.text_command(keys["HA Input Text"], "Bob") + call = await next_call() + assert call.service == "input_text.set_value" + assert call.data == {"entity_id": "input_text.name", "value": "Bob"}