Compare commits

...
Author SHA1 Message Date
J. Nick Koston ccdad2ba0d Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:37:51 -06:00
J. Nick Koston f9d0367e2b Merge branch 'dev' into api-user-service-direct 2026-11-11 19:21:49 -06:00
J. Nick Koston 79c1ba1e8f [core] Name the indented body before emitting the static function 2026-11-11 19:10:54 -06:00
J. Nick Koston c190249188 Shorten the function declaration docstrings 2026-11-09 21:11:31 -08:00
J. Nick Koston 9f50af9d73 Merge remote-tracking branch 'upstream/dev' into api-user-service-direct
# Conflicts:
#	esphome/automation.py
2026-11-09 20:57:17 -08:00
J. Nick Koston 4342ee1ee7 Merge remote-tracking branch 'origin/dev' into api-user-service-direct
# Conflicts:
#	esphome/cpp_generator.py
#	tests/unit_tests/test_cpp_generator.py
2026-11-04 18:43:14 -06:00
J. Nick Koston b1065984be Merge branch 'dev' into api-user-service-direct 2026-11-01 13:30:46 -10:00
J. Nick Koston 800e5b034c Merge dev 2026-11-01 11:57:14 -10:00
J. Nick Koston 63c7323981 [api] Give the response cleanup action an id no config entry uses 2026-10-31 09:23:56 -10:00
J. Nick Koston 19a26d4344 [api] Declare the service before building its actions
A lambda that uses its own service id waits for that id, so the service
object is created first, naming a function declared in advance. Variables
named call_id or return_response in a response service now fail
validation instead of compiling into duplicate parameters.
2026-10-31 00:34:50 -10:00
J. Nick Koston 915f94e2cc Merge remote-tracking branch 'origin/api-user-service-tests' into api-user-service-direct
# Conflicts:
#	tests/component_tests/api/test_user_services.py
#	tests/component_tests/api/test_user_services.yaml
2026-10-31 00:32:55 -10:00
J. Nick Koston 981348e2a8 [api] Test a service lambda that uses its own id 2026-10-31 00:32:30 -10:00
J. Nick Koston 1754f895f0 [api] Test the unregister in a fully inline response service
A status service with only inline actions runs them in the function the
service calls; check the unregister ends that function, and over the API
that the call is answered and never times out.
2026-10-31 00:19:00 -10:00
J. Nick Koston 72413cf22e Merge remote-tracking branch 'origin/api-user-service-tests' into api-user-service-direct 2026-10-31 00:18:51 -10:00
J. Nick Koston 3c09fea844 [api] Find user service test objects by action name
Look up each service from its strings table and follow the links to its
automation and actions, so the tests no longer depend on auto-numbered ids
or hard-coded key hashes.
2026-10-31 00:13:12 -10:00
J. Nick Koston 68d3746d90 [api] Run user service actions from one generated function 2026-10-30 23:54:33 -10:00
J. Nick Koston 2c269ec219 [api] Add tests for user service code generation 2026-10-30 23:40:46 -10:00
12 changed files with 398 additions and 224 deletions

No files matched your search

