diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1f38f4a31a..f238551160 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -371,8 +371,21 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_service_call(const HomeassistantServiceResponse &call) { - for (auto &client : this->clients_) { - client->send_homeassistant_service_call(call); + if (call.is_event) { + // For events, send to only one client to prevent duplicates + // Events represent "something that happened" and should only be sent once total + for (auto &client : this->clients_) { + if (client->is_authenticated() && client->flags_.service_call_subscription) { + client->send_homeassistant_service_call(call); + return; // Send to only the first authenticated client with service call subscription + } + } + } else { + // For service calls, send to all clients (existing behavior) + // Service calls represent "actions to take" and may need to be sent to multiple Home Assistant instances + for (auto &client : this->clients_) { + client->send_homeassistant_service_call(call); + } } } #endif diff --git a/tests/integration/fixtures/duplicate_homeassistant_events.yaml b/tests/integration/fixtures/duplicate_homeassistant_events.yaml new file mode 100644 index 0000000000..ad6568b320 --- /dev/null +++ b/tests/integration/fixtures/duplicate_homeassistant_events.yaml @@ -0,0 +1,53 @@ +esphome: + name: test-duplicate-events + friendly_name: Duplicate Events Test + +host: + +api: + services: + - service: trigger_doorbell_test + then: + - logger.log: "Triggering doorbell test event" + - homeassistant.event: + event: esphome.doorbell_pressed + data: + device_id: test_device_123 + - logger.log: "Doorbell event sent" + +logger: + level: DEBUG + +# Simulate the user's binary sensor setup +binary_sensor: + - platform: template + name: "Test Doorbell" + id: test_doorbell + # Simulate doorbell behavior - starts false, becomes true when pressed + lambda: |- + static bool pressed = false; + return pressed; + on_press: + then: + - logger.log: "Doorbell pressed - sending HA event" + - homeassistant.event: + event: esphome.doorbell_pressed + data: + device_id: test_device_123 + - logger.log: "Doorbell HA event sent" + +button: + # Button to simulate doorbell press for testing + - platform: template + name: "Simulate Doorbell Press" + id: simulate_doorbell + on_press: + - logger.log: "Simulating doorbell press" + - binary_sensor.template.publish: + id: test_doorbell + state: true + - delay: 100ms + - binary_sensor.template.publish: + id: test_doorbell + state: false + - logger.log: "Doorbell simulation complete" diff --git a/tests/integration/fixtures/homeassistant_service_calls_to_all_clients.yaml b/tests/integration/fixtures/homeassistant_service_calls_to_all_clients.yaml new file mode 100644 index 0000000000..250b431840 --- /dev/null +++ b/tests/integration/fixtures/homeassistant_service_calls_to_all_clients.yaml @@ -0,0 +1,33 @@ +esphome: + name: test-service-calls + friendly_name: Service Calls Test + +host: + +api: + services: + - service: trigger_service_call_test + then: + - logger.log: "Triggering service call test" + - homeassistant.service: + service: light.turn_on + data: + entity_id: light.test_light + brightness: "255" + - logger.log: "Service call sent" + +logger: + level: DEBUG + +button: + # Button to trigger a service call for testing + - platform: template + name: "Test Service Call" + id: test_service_call + on_press: + - logger.log: "Sending homeassistant service call" + - homeassistant.service: + service: switch.turn_on + data: + entity_id: switch.test_switch + - logger.log: "Service call complete" diff --git a/tests/integration/test_duplicate_homeassistant_events.py b/tests/integration/test_duplicate_homeassistant_events.py new file mode 100644 index 0000000000..49d379694e --- /dev/null +++ b/tests/integration/test_duplicate_homeassistant_events.py @@ -0,0 +1,129 @@ +"""Integration test for duplicate Home Assistant events issue. + +Tests that homeassistant.event actions don't generate duplicate events +when multiple API clients are connected to the same ESPHome device. + +This addresses the issue where binary_sensor on_press/on_click triggers +would fire duplicate events due to the API server sending the event +to all connected clients instead of just one. +""" + +from __future__ import annotations + +import asyncio +from collections import defaultdict + +from aioesphomeapi import HomeassistantServiceCall +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_duplicate_homeassistant_events( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that homeassistant.event actions don't generate duplicate events with multiple clients.""" + + # Track events received by each client + client1_events: list[HomeassistantServiceCall] = [] + client2_events: list[HomeassistantServiceCall] = [] + all_events: list[HomeassistantServiceCall] = [] + + # Track events by event name for easier counting + event_counts = defaultdict(int) + + def on_service_call_client1(service_call: HomeassistantServiceCall) -> None: + """Track events received by first client.""" + if service_call.is_event: + client1_events.append(service_call) + all_events.append(service_call) + event_counts[service_call.service] += 1 + + def on_service_call_client2(service_call: HomeassistantServiceCall) -> None: + """Track events received by second client.""" + if service_call.is_event: + client2_events.append(service_call) + all_events.append(service_call) + event_counts[service_call.service] += 1 + + # Connect TWO API clients to simulate the real-world scenario + # where Home Assistant might have multiple connections + async with ( + run_compiled(yaml_config), + api_client_connected() as client1, + api_client_connected() as client2, + ): + # Subscribe both clients to service calls + client1.subscribe_service_calls(on_service_call_client1) + client2.subscribe_service_calls(on_service_call_client2) + + # Get device info to ensure both clients are connected + device_info1 = await client1.device_info() + device_info2 = await client2.device_info() + assert device_info1 is not None + assert device_info2 is not None + assert device_info1.name == device_info2.name + + # List entities and services + entities1, services1 = await client1.list_entities_services() + entities2, services2 = await client2.list_entities_services() + + # Find the test button service + test_service = next( + (s for s in services1 if s.name == "trigger_doorbell_test"), None + ) + assert test_service is not None, "trigger_doorbell_test service not found" + + # Clear any initial events + client1_events.clear() + client2_events.clear() + all_events.clear() + event_counts.clear() + + # Trigger the doorbell event multiple times to simulate real usage + for i in range(3): + # Execute the service that triggers the doorbell event + client1.execute_service(test_service, {}) + + # Wait a bit for the event to be processed + await asyncio.sleep(0.1) + + # Wait for all events to be received + await asyncio.sleep(0.5) + + # Now check the results + print(f"Client1 received {len(client1_events)} events") + print(f"Client2 received {len(client2_events)} events") + print(f"Total events seen: {len(all_events)}") + + # Print event details for debugging + for i, event in enumerate(all_events): + print(f"Event {i}: {event.service} (is_event: {event.is_event})") + + # Each button press should generate exactly ONE event total, + # not one per client. The current bug would show 6 total events + # (3 button presses × 2 clients = 6 events) instead of 3. + + expected_doorbell_events = 3 + actual_doorbell_events = event_counts.get("esphome.doorbell_pressed", 0) + + assert actual_doorbell_events == expected_doorbell_events, ( + f"Expected {expected_doorbell_events} doorbell events, got {actual_doorbell_events}. " + f"This indicates duplicate events are being sent to multiple API clients. " + f"Client1 events: {len(client1_events)}, Client2 events: {len(client2_events)}" + ) + + # Additionally, verify that not both clients received the same events + # (events should be sent to only one client, not duplicated) + if len(all_events) == expected_doorbell_events: + # Events are not duplicated - this is the correct behavior + print("✓ Events are properly deduplicated") + else: + # This is the bug - events are duplicated across clients + pytest.fail( + f"Events appear to be duplicated across API clients. " + f"Expected {expected_doorbell_events} total events, got {len(all_events)}" + ) diff --git a/tests/integration/test_homeassistant_service_calls_multiple_clients.py b/tests/integration/test_homeassistant_service_calls_multiple_clients.py new file mode 100644 index 0000000000..ded10f7461 --- /dev/null +++ b/tests/integration/test_homeassistant_service_calls_multiple_clients.py @@ -0,0 +1,98 @@ +"""Integration test to verify that homeassistant.service calls are still sent to all clients. + +This test ensures that our fix for duplicate events doesn't break the expected +behavior for service calls, which should be sent to all connected clients. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import HomeassistantServiceCall +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_homeassistant_service_calls_to_all_clients( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that homeassistant.service calls are sent to all connected clients.""" + + # Track service calls received by each client + client1_service_calls: list[HomeassistantServiceCall] = [] + client2_service_calls: list[HomeassistantServiceCall] = [] + + def on_service_call_client1(service_call: HomeassistantServiceCall) -> None: + """Track service calls received by first client.""" + if not service_call.is_event: # Only track service calls, not events + client1_service_calls.append(service_call) + + def on_service_call_client2(service_call: HomeassistantServiceCall) -> None: + """Track service calls received by second client.""" + if not service_call.is_event: # Only track service calls, not events + client2_service_calls.append(service_call) + + # Connect TWO API clients + async with ( + run_compiled(yaml_config), + api_client_connected() as client1, + api_client_connected() as client2, + ): + # Subscribe both clients to service calls + client1.subscribe_service_calls(on_service_call_client1) + client2.subscribe_service_calls(on_service_call_client2) + + # Get device info to ensure both clients are connected + device_info1 = await client1.device_info() + device_info2 = await client2.device_info() + assert device_info1 is not None + assert device_info2 is not None + assert device_info1.name == device_info2.name + + # List entities and services + entities1, services1 = await client1.list_entities_services() + + # Find the test service + test_service = next( + (s for s in services1 if s.name == "trigger_service_call_test"), None + ) + assert test_service is not None, "trigger_service_call_test service not found" + + # Clear any initial service calls + client1_service_calls.clear() + client2_service_calls.clear() + + # Trigger the service call + client1.execute_service(test_service, {}) + + # Wait for service calls to be processed + await asyncio.sleep(0.5) + + # Now check the results + print(f"Client1 received {len(client1_service_calls)} service calls") + print(f"Client2 received {len(client2_service_calls)} service calls") + + # For service calls (not events), both clients should receive the call + # This is the correct behavior for service calls + assert len(client1_service_calls) > 0, ( + "Client1 should have received service calls" + ) + assert len(client2_service_calls) > 0, ( + "Client2 should have received service calls" + ) + + # Both clients should have received the same service calls + assert len(client1_service_calls) == len(client2_service_calls), ( + f"Both clients should receive the same number of service calls. " + f"Client1: {len(client1_service_calls)}, Client2: {len(client2_service_calls)}" + ) + + # Verify the service call details + for call1, call2 in zip(client1_service_calls, client2_service_calls): + assert call1.service == call2.service, "Service names should match" + assert not call1.is_event, "These should be service calls, not events" + assert not call2.is_event, "These should be service calls, not events"