diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 32d13b69ae..223af132db 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -11,6 +11,15 @@ namespace esphome { namespace api { template class TemplatableStringValue : public TemplatableValue { + private: + // Helper to convert value to string - handles the case where value is already a string + template static std::string value_to_string(T &&val) { return to_string(std::forward(val)); } + + // Overloads for string types - needed because std::to_string doesn't support them + static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str() + static std::string value_to_string(const std::string &val) { return val; } + static std::string value_to_string(std::string &&val) { return std::move(val); } + public: TemplatableStringValue() : TemplatableValue() {} @@ -19,7 +28,7 @@ template class TemplatableStringValue : public TemplatableValue::value, int> = 0> TemplatableStringValue(F f) - : TemplatableValue([f](X... x) -> std::string { return to_string(f(x...)); }) {} + : TemplatableValue([f](X... x) -> std::string { return value_to_string(f(x...)); }) {} }; template class TemplatableKeyValuePair { diff --git a/tests/integration/fixtures/api_string_lambda.yaml b/tests/integration/fixtures/api_string_lambda.yaml new file mode 100644 index 0000000000..18440b9984 --- /dev/null +++ b/tests/integration/fixtures/api_string_lambda.yaml @@ -0,0 +1,64 @@ +esphome: + name: api-string-lambda-test +host: + +api: + actions: + # Service that tests string lambda functionality + - action: test_string_lambda + variables: + input_string: string + then: + # Log the input to verify service was called + - logger.log: + format: "Service called with string: %s" + args: [input_string.c_str()] + + # This is the key test - using a lambda that returns x.c_str() + # where x is already a string. This would fail to compile in 2025.7.0b5 + # with "no matching function for call to 'to_string(std::string)'" + # This is the exact case from issue #9539 + - homeassistant.tag_scanned: !lambda 'return input_string.c_str();' + + # Also test with homeassistant.event to verify our fix works with data fields + - homeassistant.event: + event: esphome.test_string_lambda + data: + value: !lambda 'return input_string.c_str();' + + # Service that tests int lambda functionality + - action: test_int_lambda + variables: + input_number: int + then: + # Log the input to verify service was called + - logger.log: + format: "Service called with int: %d" + args: [input_number] + + # Test that int lambdas still work correctly with to_string + # The TemplatableStringValue should automatically convert int to string + - homeassistant.event: + event: esphome.test_int_lambda + data: + value: !lambda 'return input_number;' + + # Service that tests float lambda functionality + - action: test_float_lambda + variables: + input_float: float + then: + # Log the input to verify service was called + - logger.log: + format: "Service called with float: %.2f" + args: [input_float] + + # Test that float lambdas still work correctly with to_string + # The TemplatableStringValue should automatically convert float to string + - homeassistant.event: + event: esphome.test_float_lambda + data: + value: !lambda 'return input_float;' + +logger: + level: DEBUG diff --git a/tests/integration/test_api_string_lambda.py b/tests/integration/test_api_string_lambda.py new file mode 100644 index 0000000000..3bef2d86e2 --- /dev/null +++ b/tests/integration/test_api_string_lambda.py @@ -0,0 +1,85 @@ +"""Integration test for TemplatableStringValue with string lambdas.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_string_lambda( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test TemplatableStringValue works with lambdas that return different types.""" + loop = asyncio.get_running_loop() + + # Track log messages for all three service calls + string_called_future = loop.create_future() + int_called_future = loop.create_future() + float_called_future = loop.create_future() + + # Patterns to match in logs - confirms the lambdas compiled and executed + string_pattern = re.compile(r"Service called with string: STRING_FROM_LAMBDA") + int_pattern = re.compile(r"Service called with int: 42") + float_pattern = re.compile(r"Service called with float: 3\.14") + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not string_called_future.done() and string_pattern.search(line): + string_called_future.set_result(True) + if not int_called_future.done() and int_pattern.search(line): + int_called_future.set_result(True) + if not float_called_future.done() and float_pattern.search(line): + float_called_future.set_result(True) + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "api-string-lambda-test" + + # List services to find our test services + _, services = await client.list_entities_services() + + # Find all test services + string_service = next( + (s for s in services if s.name == "test_string_lambda"), None + ) + assert string_service is not None, "test_string_lambda service not found" + + int_service = next((s for s in services if s.name == "test_int_lambda"), None) + assert int_service is not None, "test_int_lambda service not found" + + float_service = next( + (s for s in services if s.name == "test_float_lambda"), None + ) + assert float_service is not None, "test_float_lambda service not found" + + # Execute all three services to test different lambda return types + client.execute_service(string_service, {"input_string": "STRING_FROM_LAMBDA"}) + client.execute_service(int_service, {"input_number": 42}) + client.execute_service(float_service, {"input_float": 3.14}) + + # Wait for all service log messages + # This confirms the lambdas compiled successfully and executed + try: + await asyncio.wait_for( + asyncio.gather( + string_called_future, int_called_future, float_called_future + ), + timeout=5.0, + ) + except TimeoutError: + pytest.fail( + "One or more service log messages not received - lambda may have failed to compile or execute" + )