mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 22:53:59 +00: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