mirror of
https://github.com/esphome/esphome.git
synced 2025-09-01 19:02:18 +01:00
Allow disabling API batch delay for real-time state updates (#9298)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
esphome:
|
||||
name: rapid-transitions-test
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Enable immediate sending for rapid transitions
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
# Add a sensor that updates frequently to trigger lambda evaluations
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Update Trigger"
|
||||
id: update_trigger
|
||||
lambda: |-
|
||||
return 0;
|
||||
update_interval: 10ms
|
||||
internal: true
|
||||
|
||||
# Simulate an IR remote binary sensor with rapid ON/OFF transitions
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: "Simulated IR Remote Button"
|
||||
id: ir_remote_button
|
||||
lambda: |-
|
||||
// Simulate rapid button presses every ~100ms
|
||||
// Each "press" is ON for ~30ms then OFF
|
||||
uint32_t now = millis();
|
||||
uint32_t press_cycle = now % 100; // 100ms cycle
|
||||
|
||||
// ON for first 30ms of each cycle
|
||||
if (press_cycle < 30) {
|
||||
// Only log state change
|
||||
if (!id(ir_remote_button).state) {
|
||||
ESP_LOGD("test", "Button ON at %u", now);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
// Only log state change
|
||||
if (id(ir_remote_button).state) {
|
||||
ESP_LOGD("test", "Button OFF at %u", now);
|
||||
}
|
||||
return false;
|
||||
}
|
58
tests/integration/test_batch_delay_zero_rapid_transitions.py
Normal file
58
tests/integration/test_batch_delay_zero_rapid_transitions.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Integration test for API batch_delay: 0 with rapid state transitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityState
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_delay_zero_rapid_transitions(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that rapid binary sensor transitions are preserved with batch_delay: 0ms."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
# Track state changes
|
||||
state_changes: list[tuple[bool, float]] = []
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
"""Track state changes with timestamps."""
|
||||
if isinstance(state, BinarySensorState):
|
||||
state_changes.append((state.state, time.monotonic()))
|
||||
|
||||
# Subscribe to state changes
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Wait for entity info
|
||||
entity_info, _ = await client.list_entities_services()
|
||||
binary_sensors = [e for e in entity_info if isinstance(e, BinarySensorInfo)]
|
||||
assert len(binary_sensors) == 1, "Expected 1 binary sensor"
|
||||
|
||||
# Collect states for 2 seconds
|
||||
await asyncio.sleep(2.1)
|
||||
|
||||
# Count ON->OFF transitions
|
||||
on_off_count = 0
|
||||
for i in range(1, len(state_changes)):
|
||||
if state_changes[i - 1][0] and not state_changes[i][0]: # ON to OFF
|
||||
on_off_count += 1
|
||||
|
||||
# With batch_delay: 0, we should capture rapid transitions
|
||||
# The test timing can be variable in CI, so we're being conservative
|
||||
# We mainly want to verify that we capture multiple rapid transitions
|
||||
assert on_off_count >= 5, (
|
||||
f"Expected at least 5 ON->OFF transitions with batch_delay: 0ms, got {on_off_count}. "
|
||||
"Rapid transitions may have been lost."
|
||||
)
|
||||
|
||||
# Also verify that state changes are happening frequently
|
||||
assert len(state_changes) >= 10, (
|
||||
f"Expected at least 10 state changes, got {len(state_changes)}"
|
||||
)
|
@@ -74,37 +74,41 @@ async def test_host_mode_empty_string_options(
|
||||
# If we got here without protobuf decoding errors, the fix is working
|
||||
# The bug would have caused "Invalid protobuf message" errors with trailing bytes
|
||||
|
||||
# Also verify we can interact with the select entities
|
||||
# Subscribe to state changes
|
||||
# Also verify we can receive state updates for select entities
|
||||
# This ensures empty strings work properly in state messages too
|
||||
states: dict[int, EntityState] = {}
|
||||
state_change_future: asyncio.Future[None] = loop.create_future()
|
||||
states_received_future: asyncio.Future[None] = loop.create_future()
|
||||
expected_select_keys = {empty_first.key, empty_middle.key, empty_last.key}
|
||||
received_select_keys = set()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
"""Track state changes."""
|
||||
states[state.key] = state
|
||||
# When we receive the state change for our select, resolve the future
|
||||
if state.key == empty_first.key and not state_change_future.done():
|
||||
state_change_future.set_result(None)
|
||||
# Track which select entities we've received states for
|
||||
if state.key in expected_select_keys:
|
||||
received_select_keys.add(state.key)
|
||||
# Once we have all select states, we're done
|
||||
if (
|
||||
received_select_keys == expected_select_keys
|
||||
and not states_received_future.done()
|
||||
):
|
||||
states_received_future.set_result(None)
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Try setting a select to an empty string option
|
||||
# This further tests that empty strings are handled correctly
|
||||
client.select_command(empty_first.key, "")
|
||||
|
||||
# Wait for state update with timeout
|
||||
# Wait for initial states with timeout
|
||||
try:
|
||||
await asyncio.wait_for(state_change_future, timeout=5.0)
|
||||
await asyncio.wait_for(states_received_future, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail(
|
||||
"Did not receive state update after setting select to empty string"
|
||||
f"Did not receive states for all select entities. "
|
||||
f"Expected keys: {expected_select_keys}, Received: {received_select_keys}"
|
||||
)
|
||||
|
||||
# Verify the state was set to empty string
|
||||
# Verify we received states for all select entities
|
||||
assert empty_first.key in states
|
||||
select_state = states[empty_first.key]
|
||||
assert hasattr(select_state, "state")
|
||||
assert select_state.state == ""
|
||||
assert empty_middle.key in states
|
||||
assert empty_last.key in states
|
||||
|
||||
# The test passes if no protobuf decoding errors occurred
|
||||
# With the bug, we would have gotten "Invalid protobuf message" errors
|
||||
# The main test is that we got here without protobuf errors
|
||||
# The select entities with empty string options were properly encoded
|
||||
|
@@ -46,14 +46,22 @@ async def test_host_mode_fan_preset(
|
||||
# Subscribe to states
|
||||
states: dict[int, FanState] = {}
|
||||
state_event = asyncio.Event()
|
||||
initial_states_received = set()
|
||||
|
||||
def on_state(state: FanState) -> None:
|
||||
if isinstance(state, FanState):
|
||||
states[state.key] = state
|
||||
initial_states_received.add(state.key)
|
||||
state_event.set()
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Wait for initial states to be received for all fans
|
||||
expected_fan_keys = {fan.key for fan in fans}
|
||||
while initial_states_received != expected_fan_keys:
|
||||
state_event.clear()
|
||||
await asyncio.wait_for(state_event.wait(), timeout=2.0)
|
||||
|
||||
# Test 1: Turn on fan without speed or preset - should set speed to 100%
|
||||
state_event.clear()
|
||||
client.fan_command(
|
||||
|
@@ -22,36 +22,51 @@ async def test_host_mode_many_entities(
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
# Subscribe to state changes
|
||||
states: dict[int, EntityState] = {}
|
||||
entity_count_future: asyncio.Future[int] = loop.create_future()
|
||||
sensor_count_future: asyncio.Future[int] = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
states[state.key] = state
|
||||
# When we have received states from a good number of entities, resolve the future
|
||||
if len(states) >= 50 and not entity_count_future.done():
|
||||
entity_count_future.set_result(len(states))
|
||||
# Count sensor states specifically
|
||||
sensor_states = [
|
||||
s
|
||||
for s in states.values()
|
||||
if hasattr(s, "state") and isinstance(s.state, float)
|
||||
]
|
||||
# When we have received states from at least 50 sensors, resolve the future
|
||||
if len(sensor_states) >= 50 and not sensor_count_future.done():
|
||||
sensor_count_future.set_result(len(sensor_states))
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Wait for states from at least 50 entities with timeout
|
||||
# Wait for states from at least 50 sensors with timeout
|
||||
try:
|
||||
entity_count = await asyncio.wait_for(entity_count_future, timeout=10.0)
|
||||
sensor_count = await asyncio.wait_for(sensor_count_future, timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
sensor_states = [
|
||||
s
|
||||
for s in states.values()
|
||||
if hasattr(s, "state") and isinstance(s.state, float)
|
||||
]
|
||||
pytest.fail(
|
||||
f"Did not receive states from at least 50 entities within 10 seconds. "
|
||||
f"Received {len(states)} states: {list(states.keys())}"
|
||||
f"Did not receive states from at least 50 sensors within 10 seconds. "
|
||||
f"Received {len(sensor_states)} sensor states out of {len(states)} total states"
|
||||
)
|
||||
|
||||
# Verify we received a good number of entity states
|
||||
assert entity_count >= 50, f"Expected at least 50 entities, got {entity_count}"
|
||||
assert len(states) >= 50, f"Expected at least 50 states, got {len(states)}"
|
||||
assert len(states) >= 50, (
|
||||
f"Expected at least 50 total states, got {len(states)}"
|
||||
)
|
||||
|
||||
# Verify we have different entity types by checking some expected values
|
||||
# Verify we have the expected sensor states
|
||||
sensor_states = [
|
||||
s
|
||||
for s in states.values()
|
||||
if hasattr(s, "state") and isinstance(s.state, float)
|
||||
]
|
||||
|
||||
assert sensor_count >= 50, (
|
||||
f"Expected at least 50 sensor states, got {sensor_count}"
|
||||
)
|
||||
assert len(sensor_states) >= 50, (
|
||||
f"Expected at least 50 sensor states, got {len(sensor_states)}"
|
||||
)
|
||||
|
Reference in New Issue
Block a user