diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 268aa4af49..e448faad48 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -54,8 +54,12 @@ template class TemplatableFn { std::invocable &&std::convertible_to, T> &&std::is_empty_v &&std::default_initializable : f_([](X... x) -> T { return static_cast(F{}(x...)); }) {} - // Reject stateful lambdas (non-empty, i.e. capturing) with a clear error - template TemplatableFn(F) requires std::invocable &&(!std::is_empty_v) = delete; + // Reject any callable that didn't match the above (stateful lambdas or inconvertible return types) + template + TemplatableFn(F) requires std::invocable && + (!std::convertible_to) &&(!std::is_empty_v || + !std::convertible_to, T> || + !std::default_initializable) = delete; bool has_value() const { return this->f_ != nullptr; } diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 3c87e311c3..81ae586e23 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -684,6 +684,15 @@ async def test_templatable__with_to_exp_callable() -> None: assert result == 84 +@pytest.mark.asyncio +async def test_templatable__with_to_exp_callable_and_output_type() -> None: + """When to_exp is provided with non-string output_type, result is lambda-wrapped.""" + result = await cg.templatable(42, [], ct.int_, to_exp=lambda x: x * 2) + + assert isinstance(result, cg.LambdaExpression) + assert result.capture == "" + + @pytest.mark.asyncio async def test_templatable__with_to_exp_dict() -> None: """When to_exp is a dict, value is looked up."""