Compare commits

..
Author SHA1 Message Date
J. Nick Koston 0bb86f8094 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:37:40 -06:00
J. Nick Koston bf9e2b7a54 Merge branch 'dev' into binary-sensor-click-callback 2026-11-01 13:03:51 -10:00
J. Nick Koston fd000ead9f Merge dev 2026-11-01 11:52:49 -10:00
J. Nick Koston 42956e0173 [binary_sensor] Read the click configs once and share the test callback
Pair each click key with its detector class in one table, add the define
and create the detectors in one pass, register every test callback
through one helper, and point the skill at the in-tree example instead
of copying it.
2026-10-31 08:33:28 -10:00
J. Nick Koston 7edd1a8ea7 Merge branch 'binary-sensor-click-callback-tests' into binary-sensor-click-callback
# Conflicts:
#	tests/components/binary_sensor/click_trigger_test.cpp
2026-10-31 08:15:50 -10:00
J. Nick Koston 35e03009e6 [binary_sensor] Test that a double click resets its times
A short second press after a double click would otherwise pair with the
first release and the next gap.
2026-10-31 08:14:30 -10:00
J. Nick Koston df69028380 [binary_sensor] Test a press published by double click actions
Pin that the double click times reset before the actions run, so a
press an action publishes on the same sensor is kept.
2026-10-31 08:13:31 -10:00
J. Nick Koston 46c0cd859f Merge branch 'binary-sensor-click-callback-tests' into binary-sensor-click-callback
# Conflicts:
#	tests/components/binary_sensor/click_trigger_test.cpp
2026-10-31 07:54:21 -10:00
J. Nick Koston 419660bb67 [binary_sensor] Fix clang-tidy in the click test and hold the press after a double click 2026-10-31 07:54:11 -10:00
J. Nick Koston 22ba443107 [binary_sensor] Fix clang-tidy in the click test and hold the press after a double click 2026-10-31 07:54:08 -10:00
pre-commit-ci-lite[bot] 4015cea895 apply automatic formatting fixes 2026-10-31 17:51:49 +00:00
J. Nick Koston d4a8b149a9 [binary_sensor] Name the click detectors and build them as callback entries
Rename the state objects to ClickDetector and DoubleClickDetector with
check(), write the detector id as ::id through ApplyCall so a trigger id
named like the callback parameter cannot hide it, build both automations
from _CALLBACK_AUTOMATIONS, share one schema helper, and document the
stateful filter pattern in the automations skill.
2026-10-31 07:46:59 -10:00
J. Nick Koston 97732c9a25 [binary_sensor] Build on_click and on_double_click as callback automations
The click timing moves into small ClickState and DoubleClickState objects
that a generated state callback asks, so an automation whose actions are
all inline runs as one function with no Trigger or Automation object.
ClickTrigger and DoubleClickTrigger are removed; only codegen used them.
2026-10-31 07:40:40 -10:00
J. Nick Koston e3bb14be43 [binary_sensor] Add tests for the click and double click triggers
Pin the click and double click timing with a C++ unit test, and the
generated trigger code with a codegen test.
2026-10-31 07:38:50 -10:00
20 changed files with 300 additions and 759 deletions

No files matched your search

