Compare commits

..
Author SHA1 Message Date
J. Nick Koston 14f809e70d Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:37:37 -06:00
J. Nick Koston 2b7c8e5523 Merge remote-tracking branch 'upstream/dev' into script-direct-callback
# Conflicts:
#	esphome/automation.py
2026-11-11 18:49:39 -06:00
J. Nick Koston 8634800ea6 Name the direct function arguments, drop an unrelated edit and a repeated test 2026-11-09 21:10:39 -08:00
J. Nick Koston b8504cf93f Expect no action objects for the rc_switch script now that scripts run one function 2026-11-09 20:57:14 -08:00
J. Nick Koston 5045d72732 Merge remote-tracking branch 'upstream/dev' into script-direct-callback
# Conflicts:
#	esphome/automation.py
2026-11-09 20:56:31 -08:00
pre-commit-ci-lite[bot] 65a3fff28d apply automatic formatting fixes 2026-11-05 00:41:54 +00:00
J. Nick Koston c98eb5b250 Merge remote-tracking branch 'origin/dev' into script-direct-callback
# Conflicts:
#	esphome/cpp_generator.py
#	tests/unit_tests/test_cpp_generator.py
2026-11-04 18:37:52 -06:00
J. Nick Koston 7464db1db8 Merge remote-tracking branch 'origin/dev' into script-direct-callback
# Conflicts:
#	esphome/automation.py
2026-11-03 18:48:03 -06:00
J. Nick Koston b3053c90ab [script] Expect the run count parameter on the script function in the NEC lambda test 2026-11-01 21:07:07 -10:00
J. Nick Koston 3d2b769f91 Merge branch 'dev' into script-direct-callback 2026-11-01 13:30:59 -10:00
J. Nick Koston 2709de3355 [script] Inline the reserved-name check, name the function pointer type 2026-11-01 16:34:13 -05:00
J. Nick Koston 2b24bef810 [script] Queued function scripts share one class holding the function; report reserved parameter names 2026-11-01 16:34:13 -05:00
J. Nick Koston b647b65e6e [script] Declare the function in the script's slot, one script.wait path, shared reserved-name check 2026-11-01 16:34:13 -05:00
J. Nick Koston a6d561ce19 [script] Create scripts in place once their class is known, reserve esphome__ names 2026-11-01 16:34:13 -05:00
J. Nick Koston ee9dcc2958 [script] Fail instead of recursing when a single script is not running in its body 2026-11-01 16:34:13 -05:00
J. Nick Koston 19fc21db23 [script] Share mode code between function scripts, declare the function in place 2026-11-01 16:34:13 -05:00
J. Nick Koston 61528b9d4e [script] Run scripts of inline actions from one generated function 2026-11-01 16:34:13 -05:00
34 changed files with 685 additions and 579 deletions

No files matched your search

