Compare commits

...
Author SHA1 Message Date
J. Nick Koston a88003900b Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:38:46 -06:00
J. Nick Koston eb06dddfc8 Merge remote-tracking branch 'upstream/dev' into homeassistant-button-apply
# Conflicts:
#	esphome/components/api/wizard.py
2026-11-12 00:41:09 -06:00
J. Nick Koston f7a5950926 [homeassistant] Let entities take their wizard buffer instead of the wizard setting it
Every linked input is a homeassistant entity, so each one sets its entity ID
from home_assistant_entity_id(), which returns the shared wizard buffer. The
api wizard no longer calls set_entity_id() or needs a reader registry.
2026-11-06 18:52:29 -06:00
J. Nick Koston d51b4c458a Merge remote-tracking branch 'origin/dev' into homeassistant-button-apply
# Conflicts:
#	esphome/components/homeassistant/__init__.py
#	tests/component_tests/api/test_wizard.py
2026-11-06 18:50:18 -06:00
J. Nick Koston 4160737fe3 Explain the dropped dump_config block in the description 2026-10-29 20:40:38 -10:00
J. Nick Koston a3b7430aea [homeassistant] Use ApplyButton for the Home Assistant button
The Home Assistant button becomes a button::ApplyButton whose generated
press function calls homeassistant::press_button_entity with the entity
ID, a literal or the wizard buffer, so the HomeassistantButton class and
its Component base go away.
2026-10-29 20:23:18 -10:00
7 changed files with 100 additions and 53 deletions

No files matched your search

