mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 07:03:55 +00:00 
			
		
		
		
	Merge remote-tracking branch 'upstream/dev' into ruff_ret
This commit is contained in:
		
							
								
								
									
										311
									
								
								tests/integration/fixtures/api_homeassistant.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										311
									
								
								tests/integration/fixtures/api_homeassistant.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,311 @@ | ||||
| esphome: | ||||
|   name: test-ha-api | ||||
|   friendly_name: Home Assistant API Test | ||||
|  | ||||
| host: | ||||
|  | ||||
| api: | ||||
|   services: | ||||
|     - service: trigger_all_tests | ||||
|       then: | ||||
|         - logger.log: "=== Starting Home Assistant API Tests ===" | ||||
|         - button.press: test_basic_service | ||||
|         - button.press: test_templated_service | ||||
|         - button.press: test_empty_string_service | ||||
|         - button.press: test_multiple_fields_service | ||||
|         - button.press: test_complex_lambda_service | ||||
|         - button.press: test_all_empty_service | ||||
|         - button.press: test_rapid_service_calls | ||||
|         - button.press: test_read_ha_states | ||||
|         - number.set: | ||||
|             id: ha_number | ||||
|             value: 42.5 | ||||
|         - switch.turn_on: ha_switch | ||||
|         - switch.turn_off: ha_switch | ||||
|         - logger.log: "=== All tests completed ===" | ||||
|  | ||||
| logger: | ||||
|   level: DEBUG | ||||
|  | ||||
| # Time component for templated values | ||||
| time: | ||||
|   - platform: homeassistant | ||||
|     id: homeassistant_time | ||||
|  | ||||
| # Global variables for testing | ||||
| globals: | ||||
|   - id: test_brightness | ||||
|     type: int | ||||
|     initial_value: '75' | ||||
|   - id: test_string | ||||
|     type: std::string | ||||
|     initial_value: '"test_value"' | ||||
|  | ||||
| # Sensors for testing state reading | ||||
| sensor: | ||||
|   - platform: template | ||||
|     name: "Test Sensor" | ||||
|     id: test_sensor | ||||
|     lambda: return 42.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   # Home Assistant sensor that reads external state | ||||
|   - platform: homeassistant | ||||
|     name: "HA Temperature" | ||||
|     entity_id: sensor.external_temperature | ||||
|     id: ha_temperature | ||||
|     on_value: | ||||
|       then: | ||||
|         - logger.log: | ||||
|             format: "HA Temperature state updated: %.1f" | ||||
|             args: ['x'] | ||||
|  | ||||
|   # Test multiple HA state sensors | ||||
|   - platform: homeassistant | ||||
|     name: "HA Humidity" | ||||
|     entity_id: sensor.external_humidity | ||||
|     id: ha_humidity | ||||
|     on_value: | ||||
|       then: | ||||
|         - logger.log: | ||||
|             format: "HA Humidity state updated: %.1f" | ||||
|             args: ['x'] | ||||
|  | ||||
| # Binary sensor from Home Assistant | ||||
| binary_sensor: | ||||
|   - platform: homeassistant | ||||
|     name: "HA Motion" | ||||
|     entity_id: binary_sensor.external_motion | ||||
|     id: ha_motion | ||||
|     on_state: | ||||
|       then: | ||||
|         - logger.log: | ||||
|             format: "HA Motion state changed: %s" | ||||
|             args: ['x ? "ON" : "OFF"'] | ||||
|  | ||||
| # Text sensor from Home Assistant | ||||
| text_sensor: | ||||
|   - platform: homeassistant | ||||
|     name: "HA Weather" | ||||
|     entity_id: weather.home | ||||
|     attribute: condition | ||||
|     id: ha_weather | ||||
|     on_value: | ||||
|       then: | ||||
|         - logger.log: | ||||
|             format: "HA Weather condition updated: %s" | ||||
|             args: ['x.c_str()'] | ||||
|  | ||||
|   # Test empty state handling | ||||
|   - platform: homeassistant | ||||
|     name: "HA Empty State" | ||||
|     entity_id: sensor.nonexistent_sensor | ||||
|     id: ha_empty_state | ||||
|     on_value: | ||||
|       then: | ||||
|         - logger.log: | ||||
|             format: "HA Empty state updated: %s" | ||||
|             args: ['x.c_str()'] | ||||
|  | ||||
| # Number component for testing HA number control | ||||
| number: | ||||
|   - platform: template | ||||
|     name: "HA Controlled Number" | ||||
|     id: ha_number | ||||
|     min_value: 0 | ||||
|     max_value: 100 | ||||
|     step: 1 | ||||
|     optimistic: true | ||||
|     set_action: | ||||
|       - logger.log: | ||||
|           format: "Setting HA number to: %.1f" | ||||
|           args: ['x'] | ||||
|       - homeassistant.action: | ||||
|           action: input_number.set_value | ||||
|           data: | ||||
|             entity_id: input_number.test_number | ||||
|             value: !lambda 'return to_string(x);' | ||||
|  | ||||
| # Switch component for testing HA switch control | ||||
| switch: | ||||
|   - platform: template | ||||
|     name: "HA Controlled Switch" | ||||
|     id: ha_switch | ||||
|     optimistic: true | ||||
|     turn_on_action: | ||||
|       - logger.log: "Toggling HA switch: switch.test_switch ON" | ||||
|       - homeassistant.action: | ||||
|           action: switch.turn_on | ||||
|           data: | ||||
|             entity_id: switch.test_switch | ||||
|     turn_off_action: | ||||
|       - logger.log: "Toggling HA switch: switch.test_switch OFF" | ||||
|       - homeassistant.action: | ||||
|           action: switch.turn_off | ||||
|           data: | ||||
|             entity_id: switch.test_switch | ||||
|  | ||||
| # Buttons for testing various service call scenarios | ||||
| button: | ||||
|   # Test 1: Basic service call with static values | ||||
|   - platform: template | ||||
|     name: "Test Basic Service" | ||||
|     id: test_basic_service | ||||
|     on_press: | ||||
|       - logger.log: "Sending HomeAssistant service call: light.turn_off" | ||||
|       - homeassistant.action: | ||||
|           action: light.turn_off | ||||
|           data: | ||||
|             entity_id: light.test_light | ||||
|       - logger.log: "Service data: entity_id=light.test_light" | ||||
|  | ||||
|   # Test 2: Service call with templated/lambda values (main bug fix test) | ||||
|   - platform: template | ||||
|     name: "Test Templated Service" | ||||
|     id: test_templated_service | ||||
|     on_press: | ||||
|       - logger.log: "Testing templated service call" | ||||
|       - lambda: |- | ||||
|           int brightness_percent = id(test_brightness); | ||||
|           std::string computed = to_string(brightness_percent * 255 / 100); | ||||
|           ESP_LOGI("test", "Lambda computed value: %s", computed.c_str()); | ||||
|       - homeassistant.action: | ||||
|           action: light.turn_on | ||||
|           data: | ||||
|             entity_id: light.test_light | ||||
|             # This creates a temporary string - the main test case | ||||
|             brightness: !lambda 'return to_string(id(test_brightness) * 255 / 100);' | ||||
|           data_template: | ||||
|             color_name: !lambda 'return id(test_string);' | ||||
|           variables: | ||||
|             transition: !lambda 'return "2.5";' | ||||
|  | ||||
|   # Test 3: Service call with empty string values | ||||
|   - platform: template | ||||
|     name: "Test Empty String Service" | ||||
|     id: test_empty_string_service | ||||
|     on_press: | ||||
|       - logger.log: "Testing empty string values" | ||||
|       - homeassistant.action: | ||||
|           action: notify.test | ||||
|           data: | ||||
|             message: "Test message" | ||||
|             title: "" | ||||
|           data_template: | ||||
|             target: !lambda 'return "";' | ||||
|           variables: | ||||
|             sound: !lambda 'return "";' | ||||
|  | ||||
|       - logger.log: "Empty value for key: title" | ||||
|       - logger.log: "Empty value for key: target" | ||||
|       - logger.log: "Empty value for key: sound" | ||||
|  | ||||
|   # Test 4: Service call with multiple data fields | ||||
|   - platform: template | ||||
|     name: "Test Multiple Fields Service" | ||||
|     id: test_multiple_fields_service | ||||
|     on_press: | ||||
|       - logger.log: "Testing multiple data fields" | ||||
|       - homeassistant.action: | ||||
|           action: climate.set_temperature | ||||
|           data: | ||||
|             entity_id: climate.test_climate | ||||
|             temperature: "22" | ||||
|             hvac_mode: "heat" | ||||
|           data_template: | ||||
|             target_temp_high: !lambda 'return "24";' | ||||
|             target_temp_low: !lambda 'return "20";' | ||||
|           variables: | ||||
|             preset_mode: !lambda 'return "comfort";' | ||||
|  | ||||
|   # Test 5: Complex lambda with string operations | ||||
|   - platform: template | ||||
|     name: "Test Complex Lambda Service" | ||||
|     id: test_complex_lambda_service | ||||
|     on_press: | ||||
|       - logger.log: "Testing complex lambda expressions" | ||||
|       - homeassistant.action: | ||||
|           action: script.test_script | ||||
|           data: | ||||
|             entity_id: !lambda |- | ||||
|               std::string base = "light."; | ||||
|               std::string room = "living_room"; | ||||
|               return base + room; | ||||
|             brightness_pct: !lambda |- | ||||
|               float sensor_val = id(test_sensor).state; | ||||
|               int pct = (int)(sensor_val * 2.38);  // 42 * 2.38 ≈ 100 | ||||
|               return to_string(pct); | ||||
|           data_template: | ||||
|             message: !lambda |- | ||||
|               char buffer[50]; | ||||
|               snprintf(buffer, sizeof(buffer), "Sensor: %.1f, Time: %02d:%02d", | ||||
|                        id(test_sensor).state, | ||||
|                        id(homeassistant_time).now().hour, | ||||
|                        id(homeassistant_time).now().minute); | ||||
|               return std::string(buffer); | ||||
|  | ||||
|   # Test 6: Service with only empty strings to verify size calculation | ||||
|   - platform: template | ||||
|     name: "Test All Empty Service" | ||||
|     id: test_all_empty_service | ||||
|     on_press: | ||||
|       - logger.log: "Testing all empty string values" | ||||
|       - homeassistant.action: | ||||
|           action: test.empty | ||||
|           data: | ||||
|             field1: "" | ||||
|             field2: "" | ||||
|           data_template: | ||||
|             field3: !lambda 'return "";' | ||||
|           variables: | ||||
|             field4: !lambda 'return "";' | ||||
|       - logger.log: "All empty service call completed" | ||||
|  | ||||
|   # Test 7: Rapid successive service calls | ||||
|   - platform: template | ||||
|     name: "Test Rapid Service Calls" | ||||
|     id: test_rapid_service_calls | ||||
|     on_press: | ||||
|       - logger.log: "Testing rapid service calls" | ||||
|       - repeat: | ||||
|           count: 5 | ||||
|           then: | ||||
|             - homeassistant.action: | ||||
|                 action: counter.increment | ||||
|                 data: | ||||
|                   entity_id: counter.test_counter | ||||
|             - delay: 10ms | ||||
|       - logger.log: "Rapid service calls completed" | ||||
|  | ||||
|   # Test 8: Log current HA states | ||||
|   - platform: template | ||||
|     name: "Test Read HA States" | ||||
|     id: test_read_ha_states | ||||
|     on_press: | ||||
|       - logger.log: "Reading current HA states" | ||||
|       - lambda: |- | ||||
|           if (id(ha_temperature).has_state()) { | ||||
|             ESP_LOGI("test", "Current HA Temperature: %.1f", id(ha_temperature).state); | ||||
|           } else { | ||||
|             ESP_LOGI("test", "HA Temperature has no state"); | ||||
|           } | ||||
|  | ||||
|           if (id(ha_humidity).has_state()) { | ||||
|             ESP_LOGI("test", "Current HA Humidity: %.1f", id(ha_humidity).state); | ||||
|           } else { | ||||
|             ESP_LOGI("test", "HA Humidity has no state"); | ||||
|           } | ||||
|  | ||||
|           ESP_LOGI("test", "Current HA Motion: %s", id(ha_motion).state ? "ON" : "OFF"); | ||||
|  | ||||
|           if (id(ha_weather).has_state()) { | ||||
|             ESP_LOGI("test", "Current HA Weather: %s", id(ha_weather).state.c_str()); | ||||
|           } else { | ||||
|             ESP_LOGI("test", "HA Weather has no state"); | ||||
|           } | ||||
|  | ||||
|           if (id(ha_empty_state).has_state()) { | ||||
|             ESP_LOGI("test", "HA Empty State value: %s", id(ha_empty_state).state.c_str()); | ||||
|           } else { | ||||
|             ESP_LOGI("test", "HA Empty State has no value (expected)"); | ||||
|           } | ||||
| @@ -210,6 +210,15 @@ sensor: | ||||
|     name: "Test Sensor 50" | ||||
|     lambda: return 50.0; | ||||
|     update_interval: 0.1s | ||||
|   # Temperature sensor for the thermostat | ||||
|   - platform: template | ||||
|     name: "Temperature Sensor" | ||||
|     id: temp_sensor | ||||
|     lambda: return 22.5; | ||||
|     unit_of_measurement: "°C" | ||||
|     device_class: temperature | ||||
|     state_class: measurement | ||||
|     update_interval: 5s | ||||
|  | ||||
| # Mixed entity types for comprehensive batching test | ||||
| binary_sensor: | ||||
| @@ -285,6 +294,50 @@ valve: | ||||
|     stop_action: | ||||
|       - logger.log: "Valve stopping" | ||||
|  | ||||
| output: | ||||
|   - platform: template | ||||
|     id: heater_output | ||||
|     type: binary | ||||
|     write_action: | ||||
|       - logger.log: "Heater output changed" | ||||
|   - platform: template | ||||
|     id: cooler_output | ||||
|     type: binary | ||||
|     write_action: | ||||
|       - logger.log: "Cooler output changed" | ||||
|  | ||||
| climate: | ||||
|   - platform: thermostat | ||||
|     name: "Test Thermostat" | ||||
|     sensor: temp_sensor | ||||
|     default_preset: Home | ||||
|     on_boot_restore_from: default_preset | ||||
|     min_heating_off_time: 1s | ||||
|     min_heating_run_time: 1s | ||||
|     min_cooling_off_time: 1s | ||||
|     min_cooling_run_time: 1s | ||||
|     min_idle_time: 1s | ||||
|     heat_action: | ||||
|       - output.turn_on: heater_output | ||||
|     cool_action: | ||||
|       - output.turn_on: cooler_output | ||||
|     idle_action: | ||||
|       - output.turn_off: heater_output | ||||
|       - output.turn_off: cooler_output | ||||
|     preset: | ||||
|       - name: Home | ||||
|         default_target_temperature_low: 20 | ||||
|         default_target_temperature_high: 24 | ||||
|         mode: heat_cool | ||||
|       - name: Away | ||||
|         default_target_temperature_low: 16 | ||||
|         default_target_temperature_high: 26 | ||||
|         mode: heat_cool | ||||
|       - name: Sleep | ||||
|         default_target_temperature_low: 18 | ||||
|         default_target_temperature_high: 22 | ||||
|         mode: heat_cool | ||||
|  | ||||
| alarm_control_panel: | ||||
|   - platform: template | ||||
|     name: "Test Alarm" | ||||
|   | ||||
| @@ -37,6 +37,15 @@ globals: | ||||
|   - id: multiple_same_name_counter | ||||
|     type: int | ||||
|     initial_value: '0' | ||||
|   - id: const_char_retry_counter | ||||
|     type: int | ||||
|     initial_value: '0' | ||||
|   - id: static_char_retry_counter | ||||
|     type: int | ||||
|     initial_value: '0' | ||||
|   - id: mixed_cancel_result | ||||
|     type: bool | ||||
|     initial_value: 'false' | ||||
|  | ||||
| # Using different component types for each test to ensure isolation | ||||
| sensor: | ||||
| @@ -229,6 +238,56 @@ script: | ||||
|               return RetryResult::RETRY; | ||||
|             }); | ||||
|  | ||||
|       # Test 8: Const char* overloads | ||||
|       - logger.log: "=== Test 8: Const char* overloads ===" | ||||
|       - lambda: |- | ||||
|           auto *component = id(simple_retry_sensor); | ||||
|  | ||||
|           // Test 8a: Direct string literal | ||||
|           App.scheduler.set_retry(component, "const_char_test", 30, 2, | ||||
|             [](uint8_t retry_countdown) { | ||||
|               id(const_char_retry_counter)++; | ||||
|               ESP_LOGI("test", "Const char retry %d", id(const_char_retry_counter)); | ||||
|               return RetryResult::DONE; | ||||
|             }); | ||||
|  | ||||
|       # Test 9: Static const char* variable | ||||
|       - logger.log: "=== Test 9: Static const char* ===" | ||||
|       - lambda: |- | ||||
|           auto *component = id(backoff_retry_sensor); | ||||
|  | ||||
|           static const char* STATIC_NAME = "static_retry_test"; | ||||
|           App.scheduler.set_retry(component, STATIC_NAME, 20, 1, | ||||
|             [](uint8_t retry_countdown) { | ||||
|               id(static_char_retry_counter)++; | ||||
|               ESP_LOGI("test", "Static const char retry %d", id(static_char_retry_counter)); | ||||
|               return RetryResult::DONE; | ||||
|             }); | ||||
|  | ||||
|           // Cancel with same static const char* | ||||
|           App.scheduler.set_timeout(component, "static_cancel", 10, []() { | ||||
|             static const char* STATIC_NAME = "static_retry_test"; | ||||
|             bool result = App.scheduler.cancel_retry(id(backoff_retry_sensor), STATIC_NAME); | ||||
|             ESP_LOGI("test", "Static cancel result: %s", result ? "true" : "false"); | ||||
|           }); | ||||
|  | ||||
|       # Test 10: Mix string and const char* cancel | ||||
|       - logger.log: "=== Test 10: Mixed string/const char* ===" | ||||
|       - lambda: |- | ||||
|           auto *component = id(immediate_done_sensor); | ||||
|  | ||||
|           // Set with std::string | ||||
|           std::string str_name = "mixed_retry"; | ||||
|           App.scheduler.set_retry(component, str_name, 40, 3, | ||||
|             [](uint8_t retry_countdown) { | ||||
|               ESP_LOGI("test", "Mixed retry - should be cancelled"); | ||||
|               return RetryResult::RETRY; | ||||
|             }); | ||||
|  | ||||
|           // Cancel with const char* | ||||
|           id(mixed_cancel_result) = App.scheduler.cancel_retry(component, "mixed_retry"); | ||||
|           ESP_LOGI("test", "Mixed cancel result: %s", id(mixed_cancel_result) ? "true" : "false"); | ||||
|  | ||||
|       # Wait for all tests to complete before reporting | ||||
|       - delay: 500ms | ||||
|  | ||||
| @@ -242,4 +301,7 @@ script: | ||||
|           ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter)); | ||||
|           ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter)); | ||||
|           ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter)); | ||||
|           ESP_LOGI("test", "Const char retry counter: %d (expected 1)", id(const_char_retry_counter)); | ||||
|           ESP_LOGI("test", "Static char retry counter: %d (expected 1)", id(static_char_retry_counter)); | ||||
|           ESP_LOGI("test", "Mixed cancel result: %s (expected true)", id(mixed_cancel_result) ? "true" : "false"); | ||||
|           ESP_LOGI("test", "All retry tests completed"); | ||||
|   | ||||
							
								
								
									
										305
									
								
								tests/integration/test_api_homeassistant.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										305
									
								
								tests/integration/test_api_homeassistant.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,305 @@ | ||||
