1
0
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:
J. Nick Koston
2025-07-02 21:50:53 -05:00
committed by GitHub
parent 798eef41b9
commit 34db02661c
7 changed files with 261 additions and 75 deletions

View File

@@ -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;
}

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

View File

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

View File

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

View File

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