mirror of
				https://github.com/esphome/esphome.git
				synced 2025-11-04 00:51:49 +00:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			2025.10.0b
			...
			copilot/fi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					5fa6cd3dae | ||
| 
						 | 
					4ec9ab944d | 
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
@@ -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"
 | 
			
		||||
							
								
								
									
										129
									
								
								tests/integration/test_duplicate_homeassistant_events.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								tests/integration/test_duplicate_homeassistant_events.py
									
									
									
									
									
										Normal file
									
								
							@@ -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)}"
 | 
			
		||||
            )
 | 
			
		||||
@@ -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"
 | 
			
		||||
		Reference in New Issue
	
	Block a user