Compare commits

..
Author SHA1 Message Date
J. Nick Koston 3c2c850cb4 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:38:58 -06:00
J. Nick Koston 95e4837b99 Merge branch 'dev' into api-client-synced-trigger 2026-11-01 12:32:34 -10:00
J. Nick Koston 16f14317a7 [api] Check the initial states arrive before on_client_synced fires 2026-10-29 20:04:02 -10:00
J. Nick Koston 08387b0fb0 [api] Don't fire on_client_synced after answering a disconnect request 2026-10-29 19:37:09 -10:00
J. Nick Koston e18413c2b7 [api] Catch an early on_client_synced in the integration test
Log unconditionally at the start of the trigger, so firing before the
subscription is seen even when the api.connected condition is false.
2026-10-29 19:15:17 -10:00
J. Nick Koston 086bfba8b3 [api] Fire on_client_synced once the deferred batch has drained
A direct send from the automation could overtake initial states still
queued under backpressure, and the trigger fired even when the final
flush killed the connection. The pending state rides in active_iterator_,
so it costs no RAM and the loop keeps its single iterator check.
2026-10-29 19:14:18 -10:00
J. Nick Koston 92e8a64669 Merge branch 'automation-owning-args' into api-client-synced-trigger 2026-10-29 17:09:42 -10:00
J. Nick Koston 32bde52dba [core] Move owning_args tests next to the automation tests 2026-10-29 17:09:39 -10:00
J. Nick Koston 32240fd69f Merge branch 'automation-owning-args' into api-client-synced-trigger 2026-10-29 17:06:59 -10:00
J. Nick Koston 8aa327264a Merge remote-tracking branch 'origin/dev' into automation-owning-args 2026-10-29 17:06:57 -10:00
J. Nick Koston b9a5ccea65 Merge branch 'automation-owning-args' into api-client-synced-trigger 2026-10-29 16:38:25 -10:00
J. Nick Koston aee3476828 [core] Move owning_args from nextion into automation
Picks std::string over StringRef for automation arguments when the
automation contains a deferring action. Shared so other components can
pass StringRef to their triggers without a lifetime issue.
2026-10-29 16:37:14 -10:00
J. Nick Koston 602c751c2f [api] Add on_client_synced trigger and pass client info as StringRef
on_client_connected fires during the hello, before the client subscribes
to states, so state_subscription_only is never true inside it. The new
on_client_synced trigger fires once the initial states have been sent,
when the subscriptions sent with SubscribeStates are active.

All three client triggers now pass StringRef; automations that defer get
std::string copies, chosen at compile time by automation.owning_args().
2026-10-29 16:30:08 -10:00
22 changed files with 242 additions and 532 deletions

No files matched your search