+90 -33
View File
@@ -335,25 +335,23 @@ def _is_collecting(id_: ID) -> bool:
return collector is not None and collector.action_id is id_
def _collect_action(
id_: ID, statements: list[str], emit: Callable[[], MockObj] | None
) -> bool:
"""Record ``id_`` as inline if it is the action or condition being collected.
def _collect_action(action: _InlineAction) -> bool:
"""Record ``action`` as inline if it is the action or condition being collected.
Matching the id leaves the conditions and child actions a builder creates to emit normally.
"""
if not _is_collecting(id_):
if not _is_collecting(action.id_):
return False
collector = _INLINE_COLLECTOR.get()
# Replacing it would silently lose the statements already collected
if collector.action is not None:
raise EsphomeError(f"The builder of {collector.name} built its action twice")
collector.action = _InlineAction(id_, statements, emit)
collector.action = action
return True
def _collect_control_flow(action_id: ID, statements: list[str]) -> MockObj:
_collect_action(action_id, statements, None)
_collect_action(_InlineAction(action_id, statements, None))
return _COLLECTED_ACTION
@@ -409,8 +407,7 @@ def _condition_expression(
) -> str:
if isinstance(condition, _InlineCondition):
return f"({condition.expression})"
names = ", ".join(name for _, name in args)
return f"{parent_ref(condition)}->check({names})"
return f"{parent_ref(condition)}->check({_arg_names(args)})"
def _condition_object(condition: MockObj | _InlineCondition) -> MockObj:
@@ -675,12 +672,22 @@ def _apply_function(
) -> MockObj:
"""Emit the generated function and declare ``id_`` as the ``ApplyAction`` or
``ApplyCondition`` templated on it, so ``play()`` calls it directly."""
action = _inline_action(id_, return_type, template_arg, args, statements)
return _COLLECTED_ACTION if _collect_action(action) else action.emit()
def _inline_action(
id_: ID,
return_type: SafeExpType,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
statements: list[str],
) -> _InlineAction:
def emit() -> MockObj:
fn = apply_static_function(id_, return_type, _apply_params(args), statements)
return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg))
return _COLLECTED_ACTION if _collect_action(id_, statements, emit) else emit()
return _InlineAction(id_, statements, emit)
def apply_statements_action(
@@ -1192,11 +1199,13 @@ def new_lambda_pvariable(
or stateless_class is StatelessLambdaCondition
)
and _collect_action(
id_obj,
[f"{immediate_lambda_call(lambda_expr)};"],
lambda: new_lambda_pvariable(
id_obj, lambda_expr, stateless_class, template_arg
),
_InlineAction(
id_obj,
[f"{immediate_lambda_call(lambda_expr)};"],
lambda: new_lambda_pvariable(
id_obj, lambda_expr, stateless_class, template_arg
),
)
)
):
return _COLLECTED_ACTION
@@ -1311,7 +1320,7 @@ async def condition_group_to_code(
operator = " || " if condition_id.type is OrCondition else " && "
check = operator.join(_condition_expression(item, args) for item in items)
_collect_action(condition_id, [f"return {check};"], emit)
_collect_action(_InlineAction(condition_id, [f"return {check};"], emit))
return _COLLECTED_ACTION
conditions = await build_condition_list(config, template_arg, args)
return _new_condition_group(condition_id, template_arg, conditions)
@@ -1338,11 +1347,13 @@ async def not_condition_to_code(
if _is_collecting(condition_id):
item = await _build_condition_or_inline(config, template_arg, args)
_collect_action(
condition_id,
[f"return !{_condition_expression(item, args)};"],
lambda: cg.new_Pvariable(
condition_id, template_arg, _condition_object(item)
),
_InlineAction(
condition_id,
[f"return !{_condition_expression(item, args)};"],
lambda: cg.new_Pvariable(
condition_id, template_arg, _condition_object(item)
),
)
)
return _COLLECTED_ACTION
condition = await build_condition(config, template_arg, args)
@@ -1924,16 +1935,21 @@ async def _build_action_plan(
"""Build the actions of ``config``; adjacent inline actions stay together, not yet emitted."""
plan: list[MockObj | list[_InlineAction]] = []
for conf in config:
action = await _build_action_or_inline(conf, templ, arg_type)
if not isinstance(action, _InlineAction):
plan.append(action)
elif plan and isinstance(plan[-1], list):
plan[-1].append(action)
else:
plan.append([action])
_plan_append(plan, await _build_action_or_inline(conf, templ, arg_type))
return plan
def _plan_append(
plan: list[MockObj | list[_InlineAction]], action: MockObj | _InlineAction
) -> None:
if not isinstance(action, _InlineAction):
plan.append(action)
elif plan and isinstance(plan[-1], list):
plan[-1].append(action)
else:
plan.append([action])
def _emit_action_plan(
plan: list[MockObj | list[_InlineAction]],
templ: cg.TemplateArguments,
@@ -2080,8 +2096,7 @@ async def new_automation(
async def _build_automation_plan(
args: TemplateArgsType, config: ConfigType
) -> list[MockObj | list[_InlineAction]]:
templ = cg.TemplateArguments(*(arg[0] for arg in args))
return await _build_action_plan(config[CONF_THEN], templ, args)
return await _build_action_plan(config[CONF_THEN], _args_templ(args), args)
def _automation_from_plan(
@@ -2090,7 +2105,7 @@ def _automation_from_plan(
config: ConfigType,
*ctor_args: MockObj,
) -> MockObj:
templ = cg.TemplateArguments(*(arg[0] for arg in args))
templ = _args_templ(args)
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args)
for action in _emit_action_plan(plan, templ, args):
cg.add(obj.add_action(action))
@@ -2110,19 +2125,34 @@ def _is_direct(plan: list[MockObj | list[_InlineAction]], config: ConfigType) ->
)
def _direct_statements(
plan: list[MockObj | list[_InlineAction]], config: ConfigType
) -> list[str] | None:
if not _is_direct(plan, config):
return None
return [line for line in _run_statements(plan[0]) if line != _STOP_CHECK]
def _direct_function(
plan: list[MockObj | list[_InlineAction]],
args: TemplateArgsType,
config: ConfigType,
) -> MockObj | None:
if not _is_direct(plan, config):
if (statements := _direct_statements(plan, config)) is None:
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
)
def _args_templ(args: TemplateArgsType) -> cg.TemplateArguments:
return cg.TemplateArguments(*(arg[0] for arg in args))
def _arg_names(args: TemplateArgsType) -> str:
return ", ".join(name for _, name in args)
def _when_call(
when: str | ApplyCall | None, parent: MockObj | None
) -> ApplyCall | None:
@@ -2140,6 +2170,33 @@ async def build_automation(
return await new_automation(args, config, trigger)
def declare_automation_function(
args: TemplateArgsType, config: ConfigType, after: list[str] | None = None
) -> tuple[MockObj, Callable[[], Awaitable[None]]]:
"""Declare a ``static void`` function that runs ``config``; return its name and the coroutine that defines its body.
Await it once the object naming the function exists; ``after`` runs after the last action unless stopped.
"""
automation_id = config[CONF_AUTOMATION_ID]
name = _apply_function_name(automation_id)
define = cg.declare_static_function(name, cg.void, _apply_params(args))
async def build() -> None:
plan = await _build_automation_plan(args, config)
if after:
after_id = cg.unique_global_id(f"{automation_id.id}__after", ApplyAction)
_plan_append(
plan,
_inline_action(after_id, cg.void, _args_templ(args), args, after),
)
if (body := _direct_statements(plan, config)) is None:
automation = _automation_from_plan(plan, args, config)
body = [f"{parent_ref(automation)}->trigger({_arg_names(args)});"]
define(body)
return cg.RawExpression(name), build
async def build_trigger_callback(
args: TemplateArgsType,
config: ConfigType,
+1
View File
@@ -31,6 +31,7 @@ from esphome.cpp_generator import ( # noqa: F401
add_global,
add_library,
add_platformio_option,
declare_static_function,
extern_progmem_array,
get_variable,
get_variable_with_full_id,
+35 -32
View File
@@ -100,11 +100,7 @@ HomeAssistantActionResponseTrigger = api_ns.class_(
"HomeAssistantActionResponseTrigger", automation.Trigger
)
APIRespondAction = api_ns.class_("APIRespondAction", automation.Action)
APIUnregisterServiceCallAction = api_ns.class_(
"APIUnregisterServiceCallAction", automation.Action
)
UserServiceTrigger = api_ns.class_("UserServiceTrigger", automation.Trigger)
UserServiceCallback = api_ns.class_("UserServiceCallback")
ListEntitiesServicesArgument = api_ns.class_("ListEntitiesServicesArgument")
# Owning element type for each YAML service variable type. Used to derive both
# the zero-copy native types and the owning fallback types below.
@@ -313,9 +309,23 @@ VARIABLE_SCHEMA = cv.All(
validate_variable = cv.maybe_simple_value(VARIABLE_SCHEMA, key=CONF_TYPE)
def _validate_reserved_variables(config: ConfigType) -> ConfigType:
"""Reject variables named like the arguments a response service passes first."""
mode = config[CONF_SUPPORTS_RESPONSE]
reserved = {"none": (), "optional": ("call_id", "return_response")}
for name in reserved.get(mode, ("call_id",)):
if name in config[CONF_VARIABLES]:
raise cv.Invalid(
f"'{name}' is reserved with supports_response: {mode}, "
"give the variable another name",
path=[CONF_VARIABLES, name],
)
return config
ACTIONS_SCHEMA = automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UserServiceTrigger),
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UserServiceCallback),
cv.Exclusive(CONF_SERVICE, group_of_exclusion=CONF_ACTION): cv.valid_name,
cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.valid_name,
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
@@ -338,6 +348,7 @@ ACTIONS_SCHEMA = automation.validate_automation(
{cv.Required(CONF_SUPPORTS_RESPONSE): _validate_supports_response},
extra=cv.ALLOW_EXTRA,
),
_validate_reserved_variables,
),
)
@@ -631,8 +642,8 @@ async def to_code(config: ConfigType) -> None:
has_optional = _has_optional_args(actions)
if has_optional:
cg.add_define("USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS")
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.MockObj] = []
# Collect all services first, then register all at once with initializer_list
services: list[cg.MockObj] = []
for index, conf in enumerate(actions):
func_args: list[tuple[MockObj, str]] = []
service_template_args: list[MockObj] = [] # User service argument types
@@ -645,7 +656,7 @@ async def to_code(config: ConfigType) -> None:
is_optional = supports_response_key == "optional"
# Add call_id and return_response based on supports_response mode
# These must match the C++ Trigger template arguments
# These must match the arguments UserServiceCallback passes
# - none: no extra args
# - status: call_id only (for reporting success/error without data)
# - only: call_id only (response always expected with data)
@@ -676,32 +687,24 @@ async def to_code(config: ConfigType) -> None:
func_args.append((native, name))
strings = _action_strings(conf, has_metadata, has_optional)
table = _add_action_strings(index, strings)
# Template args: supports_response mode, then user service arg types
templ = cg.TemplateArguments(supports_response, *service_template_args)
# Key is hashed here because the name is not readable at runtime on ESP8266
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], templ, table, fnv1_hash(conf[CONF_ACTION])
)
triggers.append(trigger)
auto = await automation.build_automation(trigger, func_args, conf)
# For non-none response modes, automatically append unregister action
# This ensures the call is unregistered after all actions complete (including async ones)
# A call that can respond is unregistered once every action has finished
after = None
if not is_none:
arg_types = [arg[0] for arg in func_args]
action_templ = cg.TemplateArguments(*arg_types)
unregister_id = ID(
f"{conf[CONF_TRIGGER_ID]}__unregister",
is_declaration=True,
type=APIUnregisterServiceCallAction.template(action_templ),
server = automation.parent_ref(var)
after = [
f"if (call_id != 0)\n {server}->unregister_active_action_call(call_id);"
]
fn, build = automation.declare_automation_function(func_args, conf, after)
templ = cg.TemplateArguments(fn, supports_response, *service_template_args)
# Key is hashed here because the name is not readable at runtime on ESP8266
services.append(
cg.new_Pvariable(
conf[CONF_TRIGGER_ID], templ, table, fnv1_hash(conf[CONF_ACTION])
)
unregister_action = cg.new_Pvariable(
unregister_id,
var,
)
cg.add(auto.add_action(unregister_action))
)
await build()
# Register all services at once - single allocation, no reallocations
cg.add(var.initialize_user_services(triggers))
cg.add(var.initialize_user_services(services))
if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
await wizard.to_code(wizard_config)
+2 -2
View File
@@ -2297,8 +2297,8 @@ void APIConnection::on_execute_service_request(const ExecuteServiceRequest &msg)
ESP_LOGV(TAG, "Could not find service");
}
// Note: For services with supports_response != none, the call is unregistered
// by an automatically appended APIUnregisterServiceCallAction at the end of
// the action list. This ensures async actions (delays, waits) complete first.
// by statements generated after the last action, so async actions (delays,
// waits) complete first.
}
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message) {
+13 -66
View File
@@ -174,59 +174,25 @@ template<typename... Ts> class UserServiceDynamic : public UserServiceDescriptor
uint32_t key_{0};
};
// Primary template declaration
template<enums::SupportsResponseType Mode, typename... Ts> class UserServiceTrigger;
// Specialization for NONE - no extra trigger arguments
template<typename... Ts>
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_NONE, Ts...> final : public UserServiceBase<Ts...>,
public Trigger<Ts...> {
/// A YAML-defined action that calls the generated function Fn. Fn takes the call id first unless
/// Mode is SUPPORTS_RESPONSE_NONE, then return_response for SUPPORTS_RESPONSE_OPTIONAL.
template<auto Fn, enums::SupportsResponseType Mode, typename... Ts>
class UserServiceCallback final : public UserServiceBase<Ts...> {
public:
UserServiceTrigger(const char *const *strings, uint32_t key)
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_NONE) {}
UserServiceCallback(const char *const *strings, uint32_t key) : UserServiceBase<Ts...>(strings, key, Mode) {}
protected:
void execute(uint32_t /*call_id*/, bool /*return_response*/, Ts... x) override { this->trigger(x...); }
};
// Specialization for OPTIONAL - call_id and return_response trigger arguments
template<typename... Ts>
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_OPTIONAL, Ts...> final : public UserServiceBase<Ts...>,
public Trigger<uint32_t, bool, Ts...> {
public:
UserServiceTrigger(const char *const *strings, uint32_t key)
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_OPTIONAL) {}
protected:
void execute(uint32_t call_id, bool return_response, Ts... x) override {
this->trigger(call_id, return_response, x...);
void execute([[maybe_unused]] uint32_t call_id, [[maybe_unused]] bool return_response, Ts... x) override {
if constexpr (Mode == enums::SUPPORTS_RESPONSE_NONE) {
Fn(x...);
} else if constexpr (Mode == enums::SUPPORTS_RESPONSE_OPTIONAL) {
Fn(call_id, return_response, x...);
} else {
Fn(call_id, x...);
}
}
};
// Specialization for ONLY - just call_id trigger argument
template<typename... Ts>
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_ONLY, Ts...> final : public UserServiceBase<Ts...>,
public Trigger<uint32_t, Ts...> {
public:
UserServiceTrigger(const char *const *strings, uint32_t key)
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_ONLY) {}
protected:
void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); }
};
// Specialization for STATUS - just call_id trigger argument (reports success/error without data)
template<typename... Ts>
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_STATUS, Ts...> final : public UserServiceBase<Ts...>,
public Trigger<uint32_t, Ts...> {
public:
UserServiceTrigger(const char *const *strings, uint32_t key)
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_STATUS) {}
protected:
void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); }
};
} // namespace esphome::api
#endif // USE_API_USER_DEFINED_ACTIONS
@@ -301,24 +267,5 @@ template<typename... Ts> class APIRespondAction final : public Action<Ts...> {
bool is_optional_mode_{false};
};
// Action to unregister a service call after execution completes
// Automatically appended to the end of action lists for non-none response modes
template<typename... Ts> class APIUnregisterServiceCallAction final : public Action<Ts...> {
public:
explicit APIUnregisterServiceCallAction(APIServer *parent) : parent_(parent) {}
void play(const Ts &...x) override {
// Extract call_id from first argument - same convention as APIRespondAction
auto args = std::forward_as_tuple(x...);
uint32_t call_id = std::get<0>(args);
if (call_id != 0) {
this->parent_->unregister_active_action_call(call_id);
}
}
protected:
APIServer *parent_;
};
} // namespace esphome::api
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
+23 -12
View File
@@ -883,6 +883,28 @@ def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj":
return Pvariable(id_, rhs)
def declare_static_function(
name: str,
return_type: SafeExpType,
parameters: TemplateArgsType,
) -> Callable[[list[str | Statement]], RawExpression]:
"""Declare ``static <return_type> <name>(parameters)`` now; return the function that emits the body later.
The body goes after the user includes, where every id is in scope.
"""
signature = (
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
add_global(RawStatement(f"{signature};"))
def define(body: list[str | Statement]) -> RawExpression:
lines = indent("\n".join(map(str, body)))
add_global_after_includes(RawStatement(f"{signature} {{\n{lines}\n}}"))
return RawExpression(name)
return define
def static_function(
name: str,
return_type: SafeExpType,
@@ -891,19 +913,8 @@ def static_function(
) -> 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.
"""
signature = (
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
add_global(RawStatement(f"{signature};"))
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(map(str, body)))}\n}}")
)
return RawExpression(name)
return declare_static_function(name, return_type, parameters)(body)
@dataclass
@@ -1,5 +1,7 @@
"""Tests for arg-type selection of api user-defined services with homeassistant.action."""
import re
CONFIG = "tests/component_tests/api/test_homeassistant_action.yaml"
@@ -7,9 +9,10 @@ def test_synchronous_chain_keeps_zero_copy_args(generate_main):
"""A chain of synchronous actions keeps the non-owning StringRef arg type."""
main_cpp = generate_main(CONFIG)
assert (
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, StringRef>"
"(api_action0_strings," in main_cpp
assert re.search(
r"api::UserServiceCallback<esphome__\w+__fn, "
r"api::enums::SUPPORTS_RESPONSE_NONE, StringRef>\(api_action0_strings,",
main_cpp,
)
@@ -20,9 +23,10 @@ def test_response_callback_args_are_owning(generate_main):
receive buffer, which is reused before the response arrives."""
main_cpp = generate_main(CONFIG)
assert (
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, std::string>"
"(api_action1_strings," in main_cpp
assert re.search(
r"api::UserServiceCallback<esphome__\w+__fn, "
r"api::enums::SUPPORTS_RESPONSE_NONE, std::string>\(api_action1_strings,",
main_cpp,
)
assert "api::HomeAssistantServiceCallAction<std::string>" in main_cpp
assert "api::HomeAssistantServiceCallAction<StringRef>" not in main_cpp
+152 -71
View File
@@ -7,6 +7,10 @@ auto-numbered ids, so adding or changing one action does not break the others.
import re
from typing import NamedTuple
import pytest
from esphome.components.api import ACTIONS_SCHEMA
from esphome.config_validation import Invalid
from esphome.helpers import fnv1_hash
CONFIG = "tests/component_tests/api/test_user_services.yaml"
@@ -15,6 +19,10 @@ LEGACY_CONFIG = "tests/component_tests/api/test_user_services_legacy.yaml"
NONE = "api::enums::SUPPORTS_RESPONSE_NONE"
OPTIONAL = "api::enums::SUPPORTS_RESPONSE_OPTIONAL"
ONLY = "api::enums::SUPPORTS_RESPONSE_ONLY"
STATUS = "api::enums::SUPPORTS_RESPONSE_STATUS"
UNREGISTER = (
"if (call_id != 0)\n ::api_apiserver_id->unregister_active_action_call(call_id);"
)
class Service(NamedTuple):
@@ -61,17 +69,31 @@ def _service(main_cpp: str, name: str) -> Service:
return Service(match.group(1), match.group(2))
def _automation(main_cpp: str, trigger: str) -> tuple[str, str]:
"""Return the id and template arguments of the Automation on ``trigger``."""
match = re.search(rf"\bnew\((\w+)\) Automation<(.*?)>\({trigger}\);", main_cpp)
assert match is not None, trigger
return match.group(1), match.group(2)
def _service_function(main_cpp: str, name: str, mode: str, types: str = "") -> str:
"""Check action ``name`` is a UserServiceCallback and return the function it calls."""
service = _service(main_cpp, name)
tail = f", {types}" if types else ""
match = re.fullmatch(
rf"api::UserServiceCallback<(\w+), {re.escape(mode + tail)}>", service.type
)
assert match is not None, service.type
return match.group(1)
def _triggered_automation(main_cpp: str, function: str, args: str) -> str:
"""Check ``function`` only triggers an Automation with ``args``; return its id."""
match = re.fullmatch(
rf"\n ::(\w+)->trigger\({re.escape(args)}\);",
_function_body(main_cpp, function),
)
assert match is not None, function
return match.group(1)
def _action_code(main_cpp: str, action: str) -> str:
"""Return the code an action runs: its generated function, or its constructor."""
constructed = _constructed(main_cpp, action)
if match := re.match(r"ApplyRunAction<(\w+)[,>]", constructed):
if match := re.match(r"Apply(?:Run)?Action<(\w+)[,>]", constructed):
return _function_body(main_cpp, match.group(1))
return constructed
@@ -80,18 +102,27 @@ def _action_class(main_cpp: str, action: str) -> str:
return re.match(r"[\w:]+", _constructed(main_cpp, action)).group(0)
def _assert_unregister_after(main_cpp: str, automation: str, types: str) -> None:
"""The last action of ``automation`` only unregisters the call."""
last = _actions_of(main_cpp, automation)[-1]
assert last == f"{automation}__after"
assert _constructed(main_cpp, last) == (
f"ApplyAction<esphome__{automation}__after__fn, {types}>()"
)
assert _action_code(main_cpp, last) == f"\n {UNREGISTER}"
def test_no_args_service(generate_main) -> None:
"""A service without arguments triggers an Automation of its merged inline actions."""
"""Inline actions run in one function the service calls, with no Automation."""
main_cpp = generate_main(CONFIG)
service = _service(main_cpp, "no_args")
assert service.type == f"api::UserServiceTrigger<{NONE}>"
automation, args = _automation(main_cpp, service.id)
assert args == ""
(action,) = _actions_of(main_cpp, automation)
assert _action_class(main_cpp, action) == "ApplyRunAction"
code = _action_code(main_cpp, action)
assert "NO_ARGS called" in code
assert "::doubled->value() = 1;" in code
function = _service_function(main_cpp, "no_args", NONE)
body = _function_body(main_cpp, function)
assert "NO_ARGS called" in body
assert "::doubled->value() = 1;" in body
assert "esphome__running" not in body
assert "Automation<>" not in main_cpp
# The service calls the function directly, with no action object around it
assert f"Action<{function}" not in main_cpp
def test_mixed_args_types(generate_main) -> None:
@@ -101,40 +132,38 @@ def test_mixed_args_types(generate_main) -> None:
"bool, int32_t, float, StringRef, const FixedVector<int32_t> &, "
"const FixedVector<std::string> &"
)
service = _service(main_cpp, "mixed_args")
assert service.type == f"api::UserServiceTrigger<{NONE}, {types}>"
automation, args = _automation(main_cpp, service.id)
assert args == types
function = _service_function(main_cpp, "mixed_args", NONE, types)
assert _strings(main_cpp, "mixed_args")[1] == (
'"mixed_args", "bool_", "int_", "float_", "string_", "int_arr", "string_arr"'
)
(action,) = _actions_of(main_cpp, automation)
assert "string_.c_str(), int_arr.size(), string_arr.size()" in _action_code(
main_cpp, action
# Arrays stay non-owning references in the generated function
assert re.search(
rf"static void {function}\([^)]*"
r"const FixedVector<int32_t> & int_arr, "
r"const FixedVector<std::string> & string_arr\) \{",
main_cpp,
)
body = _function_body(main_cpp, function)
assert "string_.c_str(), int_arr.size(), string_arr.size()" in body
def test_lambda_action_gets_args_by_value(generate_main) -> None:
"""A lambda action takes the arguments by value, so ``x *= 2`` compiles in both forms."""
main_cpp = generate_main(CONFIG)
service = _service(main_cpp, "by_value")
assert service.type == f"api::UserServiceTrigger<{NONE}, int32_t>"
automation, _ = _automation(main_cpp, service.id)
(action,) = _actions_of(main_cpp, automation)
code = _action_code(main_cpp, action)
assert code.count("[](int32_t x) -> void {") == 2
assert "x *= 2;" in code
assert '"BY_VALUE again %d", x *= 2' in code
function = _service_function(main_cpp, "by_value", NONE, "int32_t")
body = _function_body(main_cpp, function)
assert body.count("[](int32_t x) -> void {") == 2
assert "x *= 2;" in body
assert '"BY_VALUE again %d", x *= 2' in body
def test_delay_uses_owning_args(generate_main) -> None:
"""A delay keeps separate actions and owning string and array arguments."""
"""A delay keeps an Automation of separate actions, which the function triggers."""
main_cpp = generate_main(CONFIG)
types = "std::string, std::vector<int32_t>"
service = _service(main_cpp, "with_delay")
assert service.type == f"api::UserServiceTrigger<{NONE}, {types}>"
automation, args = _automation(main_cpp, service.id)
assert args == types
function = _service_function(main_cpp, "with_delay", NONE, types)
automation = _triggered_automation(main_cpp, function, "name, int_arr")
assert _constructed(main_cpp, automation) == f"Automation<{types}>()"
before, delay, after = _actions_of(main_cpp, automation)
assert _action_class(main_cpp, delay) == "DelayAction"
assert "WITH_DELAY before" in _action_code(main_cpp, before)
@@ -146,49 +175,67 @@ def test_delay_uses_owning_args(generate_main) -> None:
def test_respond_optional(generate_main) -> None:
"""An optional response passes call_id and return_response; the call is unregistered last."""
main_cpp = generate_main(CONFIG)
service = _service(main_cpp, "respond_optional")
assert service.type == f"api::UserServiceTrigger<{OPTIONAL}, int32_t>"
automation, args = _automation(main_cpp, service.id)
assert args == "uint32_t, bool, int32_t"
respond, unregister = _actions_of(main_cpp, automation)
types = "uint32_t, bool, int32_t"
function = _service_function(main_cpp, "respond_optional", OPTIONAL, "int32_t")
automation = _triggered_automation(
main_cpp, function, "call_id, return_response, value"
)
assert _constructed(main_cpp, automation) == f"Automation<{types}>()"
respond, _ = _actions_of(main_cpp, automation)
assert _constructed(main_cpp, respond) == (
"api::APIRespondAction<uint32_t, bool, int32_t>(api_apiserver_id)"
f"api::APIRespondAction<{types}>(api_apiserver_id)"
)
assert f"{respond}->set_is_optional_mode(true);" in main_cpp
assert unregister == f"{service.id}__unregister"
assert _constructed(main_cpp, unregister) == (
"api::APIUnregisterServiceCallAction<uint32_t, bool, int32_t>(api_apiserver_id)"
)
_assert_unregister_after(main_cpp, automation, types)
def test_respond_only(generate_main) -> None:
"""A response-only service passes call_id; the call is unregistered after every action."""
main_cpp = generate_main(CONFIG)
service = _service(main_cpp, "respond_only")
assert service.type == f"api::UserServiceTrigger<{ONLY}, StringRef>"
automation, args = _automation(main_cpp, service.id)
assert args == "uint32_t, StringRef"
log, respond, unregister = _actions_of(main_cpp, automation)
types = "uint32_t, StringRef"
function = _service_function(main_cpp, "respond_only", ONLY, "StringRef")
automation = _triggered_automation(main_cpp, function, "call_id, name")
assert _constructed(main_cpp, automation) == f"Automation<{types}>()"
log, respond, _ = _actions_of(main_cpp, automation)
assert "RESPOND_ONLY %s" in _action_code(main_cpp, log)
assert _constructed(main_cpp, respond) == (
"api::APIRespondAction<uint32_t, StringRef>(api_apiserver_id)"
f"api::APIRespondAction<{types}>(api_apiserver_id)"
)
assert f"{respond}->set_is_optional_mode" not in main_cpp
assert unregister == f"{service.id}__unregister"
assert _constructed(main_cpp, unregister) == (
"api::APIUnregisterServiceCallAction<uint32_t, StringRef>(api_apiserver_id)"
)
_assert_unregister_after(main_cpp, automation, types)
def test_unregister_merges_with_trailing_inline_actions(generate_main) -> None:
"""After a non-inline action, the unregister joins the inline actions that follow it."""
main_cpp = generate_main(CONFIG)
function = _service_function(main_cpp, "respond_then_log", STATUS)
automation = _triggered_automation(main_cpp, function, "call_id")
respond, log = _actions_of(main_cpp, automation)
assert _action_class(main_cpp, respond) == "api::APIRespondAction"
body = _action_code(main_cpp, log)
assert body.index("RESPONDED") < body.index("if (!esphome__running)")
assert body.endswith(f"{UNREGISTER}\n }}")
def test_unregister_in_fully_inline_response_service(generate_main) -> None:
"""With only inline actions, the unregister ends the function the service calls."""
main_cpp = generate_main(CONFIG)
function = _service_function(main_cpp, "status_inline", STATUS)
body = _function_body(main_cpp, function)
assert "->trigger(" not in body
assert "esphome__running" not in body
assert f"Action<{function}" not in main_cpp
assert body.index("STATUS_INLINE first") < body.index("send_action_response")
assert body.endswith(f"\n {{\n {UNREGISTER}\n }}")
def test_manual_trigger_id(generate_main) -> None:
"""A trigger_id set by hand names the service object."""
main_cpp = generate_main(CONFIG)
assert _service(main_cpp, "manual_trigger_id") == Service(
"manual_service_trigger", f"api::UserServiceTrigger<{NONE}>"
)
automation, _ = _automation(main_cpp, "manual_service_trigger")
(action,) = _actions_of(main_cpp, automation)
assert "MANUAL called" in _action_code(main_cpp, action)
service = _service(main_cpp, "manual_trigger_id")
assert service.id == "manual_service_trigger"
function = _service_function(main_cpp, "manual_trigger_id", NONE)
assert "MANUAL called" in _function_body(main_cpp, function)
def test_lambda_uses_own_service_id(generate_main) -> None:
@@ -196,9 +243,34 @@ def test_lambda_uses_own_service_id(generate_main) -> None:
main_cpp = generate_main(CONFIG)
service = _service(main_cpp, "self_reference")
assert service.id == "self_reference_service"
automation, _ = _automation(main_cpp, service.id)
(action,) = _actions_of(main_cpp, automation)
assert "(const void *) self_reference_service" in _action_code(main_cpp, action)
function = _service_function(main_cpp, "self_reference", NONE)
body = _function_body(main_cpp, function)
assert "(const void *) self_reference_service" in body
@pytest.mark.parametrize(
("mode", "name"),
[
("status", "call_id"),
("optional", "call_id"),
("optional", "return_response"),
],
)
def test_reserved_variable_name_rejected(mode: str, name: str) -> None:
"""A variable cannot take the name of an argument the response mode passes first."""
action = {"action": "x", "supports_response": mode, "variables": {name: "int"}}
with pytest.raises(Invalid, match=f"'{name}' is reserved"):
ACTIONS_SCHEMA({**action, "then": []})
@pytest.mark.parametrize(
("mode", "name"), [("none", "call_id"), ("status", "return_response")]
)
def test_variable_name_free_in_other_modes(mode: str, name: str) -> None:
"""The names are only reserved in the modes that pass those arguments."""
action = {"action": "x", "supports_response": mode, "variables": {name: "int"}}
(config,) = ACTIONS_SCHEMA({**action, "then": []})
assert name in config["variables"]
def test_services_registered_in_order(generate_main) -> None:
@@ -212,7 +284,10 @@ def test_services_registered_in_order(generate_main) -> None:
"respond_optional",
"respond_only",
"manual_trigger_id",
"respond_then_log",
"status_inline",
"self_reference",
"respond_named",
]
match = re.search(
r"\bapi_apiserver_id->initialize_user_services\(\{(.*?)\}\);", main_cpp
@@ -225,9 +300,15 @@ def test_legacy_services_key(generate_main) -> None:
"""The legacy ``services:`` key with ``service:`` names builds the same service."""
main_cpp = generate_main(LEGACY_CONFIG)
assert _strings(main_cpp, "legacy_service")[1] == '"legacy_service", "devices"'
service = _service(main_cpp, "legacy_service")
assert service.type == f"api::UserServiceTrigger<{NONE}, StringRef>"
automation, args = _automation(main_cpp, service.id)
assert args == "StringRef"
(action,) = _actions_of(main_cpp, automation)
assert '"LEGACY %s", devices.c_str()' in _action_code(main_cpp, action)
function = _service_function(main_cpp, "legacy_service", NONE, "StringRef")
assert "Automation<StringRef>" not in main_cpp
assert '"LEGACY %s", devices.c_str()' in _function_body(main_cpp, function)
def test_cleanup_action_id_avoids_config_ids(generate_main) -> None:
"""The response cleanup action does not take an id the config already uses."""
main_cpp = generate_main(CONFIG)
last = _actions_of(main_cpp, "worker")[-1]
assert last.startswith("worker__after")
assert last != "worker__after"
assert _action_code(main_cpp, last) == f"\n {UNREGISTER}"
@@ -14,6 +14,10 @@ globals:
- id: doubled
type: int
initial_value: "0"
# Same id the respond_named cleanup action would get without a uniqueness check
- id: worker__after
type: int
initial_value: "0"
api:
actions:
@@ -83,9 +87,29 @@ api:
trigger_id: manual_service_trigger
then:
- logger.log: "MANUAL called"
- action: respond_then_log
then:
- api.respond:
success: true
- logger.log: "RESPONDED"
# Only inline actions: the lambda reports the status itself
- action: status_inline
supports_response: status
then:
- logger.log: "STATUS_INLINE first"
- lambda: |-
api::global_api_server->send_action_response(call_id, true, StringRef());
# A lambda may use the service's own id
- action: self_reference
trigger_id: self_reference_service
then:
- lambda: |-
ESP_LOGD("test", "SELF_REFERENCE %p", (const void *) id(self_reference_service));
# A config id that the response cleanup action's generated id would otherwise take
- action: respond_named
automation_id: worker
supports_response: only
then:
- api.respond:
data: !lambda |-
root["ok"] = true;
@@ -10,6 +10,7 @@ logger:
level: DEBUG
api:
id: api_server
actions:
# Action that responds immediately - should work fine
- action: action_immediate
@@ -42,3 +43,14 @@ api:
- api.respond:
data: !lambda |-
root["status"] = "long_delay";
# Only inline actions; the lambda sends the status itself. The call must be
# unregistered after it, so it never times out.
- action: action_inline_status
supports_response: status
then:
- logger.log:
format: "ACTION_INLINE_STATUS call_id=%u"
args: [(unsigned) call_id]
- lambda: |-
id(api_server)->send_action_response(call_id, true, StringRef());
+21 -2
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import contextlib
import re
import pytest
@@ -29,6 +30,7 @@ async def test_api_action_timeout(
1. Actions that respond within the timeout work correctly
2. Actions that exceed the timeout have their calls cleaned up
3. Late responses log a warning but don't crash
4. A call to a service of only inline actions is unregistered after them
"""
waiter = LineWaiter()
@@ -43,13 +45,26 @@ async def test_api_action_timeout(
# List services
_, services = await client.list_entities_services()
# Should have 3 services
assert len(services) == 3, f"Expected 3 services, found {len(services)}"
# Should have 4 services
assert len(services) == 4, f"Expected 4 services, found {len(services)}"
# Find our services
action_immediate = require_service(services, "action_immediate")
action_short_delay = require_service(services, "action_short_delay")
action_long_delay = require_service(services, "action_long_delay")
action_inline_status = require_service(services, "action_inline_status")
# Test 0: an inline-only service responds from a lambda, then is unregistered;
# the timed-out check at the end shows the unregister ran
response = await client.execute_service(
action_inline_status,
{},
return_response=True,
)
assert response is not None, "Expected response for inline status action"
assert response.success is True
line = await waiter.wait_for("ACTION_INLINE_STATUS call_id=", timeout=1.0)
inline_call_id = re.search(r"call_id=(\d+)", line).group(1)
# Test 1: Immediate response should work
response = await client.execute_service(
@@ -114,3 +129,7 @@ async def test_api_action_timeout(
)
assert response is not None, "System should still work after timeout"
assert response.success is True
# Well over 500 ms have passed: only the long delay call timed out
assert waiter.count(re.compile(r"Action call \d+ timed out")) == 1
assert waiter.count(f"Action call {inline_call_id} timed out") == 0
+15
View File
@@ -1173,6 +1173,21 @@ def test_static_function_declares_early_and_defines_after_includes() -> None:
]
def test_declare_static_function_defines_the_body_later() -> None:
"""The declaration is emitted at once; the body only when define() is called."""
define = cg.declare_static_function("esphome__act__fn", ct.void, [])
assert [str(st) for st in CORE.global_statements] == [
"static void esphome__act__fn();"
]
assert CORE.global_statements_after_includes == []
fn = define(["first();", "second();"])
assert str(fn) == "esphome__act__fn"
assert [str(st) for st in CORE.global_statements_after_includes] == [
"static void esphome__act__fn() {\n first();\n second();\n}"
]
def test_static_function_accepts_statements() -> None:
call = cg.MockObj("foo")(cg.MockObj("x") >> 2)
cg.static_function(