| """Integration test for Home Assistant API functionality. | ||||
|  | ||||
| Tests: | ||||
| - Home Assistant service calls with templated values (main bug fix) | ||||
| - Service calls with empty string values | ||||
| - Home Assistant state reading (sensors, binary sensors, text sensors) | ||||
| - Home Assistant number and switch component control | ||||
| - Complex lambda expressions and string handling | ||||
| """ | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
| import re | ||||
|  | ||||
| from aioesphomeapi import HomeassistantServiceCall | ||||
| import pytest | ||||
|  | ||||
| from .types import APIClientConnectedFactory, RunCompiledFunction | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_api_homeassistant( | ||||
|     yaml_config: str, | ||||
|     run_compiled: RunCompiledFunction, | ||||
|     api_client_connected: APIClientConnectedFactory, | ||||
| ) -> None: | ||||
|     """Comprehensive test for Home Assistant API functionality.""" | ||||
|     loop = asyncio.get_running_loop() | ||||
|  | ||||
|     # Create futures for patterns that capture values | ||||
|     lambda_computed_future = loop.create_future() | ||||
|     ha_temp_state_future = loop.create_future() | ||||
|     ha_humidity_state_future = loop.create_future() | ||||
|     ha_motion_state_future = loop.create_future() | ||||
|     ha_weather_state_future = loop.create_future() | ||||
|  | ||||
|     # State update futures | ||||
|     temp_update_future = loop.create_future() | ||||
|     humidity_update_future = loop.create_future() | ||||
|     motion_update_future = loop.create_future() | ||||
|     weather_update_future = loop.create_future() | ||||
|  | ||||
|     # Number future | ||||
|     ha_number_future = loop.create_future() | ||||
|  | ||||
|     tests_complete_future = loop.create_future() | ||||
|  | ||||
|     # Patterns to match in logs - only keeping patterns that capture values | ||||
|     lambda_computed_pattern = re.compile(r"Lambda computed value: (\d+)") | ||||
|     ha_temp_state_pattern = re.compile(r"Current HA Temperature: ([\d.]+)") | ||||
|     ha_humidity_state_pattern = re.compile(r"Current HA Humidity: ([\d.]+)") | ||||
|     ha_motion_state_pattern = re.compile(r"Current HA Motion: (ON|OFF)") | ||||
|     ha_weather_state_pattern = re.compile(r"Current HA Weather: (\w+)") | ||||
|  | ||||
|     # State update patterns | ||||
|     temp_update_pattern = re.compile(r"HA Temperature state updated: ([\d.]+)") | ||||
|     humidity_update_pattern = re.compile(r"HA Humidity state updated: ([\d.]+)") | ||||
|     motion_update_pattern = re.compile(r"HA Motion state changed: (ON|OFF)") | ||||
|     weather_update_pattern = re.compile(r"HA Weather condition updated: (\w+)") | ||||
|  | ||||
|     # Number pattern | ||||
|     ha_number_pattern = re.compile(r"Setting HA number to: ([\d.]+)") | ||||
|  | ||||
|     tests_complete_pattern = re.compile(r"=== All tests completed ===") | ||||
|  | ||||
|     # Track all log lines for debugging | ||||
|     log_lines: list[str] = [] | ||||
|  | ||||
|     # Track HomeAssistant service calls | ||||
|     ha_service_calls: list[HomeassistantServiceCall] = [] | ||||
|  | ||||
|     # Service call futures organized by service name | ||||
|     service_call_futures = { | ||||
|         "light.turn_off": loop.create_future(),  # basic_service_call | ||||
|         "light.turn_on": loop.create_future(),  # templated_service_call | ||||
|         "notify.test": loop.create_future(),  # empty_string_service_call | ||||
|         "climate.set_temperature": loop.create_future(),  # multiple_fields_service_call | ||||
|         "script.test_script": loop.create_future(),  # complex_lambda_service_call | ||||
|         "test.empty": loop.create_future(),  # all_empty_service_call | ||||
|         "input_number.set_value": loop.create_future(),  # ha_number_service_call | ||||
|         "switch.turn_on": loop.create_future(),  # ha_switch_on_service_call | ||||
|         "switch.turn_off": loop.create_future(),  # ha_switch_off_service_call | ||||
|     } | ||||
|  | ||||
|     def on_service_call(service_call: HomeassistantServiceCall) -> None: | ||||
|         """Capture HomeAssistant service calls.""" | ||||
|         ha_service_calls.append(service_call) | ||||
|  | ||||
|         # Check if this service call is one we're waiting for | ||||
|         if service_call.service in service_call_futures: | ||||
|             future = service_call_futures[service_call.service] | ||||
|             if not future.done(): | ||||
|                 future.set_result(service_call) | ||||
|  | ||||
|     def check_output(line: str) -> None: | ||||
|         """Check log output for expected messages.""" | ||||
|         log_lines.append(line) | ||||
|  | ||||
|         # Check for patterns that capture values | ||||
|         if not lambda_computed_future.done(): | ||||
|             match = lambda_computed_pattern.search(line) | ||||
|             if match: | ||||
|                 lambda_computed_future.set_result(match.group(1)) | ||||
|         elif not ha_temp_state_future.done() and ha_temp_state_pattern.search(line): | ||||
|             ha_temp_state_future.set_result(line) | ||||
|         elif not ha_humidity_state_future.done() and ha_humidity_state_pattern.search( | ||||
|             line | ||||
|         ): | ||||
|             ha_humidity_state_future.set_result(line) | ||||
|         elif not ha_motion_state_future.done() and ha_motion_state_pattern.search(line): | ||||
|             ha_motion_state_future.set_result(line) | ||||
|         elif not ha_weather_state_future.done() and ha_weather_state_pattern.search( | ||||
|             line | ||||
|         ): | ||||
|             ha_weather_state_future.set_result(line) | ||||
|  | ||||
|         # Check state update patterns | ||||
|         elif not temp_update_future.done() and temp_update_pattern.search(line): | ||||
|             temp_update_future.set_result(line) | ||||
|         elif not humidity_update_future.done() and humidity_update_pattern.search(line): | ||||
|             humidity_update_future.set_result(line) | ||||
|         elif not motion_update_future.done() and motion_update_pattern.search(line): | ||||
|             motion_update_future.set_result(line) | ||||
|         elif not weather_update_future.done() and weather_update_pattern.search(line): | ||||
|             weather_update_future.set_result(line) | ||||
|  | ||||
|         # Check number pattern | ||||
|         elif not ha_number_future.done() and ha_number_pattern.search(line): | ||||
|             match = ha_number_pattern.search(line) | ||||
|             if match: | ||||
|                 ha_number_future.set_result(match.group(1)) | ||||
|  | ||||
|         elif not tests_complete_future.done() and tests_complete_pattern.search(line): | ||||
|             tests_complete_future.set_result(True) | ||||
|  | ||||
|     # Run with log monitoring | ||||
|     async with ( | ||||
|         run_compiled(yaml_config, line_callback=check_output), | ||||
|         api_client_connected() as client, | ||||
|     ): | ||||
|         # Verify device info | ||||
|         device_info = await client.device_info() | ||||
|         assert device_info is not None | ||||
|         assert device_info.name == "test-ha-api" | ||||
|  | ||||
|         # Subscribe to HomeAssistant service calls | ||||
|         client.subscribe_service_calls(on_service_call) | ||||
|  | ||||
|         # Send some Home Assistant states for our sensors to read | ||||
|         client.send_home_assistant_state("sensor.external_temperature", "", "22.5") | ||||
|         client.send_home_assistant_state("sensor.external_humidity", "", "65.0") | ||||
|         client.send_home_assistant_state("binary_sensor.external_motion", "", "ON") | ||||
|         client.send_home_assistant_state("weather.home", "condition", "sunny") | ||||
|  | ||||
|         # List entities and services | ||||
|         _, services = await client.list_entities_services() | ||||
|  | ||||
|         # Find the trigger service | ||||
|         trigger_service = next( | ||||
|             (s for s in services if s.name == "trigger_all_tests"), None | ||||
|         ) | ||||
|         assert trigger_service is not None, "trigger_all_tests service not found" | ||||
|  | ||||
|         # Execute all tests | ||||
|         client.execute_service(trigger_service, {}) | ||||
|  | ||||
|         # Wait for all tests to complete with appropriate timeouts | ||||
|         try: | ||||
|             # Templated service test - the main bug fix | ||||
|             computed_value = await asyncio.wait_for(lambda_computed_future, timeout=5.0) | ||||
|             # Verify the computed value is reasonable (75 * 255 / 100 = 191.25 -> 191) | ||||
|             assert computed_value in ["191", "192"], ( | ||||
|                 f"Unexpected computed value: {computed_value}" | ||||
|             ) | ||||
|  | ||||
|             # Check state reads - verify we received the mocked values | ||||
|             temp_line = await asyncio.wait_for(ha_temp_state_future, timeout=5.0) | ||||
|             assert "Current HA Temperature: 22.5" in temp_line | ||||
|  | ||||
|             humidity_line = await asyncio.wait_for( | ||||
|                 ha_humidity_state_future, timeout=5.0 | ||||
|             ) | ||||
|             assert "Current HA Humidity: 65.0" in humidity_line | ||||
|  | ||||
|             motion_line = await asyncio.wait_for(ha_motion_state_future, timeout=5.0) | ||||
|             assert "Current HA Motion: ON" in motion_line | ||||
|  | ||||
|             weather_line = await asyncio.wait_for(ha_weather_state_future, timeout=5.0) | ||||
|             assert "Current HA Weather: sunny" in weather_line | ||||
|  | ||||
|             # Number test | ||||
|             number_value = await asyncio.wait_for(ha_number_future, timeout=5.0) | ||||
|             assert number_value == "42.5", f"Unexpected number value: {number_value}" | ||||
|  | ||||
|             # Wait for completion | ||||
|             await asyncio.wait_for(tests_complete_future, timeout=5.0) | ||||
|  | ||||
|             # Now verify the protobuf messages | ||||
|             # 1. Basic service call | ||||
|             basic_call = await asyncio.wait_for( | ||||
|                 service_call_futures["light.turn_off"], timeout=2.0 | ||||
|             ) | ||||
|             assert basic_call.service == "light.turn_off" | ||||
|             assert "entity_id" in basic_call.data, ( | ||||
|                 f"entity_id not found in data: {basic_call.data}" | ||||
|             ) | ||||
|             assert basic_call.data["entity_id"] == "light.test_light", ( | ||||
|                 f"Wrong entity_id: {basic_call.data['entity_id']}" | ||||
|             ) | ||||
|  | ||||
|             # 2. Templated service call - verify the temporary string issue is fixed | ||||
|             templated_call = await asyncio.wait_for( | ||||
|                 service_call_futures["light.turn_on"], timeout=2.0 | ||||
|             ) | ||||
|             assert templated_call.service == "light.turn_on" | ||||
|             # Check the computed brightness value | ||||
|             assert "brightness" in templated_call.data | ||||
|             assert templated_call.data["brightness"] in ["191", "192"]  # 75 * 255 / 100 | ||||
|             # Check data_template | ||||
|             assert "color_name" in templated_call.data_template | ||||
|             assert templated_call.data_template["color_name"] == "test_value" | ||||
|             # Check variables | ||||
|             assert "transition" in templated_call.variables | ||||
|             assert templated_call.variables["transition"] == "2.5" | ||||
|  | ||||
|             # 3. Empty string service call | ||||
|             empty_call = await asyncio.wait_for( | ||||
|                 service_call_futures["notify.test"], timeout=2.0 | ||||
|             ) | ||||
|             assert empty_call.service == "notify.test" | ||||
|             # Verify empty strings are properly handled | ||||
|             assert "title" in empty_call.data and empty_call.data["title"] == "" | ||||
|             assert ( | ||||
|                 "target" in empty_call.data_template | ||||
|                 and empty_call.data_template["target"] == "" | ||||
|             ) | ||||
|             assert ( | ||||
|                 "sound" in empty_call.variables and empty_call.variables["sound"] == "" | ||||
|             ) | ||||
|  | ||||
|             # 4. Multiple fields service call | ||||
|             multi_call = await asyncio.wait_for( | ||||
|                 service_call_futures["climate.set_temperature"], timeout=2.0 | ||||
|             ) | ||||
|             assert multi_call.service == "climate.set_temperature" | ||||
|             assert multi_call.data["temperature"] == "22" | ||||
|             assert multi_call.data["hvac_mode"] == "heat" | ||||
|             assert multi_call.data_template["target_temp_high"] == "24" | ||||
|             assert multi_call.variables["preset_mode"] == "comfort" | ||||
|  | ||||
|             # 5. Complex lambda service call | ||||
|             complex_call = await asyncio.wait_for( | ||||
|                 service_call_futures["script.test_script"], timeout=2.0 | ||||
|             ) | ||||
|             assert complex_call.service == "script.test_script" | ||||
|             assert complex_call.data["entity_id"] == "light.living_room" | ||||
|             assert complex_call.data["brightness_pct"] == "99"  # 42 * 2.38 ≈ 99 | ||||
|             # Check message includes sensor value | ||||
|             assert "message" in complex_call.data_template | ||||
|             assert "Sensor: 42.0" in complex_call.data_template["message"] | ||||
|  | ||||
|             # 6. All empty service call | ||||
|             all_empty_call = await asyncio.wait_for( | ||||
|                 service_call_futures["test.empty"], timeout=2.0 | ||||
|             ) | ||||
|             assert all_empty_call.service == "test.empty" | ||||
|             # All fields should be empty strings | ||||
|             assert all(v == "" for v in all_empty_call.data.values()) | ||||
|             assert all(v == "" for v in all_empty_call.data_template.values()) | ||||
|             assert all(v == "" for v in all_empty_call.variables.values()) | ||||
|  | ||||
|             # 7. HA Number service call | ||||
|             number_call = await asyncio.wait_for( | ||||
|                 service_call_futures["input_number.set_value"], timeout=2.0 | ||||
|             ) | ||||
|             assert number_call.service == "input_number.set_value" | ||||
|             assert number_call.data["entity_id"] == "input_number.test_number" | ||||
|             # The value might be formatted with trailing zeros | ||||
|             assert float(number_call.data["value"]) == 42.5 | ||||
|  | ||||
|             # 8. HA Switch service calls | ||||
|             switch_on_call = await asyncio.wait_for( | ||||
|                 service_call_futures["switch.turn_on"], timeout=2.0 | ||||
|             ) | ||||
|             assert switch_on_call.service == "switch.turn_on" | ||||
|             assert switch_on_call.data["entity_id"] == "switch.test_switch" | ||||
|  | ||||
|             switch_off_call = await asyncio.wait_for( | ||||
|                 service_call_futures["switch.turn_off"], timeout=2.0 | ||||
|             ) | ||||
|             assert switch_off_call.service == "switch.turn_off" | ||||
|             assert switch_off_call.data["entity_id"] == "switch.test_switch" | ||||
|  | ||||
|         except TimeoutError as e: | ||||
|             # Show recent log lines for debugging | ||||
|             recent_logs = "\n".join(log_lines[-20:]) | ||||
|             service_calls_summary = "\n".join( | ||||
|                 f"- {call.service}" for call in ha_service_calls | ||||
|             ) | ||||
|             pytest.fail( | ||||
|                 f"Test timed out waiting for expected log pattern or service call. Error: {e}\n\n" | ||||
|                 f"Recent log lines:\n{recent_logs}\n\n" | ||||
|                 f"Received service calls:\n{service_calls_summary}" | ||||
|             ) | ||||
| @@ -4,7 +4,7 @@ from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
|  | ||||
| from aioesphomeapi import EntityState, SensorState | ||||
| from aioesphomeapi import ClimateInfo, EntityState, SensorState | ||||
| import pytest | ||||
|  | ||||
| from .types import APIClientConnectedFactory, RunCompiledFunction | ||||
| @@ -70,3 +70,22 @@ async def test_host_mode_many_entities( | ||||
|         assert len(sensor_states) >= 50, ( | ||||
|             f"Expected at least 50 sensor states, got {len(sensor_states)}" | ||||
|         ) | ||||
|  | ||||
|         # Get entity info to verify climate entity details | ||||
|         entities = await client.list_entities_services() | ||||
|         climate_infos = [e for e in entities[0] if isinstance(e, ClimateInfo)] | ||||
|         assert len(climate_infos) >= 1, "Expected at least 1 climate entity" | ||||
|  | ||||
|         climate_info = climate_infos[0] | ||||
|         # Verify the thermostat has presets | ||||
|         assert len(climate_info.supported_presets) > 0, ( | ||||
|             "Expected climate to have presets" | ||||
|         ) | ||||
|         # The thermostat platform uses standard presets (Home, Away, Sleep) | ||||
|         # which should be transmitted properly without string copies | ||||
|  | ||||
|         # Verify specific presets exist | ||||
|         preset_names = [p.name for p in climate_info.supported_presets] | ||||
|         assert "HOME" in preset_names, f"Expected 'HOME' preset, got {preset_names}" | ||||
|         assert "AWAY" in preset_names, f"Expected 'AWAY' preset, got {preset_names}" | ||||
|         assert "SLEEP" in preset_names, f"Expected 'SLEEP' preset, got {preset_names}" | ||||
|   | ||||
| @@ -23,6 +23,9 @@ async def test_scheduler_retry_test( | ||||
|     empty_name_retry_done = asyncio.Event() | ||||
|     component_retry_done = asyncio.Event() | ||||
|     multiple_name_done = asyncio.Event() | ||||
|     const_char_done = asyncio.Event() | ||||
|     static_char_done = asyncio.Event() | ||||
|     mixed_cancel_done = asyncio.Event() | ||||
|     test_complete = asyncio.Event() | ||||
|  | ||||
|     # Track retry counts | ||||
| @@ -33,16 +36,20 @@ async def test_scheduler_retry_test( | ||||
|     empty_name_retry_count = 0 | ||||
|     component_retry_count = 0 | ||||
|     multiple_name_count = 0 | ||||
|     const_char_retry_count = 0 | ||||
|     static_char_retry_count = 0 | ||||
|  | ||||
|     # Track specific test results | ||||
|     cancel_result = None | ||||
|     empty_cancel_result = None | ||||
|     mixed_cancel_result = None | ||||
|     backoff_intervals = [] | ||||
|  | ||||
|     def on_log_line(line: str) -> None: | ||||
|         nonlocal simple_retry_count, backoff_retry_count, immediate_done_count | ||||
|         nonlocal cancel_retry_count, empty_name_retry_count, component_retry_count | ||||
|         nonlocal multiple_name_count, cancel_result, empty_cancel_result | ||||
|         nonlocal multiple_name_count, const_char_retry_count, static_char_retry_count | ||||
|         nonlocal cancel_result, empty_cancel_result, mixed_cancel_result | ||||
|  | ||||
|         # Strip ANSI color codes | ||||
|         clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) | ||||
| @@ -106,6 +113,27 @@ async def test_scheduler_retry_test( | ||||
|                 if multiple_name_count >= 20: | ||||
|                     multiple_name_done.set() | ||||
|  | ||||
|         # Const char retry test | ||||
|         elif "Const char retry" in clean_line: | ||||
|             if match := re.search(r"Const char retry (\d+)", clean_line): | ||||
|                 const_char_retry_count = int(match.group(1)) | ||||
|                 const_char_done.set() | ||||
|  | ||||
|         # Static const char retry test | ||||
|         elif "Static const char retry" in clean_line: | ||||
|             if match := re.search(r"Static const char retry (\d+)", clean_line): | ||||
|                 static_char_retry_count = int(match.group(1)) | ||||
|                 static_char_done.set() | ||||
|  | ||||
|         elif "Static cancel result:" in clean_line: | ||||
|             # This is part of test 9, but we don't track it separately | ||||
|             pass | ||||
|  | ||||
|         # Mixed cancel test | ||||
|         elif "Mixed cancel result:" in clean_line: | ||||
|             mixed_cancel_result = "true" in clean_line | ||||
|             mixed_cancel_done.set() | ||||
|  | ||||
|         # Test completion | ||||
|         elif "All retry tests completed" in clean_line: | ||||
|             test_complete.set() | ||||
| @@ -227,6 +255,40 @@ async def test_scheduler_retry_test( | ||||
|             f"Expected multiple name count >= 20 (second retry only), got {multiple_name_count}" | ||||
|         ) | ||||
|  | ||||
|         # Wait for const char retry test | ||||
|         try: | ||||
|             await asyncio.wait_for(const_char_done.wait(), timeout=1.0) | ||||
|         except TimeoutError: | ||||
|             pytest.fail( | ||||
|                 f"Const char retry test did not complete. Count: {const_char_retry_count}" | ||||
|             ) | ||||
|  | ||||
|         assert const_char_retry_count == 1, ( | ||||
|             f"Expected 1 const char retry call, got {const_char_retry_count}" | ||||
|         ) | ||||
|  | ||||
|         # Wait for static char retry test | ||||
|         try: | ||||
|             await asyncio.wait_for(static_char_done.wait(), timeout=1.0) | ||||
|         except TimeoutError: | ||||
|             pytest.fail( | ||||
|                 f"Static char retry test did not complete. Count: {static_char_retry_count}" | ||||
|             ) | ||||
|  | ||||
|         assert static_char_retry_count == 1, ( | ||||
|             f"Expected 1 static char retry call, got {static_char_retry_count}" | ||||
|         ) | ||||
|  | ||||
|         # Wait for mixed cancel test | ||||
|         try: | ||||
|             await asyncio.wait_for(mixed_cancel_done.wait(), timeout=1.0) | ||||
|         except TimeoutError: | ||||
|             pytest.fail("Mixed cancel test did not complete") | ||||
|  | ||||
|         assert mixed_cancel_result is True, ( | ||||
|             "Mixed string/const char cancel should have succeeded" | ||||
|         ) | ||||
|  | ||||
|         # Wait for test completion | ||||
|         try: | ||||
|             await asyncio.wait_for(test_complete.wait(), timeout=1.0) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user