+9 -2
View File
@@ -123,10 +123,17 @@ Both live in `esphome/core/helpers.h` and accept `std::function`, lambdas and fo
their templatized `add()` method. `LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first
callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
A filter that keeps state between calls (timing, edges) still needs no Trigger class: give each automation a
small object with constant initializers, created from its `CONF_TRIGGER_ID` before the automations are built,
and let `when` ask it, e.g. `ApplyCall("{}->check(state, {}, {})", ((CONF_TRIGGER_ID, ...), ...))`. An `ID`
value in an `ApplyCall` is written as `::id`, so a callback parameter cannot hide it. `on_click` in
`esphome/components/binary_sensor/__init__.py` is the in-tree example; once a second component needs this,
move the object creation into `CallbackAutomation`.
## Triggers: Trigger class method
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state
beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the state must be set up from the
parent (in the constructor or in `setup()`), or the trigger needs its own timers.
**Python:**
```python
+21 -64
View File
@@ -36,7 +36,6 @@ from esphome.cpp_generator import (
LambdaExpression,
MockObj,
MockObjClass,
Statement,
TemplateArgsType,
call_lambda,
)
@@ -705,16 +704,11 @@ def apply_static_function(
statements: list[str],
share: bool = False,
suffix: str = "fn",
add_global_: Callable[[Statement], object] | None = None,
) -> MockObj:
"""Emit the static function for ``id_``; ``suffix`` names a second one, ``share`` reuses an identical one.
``add_global_`` places the declaration (e.g. at a ``LatePvariable``); such a function is never shared.
"""
name = _apply_function_name(id_, suffix)
if add_global_ is not None:
return cg.static_function(name, return_type, params, statements, add_global_)
return cg.shared_static_function(name, return_type, params, statements, share=share)
"""Emit the static function for ``id_``; ``suffix`` names a second one, ``share`` reuses an identical one."""
return cg.shared_static_function(
_apply_function_name(id_, suffix), return_type, params, statements, share=share
)
def _apply_function_name(id_: ID, suffix: str = "fn") -> str:
@@ -1909,31 +1903,19 @@ def _emit_inline_run(
"""
if len(run) == 1 and run[0].emit is not None:
return run[0].emit()
statements = _run_statements(run)
id_ = run[0].id_.copy()
id_.type = ApplyRunAction
return cg.new_Pvariable(
id_, cg.TemplateArguments(_run_function(id_, run, args), *template_arg)
)
def _run_function(
id_: ID,
run: list[_InlineAction],
args: TemplateArgsType,
stoppable: bool = True,
add_global_: Callable[[Statement], object] | None = None,
) -> MockObj:
"""Emit the function that runs ``run``; a stoppable one takes the run count first and
returns once a stop clears it. ``add_global_`` is passed to ``cg.static_function``."""
params = _apply_params(args)
statements = _run_statements(run)
if stoppable:
params = [(cg.RawExpression("const int &"), _RUNNING_PARAM), *params]
else:
statements = [line for line in statements if line != _STOP_CHECK]
return apply_static_function(
id_, cg.void, params, statements, add_global_=add_global_
fn = apply_static_function(
id_,
cg.void,
[
(cg.RawExpression("const int &"), _RUNNING_PARAM),
*_apply_params(args),
],
statements,
)
return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg))
async def _build_action_plan(
@@ -2116,10 +2098,10 @@ def _automation_from_plan(
def _is_direct(plan: list[MockObj | list[_InlineAction]], config: ConfigType) -> bool:
"""Whether an automation is all inline actions without a manual id, so it needs no objects.
"""Whether a callback automation is all inline actions, so it needs no objects.
Without a Trigger or a manual id nothing can reach such an automation to stop it, so a
caller that creates no Trigger needs no stop checks either.
Without a Trigger or a manual id nothing can reach such an automation to stop it, so
no stop checks are needed either. Only for callers that create no Trigger.
"""
return (
not config[CONF_AUTOMATION_ID].is_manual
@@ -2132,15 +2114,13 @@ def _direct_function(
plan: list[MockObj | list[_InlineAction]],
args: TemplateArgsType,
config: ConfigType,
id_: ID | None = None,
stoppable: bool = False,
add_global_: Callable[[Statement], object] | None = None,
) -> MockObj | None:
"""The function that runs ``plan`` if it is direct, named after ``id_`` (default: its
first action); see ``_run_function``."""
if not _is_direct(plan, config):
return None
return _run_function(id_ or plan[0][0].id_, plan[0], args, stoppable, add_global_)
statements = [line for line in _run_statements(plan[0]) if line != _STOP_CHECK]
return apply_static_function(
plan[0][0].id_, cg.void, _apply_params(args), statements
)
def _when_call(
@@ -2160,29 +2140,6 @@ async def build_automation(
return await new_automation(args, config, trigger)
async def build_automation_or_run_function(
trigger: MockObj,
args: TemplateArgsType,
config: ConfigType,
add_global_: Callable[[Statement], object] | None = None,
) -> MockObj | None:
"""Build ``config`` on ``trigger`` like ``build_automation`` and return None; if every
action is inline, return the stoppable function ``esphome__<trigger id>__fn`` instead."""
plan = await _build_automation_plan(args, config)
if (
fn := _direct_function(
plan,
args,
config,
id_=trigger.base,
stoppable=True,
add_global_=add_global_,
)
) is None:
_automation_from_plan(plan, args, config, trigger)
return fn
async def build_trigger_callback(
args: TemplateArgsType,
config: ConfigType,
-1
View File
@@ -12,7 +12,6 @@ from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
Expression,
FlashStringLiteral,
LatePvariable,
LineComment,
LogStringLiteral,
MockObj,
+49 -47
View File
@@ -134,10 +134,8 @@ BinarySensorInitiallyOff = binary_sensor_ns.class_(
BinarySensorPtr = BinarySensor.operator("ptr")
# Triggers
ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.template())
DoubleClickTrigger = binary_sensor_ns.class_(
"DoubleClickTrigger", automation.Trigger.template()
)
ClickDetector = binary_sensor_ns.class_("ClickDetector")
DoubleClickDetector = binary_sensor_ns.class_("DoubleClickDetector")
MultiClickTrigger = binary_sensor_ns.class_(
"MultiClickTrigger", automation.Trigger.template(), cg.Component
)
@@ -450,6 +448,23 @@ def validate_click_timing(value):
return value
def _click_automation_schema(detector: MockObjClass) -> cv.All:
return cv.All(
automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(detector),
cv.Optional(
CONF_MIN_LENGTH, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_LENGTH, default="350ms"
): cv.positive_time_period_milliseconds,
}
),
validate_click_timing,
)
def validate_publish_initial_state(value):
value = cv.boolean(value)
_LOGGER.warning(
@@ -482,35 +497,9 @@ _BINARY_SENSOR_SCHEMA = (
): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
cv.Optional(CONF_ON_CLICK): cv.All(
automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ClickTrigger),
cv.Optional(
CONF_MIN_LENGTH, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_LENGTH, default="350ms"
): cv.positive_time_period_milliseconds,
}
),
validate_click_timing,
),
cv.Optional(CONF_ON_DOUBLE_CLICK): cv.All(
automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DoubleClickTrigger
),
cv.Optional(
CONF_MIN_LENGTH, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_LENGTH, default="350ms"
): cv.positive_time_period_milliseconds,
}
),
validate_click_timing,
cv.Optional(CONF_ON_CLICK): _click_automation_schema(ClickDetector),
cv.Optional(CONF_ON_DOUBLE_CLICK): _click_automation_schema(
DoubleClickDetector
),
cv.Optional(CONF_ON_MULTI_CLICK): automation.validate_automation(
{
@@ -564,6 +553,11 @@ def binary_sensor_schema(
return _BINARY_SENSOR_SCHEMA.extend(schema)
_CLICK_DETECTORS = (
(CONF_ON_CLICK, ClickDetector),
(CONF_ON_DOUBLE_CLICK, DoubleClickDetector),
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_PRESS,
@@ -586,32 +580,40 @@ _CALLBACK_AUTOMATIONS = (
"add_full_state_callback",
[(cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x")],
),
*(
automation.CallbackAutomation(
conf_key,
"add_on_state_callback",
params=[(bool, "state")],
forward=[],
when=automation.ApplyCall(
"{}->check(state, {}, {})",
(
(CONF_TRIGGER_ID, detector.operator("ptr")),
(CONF_MIN_LENGTH, cg.uint32),
(CONF_MAX_LENGTH, cg.uint32),
),
),
)
for conf_key, detector in _CLICK_DETECTORS
),
)
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _build_binary_sensor_automations(var, config):
# The detectors must exist before the automations that name them are built
if clicks := [c for key, _ in _CLICK_DETECTORS for c in config.get(key, ())]:
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
for conf in clicks:
cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK):
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
if config.get(CONF_ON_MULTI_CLICK):
cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER")
if config.get(CONF_ON_RAW_VALUE):
cg.add_define("USE_BINARY_SENSOR_RAW_CALLBACK")
for conf in config.get(CONF_ON_CLICK, []):
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_DOUBLE_CLICK, []):
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_MULTI_CLICK, []):
timings = [
cg.StructInitializer(
@@ -136,6 +136,28 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
return length >= min_length && length <= max_length;
}
}
bool ClickDetector::check(bool state, uint32_t min_length, uint32_t max_length) {
if (state) {
this->start_time_ = millis();
return false;
}
return match_interval(min_length, max_length, millis() - this->start_time_);
}
bool DoubleClickDetector::check(bool state, uint32_t min_length, uint32_t max_length) {
const uint32_t now = millis();
if (state && this->start_time_ != 0 && this->end_time_ != 0 &&
match_interval(min_length, max_length, this->end_time_ - this->start_time_) &&
match_interval(min_length, max_length, now - this->end_time_)) {
this->start_time_ = 0;
this->end_time_ = 0;
return true;
}
this->start_time_ = this->end_time_;
this->end_time_ = now;
return false;
}
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER
} // namespace esphome::binary_sensor
+11 -40
View File
@@ -24,54 +24,25 @@ static_assert(std::is_same_v<decltype(MultiClickTriggerEvent::state), uint32_t>
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length);
class ClickTrigger final : public Trigger<> {
/// Times the presses of one on_click automation.
class ClickDetector {
public:
explicit ClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length)
: min_length_(min_length), max_length_(max_length) {
parent->add_on_state_callback([this](bool state) {
if (state) {
this->start_time_ = millis();
} else {
const uint32_t length = millis() - this->start_time_;
if (match_interval(this->min_length_, this->max_length_, length))
this->trigger();
}
});
}
/// Whether `state` ends a press of min_length to max_length ms; a max_length of 0 means no maximum.
bool check(bool state, uint32_t min_length, uint32_t max_length);
protected:
uint32_t start_time_{0}; /// The millis() time when the click started.
uint32_t min_length_; /// Minimum length of click. 0 means no minimum.
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
uint32_t start_time_{0}; ///< The millis() time when the press started.
};
class DoubleClickTrigger final : public Trigger<> {
/// Times the presses of one on_double_click automation.
class DoubleClickDetector {
public:
explicit DoubleClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length)
: min_length_(min_length), max_length_(max_length) {
parent->add_on_state_callback([this](bool state) {
const uint32_t now = millis();
if (state && this->start_time_ != 0 && this->end_time_ != 0) {
if (match_interval(this->min_length_, this->max_length_, this->end_time_ - this->start_time_) &&
match_interval(this->min_length_, this->max_length_, now - this->end_time_)) {
this->trigger();
this->start_time_ = 0;
this->end_time_ = 0;
return;
}
}
this->start_time_ = this->end_time_;
this->end_time_ = now;
});
}
/// Whether `state` is the second press, with the first press and the gap both in the window.
bool check(bool state, uint32_t min_length, uint32_t max_length);
protected:
uint32_t start_time_{0};
uint32_t end_time_{0};
uint32_t min_length_; /// Minimum length of click. 0 means no minimum.
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
uint32_t start_time_{0}; ///< The millis() time of the edge before the last one.
uint32_t end_time_{0}; ///< The millis() time of the last edge.
};
class MultiClickTrigger final : public Trigger<>, public Component {
+27 -115
View File
@@ -1,5 +1,3 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from esphome import automation
@@ -14,14 +12,12 @@ from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "script"
script_ns = cg.esphome_ns.namespace("script")
Script = script_ns.class_("Script")
AutomationScript = script_ns.class_(
"AutomationScript", Script, automation.Trigger.template()
)
FunctionBody = script_ns.class_("FunctionBody", Script)
FunctionScript = script_ns.class_("FunctionScript", Script)
FunctionPointerScript = script_ns.class_("FunctionPointerScript", Script)
Script = script_ns.class_("Script", automation.Trigger.template())
ScriptWaitAction = script_ns.class_("ScriptWaitAction", automation.Action, cg.Component)
SingleScript = script_ns.class_("SingleScript", Script)
RestartScript = script_ns.class_("RestartScript", Script)
QueueingScript = script_ns.class_("QueueingScript", Script, cg.Component)
ParallelScript = script_ns.class_("ParallelScript", Script)
CONF_SINGLE = "single"
CONF_QUEUED = "queued"
@@ -29,38 +25,12 @@ CONF_PARALLEL = "parallel"
CONF_MAX_RUNS = "max_runs"
CONF_SCRIPT_STOP = "script.stop"
def _mode(
name: str, *parents: cg.MockObjClass
) -> tuple[cg.MockObjClass, cg.MockObjClass]:
"""The class of a mode with an Automation, and its template on the class that runs."""
return (
script_ns.class_(f"{name}Script", AutomationScript, *parents),
script_ns.class_(f"{name}Mode", Script, *parents),
)
MODES = {
CONF_SINGLE: _mode("Single"),
CONF_RESTART: _mode("Restart"),
CONF_QUEUED: _mode("Queueing", cg.Component),
CONF_PARALLEL: _mode("Parallel"),
SCRIPT_MODES = {
CONF_SINGLE: SingleScript,
CONF_RESTART: RestartScript,
CONF_QUEUED: QueueingScript,
CONF_PARALLEL: ParallelScript,
}
SCRIPT_MODES = {mode: classes[0] for mode, classes in MODES.items()}
@dataclass
class ScriptData:
# By script id: the class script.wait is templated on, or the script.wait actions to
# create once the script's class is known
waits: dict[str, cg.MockObj | list[Callable[[cg.MockObj], None]]] = field(
default_factory=dict
)
def _data() -> ScriptData:
return CORE.data.setdefault(DOMAIN, ScriptData())
PARAMETER_TYPE_TRANSLATIONS = {
"string": "std::string",
@@ -133,20 +103,6 @@ def validate_parameter_name(value):
raise cv.Invalid(f"Script's parameter name cannot be {CONF_ID}")
def validate_parameter_names(value: Any) -> Any:
"""Reject reserved parameter names here: as dict keys of a schema they would only be
reported as "extra keys not allowed"."""
if isinstance(value, dict):
for name in value:
# Generated C++ code names its own variables like this
if str(name).startswith("esphome__"):
raise cv.Invalid(
"Names starting with 'esphome__' are reserved for generated code",
path=[name],
)
return value
ALLOWED_PARAM_TYPE_CHARSET = set("abcdefghijklmnopqrstuvwxyz0123456789_:*&[]<>")
@@ -214,9 +170,10 @@ CONFIG_SCHEMA = cv.All(
*SCRIPT_MODES, lower=True
),
cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100),
cv.Optional(CONF_PARAMETERS, default={}): cv.All(
validate_parameter_names,
cv.Schema({validate_parameter_name: validate_parameter_type}),
cv.Optional(CONF_PARAMETERS, default={}): cv.Schema(
{
validate_parameter_name: validate_parameter_type,
}
),
cv.Optional(CONF_ON_STOP): automation.validate_automation(single=True),
},
@@ -226,56 +183,28 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
# Register all variables first, so that scripts can use other scripts. Each is created
# once its actions are built, which decides its class, in the place it was declared.
waits = _data().waits
scripts = []
# Register all variables first, so that scripts can use other scripts
triggers = []
for conf in config:
template, func_args = parameters_to_template(conf[CONF_PARAMETERS])
id_ = conf[CONF_ID].copy()
id_.type = id_.type.template(template)
late = cg.LatePvariable(id_)
waits[id_.id] = []
script = late.var
trigger = cg.new_Pvariable(conf[CONF_ID], template)
# Add a human-readable name to the script
cg.add(script.set_name(cg.LogStringLiteral(id_.id)))
cg.add(trigger.set_name(cg.LogStringLiteral(conf[CONF_ID].id)))
if CONF_MAX_RUNS in conf:
cg.add(script.set_max_runs(conf[CONF_MAX_RUNS]))
cg.add(trigger.set_max_runs(conf[CONF_MAX_RUNS]))
if queued := conf[CONF_MODE] == CONF_QUEUED:
await cg.register_component(script, conf)
if conf[CONF_MODE] == CONF_QUEUED:
await cg.register_component(trigger, conf)
scripts.append((late, template, func_args, conf, queued))
triggers.append((trigger, func_args, conf))
if any(CONF_ON_STOP in conf for conf in config):
cg.add_define("USE_SCRIPT_ON_STOP")
for late, template, func_args, conf, queued in scripts:
# The class or its constructor names the function, so it is declared in the script's place
fn = await automation.build_automation_or_run_function(
late.var, func_args, conf, late.global_slot.add
)
script_class = wait_class = late.id.type
ctor_args = []
if fn is not None:
wait_class = MODES[conf[CONF_MODE]][1].template(
FunctionBody.template(template), *template
)
if queued:
# One class for all of them: a per-script class would also copy the
# Component vtable, which costs more flash than the pointer's RAM
script_class = FunctionPointerScript.template(wait_class, *template)
ctor_args = [fn]
else:
script_class = FunctionScript.template(wait_class, fn, *template)
late.define(script_class, *ctor_args)
for create_wait in waits[late.id.id]:
create_wait(wait_class)
waits[late.id.id] = wait_class
for trigger, func_args, conf in triggers:
await automation.build_automation(trigger, func_args, conf)
if (stop_conf := conf.get(CONF_ON_STOP)) is not None:
await automation.build_automation(
late.var.get_stop_trigger(), [], stop_conf
)
await automation.build_automation(trigger.get_stop_trigger(), [], stop_conf)
@automation.register_action(
@@ -362,25 +291,8 @@ automation.register_apply_action(
)
async def script_wait_action_to_code(config, action_id, template_arg, args):
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
script_waits = _data().waits[full_id.id]
pending = isinstance(script_waits, list)
# Created where it is built, once the class of the script it waits for is known
id_ = action_id.copy()
id_.type = id_.type.template(
full_id.type if pending else script_waits, *template_arg
)
late = cg.LatePvariable(id_)
def create(script_class: cg.MockObj) -> None:
late.define(
action_id.type, cg.TemplateArguments(script_class, *template_arg), paren
)
if pending:
script_waits.append(create)
else:
create(script_waits)
var = late.var
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
var = cg.new_Pvariable(action_id, template_arg, paren)
await cg.register_component(var, {})
return var
+34 -99
View File
@@ -24,7 +24,7 @@ class ScriptLogger {
};
/// The abstract base class for all script types.
template<typename... Ts> class Script : public ScriptLogger {
template<typename... Ts> class Script : public ScriptLogger, public Trigger<Ts...> {
public:
/** Execute a new instance of this script.
*
@@ -32,83 +32,44 @@ template<typename... Ts> class Script : public ScriptLogger {
*/
virtual void execute(Ts...) = 0;
/// Check if any instance of this script is currently running.
virtual bool is_running() = 0;
/// Stop all instances of this script. Fires the on_stop trigger once if any instance was running.
virtual void stop() = 0;
virtual bool is_running() { return this->is_action_running(); }
#ifdef USE_SCRIPT_ON_STOP
/// Stop all instances of this script. Fires the on_stop trigger once if any instance was running.
virtual void stop() {
if (this->is_action_running())
this->stop_running_();
}
Trigger<> *get_stop_trigger() { return &this->stop_trigger_; }
#else
/// Stop all instances of this script.
virtual void stop() { this->stop_action(); }
#endif
// Internal function to give scripts readable names.
void set_name(const LogString *name) { name_ = name; }
protected:
const LogString *name_{nullptr};
#ifdef USE_SCRIPT_ON_STOP
Trigger<> stop_trigger_;
#endif
};
/// Runs the script's actions through an Automation, for actions that can wait (delay, wait_until, ...).
template<typename... Ts> class AutomationScript : public Script<Ts...>, public Trigger<Ts...> {
protected:
bool body_running_() { return this->is_action_running(); }
int body_num_running_() { return this->automation_parent_->num_running(); }
inline void run_body_(const Ts &...x) ESPHOME_ALWAYS_INLINE { this->trigger(x...); }
void stop_body_() { this->stop_action(); }
};
/** Runs the script's actions as one generated function, when they are all inline.
*
* Like ApplyRunAction, the function gets the run count by reference and returns early once a
* stop clears it. The mode code built on this is shared; FunctionScript or FunctionPointerScript
* supplies the function.
*/
template<typename... Ts> class FunctionBody : public Script<Ts...> {
protected:
bool body_running_() { return this->num_running_ > 0; }
int body_num_running_() { return this->num_running_; }
inline void run_body_(const Ts &...x) ESPHOME_ALWAYS_INLINE {
this->num_running_++;
this->run_function(this->num_running_, x...);
if (this->num_running_ > 0)
this->num_running_--;
}
void stop_body_() { this->num_running_ = 0; }
virtual void run_function(const int &running, const Ts &...x) = 0;
int num_running_{0};
};
/// What all script modes share, on top of the class that runs the actions.
template<class Body, typename... Ts> class ScriptBase : public Body {
public:
bool is_running() override { return this->body_running_(); }
#ifdef USE_SCRIPT_ON_STOP
void stop() override {
if (this->body_running_())
this->stop_running_();
}
#else
void stop() override { this->stop_body_(); }
#endif
protected:
// Publish this script's name as the current source while the actions run (RAII, so nesting
// composes), so deferred work is attributed to it in blocking warnings.
// Run the action chain with this script's name published as the current source (RAII save/restore,
// so nesting composes), so deferred work inside the script is attributed to it in blocking
// warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame).
inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE {
ScopedSourceGuard source_guard{this->name_};
this->run_body_(x...);
this->trigger(x...);
}
// Stop the running actions early and fire the on_stop trigger.
void stop_running_() {
this->stop_body_();
this->stop_action();
#ifdef USE_SCRIPT_ON_STOP
this->stop_trigger_.trigger();
#endif
}
const LogString *name_{nullptr};
#ifdef USE_SCRIPT_ON_STOP
Trigger<> stop_trigger_;
#endif
};
/** A script type for which only a single instance at a time is allowed.
@@ -116,10 +77,10 @@ template<class Body, typename... Ts> class ScriptBase : public Body {
* If a new instance is executed while the previous one hasn't finished yet,
* a warning is printed and the new instance is discarded.
*/
template<class Body, typename... Ts> class SingleMode : public ScriptBase<Body, Ts...> {
template<typename... Ts> class SingleScript : public Script<Ts...> {
public:
void execute(Ts... x) override {
if (this->body_running_()) {
if (this->is_action_running()) {
this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' is already running! (mode: single)"),
LOG_STR_ARG(this->name_));
return;
@@ -134,16 +95,16 @@ template<class Body, typename... Ts> class SingleMode : public ScriptBase<Body,
* If a new instance is started but another one is already running, the existing
* script is stopped and the new instance starts from the beginning.
*/
template<class Body, typename... Ts> class RestartMode : public ScriptBase<Body, Ts...> {
template<typename... Ts> class RestartScript : public Script<Ts...> {
public:
void execute(Ts... x) override {
if (this->body_running_()) {
if (this->is_action_running()) {
this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), LOG_STR_ARG(this->name_));
this->stop_running_();
#ifdef USE_SCRIPT_ON_STOP
// on_stop may have executed this script again; only the newest run may continue
if (this->body_running_())
this->stop_body_();
if (this->is_action_running())
this->stop_action();
#endif
}
@@ -165,10 +126,10 @@ template<class Body, typename... Ts> class RestartMode : public ScriptBase<Body,
* - Subsequent executes while running are queued starting at position 0
* - Maximum total instances = max_runs_ (includes 1 running + (max_runs_ - 1) queued)
*/
template<class Body, typename... Ts> class QueueingMode : public ScriptBase<Body, Ts...>, public Component {
template<typename... Ts> class QueueingScript : public Script<Ts...>, public Component {
public:
void execute(Ts... x) override {
if (this->body_running_() || this->num_queued_ > 0) {
if (this->is_action_running() || this->num_queued_ > 0) {
// num_queued_ is the number of *queued* instances (waiting, not including currently running)
// max_runs_ is the maximum *total* instances (running + queued)
// So we reject when num_queued_ + 1 >= max_runs_ (queued + running >= max)
@@ -203,7 +164,7 @@ template<class Body, typename... Ts> class QueueingMode : public ScriptBase<Body
void stop() override {
#ifdef USE_SCRIPT_ON_STOP
// Queued runs are unfinished work too, even between two runs when nothing is running
const bool stopping = this->body_running_() || this->num_queued_ != 0;
const bool stopping = this->is_action_running() || this->num_queued_ != 0;
#endif
// Clear all queued items to free memory immediately
// Resetting the array automatically destroys all unique_ptrs and their contents
@@ -214,12 +175,12 @@ template<class Body, typename... Ts> class QueueingMode : public ScriptBase<Body
if (stopping)
this->stop_running_();
#else
ScriptBase<Body, Ts...>::stop();
Script<Ts...>::stop();
#endif
}
void loop() override {
if (this->num_queued_ != 0 && !this->body_running_()) {
if (this->num_queued_ != 0 && !this->is_action_running()) {
// Dequeue: decrement count, move tuple out (frees slot), advance read position
this->num_queued_--;
const size_t queue_capacity = static_cast<size_t>(this->max_runs_ - 1);
@@ -266,10 +227,10 @@ template<class Body, typename... Ts> class QueueingMode : public ScriptBase<Body
* If a new instance is started while previous ones haven't finished yet,
* the new one is executed in parallel to the other instances.
*/
template<class Body, typename... Ts> class ParallelMode : public ScriptBase<Body, Ts...> {
template<typename... Ts> class ParallelScript : public Script<Ts...> {
public:
void execute(Ts... x) override {
if (this->max_runs_ != 0 && this->body_num_running_() >= this->max_runs_) {
if (this->max_runs_ != 0 && this->automation_parent_->num_running() >= this->max_runs_) {
this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of parallel runs exceeded!"),
LOG_STR_ARG(this->name_));
return;
@@ -282,32 +243,6 @@ template<class Body, typename... Ts> class ParallelMode : public ScriptBase<Body
int max_runs_ = 0;
};
template<typename... Ts> using SingleScript = SingleMode<AutomationScript<Ts...>, Ts...>;
template<typename... Ts> using RestartScript = RestartMode<AutomationScript<Ts...>, Ts...>;
template<typename... Ts> using QueueingScript = QueueingMode<AutomationScript<Ts...>, Ts...>;
template<typename... Ts> using ParallelScript = ParallelMode<AutomationScript<Ts...>, Ts...>;
/// A script of mode `Mode` (built on FunctionBody) whose actions are the generated function Fn.
template<class Mode, auto Fn, typename... Ts> class FunctionScript final : public Mode {
protected:
void run_function(const int &running, const Ts &...x) override { Fn(running, x...); }
};
/** Like FunctionScript, but one class for all scripts of `Mode`, holding the function.
*
* For queued scripts, whose per-script class would copy the Component vtable too.
*/
template<class Mode, typename... Ts> class FunctionPointerScript final : public Mode {
public:
using fn_t = void (*)(const int &, const Ts &...);
explicit FunctionPointerScript(fn_t fn) : fn_(fn) {}
protected:
void run_function(const int &running, const Ts &...x) override { this->fn_(running, x...); }
const fn_t fn_;
};
/** Wait for a script to finish before continuing.
*
* Uses queue-based storage to safely handle concurrent executions.
+14 -65
View File
@@ -803,18 +803,6 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj":
if type_ is not None:
id_.type = type_
_emit_pvariable(id_, rhs, CORE.add_global, CORE.add)
CORE.register_variable(id_, obj)
return obj
def _emit_pvariable(
id_: ID,
rhs: SafeExpType,
add_global_: Callable[[Expression | Statement], object],
add_: Callable[[Expression | Statement], object],
) -> None:
"""Emit the storage, declaration and construction of the pointer variable ``id_``."""
if isinstance(rhs, MockObj) and rhs.is_new_expr:
# For 'new' allocations, use placement new into static storage
# to avoid heap fragmentation on embedded devices.
@@ -837,14 +825,14 @@ def _emit_pvariable(
storage_name = _pstorage_name(actual_type, id_.id)
# Declare aligned byte array for the object storage
add_global_(
CORE.add_global(
RawStatement(
f"alignas({actual_type}) static unsigned char {storage_name}[sizeof({actual_type})];"
)
)
# Pointer declaration uses id_.type to preserve the declared base-class
# pointer type for downstream callers (polymorphism through base ptr).
add_global_(
CORE.add_global(
AssignmentExpression(
f"static {pointer_type}",
"*const ",
@@ -853,48 +841,15 @@ def _emit_pvariable(
)
)
placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args)
add_(ExpressionStatement(placement_new))
CORE.add(ExpressionStatement(placement_new))
CORE.data.setdefault(_STATIC_POINTER_IDS, set()).add(id_.id)
else:
decl = VariableDeclarationExpression(id_.type, "*", id_, static=True)
add_global_(decl)
add_(AssignmentExpression(None, None, id_, rhs))
CORE.add_global(decl)
CORE.add(AssignmentExpression(None, None, id_, rhs))
class _StatementSlot(Statement):
"""A place for statements that are added later."""
__slots__ = ("statements",)
def __init__(self) -> None:
self.statements: list[Statement] = []
def add(self, expression: Expression | Statement) -> None:
self.statements.append(statement(expression))
def __str__(self) -> str:
return "\n".join(str(item).rstrip() for item in self.statements)
class LatePvariable:
"""A pointer variable registered now, so other code can refer to ``var``, and created by
``define()`` in the place it was declared, once its class is known."""
def __init__(self, id_: ID) -> None:
self.id = id_
self.var = MockObj(id_, "->")
# Global declarations that must come before the variable can be added here
self.global_slot = _StatementSlot()
self._main_slot = _StatementSlot()
CORE.add_global(self.global_slot)
CORE.add(self._main_slot)
CORE.register_variable(id_, self.var)
def define(self, type_: "MockObjClass", *args: SafeExpType) -> None:
"""Create the variable as ``type_``, like ``new_Pvariable`` with ``args``."""
self.id.type = type_
rhs = _new_expression(self.id, args)
_emit_pvariable(self.id, rhs, self.global_slot.add, self._main_slot.add)
CORE.register_variable(id_, obj)
return obj
_STATIC_POINTER_IDS = "static_pointer_ids"
@@ -916,21 +871,16 @@ def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj":
with the given arguments.
:param id_: The ID used to declare the variable (also specifies the type).
:param args: The values to pass to the constructor, after optional template arguments.
:param args: The values to pass to the constructor.
:return: The new variable as a MockObj.
"""
if args and isinstance(args[0], TemplateArguments):
id_ = id_.copy()
return Pvariable(id_, _new_expression(id_, args))
def _new_expression(id_: ID, args: tuple[SafeExpType, ...]) -> "MockObj":
"""``new`` of ``id_.type``, first templated on ``args[0]`` if that is TemplateArguments."""
if args and isinstance(args[0], TemplateArguments):
id_.type = id_.type.template(args[0])
args = args[1:]
return id_.type.new(*args)
rhs = id_.type.new(*args)
return Pvariable(id_, rhs)
def static_function(
@@ -938,19 +888,18 @@ def static_function(
return_type: SafeExpType,
parameters: TemplateArgsType,
body: list[str | Statement],
add_global_: Callable[[Statement], object] | None = None,
) -> 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 (or to ``add_global_``, e.g. a
``LatePvariable``'s place), so a template argument can name the function at once; the body
goes after the user includes, where every id and everything those headers declare is in scope.
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
headers declare is in scope.
"""
signature = (
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
(add_global_ or add_global)(RawStatement(f"{signature};"))
add_global(RawStatement(f"{signature};"))
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(map(str, body)))}\n}}")
)
@@ -8,18 +8,24 @@ from esphome.core import CORE
def test_click_triggers(generate_main: Callable[[str | Path], str]) -> None:
"""Each trigger gets its timing in milliseconds, defaults 50 and 350, and its own automation."""
"""Each trigger gets a detector and a callback with its timing in milliseconds, defaults 50 and 350."""
main_cpp = generate_main("tests/component_tests/binary_sensor/test_click.yaml")
triggers = re.findall(
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Trigger\(button_a, (\d+), (\d+)\);",
main_cpp,
kinds = dict(
re.findall(
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Detector\(\);", main_cpp
)
)
assert [trigger[1:] for trigger in triggers] == [
calls = re.findall(r"if \(!\(::(\w+)->check\(state, (\d+), (\d+)\)\)\)", main_cpp)
assert [(kinds[detector_id], *timing) for detector_id, *timing in calls] == [
("Click", "50", "350"),
("Click", "1000", "3000"),
("DoubleClick", "20", "400"),
]
for trigger_id, *_ in triggers:
assert f"Automation<>({trigger_id});" in main_cpp
assert main_cpp.count("button_a->add_on_state_callback([](") == 3
# Only the automation with a delay keeps an Automation object
assert re.findall(r"new\((\w+)\) Automation<>\(\);", main_cpp) == [
"automation_id_2"
]
assert "::automation_id_2->trigger();" in main_cpp
assert "USE_BINARY_SENSOR_CLICK_TRIGGER" in {define.name for define in CORE.defines}
@@ -21,7 +21,9 @@ binary_sensor:
- delay: 1s
- logger.log: a long click done
on_double_click:
- min_length: 20ms
# Named like the callback parameter, which must not hide it
- trigger_id: state
min_length: 20ms
max_length: 400ms
then:
- logger.log: a double click
@@ -14,8 +14,7 @@ def test_nec_field_lambdas_get_the_script_args(
main_cpp = generate_main(component_config_path("nec_lambda_args.yaml"))
(body,) = protocol_call_bodies(main_cpp)
assert re.search(
r"static void esphome__\w+__fn\(const int & esphome__running, "
r"const std::remove_cvref_t<int> & x\) \{",
r"static void esphome__\w+__fn\(const std::remove_cvref_t<int> & x\) \{",
main_cpp,
)
assert "apply_call.data.address = [](int x) -> uint16_t {" in body
@@ -51,10 +51,9 @@ def test_code_storage_id_warns(
def test_no_action_objects(main_cpp: str) -> None:
assert "RawAction" not in main_cpp
assert "RCSwitch" not in main_cpp.replace("RCSwitchBase", "")
# The script runs its transmits from one generated function; the buttons call functions directly
assert "new(applyaction_id" not in main_cpp
assert "new(automation_id" not in main_cpp
assert "FunctionScript<" in main_cpp
# The script's two transmits merge into one action; the buttons call functions directly
assert main_cpp.count("new(applyaction_id") == 1
assert main_cpp.count("new(automation_id") == 1
def test_raw_static_code_in_flash(main_cpp: str, bodies: list[str]) -> None:
@@ -33,50 +33,24 @@ def _actions_of(main_cpp: str, automation: str) -> list[str]:
return re.findall(rf"\b{automation}->add_action\((\w+)\);", main_cpp)
def _template_tail(types: tuple[str, ...]) -> str:
return "".join(f", {type_}" for type_ in types)
def _single_action(main_cpp: str, script: str) -> str:
(action,) = _actions_of(main_cpp, _automation(main_cpp, script))
return action
def _mode_class(mode: str, *types: str) -> str:
"""The mode class shared by scripts that run a function, with the same parameters."""
return f"script::{mode}<script::FunctionBody<{', '.join(types)}>{_template_tail(types)}>"
def _run_function(main_cpp: str, script: str) -> str:
"""Return the function of the one merged action the script's Automation runs."""
constructed = _constructed(main_cpp, _single_action(main_cpp, script))
match = re.match(r"ApplyRunAction<(\w+)[,>]", constructed)
assert match is not None, constructed
return match.group(1)
def _function_script(mode: str, script: str, *types: str) -> str:
"""The class of a script whose actions run as one function."""
return (
f"script::FunctionScript<{_mode_class(mode, *types)}, "
f"esphome__{script}__fn{_template_tail(types)}>"
)
def _assert_function_script(main_cpp: str, mode: str, script: str, *types: str) -> None:
assert (
_constructed(main_cpp, script) == f"{_function_script(mode, script, *types)}()"
)
def _wait_class(script_class: str) -> str:
return f"script::ScriptWaitAction<{script_class}>"
def _script_body(main_cpp: str, script: str) -> str:
"""The function a script runs; it has no Automation."""
assert not re.search(rf"\bAutomation<.*?>\({script}\);", main_cpp)
return _function_body(main_cpp, f"esphome__{script}__fn")
def test_inline_actions_run_as_one_function(generate_main) -> None:
"""A script of inline actions calls one function, declared before the script."""
def test_inline_actions_merge_into_one_action(generate_main) -> None:
"""A script of inline actions runs them as one merged action of an Automation."""
main_cpp = generate_main(CONFIG)
class_ = _function_script("SingleMode", "inline_single")
assert _constructed(main_cpp, "inline_single") == f"{class_}()"
declaration = main_cpp.index(
"static void esphome__inline_single__fn(const int & esphome__running);"
)
assert declaration < main_cpp.index(f"alignas({class_})")
assert f"static {class_} *const inline_single" in main_cpp
body = _script_body(main_cpp, "inline_single")
assert _constructed(main_cpp, "inline_single") == "script::SingleScript<>()"
body = _function_body(main_cpp, _run_function(main_cpp, "inline_single"))
assert '"INLINE first"' in body
assert "counter->value() += 1;" in body
# A script can be stopped from inside, so the rest of the run checks for it
@@ -84,37 +58,31 @@ def test_inline_actions_run_as_one_function(generate_main) -> None:
def test_one_lambda_action(generate_main) -> None:
"""A single inline action is the whole function."""
"""A single inline action keeps its own action object."""
main_cpp = generate_main(CONFIG)
body = _script_body(main_cpp, "one_action")
assert '"ONE action"' in body
assert "esphome__running" not in body
constructed = _constructed(main_cpp, _single_action(main_cpp, "one_action"))
assert constructed.startswith("StatelessLambdaAction<>(")
assert '"ONE action"' in constructed
def test_modes(generate_main) -> None:
"""Each mode has its own template; max_runs is set on queued and parallel scripts."""
"""Each mode has its own class; max_runs is set on queued and parallel scripts."""
main_cpp = generate_main(CONFIG)
for mode, script in (
("RestartMode", "inline_restart"),
("ParallelMode", "inline_parallel"),
):
_assert_function_script(main_cpp, mode, script)
# One class for all queued scripts, given the function
assert _constructed(main_cpp, "inline_queued") == (
f"script::FunctionPointerScript<{_mode_class('QueueingMode')}>"
"(esphome__inline_queued__fn)"
)
assert _constructed(main_cpp, "inline_restart") == "script::RestartScript<>()"
assert _constructed(main_cpp, "inline_queued") == "script::QueueingScript<>()"
assert _constructed(main_cpp, "inline_parallel") == "script::ParallelScript<>()"
assert "inline_queued->set_max_runs(3);" in main_cpp
assert "inline_parallel->set_max_runs(2);" in main_cpp
assert re.search(r"App\.register_component_\(inline_queued, \d+\);", main_cpp)
assert '"QUEUED run"' in _script_body(main_cpp, "inline_queued")
assert '"PARALLEL run"' in _script_body(main_cpp, "inline_parallel")
for script in ("inline_queued", "inline_parallel"):
constructed = _constructed(main_cpp, _single_action(main_cpp, script))
assert constructed.startswith("StatelessLambdaAction<>(")
def test_restart_executes_itself(generate_main) -> None:
"""A script can execute itself; on_stop keeps its own Automation."""
"""A script can execute itself; on_stop has its own Automation."""
main_cpp = generate_main(CONFIG)
body = _script_body(main_cpp, "inline_restart")
body = _function_body(main_cpp, _run_function(main_cpp, "inline_restart"))
assert "::inline_restart->execute();" in body
stop = _automation(main_cpp, r"inline_restart->get_stop_trigger\(\)")
(action,) = _actions_of(main_cpp, stop)
@@ -124,14 +92,18 @@ def test_restart_executes_itself(generate_main) -> None:
def test_parameters_by_value(generate_main) -> None:
"""Lambdas take the parameters by value, so ``x *= 2`` compiles in every form."""
main_cpp = generate_main(CONFIG)
_assert_function_script(main_cpp, "SingleMode", "with_params", "int", "std::string")
assert (
_constructed(main_cpp, "with_params")
== "script::SingleScript<int, std::string>()"
)
name = _run_function(main_cpp, "with_params")
assert re.search(
r"static void esphome__with_params__fn\(const int & esphome__running, "
rf"static void {name}\(const int & esphome__running, "
r"const std::remove_cvref_t<int> & x, "
r"const std::remove_cvref_t<std::string> & s\)",
main_cpp,
)
body = _script_body(main_cpp, "with_params")
body = _function_body(main_cpp, name)
assert body.count("[](int x, std::string s) -> void {") == 2
assert body.count("[](int x, std::string s) -> int {") == 2
assert "x *= 2;\n ESP_LOGI(" in body
@@ -144,7 +116,7 @@ def test_parameters_by_value(generate_main) -> None:
def test_script_stop_inside(generate_main) -> None:
"""``script.stop`` without an id stops the script it is in; the next action checks."""
main_cpp = generate_main(CONFIG)
body = _script_body(main_cpp, "stops_itself")
body = _function_body(main_cpp, _run_function(main_cpp, "stops_itself"))
stop, rest = body.split("::stops_itself->stop();")
assert '"STOPS start"' in stop
assert rest.lstrip("\n }").startswith("if (!esphome__running)\n return;")
@@ -154,59 +126,35 @@ def test_script_stop_inside(generate_main) -> None:
def test_calls_itself_with_parameter(generate_main) -> None:
"""A parallel script with a parameter can execute itself from an ``if``."""
main_cpp = generate_main(CONFIG)
_assert_function_script(main_cpp, "ParallelMode", "calls_itself", "int")
body = _script_body(main_cpp, "calls_itself")
assert _constructed(main_cpp, "calls_itself") == "script::ParallelScript<int>()"
body = _function_body(main_cpp, _run_function(main_cpp, "calls_itself"))
assert "return n > 0;" in body
assert "::calls_itself->execute(std::move(esphome__arg_0));" in body
def test_delay_keeps_separate_actions(generate_main) -> None:
"""A delay keeps today's class, Automation and separate actions."""
"""A delay keeps the Automation and its separate actions."""
main_cpp = generate_main(CONFIG)
assert _constructed(main_cpp, "with_delay") == "script::SingleScript<>()"
assert "static script::SingleScript<> *const with_delay" in main_cpp
before, delay, after = _actions_of(main_cpp, _automation(main_cpp, "with_delay"))
assert _constructed(main_cpp, delay) == "DelayAction<>()"
assert '"DELAY before"' in _constructed(main_cpp, before)
assert '"DELAY after"' in _constructed(main_cpp, after)
assert "esphome__with_delay__fn" not in main_cpp
def test_manual_automation_id(generate_main) -> None:
"""An automation_id set by hand names the Automation, so a lambda can reach it."""
main_cpp = generate_main(CONFIG)
assert _constructed(main_cpp, "manual_automation") == "script::SingleScript<>()"
assert _automation(main_cpp, "manual_automation") == "manual_automation_id"
(action,) = _actions_of(main_cpp, "manual_automation_id")
assert '"MANUAL"' in _constructed(main_cpp, action)
def test_script_wait(generate_main) -> None:
"""``script.wait`` on a script that runs a function uses the class shared by its mode."""
"""``script.wait`` is a component templated on the class of the script it waits for."""
main_cpp = generate_main(CONFIG)
(action,) = _actions_of(main_cpp, _automation(main_cpp, "waits"))
class_ = _wait_class(_mode_class("SingleMode"))
assert _constructed(main_cpp, action) == f"{class_}(inline_single)"
def test_script_wait_built_first(generate_main) -> None:
"""A ``script.wait`` built before the script it waits for gets the shared class too."""
main_cpp = generate_main(CONFIG)
late, delay = _actions_of(main_cpp, _automation(main_cpp, "waits_early"))
class_ = _wait_class(_mode_class("SingleMode"))
assert _constructed(main_cpp, late) == f"{class_}(late_target)"
assert f"static {class_} *const {late}" in main_cpp
assert _constructed(main_cpp, delay) == (
f"{_wait_class('script::SingleScript<>')}(with_delay)"
)
def test_script_wait_keeps_waiting_script_parameters(generate_main) -> None:
"""Only the waited-for script's class changes, not a parameter with the same name."""
main_cpp = generate_main(CONFIG)
automation = _automation(main_cpp, "waits_with_param")
wait, _ = _actions_of(main_cpp, automation)
assert _constructed(main_cpp, wait) == (
f"script::ScriptWaitAction<{_mode_class('SingleMode')}, "
"script::SingleScript<>*>(late_target)"
assert (
_constructed(main_cpp, action)
== "script::ScriptWaitAction<script::SingleScript<>>(inline_single)"
)
@@ -11,20 +11,6 @@ globals:
type: int
script:
# Built before the scripts it waits for
- id: waits_early
then:
- script.wait: late_target
- script.wait: with_delay
# Has a parameter of the class an inline script had before it was known
- id: waits_with_param
parameters:
other: script::SingleScript<>*
then:
- script.wait: late_target
- lambda: other->execute();
# Inline actions only
- id: inline_single
then:
@@ -117,7 +103,3 @@ script:
- id: waits
then:
- script.wait: inline_single
- id: late_target
then:
- logger.log: "LATE target"
@@ -1,16 +1,15 @@
#include <gtest/gtest.h>
#include <memory>
#include <functional>
#include <utility>
#include "esphome/components/binary_sensor/automation.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/binary_sensor/filter.h"
#include "esphome/core/automation.h"
#include "esphome/core/base_automation.h"
#include "esphome/core/hal.h"
namespace esphome::binary_sensor::testing {
// The triggers read the real millis(), so every wait is at least 80 ms away from a window edge
// The click detectors read the real millis(), so every wait is at least 80 ms away from a window edge
static constexpr uint32_t MIN_MS = 100;
static constexpr uint32_t MAX_MS = 300;
static constexpr uint32_t SHORT_MS = 20;
@@ -24,18 +23,24 @@ class ClickTriggerTest : public ::testing::Test {
void SetUp() override {
fired = 0;
this->sensor_.publish_state(false);
this->automation_.add_action(&this->action_);
}
// As codegen builds on_click and on_double_click
void add_click_(uint32_t min_length = MIN_MS, uint32_t max_length = MAX_MS, BinarySensor *sensor = nullptr) {
this->click_ = std::make_unique<ClickTrigger>(sensor != nullptr ? sensor : &this->sensor_, min_length, max_length);
this->click_->set_automation_parent(&this->automation_);
this->watch_(this->click_, min_length, max_length, sensor != nullptr ? *sensor : this->sensor_);
}
void add_double_click_() {
this->double_click_ = std::make_unique<DoubleClickTrigger>(&this->sensor_, MIN_MS, MAX_MS);
this->double_click_->set_automation_parent(&this->automation_);
void add_double_click_(std::function<void()> on_fire = count_fire) {
this->watch_(this->double_click_, MIN_MS, MAX_MS, this->sensor_, std::move(on_fire));
}
template<typename D>
void watch_(D &detector, uint32_t min_length, uint32_t max_length, BinarySensor &sensor,
std::function<void()> on_fire = count_fire) {
sensor.add_on_state_callback([&detector, min_length, max_length, on_fire = std::move(on_fire)](bool state) {
if (detector.check(state, min_length, max_length))
on_fire();
});
}
static void count_fire() { fired++; }
void press_for_(uint32_t length) {
this->sensor_.publish_state(true);
@@ -44,10 +49,8 @@ class ClickTriggerTest : public ::testing::Test {
}
BinarySensor sensor_;
StatelessLambdaAction<> action_{[]() { fired++; }};
Automation<> automation_;
std::unique_ptr<ClickTrigger> click_;
std::unique_ptr<DoubleClickTrigger> double_click_;
ClickDetector click_;
DoubleClickDetector double_click_;
};
TEST(ClickMatchInterval, BoundsAreInclusive) {
@@ -198,4 +201,23 @@ TEST_F(ClickTriggerTest, DoubleClickDoesNotFireTwiceWithoutReset) {
EXPECT_EQ(fired, 1);
}
TEST_F(ClickTriggerTest, DoubleClickKeepsPressPublishedByItsActions) {
// The times reset before the actions run, so a press the first action publishes is kept
this->add_double_click_([this]() {
if (++fired == 1) {
this->sensor_.publish_state(false);
this->sensor_.publish_state(true);
}
});
this->press_for_(INSIDE_MS);
delay(INSIDE_MS);
this->sensor_.publish_state(true);
EXPECT_EQ(fired, 1);
delay(INSIDE_MS);
this->sensor_.publish_state(false);
delay(INSIDE_MS);
this->sensor_.publish_state(true);
EXPECT_EQ(fired, 2);
}
} // namespace esphome::binary_sensor::testing
@@ -1,121 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/components/script/script.h"
namespace esphome::script::testing {
// Each body is the function of its script, as code generation emits it; the bodies reach
// their script and record what happened through globals.
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
static void single_body(const int &running);
using TestSingle = FunctionScript<SingleMode<FunctionBody<>>, single_body>;
static TestSingle *single_script;
static int single_runs;
static bool single_running_inside;
static void single_body(const int &running) {
single_runs++;
single_running_inside = single_script->is_running();
// Only once, so a script that is not running here fails the test instead of recursing
if (single_runs == 1)
single_script->execute();
}
TEST(FunctionScriptTest, SingleIsRunningOnlyInsideItsBody) {
TestSingle script;
single_script = &script;
single_runs = 0;
script.execute();
EXPECT_EQ(single_runs, 1);
EXPECT_TRUE(single_running_inside);
EXPECT_FALSE(script.is_running());
}
static void stop_body(const int &running);
using TestStop = FunctionScript<SingleMode<FunctionBody<>>, stop_body>;
static TestStop *stop_script;
static int stop_running_after;
static void stop_body(const int &running) {
stop_script->stop();
stop_running_after = running;
}
TEST(FunctionScriptTest, StopInsideClearsTheRunCount) {
TestStop script;
stop_script = &script;
stop_running_after = -1;
script.execute();
EXPECT_EQ(stop_running_after, 0);
EXPECT_FALSE(script.is_running());
}
static void restart_body(const int &running);
using TestRestart = FunctionScript<RestartMode<FunctionBody<>>, restart_body>;
static TestRestart *restart_script;
static int restart_starts;
static int restart_ends;
static void restart_body(const int &running) {
if (restart_starts++ == 0)
restart_script->execute();
if (running)
restart_ends++;
}
TEST(FunctionScriptTest, RestartFromInsideStopsTheOuterRun) {
TestRestart script;
restart_script = &script;
restart_starts = restart_ends = 0;
script.execute();
EXPECT_EQ(restart_starts, 2);
EXPECT_EQ(restart_ends, 1);
EXPECT_FALSE(script.is_running());
}
static void parallel_body(const int &running, const int &n);
using TestParallel = FunctionScript<ParallelMode<FunctionBody<int>, int>, parallel_body, int>;
static TestParallel *parallel_script;
static int parallel_runs;
static void parallel_body(const int &running, const int &n) {
parallel_runs++;
if (n < 5)
parallel_script->execute(n + 1);
}
TEST(FunctionScriptTest, ParallelCountsNestedRuns) {
TestParallel script;
script.set_max_runs(2);
parallel_script = &script;
parallel_runs = 0;
script.execute(1);
EXPECT_EQ(parallel_runs, 2);
EXPECT_FALSE(script.is_running());
}
static void queued_body(const int &running, const int &n);
using TestQueued = FunctionPointerScript<QueueingMode<FunctionBody<int>, int>, int>;
// Through the base class, as external components keep scripts
static Script<int> *queued_script;
static int queued_last;
static int queued_runs;
static void queued_body(const int &running, const int &n) {
queued_runs++;
queued_last = n;
if (n == 1) {
queued_script->execute(2);
queued_script->execute(3);
}
}
TEST(FunctionScriptTest, QueuedRunsTheQueuedRunAfterTheBody) {
TestQueued script(queued_body);
script.set_max_runs(2);
queued_script = &script;
queued_runs = 0;
script.execute(1);
EXPECT_EQ(queued_runs, 2);
EXPECT_EQ(queued_last, 2);
EXPECT_FALSE(script.is_running());
}
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::script::testing
+15 -35
View File
@@ -9,14 +9,14 @@ from esphome import automation
from esphome.components.const import CONF_ON_STOP
from esphome.components.script import (
CONF_SCRIPT_STOP,
CONFIG_SCHEMA,
SCRIPT_MODES,
SingleScript,
default_script_stop_ids,
to_code,
)
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_THEN
from esphome.core import ID
from esphome.cpp_generator import MockObj
def test_bare_script_stop_defaults_to_enclosing_script() -> None:
@@ -103,78 +103,58 @@ async def test_on_stop_builds_stop_trigger_automation() -> None:
stop_conf = {CONF_THEN: []}
config = [
{
CONF_ID: ID("my_script", is_declaration=True, type=SCRIPT_MODES["single"]),
CONF_ID: ID("my_script", is_declaration=True, type=SingleScript),
CONF_MODE: "single",
CONF_PARAMETERS: {},
CONF_THEN: [],
CONF_ON_STOP: stop_conf,
},
{
CONF_ID: ID(
"other_script", is_declaration=True, type=SCRIPT_MODES["single"]
),
CONF_ID: ID("other_script", is_declaration=True, type=SingleScript),
CONF_MODE: "single",
CONF_PARAMETERS: {},
CONF_THEN: [],
},
]
with (
patch("esphome.codegen.new_Pvariable") as new_pvariable,
patch("esphome.codegen.add"),
patch("esphome.codegen.add_define") as add_define,
patch(
"esphome.automation.build_automation", new_callable=AsyncMock
) as build_automation,
patch(
"esphome.automation.build_automation_or_run_function",
new_callable=AsyncMock,
return_value=None,
) as build_or_run,
):
new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->")
await to_code(config)
add_define.assert_called_once_with("USE_SCRIPT_ON_STOP")
assert [(str(c.args[0]), *c.args[1:3]) for c in build_or_run.await_args_list] == [
("my_script", [], config[0]),
("other_script", [], config[1]),
]
assert [
(str(c.args[0]), *c.args[1:]) for c in build_automation.await_args_list
] == [("my_script->get_stop_trigger()", [], stop_conf)]
] == [
("my_script", [], config[0]),
("my_script->get_stop_trigger()", [], stop_conf),
("other_script", [], config[1]),
]
@pytest.mark.asyncio
async def test_no_on_stop_define_without_on_stop() -> None:
config = [
{
CONF_ID: ID("my_script", is_declaration=True, type=SCRIPT_MODES["single"]),
CONF_ID: ID("my_script", is_declaration=True, type=SingleScript),
CONF_MODE: "single",
CONF_PARAMETERS: {},
CONF_THEN: [],
}
]
with (
patch("esphome.codegen.new_Pvariable") as new_pvariable,
patch("esphome.codegen.add"),
patch("esphome.codegen.add_define") as add_define,
patch(
"esphome.automation.build_automation_or_run_function",
new_callable=AsyncMock,
return_value=None,
),
patch("esphome.automation.build_automation", new_callable=AsyncMock),
):
new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->")
await to_code(config)
add_define.assert_not_called()
@pytest.mark.parametrize("name", ["esphome__running", "esphome__arg_0"])
def test_reserved_parameter_names_rejected(name: str) -> None:
"""The reason reaches the user, at the parameter's path."""
config = [{CONF_ID: "my_script", CONF_PARAMETERS: {name: "int"}, CONF_THEN: []}]
with pytest.raises(cv.MultipleInvalid) as err:
CONFIG_SCHEMA(config)
(error,) = err.value.errors
assert (
error.msg == "Names starting with 'esphome__' are reserved for generated code"
)
assert error.path == [0, CONF_PARAMETERS, name]
+1 -5
View File
@@ -1595,11 +1595,7 @@ def apply_functions(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str, str
emitted: list[tuple[str, str, str]] = []
def fake_static_function(
name: str,
return_type: Any,
parameters: list[tuple[Any, str]],
body: list[str],
add_global_: Any = None,
name: str, return_type: Any, parameters: list[tuple[Any, str]], body: list[str]
) -> RawExpression:
assert str(return_type) == "void"
params = ", ".join(f"{type_} {arg}" for type_, arg in parameters)
-26
View File
@@ -1173,32 +1173,6 @@ def test_static_function_declares_early_and_defines_after_includes() -> None:
]
def test_static_function_declaration_sink() -> None:
declarations: list[cg.Statement] = []
cg.static_function(
"esphome__act__fn", ct.void, [], ["return;"], declarations.append
)
assert [str(st) for st in declarations] == ["static void esphome__act__fn();"]
assert CORE.global_statements == []
def test_late_pvariable_is_created_where_declared() -> None:
"""A late variable is registered at once and emitted in its place, like new_Pvariable."""
id_ = ID("late", is_declaration=True, type=ct.esphome_ns.class_("Late"))
late = cg.LatePvariable(id_)
assert CORE.variables[id_] is late.var
CORE.add_global(cg.RawStatement("int after;"))
cg.add(cg.RawStatement("after();"))
late.global_slot.add(cg.RawStatement("int before_late;"))
late.define(ct.esphome_ns.class_("Final"), cg.TemplateArguments(ct.int32), 5)
assert str(id_.type) == "Final<int32_t>"
globals_ = CORE.cpp_global_section
assert globals_.index("Final<int32_t> *const late") < globals_.index("int after;")
main = CORE.cpp_main_section
assert main.index("new(late) Final<int32_t>(5);") < main.index("after();")
assert cg.is_static_pointer(late.var)
def test_static_function_accepts_statements() -> None:
call = cg.MockObj("foo")(cg.MockObj("x") >> 2)
cg.static_function(