Compare commits

..
Author SHA1 Message Date
J. Nick Koston 5849d87a07 Merge remote-tracking branch 'origin/on-boot-direct-callback' into HEAD 2026-11-14 08:40:09 -06:00
J. Nick Koston 113eb659d5 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:38:04 -06:00
J. Nick Koston c7b321eba0 Merge remote-tracking branch 'origin/on-boot-direct-callback' into interval-tests 2026-11-11 21:45:19 -06:00
J. Nick Koston a9ae0380cb Build the on_boot callback from codegen expressions instead of f-strings 2026-11-11 21:45:00 -06:00
J. Nick Koston 0bf9e7681a Merge remote-tracking branch 'origin/on-boot-direct-callback' into interval-tests 2026-11-11 12:38:49 -06:00
J. Nick Koston 9d65f4fe20 Merge remote-tracking branch 'origin/dev' into on-boot-direct-callback 2026-11-11 12:38:38 -06:00
J. Nick Koston a48151a993 Merge branch 'on-boot-direct-callback' into interval-tests 2026-11-07 14:13:19 -06:00
J. Nick Koston 3482d33176 [core] Name the DATA priority bit pattern and check it at compile time 2026-11-07 14:13:07 -06:00
J. Nick Koston 6ecd846999 Merge remote-tracking branch 'origin/dev' into on-boot-direct-callback 2026-11-07 14:06:30 -06:00
J. Nick Koston 4912a88528 [core] Restore the checks that each callback runs Fn only from its own method 2026-11-07 14:02:52 -06:00
J. Nick Koston 6eb97bf7ee [interval] Trim the interval codegen and integration tests 2026-11-07 13:48:59 -06:00
J. Nick Koston f41f1c711a Merge branch 'on-boot-direct-callback' into interval-tests 2026-11-07 13:48:38 -06:00
J. Nick Koston 475d794012 [core] Share the callback call target and drop per-callback DATA priority overrides 2026-11-07 13:47:47 -06:00
J. Nick Koston 42ea2a2821 Merge branch 'on-boot-direct-callback' into interval-tests 2026-11-07 13:33:03 -06:00
J. Nick Koston e0e25ac7cc Merge remote-tracking branch 'origin/dev' into on-boot-direct-callback 2026-11-07 13:28:39 -06:00
J. Nick Koston 948a9eeb27 [core] Use bit_cast for the startup callback priority now that it folds 2026-11-04 21:10:47 -06:00
J. Nick Koston 65a83c12b6 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-04 21:09:03 -06:00
J. Nick Koston 7d8b9c31b2 Merge branch 'on-boot-direct-callback' into interval-tests 2026-11-04 18:35:40 -06:00
J. Nick Koston d9d8518171 Merge remote-tracking branch 'origin/dev' into on-boot-direct-callback
# Conflicts:
#	esphome/cpp_generator.py
#	tests/unit_tests/test_cpp_generator.py
2026-11-04 18:34:31 -06:00
J. Nick Koston e6f6c0f09b Merge on-boot-direct-callback 2026-11-02 09:46:22 -06:00
J. Nick Koston 7815874624 Merge dev 2026-11-02 09:46:07 -06:00
J. Nick Koston d9082378c5 Update the interval codegen tests for intervals with a startup delay 2026-11-02 09:34:59 -06:00
J. Nick Koston 95f7423424 Merge dev 2026-11-02 09:33:12 -06:00
J. Nick Koston fef7a15521 Merge on-boot-direct-callback 2026-11-02 09:33:06 -06:00
J. Nick Koston 0b6fc2c3c5 Merge remote-tracking branch 'upstream/dev' into on-boot-direct-callback
# Conflicts:
#	tests/component_tests/automation/test_core_automations.py
2026-10-31 07:09:54 -10:00
J. Nick Koston 15810c176c Include application.h for HasLoopOverride and check that resume runs the interval again 2026-10-30 21:52:58 -10:00
J. Nick Koston 436baa9375 Merge remote-tracking branch 'origin/on-boot-direct-callback' into interval-tests 2026-10-30 21:50:33 -10:00
J. Nick Koston 4705fbb703 Merge remote-tracking branch 'origin/on-boot-tests' into on-boot-direct-callback
# Conflicts:
#	tests/component_tests/automation/test_core_automations.py
2026-10-30 21:46:06 -10:00
J. Nick Koston ce70abbbb4 Pin that the delayed boot step runs after setup and find automations by trigger 2026-10-30 21:42:15 -10:00
J. Nick Koston 6b5ae000d6 Give the named interval a startup delay 2026-10-30 21:41:24 -10:00
J. Nick Koston 2cfd2a8f51 [interval] Add tests for interval code generation
Pin the IntervalTrigger construction, update interval, startup delay,
registration order and source, setup priority override, and that an
interval runs repeatedly, waits for its startup delay and can be
suspended by id.
2026-10-30 21:35:07 -10:00
J. Nick Koston 77574a50eb Share static_function's declare and define steps and the priority bit cast 2026-10-30 21:34:12 -10:00
J. Nick Koston c52beb6d5e Fold the priority to a constant on every toolchain and share one loop
GCC 10 does not inline the bit_cast fallback at -Os and IDF builds memcpy
as a call, so read the bits with __builtin_memcpy.
2026-10-30 21:25:32 -10:00
J. Nick Koston dde3aaf50e Register the component before its actions are built
Building the actions can wait for ids declared later, so registering after
them moved the block behind other components of the same priority. The
function is now named after the component and declared first; when an
action is not inline it triggers the Automation, so every block without a
hand-set trigger_id uses the small component.
2026-10-30 21:21:50 -10:00
J. Nick Koston 6f7af22b41 Merge branch 'on-boot-tests' into on-boot-direct-callback 2026-10-30 21:20:48 -10:00
J. Nick Koston de0bf34e84 Pin where on_boot registers when its actions wait for other ids 2026-10-30 21:20:45 -10:00
J. Nick Koston 7bd7547983 [core] Run on_boot from a direct setup callback when every action is inline
An on_boot, on_shutdown or on_loop block whose actions are all inline now
builds one small component that calls the generated function from setup(),
on_shutdown() or loop(), instead of a Trigger, an Automation and action
objects. The setup priority is a template argument, so it takes no RAM.
Blocks with a delay, a wait or a trigger_id set by hand keep the Trigger.
2026-10-30 21:16:27 -10:00
J. Nick Koston aae52f2265 [core] Add tests for on_boot code generation and ordering
Pin the code generated today for on_boot, on_shutdown and on_loop
(priorities, registration order, the Trigger kept for a delay or a
trigger_id set by hand) and add an integration test that on_boot blocks
run once in priority order.
2026-10-30 21:05:09 -10:00
14 changed files with 591 additions and 187 deletions

