mirror of
https://github.com/esphome/esphome.git
synced 2025-11-01 07:31:51 +00:00
Compare commits
2 Commits
platformio
...
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