This commit is contained in:
J. Nick Koston
2026-02-21 21:07:21 -06:00
parent 8bf757d989
commit 38058d0308
17 changed files with 81 additions and 76 deletions
+16 -15
View File
@@ -62,18 +62,18 @@ def register_action(
action_type: MockObjClass,
schema: cv.Schema,
*,
deferred: bool = True,
synchronous: bool = False,
):
"""Register an action type.
Actions default to ``deferred=True`` (safe default), meaning string
Actions default to ``synchronous=False`` (safe default), meaning string
arguments use owning std::string to prevent dangling references.
Set ``deferred=False`` only for actions that complete synchronously
Set ``synchronous=True`` only for actions that complete synchronously
and never store trigger arguments for later execution. This allows
the code generator to use non-owning StringRef for zero-copy access.
"""
return ACTION_REGISTRY.register(name, action_type, schema, deferred=deferred)
return ACTION_REGISTRY.register(name, action_type, schema, synchronous=synchronous)
def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schema):
@@ -383,7 +383,7 @@ async def delay_action_to_code(
cv.has_at_least_one_key(CONF_THEN, CONF_ELSE),
cv.has_at_least_one_key(CONF_CONDITION, CONF_ANY, CONF_ALL),
),
deferred=False,
synchronous=True,
)
async def if_action_to_code(
config: ConfigType,
@@ -412,7 +412,7 @@ async def if_action_to_code(
cv.Required(CONF_THEN): validate_action_list,
}
),
deferred=False,
synchronous=True,
)
async def while_action_to_code(
config: ConfigType,
@@ -436,7 +436,7 @@ async def while_action_to_code(
cv.Required(CONF_THEN): validate_action_list,
}
),
deferred=False,
synchronous=True,
)
async def repeat_action_to_code(
config: ConfigType,
@@ -481,7 +481,7 @@ async def wait_until_action_to_code(
return var
@register_action("lambda", LambdaAction, cv.lambda_, deferred=False)
@register_action("lambda", LambdaAction, cv.lambda_, synchronous=True)
async def lambda_action_to_code(
config: ConfigType,
action_id: ID,
@@ -500,7 +500,7 @@ async def lambda_action_to_code(
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
}
),
deferred=False,
synchronous=True,
)
async def component_update_action_to_code(
config: ConfigType,
@@ -520,7 +520,7 @@ async def component_update_action_to_code(
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
}
),
deferred=False,
synchronous=True,
)
async def component_suspend_action_to_code(
config: ConfigType,
@@ -543,7 +543,7 @@ async def component_suspend_action_to_code(
),
}
),
deferred=False,
synchronous=True,
)
async def component_resume_action_to_code(
config: ConfigType,
@@ -602,16 +602,17 @@ async def build_condition_list(
def has_deferred_actions(actions: ConfigType) -> bool:
"""Check if a validated action list contains any deferred actions.
"""Check if a validated action list contains any non-synchronous actions.
Deferred actions (delay, wait_until, script.wait) store trigger args
for later execution, making non-owning types like StringRef unsafe.
Non-synchronous actions (delay, wait_until, script.wait, etc.) store
trigger args for later execution, making non-owning types like StringRef
unsafe. Actions that haven't been audited default to non-synchronous.
"""
if isinstance(actions, list):
return any(has_deferred_actions(item) for item in actions)
if isinstance(actions, dict):
for key in actions:
if key in ACTION_REGISTRY and ACTION_REGISTRY[key].deferred:
if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous:
return True
return any(
has_deferred_actions(v)
+10 -8
View File
@@ -380,15 +380,17 @@ async def to_code(config: ConfigType) -> None:
if is_optional:
func_args.append((cg.bool_, "return_response"))
# Check if action chain has deferred actions that would make
# Check if action chain has non-synchronous actions that would make
# non-owning StringRef dangle (rx_buf_ reused after delay)
has_deferred = automation.has_deferred_actions(conf.get(CONF_THEN, []))
has_non_synchronous = automation.has_deferred_actions(
conf.get(CONF_THEN, [])
)
service_arg_names: list[str] = []
for name, var_ in conf[CONF_VARIABLES].items():
native = SERVICE_ARG_NATIVE_TYPES[var_]
# Fall back to std::string for string args if deferred actions exist
if has_deferred and native is cg.StringRef:
# Fall back to std::string for string args if non-synchronous actions exist
if has_non_synchronous and native is cg.StringRef:
native = cg.std_string
service_template_args.append(native)
func_args.append((native, name))
@@ -516,13 +518,13 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
"homeassistant.action",
HomeAssistantServiceCallAction,
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
deferred=False,
synchronous=True,
)
@automation.register_action(
"homeassistant.service",
HomeAssistantServiceCallAction,
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
deferred=False,
synchronous=True,
)
async def homeassistant_service_to_code(
config: ConfigType,
@@ -613,7 +615,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
"homeassistant.event",
HomeAssistantServiceCallAction,
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
deferred=False,
synchronous=True,
)
async def homeassistant_event_to_code(config, action_id, template_arg, args):
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
@@ -654,7 +656,7 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
"homeassistant.tag_scanned",
HomeAssistantServiceCallAction,
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
deferred=False,
synchronous=True,
)
async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args):
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
+1 -1
View File
@@ -124,7 +124,7 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id(
@automation.register_action(
"button.press", PressAction, BUTTON_PRESS_SCHEMA, deferred=False
"button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True
)
async def button_press_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+5 -5
View File
@@ -249,7 +249,7 @@ COVER_ACTION_SCHEMA = maybe_simple_id(
@automation.register_action(
"cover.open", OpenAction, COVER_ACTION_SCHEMA, deferred=False
"cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True
)
async def cover_open_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -257,7 +257,7 @@ async def cover_open_to_code(config, action_id, template_arg, args):
@automation.register_action(
"cover.close", CloseAction, COVER_ACTION_SCHEMA, deferred=False
"cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True
)
async def cover_close_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -265,7 +265,7 @@ async def cover_close_to_code(config, action_id, template_arg, args):
@automation.register_action(
"cover.stop", StopAction, COVER_ACTION_SCHEMA, deferred=False
"cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True
)
async def cover_stop_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -273,7 +273,7 @@ async def cover_stop_to_code(config, action_id, template_arg, args):
@automation.register_action(
"cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, deferred=False
"cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True
)
async def cover_toggle_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -292,7 +292,7 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema(
@automation.register_action(
"cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, deferred=False
"cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, synchronous=True
)
async def cover_control_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+3 -3
View File
@@ -312,7 +312,7 @@ FAN_ACTION_SCHEMA = maybe_simple_id(
@automation.register_action(
"fan.toggle", ToggleAction, FAN_ACTION_SCHEMA, deferred=False
"fan.toggle", ToggleAction, FAN_ACTION_SCHEMA, synchronous=True
)
async def fan_toggle_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -320,7 +320,7 @@ async def fan_toggle_to_code(config, action_id, template_arg, args):
@automation.register_action(
"fan.turn_off", TurnOffAction, FAN_ACTION_SCHEMA, deferred=False
"fan.turn_off", TurnOffAction, FAN_ACTION_SCHEMA, synchronous=True
)
async def fan_turn_off_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -340,7 +340,7 @@ async def fan_turn_off_to_code(config, action_id, template_arg, args):
),
}
),
deferred=False,
synchronous=True,
)
async def fan_turn_on_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+1 -1
View File
@@ -102,7 +102,7 @@ async def to_code(config):
cv.Required(CONF_VALUE): cv.templatable(cv.string_strict),
}
),
deferred=False,
synchronous=True,
)
async def globals_set_to_code(config, action_id, template_arg, args):
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
+5 -5
View File
@@ -51,7 +51,7 @@ from .types import (
),
}
),
deferred=False,
synchronous=True,
)
async def light_toggle_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -112,13 +112,13 @@ LIGHT_TURN_ON_ACTION_SCHEMA = automation.maybe_simple_id(
@automation.register_action(
"light.turn_off", LightControlAction, LIGHT_TURN_OFF_ACTION_SCHEMA, deferred=False
"light.turn_off", LightControlAction, LIGHT_TURN_OFF_ACTION_SCHEMA, synchronous=True
)
@automation.register_action(
"light.turn_on", LightControlAction, LIGHT_TURN_ON_ACTION_SCHEMA, deferred=False
"light.turn_on", LightControlAction, LIGHT_TURN_ON_ACTION_SCHEMA, synchronous=True
)
@automation.register_action(
"light.control", LightControlAction, LIGHT_CONTROL_ACTION_SCHEMA, deferred=False
"light.control", LightControlAction, LIGHT_CONTROL_ACTION_SCHEMA, synchronous=True
)
async def light_control_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -197,7 +197,7 @@ LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
"light.dim_relative",
DimRelativeAction,
LIGHT_DIM_RELATIVE_ACTION_SCHEMA,
deferred=False,
synchronous=True,
)
async def light_dim_relative_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+1 -1
View File
@@ -519,7 +519,7 @@ LOGGER_LOG_ACTION_SCHEMA = cv.All(
@automation.register_action(
CONF_LOGGER_LOG, LambdaAction, LOGGER_LOG_ACTION_SCHEMA, deferred=False
CONF_LOGGER_LOG, LambdaAction, LOGGER_LOG_ACTION_SCHEMA, synchronous=True
)
async def logger_log_action_to_code(config, action_id, template_arg, args):
esp_log = LOG_LEVEL_TO_ESP_LOG[config[CONF_LEVEL]]
+2 -2
View File
@@ -492,7 +492,7 @@ MQTT_PUBLISH_ACTION_SCHEMA = cv.Schema(
@automation.register_action(
"mqtt.publish", MQTTPublishAction, MQTT_PUBLISH_ACTION_SCHEMA, deferred=False
"mqtt.publish", MQTTPublishAction, MQTT_PUBLISH_ACTION_SCHEMA, synchronous=True
)
async def mqtt_publish_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -524,7 +524,7 @@ MQTT_PUBLISH_JSON_ACTION_SCHEMA = cv.Schema(
"mqtt.publish_json",
MQTTPublishJsonAction,
MQTT_PUBLISH_JSON_ACTION_SCHEMA,
deferred=False,
synchronous=True,
)
async def mqtt_publish_json_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+6 -6
View File
@@ -347,7 +347,7 @@ OPERATION_BASE_SCHEMA = cv.Schema(
cv.Required(CONF_VALUE): cv.templatable(cv.float_),
}
),
deferred=False,
synchronous=True,
)
async def number_set_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -370,7 +370,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
}
)
),
deferred=False,
synchronous=True,
)
@automation.register_action(
"number.decrement",
@@ -385,7 +385,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
}
)
),
deferred=False,
synchronous=True,
)
@automation.register_action(
"number.to_min",
@@ -399,7 +399,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
}
)
),
deferred=False,
synchronous=True,
)
@automation.register_action(
"number.to_max",
@@ -413,7 +413,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
}
)
),
deferred=False,
synchronous=True,
)
@automation.register_action(
"number.operation",
@@ -426,7 +426,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean),
}
),
deferred=False,
synchronous=True,
)
async def number_to_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+3 -3
View File
@@ -75,7 +75,7 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id(
@automation.register_action(
"output.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, deferred=False
"output.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True
)
async def output_turn_on_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -83,7 +83,7 @@ async def output_turn_on_to_code(config, action_id, template_arg, args):
@automation.register_action(
"output.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA, deferred=False
"output.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True
)
async def output_turn_off_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -99,7 +99,7 @@ async def output_turn_off_to_code(config, action_id, template_arg, args):
cv.Required(CONF_LEVEL): cv.templatable(cv.percentage),
}
),
deferred=False,
synchronous=True,
)
async def output_set_level_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+2 -2
View File
@@ -160,7 +160,7 @@ async def to_code(config):
cv.Optional(validate_parameter_name): cv.templatable(cv.valid),
},
),
deferred=False,
synchronous=True,
)
async def script_execute_action_to_code(config, action_id, template_arg, args):
def convert(type: str):
@@ -209,7 +209,7 @@ async def script_execute_action_to_code(config, action_id, template_arg, args):
"script.stop",
ScriptStopAction,
maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}),
deferred=False,
synchronous=True,
)
async def script_stop_action_to_code(config, action_id, template_arg, args):
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
+7 -7
View File
@@ -145,7 +145,7 @@ OPERATION_BASE_SCHEMA = cv.Schema(
cv.Required(CONF_OPTION): cv.templatable(cv.string_strict),
}
),
deferred=False,
synchronous=True,
)
async def select_set_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -163,7 +163,7 @@ async def select_set_to_code(config, action_id, template_arg, args):
cv.Required(CONF_INDEX): cv.templatable(cv.positive_int),
}
),
deferred=False,
synchronous=True,
)
async def select_set_index_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -219,7 +219,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean),
}
),
deferred=False,
synchronous=True,
)
@automation.register_action(
"select.next",
@@ -232,7 +232,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
}
)
),
deferred=False,
synchronous=True,
)
@automation.register_action(
"select.previous",
@@ -247,7 +247,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
}
)
),
deferred=False,
synchronous=True,
)
@automation.register_action(
"select.first",
@@ -259,7 +259,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
}
)
),
deferred=False,
synchronous=True,
)
@automation.register_action(
"select.last",
@@ -271,7 +271,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
}
)
),
deferred=False,
synchronous=True,
)
async def select_operation_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+4 -4
View File
@@ -198,7 +198,7 @@ SWITCH_CONTROL_ACTION_SCHEMA = automation.maybe_simple_id(
@automation.register_action(
"switch.control", ControlAction, SWITCH_CONTROL_ACTION_SCHEMA, deferred=False
"switch.control", ControlAction, SWITCH_CONTROL_ACTION_SCHEMA, synchronous=True
)
async def switch_control_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -209,13 +209,13 @@ async def switch_control_to_code(config, action_id, template_arg, args):
@automation.register_action(
"switch.toggle", ToggleAction, SWITCH_ACTION_SCHEMA, deferred=False
"switch.toggle", ToggleAction, SWITCH_ACTION_SCHEMA, synchronous=True
)
@automation.register_action(
"switch.turn_off", TurnOffAction, SWITCH_ACTION_SCHEMA, deferred=False
"switch.turn_off", TurnOffAction, SWITCH_ACTION_SCHEMA, synchronous=True
)
@automation.register_action(
"switch.turn_on", TurnOnAction, SWITCH_ACTION_SCHEMA, deferred=False
"switch.turn_on", TurnOnAction, SWITCH_ACTION_SCHEMA, synchronous=True
)
async def switch_toggle_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+1 -1
View File
@@ -164,7 +164,7 @@ OPERATION_BASE_SCHEMA = cv.Schema(
cv.Required(CONF_VALUE): cv.templatable(cv.string_strict),
}
),
deferred=False,
synchronous=True,
)
async def text_set_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+6 -4
View File
@@ -25,13 +25,13 @@ class RegistryEntry:
type_id: "MockObjClass",
schema: "Schema",
*,
deferred: bool = True,
synchronous: bool = False,
):
self.name = name
self.fun = fun
self.type_id = type_id
self.raw_schema = schema
self.deferred = deferred
self.synchronous = synchronous
@property
def coroutine_fun(self):
@@ -58,10 +58,12 @@ class Registry(dict[str, RegistryEntry]):
type_id: "MockObjClass",
schema: "Schema",
*,
deferred: bool = True,
synchronous: bool = False,
):
def decorator(fun: Callable[..., Any]):
self[name] = RegistryEntry(name, fun, type_id, schema, deferred=deferred)
self[name] = RegistryEntry(
name, fun, type_id, schema, synchronous=synchronous
)
return fun
return decorator
+8 -8
View File
@@ -9,25 +9,25 @@ from esphome.automation import has_deferred_actions
from esphome.util import RegistryEntry
def _make_registry(deferred_actions: set[str]) -> dict[str, RegistryEntry]:
"""Create a mock ACTION_REGISTRY with specified deferred actions.
def _make_registry(non_synchronous_actions: set[str]) -> dict[str, RegistryEntry]:
"""Create a mock ACTION_REGISTRY with specified non-synchronous actions.
Uses the default deferred=True, matching the real registry behavior.
Uses the default synchronous=False, matching the real registry behavior.
"""
registry: dict[str, RegistryEntry] = {}
for name in deferred_actions:
for name in non_synchronous_actions:
registry[name] = RegistryEntry(name, lambda: None, None, None)
return registry
@pytest.fixture
def mock_registry() -> Generator[dict[str, RegistryEntry]]:
"""Fixture that patches ACTION_REGISTRY with delay, wait_until, script.wait as deferred."""
"""Fixture that patches ACTION_REGISTRY with delay, wait_until, script.wait as non-synchronous."""
registry: dict[str, RegistryEntry] = _make_registry(
{"delay", "wait_until", "script.wait"}
)
registry["logger.log"] = RegistryEntry(
"logger.log", lambda: None, None, None, deferred=False
"logger.log", lambda: None, None, None, synchronous=True
)
with patch("esphome.automation.ACTION_REGISTRY", registry):
yield registry
@@ -82,10 +82,10 @@ def test_has_deferred_actions_unknown_not_in_registry(
assert has_deferred_actions([{"unknown.action": "value"}]) is False
def test_has_deferred_actions_default_deferred(
def test_has_deferred_actions_default_non_synchronous(
mock_registry: dict[str, RegistryEntry],
) -> None:
"""Actions registered without explicit deferred=False default to deferred=True."""
"""Actions registered without explicit synchronous=True default to non-synchronous."""
mock_registry["some.action"] = RegistryEntry(
"some.action", lambda: None, None, None
)