No files matched your search

+68 -44
View File
@@ -1,11 +1,11 @@
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
import string
import struct
from typing import Any
import voluptuous as vol
@@ -1152,6 +1152,9 @@ 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")
@@ -1769,44 +1772,25 @@ async def lambda_action_to_code(
return new_lambda_pvariable(action_id, lambda_, StatelessLambdaAction, template_arg)
_POLLING_COMPONENT_SCHEMA = maybe_simple_id(
{cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}
register_simple_action(
"component.update",
UpdateComponentAction,
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_simple_action(
"component.suspend",
SuspendComponentAction,
maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}),
synchronous=True,
)
@register_action(
"component.resume",
ApplyAction,
ResumeComponentAction,
maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
@@ -1823,16 +1807,12 @@ async def component_resume_action_to_code(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
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)
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
async def build_action(
@@ -2144,6 +2124,17 @@ 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:
@@ -2161,6 +2152,40 @@ 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,
@@ -2194,8 +2219,7 @@ async def _trigger_callback(
call: ApplyCall | None,
parent: MockObj | None,
) -> LambdaExpression:
if (target := _direct_function(plan, args, config)) is None:
target = f"{parent_ref(_automation_from_plan(plan, args, config))}->trigger"
target = _call_target(plan, args, config)
statements: list[str] = []
if call is not None:
members = call.members
+1
View File
@@ -31,6 +31,7 @@ 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,
+83
View File
@@ -171,6 +171,41 @@ 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:
@@ -595,4 +630,52 @@ 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
+12 -14
View File
@@ -124,6 +124,9 @@ 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)
)
@@ -785,20 +788,15 @@ async def _add_environment_variables(env_vars: dict[str, str]) -> None:
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _add_automations(config):
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)
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)
)
# Datetime component has special subtypes that need additional defines
+22 -9
View File
@@ -883,14 +883,13 @@ def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj":
return Pvariable(id_, rhs)
def static_function(
def declare_static_function(
name: str,
return_type: SafeExpType,
parameters: TemplateArgsType,
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.
) -> 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.
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
@@ -900,10 +899,24 @@ def static_function(
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
add_global(RawStatement(f"{signature};"))
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(map(str, body)))}\n}}")
)
return RawExpression(name)
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)
@dataclass
@@ -30,14 +30,6 @@ 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)
@@ -56,18 +48,59 @@ 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 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")
"""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")
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_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_manual_trigger_id_keeps_trigger(main_cpp: str) -> None:
@@ -77,19 +110,30 @@ def test_on_boot_manual_trigger_id_keeps_trigger(main_cpp: str) -> None:
def test_on_shutdown_priorities(main_cpp: str) -> None:
"""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")
"""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")
def test_on_loop(main_cpp: str) -> None:
"""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")
"""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")
def test_on_boot_registered_before_its_actions_are_built(
@@ -0,0 +1,42 @@
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;
@@ -0,0 +1,72 @@
"""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)")
@@ -0,0 +1,64 @@
#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
@@ -0,0 +1,34 @@
#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,46 @@
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));
}
+44
View File
@@ -0,0 +1,44 @@
"""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"),
]
+17 -93
View File
@@ -2441,25 +2441,16 @@ def polling_component() -> None:
@pytest.mark.asyncio
@pytest.mark.usefixtures("polling_component")
@pytest.mark.parametrize(
("name", "call"),
[
("component.update", "update()"),
("component.suspend", "stop_poller()"),
("component.resume", "start_poller()"),
],
"name",
["component.update", "component.suspend", "component.resume"],
)
async def test_component_action_codegen(name: str, call: str) -> None:
"""The call goes through ``PollingComponent`` and only once the component is ready."""
async def test_component_action_codegen(name: str) -> None:
"""Each action is an object constructed with the component, with no interval set."""
await build_action(_component_action(name), TEMPLATE_ARG, [])
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
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
@pytest.mark.asyncio
@@ -2475,17 +2466,14 @@ async def test_component_resume_constant_interval_codegen() -> None:
)
assert (
" if (esphome__component.is_ready()) {\n"
" esphome__component.set_update_interval(250);\n"
" esphome__component.start_poller();\n"
" }\n"
) in CORE.cpp_global_section
"act->set_update_interval([]() -> uint32_t {\n return 250;\n});"
) in CORE.cpp_main_section
@pytest.mark.asyncio
@pytest.mark.usefixtures("polling_component")
async def test_component_resume_interval_lambda_codegen() -> None:
"""A templated interval is computed from the trigger args, only once ready."""
"""A templated interval is a lambda over the trigger args."""
await build_action(
_component_action(
"component.resume", {CONF_UPDATE_INTERVAL: Lambda("return x * 1000;")}
@@ -2494,91 +2482,27 @@ 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 (
"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"
"act->set_update_interval([](uint32_t x) -> uint32_t {\n"
" return x * 1000;\n"
" }(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
"});"
) in main
@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.
It is called rather than reduced, so even a single return modifies only its own copy.
"""
"""The user's interval lambda gets the trigger args by value, so it may modify them."""
await build_action(
_component_action("component.resume", {CONF_UPDATE_INTERVAL: Lambda(body)}),
cg.TemplateArguments(cg.uint32),
[(cg.uint32, "x")],
)
assert (
f"set_update_interval([](uint32_t x) -> uint32_t {{\n {body}\n }}(x));"
in CORE.cpp_global_section
)
assert f"[](uint32_t x) -> uint32_t {{\n {body}\n}}" in CORE.cpp_main_section
COMPONENT_ID = ID("comp", type=cg.Component)
+15
View File
@@ -1173,6 +1173,21 @@ 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(