1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-01 15:41:52 +00:00

Fix duplicate homeassistant.event firing with multiple API clients

This commit is contained in:
copilot-swe-agent[bot]
2025-08-05 13:40:59 +00:00
parent 4ec9ab944d
commit 5fa6cd3dae
5 changed files with 328 additions and 2 deletions

View File

@@ -371,10 +371,23 @@ 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) {
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
#ifdef USE_API_HOMEASSISTANT_STATES

View File

@@ -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"

View File

@@ -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"

View 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)}"
)

View File

@@ -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"