+2 -47
View File
@@ -5,7 +5,6 @@ import logging
import math
import re
import string
import struct
from typing import Any
import voluptuous as vol
@@ -2124,17 +2123,6 @@ def _direct_function(
)
def _call_target(
plan: list[MockObj | list[_InlineAction]],
args: TemplateArgsType,
config: ConfigType,
) -> MockObj | str:
"""What a generated callback calls: the direct function, else ``<automation>->trigger``."""
if (target := _direct_function(plan, args, config)) is None:
target = parent_ref(_automation_from_plan(plan, args, config)).trigger
return target
def _when_call(
when: str | ApplyCall | None, parent: MockObj | None
) -> ApplyCall | None:
@@ -2152,40 +2140,6 @@ async def build_automation(
return await new_automation(args, config, trigger)
async def build_component_automation(
config: ConfigType, component_class: MockObjClass, priority: float | None = None
) -> MockObj:
"""Create and register the Component that runs a no-argument automation.
That is ``component_class<function[, priority bits]>``, whose function runs the actions
directly when they are all inline, else triggers their Automation. A ``trigger_id`` set
by hand keeps its Trigger, built with ``priority`` if given.
"""
trigger_id: ID = config[CONF_TRIGGER_ID]
if trigger_id.is_manual:
var = cg.new_Pvariable(trigger_id, *([] if priority is None else [priority]))
await cg.register_component(var, config)
await build_automation(var, [], config)
return var
# Declared and registered before the actions are built, which can wait for other ids
name = _apply_function_name(trigger_id)
define = cg.declare_static_function(name, cg.void, [])
id_ = trigger_id.copy()
id_.type = component_class
bits = [] if priority is None else [_float_bits(priority)]
var = cg.new_Pvariable(id_, cg.TemplateArguments(cg.RawExpression(name), *bits))
await cg.register_component(var, config)
plan = await _build_automation_plan([], config)
define([str(cg.statement(CallExpression(_call_target(plan, [], config))))])
return var
def _float_bits(value: float) -> HexInt:
"""``value`` as a C++ float's bit pattern, for a template argument."""
(bits,) = struct.unpack("<I", struct.pack("<f", value))
return HexInt(bits)
async def build_trigger_callback(
args: TemplateArgsType,
config: ConfigType,
@@ -2219,7 +2173,8 @@ async def _trigger_callback(
call: ApplyCall | None,
parent: MockObj | None,
) -> LambdaExpression:
target = _call_target(plan, args, config)
if (target := _direct_function(plan, args, config)) is None:
target = f"{parent_ref(_automation_from_plan(plan, args, config))}->trigger"
statements: list[str] = []
if call is not None:
members = call.members
-1
View File
@@ -31,7 +31,6 @@ from esphome.cpp_generator import ( # noqa: F401
add_global,
add_library,
add_platformio_option,
declare_static_function,
extern_progmem_array,
get_variable,
get_variable_with_full_id,
+32 -15
View File
@@ -144,6 +144,7 @@ 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"
@@ -453,6 +454,9 @@ 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:
@@ -706,21 +710,34 @@ async def to_code(config: ConfigType) -> None:
if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
await wizard.to_code(wizard_config)
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],
)
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 (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
if key := encryption_config.get(CONF_KEY):
+20 -2
View File
@@ -251,6 +251,9 @@ 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;
@@ -415,10 +418,25 @@ 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);
}
@@ -1936,8 +1954,8 @@ void APIConnection::complete_authentication_() {
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
{
char peername[socket::SOCKADDR_STR_LEN];
this->parent_->get_client_connected_trigger()->trigger(std::string(this->helper_->get_client_name()),
std::string(this->helper_->get_peername_to(peername)));
this->parent_->get_client_connected_callback().call(StringRef(this->helper_->get_client_name()),
StringRef(this->helper_->get_peername_to(peername)));
}
#endif
#ifdef USE_HOMEASSISTANT_TIME
+14 -2
View File
@@ -296,7 +296,11 @@ 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) {
if (this->active_iterator_ == ActiveIterator::NONE
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|| this->active_iterator_ == ActiveIterator::CLIENT_SYNCED_PENDING
#endif
) {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
}
}
@@ -642,7 +646,15 @@ 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 };
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
};
union IteratorUnion {
ListEntitiesIterator list_entities;
+6 -4
View File
@@ -257,9 +257,11 @@ 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 peername_buf[socket::SOCKADDR_STR_LEN];
std::string client_name(client->get_name());
std::string client_peername(client->get_peername_to(peername_buf));
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);
#endif
// Read before the swap-and-reset below destroys the connection
@@ -302,7 +304,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_trigger_.trigger(client_name, client_peername);
this->client_disconnected_callback_.call(StringRef(client_name), StringRef(peername));
#endif
}
+23 -4
View File
@@ -277,11 +277,27 @@ 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
Trigger<std::string, std::string> *get_client_connected_trigger() { return &this->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_; }
#endif
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
Trigger<std::string, std::string> *get_client_disconnected_trigger() { return &this->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_; }
#endif
protected:
@@ -343,10 +359,13 @@ class APIServer final : public Component
// Pointers and pointer-like types first (4 bytes each)
socket::ListenSocket *socket_{nullptr};
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
Trigger<std::string, std::string> client_connected_trigger_;
ClientCallback client_connected_callback_;
#endif
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
Trigger<std::string, std::string> client_disconnected_trigger_;
ClientCallback client_disconnected_callback_;
#endif
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
ClientCallback client_synced_callback_;
#endif
// 4-byte aligned types
-35
View File
@@ -171,41 +171,6 @@ class LoopTrigger : public Trigger<>, public Component {
float get_setup_priority() const override { return setup_priority::DATA; }
};
/// Component whose setup priority is the float with bit pattern PRIORITY_BITS, since GCC 10 (ESP8266)
/// has no float template parameters. Templated on the priority only, so each priority has one copy.
template<uint32_t PRIORITY_BITS> class PriorityComponent : public Component {
public:
float get_setup_priority() const override { return bit_cast<float>(PRIORITY_BITS); }
};
/// Bit pattern of setup_priority::DATA (600.0f), the Component default.
static constexpr uint32_t SETUP_PRIORITY_DATA_BITS = 0x44160000;
// bit_cast is constexpr only where std::bit_cast exists, which GCC 10 (ESP8266) lacks
#if __cpp_lib_bit_cast >= 201806
static_assert(bit_cast<uint32_t>(setup_priority::DATA) == SETUP_PRIORITY_DATA_BITS);
#endif
/// The default priority needs no override.
template<> class PriorityComponent<SETUP_PRIORITY_DATA_BITS> : public Component {};
/// Calls Fn from setup(), for on_boot.
template<auto Fn, uint32_t PRIORITY_BITS> class StartupCallback final : public PriorityComponent<PRIORITY_BITS> {
public:
void setup() override { Fn(); }
};
/// Calls Fn from on_shutdown(), for on_shutdown.
template<auto Fn, uint32_t PRIORITY_BITS> class ShutdownCallback final : public PriorityComponent<PRIORITY_BITS> {
public:
void on_shutdown() override { Fn(); }
};
/// Calls Fn from loop(), for on_loop.
template<auto Fn> class LoopCallback final : public Component {
public:
void loop() override { Fn(); }
};
#ifdef ESPHOME_PROJECT_NAME
class ProjectUpdateTrigger : public Trigger<std::string>, public Component {
public:
+14 -12
View File
@@ -124,9 +124,6 @@ ShutdownTrigger = cg.esphome_ns.class_(
LoopTrigger = cg.esphome_ns.class_(
"LoopTrigger", cg.Component, automation.Trigger.template()
)
StartupCallback = cg.esphome_ns.class_("StartupCallback", cg.Component)
ShutdownCallback = cg.esphome_ns.class_("ShutdownCallback", cg.Component)
LoopCallback = cg.esphome_ns.class_("LoopCallback", cg.Component)
ProjectUpdateTrigger = cg.esphome_ns.class_(
"ProjectUpdateTrigger", cg.Component, automation.Trigger.template(cg.std_string)
)
@@ -788,15 +785,20 @@ async def _add_environment_variables(env_vars: dict[str, str]) -> None:
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _add_automations(config):
for key, component_class in (
(CONF_ON_BOOT, StartupCallback),
(CONF_ON_SHUTDOWN, ShutdownCallback),
(CONF_ON_LOOP, LoopCallback),
):
for conf in config.get(key, []):
await automation.build_component_automation(
conf, component_class, conf.get(CONF_PRIORITY)
)
for conf in config.get(CONF_ON_BOOT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf.get(CONF_PRIORITY))
await cg.register_component(trigger, conf)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_SHUTDOWN, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf.get(CONF_PRIORITY))
await cg.register_component(trigger, conf)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_LOOP, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await cg.register_component(trigger, conf)
await automation.build_automation(trigger, [], conf)
# Datetime component has special subtypes that need additional defines
+1
View File
@@ -401,6 +401,7 @@
#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
+9 -22
View File
@@ -883,13 +883,14 @@ def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj":
return Pvariable(id_, rhs)
def declare_static_function(
def static_function(
name: str,
return_type: SafeExpType,
parameters: TemplateArgsType,
) -> Callable[[list[str | Statement]], RawExpression]:
"""Declare ``static <return_type> <name>(parameters)`` now and return the function that
emits its body, for a caller that names the function before the body is known.
body: list[str | Statement],
) -> RawExpression:
"""Emit ``static <return_type> <name>(parameters) { body }`` at global scope and return an
expression naming it, for use as a template argument or a function pointer.
The declaration goes with the other globals, so a template argument can name the function
at once; the body goes after the user includes, where every id and everything those
@@ -899,24 +900,10 @@ def declare_static_function(
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
add_global(RawStatement(f"{signature};"))
def define(body: list[str | Statement]) -> RawExpression:
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(map(str, body)))}\n}}")
)
return RawExpression(name)
return define
def static_function(
name: str,
return_type: SafeExpType,
parameters: TemplateArgsType,
body: list[str | Statement],
) -> RawExpression:
"""``declare_static_function`` with the body known at once."""
return declare_static_function(name, return_type, parameters)(body)
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(map(str, body)))}\n}}")
)
return RawExpression(name)
@dataclass
@@ -30,6 +30,14 @@ def _automation_of(main_cpp: str, trigger: str) -> str:
return match.group(1)
def _has_delay(main_cpp: str, trigger: str) -> bool:
automation = _automation_of(main_cpp, trigger)
return (
re.search(rf"{automation}->add_action\(delayaction_id\w*\);", main_cpp)
is not None
)
def _registered(main_cpp: str) -> list[str]:
return re.findall(r"App\.register_component_\((\w+), \d+\);", main_cpp)
@@ -48,59 +56,18 @@ def test_core_automations_registration_order(main_cpp: str) -> None:
]
def _function_body(main_cpp: str, name: str) -> str:
match = re.search(rf"static void {name}\(\) \{{(.*?)\n\}}\n", main_cpp, re.DOTALL)
assert match is not None, name
return match.group(1)
def _actions_body(main_cpp: str, function: str) -> str:
"""The body of the function of inline actions that ``function`` calls."""
match = re.fullmatch(r"\n (\w+)\(\);", _function_body(main_cpp, function))
assert match is not None, function
return _function_body(main_cpp, match.group(1))
def _delayed_automation(main_cpp: str, function: str) -> str:
"""The Automation that ``function`` triggers, checked to hold a delay; found through the
function rather than an id counter."""
match = re.fullmatch(
r"\n ::(\w+)->trigger\(\);", _function_body(main_cpp, function)
)
assert match is not None, function
automation = match.group(1)
assert f"new({automation}) Automation<>();" in main_cpp
assert re.search(rf"{automation}->add_action\(delayaction_id\w*\);", main_cpp)
return automation
def test_on_boot_inline_priorities(main_cpp: str) -> None:
"""All-inline on_boot blocks call one function from setup() at their priority, default 600.
The priority is the float's bit pattern: 0x44480000 is 800.0f, 0x44160000 is 600.0f.
"""
assert _constructed(main_cpp, "startuptrigger_id") == (
"StartupCallback<esphome__startuptrigger_id__fn, 0x44480000>()"
)
assert _constructed(main_cpp, "startuptrigger_id_2") == (
"StartupCallback<esphome__startuptrigger_id_2__fn, 0x44160000>()"
)
assert "Automation<>(startuptrigger_id" not in main_cpp
# Both actions of the first block run in the one function, with no stop checks
body = _actions_body(main_cpp, "esphome__startuptrigger_id__fn")
assert "boot high" in body
assert "boot high lambda" in body
assert "esphome__running" not in body
assert "Automation" not in body
assert "boot default" in _actions_body(main_cpp, "esphome__startuptrigger_id_2__fn")
"""All-inline on_boot blocks keep their configured priority, default 600."""
assert _constructed(main_cpp, "startuptrigger_id") == "StartupTrigger(800.0f)"
assert _constructed(main_cpp, "startuptrigger_id_2") == "StartupTrigger(600.0f)"
_automation_of(main_cpp, "startuptrigger_id")
_automation_of(main_cpp, "startuptrigger_id_2")
def test_on_boot_delay_triggers_automation(main_cpp: str) -> None:
"""A delay keeps the Automation of separate actions, which the function triggers."""
assert _constructed(main_cpp, "startuptrigger_id_3") == (
"StartupCallback<esphome__startuptrigger_id_3__fn, 0xC2C80000>()"
)
_delayed_automation(main_cpp, "esphome__startuptrigger_id_3__fn")
def test_on_boot_delay_keeps_trigger(main_cpp: str) -> None:
"""A delay needs the Trigger and its Automation of separate actions."""
assert _constructed(main_cpp, "startuptrigger_id_3") == "StartupTrigger(-100.0f)"
assert _has_delay(main_cpp, "startuptrigger_id_3")
def test_on_boot_manual_trigger_id_keeps_trigger(main_cpp: str) -> None:
@@ -110,30 +77,19 @@ def test_on_boot_manual_trigger_id_keeps_trigger(main_cpp: str) -> None:
def test_on_shutdown_priorities(main_cpp: str) -> None:
"""on_shutdown calls its function at its priority; a delay keeps the Automation."""
assert _constructed(main_cpp, "shutdowntrigger_id") == (
"ShutdownCallback<esphome__shutdowntrigger_id__fn, 0x442F0000>()"
)
assert "shutdown inline" in _actions_body(
main_cpp, "esphome__shutdowntrigger_id__fn"
)
assert _constructed(main_cpp, "shutdowntrigger_id_2") == (
"ShutdownCallback<esphome__shutdowntrigger_id_2__fn, 0x44160000>()"
)
_delayed_automation(main_cpp, "esphome__shutdowntrigger_id_2__fn")
"""on_shutdown blocks keep their priority; a delay keeps the Trigger."""
assert _constructed(main_cpp, "shutdowntrigger_id") == "ShutdownTrigger(700.0f)"
_automation_of(main_cpp, "shutdowntrigger_id")
assert _constructed(main_cpp, "shutdowntrigger_id_2") == "ShutdownTrigger(600.0f)"
assert _has_delay(main_cpp, "shutdowntrigger_id_2")
def test_on_loop(main_cpp: str) -> None:
"""on_loop calls its function each loop; a delay keeps the Automation."""
assert _constructed(main_cpp, "looptrigger_id") == (
"LoopCallback<esphome__looptrigger_id__fn>()"
)
assert "loop inline" in _actions_body(main_cpp, "esphome__looptrigger_id__fn")
assert "HasLoopOverride<LoopCallback<esphome__looptrigger_id__fn>>" in main_cpp
assert _constructed(main_cpp, "looptrigger_id_2") == (
"LoopCallback<esphome__looptrigger_id_2__fn>()"
)
_delayed_automation(main_cpp, "esphome__looptrigger_id_2__fn")
"""on_loop blocks are looping components; a delay keeps the Trigger."""
assert _constructed(main_cpp, "looptrigger_id") == "LoopTrigger()"
_automation_of(main_cpp, "looptrigger_id")
assert _constructed(main_cpp, "looptrigger_id_2") == "LoopTrigger()"
assert _has_delay(main_cpp, "looptrigger_id_2")
def test_on_boot_registered_before_its_actions_are_built(
@@ -1,42 +0,0 @@
esphome:
name: test
esp32:
board: nodemcu-32s
logger:
interval:
- interval: 1s
then:
- logger.log: inline tick
- lambda: ESP_LOGD("test", "inline lambda");
- interval: 2s
startup_delay: 5s
then:
- logger.log: delayed start tick
- interval: 3s
then:
- logger.log: before delay
- delay: 10ms
- logger.log: after delay
- id: named_interval
interval: 4s
startup_delay: 3s
then:
- logger.log: named tick
- interval: 5s
setup_priority: 250
then:
# The script is declared later, so building this action waits for it
- script.execute: later_script
script:
- id: later_script
then:
- logger.log: script ran
sensor:
- platform: template
id: some_sensor
lambda: return 1.0;
@@ -1,72 +0,0 @@
"""Tests for the code generated for interval blocks."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
CONFIG = "interval_codegen.yaml"
@pytest.fixture
def main_cpp(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path(CONFIG))
def _constructed(main_cpp: str, var: str) -> str:
match = re.search(rf"new\({var}\) ([^;]+);", main_cpp)
assert match is not None, var
return match.group(1)
@pytest.mark.parametrize(
("var", "expected", "update_interval"),
[
("interval_intervaltrigger_id", "interval::IntervalTrigger()", 1000),
(
"interval_intervaltrigger_id_2",
"interval::DelayedIntervalTrigger(5000)",
2000,
),
("interval_intervaltrigger_id_3", "interval::IntervalTrigger()", 3000),
("named_interval", "interval::DelayedIntervalTrigger(3000)", 4000),
("interval_intervaltrigger_id_4", "interval::IntervalTrigger()", 5000),
],
)
def test_interval_constructed(
main_cpp: str, var: str, expected: str, update_interval: int
) -> None:
"""Each interval is built with its startup delay, if any, and gets its update interval."""
assert _constructed(main_cpp, var) == expected
assert f"{var}->set_update_interval({update_interval});" in main_cpp
def test_interval_delay_keeps_automation(main_cpp: str) -> None:
"""A delay needs the Automation of separate actions."""
automation = re.search(
r"new\((\w+)\) Automation<>\(interval_intervaltrigger_id_3\);", main_cpp
)
assert automation is not None
assert re.search(
rf"{automation.group(1)}->add_action\(delayaction_id\w*\);", main_cpp
)
def test_interval_registration_order(main_cpp: str) -> None:
"""Intervals register in config order; one waiting for a later id keeps its place."""
registered = re.findall(r"App\.register_component_\((\w+), \d+\);", main_cpp)
assert registered[-6:] == [
"interval_intervaltrigger_id",
"interval_intervaltrigger_id_2",
"interval_intervaltrigger_id_3",
"named_interval",
"interval_intervaltrigger_id_4",
"some_sensor",
]
assert main_cpp.index(
"App.register_component_(interval_intervaltrigger_id_4"
) < main_cpp.index("new(later_script)")
+10
View File
@@ -98,6 +98,16 @@ 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,64 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/core/base_automation.h"
namespace esphome::core::testing {
namespace {
int calls = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void count_call() { calls++; }
// Bit patterns of 800.0f, 600.0f, -100.0f and 250.5f, as the code generator writes them
constexpr uint32_t PRIORITY_800 = 0x44480000;
constexpr uint32_t PRIORITY_MINUS_100 = 0xC2C80000;
constexpr uint32_t PRIORITY_250_5 = 0x437A8000;
} // namespace
TEST(CoreAutomationCallbacks, StartupCallbackRunsFromSetupAtItsPriority) {
calls = 0;
StartupCallback<count_call, PRIORITY_800> high;
StartupCallback<count_call, PRIORITY_MINUS_100> late;
StartupCallback<count_call, PRIORITY_250_5> fractional;
EXPECT_EQ(high.get_setup_priority(), 800.0f);
EXPECT_EQ(late.get_setup_priority(), -100.0f);
EXPECT_EQ(fractional.get_setup_priority(), 250.5f);
StartupCallback<count_call, SETUP_PRIORITY_DATA_BITS> standard;
EXPECT_EQ(standard.get_setup_priority(), setup_priority::DATA);
high.loop();
high.on_shutdown();
EXPECT_EQ(calls, 0);
high.setup();
EXPECT_EQ(calls, 1);
}
TEST(CoreAutomationCallbacks, ShutdownCallbackRunsFromOnShutdownAtItsPriority) {
calls = 0;
ShutdownCallback<count_call, PRIORITY_MINUS_100> shutdown;
EXPECT_EQ(shutdown.get_setup_priority(), -100.0f);
shutdown.setup();
shutdown.loop();
EXPECT_EQ(calls, 0);
shutdown.on_shutdown();
EXPECT_EQ(calls, 1);
}
TEST(CoreAutomationCallbacks, LoopCallbackRunsEachLoopAtDataPriority) {
calls = 0;
LoopCallback<count_call> looping;
EXPECT_EQ(looping.get_setup_priority(), setup_priority::DATA);
EXPECT_TRUE(HasLoopOverride<LoopCallback<count_call>>::value);
EXPECT_FALSE((HasLoopOverride<StartupCallback<count_call, PRIORITY_800>>::value));
looping.setup();
EXPECT_EQ(calls, 0);
looping.loop();
looping.loop();
EXPECT_EQ(calls, 2);
}
} // namespace esphome::core::testing
@@ -1,34 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/components/interval/interval.h"
#include "esphome/core/application.h"
namespace esphome::interval::testing {
class CountAction : public Action<> {
public:
explicit CountAction(int *count) : count_(count) {}
void play() override { (*this->count_)++; }
protected:
int *count_;
};
TEST(IntervalTrigger, UpdateRunsTheAutomationAtDataPriority) {
IntervalTrigger trigger;
Automation<> automation{&trigger};
int count = 0;
CountAction action{&count};
automation.add_action(&action);
EXPECT_EQ(trigger.get_setup_priority(), setup_priority::DATA);
EXPECT_FALSE(HasLoopOverride<IntervalTrigger>::value);
trigger.set_update_interval(1000);
EXPECT_EQ(trigger.get_update_interval(), 1000u);
trigger.update();
trigger.update();
EXPECT_EQ(count, 2);
}
} // namespace esphome::interval::testing
@@ -0,0 +1,28 @@
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,46 +0,0 @@
esphome:
name: interval-runs
host:
api:
logger:
level: DEBUG
globals:
- id: inline_count
type: int
initial_value: "0"
- id: action_count
type: int
initial_value: "0"
- id: startup_count
type: int
initial_value: "0"
interval:
# All actions inline
- interval: 50ms
then:
- lambda: id(inline_count) += 1;
- lambda: |-
if (id(inline_count) == 1) {
ESP_LOGI("test", "INLINE first uptime=%u", (unsigned) millis());
} else if (id(inline_count) == 20) {
ESP_LOGI("test", "INLINE ran 20 times");
}
# A delay between the actions
- interval: 100ms
then:
- lambda: |-
id(action_count) += 1;
ESP_LOGI("test", "ACTION start %d", id(action_count));
- delay: 20ms
- lambda: ESP_LOGI("test", "ACTION end %d", id(action_count));
# First run waits for the startup delay
- interval: 50ms
startup_delay: 1000ms
then:
- lambda: |-
if (++id(startup_count) == 1) {
ESP_LOGI("test", "STARTUP first uptime=%u inline_count=%d", (unsigned) millis(), id(inline_count));
}
@@ -0,0 +1,56 @@
"""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
-44
View File
@@ -1,44 +0,0 @@
"""Integration test that intervals run repeatedly and honour their startup delay."""
from __future__ import annotations
import re
import pytest
from .log_utils import LineWaiter
from .types import RunCompiledFunction
INLINE_FIRST = re.compile(r"INLINE first uptime=(\d+)")
STARTUP_FIRST = re.compile(r"STARTUP first uptime=(\d+) inline_count=(\d+)")
ACTION = re.compile(r"ACTION (start|end) (\d+)")
@pytest.mark.asyncio
async def test_interval_runs(
yaml_config: str,
run_compiled: RunCompiledFunction,
) -> None:
"""Inline and delayed actions repeat; the startup delay holds the first run back."""
waiter = LineWaiter()
inline_future = waiter.expect(INLINE_FIRST)
startup_future = waiter.expect(STARTUP_FIRST)
async with run_compiled(yaml_config, line_callback=waiter.callback):
inline = await waiter.wait(inline_future)
startup = await waiter.wait(startup_future)
await waiter.wait_for_each("INLINE ran 20 times", "ACTION end 3")
# The startup delay held the first run back while the inline interval kept running
assert int(startup.group(1)) - int(inline.group(1)) >= 800
assert int(startup.group(2)) >= 10
# Each run of the delayed actions finished before the next started
actions = [m.groups() for line in waiter.lines if (m := ACTION.search(line))]
assert actions[:6] == [
("start", "1"),
("end", "1"),
("start", "2"),
("end", "2"),
("start", "3"),
("end", "3"),
]
-15
View File
@@ -1173,21 +1173,6 @@ def test_static_function_declares_early_and_defines_after_includes() -> None:
]
def test_declare_static_function_defines_the_body_later() -> None:
"""The declaration is emitted at once; the body only when define() is called."""
define = cg.declare_static_function("esphome__act__fn", ct.void, [])
assert [str(st) for st in CORE.global_statements] == [
"static void esphome__act__fn();"
]
assert CORE.global_statements_after_includes == []
fn = define(["first();", "second();"])
assert str(fn) == "esphome__act__fn"
assert [str(st) for st in CORE.global_statements_after_includes] == [
"static void esphome__act__fn() {\n first();\n second();\n}"
]
def test_static_function_accepts_statements() -> None:
call = cg.MockObj("foo")(cg.MockObj("x") >> 2)
cg.static_function(