Compare commits

..
12 changed files with 162 additions and 276 deletions

No files matched your search

+42 -21
View File
@@ -1,6 +1,7 @@
from collections.abc import Awaitable, Callable, Sequence
from contextvars import ContextVar
from dataclasses import dataclass, field
from functools import partial
import logging
import math
import re
@@ -1151,9 +1152,6 @@ IfAction = cg.esphome_ns.class_("IfAction", Action)
WhileAction = cg.esphome_ns.class_("WhileAction", Action)
RepeatAction = cg.esphome_ns.class_("RepeatAction", Action)
WaitUntilAction = cg.esphome_ns.class_("WaitUntilAction", Action, cg.Component)
UpdateComponentAction = cg.esphome_ns.class_("UpdateComponentAction", Action)
SuspendComponentAction = cg.esphome_ns.class_("SuspendComponentAction", Action)
ResumeComponentAction = cg.esphome_ns.class_("ResumeComponentAction", Action)
Automation = cg.esphome_ns.class_("Automation")
TriggerForwarder = cg.esphome_ns.class_("TriggerForwarder")
TriggerOnTrueForwarder = cg.esphome_ns.class_("TriggerOnTrueForwarder")
@@ -1771,25 +1769,44 @@ async def lambda_action_to_code(
return new_lambda_pvariable(action_id, lambda_, StatelessLambdaAction, template_arg)
register_simple_action(
"component.update",
UpdateComponentAction,
maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}),
synchronous=True,
_POLLING_COMPONENT_SCHEMA = maybe_simple_id(
{cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}
)
register_simple_action(
"component.suspend",
SuspendComponentAction,
maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}),
synchronous=True,
)
async def _polling_component_action(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
calls: list[str],
) -> MockObj:
"""Run ``calls`` on the component once it is ready, through ``PollingComponent``.
An if block, not an early return, so actions merged after this one still run.
"""
parent = await _apply_parent(config)
statements = [
f"{cg.PollingComponent} &esphome__component = *{parent};",
"if (esphome__component.is_ready()) {",
*(f" esphome__component.{call};" for call in calls),
"}",
]
return apply_statements_action(action_id, template_arg, args, statements)
for _name, _call in (
("component.update", "update()"),
("component.suspend", "stop_poller()"),
):
register_action(_name, ApplyAction, _POLLING_COMPONENT_SCHEMA, synchronous=True)(
partial(_polling_component_action, calls=[_call])
)
@register_action(
"component.resume",
ResumeComponentAction,
ApplyAction,
maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
@@ -1806,12 +1823,16 @@ async def component_resume_action_to_code(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
comp = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, comp)
if CONF_UPDATE_INTERVAL in config:
template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, cg.uint32)
cg.add(var.set_update_interval(template_))
return var
calls: list[str] = []
if (interval := config.get(CONF_UPDATE_INTERVAL)) is not None:
if isinstance(interval, Lambda):
# Built with the trigger's own argument types and always called, so the body
# may modify its by-value arguments
lamb = await cg.process_lambda(interval, args, return_type=cg.uint32)
interval = CallExpression(lamb, *(MockObj(name) for _, name in args))
calls.append(f"set_update_interval({cg.safe_exp(interval)})")
calls.append("start_poller()")
return await _polling_component_action(config, action_id, template_arg, args, calls)
async def build_action(
+15 -32
View File
@@ -144,7 +144,6 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
CONF_LISTEN_BACKLOG = "listen_backlog"
CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_ON_CLIENT_SYNCED = "on_client_synced"
CONF_OUTGOING_CONNECTION = "outgoing_connection"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
@@ -454,9 +453,6 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation(
single=True
),
cv.Optional(CONF_ON_CLIENT_SYNCED): automation.validate_automation(
single=True
),
# Connection limits to prevent memory exhaustion on resource-constrained devices
# Each connection uses ~500-1000 bytes of RAM plus system resources
# Platform defaults based on available RAM and network stack implementation:
@@ -710,34 +706,21 @@ async def to_code(config: ConfigType) -> None:
if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
await wizard.to_code(wizard_config)
for conf_key, define, callback_method in (
(
CONF_ON_CLIENT_CONNECTED,
"USE_API_CLIENT_CONNECTED_TRIGGER",
"set_on_client_connected_callback",
),
(
CONF_ON_CLIENT_DISCONNECTED,
"USE_API_CLIENT_DISCONNECTED_TRIGGER",
"set_on_client_disconnected_callback",
),
(
CONF_ON_CLIENT_SYNCED,
"USE_API_CLIENT_SYNCED_TRIGGER",
"set_on_client_synced_callback",
),
):
if (automation_conf := config.get(conf_key)) is not None:
cg.add_define(define)
await automation.build_callback_automation(
var,
callback_method,
automation.owning_args(
[(cg.StringRef, "client_info"), (cg.StringRef, "client_address")],
automation_conf,
),
automation_conf,
)
if CONF_ON_CLIENT_CONNECTED in config:
cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER")
await automation.build_automation(
var.get_client_connected_trigger(),
[(cg.std_string, "client_info"), (cg.std_string, "client_address")],
config[CONF_ON_CLIENT_CONNECTED],
)
if CONF_ON_CLIENT_DISCONNECTED in config:
cg.add_define("USE_API_CLIENT_DISCONNECTED_TRIGGER")
await automation.build_automation(
var.get_client_disconnected_trigger(),
[(cg.std_string, "client_info"), (cg.std_string, "client_address")],
config[CONF_ON_CLIENT_DISCONNECTED],
)
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
if key := encryption_config.get(CONF_KEY):
+2 -20
View File
@@ -251,9 +251,6 @@ void APIConnection::destroy_active_iterator_() {
this->iterator_storage_.initial_state.~InitialStateIterator();
break;
case ActiveIterator::NONE:
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
case ActiveIterator::CLIENT_SYNCED_PENDING:
#endif
break;
}
this->active_iterator_ = ActiveIterator::NONE;
@@ -418,25 +415,10 @@ void APIConnection::process_active_iterator_() {
} else {
this->process_iterator_batch_(this->iterator_storage_.list_entities);
}
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
} else if (this->active_iterator_ == ActiveIterator::CLIENT_SYNCED_PENDING) {
// Fired after the sync, not on SubscribeStates, so the subscriptions sent with it are active.
// Waits for the batch to drain so a direct send from the automation cannot overtake queued states.
// next_close: a DisconnectRequest this pass already answered; remove is only set next loop.
if (this->deferred_batch_.empty() && !this->flags_.remove && !this->flags_.next_close) {
this->active_iterator_ = ActiveIterator::NONE;
char peername[socket::SOCKADDR_STR_LEN];
this->parent_->get_client_synced_callback().call(StringRef(this->helper_->get_client_name()),
StringRef(this->helper_->get_peername_to(peername)));
}
#endif
} else { // INITIAL_STATE
if (this->iterator_storage_.initial_state.completed()) {
this->destroy_active_iterator_();
this->finalize_iterator_sync_();
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
this->active_iterator_ = ActiveIterator::CLIENT_SYNCED_PENDING;
#endif
} else {
this->process_iterator_batch_(this->iterator_storage_.initial_state);
}
@@ -1954,8 +1936,8 @@ void APIConnection::complete_authentication_() {
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
{
char peername[socket::SOCKADDR_STR_LEN];
this->parent_->get_client_connected_callback().call(StringRef(this->helper_->get_client_name()),
StringRef(this->helper_->get_peername_to(peername)));
this->parent_->get_client_connected_trigger()->trigger(std::string(this->helper_->get_client_name()),
std::string(this->helper_->get_peername_to(peername)));
}
#endif
#ifdef USE_HOMEASSISTANT_TIME
+2 -14
View File
@@ -296,11 +296,7 @@ class APIConnection final : public APIServerConnectionBase {
this->flags_.state_subscription = true;
// Start initial state iterator only if no iterator is active
// If list_entities is running, we'll start initial_state when it completes
if (this->active_iterator_ == ActiveIterator::NONE
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|| this->active_iterator_ == ActiveIterator::CLIENT_SYNCED_PENDING
#endif
) {
if (this->active_iterator_ == ActiveIterator::NONE) {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
}
}
@@ -646,15 +642,7 @@ class APIConnection final : public APIServerConnectionBase {
// Group 2: Iterator union (saves ~16 bytes vs separate iterators)
// These iterators are never active simultaneously - list_entities runs to completion
// before initial_state begins, so we use a union with explicit construction/destruction.
enum class ActiveIterator : uint8_t {
NONE,
LIST_ENTITIES,
INITIAL_STATE,
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
// Initial states sent, on_client_synced not fired yet; uses no iterator storage
CLIENT_SYNCED_PENDING,
#endif
};
enum class ActiveIterator : uint8_t { NONE, LIST_ENTITIES, INITIAL_STATE };
union IteratorUnion {
ListEntitiesIterator list_entities;
+4 -6
View File
@@ -257,11 +257,9 @@ void APIServer::remove_client_(uint8_t client_index) {
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Save client info before closing socket and removal for the trigger
char client_name[CLIENT_INFO_NAME_MAX_LEN];
strncpy(client_name, client->get_name(), sizeof(client_name) - 1);
client_name[sizeof(client_name) - 1] = '\0';
char peername[socket::SOCKADDR_STR_LEN];
client->get_peername_to(peername);
char peername_buf[socket::SOCKADDR_STR_LEN];
std::string client_name(client->get_name());
std::string client_peername(client->get_peername_to(peername_buf));
#endif
// Read before the swap-and-reset below destroys the connection
@@ -304,7 +302,7 @@ void APIServer::remove_client_(uint8_t client_index) {
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Fire trigger after client is removed so api.connected reflects the true state
this->client_disconnected_callback_.call(StringRef(client_name), StringRef(peername));
this->client_disconnected_trigger_.trigger(client_name, client_peername);
#endif
}
+4 -23
View File
@@ -277,27 +277,11 @@ class APIServer final : public Component
const std::vector<UserServiceDescriptor *> &get_user_services() const { return this->user_services_; }
#endif
#if defined(USE_API_CLIENT_CONNECTED_TRIGGER) || defined(USE_API_CLIENT_DISCONNECTED_TRIGGER) || \
defined(USE_API_CLIENT_SYNCED_TRIGGER)
/// Called with the client name and address, which are only valid during the call
using ClientCallback = Callback<void(StringRef, StringRef)>;
#endif
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
template<typename F> void set_on_client_connected_callback(F &&callback) {
this->client_connected_callback_ = ClientCallback::create(std::forward<F>(callback));
}
const ClientCallback &get_client_connected_callback() const { return this->client_connected_callback_; }
Trigger<std::string, std::string> *get_client_connected_trigger() { return &this->client_connected_trigger_; }
#endif
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
template<typename F> void set_on_client_disconnected_callback(F &&callback) {
this->client_disconnected_callback_ = ClientCallback::create(std::forward<F>(callback));
}
#endif
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
template<typename F> void set_on_client_synced_callback(F &&callback) {
this->client_synced_callback_ = ClientCallback::create(std::forward<F>(callback));
}
const ClientCallback &get_client_synced_callback() const { return this->client_synced_callback_; }
Trigger<std::string, std::string> *get_client_disconnected_trigger() { return &this->client_disconnected_trigger_; }
#endif
protected:
@@ -359,13 +343,10 @@ class APIServer final : public Component
// Pointers and pointer-like types first (4 bytes each)
socket::ListenSocket *socket_{nullptr};
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
ClientCallback client_connected_callback_;
Trigger<std::string, std::string> client_connected_trigger_;
#endif
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
ClientCallback client_disconnected_callback_;
#endif
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
ClientCallback client_synced_callback_;
Trigger<std::string, std::string> client_disconnected_trigger_;
#endif
// 4-byte aligned types
-48
View File
@@ -595,52 +595,4 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
uint8_t stop_generation_{0};
};
template<typename... Ts> class UpdateComponentAction : public Action<Ts...> {
public:
UpdateComponentAction(PollingComponent *component) : component_(component) {}
void play(const Ts &...x) override {
if (!this->component_->is_ready())
return;
this->component_->update();
}
protected:
PollingComponent *component_;
};
template<typename... Ts> class SuspendComponentAction : public Action<Ts...> {
public:
SuspendComponentAction(PollingComponent *component) : component_(component) {}
void play(const Ts &...x) override {
if (!this->component_->is_ready())
return;
this->component_->stop_poller();
}
protected:
PollingComponent *component_;
};
template<typename... Ts> class ResumeComponentAction : public Action<Ts...> {
public:
ResumeComponentAction(PollingComponent *component) : component_(component) {}
TEMPLATABLE_VALUE(uint32_t, update_interval)
void play(const Ts &...x) override {
if (!this->component_->is_ready()) {
return;
}
optional<uint32_t> update_interval = this->update_interval_.optional_value(x...);
if (update_interval.has_value()) {
this->component_->set_update_interval(update_interval.value());
}
this->component_->start_poller();
}
protected:
PollingComponent *component_;
};
} // namespace esphome
-1
View File
@@ -401,7 +401,6 @@
#define USE_API
#define USE_API_CLIENT_CONNECTED_TRIGGER
#define USE_API_CLIENT_DISCONNECTED_TRIGGER
#define USE_API_CLIENT_SYNCED_TRIGGER
#define USE_API_HOMEASSISTANT_ACTION_RESPONSES
#define USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
#define USE_API_HOMEASSISTANT_SERVICES
-10
View File
@@ -98,16 +98,6 @@ esphome:
api:
port: 8000
reboot_timeout: 0min
on_client_connected:
# Deferring actions get owned copies of the arguments
- delay: 1s
- logger.log:
format: "Client %s connected from %s"
args: [client_info.c_str(), client_address.c_str()]
on_client_synced:
- logger.log:
format: "Client %s synced from %s"
args: [client_info.c_str(), client_address.c_str()]
actions:
- action: hello_world
description: Log a greeting
@@ -1,28 +0,0 @@
esphome:
name: api-client-synced
host:
api:
on_client_synced:
- logger.log: "Synced trigger entered"
- if:
condition:
api.connected:
state_subscription_only: true
then:
- logger.log: "Synced trigger fired"
# Home Assistant subscribes to actions in the same packet as states
- homeassistant.event:
event: esphome.client_synced
switch:
# A known state at boot, so the test can check it is sent before the trigger
- platform: template
id: ready_switch
name: "Ready switch"
optimistic: true
restore_mode: ALWAYS_ON
logger:
level: DEBUG
@@ -1,56 +0,0 @@
"""Integration test for the api on_client_synced trigger."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, HomeassistantServiceCall, SwitchState
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_api_client_synced(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The trigger fires after the initial states, with the action subscription active."""
waiter = LineWaiter()
calls: asyncio.Queue[HomeassistantServiceCall] = asyncio.Queue()
# Everything the client receives, in order
received: list[EntityState | HomeassistantServiceCall] = []
def on_service_call(call: HomeassistantServiceCall) -> None:
received.append(call)
calls.put_nowait(call)
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await client.device_info()
assert waiter.count("Synced trigger entered") == 0
client.subscribe_home_assistant_states_and_services(
on_state=received.append,
on_service_call=on_service_call,
on_state_sub=lambda *_: None,
)
await waiter.wait_for_each(
"Synced trigger entered", "Synced trigger fired", timeout=5.0
)
call = await asyncio.wait_for(calls.get(), timeout=5.0)
assert call.service == "esphome.client_synced"
assert call.is_event
# The initial switch state arrives before the trigger's event
synced_at = received.index(call)
switch_states = [
i
for i, item in enumerate(received[:synced_at])
if isinstance(item, SwitchState)
]
assert switch_states, received
assert received[switch_states[0]].state is True
+93 -17
View File
@@ -2441,16 +2441,25 @@ def polling_component() -> None:
@pytest.mark.asyncio
@pytest.mark.usefixtures("polling_component")
@pytest.mark.parametrize(
"name",
["component.update", "component.suspend", "component.resume"],
("name", "call"),
[
("component.update", "update()"),
("component.suspend", "stop_poller()"),
("component.resume", "start_poller()"),
],
)
async def test_component_action_codegen(name: str) -> None:
"""Each action is an object constructed with the component, with no interval set."""
async def test_component_action_codegen(name: str, call: str) -> None:
"""The call goes through ``PollingComponent`` and only once the component is ready."""
await build_action(_component_action(name), TEMPLATE_ARG, [])
class_name = str(ACTION_REGISTRY[name].type_id)
assert f"new(act) {class_name}<>(comp);" in CORE.cpp_main_section
assert "set_update_interval" not in CORE.cpp_main_section
assert "new(act) ApplyAction<esphome__act__fn>();" in CORE.cpp_main_section
assert (
" PollingComponent &esphome__component = *::comp;\n"
" if (esphome__component.is_ready()) {\n"
f" esphome__component.{call};\n"
" }\n"
) in CORE.cpp_global_section
assert "set_update_interval" not in CORE.cpp_global_section
@pytest.mark.asyncio
@@ -2466,14 +2475,17 @@ async def test_component_resume_constant_interval_codegen() -> None:
)
assert (
"act->set_update_interval([]() -> uint32_t {\n return 250;\n});"
) in CORE.cpp_main_section
" if (esphome__component.is_ready()) {\n"
" esphome__component.set_update_interval(250);\n"
" esphome__component.start_poller();\n"
" }\n"
) in CORE.cpp_global_section
@pytest.mark.asyncio
@pytest.mark.usefixtures("polling_component")
async def test_component_resume_interval_lambda_codegen() -> None:
"""A templated interval is a lambda over the trigger args."""
"""A templated interval is computed from the trigger args, only once ready."""
await build_action(
_component_action(
"component.resume", {CONF_UPDATE_INTERVAL: Lambda("return x * 1000;")}
@@ -2482,27 +2494,91 @@ async def test_component_resume_interval_lambda_codegen() -> None:
[(cg.uint32, "x")],
)
main = CORE.cpp_main_section
assert "new(act) ResumeComponentAction<uint32_t>(comp);" in main
assert (
"act->set_update_interval([](uint32_t x) -> uint32_t {\n"
"static void esphome__act__fn(const std::remove_cvref_t<uint32_t> & x) {\n"
" PollingComponent &esphome__component = *::comp;\n"
" if (esphome__component.is_ready()) {\n"
" esphome__component.set_update_interval([](uint32_t x) -> uint32_t {\n"
" return x * 1000;\n"
"});"
) in main
" }(x));\n"
" esphome__component.start_poller();\n"
" }\n"
) in CORE.cpp_global_section
@pytest.mark.asyncio
@pytest.mark.usefixtures("apply_actions")
async def test_component_update_not_ready_still_runs_merged_actions() -> None:
"""The ready check skips only the update, never the actions merged after it."""
cg.new_Pvariable(ID("pcomp", is_declaration=True, type=cg.PollingComponent))
update = _component_action("component.update")
update["component.update"][CONF_ID] = ID("pcomp", type=cg.PollingComponent)
result = await build_action_list(
[update, _action("my.a", "act_a")], TEMPLATE_ARG, []
)
assert [str(action) for action in result] == ["act"]
text = CORE.cpp_global_section
assert text.index("esphome__component.update();") < text.index("::comp->do_a();")
# The only return is the stop check between the two actions
assert text.count("return;") == 1
@pytest.mark.asyncio
@pytest.mark.usefixtures("apply_actions")
async def test_callback_automation_if_with_component_update_is_direct() -> None:
"""An ``if`` whose branches update components becomes plain C++ in the callback."""
cg.new_Pvariable(ID("pcomp", is_declaration=True, type=cg.PollingComponent))
def update(id_: str) -> ConfigType:
return {
"component.update": {CONF_ID: ID("pcomp", type=cg.PollingComponent)},
CONF_TYPE_ID: ID(
id_, is_declaration=True, type=ApplyAction, is_manual=False
),
}
conf = _automation_conf(
{
"if": {
CONF_CONDITION: _inline_condition(),
CONF_THEN: [update("upd_then")],
CONF_ELSE: [update("upd_else")],
},
CONF_TYPE_ID: ID(
"act_if", is_declaration=True, type=IfAction, is_manual=False
),
}
)
await build_callback_automation(CALLBACK_PARENT, "add_cb", [], conf)
assert "Action" not in CORE.cpp_main_section
assert "Condition" not in CORE.cpp_main_section
text = CORE.cpp_global_section
assert "if (esphome__condition) {" in text
assert text.count("esphome__component.update();") == 2
@pytest.mark.asyncio
@pytest.mark.usefixtures("polling_component")
@pytest.mark.parametrize("body", ["x *= 1000; return x;", "return x *= 1000;"])
async def test_component_resume_interval_lambda_takes_args_by_value(body: str) -> None:
"""The user's interval lambda gets the trigger args by value, so it may modify them."""
"""The user's interval lambda gets the trigger args by value, so it may modify them.
It is called rather than reduced, so even a single return modifies only its own copy.
"""
await build_action(
_component_action("component.resume", {CONF_UPDATE_INTERVAL: Lambda(body)}),
cg.TemplateArguments(cg.uint32),
[(cg.uint32, "x")],
)
assert f"[](uint32_t x) -> uint32_t {{\n {body}\n}}" in CORE.cpp_main_section
assert (
f"set_update_interval([](uint32_t x) -> uint32_t {{\n {body}\n }}(x));"
in CORE.cpp_global_section
)
COMPONENT_ID = ID("comp", type=cg.Component)