[homeassistant] Add text, select and button platforms (#19964)

This commit is contained in:
Jesse Hills
2026-10-07 11:46:36 +13:00
committed by GitHub
parent ab5a4b4f04
commit e040647530
28 changed files with 1195 additions and 4 deletions
+3
View File
@@ -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
@@ -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)
@@ -0,0 +1,47 @@
#include "homeassistant_button.h"
#include <cstring>
#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
@@ -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
@@ -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)
@@ -0,0 +1,258 @@
#include "homeassistant_select.h"
#include <cstring>
#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<typename C> 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<char>(code_point));
} else if (code_point < 0x800) {
on_char(static_cast<char>(0xC0 | (code_point >> 6)));
on_char(static_cast<char>(0x80 | (code_point & 0x3F)));
} else if (code_point < 0x10000) {
on_char(static_cast<char>(0xE0 | (code_point >> 12)));
on_char(static_cast<char>(0x80 | ((code_point >> 6) & 0x3F)));
on_char(static_cast<char>(0x80 | (code_point & 0x3F)));
} else {
on_char(static_cast<char>(0xF0 | (code_point >> 18)));
on_char(static_cast<char>(0x80 | ((code_point >> 12) & 0x3F)));
on_char(static_cast<char>(0x80 | ((code_point >> 6) & 0x3F)));
on_char(static_cast<char>(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<typename C, typename O> 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<size_t>(end - pos) < digits)
return false;
auto code_point = parse_hex<uint32_t>(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<char[]>(this->options_buffer_size_);
this->option_list_ = std::make_unique<const char *[]>(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<size_t> 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
@@ -0,0 +1,47 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#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<char[]> options_buffer_;
std::unique_ptr<const char *[]> option_list_;
uint16_t options_buffer_size_;
uint8_t max_options_;
};
} // namespace esphome::homeassistant
@@ -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)
@@ -0,0 +1,107 @@
#include "homeassistant_text.h"
#include <cstring>
#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<int>(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<int>(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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
@@ -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
+27 -4
View File
@@ -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
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -0,0 +1,2 @@
packages:
common: !include common.yaml
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -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
@@ -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"}