diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index d1002ad0af..001f1ea01e 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1753,7 +1753,7 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { } #endif - UrlMatch match = match_url(request->url(), true); // NOLINT + UrlMatch match = match_url(request->url().c_str(), true); // NOLINT if (!match.valid) return false; #ifdef USE_SENSOR @@ -1892,7 +1892,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif - UrlMatch match = match_url(request->url()); // NOLINT + UrlMatch match = match_url(request->url().c_str()); // NOLINT #ifdef USE_SENSOR if (match.domain_equals("sensor")) { this->handle_sensor_request(request, match); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 22454249aa..ea3c8bdc17 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -101,8 +101,11 @@ #define USE_AUDIO_FLAC_SUPPORT #define USE_AUDIO_MP3_SUPPORT #define USE_API +#define USE_API_CLIENT_CONNECTED_TRIGGER +#define USE_API_CLIENT_DISCONNECTED_TRIGGER #define USE_API_NOISE #define USE_API_PLAINTEXT +#define USE_API_YAML_SERVICES #define USE_MD5 #define USE_MQTT #define USE_NETWORK diff --git a/tests/integration/fixtures/api_conditional_memory.yaml b/tests/integration/fixtures/api_conditional_memory.yaml new file mode 100644 index 0000000000..4bbba5084b --- /dev/null +++ b/tests/integration/fixtures/api_conditional_memory.yaml @@ -0,0 +1,71 @@ +esphome: + name: api-conditional-memory-test +host: +api: + actions: + - action: test_simple_service + then: + - logger.log: "Simple service called" + - binary_sensor.template.publish: + id: service_called_sensor + state: ON + - action: test_service_with_args + variables: + arg_string: string + arg_int: int + arg_bool: bool + arg_float: float + then: + - logger.log: + format: "Service called with: %s, %d, %d, %.2f" + args: [arg_string.c_str(), arg_int, arg_bool, arg_float] + - sensor.template.publish: + id: service_arg_sensor + state: !lambda 'return arg_float;' + on_client_connected: + - logger.log: + format: "Client %s connected from %s" + args: [client_info.c_str(), client_address.c_str()] + - binary_sensor.template.publish: + id: client_connected + state: ON + - text_sensor.template.publish: + id: last_client_info + state: !lambda 'return client_info;' + on_client_disconnected: + - logger.log: + format: "Client %s disconnected from %s" + args: [client_info.c_str(), client_address.c_str()] + - binary_sensor.template.publish: + id: client_connected + state: OFF + - binary_sensor.template.publish: + id: client_disconnected_event + state: ON + +logger: + level: DEBUG + +binary_sensor: + - platform: template + name: "Client Connected" + id: client_connected + device_class: connectivity + - platform: template + name: "Client Disconnected Event" + id: client_disconnected_event + - platform: template + name: "Service Called" + id: service_called_sensor + +sensor: + - platform: template + name: "Service Argument Value" + id: service_arg_sensor + unit_of_measurement: "" + accuracy_decimals: 2 + +text_sensor: + - platform: template + name: "Last Client Info" + id: last_client_info diff --git a/tests/integration/test_api_conditional_memory.py b/tests/integration/test_api_conditional_memory.py new file mode 100644 index 0000000000..b85e8d91af --- /dev/null +++ b/tests/integration/test_api_conditional_memory.py @@ -0,0 +1,205 @@ +"""Integration test for API conditional memory optimization with triggers and services.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ( + BinarySensorInfo, + EntityState, + SensorInfo, + TextSensorInfo, + UserService, + UserServiceArgType, +) +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_conditional_memory( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test API triggers and services work correctly with conditional compilation.""" + loop = asyncio.get_running_loop() + # Keep ESPHome process running throughout the test + async with run_compiled(yaml_config): + # First connection + async with 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-conditional-memory-test" + + # List entities and services + entity_info, services = await asyncio.wait_for( + client.list_entities_services(), timeout=5.0 + ) + + # Find our entities + client_connected: BinarySensorInfo | None = None + client_disconnected_event: BinarySensorInfo | None = None + service_called_sensor: BinarySensorInfo | None = None + service_arg_sensor: SensorInfo | None = None + last_client_info: TextSensorInfo | None = None + + for entity in entity_info: + if isinstance(entity, BinarySensorInfo): + if entity.object_id == "client_connected": + client_connected = entity + elif entity.object_id == "client_disconnected_event": + client_disconnected_event = entity + elif entity.object_id == "service_called": + service_called_sensor = entity + elif isinstance(entity, SensorInfo): + if entity.object_id == "service_argument_value": + service_arg_sensor = entity + elif isinstance(entity, TextSensorInfo): + if entity.object_id == "last_client_info": + last_client_info = entity + + # Verify all entities exist + assert client_connected is not None, "client_connected sensor not found" + assert client_disconnected_event is not None, ( + "client_disconnected_event sensor not found" + ) + assert service_called_sensor is not None, "service_called sensor not found" + assert service_arg_sensor is not None, "service_arg_sensor not found" + assert last_client_info is not None, "last_client_info sensor not found" + + # Verify services exist + assert len(services) == 2, f"Expected 2 services, found {len(services)}" + + # Find our services + simple_service: UserService | None = None + service_with_args: UserService | None = None + + for service in services: + if service.name == "test_simple_service": + simple_service = service + elif service.name == "test_service_with_args": + service_with_args = service + + assert simple_service is not None, "test_simple_service not found" + assert service_with_args is not None, "test_service_with_args not found" + + # Verify service arguments + assert len(service_with_args.args) == 4, ( + f"Expected 4 args, found {len(service_with_args.args)}" + ) + + # Check arg types + arg_types = {arg.name: arg.type for arg in service_with_args.args} + assert arg_types["arg_string"] == UserServiceArgType.STRING + assert arg_types["arg_int"] == UserServiceArgType.INT + assert arg_types["arg_bool"] == UserServiceArgType.BOOL + assert arg_types["arg_float"] == UserServiceArgType.FLOAT + + # Track state changes + states: dict[int, EntityState] = {} + states_future: asyncio.Future[None] = loop.create_future() + + def on_state(state: EntityState) -> None: + states[state.key] = state + # Check if we have initial states for connection sensors + if ( + client_connected.key in states + and last_client_info.key in states + and not states_future.done() + ): + states_future.set_result(None) + + client.subscribe_states(on_state) + + # Wait for initial states + await asyncio.wait_for(states_future, timeout=5.0) + + # Verify on_client_connected trigger fired + connected_state = states.get(client_connected.key) + assert connected_state is not None + assert connected_state.state is True, "Client should be connected" + + # Verify client info was captured + client_info_state = states.get(last_client_info.key) + assert client_info_state is not None + assert isinstance(client_info_state.state, str) + assert len(client_info_state.state) > 0, "Client info should not be empty" + + # Test simple service + service_future: asyncio.Future[None] = loop.create_future() + + def check_service_called(state: EntityState) -> None: + if state.key == service_called_sensor.key and state.state is True: + if not service_future.done(): + service_future.set_result(None) + + # Update callback to check for service execution + client.subscribe_states(check_service_called) + + # Call simple service + client.execute_service(simple_service, {}) + + # Wait for service to execute + await asyncio.wait_for(service_future, timeout=5.0) + + # Test service with arguments + arg_future: asyncio.Future[None] = loop.create_future() + expected_float = 42.5 + + def check_arg_sensor(state: EntityState) -> None: + if ( + state.key == service_arg_sensor.key + and abs(state.state - expected_float) < 0.01 + ): + if not arg_future.done(): + arg_future.set_result(None) + + client.subscribe_states(check_arg_sensor) + + # Call service with arguments + client.execute_service( + service_with_args, + { + "arg_string": "test_string", + "arg_int": 123, + "arg_bool": True, + "arg_float": expected_float, + }, + ) + + # Wait for service with args to execute + await asyncio.wait_for(arg_future, timeout=5.0) + + # After disconnecting first client, reconnect and verify triggers work + async with api_client_connected() as client2: + # Subscribe to states with new client + states2: dict[int, EntityState] = {} + connected_future: asyncio.Future[None] = loop.create_future() + + def on_state2(state: EntityState) -> None: + states2[state.key] = state + # Check for reconnection + if state.key == client_connected.key and state.state is True: + if not connected_future.done(): + connected_future.set_result(None) + + client2.subscribe_states(on_state2) + + # Wait for connected state + await asyncio.wait_for(connected_future, timeout=5.0) + + # Verify client is connected again (on_client_connected fired) + assert states2[client_connected.key].state is True, ( + "Client should be reconnected" + ) + + # The client_disconnected_event should be ON from when we disconnected + # (it was set ON by on_client_disconnected trigger) + disconnected_state = states2.get(client_disconnected_event.key) + assert disconnected_state is not None + assert disconnected_state.state is True, ( + "Disconnect event should be ON from previous disconnect" + )