+38 -9
View File
@@ -83,6 +83,8 @@ class WizardData:
# The entities that components gave a role, and the role, in the order they were registered
roles: list[tuple[ID, str]] = field(default_factory=list)
# The entity ID buffer of each input, by input ID, created on first use
buffers: dict[str, cg.MockObj] = field(default_factory=dict)
def _get_data() -> WizardData:
@@ -257,6 +259,38 @@ WIZARD_DOMAIN_LIMITED_PLATFORMS = (
)
def _wizard_input_size(conf: ConfigType) -> int:
return conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
def _wizard_input_buffer(conf: ConfigType) -> cg.MockObj:
"""The buffer an input's entity ID is written to, declared once by whichever caller comes first."""
input_id = _wizard_input_id(conf)
data = _get_data()
if (buffer := data.buffers.get(input_id.id)) is None:
owner = ID(input_id.id, type=WizardInput)
buffer = cg.static_buffer(owner, cg.char, _wizard_input_size(conf))
data.buffers[input_id.id] = buffer
return buffer
def _linked_inputs(api_config: ConfigType) -> dict[str, ConfigType]:
"""The inputs linked to a homeassistant entity, by the entity's ID."""
if (wizard := api_config.get(CONF_WIZARD)) is None:
return {}
return {
conf[CONF_ENTITY].id: conf
for conf in _wizard_inputs(wizard)
if CONF_ENTITY in conf
}
def wizard_input_buffer(entity_id: ID) -> cg.MockObj | None:
"""The buffer holding the entity ID of a linked wizard input, or None if the entity is not one."""
conf = _linked_inputs(CORE.config.get(API_DOMAIN, {})).get(entity_id.id)
return None if conf is None else _wizard_input_buffer(conf)
# The C++ base class of each kind of entity a wizard page can show, and the platform an API client
# knows it by. An entity key is only unique within its platform, so the wizard sends both.
WIZARD_ENTITY_PLATFORMS: dict[str, str] = {
@@ -300,11 +334,7 @@ def _wizard_entity_platform(
def wizard_input_ids(api_config: ConfigType) -> set[str]:
"""The IDs of the entities that are linked inputs of the wizard in the given api config."""
if (wizard := api_config.get(CONF_WIZARD)) is None:
return set()
return {
conf[CONF_ENTITY].id for conf in _wizard_inputs(wizard) if CONF_ENTITY in conf
}
return set(_linked_inputs(api_config))
def _wizard_input_declaration(
@@ -584,14 +614,13 @@ async def to_code(wizard: ConfigType) -> None:
entries: list[cg.RawExpression] = []
for conf in _wizard_inputs(wizard):
input_id = _wizard_input_id(conf)
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
buffer = cg.static_buffer(ID(input_id.id, type=WizardInput), cg.char, size)
# A linked entity takes its buffer from wizard_input_buffer()
buffer = _wizard_input_buffer(conf)
if CONF_ID in conf:
cg.new_Pvariable(input_id, buffer)
else:
cg.add((await cg.get_variable(input_id)).set_entity_id(buffer))
key = fnv1_hash(input_id.id)
if conf[CONF_MULTIPLE]:
size = _wizard_input_size(conf)
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
else:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
+11 -3
View File
@@ -6,7 +6,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
from esphome.cpp_generator import MockObj
from esphome.cpp_generator import Expression, MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -81,11 +81,19 @@ async def _emit_state_subscription_count_define() -> None:
cg.add_define("HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT", count)
def home_assistant_entity_id(config: ConfigType) -> Expression:
"""The entity ID as a C string: a literal, or the API's buffer for a wizard input."""
from esphome.components.api import wizard
if (buffer := wizard.wizard_input_buffer(config[CONF_ID])) is not None:
return buffer
return cg.safe_exp(config[CONF_ENTITY_ID])
def setup_home_assistant_entity(
var: MockObj, config: ConfigType, state_subscription_count: int = 1
) -> None:
# A wizard input has no entity_id; the api wizard sets it
cg.add_optional(var.set_entity_id, config.get(CONF_ENTITY_ID))
cg.add(var.set_entity_id(home_assistant_entity_id(config)))
cg.add_optional(var.set_attribute, config.get(CONF_ATTRIBUTE))
cg.add_define("USE_API_HOMEASSISTANT_STATES")
if state_subscription_count > 0:
@@ -6,8 +6,7 @@ from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
home_assistant_entity_id,
validate_entity_domain,
)
@@ -19,14 +18,11 @@ SUPPORTED_DOMAINS = [
"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),
button.button_schema(button.ApplyButton).extend(
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA
),
validate_entity_domain("button", SUPPORTED_DOMAINS),
)
@@ -35,7 +31,10 @@ FINAL_VALIDATE_SCHEMA = final_validate_entity_id
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)
# Button only calls services, does not subscribe to state
setup_home_assistant_entity(var, config, state_subscription_count=0)
cg.add_define("USE_API_HOMEASSISTANT_STATES")
entity_id = home_assistant_entity_id(config)
await button.sub_buttons(config).create(
config,
None,
apply=f"homeassistant::press_button_entity(entity, {entity_id})",
)
@@ -12,22 +12,15 @@ namespace esphome::homeassistant {
ESPHOME_LOG_TAG(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() {
void press_button_entity([[maybe_unused]] button::Button *button, const char *entity_id) {
if (!api::global_api_server->is_connected()) {
ESP_LOGE(TAG, "No clients connected to API server");
return;
}
#ifdef USE_API_WIZARD_LINKED_INPUTS
if (this->entity_id_[0] == '\0') {
ESP_LOGW(TAG, "'%s': No entity ID set yet", LOG_STR_ARG(this->get_log_name()));
if (entity_id[0] == '\0') {
ESP_LOGW(TAG, "'%s': No entity ID set yet", LOG_STR_ARG(button->get_log_name()));
return;
}
#endif
@@ -39,14 +32,14 @@ void HomeassistantButton::press_action() {
api::HomeassistantActionRequest resp;
// An input helper entity id starts with the "input_" its action name starts with
const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_BUTTON, sizeof("input_") - 1) == 0;
const bool input_helper = ESPHOME_strncmp_P(entity_id, SERVICE_INPUT_BUTTON, sizeof("input_") - 1) == 0;
resp.service =
input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_BUTTON) : ProgmemStringRef::from_lit(SERVICE_BUTTON);
resp.data.init(1);
auto &entity_id_kv = resp.data.emplace_back();
entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY);
entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_));
entity_id_kv.value = ProgmemStringRef(StringRef(entity_id));
api::global_api_server->send_homeassistant_action(resp);
}
@@ -1,21 +1,10 @@
#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};
};
/// Ask Home Assistant to press entity_id, with input_button.press for an input_button helper.
void press_button_entity(button::Button *button, const char *entity_id);
} // namespace esphome::homeassistant
+22 -1
View File
@@ -651,6 +651,18 @@ def _buffer(main_cpp: str, input_id: str) -> str:
return re.search(rf"\{{{fnv1_hash(input_id)}u, (\w+)", main_cpp)[1]
def _button_press_call(button_id: str, main_cpp: str) -> str:
"""The one statement of the generated press function of an ApplyButton."""
fn = re.search(rf"new\({button_id}\) button::ApplyButton<(\w+)>\(\);", main_cpp)
assert fn is not None
body = re.search(
rf"static void {fn.group(1)}\(button::Button \* entity\) \{{\n (.*?)\n\}}",
main_cpp,
)
assert body is not None
return body.group(1)
def test_text_select_and_button_default_to_their_domains(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
@@ -669,8 +681,11 @@ def test_text_select_and_button_default_to_their_domains(
# A number takes number and input_number entities, and the other sensors take anything
assert filters[fnv1_hash("ha_number")] == [{"domain": ["input_number", "number"]}]
assert filters[fnv1_hash("ha_binary")] is None
for entity in ("ha_txt", "ha_select", "ha_button"):
for entity in ("ha_txt", "ha_select"):
assert f"{entity}->set_entity_id({_buffer(main_cpp, entity)});" in main_cpp
assert _button_press_call("ha_button", main_cpp) == (
f"homeassistant::press_button_entity(entity, {_buffer(main_cpp, 'ha_button')});"
)
@pytest.mark.parametrize(
@@ -885,7 +900,13 @@ def test_inputs_have_buffers_hashes_and_a_table(
assert len(set(buffers.values())) == len(entities)
assert get_define_value("API_WIZARD_INPUT_COUNT") == str(len(entities))
for entity in entities[:-2]:
if entity == "ha_button":
continue
assert f"{entity}->set_entity_id({buffers[entity]});" in main_cpp
# The button's generated press function reads the buffer, which is declared before it
call = f"homeassistant::press_button_entity(entity, {buffers['ha_button']});"
assert _button_press_call("ha_button", main_cpp) == call
assert main_cpp.index(f"static char {buffers['ha_button']}[") < main_cpp.index(call)
# The standalone inputs are objects that read the same buffer
assert f"WizardInput({buffers['weather_input']})" in main_cpp
assert f"WizardInput({buffers['unset_input']})" in main_cpp
@@ -2,6 +2,7 @@
from collections.abc import Callable
from pathlib import Path
import re
import pytest
@@ -15,9 +16,16 @@ def test_button_valid_domains(generate_main: Callable[[str | Path], str]) -> Non
"""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
for button_id, entity_id in (
("ha_button", "button.example"),
("ha_input_button", "input_button.example"),
):
fn = re.search(rf"new\({button_id}\) button::ApplyButton<(\w+)>\(\);", main_cpp)
assert fn is not None
assert (
f"static void {fn.group(1)}(button::Button * entity) {{\n"
f' homeassistant::press_button_entity(entity, "{entity_id}");\n}}'
) 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)