+1 -1
View File
@@ -741,7 +741,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @arpena @glmnet @Rodrigoah
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
+64 -21
View File
@@ -36,6 +36,7 @@ from esphome.cpp_generator import (
LambdaExpression,
MockObj,
MockObjClass,
Statement,
TemplateArgsType,
call_lambda,
)
@@ -704,11 +705,16 @@ 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."""
return cg.shared_static_function(
_apply_function_name(id_, suffix), return_type, params, statements, share=share
)
"""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)
def _apply_function_name(id_: ID, suffix: str = "fn") -> str:
@@ -1903,19 +1909,31 @@ 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
fn = apply_static_function(
id_,
cg.void,
[
(cg.RawExpression("const int &"), _RUNNING_PARAM),
*_apply_params(args),
],
statements,
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_
)
return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg))
async def _build_action_plan(
@@ -2098,10 +2116,10 @@ def _automation_from_plan(
def _is_direct(plan: list[MockObj | list[_InlineAction]], config: ConfigType) -> bool:
"""Whether a callback automation is all inline actions, so it needs no objects.
"""Whether an automation is all inline actions without a manual id, so it needs no objects.
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.
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.
"""
return (
not config[CONF_AUTOMATION_ID].is_manual
@@ -2114,13 +2132,15 @@ 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
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
)
return _run_function(id_ or plan[0][0].id_, plan[0], args, stoppable, add_global_)
def _when_call(
@@ -2140,6 +2160,29 @@ 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,6 +12,7 @@ from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
Expression,
FlashStringLiteral,
LatePvariable,
LineComment,
LogStringLiteral,
MockObj,
@@ -7,8 +7,6 @@
#include "esphome/core/automation.h"
#include "esphome/components/network/ip_address.h"
#include <atomic>
#ifdef USE_ETHERNET
#ifdef USE_ESP32
@@ -368,9 +366,6 @@ class EthernetComponent final : public Component {
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
StaticVector<EthernetIPStateListener *, ESPHOME_ETHERNET_IP_STATE_LISTENERS> ip_state_listeners_;
#ifdef USE_ESP32
std::atomic<uint8_t> ip_state_pending_{0};
#endif
#endif
#ifdef USE_ETHERNET_CONNECT_TRIGGER
@@ -129,14 +129,6 @@ void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *me
void EthernetComponent::loop() {
const uint32_t now = App.get_loop_component_start_time();
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
// The IP events arrive on the event task; the listeners publish states, so notify them here
if (this->ip_state_pending_.load(std::memory_order_acquire) != 0) {
this->ip_state_pending_.store(0, std::memory_order_relaxed);
this->notify_ip_state_listeners_();
}
#endif
switch (this->state_) {
case EthernetComponentState::STOPPED:
if (this->started_) {
@@ -778,8 +770,7 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base
event_name = "ETH connected";
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP)
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
global_eth_component->enable_loop_soon_any_context();
global_eth_component->notify_ip_state_listeners_();
#endif
#if USE_NETWORK_IPV6
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
@@ -807,9 +798,6 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
const esp_netif_ip_info_t *ip_info = &event->ip_info;
ESP_LOGV(TAG, "[Ethernet event] ETH Got IP " IPSTR, IP2STR(&ip_info->ip));
global_eth_component->got_ipv4_address_ = true;
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ = global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
@@ -817,6 +805,9 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
global_eth_component->connected_ = true;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif /* USE_NETWORK_IPV6 */
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#if USE_NETWORK_IPV6
@@ -828,9 +819,6 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
// after a link flap fires another event for the same address.
struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s);
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ =
global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT);
@@ -839,6 +827,9 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
global_eth_component->connected_ = global_eth_component->got_ipv4_address_;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#endif /* USE_NETWORK_IPV6 */
+115 -27
View File
@@ -1,3 +1,5 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from esphome import automation
@@ -12,12 +14,14 @@ from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "script"
script_ns = cg.esphome_ns.namespace("script")
Script = script_ns.class_("Script", automation.Trigger.template())
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)
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"
@@ -25,12 +29,38 @@ CONF_PARALLEL = "parallel"
CONF_MAX_RUNS = "max_runs"
CONF_SCRIPT_STOP = "script.stop"
SCRIPT_MODES = {
CONF_SINGLE: SingleScript,
CONF_RESTART: RestartScript,
CONF_QUEUED: QueueingScript,
CONF_PARALLEL: ParallelScript,
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 = {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",
@@ -103,6 +133,20 @@ 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_:*&[]<>")
@@ -170,10 +214,9 @@ 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.Schema(
{
validate_parameter_name: validate_parameter_type,
}
cv.Optional(CONF_PARAMETERS, default={}): cv.All(
validate_parameter_names,
cv.Schema({validate_parameter_name: validate_parameter_type}),
),
cv.Optional(CONF_ON_STOP): automation.validate_automation(single=True),
},
@@ -183,28 +226,56 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
# Register all variables first, so that scripts can use other scripts
triggers = []
# 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 = []
for conf in config:
template, func_args = parameters_to_template(conf[CONF_PARAMETERS])
trigger = cg.new_Pvariable(conf[CONF_ID], template)
id_ = conf[CONF_ID].copy()
id_.type = id_.type.template(template)
late = cg.LatePvariable(id_)
waits[id_.id] = []
script = late.var
# Add a human-readable name to the script
cg.add(trigger.set_name(cg.LogStringLiteral(conf[CONF_ID].id)))
cg.add(script.set_name(cg.LogStringLiteral(id_.id)))
if CONF_MAX_RUNS in conf:
cg.add(trigger.set_max_runs(conf[CONF_MAX_RUNS]))
cg.add(script.set_max_runs(conf[CONF_MAX_RUNS]))
if conf[CONF_MODE] == CONF_QUEUED:
await cg.register_component(trigger, conf)
if queued := conf[CONF_MODE] == CONF_QUEUED:
await cg.register_component(script, conf)
triggers.append((trigger, func_args, conf))
scripts.append((late, template, func_args, conf, queued))
if any(CONF_ON_STOP in conf for conf in config):
cg.add_define("USE_SCRIPT_ON_STOP")
for trigger, func_args, conf in triggers:
await automation.build_automation(trigger, func_args, conf)
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
if (stop_conf := conf.get(CONF_ON_STOP)) is not None:
await automation.build_automation(trigger.get_stop_trigger(), [], stop_conf)
await automation.build_automation(
late.var.get_stop_trigger(), [], stop_conf
)
@automation.register_action(
@@ -291,8 +362,25 @@ 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])
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
var = cg.new_Pvariable(action_id, template_arg, paren)
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
await cg.register_component(var, {})
return var
+104 -39
View File
@@ -24,7 +24,7 @@ class ScriptLogger {
};
/// The abstract base class for all script types.
template<typename... Ts> class Script : public ScriptLogger, public Trigger<Ts...> {
template<typename... Ts> class Script : public ScriptLogger {
public:
/** Execute a new instance of this script.
*
@@ -32,55 +32,94 @@ template<typename... Ts> class Script : public ScriptLogger, public Trigger<Ts..
*/
virtual void execute(Ts...) = 0;
/// Check if any instance of this script is currently running.
virtual bool is_running() { return this->is_action_running(); }
#ifdef USE_SCRIPT_ON_STOP
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() {
if (this->is_action_running())
this->stop_running_();
}
virtual void stop() = 0;
#ifdef USE_SCRIPT_ON_STOP
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:
// 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->trigger(x...);
}
// Stop the running actions early and fire the on_stop trigger.
void stop_running_() {
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
};
/// 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.
inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE {
ScopedSourceGuard source_guard{this->name_};
this->run_body_(x...);
}
// Stop the running actions early and fire the on_stop trigger.
void stop_running_() {
this->stop_body_();
#ifdef USE_SCRIPT_ON_STOP
this->stop_trigger_.trigger();
#endif
}
};
/** A script type for which only a single instance at a time is allowed.
*
* 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<typename... Ts> class SingleScript : public Script<Ts...> {
template<class Body, typename... Ts> class SingleMode : public ScriptBase<Body, Ts...> {
public:
void execute(Ts... x) override {
if (this->is_action_running()) {
if (this->body_running_()) {
this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' is already running! (mode: single)"),
LOG_STR_ARG(this->name_));
return;
@@ -95,16 +134,16 @@ template<typename... Ts> class SingleScript : public Script<Ts...> {
* 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<typename... Ts> class RestartScript : public Script<Ts...> {
template<class Body, typename... Ts> class RestartMode : public ScriptBase<Body, Ts...> {
public:
void execute(Ts... x) override {
if (this->is_action_running()) {
if (this->body_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->is_action_running())
this->stop_action();
if (this->body_running_())
this->stop_body_();
#endif
}
@@ -126,10 +165,10 @@ template<typename... Ts> class RestartScript : public Script<Ts...> {
* - Subsequent executes while running are queued starting at position 0
* - Maximum total instances = max_runs_ (includes 1 running + (max_runs_ - 1) queued)
*/
template<typename... Ts> class QueueingScript : public Script<Ts...>, public Component {
template<class Body, typename... Ts> class QueueingMode : public ScriptBase<Body, Ts...>, public Component {
public:
void execute(Ts... x) override {
if (this->is_action_running() || this->num_queued_ > 0) {
if (this->body_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)
@@ -164,7 +203,7 @@ template<typename... Ts> class QueueingScript : public Script<Ts...>, public Com
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->is_action_running() || this->num_queued_ != 0;
const bool stopping = this->body_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
@@ -175,12 +214,12 @@ template<typename... Ts> class QueueingScript : public Script<Ts...>, public Com
if (stopping)
this->stop_running_();
#else
Script<Ts...>::stop();
ScriptBase<Body, Ts...>::stop();
#endif
}
void loop() override {
if (this->num_queued_ != 0 && !this->is_action_running()) {
if (this->num_queued_ != 0 && !this->body_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);
@@ -227,10 +266,10 @@ template<typename... Ts> class QueueingScript : public Script<Ts...>, public Com
* 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<typename... Ts> class ParallelScript : public Script<Ts...> {
template<class Body, typename... Ts> class ParallelMode : public ScriptBase<Body, Ts...> {
public:
void execute(Ts... x) override {
if (this->max_runs_ != 0 && this->automation_parent_->num_running() >= this->max_runs_) {
if (this->max_runs_ != 0 && this->body_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;
@@ -243,6 +282,32 @@ template<typename... Ts> class ParallelScript : public Script<Ts...> {
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.
+2 -6
View File
@@ -5,15 +5,13 @@ from esphome.const import CONF_MODEL
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@glmnet", "@arpena", "@Rodrigoah"]
CODEOWNERS = ["@glmnet", "@arpena"]
whirlpool_ns = cg.esphome_ns.namespace("whirlpool")
WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR)
Model = whirlpool_ns.enum("Model")
MODELS = Model.options(
"DG11J1-3A", "DG11J1-91", "HISENSE_ARG", "HITACHI_HCRA31NEWH", prefix="MODEL_"
)
MODELS = Model.options("DG11J1-3A", "DG11J1-91", "HISENSE_ARG", prefix="MODEL_")
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend(
{
@@ -28,5 +26,3 @@ async def to_code(config: ConfigType) -> None:
# cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared
if config[CONF_MODEL] == "HISENSE_ARG":
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
elif config[CONF_MODEL] == "HITACHI_HCRA31NEWH":
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
+15 -61
View File
@@ -2,8 +2,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome::whirlpool {
ESPHOME_LOG_TAG(TAG, "whirlpool.climate");
@@ -46,17 +44,6 @@ static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15
static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15
static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C
// Hitachi HCRA31NEWH model: power is a state in every frame
static constexpr uint8_t HITACHI_BYTE10 = 0x20; // byte 10
static constexpr uint8_t HITACHI_COOL = 0x02; // byte 15
static constexpr uint8_t HITACHI_OFF = 0x01; // byte 15
static constexpr uint8_t HITACHI_BYTE18_ON = 0x38; // byte 18
static constexpr uint8_t HITACHI_BYTE18_OFF = 0x28; // byte 18
static constexpr uint8_t HITACHI_BYTE19 = 0x08; // byte 19
// Header, 21 bytes of 8 bits, two dividers and the footer mark
static constexpr uint32_t WHIRLPOOL_FRAME_TIMINGS = 2 + WHIRLPOOL_STATE_LENGTH * 16 + 2 * 2 + 1;
WhirlpoolClimate::WhirlpoolClimate()
: climate_ir::ClimateIR(
WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true,
@@ -68,19 +55,19 @@ void WhirlpoolClimate::transmit_state() {
this->last_transmit_time_ = millis(); // setting the time of the last transmission.
uint8_t remote_state[WHIRLPOOL_STATE_LENGTH];
memset(remote_state, 0, sizeof(remote_state));
put_be16(remote_state, this->remote_id_());
remote_state[0] = 0x83;
remote_state[1] = 0x06;
remote_state[6] = 0x80;
remote_state[18] = 0x08;
remote_state[18] = 0x08; // the same on every supported model
const bool powered_on = this->mode != climate::CLIMATE_MODE_OFF;
// Hitachi sends power as a state instead of a toggle
const bool power_toggle = !this->is_hitachi_hcra31newh_() && powered_on != this->powered_on_assumed;
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
const bool power_toggle = powered_on != this->powered_on_assumed;
if (power_toggle) {
// Set power toggle command
remote_state[2] = 4;
remote_state[15] = 1;
this->powered_on_assumed = powered_on;
}
this->powered_on_assumed = powered_on;
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT_COOL:
// set fan auto
@@ -106,10 +93,6 @@ void WhirlpoolClimate::transmit_state() {
remote_state[15] = 6;
break;
case climate::CLIMATE_MODE_OFF:
// These remotes keep the cool code in the power-off frame
if (this->is_hisense_arg_() || this->is_hitachi_hcra31newh_())
remote_state[3] = WHIRLPOOL_COOL;
break;
default:
break;
}
@@ -119,7 +102,6 @@ void WhirlpoolClimate::transmit_state() {
// Temperature
auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_()));
const auto temp_step = (uint8_t) (temp - this->temperature_min_());
this->target_temperature = temp; // publish what the unit was sent
if (hisense_arg && this->fixed_temp_mode_()) {
remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4;
} else {
@@ -149,6 +131,7 @@ void WhirlpoolClimate::transmit_state() {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
remote_state[8] = WHIRLPOOL_SWING_BYTE8;
}
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
} else if (this->send_swing_cmd_) {
if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
@@ -156,11 +139,6 @@ void WhirlpoolClimate::transmit_state() {
}
}
if (hisense_arg)
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
if (this->is_hitachi_hcra31newh_())
this->encode_hitachi_hcra31newh_(remote_state);
// Checksum
remote_state[13] = xor_bytes(&remote_state[2], 11, remote_state[13]);
remote_state[20] = xor_bytes(&remote_state[14], 6, remote_state[20]);
@@ -170,7 +148,6 @@ void WhirlpoolClimate::transmit_state() {
// Send code
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
data->reserve(WHIRLPOOL_FRAME_TIMINGS);
data->set_carrier_frequency(38000);
@@ -203,6 +180,10 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY;
switch (this->mode) {
case climate::CLIMATE_MODE_OFF:
// The remote keeps the cool code in the power-off frame
remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL;
break;
case climate::CLIMATE_MODE_DRY:
// The unit runs these fan speeds itself; the user's choice is kept for the next mode change
remote_state[2] &= ~WHIRLPOOL_FAN_MASK;
@@ -231,27 +212,6 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
#endif
}
void WhirlpoolClimate::encode_hitachi_hcra31newh_(uint8_t *remote_state) const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
remote_state[10] = HITACHI_BYTE10;
remote_state[18] = HITACHI_BYTE18_ON;
remote_state[19] = HITACHI_BYTE19;
// The unit takes the whole state from every frame: only the off frame carries the power bit
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
remote_state[15] = HITACHI_COOL;
break;
case climate::CLIMATE_MODE_OFF:
remote_state[2] |= WHIRLPOOL_POWER;
remote_state[15] = HITACHI_OFF;
remote_state[18] = HITACHI_BYTE18_OFF;
break;
default:
break;
}
#endif
}
bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
// Check if the esp isn't currently transmitting.
if (millis() - this->last_transmit_time_ < 500) {
@@ -274,8 +234,7 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
if (!data.expect_item(WHIRLPOOL_BIT_MARK, WHIRLPOOL_GAP))
return false;
}
// The Hitachi remote always sends all 21 bytes; byte 18 holds its power state
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid() && !this->is_hitachi_hcra31newh_()) {
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid()) {
// Remote control only sent 14 bytes, nothing more to read, not even the footer
ESP_LOGV(TAG, "Remote control only sent %d bytes", i);
skip_footer = true;
@@ -302,18 +261,13 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
ESP_LOGV_HEX(TAG, remote_state, WHIRLPOOL_STATE_LENGTH, WHIRLPOOL_STATE_LENGTH, "Received: %s");
// verify header remote code
if (encode_uint16(remote_state[0], remote_state[1]) != this->remote_id_())
if (remote_state[0] != 0x83 || remote_state[1] != 0x06)
return false;
// powr on/off button
ESP_LOGV(TAG, "Power: %02X", (remote_state[2] & WHIRLPOOL_POWER));
if (this->is_hitachi_hcra31newh_()) {
// Power is a state: byte 18 says whether the unit is off
this->powered_on_assumed = remote_state[18] != HITACHI_BYTE18_OFF;
if (!this->powered_on_assumed)
this->mode = climate::CLIMATE_MODE_OFF;
} else if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
if (powered_on) {
@@ -355,7 +309,7 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
int temp = remote_state[3] & 0xF0;
ESP_LOGVV(TAG, "Temperature Raw: %02X", temp);
temp = (uint8_t) temp >> 4;
temp = std::min(temp + static_cast<int>(this->temperature_min_()), static_cast<int>(this->temperature_max_()));
temp += static_cast<int>(this->temperature_min_());
ESP_LOGVV(TAG, "Temperature Climate: %u", temp);
this->target_temperature = temp;
}
+4 -19
View File
@@ -6,10 +6,9 @@ namespace esphome::whirlpool {
/// Simple enum to represent models.
enum Model {
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
MODEL_HITACHI_HCRA31NEWH = 3, /// Hitachi with the HCRA31NEWH remote, 16 to 30
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
};
// Temperature
@@ -30,9 +29,7 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
/// Override control to change settings of the climate device.
void control(const climate::ClimateCall &call) override {
// The toggle remotes flip swing on every press, so only send one when the mode really changes
auto swing = call.get_swing_mode();
this->send_swing_cmd_ = swing.has_value() && *swing != this->swing_mode;
send_swing_cmd_ = call.get_swing_mode().has_value();
climate_ir::ClimateIR::control(call);
}
@@ -58,22 +55,10 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
return false;
#endif
}
/// False when the model is compiled out, so the compiler drops the Hitachi branches.
bool is_hitachi_hcra31newh_() const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
return this->model_ == MODEL_HITACHI_HCRA31NEWH;
#else
return false;
#endif
}
/// The remote ID sent in bytes 0 and 1.
uint16_t remote_id_() const { return this->is_hitachi_hcra31newh_() ? 0xA959 : 0x8306; }
/// Modes in which the Hisense ARG model sends a fixed temperature.
bool fixed_temp_mode_() const;
/// Add the Hisense ARG bytes on top of the common whirlpool frame.
void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const;
/// Add the Hitachi HCRA31NEWH bytes on top of the common whirlpool frame.
void encode_hitachi_hcra31newh_(uint8_t *remote_state) const;
/// Set the time of the last transmission.
uint32_t last_transmit_time_{};
+1 -1
View File
@@ -563,7 +563,7 @@ class WiFiComponent final : public Component {
// (In most use cases you won't need these)
/// Setup WiFi interface.
void setup() override;
#if defined(USE_ESP32) || defined(USE_LN882X) || defined(USE_BK72XX)
#if defined(USE_LN882X) || defined(USE_BK72XX)
void on_powerdown() override;
#endif
void start();
@@ -1635,21 +1635,6 @@ bool WiFiComponent::wifi_dpp_start_listen_() {
void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); }
#endif
void WiFiComponent::on_powerdown() {
// Leave the AP before esp_restart() or deep sleep stops Wi-Fi. esp_wifi_stop() while associated
// makes the ESP32 retransmit its Disassociation up to the retry limit although the AP ACKs it
// (32 copies with protected management frames), which some APs handle badly.
// Upstream bug: https://github.com/espressif/esp-idf/issues/19443 - remove this once it is fixed
// in the ESP-IDF versions we support.
// Ask the driver: s_sta_connected lags until the main loop has handled the connect event.
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK)
return;
ESP_LOGD(TAG, "Disconnecting before powerdown");
esp_wifi_disconnect();
delay(100); // NOLINT: once per reboot or deep sleep, lets the disassociation go out
}
bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); }
bssid_t WiFiComponent::wifi_bssid() {
-2
View File
@@ -294,8 +294,6 @@ void Application::reboot() {
for (auto &component : std::ranges::reverse_view(this->components_)) {
component->on_shutdown();
}
// No teardown on a forced reboot, but still power hardware down (Wi-Fi leaves the AP)
this->run_powerdown_hooks();
arch_restart();
}
void Application::safe_reboot() {
+2 -4
View File
@@ -185,12 +185,10 @@ class Component {
*/
virtual bool teardown() { return true; }
/** Called last before a reboot or deep sleep to power down hardware.
/** Called after teardown is complete to power down hardware.
*
* On a safe shutdown this is called after all components have finished their teardown process,
* This is called after all components have finished their teardown process,
* making it safe to power down hardware like ethernet PHY.
* On a forced reboot (Application::reboot()) it is called right after on_shutdown() with no
* teardown, so implementations must not assume teardown() has run.
*/
virtual void on_powerdown() {}
-1
View File
@@ -387,7 +387,6 @@
#define USE_WATER_HEATER
#define USE_WATER_HEATER_VISUAL_OVERRIDES
#define USE_WHIRLPOOL_HISENSE_ARG
#define USE_WHIRLPOOL_HITACHI_HCRA31NEWH
#define USE_ZWAVE_PROXY
// Feature flags which do not work for zephyr
+65 -14
View File
@@ -803,6 +803,18 @@ 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.
@@ -825,14 +837,14 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj":
storage_name = _pstorage_name(actual_type, id_.id)
# Declare aligned byte array for the object storage
CORE.add_global(
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).
CORE.add_global(
add_global_(
AssignmentExpression(
f"static {pointer_type}",
"*const ",
@@ -841,15 +853,48 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj":
)
)
placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args)
CORE.add(ExpressionStatement(placement_new))
add_(ExpressionStatement(placement_new))
CORE.data.setdefault(_STATIC_POINTER_IDS, set()).add(id_.id)
else:
decl = VariableDeclarationExpression(id_.type, "*", id_, static=True)
CORE.add_global(decl)
CORE.add(AssignmentExpression(None, None, id_, rhs))
add_global_(decl)
add_(AssignmentExpression(None, None, id_, rhs))
CORE.register_variable(id_, obj)
return obj
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)
_STATIC_POINTER_IDS = "static_pointer_ids"
@@ -871,16 +916,21 @@ 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.
:param args: The values to pass to the constructor, after optional template arguments.
: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:]
rhs = id_.type.new(*args)
return Pvariable(id_, rhs)
return id_.type.new(*args)
def static_function(
@@ -888,18 +938,19 @@ 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, 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 (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.
"""
signature = (
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
add_global(RawStatement(f"{signature};"))
(add_global_ or add_global)(RawStatement(f"{signature};"))
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(map(str, body)))}\n}}")
)
@@ -14,7 +14,8 @@ 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 std::remove_cvref_t<int> & x\) \{",
r"static void esphome__\w+__fn\(const int & esphome__running, "
r"const std::remove_cvref_t<int> & x\) \{",
main_cpp,
)
assert "apply_call.data.address = [](int x) -> uint16_t {" in body
@@ -51,9 +51,10 @@ 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'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
# 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
def test_raw_static_code_in_flash(main_cpp: str, bodies: list[str]) -> None:
@@ -33,24 +33,50 @@ def _actions_of(main_cpp: str, automation: str) -> list[str]:
return re.findall(rf"\b{automation}->add_action\((\w+)\);", main_cpp)
def _single_action(main_cpp: str, script: str) -> str:
(action,) = _actions_of(main_cpp, _automation(main_cpp, script))
return action
def _template_tail(types: tuple[str, ...]) -> str:
return "".join(f", {type_}" for type_ in 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 _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 test_inline_actions_merge_into_one_action(generate_main) -> None:
"""A script of inline actions runs them as one merged action of an Automation."""
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."""
main_cpp = generate_main(CONFIG)
assert _constructed(main_cpp, "inline_single") == "script::SingleScript<>()"
body = _function_body(main_cpp, _run_function(main_cpp, "inline_single"))
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 '"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
@@ -58,31 +84,37 @@ def test_inline_actions_merge_into_one_action(generate_main) -> None:
def test_one_lambda_action(generate_main) -> None:
"""A single inline action keeps its own action object."""
"""A single inline action is the whole function."""
main_cpp = generate_main(CONFIG)
constructed = _constructed(main_cpp, _single_action(main_cpp, "one_action"))
assert constructed.startswith("StatelessLambdaAction<>(")
assert '"ONE action"' in constructed
body = _script_body(main_cpp, "one_action")
assert '"ONE action"' in body
assert "esphome__running" not in body
def test_modes(generate_main) -> None:
"""Each mode has its own class; max_runs is set on queued and parallel scripts."""
"""Each mode has its own template; max_runs is set on queued and parallel scripts."""
main_cpp = generate_main(CONFIG)
assert _constructed(main_cpp, "inline_restart") == "script::RestartScript<>()"
assert _constructed(main_cpp, "inline_queued") == "script::QueueingScript<>()"
assert _constructed(main_cpp, "inline_parallel") == "script::ParallelScript<>()"
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 "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)
for script in ("inline_queued", "inline_parallel"):
constructed = _constructed(main_cpp, _single_action(main_cpp, script))
assert constructed.startswith("StatelessLambdaAction<>(")
assert '"QUEUED run"' in _script_body(main_cpp, "inline_queued")
assert '"PARALLEL run"' in _script_body(main_cpp, "inline_parallel")
def test_restart_executes_itself(generate_main) -> None:
"""A script can execute itself; on_stop has its own Automation."""
"""A script can execute itself; on_stop keeps its own Automation."""
main_cpp = generate_main(CONFIG)
body = _function_body(main_cpp, _run_function(main_cpp, "inline_restart"))
body = _script_body(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)
@@ -92,18 +124,14 @@ 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 (
_constructed(main_cpp, "with_params")
== "script::SingleScript<int, std::string>()"
)
name = _run_function(main_cpp, "with_params")
_assert_function_script(main_cpp, "SingleMode", "with_params", "int", "std::string")
assert re.search(
rf"static void {name}\(const int & esphome__running, "
r"static void esphome__with_params__fn\(const int & esphome__running, "
r"const std::remove_cvref_t<int> & x, "
r"const std::remove_cvref_t<std::string> & s\)",
main_cpp,
)
body = _function_body(main_cpp, name)
body = _script_body(main_cpp, "with_params")
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
@@ -116,7 +144,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 = _function_body(main_cpp, _run_function(main_cpp, "stops_itself"))
body = _script_body(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;")
@@ -126,35 +154,59 @@ 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 _constructed(main_cpp, "calls_itself") == "script::ParallelScript<int>()"
body = _function_body(main_cpp, _run_function(main_cpp, "calls_itself"))
_assert_function_script(main_cpp, "ParallelMode", "calls_itself", "int")
body = _script_body(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 the Automation and its separate actions."""
"""A delay keeps today's class, Automation and 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`` is a component templated on the class of the script it waits for."""
"""``script.wait`` on a script that runs a function uses the class shared by its mode."""
main_cpp = generate_main(CONFIG)
(action,) = _actions_of(main_cpp, _automation(main_cpp, "waits"))
assert (
_constructed(main_cpp, action)
== "script::ScriptWaitAction<script::SingleScript<>>(inline_single)"
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)"
)
@@ -11,6 +11,20 @@ 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:
@@ -103,3 +117,7 @@ script:
- id: waits
then:
- script.wait: inline_single
- id: late_target
then:
- logger.log: "LATE target"
@@ -2,7 +2,3 @@ packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
# IPv6 with an IP sensor: compiles the IPv6 event handler's listener notification
network:
enable_ipv6: true
@@ -0,0 +1,121 @@
#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
+1 -2
View File
@@ -10,8 +10,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.auto_load = ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# Compiles in the Hisense ARG and Hitachi models, which climate.py adds only when configured
# Compiles in the Hisense ARG model, which climate.py adds only when it is configured
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
manifest.to_code = to_code_testing
-5
View File
@@ -8,8 +8,3 @@ climate:
model: HISENSE_ARG
transmitter_id: xmitr
receiver_id: rcvr
- platform: whirlpool
name: Whirlpool Hitachi HCRA31NEWH Climate
model: HITACHI_HCRA31NEWH
transmitter_id: xmitr
receiver_id: rcvr
@@ -252,9 +252,6 @@ TEST(WhirlpoolHisenseArg, SwingIsSentOnEveryTransmit) {
TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already off";
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0x80);
@@ -264,9 +261,6 @@ TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0);
EXPECT_EQ(frame[8], 0x00);
f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already vertical";
}
// Cool 24 C (step 8), fan low, powered on assumed
@@ -391,90 +385,4 @@ TEST(WhirlpoolHisenseArg, EveryTransmitHasValidChecksums) {
}
}
// Cool 24 C (step 8), fan low, unit on
static Frame hitachi_cool_frame() {
return with_checksums({0xA9, 0x59, 0x03, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x38, 0x08, 0x00});
}
// The off frame: power bit, cool code, byte 15 0x01 and byte 18 0x28
static Frame hitachi_off_frame() {
return with_checksums({0xA9, 0x59, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x28, 0x08, 0x00});
}
TEST(WhirlpoolHitachiHCRA31NEWH, CoolAndOffMatchKnownFrames) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW);
// Turning on sends the full state with no power bit
expect_frame(f.last_frame(), hitachi_cool_frame());
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
expect_frame(f.last_frame(), hitachi_off_frame());
// A second off command still sends the power bit
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
EXPECT_EQ(f.last_frame()[2] & 0x04, 0x04);
f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW);
const Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x04, 0);
EXPECT_EQ(frame[3], 0x80);
EXPECT_EQ(frame[15], 0x06);
EXPECT_EQ(frame[18], 0x38);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivePowerIsAState) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// An on frame restores the mode even though it has no power bit
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW);
// The same frame again keeps it on
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// A repeated off frame does not toggle
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveChecksRemoteId) {
Fixture hitachi(MODEL_HITACHI_HCRA31NEWH);
EXPECT_FALSE(receive(hitachi.climate, hisense_cool_frame()));
Fixture whirlpool(MODEL_DG11J1_91);
EXPECT_FALSE(receive(whirlpool.climate, hitachi_cool_frame()));
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivedTemperatureIsClamped) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
Frame frame = hitachi_cool_frame();
frame[3] = 0xF2; // step 15 would be 31 C
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_FLOAT_EQ(f.climate.target_temperature, 30);
}
TEST(WhirlpoolDG11J13A, TargetIsClampedAndRoundedToWhatWasSent) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 35, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 32);
f.command(climate::CLIMATE_MODE_COOL, 24.4f, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveRejectsShortFrame) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
remote_base::RawTimings raw = waveform(hitachi_cool_frame());
// Cut after the second divider: 14 bytes, no footer
raw.resize(2 + 14 * 16 + 2 * 2);
remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
EXPECT_FALSE(static_cast<remote_base::RemoteReceiverListener &>(f.climate).on_receive(data));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
} // namespace esphome::whirlpool::testing
@@ -1,23 +0,0 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_NAME
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
MULTI_CONF = True
reboot_hook_recorder_ns = cg.esphome_ns.namespace("reboot_hook_recorder")
RebootHookRecorder = reboot_hook_recorder_ns.class_("RebootHookRecorder", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(RebootHookRecorder),
cv.Required(CONF_NAME): cv.string,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_name(config[CONF_NAME]))
@@ -1,10 +0,0 @@
#include "reboot_hook_recorder.h"
#include "esphome/core/log.h"
namespace esphome::reboot_hook_recorder {
ESPHOME_LOG_TAG(TAG, "reboot_hook_recorder");
void RebootHookRecorder::record_(const char *hook) { ESP_LOGI(TAG, "HOOK %s %s", this->name_, hook); }
} // namespace esphome::reboot_hook_recorder
@@ -1,33 +0,0 @@
#pragma once
#include "esphome/core/component.h"
namespace esphome::reboot_hook_recorder {
/// Logs every shutdown lifecycle hook it receives, so a test can check their order.
class RebootHookRecorder : public Component {
public:
void set_name(const char *name) { this->name_ = name; }
void on_safe_shutdown() override { this->record_("on_safe_shutdown"); }
void on_shutdown() override { this->record_("on_shutdown"); }
// Needs a second call to finish, like a component waiting for a connection to close
bool teardown() override {
if (++this->teardown_calls_ < 2) {
this->record_("teardown_pending");
return false;
}
if (this->teardown_calls_ == 2)
this->record_("teardown_done");
return true;
}
void on_powerdown() override { this->record_("on_powerdown"); }
protected:
void record_(const char *hook);
const char *name_{""};
uint8_t teardown_calls_{0};
};
} // namespace esphome::reboot_hook_recorder
@@ -1,19 +0,0 @@
esphome:
name: reboot-hooks-forced
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
@@ -1,19 +0,0 @@
esphome:
name: reboot-hooks-safe
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.safe_reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
@@ -1,80 +0,0 @@
"""Test the order of the shutdown lifecycle hooks on a forced and on a safe reboot."""
import re
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
HOOK_PATTERN = re.compile(r"HOOK (\w+) (\w+)")
# Hooks run in reverse component order, so "first" gets each hook last.
LAST_HOOK = "HOOK first on_powerdown"
async def _record_reboot_hooks(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> list[tuple[str, str]]:
"""Connect, which makes the device reboot, and return its (component, hook) calls in order."""
waiter = LineWaiter()
hooks: list[tuple[str, str]] = []
def on_line(line: str) -> None:
if match := HOOK_PATTERN.search(line):
hooks.append((match[1], match[2]))
waiter.callback(line)
async with (
run_compiled(yaml_config, line_callback=on_line),
api_client_connected(),
):
await wait_or_fail(
waiter.wait_for(LAST_HOOK),
5.0,
"Device did not run the powerdown hooks",
hooks=hooks,
)
return hooks
@pytest.mark.asyncio
async def test_reboot_hook_order_forced(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A forced reboot runs on_shutdown then on_powerdown, with no safe shutdown or teardown."""
hooks = await _record_reboot_hooks(yaml_config, run_compiled, api_client_connected)
assert hooks == [
("second", "on_shutdown"),
("first", "on_shutdown"),
("second", "on_powerdown"),
("first", "on_powerdown"),
]
@pytest.mark.asyncio
async def test_reboot_hook_order_safe(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A safe reboot powers down only after every component has finished its teardown."""
hooks = await _record_reboot_hooks(yaml_config, run_compiled, api_client_connected)
assert hooks == [
("second", "on_safe_shutdown"),
("first", "on_safe_shutdown"),
("second", "on_shutdown"),
("first", "on_shutdown"),
("second", "teardown_pending"),
("first", "teardown_pending"),
("second", "teardown_done"),
("first", "teardown_done"),
("second", "on_powerdown"),
("first", "on_powerdown"),
]
+34 -14
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,
SingleScript,
CONFIG_SCHEMA,
SCRIPT_MODES,
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,58 +103,78 @@ async def test_on_stop_builds_stop_trigger_automation() -> None:
stop_conf = {CONF_THEN: []}
config = [
{
CONF_ID: ID("my_script", is_declaration=True, type=SingleScript),
CONF_ID: ID("my_script", is_declaration=True, type=SCRIPT_MODES["single"]),
CONF_MODE: "single",
CONF_PARAMETERS: {},
CONF_THEN: [],
CONF_ON_STOP: stop_conf,
},
{
CONF_ID: ID("other_script", is_declaration=True, type=SingleScript),
CONF_ID: ID(
"other_script", is_declaration=True, type=SCRIPT_MODES["single"]
),
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:]) for c in build_automation.await_args_list
] == [
assert [(str(c.args[0]), *c.args[1:3]) for c in build_or_run.await_args_list] == [
("my_script", [], config[0]),
("my_script->get_stop_trigger()", [], stop_conf),
("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)]
@pytest.mark.asyncio
async def test_no_on_stop_define_without_on_stop() -> None:
config = [
{
CONF_ID: ID("my_script", is_declaration=True, type=SingleScript),
CONF_ID: ID("my_script", is_declaration=True, type=SCRIPT_MODES["single"]),
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),
patch(
"esphome.automation.build_automation_or_run_function",
new_callable=AsyncMock,
return_value=None,
),
):
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]
+5 -1
View File
@@ -1595,7 +1595,11 @@ 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]
name: str,
return_type: Any,
parameters: list[tuple[Any, str]],
body: list[str],
add_global_: Any = None,
) -> RawExpression:
assert str(return_type) == "void"
params = ", ".join(f"{type_} {arg}" for type_, arg in parameters)
+26
View File
@@ -1173,6 +1173,32 @@ 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(