"""Test ESPHome manager.""" import asyncio import base64 from collections.abc import Generator import json import logging import math from typing import Any from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( ZERO_NOISE_PSK, APIClient, APIConnectionError, APIVersion, AreaInfo, BinarySensorInfo, BinarySensorState, BluetoothProxyFeature, CameraState, DeviceInfo, EncryptionPlaintextAPIError, EntityInfo, EntityState, Event, ExecuteServiceResponse, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, LogLevel, ReconnectLogic, RequiresEncryptionAPIError, SensorInfo, SensorState, SubDeviceInfo, SupportsResponseType, UserService, UserServiceArg, UserServiceArgType, ZWaveProxyRequest, ZWaveProxyRequestType, build_device_unique_id, ) import aiohttp from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol from homeassistant import config_entries from homeassistant.components.esphome.config_flow import PROBE_NOISE_PSK from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_NOISE_PSK, CONF_SUBSCRIBE_LOGS, DOMAIN, STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) from homeassistant.components.esphome.encryption_key_storage import ( ENCRYPTION_KEY_STORAGE_KEY, ) from homeassistant.components.esphome.entry_data import ( SAVE_DELAY, STATE_TYPE_TO_COMPONENT_TYPE, ) from homeassistant.components.esphome.manager import DEVICE_CONFLICT_ISSUE_FORMAT from homeassistant.components.tag import DOMAIN as TAG_DOMAIN from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, EVENT_HOMEASSISTANT_CLOSE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, issue_registry as ir, ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType from tests.common import ( MockConfigEntry, async_call_logger_set_level, async_capture_events, async_fire_time_changed, async_mock_service, ) async def test_esphome_device_subscribe_logs( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, ) -> None: """Test configuring a device to subscribe to logs.""" assert await async_setup_component(hass, "logger", {"logger": {}}) entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "fe80::1", CONF_PORT: 6053, CONF_PASSWORD: "", }, options={CONF_SUBSCRIBE_LOGS: True}, ) entry.add_to_hass(hass) device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={}, ) await hass.async_block_till_done() async with async_call_logger_set_level( "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog ): assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE caplog.set_level(logging.DEBUG) device.mock_on_log_message( Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") ) await hass.async_block_till_done() assert "test_log_message" in caplog.text device.mock_on_log_message( Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") ) await hass.async_block_till_done() assert "test_error_log_message" in caplog.text caplog.set_level(logging.ERROR) device.mock_on_log_message( Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") ) await hass.async_block_till_done() assert "test_debug_log_message" not in caplog.text caplog.set_level(logging.DEBUG) device.mock_on_log_message( Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") ) await hass.async_block_till_done() assert "test_debug_log_message" in caplog.text async with async_call_logger_set_level( "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog ): assert device.current_log_level == LogLevel.LOG_LEVEL_WARN async with async_call_logger_set_level( "homeassistant.components.esphome", "ERROR", hass=hass, caplog=caplog ): assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR async with async_call_logger_set_level( "homeassistant.components.esphome", "INFO", hass=hass, caplog=caplog ): assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with service calls not allowed.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"esphome_version": "2023.3.0"}, ) await hass.async_block_till_done() mock_esphome_test = async_mock_service(hass, DOMAIN, "test") device.mock_service_call( HomeassistantServiceCall( service="esphome.test", data={}, ) ) await hass.async_block_till_done() assert len(mock_esphome_test) == 0 issue = issue_registry.async_get_issue( "esphome", "service_calls_not_enabled-11:22:33:44:55:aa" ) assert issue is not None assert ( "If you trust this device and want to allow access " "for it to make Home Assistant service calls, you can " "enable this functionality in the options flow" ) in caplog.text @pytest.mark.parametrize("has_deep_sleep", [True, False]) async def test_reconnect_logic_seeds_deep_sleep_from_restored_device_info( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, has_deep_sleep: bool, ) -> None: """Restored device_info seeds reconnect_logic.deep_sleep before the first connect.""" deep_sleep_at_start: list[bool] = [] real_start = ReconnectLogic.start async def _start(self: ReconnectLogic) -> None: # Captured before the first connect refreshes it, so this is the # pre-populated value from the restored device_info. deep_sleep_at_start.append(self.deep_sleep) await real_start(self) with patch.object(ReconnectLogic, "start", _start): await mock_esphome_device( mock_client=mock_client, device_info={"has_deep_sleep": has_deep_sleep}, mock_storage=True, ) assert deep_sleep_at_start == [has_deep_sleep] _DEEP_SLEEP_SENSOR_INFOS = [SensorInfo(object_id="mysensor", key=1, name="my sensor")] _DEEP_SLEEP_SENSOR_STATES = {"sensor": [SensorState(key=1, state=42).to_dict()]} _SUB_DEVICES = [ SubDeviceInfo(device_id=11111111, name="Sub Device 1", area_id=0), SubDeviceInfo(device_id=22222222, name="Sub Device 2", area_id=0), ] _SUB_DEVICE_SENSOR_INFOS = [ SensorInfo(object_id="temp", key=1, name="Temp", device_id=11111111), SensorInfo(object_id="temp", key=1, name="Temp", device_id=22222222), ] _SUB_DEVICE_SENSOR_STATES = [ SensorState(key=1, state=11, device_id=11111111), SensorState(key=1, state=22, device_id=22222222), ] def _seed_deep_sleep_storage( hass: HomeAssistant, hass_storage: dict[str, Any], *, infos: dict[str, list[EntityInfo]] | None = None, states: dict[str, list[Any]] | None = None, expected_disconnect: bool | None = True, sub_devices: list[SubDeviceInfo] | None = None, ) -> MockConfigEntry: """Seed storage as if a deep-sleep device had persisted its last known state. Passing None for expected_disconnect writes a pre-upgrade payload without the states and expected_disconnect keys. """ entry = MockConfigEntry( domain=DOMAIN, unique_id="11:22:33:44:55:aa", data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, title="test", ) entry.add_to_hass(hass) device_info = DeviceInfo( name="test", friendly_name="Test", mac_address="11:22:33:44:55:AA", esphome_version="1.0.0", has_deep_sleep=True, devices=sub_devices or [], ) data: dict[str, Any] = { "device_info": device_info.to_dict(), "api_version": APIVersion(99, 99).to_dict(), "services": [], } for comp_type, comp_infos in ( infos or {"sensor": _DEEP_SLEEP_SENSOR_INFOS} ).items(): data[comp_type] = [info.to_dict() for info in comp_infos] if expected_disconnect is not None: data["states"] = states or _DEEP_SLEEP_SENSOR_STATES data["expected_disconnect"] = expected_disconnect storage_key = f"{DOMAIN}.{entry.entry_id}" hass_storage[storage_key] = { "version": 1, "minor_version": 1, "key": storage_key, "data": data, } return entry async def _async_setup_without_connecting( hass: HomeAssistant, mock_client: APIClient, entry: MockConfigEntry ) -> None: """Set up the entry while the device is unreachable.""" mock_client.device_info_and_list_entities = AsyncMock( side_effect=APIConnectionError("offline for test") ) assert await hass.config_entries.async_setup(entry.entry_id) is True await hass.async_block_till_done() @pytest.mark.parametrize( ("entity_info", "states", "extra_states", "device_info", "expected_states"), [ pytest.param( _DEEP_SLEEP_SENSOR_INFOS, [SensorState(key=1, state=50)], [], {}, {"sensor": [SensorState(key=1, state=50).to_dict()]}, id="finite", ), pytest.param( _DEEP_SLEEP_SENSOR_INFOS, [SensorState(key=1, state=math.nan)], [], {}, {"sensor": [SensorState(key=1).to_dict() | {"state": None}]}, id="nan_stored_as_null", ), pytest.param( _DEEP_SLEEP_SENSOR_INFOS, [SensorState(key=1, state=math.inf)], [], {}, {"sensor": [SensorState(key=1).to_dict() | {"state": None}]}, id="inf_stored_as_null", ), pytest.param( _DEEP_SLEEP_SENSOR_INFOS, [SensorState(key=1, state=50)], [CameraState(key=2, data=b"jpeg"), Event(key=3, event_type="pressed")], {}, {"sensor": [SensorState(key=1, state=50).to_dict()]}, id="camera_and_event_excluded", ), pytest.param( _SUB_DEVICE_SENSOR_INFOS, _SUB_DEVICE_SENSOR_STATES, [], {"devices": _SUB_DEVICES}, {"sensor": [state.to_dict() for state in _SUB_DEVICE_SENSOR_STATES]}, id="same_key_on_sub_devices", ), ], ) async def test_deep_sleep_device_persists_states_on_disconnect( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], mock_esphome_device: MockESPHomeDeviceType, entity_info: list[EntityInfo], states: list[EntityState], extra_states: list[EntityState], device_info: dict[str, Any], expected_states: dict[str, list[dict[str, Any]]], ) -> None: """A deep-sleep device persists its states and expected_disconnect on disconnect.""" device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, states=states, device_info={"has_deep_sleep": True, **device_info}, ) for state in extra_states: device.set_state(state) await hass.async_block_till_done() entry = device.entry await device.mock_disconnect(expected_disconnect=True) # Unloading flushes the delayed save scheduled on disconnect await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() data = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"] assert data["expected_disconnect"] is True assert data["states"] == expected_states @pytest.mark.parametrize( ("infos", "states", "stored_expected_disconnect", "entity_id", "expected_state"), [ pytest.param( None, _DEEP_SLEEP_SENSOR_STATES, True, "sensor.test_my_sensor", "42", id="expected_disconnect_restores_available", ), pytest.param( None, _DEEP_SLEEP_SENSOR_STATES, False, "sensor.test_my_sensor", STATE_UNAVAILABLE, id="unexpected_disconnect_stays_unavailable", ), pytest.param( None, _DEEP_SLEEP_SENSOR_STATES, None, "sensor.test_my_sensor", STATE_UNAVAILABLE, id="legacy_payload_without_new_keys", ), pytest.param( None, {**_DEEP_SLEEP_SENSOR_STATES, "unknown_component": [{"key": 1}]}, True, "sensor.test_my_sensor", "42", id="unknown_component_type_skipped", ), pytest.param( None, {"sensor": [SensorState(key=1).to_dict() | {"state": None}]}, True, "sensor.test_my_sensor", STATE_UNKNOWN, id="null_state_restores_unknown", ), pytest.param( None, {"sensor": [SensorState(key=2, state=42).to_dict()]}, True, "sensor.test_my_sensor", STATE_UNKNOWN, id="state_without_entity_dropped", ), pytest.param( {"binary_sensor": [BinarySensorInfo(object_id="door", key=1, name="Door")]}, { "binary_sensor": [ BinarySensorState(key=1, state=True, missing_state=False).to_dict() ] }, True, "binary_sensor.test_door", STATE_ON, id="binary_sensor_restores_on", ), pytest.param( {"binary_sensor": [BinarySensorInfo(object_id="door", key=1, name="Door")]}, { "binary_sensor": [ BinarySensorState(key=1, state=False, missing_state=False).to_dict() ] }, True, "binary_sensor.test_door", STATE_OFF, id="binary_sensor_restores_off", ), ], ) async def test_cold_start_offline_restores_deep_sleep_entities( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], infos: dict[str, list[EntityInfo]] | None, states: dict[str, list[Any]], stored_expected_disconnect: bool | None, entity_id: str, expected_state: str, ) -> None: """A deep-sleep device restores its entities before ever reconnecting.""" entry = _seed_deep_sleep_storage( hass, hass_storage, infos=infos, states=states, expected_disconnect=stored_expected_disconnect, ) await _async_setup_without_connecting(hass, mock_client, entry) state = hass.states.get(entity_id) assert state is not None assert state.state == expected_state async def test_cold_start_restores_same_key_on_sub_devices( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], entity_registry: er.EntityRegistry, ) -> None: """Restored states with the same key land on their own sub device entity.""" entry = _seed_deep_sleep_storage( hass, hass_storage, infos={"sensor": _SUB_DEVICE_SENSOR_INFOS}, states={"sensor": [state.to_dict() for state in _SUB_DEVICE_SENSOR_STATES]}, sub_devices=_SUB_DEVICES, ) await _async_setup_without_connecting(hass, mock_client, entry) for info, expected in zip(_SUB_DEVICE_SENSOR_INFOS, ("11", "22"), strict=True): entity_id = entity_registry.async_get_entity_id( "sensor", DOMAIN, build_device_unique_id("11:22:33:44:55:AA", info) ) assert entity_id is not None state = hass.states.get(entity_id) assert state is not None assert state.state == expected async def test_restored_state_of_removed_entity_dropped_on_wake( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], mock_esphome_device: MockESPHomeDeviceType, ) -> None: """The stale-state sweep still runs when the device wakes with new entities.""" entry = _seed_deep_sleep_storage(hass, hass_storage) new_info = SensorInfo(object_id="other", key=2, name="other") device = await mock_esphome_device( mock_client=mock_client, entry=entry, entity_info=[new_info], device_info={"has_deep_sleep": True}, ) assert hass.states.get("sensor.test_my_sensor") is None state = hass.states.get("sensor.test_other") assert state is not None assert state.state == STATE_UNKNOWN await device.mock_disconnect(expected_disconnect=True) await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() data = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"] assert data["sensor"] == [new_info.to_dict()] assert data["states"] == {} async def test_wake_with_unchanged_state_after_restore_is_dispatched( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], mock_esphome_device: MockESPHomeDeviceType, ) -> None: """A wake-up state identical to the restored one still reaches the entity.""" entry = _seed_deep_sleep_storage(hass, hass_storage, expected_disconnect=False) await mock_esphome_device( mock_client=mock_client, entry=entry, entity_info=_DEEP_SLEEP_SENSOR_INFOS, states=[SensorState(key=1, state=42)], device_info={"has_deep_sleep": True}, ) state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "42" async def test_deep_sleep_states_flushed_on_unload_while_connected( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], mock_esphome_device: MockESPHomeDeviceType, freezer: FrozenDateTimeFactory, ) -> None: """Unloading a connected deep-sleep device persists the states it received.""" device = await mock_esphome_device( mock_client=mock_client, entity_info=_DEEP_SLEEP_SENSOR_INFOS, device_info={"has_deep_sleep": True}, ) entry = device.entry # Real devices send states after the connect-time save has been scheduled device.set_state(SensorState(key=1, state=50)) await hass.async_block_till_done() # The mock client never runs on_disconnect, like a real disconnect whose # callback is still waiting on the reconnect lock await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() data = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"] assert data["expected_disconnect"] is True assert data["states"] == {"sensor": [SensorState(key=1, state=50).to_dict()]} # A late on_disconnect from the old instance must not overwrite the store await device.mock_disconnect(expected_disconnect=False) freezer.tick(SAVE_DELAY + 1) async_fire_time_changed(hass) await hass.async_block_till_done() data = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"] assert data["expected_disconnect"] is True async def test_deep_sleep_states_persisted_on_shutdown_while_connected( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Restarting Home Assistant while the device is awake persists its states.""" device = await mock_esphome_device( mock_client=mock_client, entity_info=_DEEP_SLEEP_SENSOR_INFOS, device_info={"has_deep_sleep": True}, ) entry = device.entry device.set_state(SensorState(key=1, state=50)) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) await hass.async_block_till_done() data = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"] assert data["expected_disconnect"] is True assert data["states"] == {"sensor": [SensorState(key=1, state=50).to_dict()]} @pytest.mark.parametrize( ("component_type", "state_cls"), [ pytest.param(component_type, state_cls, id=component_type) for state_cls, component_type in STATE_TYPE_TO_COMPONENT_TYPE.items() ], ) async def test_every_persisted_state_type_round_trips( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], mock_esphome_device: MockESPHomeDeviceType, component_type: str, state_cls: type[EntityState], ) -> None: """Every persisted state type survives the store and loads back unchanged.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"has_deep_sleep": True} ) entry = device.entry state = state_cls(key=1) device.set_state(state) await hass.async_block_till_done() await device.mock_disconnect(expected_disconnect=True) await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() stored = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"]["states"] assert stored == {component_type: [state.to_dict()]} assert state_cls.from_dict(stored[component_type][0]) == state async def test_non_deep_sleep_device_does_not_persist_states( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], mock_esphome_device: MockESPHomeDeviceType, ) -> None: """A device without deep sleep never gains the new storage keys.""" device = await mock_esphome_device( mock_client=mock_client, entity_info=_DEEP_SLEEP_SENSOR_INFOS, states=[SensorState(key=1, state=50)], ) entry = device.entry await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() data = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"] assert "states" not in data assert "expected_disconnect" not in data async def test_esphome_device_service_calls_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with service calls are allowed.""" await async_setup_component(hass, TAG_DOMAIN, {}) hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) device = await mock_esphome_device( mock_client=mock_client, device_info={"esphome_version": "2023.3.0"}, entry=mock_config_entry, ) await hass.async_block_till_done() mock_calls: list[ServiceCall] = [] async def _mock_service(call: ServiceCall) -> None: mock_calls.append(call) hass.services.async_register(DOMAIN, "test", _mock_service) device.mock_service_call( HomeassistantServiceCall( service="esphome.test", data={"raw": "data"}, ) ) await hass.async_block_till_done() issue = issue_registry.async_get_issue( "esphome", "service_calls_not_enabled-11:22:33:44:55:aa" ) assert issue is None assert len(mock_calls) == 1 service_call = mock_calls[0] assert service_call.domain == DOMAIN assert service_call.service == "test" assert service_call.data == {"raw": "data"} mock_calls.clear() device.mock_service_call( HomeassistantServiceCall( service="esphome.test", data_template={"raw": "{{invalid}}"}, ) ) await hass.async_block_till_done() assert ( "Template variable warning: 'invalid' is undefined when rendering '{{invalid}}'" in caplog.text ) assert len(mock_calls) == 1 service_call = mock_calls[0] assert service_call.domain == DOMAIN assert service_call.service == "test" assert service_call.data == {"raw": ""} mock_calls.clear() caplog.clear() device.mock_service_call( HomeassistantServiceCall( service="esphome.test", data_template={"raw": "{{-- invalid --}}"}, ) ) await hass.async_block_till_done() assert "TemplateSyntaxError" in caplog.text assert "{{-- invalid --}}" in caplog.text assert len(mock_calls) == 0 mock_calls.clear() caplog.clear() device.mock_service_call( HomeassistantServiceCall( service="esphome.test", data_template={"raw": "{{var}}"}, variables={"var": "value"}, ) ) await hass.async_block_till_done() assert len(mock_calls) == 1 service_call = mock_calls[0] assert service_call.domain == DOMAIN assert service_call.service == "test" assert service_call.data == {"raw": "value"} mock_calls.clear() device.mock_service_call( HomeassistantServiceCall( service="esphome.test", data_template={"raw": "valid"}, ) ) await hass.async_block_till_done() assert len(mock_calls) == 1 service_call = mock_calls[0] assert service_call.domain == DOMAIN assert service_call.service == "test" assert service_call.data == {"raw": "valid"} mock_calls.clear() # Try firing events events = async_capture_events(hass, "esphome.test") device.mock_service_call( HomeassistantServiceCall( service="esphome.test", is_event=True, data={"raw": "event"}, ) ) await hass.async_block_till_done() assert len(events) == 1 event = events[0] assert event.data["raw"] == "event" assert event.event_type == "esphome.test" events.clear() caplog.clear() # Try scanning a tag events = async_capture_events(hass, "tag_scanned") device.mock_service_call( HomeassistantServiceCall( service="esphome.tag_scanned", is_event=True, data={"tag_id": "1234"}, ) ) await hass.async_block_till_done() assert len(events) == 1 event = events[0] assert event.event_type == "tag_scanned" assert event.data["tag_id"] == "1234" events.clear() caplog.clear() # Try firing events for disallowed domain events = async_capture_events(hass, "wrong.test") device.mock_service_call( HomeassistantServiceCall( service="wrong.test", is_event=True, data={"raw": "event"}, ) ) await hass.async_block_till_done() assert len(events) == 0 assert "Can only generate events under esphome domain" in caplog.text events.clear() async def test_esphome_device_service_call_with_response( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test service call with response expected.""" hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) device = await mock_esphome_device( mock_client=mock_client, device_info={"esphome_version": "2023.3.0"}, entry=mock_config_entry, ) await hass.async_block_till_done() # Register a service that returns a response async def _mock_service_with_response(call: ServiceCall) -> dict[str, Any]: return {"result": "success", "value": 42} hass.services.async_register( DOMAIN, "test_with_response", _mock_service_with_response, supports_response=True, ) # Mock the send_homeassistant_action_response method mock_client.send_homeassistant_action_response = Mock() # Call service with response expected device.mock_service_call( HomeassistantServiceCall( service="esphome.test_with_response", data={"input": "test"}, call_id=123, wants_response=True, ) ) await hass.async_block_till_done() # Verify response was sent back to ESPHome mock_client.send_homeassistant_action_response.assert_called_once() call_id, success, error_message, response_data = ( mock_client.send_homeassistant_action_response.call_args[0] ) assert call_id == 123 assert success is True assert error_message == "" assert response_data == b'{"response":{"result":"success","value":42}}' async def test_esphome_device_service_call_with_response_template( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test service call with response template.""" hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) device = await mock_esphome_device( mock_client=mock_client, device_info={"esphome_version": "2023.3.0"}, entry=mock_config_entry, ) await hass.async_block_till_done() # Register a service that returns a response async def _mock_service_with_response(call: ServiceCall) -> dict[str, Any]: return {"temperature": 23.5, "humidity": 65} hass.services.async_register( DOMAIN, "get_data", _mock_service_with_response, supports_response=True ) # Mock the send_homeassistant_action_response method mock_client.send_homeassistant_action_response = Mock() # Call service with response template device.mock_service_call( HomeassistantServiceCall( service="esphome.get_data", data={}, call_id=456, wants_response=True, response_template="{{ response.temperature }}", ) ) await hass.async_block_till_done() # Verify response was sent back with template applied mock_client.send_homeassistant_action_response.assert_called_once() call_id, success, error_message, response_data = ( mock_client.send_homeassistant_action_response.call_args[0] ) assert call_id == 456 assert success is True assert error_message == "" assert response_data == b'{"response":23.5}' async def test_esphome_device_service_call_with_response_template_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, ) -> None: """Test service call with invalid response template.""" hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) device = await mock_esphome_device( mock_client=mock_client, device_info={"esphome_version": "2023.3.0"}, entry=mock_config_entry, ) await hass.async_block_till_done() # Register a service that returns a response async def _mock_service_with_response(call: ServiceCall) -> dict[str, Any]: return {"temperature": 23.5} hass.services.async_register( DOMAIN, "get_data", _mock_service_with_response, supports_response=True ) # Mock the send_homeassistant_action_response method mock_client.send_homeassistant_action_response = Mock() # Call service with invalid response template device.mock_service_call( HomeassistantServiceCall( service="esphome.get_data", data={}, call_id=789, wants_response=True, response_template="{{ response.invalid_field }}", ) ) await hass.async_block_till_done() # Verify error response was sent back mock_client.send_homeassistant_action_response.assert_called_once() call_id, success, error_message, response_data = ( mock_client.send_homeassistant_action_response.call_args[0] ) assert call_id == 789 assert success is False assert "Error rendering response template" in error_message assert response_data == b"" async def test_esphome_device_service_call_with_notification( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test service call with notification (no response expected).""" hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) device = await mock_esphome_device( mock_client=mock_client, device_info={"esphome_version": "2023.3.0"}, entry=mock_config_entry, ) await hass.async_block_till_done() # Register a service without response async def _mock_service(call: ServiceCall) -> None: pass hass.services.async_register(DOMAIN, "test_notify", _mock_service) # Mock the send_homeassistant_action_response method mock_client.send_homeassistant_action_response = Mock() # Call service with call_id but no wants_response device.mock_service_call( HomeassistantServiceCall( service="esphome.test_notify", data={"input": "test"}, call_id=999, wants_response=False, ) ) await hass.async_block_till_done() # Verify success notification was sent back mock_client.send_homeassistant_action_response.assert_called_once() call_id, success, error_message, response_data = ( mock_client.send_homeassistant_action_response.call_args[0] ) assert call_id == 999 assert success is True assert error_message == "" assert response_data == b"" async def test_esphome_device_service_call_with_service_not_found( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test service call when service does not exist.""" hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) device = await mock_esphome_device( mock_client=mock_client, device_info={"esphome_version": "2023.3.0"}, entry=mock_config_entry, ) await hass.async_block_till_done() # Mock the send_homeassistant_action_response method mock_client.send_homeassistant_action_response = Mock() # Call non-existent service with notification device.mock_service_call( HomeassistantServiceCall( service="esphome.nonexistent", data={}, call_id=111, wants_response=False, ) ) await hass.async_block_till_done() # Verify error notification was sent back mock_client.send_homeassistant_action_response.assert_called_once() call_id, success, error_message, response_data = ( mock_client.send_homeassistant_action_response.call_args[0] ) assert call_id == 111 assert success is False assert "not found" in error_message.lower() assert response_data == b"" async def test_esphome_device_service_call_with_validation_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test service call with validation error.""" hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) device = await mock_esphome_device( mock_client=mock_client, device_info={"esphome_version": "2023.3.0"}, entry=mock_config_entry, ) await hass.async_block_till_done() # Register a service that validates input async def _mock_service(call: ServiceCall) -> None: raise vol.Invalid("Invalid input provided") hass.services.async_register(DOMAIN, "validate_test", _mock_service) # Mock the send_homeassistant_action_response method mock_client.send_homeassistant_action_response = Mock() # Call service with invalid data device.mock_service_call( HomeassistantServiceCall( service="esphome.validate_test", data={"invalid": "data"}, call_id=222, wants_response=False, ) ) await hass.async_block_till_done() # Verify error notification was sent back mock_client.send_homeassistant_action_response.assert_called_once() call_id, success, error_message, response_data = ( mock_client.send_homeassistant_action_response.call_args[0] ) assert call_id == 222 assert success is False assert "Invalid input provided" in error_message assert response_data == b"" async def test_esphome_device_service_call_fire_and_forget( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test fire-and-forget service call (no call_id).""" hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) device = await mock_esphome_device( mock_client=mock_client, device_info={"esphome_version": "2023.3.0"}, entry=mock_config_entry, ) await hass.async_block_till_done() mock_calls: list[ServiceCall] = [] async def _mock_service(call: ServiceCall) -> None: mock_calls.append(call) hass.services.async_register(DOMAIN, "fire_forget", _mock_service) # Mock the send_homeassistant_action_response method mock_client.send_homeassistant_action_response = Mock() # Call service without call_id (fire-and-forget) device.mock_service_call( HomeassistantServiceCall( service="esphome.fire_forget", data={"test": "data"}, ) ) await hass.async_block_till_done() # Verify service was called but no response was sent assert len(mock_calls) == 1 assert mock_calls[0].data == {"test": "data"} mock_client.send_homeassistant_action_response.assert_not_called() async def test_esphome_device_with_old_bluetooth( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with old bluetooth creates an issue.""" await mock_esphome_device( mock_client=mock_client, device_info={"bluetooth_proxy_feature_flags": 1, "esphome_version": "2023.3.0"}, ) await hass.async_block_till_done() issue = issue_registry.async_get_issue( "esphome", "ble_firmware_outdated-11:22:33:44:55:AA" ) assert ( issue.learn_more_url == f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" ) async def test_esphome_device_with_password( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with legacy password creates an issue.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: "has", }, ) entry.add_to_hass(hass) await mock_esphome_device( mock_client=mock_client, device_info={"bluetooth_proxy_feature_flags": 0, "esphome_version": "2023.3.0"}, entry=entry, ) await hass.async_block_till_done() assert ( issue_registry.async_get_issue( # This issue uses the ESPHome mac address which # is always UPPER case "esphome", "api_password_deprecated-11:22:33:44:55:AA", ) is not None ) async def test_esphome_device_with_current_bluetooth( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with recent bluetooth does not create an issue.""" await mock_esphome_device( mock_client=mock_client, device_info={ "bluetooth_proxy_feature_flags": 1, "esphome_version": STABLE_BLE_VERSION_STR, }, ) await hass.async_block_till_done() assert ( # This issue uses the ESPHome device info mac address which # is always UPPER case issue_registry.async_get_issue( "esphome", "ble_firmware_outdated-11:22:33:44:55:AA" ) is None ) @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_updated_to_mac( hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we update config entry unique ID to MAC address.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, unique_id="mock-config-name", ) entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) mock_client.subscribe_home_assistant_states_and_services = ( async_subscribe_home_assistant_states_and_services ) device_info = DeviceInfo(mac_address="1122334455aa") mock_client.device_info = AsyncMock(return_value=device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await subscribe_done assert entry.unique_id == "11:22:33:44:55:aa" @pytest.mark.usefixtures("mock_zeroconf") async def test_add_missing_bluetooth_mac_address( hass: HomeAssistant, mock_client ) -> None: """Test bluetooth mac is added if its missing.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, unique_id="mock-config-name", ) entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) mock_client.subscribe_home_assistant_states_and_services = ( async_subscribe_home_assistant_states_and_services ) device_info = DeviceInfo( mac_address="1122334455aa", bluetooth_mac_address="AA:BB:CC:DD:EE:FF", ) mock_client.device_info = AsyncMock(return_value=device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await subscribe_done assert entry.unique_id == "11:22:33:44:55:aa" assert entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) == "AA:BB:CC:DD:EE:FF" @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_same_and_already_mac( hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we never update the entry unique ID event if the name is the same.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) disconnect_done = hass.loop.create_future() def async_disconnect(*args, **kwargs) -> None: disconnect_done.set_result(None) mock_client.disconnect = async_disconnect device_info = DeviceInfo(mac_address="1122334455ab", name="test") mock_client.device_info = AsyncMock(return_value=device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await disconnect_done # Mac should never update assert entry.unique_id == "11:22:33:44:55:aa" @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_updated_if_name_unset_and_already_mac( hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we never update config entry unique ID even if the name is unset.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) disconnect_done = hass.loop.create_future() def async_disconnect(*args, **kwargs) -> None: disconnect_done.set_result(None) mock_client.disconnect = async_disconnect device_info = DeviceInfo(mac_address="1122334455ab", name="test") mock_client.device_info = AsyncMock(return_value=device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await disconnect_done # Mac should never update assert entry.unique_id == "11:22:33:44:55:aa" @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_different_and_already_mac( hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we do not update config entry unique ID if the name is different.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) disconnect_done = hass.loop.create_future() def async_disconnect(*args, **kwargs) -> None: disconnect_done.set_result(None) mock_client.disconnect = async_disconnect device_info = DeviceInfo(mac_address="1122334455ab", name="different") mock_client.device_info = AsyncMock(return_value=device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await disconnect_done # Mac should not be updated because name is different assert entry.unique_id == "11:22:33:44:55:aa" # Name should not be updated either assert entry.data[CONF_DEVICE_NAME] == "test" @pytest.mark.usefixtures("mock_zeroconf") async def test_name_updated_only_if_mac_matches( hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we update config entry name only if the mac matches.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "old", }, unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) mock_client.subscribe_home_assistant_states_and_services = ( async_subscribe_home_assistant_states_and_services ) device_info = DeviceInfo(mac_address="1122334455aa", name="new") mock_client.device_info = AsyncMock(return_value=device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await subscribe_done assert entry.unique_id == "11:22:33:44:55:aa" assert entry.data[CONF_DEVICE_NAME] == "new" @pytest.mark.usefixtures("mock_zeroconf") async def test_name_updated_only_if_mac_was_unset( hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we update config entry name if the old unique id was not a mac.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "old", }, unique_id="notamac", ) entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) mock_client.subscribe_home_assistant_states_and_services = ( async_subscribe_home_assistant_states_and_services ) device_info = DeviceInfo(mac_address="1122334455aa", name="new") mock_client.device_info = AsyncMock(return_value=device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await subscribe_done assert entry.unique_id == "11:22:33:44:55:aa" assert entry.data[CONF_DEVICE_NAME] == "new" @pytest.mark.usefixtures("mock_zeroconf") async def test_connection_aborted_wrong_device( hass: HomeAssistant, mock_client: APIClient, caplog: pytest.LogCaptureFixture, issue_registry: ir.IssueRegistry, ) -> None: """Test we abort if unique id is a mac and neither name nor mac match.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) disconnect_done = hass.loop.create_future() async def async_disconnect(*args, **kwargs) -> None: disconnect_done.set_result(None) mock_client.disconnect = async_disconnect device_info = DeviceInfo(mac_address="1122334455ab", name="different") mock_client.device_info = AsyncMock(return_value=device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await disconnect_done assert ( "Unexpected device found at 192.168.43.183; expected `test` " "with mac address `11:22:33:44:55:aa`, found `different` " "with mac address `11:22:33:44:55:ab`" in caplog.text ) # If its a different name, it means their DHCP # reservations are missing and the device is not # actually the same device, and there is nothing # we can do to fix it so we only log a warning assert not issue_registry.async_get_issue( domain=DOMAIN, issue_id=DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) ) assert "Error getting setting up connection for" not in caplog.text mock_client.disconnect = AsyncMock() caplog.clear() # Make sure discovery triggers a reconnect service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test", macaddress="1122334455aa", ) device_info = DeviceInfo(mac_address="1122334455aa", name="test") new_info = AsyncMock(return_value=device_info) mock_client.device_info = new_info # Also need to update device_info_and_list_entities new_combined_info = AsyncMock(return_value=(device_info, [], [])) mock_client.device_info_and_list_entities = new_combined_info result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured_updates" assert result["description_placeholders"] == { "title": "Mock Title", "name": "test", "mac": "11:22:33:44:55:aa", } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() # Check that either device_info or device_info_and_list_entities was called assert len(new_info.mock_calls) + len(new_combined_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text @pytest.mark.usefixtures("mock_zeroconf") async def test_connection_aborted_wrong_device_same_name( hass: HomeAssistant, mock_client: APIClient, caplog: pytest.LogCaptureFixture, issue_registry: ir.IssueRegistry, ) -> None: """Test we abort the connection if the unique id is a mac and the name matches.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) disconnect_done = hass.loop.create_future() async def async_disconnect(*args, **kwargs) -> None: disconnect_done.set_result(None) mock_client.disconnect = async_disconnect device_info = DeviceInfo(mac_address="1122334455ab", name="test") mock_client.device_info = AsyncMock(return_value=device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await disconnect_done assert ( "Unexpected device found at 192.168.43.183; expected `test` " "with mac address `11:22:33:44:55:aa`, found `test` " "with mac address `11:22:33:44:55:ab`" in caplog.text ) # We should start a repair flow to help them fix the issue assert issue_registry.async_get_issue( domain=DOMAIN, issue_id=DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) ) assert "Error getting setting up connection for" not in caplog.text mock_client.disconnect = AsyncMock() caplog.clear() # Make sure discovery triggers a reconnect service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test", macaddress="1122334455aa", ) device_info = DeviceInfo(mac_address="1122334455aa", name="test") new_info = AsyncMock(return_value=device_info) mock_client.device_info = new_info # Also need to update device_info_and_list_entities new_combined_info = AsyncMock(return_value=(device_info, [], [])) mock_client.device_info_and_list_entities = new_combined_info result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured_updates" assert result["description_placeholders"] == { "title": "Mock Title", "name": "test", "mac": "11:22:33:44:55:aa", } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() # Check that either device_info or device_info_and_list_entities was called assert len(new_info.mock_calls) + len(new_combined_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text @pytest.mark.usefixtures("mock_zeroconf") async def test_failure_during_connect( hass: HomeAssistant, mock_client: APIClient, caplog: pytest.LogCaptureFixture, ) -> None: """Test we disconnect when there is a failure during connection setup.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) disconnect_done = hass.loop.create_future() async def async_disconnect(*args, **kwargs) -> None: disconnect_done.set_result(None) mock_client.disconnect = async_disconnect mock_client.device_info = AsyncMock(side_effect=APIConnectionError("fail")) mock_client.list_entities_services = AsyncMock( side_effect=APIConnectionError("fail") ) mock_client.device_info_and_list_entities = AsyncMock( side_effect=APIConnectionError("fail") ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async with asyncio.timeout(1): await disconnect_done assert "Error getting setting up connection for" in caplog.text async def test_state_subscription( mock_client: APIClient, hass: HomeAssistant, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome subscribes to state changes.""" device = await mock_esphome_device( mock_client=mock_client, ) await hass.async_block_till_done() hass.states.async_set("binary_sensor.test", "on", {"bool": True, "float": 3.0}) device.mock_home_assistant_state_subscription("binary_sensor.test", None) await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [ call("binary_sensor.test", None, "on") ] mock_client.send_home_assistant_state.reset_mock() hass.states.async_set("binary_sensor.test", "off", {"bool": True, "float": 3.0}) await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [ call("binary_sensor.test", None, "off") ] mock_client.send_home_assistant_state.reset_mock() device.mock_home_assistant_state_subscription("binary_sensor.test", "bool") await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [ call("binary_sensor.test", "bool", "on") ] mock_client.send_home_assistant_state.reset_mock() hass.states.async_set("binary_sensor.test", "off", {"bool": False, "float": 3.0}) await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [ call("binary_sensor.test", "bool", "off") ] mock_client.send_home_assistant_state.reset_mock() device.mock_home_assistant_state_subscription("binary_sensor.test", "float") await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [ call("binary_sensor.test", "float", "3.0") ] mock_client.send_home_assistant_state.reset_mock() hass.states.async_set("binary_sensor.test", "on", {"bool": True, "float": 4.0}) await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [ call("binary_sensor.test", None, "on"), call("binary_sensor.test", "bool", "on"), call("binary_sensor.test", "float", "4.0"), ] mock_client.send_home_assistant_state.reset_mock() hass.states.async_set("binary_sensor.test", "on", {}) await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [] hass.states.async_remove("binary_sensor.test") await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [] async def test_state_request( mock_client: APIClient, hass: HomeAssistant, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome requests state change.""" device = await mock_esphome_device( mock_client=mock_client, ) await hass.async_block_till_done() hass.states.async_set("binary_sensor.test", "on", {"bool": True, "float": 3.0}) device.mock_home_assistant_state_request("binary_sensor.test", None) await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [ call("binary_sensor.test", None, "on") ] mock_client.send_home_assistant_state.reset_mock() hass.states.async_set("binary_sensor.test", "off", {"bool": False, "float": 5.0}) await hass.async_block_till_done() assert mock_client.send_home_assistant_state.mock_calls == [] async def test_debug_logging( mock_client: APIClient, hass: HomeAssistant, mock_generic_device_entry: MockGenericDeviceEntryType, caplog: pytest.LogCaptureFixture, ) -> None: """Test enabling and disabling debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) await mock_generic_device_entry( mock_client=mock_client, ) async with async_call_logger_set_level( "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog ): mock_client.set_debug.assert_has_calls([call(True)]) mock_client.reset_mock() async with async_call_logger_set_level( "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog ): mock_client.set_debug.assert_has_calls([call(False)]) async def test_esphome_device_with_dash_in_name_user_services( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with user services and a dash in the name.""" service1 = UserService( name="my_service", key=1, args=[ UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), UserServiceArg(name="arg2", type=UserServiceArgType.INT), UserServiceArg(name="arg3", type=UserServiceArgType.FLOAT), UserServiceArg(name="arg4", type=UserServiceArgType.STRING), UserServiceArg(name="arg5", type=UserServiceArgType.BOOL_ARRAY), UserServiceArg(name="arg6", type=UserServiceArgType.INT_ARRAY), UserServiceArg(name="arg7", type=UserServiceArgType.FLOAT_ARRAY), UserServiceArg(name="arg8", type=UserServiceArgType.STRING_ARRAY), ], ) service2 = UserService( name="simple_service", key=2, args=[ UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), ], ) device = await mock_esphome_device( mock_client=mock_client, user_service=[service1, service2], device_info={"name": "with-dash"}, ) await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, "with_dash_my_service") assert hass.services.has_service(DOMAIN, "with_dash_simple_service") await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": True}) await hass.async_block_till_done() mock_client.execute_service.assert_has_calls( [ call( UserService( name="simple_service", key=2, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], ), {"arg1": True}, ) ] ) mock_client.execute_service.reset_mock() # Verify the service can be removed mock_client.list_entities_services = AsyncMock(return_value=([], [service1])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device.device_info, [], [service1]) ) await device.mock_disconnect(True) await hass.async_block_till_done() await device.mock_connect() await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, "with_dash_my_service") assert not hass.services.has_service(DOMAIN, "with_dash_simple_service") async def test_esphome_user_services_ignores_invalid_arg_types( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with user services and a dash in the name.""" service1 = UserService( name="bad_service", key=1, args=[ UserServiceArg(name="arg1", type="wrong"), ], ) service2 = UserService( name="simple_service", key=2, args=[ UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), ], ) device = await mock_esphome_device( mock_client=mock_client, user_service=[service1, service2], device_info={"name": "with-dash"}, ) await hass.async_block_till_done() assert not hass.services.has_service(DOMAIN, "with_dash_bad_service") assert hass.services.has_service(DOMAIN, "with_dash_simple_service") await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": True}) await hass.async_block_till_done() mock_client.execute_service.assert_has_calls( [ call( UserService( name="simple_service", key=2, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], ), {"arg1": True}, ) ] ) mock_client.execute_service.reset_mock() # Verify the service can be removed mock_client.list_entities_services = AsyncMock(return_value=([], [service2])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device.device_info, [], [service2]) ) await device.mock_disconnect(True) await hass.async_block_till_done() await device.mock_connect() await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, "with_dash_simple_service") assert not hass.services.has_service(DOMAIN, "with_dash_bad_service") async def test_esphome_user_service_fails( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test executing a user service fails due to disconnect.""" service1 = UserService( name="simple_service", key=2, args=[ UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), ], ) await mock_esphome_device( mock_client=mock_client, user_service=[service1], device_info={"name": "with-dash"}, ) await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, "with_dash_simple_service") mock_client.execute_service = AsyncMock(side_effect=APIConnectionError("fail")) with pytest.raises(HomeAssistantError) as exc: await hass.services.async_call( DOMAIN, "with_dash_simple_service", {"arg1": True}, blocking=True ) assert exc.value.translation_domain == DOMAIN assert exc.value.translation_key == "action_call_failed" assert exc.value.translation_placeholders == { "call_name": "simple_service", "device_name": "with-dash", "error": "fail", } assert ( str(exc.value) == "Failed to execute the action call simple_service on with-dash: fail" ) mock_client.execute_service.assert_has_calls( [ call( UserService( name="simple_service", key=2, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], ), {"arg1": True}, ) ] ) async def test_esphome_user_services_changes( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with user services that change arguments.""" service1 = UserService( name="simple_service", key=2, args=[ UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), ], ) device = await mock_esphome_device( mock_client=mock_client, user_service=[service1], device_info={"name": "with-dash"}, ) await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, "with_dash_simple_service") await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": True}) await hass.async_block_till_done() mock_client.execute_service.assert_has_calls( [ call( UserService( name="simple_service", key=2, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], ), {"arg1": True}, ) ] ) mock_client.execute_service.reset_mock() new_service1 = UserService( name="simple_service", key=2, args=[ UserServiceArg(name="arg1", type=UserServiceArgType.FLOAT), ], ) # Verify the service can be updated mock_client.list_entities_services = AsyncMock(return_value=([], [new_service1])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device.device_info, [], [new_service1]) ) await device.mock_disconnect(True) await hass.async_block_till_done() await device.mock_connect() await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, "with_dash_simple_service") await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": 4.5}) await hass.async_block_till_done() mock_client.execute_service.assert_has_calls( [ call( UserService( name="simple_service", key=2, args=[UserServiceArg(name="arg1", type=UserServiceArgType.FLOAT)], ), {"arg1": 4.5}, ) ] ) mock_client.execute_service.reset_mock() async def test_esphome_device_with_suggested_area( hass: HomeAssistant, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with suggested area.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"suggested_area": "kitchen"}, ) await hass.async_block_till_done() entry = device.entry dev = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, entry.unique_id), entry.entry_id ) assert dev.area_id == area_registry.async_get_area_by_name("kitchen").id async def test_esphome_device_area_priority( hass: HomeAssistant, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that device_info.area takes priority over suggested_area.""" device = await mock_esphome_device( mock_client=mock_client, device_info={ "suggested_area": "kitchen", "area": AreaInfo(area_id=0, name="Living Room"), }, ) await hass.async_block_till_done() entry = device.entry dev = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, entry.unique_id), entry.entry_id ) # Should use device_info.area.name instead of suggested_area assert dev.area_id == area_registry.async_get_area_by_name("Living Room").id async def test_esphome_device_with_project( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a project.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"project_name": "mfr.model", "project_version": "2.2.2"}, ) await hass.async_block_till_done() entry = device.entry dev = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, entry.unique_id), entry.entry_id ) assert dev.manufacturer == "mfr" assert dev.model == "model" assert dev.sw_version == "2.2.2 (ESPHome 1.0.0)" async def test_esphome_device_with_manufacturer( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a manufacturer.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"manufacturer": "acme"}, ) await hass.async_block_till_done() entry = device.entry dev = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, entry.unique_id), entry.entry_id ) assert dev.manufacturer == "acme" async def test_esphome_device_with_web_server( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a web server.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"webserver_port": 80}, ) await hass.async_block_till_done() entry = device.entry dev = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, entry.unique_id), entry.entry_id ) assert dev.configuration_url == "http://test.local:80" async def test_esphome_device_with_ipv6_web_server( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a web server.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "fe80::1", CONF_PORT: 6053, CONF_PASSWORD: "", }, options={}, ) entry.add_to_hass(hass) device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={"webserver_port": 80}, ) await hass.async_block_till_done() entry = device.entry dev = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, entry.unique_id), entry.entry_id ) assert dev.configuration_url == "http://[fe80::1]:80" async def test_esphome_device_with_compilation_time( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a compilation_time.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"compilation_time": "comp_time"}, ) await hass.async_block_till_done() entry = device.entry dev = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, entry.unique_id), entry.entry_id ) assert "comp_time" in dev.sw_version async def test_disconnects_at_close_event( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test the device is disconnected at the close event.""" await mock_esphome_device( mock_client=mock_client, device_info={"compilation_time": "comp_time"}, ) await hass.async_block_till_done() assert mock_client.disconnect.call_count == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) await hass.async_block_till_done() assert mock_client.disconnect.call_count == 1 @pytest.mark.parametrize( "error", [ EncryptionPlaintextAPIError, RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError, InvalidAuthAPIError, ], ) async def test_start_reauth( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, error: Exception, ) -> None: """Test exceptions on connect error trigger reauth.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"compilation_time": "comp_time"}, ) await hass.async_block_till_done() await device.mock_connect_error(error("fail")) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress(DOMAIN) assert len(flows) == 1 flow = flows[0] assert flow["context"]["source"] == "reauth" async def test_no_reauth_wrong_mac( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, ) -> None: """Test exceptions on connect error trigger reauth.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"compilation_time": "comp_time"}, ) await hass.async_block_till_done() await device.mock_connect_error( InvalidEncryptionKeyAPIError( "fail", received_mac="aabbccddeeff", received_name="test" ) ) await hass.async_block_till_done() # Reauth should not be triggered flows = hass.config_entries.flow.async_progress(DOMAIN) assert len(flows) == 0 assert ( "Unexpected device found at test.local; expected `test` " "with mac address `11:22:33:44:55:aa`, found `test` " "with mac address `aa:bb:cc:dd:ee:ff`" in caplog.text ) async def test_auth_error_during_on_connect_triggers_reauth( hass: HomeAssistant, mock_client: APIClient, ) -> None: """Test that InvalidAuthAPIError during on_connect triggers reauth.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="11:22:33:44:55:aa", data={ CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: "wrong_password", }, ) entry.add_to_hass(hass) mock_client.device_info_and_list_entities = AsyncMock( side_effect=InvalidAuthAPIError("Invalid password!") ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress(DOMAIN) assert len(flows) == 1 assert flows[0]["context"]["source"] == "reauth" assert flows[0]["context"]["entry_id"] == entry.entry_id assert mock_client.disconnect.call_count >= 1 async def test_entry_missing_unique_id( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test the unique id is added from storage if available.""" entry = MockConfigEntry( domain=DOMAIN, unique_id=None, data={ CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: "", }, options={CONF_ALLOW_SERVICE_CALLS: True}, ) entry.add_to_hass(hass) await mock_esphome_device(mock_client=mock_client, mock_storage=True) await hass.async_block_till_done() assert entry.unique_id == "11:22:33:44:55:aa" async def test_entry_missing_bluetooth_mac_address( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test the bluetooth_mac_address is added if available.""" entry = MockConfigEntry( domain=DOMAIN, unique_id=None, data={ CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: "", }, options={CONF_ALLOW_SERVICE_CALLS: True}, ) entry.add_to_hass(hass) await mock_esphome_device( mock_client=mock_client, mock_storage=True, device_info={"bluetooth_mac_address": "AA:BB:CC:DD:EE:FC"}, ) await hass.async_block_till_done() assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC" async def test_device_adds_friendly_name( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, ) -> None: """Test a device with user services that change arguments.""" device = await mock_esphome_device( mock_client=mock_client, device_info={"name": "nofriendlyname", "friendly_name": ""}, ) await hass.async_block_till_done() dev_reg = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures dev = dev_reg.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, device.entry.unique_id), device.entry.entry_id ) assert dev.name == "Nofriendlyname" assert ( "No `friendly_name` set in the `esphome:` section of " "the YAML config for device 'nofriendlyname'" ) in caplog.text caplog.clear() await device.mock_disconnect(True) await hass.async_block_till_done() device.device_info = DeviceInfo( **{**device.device_info.to_dict(), "friendly_name": "I have a friendly name"} ) mock_client.device_info = AsyncMock(return_value=device.device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device.device_info, [], []) ) await device.mock_connect() await hass.async_block_till_done() dev = dev_reg.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, device.entry.unique_id), device.entry.entry_id ) assert dev.name == "I have a friendly name" assert ( "No `friendly_name` set in the `esphome:` section of the YAML config for device" ) not in caplog.text async def test_assist_in_progress_issue_deleted( hass: HomeAssistant, mock_client: APIClient, entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test assist in progress entity and issue is deleted. Remove this cleanup after 2026.4 """ entry = entity_registry.async_get_or_create( domain="binary_sensor", platform=DOMAIN, unique_id="11:22:33:44:55:AA-assist_in_progress", ) ir.async_create_issue( hass, DOMAIN, f"assist_in_progress_deprecated_{entry.id}", is_fixable=True, is_persistent=True, severity=ir.IssueSeverity.WARNING, translation_key="assist_in_progress_deprecated", translation_placeholders={ "integration_name": "ESPHome", }, ) await mock_esphome_device( mock_client=mock_client, device_info={}, mock_storage=True, ) assert ( entity_registry.async_get_entity_id( "binary_sensor", DOMAIN, "11:22:33:44:55:AA-assist_in_progress" ) is None ) assert ( issue_registry.async_get_issue( DOMAIN, f"assist_in_progress_deprecated_{entry.id}" ) is None ) async def test_sub_device_creation( hass: HomeAssistant, area_registry: ar.AreaRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test sub devices are created in device registry.""" device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures # Define areas areas = [ AreaInfo(area_id=1, name="Living Room"), AreaInfo(area_id=2, name="Bedroom"), AreaInfo(area_id=3, name="Kitchen"), ] # Define sub devices sub_devices = [ SubDeviceInfo(device_id=11111111, name="Motion Sensor", area_id=1), SubDeviceInfo(device_id=22222222, name="Light Switch", area_id=1), SubDeviceInfo(device_id=33333333, name="Temperature Sensor", area_id=2), ] device_info = { "areas": areas, "devices": sub_devices, "area": AreaInfo(area_id=0, name="Main Hub"), } device = await mock_esphome_device( mock_client=mock_client, device_info=device_info, ) # Check main device is created main_device = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address), device.entry.entry_id, ) assert main_device is not None assert main_device.area_id == area_registry.async_get_area_by_name("Main Hub").id # Check sub devices are created sub_device_1 = device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_11111111"), device.entry.entry_id ) assert sub_device_1 is not None assert sub_device_1.name == "Motion Sensor" assert ( sub_device_1.area_id == area_registry.async_get_area_by_name("Living Room").id ) assert sub_device_1.via_device_id == main_device.id sub_device_2 = device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id ) assert sub_device_2 is not None assert sub_device_2.name == "Light Switch" assert ( sub_device_2.area_id == area_registry.async_get_area_by_name("Living Room").id ) assert sub_device_2.via_device_id == main_device.id sub_device_3 = device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_33333333"), device.entry.entry_id ) assert sub_device_3 is not None assert sub_device_3.name == "Temperature Sensor" assert sub_device_3.area_id == area_registry.async_get_area_by_name("Bedroom").id assert sub_device_3.via_device_id == main_device.id async def test_sub_device_cleanup( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test sub devices are removed when they no longer exist.""" device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures # Initial sub devices sub_devices_initial = [ SubDeviceInfo(device_id=11111111, name="Device 1", area_id=0), SubDeviceInfo(device_id=22222222, name="Device 2", area_id=0), SubDeviceInfo(device_id=33333333, name="Device 3", area_id=0), ] device_info = { "devices": sub_devices_initial, } device = await mock_esphome_device( mock_client=mock_client, device_info=device_info, ) # Verify all sub devices exist assert ( device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_11111111"), device.entry.entry_id, ) is not None ) assert ( device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id, ) is not None ) assert ( device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_33333333"), device.entry.entry_id, ) is not None ) # Now update with fewer sub devices (device 2 removed) sub_devices_updated = [ SubDeviceInfo(device_id=11111111, name="Device 1", area_id=0), SubDeviceInfo(device_id=33333333, name="Device 3", area_id=0), ] # Update device info device.device_info = DeviceInfo( name="test", friendly_name="Test", esphome_version="1.0.0", mac_address="11:22:33:44:55:AA", devices=sub_devices_updated, ) # Update the mock client to return the new device info mock_client.device_info = AsyncMock(return_value=device.device_info) mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.device_info_and_list_entities = AsyncMock( return_value=(device.device_info, [], []) ) # Simulate reconnection which triggers device registry update await device.mock_connect() await hass.async_block_till_done() # Verify device 2 was removed assert ( device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_11111111"), device.entry.entry_id, ) is not None ) assert ( device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id, ) is None ) # Should be removed assert ( device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_33333333"), device.entry.entry_id, ) is not None ) async def test_sub_device_with_empty_name( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test sub devices with empty names are handled correctly.""" device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures # Define sub devices with empty names sub_devices = [ SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name SubDeviceInfo(device_id=22222222, name="Valid Name", area_id=0), ] device_info = { "devices": sub_devices, } device = await mock_esphome_device( mock_client=mock_client, device_info=device_info, ) await hass.async_block_till_done() # Check sub device with empty name sub_device_1 = device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_11111111"), device.entry.entry_id ) assert sub_device_1 is not None # Empty sub-device names should fall back to main device name main_device = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address), device.entry.entry_id, ) assert sub_device_1.name == main_device.name # Check sub device with valid name sub_device_2 = device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id ) assert sub_device_2 is not None assert sub_device_2.name == "Valid Name" async def test_sub_device_references_main_device_area( hass: HomeAssistant, area_registry: ar.AreaRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test sub devices can reference the main device's area.""" device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures # Define areas - note we don't include area_id=0 in the areas list areas = [ AreaInfo(area_id=1, name="Living Room"), AreaInfo(area_id=2, name="Bedroom"), ] # Define sub devices - one references the main device's area (area_id=0) sub_devices = [ SubDeviceInfo( device_id=11111111, name="Motion Sensor", area_id=0 ), # Main device area SubDeviceInfo( device_id=22222222, name="Light Switch", area_id=1 ), # Living Room SubDeviceInfo( device_id=33333333, name="Temperature Sensor", area_id=2 ), # Bedroom ] device_info = { "areas": areas, "devices": sub_devices, "area": AreaInfo(area_id=0, name="Main Hub Area"), } device = await mock_esphome_device( mock_client=mock_client, device_info=device_info, ) # Check main device has correct area main_device = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address), device.entry.entry_id, ) assert main_device is not None assert ( main_device.area_id == area_registry.async_get_area_by_name("Main Hub Area").id ) # Check sub device 1 uses main device's area sub_device_1 = device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_11111111"), device.entry.entry_id ) assert sub_device_1 is not None assert ( sub_device_1.area_id == area_registry.async_get_area_by_name("Main Hub Area").id ) # Check sub device 2 uses Living Room sub_device_2 = device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id ) assert sub_device_2 is not None assert ( sub_device_2.area_id == area_registry.async_get_area_by_name("Living Room").id ) # Check sub device 3 uses Bedroom sub_device_3 = device_registry.async_get_device_by_identifier( (DOMAIN, f"{device.device_info.mac_address}_33333333"), device.entry.entry_id ) assert sub_device_3 is not None assert sub_device_3.area_id == area_registry.async_get_area_by_name("Bedroom").id @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dynamic_encryption_key_generation( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], ) -> None: """Test that a device without a key in storage gets a new one generated.""" mac_address = "11:22:33:44:55:aa" test_key_bytes = b"test_key_32_bytes_long_exactly!" mock_token_bytes.return_value = test_key_bytes expected_key = base64.b64encode(test_key_bytes).decode() # Create entry without noise PSK entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", }, unique_id=mac_address, ) entry.add_to_hass(hass) # Mock the client methods mock_client.noise_encryption_set_key = AsyncMock(return_value=True) # Set up device with encryption support device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) # Force reconnect to trigger key generation await device.mock_disconnect(True) await device.mock_connect() # Verify the key was generated and set mock_token_bytes.assert_called_once_with(32) mock_client.noise_encryption_set_key.assert_called_once() # Verify config entry was updated assert entry.data[CONF_NOISE_PSK] == expected_key async def test_manager_retrieves_key_from_storage_on_reconnect( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], ) -> None: """Test that manager retrieves encryption key from storage during reconnect.""" mac_address = "11:22:33:44:55:aa" test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() # Set up storage with existing key hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { "version": 1, "minor_version": 1, "key": ENCRYPTION_KEY_STORAGE_KEY, "data": {"keys": {mac_address: test_key}}, } # Create entry without noise PSK (will be loaded from storage) entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", }, unique_id=mac_address, ) entry.add_to_hass(hass) # Mock the client methods mock_client.noise_encryption_set_key = AsyncMock(return_value=True) # Set up device with encryption support device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) # Force reconnect to trigger key retrieval from storage await device.mock_disconnect(True) await device.mock_connect() # Verify noise_encryption_set_key was called with the stored key mock_client.noise_encryption_set_key.assert_called_once_with(test_key.encode()) # Verify config entry was updated with key from storage assert entry.data[CONF_NOISE_PSK] == test_key async def test_manager_handle_dynamic_encryption_key_guard_clauses( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test _handle_dynamic_encryption_key guard clauses and early returns.""" # Test guard clause - no unique_id entry_no_id = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", }, unique_id=None, # No unique ID - should not generate key ) entry_no_id.add_to_hass(hass) # Set up device without unique ID device = await mock_esphome_device( mock_client=mock_client, entry=entry_no_id, device_info={ "uses_password": False, "name": "test-device", "mac_address": "11:22:33:44:55:aa", "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) # noise_encryption_set_key should not be called when no unique_id mock_client.noise_encryption_set_key = AsyncMock() await device.mock_disconnect(True) await device.mock_connect() mock_client.noise_encryption_set_key.assert_not_called() async def test_manager_handle_dynamic_encryption_key_edge_cases( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test _handle_dynamic_encryption_key edge cases for better coverage.""" mac_address = "11:22:33:44:55:aa" # Test device without encryption support entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", }, unique_id=mac_address, ) entry.add_to_hass(hass) # Set up device without encryption support device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": False, # No encryption support }, ) # noise_encryption_set_key should not be called when encryption not supported mock_client.noise_encryption_set_key = AsyncMock() await device.mock_disconnect(True) await device.mock_connect() mock_client.noise_encryption_set_key.assert_not_called() @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_manager_dynamic_encryption_key_generation_flow( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], ) -> None: """Test the complete dynamic encryption key generation flow.""" mac_address = "11:22:33:44:55:aa" test_key_bytes = b"test_key_32_bytes_long_exactly!" mock_token_bytes.return_value = test_key_bytes expected_key = base64.b64encode(test_key_bytes).decode() # Initialize empty storage hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { "version": 1, "minor_version": 1, "key": ENCRYPTION_KEY_STORAGE_KEY, "data": { "keys": {} # No existing keys }, } # Create entry without noise PSK entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", }, unique_id=mac_address, ) entry.add_to_hass(hass) # Mock the client methods mock_client.noise_encryption_set_key = AsyncMock(return_value=True) # Set up device with encryption support device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) # Force reconnect to trigger key generation await device.mock_disconnect(True) await device.mock_connect() # Verify the complete flow mock_token_bytes.assert_called_once_with(32) mock_client.noise_encryption_set_key.assert_called_once() assert entry.data[CONF_NOISE_PSK] == expected_key # Verify key was stored in hass_storage assert ( hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] == expected_key ) @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_manager_handle_dynamic_encryption_key_no_existing_key( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], ) -> None: """Test _handle_dynamic_encryption_key when no existing key is found.""" mac_address = "11:22:33:44:55:aa" test_key_bytes = b"test_key_32_bytes_long_exactly!" mock_token_bytes.return_value = test_key_bytes expected_key = base64.b64encode(test_key_bytes).decode() # Initialize empty storage hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { "version": 1, "minor_version": 1, "key": ENCRYPTION_KEY_STORAGE_KEY, "data": { "keys": {} # No existing keys }, } # Create entry without noise PSK entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", }, unique_id=mac_address, ) entry.add_to_hass(hass) # Mock the client methods mock_client.noise_encryption_set_key = AsyncMock(return_value=True) # Set up device with encryption support device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) # Force reconnect to trigger key generation await device.mock_disconnect(True) await device.mock_connect() # Verify key generation flow mock_token_bytes.assert_called_once_with(32) mock_client.noise_encryption_set_key.assert_called_once() # Verify config entry was updated assert entry.data[CONF_NOISE_PSK] == expected_key # Verify key was stored assert ( hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] == expected_key ) @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_manager_handle_dynamic_encryption_key_device_set_key_fails( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], ) -> None: """Test dynamic encryption key when set_key returns False.""" mac_address = "11:22:33:44:55:aa" test_key_bytes = b"test_key_32_bytes_long_exactly!" mock_token_bytes.return_value = test_key_bytes # Initialize empty storage hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { "version": 1, "minor_version": 1, "key": ENCRYPTION_KEY_STORAGE_KEY, "data": { "keys": {} # No existing keys }, } # Create entry without noise PSK entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", }, unique_id=mac_address, ) entry.add_to_hass(hass) # Mock the client methods - set_key returns False mock_client.noise_encryption_set_key = AsyncMock(return_value=False) # Set up device with encryption support device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) # Reset mocks since initial connection already happened mock_token_bytes.reset_mock() mock_client.noise_encryption_set_key.reset_mock() # Force reconnect to trigger key generation await device.mock_disconnect(True) await device.mock_connect() # Verify key generation was attempted with the expected key mock_token_bytes.assert_called_once_with(32) mock_client.noise_encryption_set_key.assert_called_once_with( base64.b64encode(test_key_bytes) ) # Verify config entry was NOT updated since set_key failed assert CONF_NOISE_PSK not in entry.data @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_manager_handle_dynamic_encryption_key_connection_error( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], ) -> None: """Test dynamic encryption key when set_key raises APIConnectionError.""" mac_address = "11:22:33:44:55:aa" test_key_bytes = b"test_key_32_bytes_long_exactly!" mock_token_bytes.return_value = test_key_bytes # Initialize empty storage hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { "version": 1, "minor_version": 1, "key": ENCRYPTION_KEY_STORAGE_KEY, "data": { "keys": {} # No existing keys }, } # Create entry without noise PSK entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", }, unique_id=mac_address, ) entry.add_to_hass(hass) # Mock the client methods - set_key raises APIConnectionError mock_client.noise_encryption_set_key = AsyncMock( side_effect=APIConnectionError("Connection failed") ) # Set up device with encryption support device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) # Force reconnect to trigger key generation await device.mock_disconnect(True) await device.mock_connect() # Verify key generation was attempted twice # (once during setup, once during reconnect) # This is expected because the first attempt failed with connection error assert mock_token_bytes.call_count == 2 mock_token_bytes.assert_called_with(32) assert mock_client.noise_encryption_set_key.call_count == 2 # Verify config entry was NOT updated since connection error occurred assert CONF_NOISE_PSK not in entry.data # Verify key was NOT stored due to connection error assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] @pytest.fixture def mock_provisioning_client(mock_client: APIClient) -> Generator[Mock]: """Mock the APIClient built for the zero PSK provisioning connection.""" client = Mock(spec=APIClient) client.connect = AsyncMock() client.disconnect = AsyncMock() client.noise_encryption_set_key = AsyncMock(return_value=True) def _api_client(*args: Any, **kwargs: Any) -> Mock: if kwargs.get("noise_psk") == ZERO_NOISE_PSK: return client return mock_client(*args, **kwargs) with patch( "homeassistant.components.esphome.manager.APIClient", side_effect=_api_client ): yield client def _make_provisionable_entry(hass: HomeAssistant, mac_address: str) -> MockConfigEntry: """Create a config entry without a noise PSK.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", }, unique_id=mac_address, ) entry.add_to_hass(hass) return entry @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dynamic_encryption_key_provisioned_over_zero_psk( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_provisioning_client: Mock, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], ) -> None: """Test provisionable firmware gets the key over a zero PSK connection.""" mac_address = "11:22:33:44:55:aa" test_key_bytes = b"test_key_32_bytes_long_exactly!" mock_token_bytes.return_value = test_key_bytes expected_key = base64.b64encode(test_key_bytes).decode() entry = _make_provisionable_entry(hass, mac_address) # The main (plaintext) client must never be used to push the key mock_client.noise_encryption_set_key = AsyncMock(return_value=True) device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2026.8.0", "api_encryption_supported": True, "api_encryption_provisionable": True, }, ) await device.mock_disconnect(True) await device.mock_connect() # The key went over the zero PSK client (the fixture only hands it out # for constructions using ZERO_NOISE_PSK), not the plaintext connection mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( base64.b64encode(test_key_bytes) ) mock_client.noise_encryption_set_key.assert_not_called() mock_provisioning_client.disconnect.assert_called_with(force=True) # Entry and storage were updated assert entry.data[CONF_NOISE_PSK] == expected_key assert ( hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] == expected_key ) async def test_dynamic_encryption_key_provisioned_over_zero_psk_from_storage( hass: HomeAssistant, mock_client: APIClient, mock_provisioning_client: Mock, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], ) -> None: """Test a stored key is re-provisioned over the zero PSK connection.""" mac_address = "11:22:33:44:55:aa" test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { "version": 1, "minor_version": 1, "key": ENCRYPTION_KEY_STORAGE_KEY, "data": {"keys": {mac_address: test_key}}, } entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2026.8.0", "api_encryption_supported": True, "api_encryption_provisionable": True, }, ) await device.mock_disconnect(True) await device.mock_connect() mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( test_key.encode() ) mock_client.noise_encryption_set_key.assert_not_called() assert entry.data[CONF_NOISE_PSK] == test_key @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dynamic_encryption_key_synced_to_dashboard( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], ) -> None: """Test a newly generated key is pushed to the dashboard.""" mac_address = "11:22:33:44:55:aa" test_key_bytes = b"test_key_32_bytes_long_exactly!" mock_token_bytes.return_value = test_key_bytes expected_key = base64.b64encode(test_key_bytes).decode() entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, return_value={"result": "stored"}, ) as mock_post_key: device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) assert entry.data[CONF_NOISE_PSK] == expected_key # Provisioning-time push first; the reconnect re-offers the same key. assert mock_post_key.await_args_list[0] == call( "test-device", expected_key, mac=mac_address ) async def test_dynamic_encryption_key_from_storage_synced_to_dashboard( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], ) -> None: """Test a storage-recovered key is pushed to the dashboard too.""" mac_address = "11:22:33:44:55:aa" test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { "version": 1, "minor_version": 1, "key": ENCRYPTION_KEY_STORAGE_KEY, "data": {"keys": {mac_address: test_key}}, } entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, return_value={"result": "stored"}, ) as mock_post_key: device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) assert entry.data[CONF_NOISE_PSK] == test_key assert mock_post_key.await_args_list[0] == call( "test-device", test_key, mac=mac_address ) @pytest.mark.parametrize( ("sync_error", "expect_warning"), [ ( aiohttp.ClientResponseError(request_info=Mock(), history=(), status=404), False, ), ( aiohttp.ClientResponseError(request_info=Mock(), history=(), status=405), False, ), ( aiohttp.ClientResponseError(request_info=Mock(), history=(), status=500), True, ), (aiohttp.ClientError("boom"), True), (TimeoutError(), True), (json.JSONDecodeError("boom", "x", 0), True), ], ) @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dynamic_encryption_key_dashboard_sync_failure_is_not_fatal( mock_token_bytes: Mock, hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], sync_error: Exception, expect_warning: bool, caplog: pytest.LogCaptureFixture, ) -> None: """Test an old dashboard (404) or a flaky one never fails the connect flow.""" mac_address = "11:22:33:44:55:aa" test_key_bytes = b"test_key_32_bytes_long_exactly!" mock_token_bytes.return_value = test_key_bytes expected_key = base64.b64encode(test_key_bytes).decode() entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, side_effect=sync_error, ) as mock_post_key: device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) assert mock_post_key.await_count >= 1 # The flow must have run to completion — a sync exception escaping # _on_connect would skip the device-registry setup after the handoff. assert ( device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, mac_address), entry.entry_id ) is not None ) assert entry.data[CONF_NOISE_PSK] == expected_key assert ( hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] == expected_key ) # Endpoint-absent (404/405) stays debug; real failures warn so the # possible lockout is visible. assert ("could not store the encryption key" in caplog.text) is expect_warning @pytest.mark.parametrize( "unexpected", [ pytest.param({"error": "unknown device"}, id="not_an_outcome"), pytest.param(["valid", "json", "wrong", "shape"], id="not_a_dict"), pytest.param({"result": ["updated"]}, id="unhashable_result"), ], ) @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dashboard_unexpected_sync_result_logs_warning( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], caplog: pytest.LogCaptureFixture, unexpected: Any, ) -> None: """Test an unrecognized dashboard response is treated as a failed handoff.""" mac_address = "11:22:33:44:55:aa" mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, return_value=unexpected, ): device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) assert caplog.text.count("could not store the encryption key") == 1 assert "unexpected response" in caplog.text async def test_existing_key_resynced_to_dashboard_on_connect( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], ) -> None: """Test a connect with a proven-valid HA-provisioned key re-offers it.""" mac_address = "11:22:33:44:55:aa" test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { "version": 1, "minor_version": 1, "key": ENCRYPTION_KEY_STORAGE_KEY, "data": {"keys": {mac_address: test_key}}, } entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", CONF_NOISE_PSK: test_key, }, unique_id=mac_address, ) entry.add_to_hass(hass) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, return_value={"result": "unchanged", "configurations": ["test-device.yaml"]}, ) as mock_post_key: device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) mock_post_key.assert_awaited_with("test-device", test_key, mac=mac_address) # No success latch: every connect re-offers so a dashboard whose # copy was deleted gets it back; the dashboard no-ops otherwise. assert mock_post_key.await_count == 2 async def test_user_provided_key_not_resynced_to_dashboard( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], ) -> None: """Test a user-authored YAML key (absent from storage) is never pushed.""" mac_address = "11:22:33:44:55:aa" test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-device", CONF_NOISE_PSK: test_key, }, unique_id=mac_address, ) entry.add_to_hass(hass) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, ) as mock_post_key: device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) mock_post_key.assert_not_awaited() @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dashboard_not_writable_response_logs_warning( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], caplog: pytest.LogCaptureFixture, ) -> None: """Test a dashboard that declined the key surfaces an actionable warning.""" mac_address = "11:22:33:44:55:aa" test_key_bytes = b"test_key_32_bytes_long_exactly!" mock_token_bytes.return_value = test_key_bytes expected_key = base64.b64encode(test_key_bytes).decode() entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, return_value={ "result": "not_writable", "configurations": ["test-device.yaml"], "reason": "the key is provided via !secret or a substitution", }, ): device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) assert entry.data[CONF_NOISE_PSK] == expected_key assert caplog.text.count("could not store the encryption key") == 1 assert "!secret" in caplog.text @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dashboard_partial_success_warns_and_keeps_retrying( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], caplog: pytest.LogCaptureFixture, ) -> None: """Test an updated-with-reason response warns and does not latch success.""" mac_address = "11:22:33:44:55:aa" mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, return_value={ "result": "updated", "configurations": ["test-device.yaml", "test-device (1).yaml"], "reason": "the key is provided via !secret or a substitution", }, ) as mock_post_key: device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) # The duplicate-name sibling still carries a competing key: warn # (once) while reconnects keep retrying. assert caplog.text.count("could not store the encryption key") == 1 assert "!secret" in caplog.text assert mock_post_key.await_count == 2 @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dashboard_sync_warns_once_across_causes( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], caplog: pytest.LogCaptureFixture, ) -> None: """Test the handoff warns exactly once regardless of failure causes.""" mac_address = "11:22:33:44:55:aa" mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, side_effect=[ aiohttp.ClientError("boom"), {"result": "not_writable", "reason": "the key is provided via !secret"}, ], ): device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) assert caplog.text.count("could not store the encryption key") == 1 assert "boom" in caplog.text assert "!secret" not in caplog.text @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dynamic_encryption_key_not_synced_without_dashboard( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], ) -> None: """Test no dashboard registered means no push is attempted.""" mac_address = "11:22:33:44:55:aa" mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, ) as mock_post_key: device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) assert entry.data[CONF_NOISE_PSK] != "" mock_post_key.assert_not_awaited() @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dynamic_encryption_key_not_synced_when_provisioning_fails( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], mock_dashboard: dict[str, Any], ) -> None: """Test a key the device rejected is never pushed to the dashboard.""" mac_address = "11:22:33:44:55:aa" mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=False) with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", new_callable=AsyncMock, ) as mock_post_key: device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2023.12.0", "api_encryption_supported": True, }, ) await device.mock_disconnect(True) await device.mock_connect() await hass.async_block_till_done(wait_background_tasks=True) assert CONF_NOISE_PSK not in entry.data or entry.data[CONF_NOISE_PSK] == "" mock_post_key.assert_not_awaited() @pytest.mark.parametrize( ("connect_error", "set_key_result"), [ # Device already has a key (distinct log branch) (InvalidEncryptionKeyAPIError("already keyed"), True), # Old firmware answering plaintext to the noise hello (generic branch; # all connection errors are APIConnectionError subclasses) (EncryptionPlaintextAPIError("plaintext"), True), # Device accepted the connection but rejected the key (None, False), ], ) @patch("homeassistant.components.esphome.manager.secrets.token_bytes") async def test_dynamic_encryption_key_zero_psk_failures_never_use_plaintext( mock_token_bytes: Mock, hass: HomeAssistant, mock_client: APIClient, mock_provisioning_client: Mock, mock_esphome_device: MockESPHomeDeviceType, hass_storage: dict[str, Any], connect_error: Exception | None, set_key_result: bool, ) -> None: """Test zero PSK provisioning failures do not fall back to plaintext.""" mac_address = "11:22:33:44:55:aa" mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { "version": 1, "minor_version": 1, "key": ENCRYPTION_KEY_STORAGE_KEY, "data": {"keys": {}}, } entry = _make_provisionable_entry(hass, mac_address) mock_client.noise_encryption_set_key = AsyncMock(return_value=True) # A None side_effect leaves connect behaving normally mock_provisioning_client.connect.side_effect = connect_error mock_provisioning_client.noise_encryption_set_key.return_value = set_key_result device = await mock_esphome_device( mock_client=mock_client, entry=entry, device_info={ "uses_password": False, "name": "test-device", "mac_address": mac_address, "esphome_version": "2026.8.0", "api_encryption_supported": True, "api_encryption_provisionable": True, }, ) await device.mock_disconnect(True) await device.mock_connect() # The plaintext connection was never used to push the key, the entry was # not updated, and no generated key was stored mock_client.noise_encryption_set_key.assert_not_called() assert CONF_NOISE_PSK not in entry.data assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] mock_provisioning_client.disconnect.assert_called_with(force=True) def test_zero_noise_psk_is_not_the_probe_key() -> None: """Test the provisioning PSK is 32 zero bytes and differs from the probe.""" assert base64.b64decode(ZERO_NOISE_PSK) == bytes(32) assert ZERO_NOISE_PSK != PROBE_NOISE_PSK async def test_zwave_proxy_request_home_id_change( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test Z-Wave proxy request handler with HOME_ID_CHANGE request.""" noise_psk = "cD3vRGhSJTMgc2VjdXJlIG5vaXNlIHBzayBoZXJlIQ==" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_DEVICE_NAME: "test-zwave-proxy", CONF_NOISE_PSK: noise_psk, }, unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) device_info = { "name": "test-zwave-proxy", "mac_address": "11:22:33:44:55:AA", "zwave_proxy_feature_flags": 1, } await mock_esphome_device( mock_client=mock_client, entry=entry, device_info=device_info, ) await hass.async_block_till_done() # Get the manager's _async_zwave_proxy_request callback # It's registered via subscribe_zwave_proxy_request zwave_proxy_callback = None for call_item in mock_client.subscribe_zwave_proxy_request.call_args_list: if call_item[0]: zwave_proxy_callback = call_item[0][0] break assert zwave_proxy_callback is not None # Create a mock request with a different type (not HOME_ID_CHANGE) # Assuming there are other types, we'll use a placeholder value request = ZWaveProxyRequest( type=0, # Not HOME_ID_CHANGE data=b"\x00\x00\x00\x01", ) # Track flow creation with patch( "homeassistant.helpers.discovery_flow.async_create_flow" ) as mock_create_flow: # Call the callback zwave_proxy_callback(request) await hass.async_block_till_done() # Verify no flow was created for non-HOME_ID_CHANGE requests mock_create_flow.assert_not_called() # A dynamically provisioned key is written to the config entry but cannot # be applied to the already connected client, so the client reports no PSK. mock_client.noise_psk = None # Create a mock request with HOME_ID_CHANGE type and zwave_home_id as bytes zwave_home_id = 1234567890 request = ZWaveProxyRequest( type=ZWaveProxyRequestType.HOME_ID_CHANGE, data=zwave_home_id.to_bytes(4, byteorder="big") + b"\x00\x00", # Extra bytes should be ignored ) # Track flow creation with patch( "homeassistant.helpers.discovery_flow.async_create_flow" ) as mock_create_flow: # Call the callback zwave_proxy_callback(request) await hass.async_block_till_done() # Verify async_create_zwave_js_flow was called with correct arguments mock_create_flow.assert_called_once() call_args = mock_create_flow.call_args assert call_args[0][0] == hass assert call_args[0][1] == "zwave_js" # The noise PSK is taken from the config entry, not the live client assert call_args[0][3].noise_psk == noise_psk async def test_no_zwave_proxy_subscribe_without_feature_flags( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test Z-Wave proxy subscription skipped without feature flags.""" device_info = { "name": "test-device", "mac_address": "11:22:33:44:55:AA", "zwave_proxy_feature_flags": 0, # No Z-Wave proxy features } # Mock the subscribe_zwave_proxy_request method mock_client.subscribe_zwave_proxy_request = Mock(return_value=lambda: None) await mock_esphome_device( mock_client=mock_client, device_info=device_info, ) await hass.async_block_till_done() # Verify subscribe_zwave_proxy_request was NOT called mock_client.subscribe_zwave_proxy_request.assert_not_called() async def test_execute_service_response_type_none( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service with SupportsResponseType.NONE (fire and forget).""" service = UserService( name="fire_forget_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.NONE, ) # For NONE type, no response is expected mock_client.execute_service = AsyncMock(return_value=None) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, "test_fire_forget_service") # Call the service - should be fire and forget await hass.services.async_call( DOMAIN, "test_fire_forget_service", {"arg1": True}, blocking=True ) await hass.async_block_till_done() # Verify execute_service was called without extra kwargs (fire and forget) mock_client.execute_service.assert_called_once() call_args = mock_client.execute_service.call_args assert call_args[0][1] == {"arg1": True} # Fire and forget - no return_response or other kwargs assert call_args[1] == {} async def test_execute_service_response_type_status( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service with SupportsResponseType.STATUS.""" service = UserService( name="status_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.STATUS, ) # Set up mock response mock_client.execute_service = AsyncMock( return_value=ExecuteServiceResponse( call_id=1, success=True, error_message="", response_data=b"", ) ) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() # Call the service - should wait for response but not return data # Note: STATUS maps to SupportsResponse.NONE so we can't use return_response=True await hass.services.async_call( DOMAIN, "test_status_service", {"arg1": True}, blocking=True ) await hass.async_block_till_done() # Verify return_response was False (STATUS doesn't need response_data) call_args = mock_client.execute_service.call_args assert call_args[1].get("return_response") is False async def test_execute_service_response_type_optional_without_return( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service OPTIONAL when caller skips response.""" service = UserService( name="optional_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.OPTIONAL, ) # Set up mock response mock_client.execute_service = AsyncMock( return_value=ExecuteServiceResponse( call_id=1, success=True, error_message="", response_data=b'{"result": "data"}', ) ) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() # Call without return_response - should still wait but not return data result = await hass.services.async_call( DOMAIN, "test_optional_service", {"arg1": True}, blocking=True ) await hass.async_block_till_done() assert result is None # Verify return_response was False (caller didn't request it) call_args = mock_client.execute_service.call_args assert call_args[1].get("return_response") is False async def test_execute_service_response_type_optional_with_return( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service OPTIONAL when caller requests response.""" service = UserService( name="optional_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.OPTIONAL, ) # Set up mock response with data mock_client.execute_service = AsyncMock( return_value=ExecuteServiceResponse( call_id=1, success=True, error_message="", response_data=b'{"result": "data"}', ) ) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() # Call with return_response=True result = await hass.services.async_call( DOMAIN, "test_optional_service", {"arg1": True}, blocking=True, return_response=True, ) await hass.async_block_till_done() # Should return parsed JSON data assert result == {"result": "data"} # Verify return_response was True call_args = mock_client.execute_service.call_args assert call_args[1].get("return_response") is True async def test_execute_service_response_type_only( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service with SupportsResponseType.ONLY.""" service = UserService( name="only_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.ONLY, ) # Set up mock response mock_client.execute_service = AsyncMock( return_value=ExecuteServiceResponse( call_id=1, success=True, error_message="", response_data=b'{"status": "ok", "value": 42}', ) ) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() # Call the service - ONLY type always returns data result = await hass.services.async_call( DOMAIN, "test_only_service", {"arg1": True}, blocking=True, return_response=True ) await hass.async_block_till_done() assert result == {"status": "ok", "value": 42} # Verify return_response was True call_args = mock_client.execute_service.call_args assert call_args[1].get("return_response") is True async def test_execute_service_timeout( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service timeout handling.""" service = UserService( name="slow_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.STATUS, ) # Mock execute_service to raise TimeoutError mock_client.execute_service = AsyncMock(side_effect=TimeoutError()) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( DOMAIN, "test_slow_service", {"arg1": True}, blocking=True ) assert "Timeout" in str(exc_info.value) async def test_execute_service_connection_error( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service connection error handling.""" service = UserService( name="error_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.NONE, ) mock_client.execute_service = AsyncMock( side_effect=APIConnectionError("Connection lost") ) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( DOMAIN, "test_error_service", {"arg1": True}, blocking=True ) assert "Connection lost" in str(exc_info.value) async def test_execute_service_connection_error_with_response( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service connection error when waiting for response.""" service = UserService( name="error_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.STATUS, # Uses response path ) mock_client.execute_service = AsyncMock( side_effect=APIConnectionError("Connection lost") ) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( DOMAIN, "test_error_service", {"arg1": True}, blocking=True ) assert "Connection lost" in str(exc_info.value) async def test_execute_service_failure_response( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service with failure response from device.""" service = UserService( name="failing_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.STATUS, ) # Set up mock failure response mock_client.execute_service = AsyncMock( return_value=ExecuteServiceResponse( call_id=1, success=False, error_message="Device reported error: invalid argument", response_data=b"", ) ) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( DOMAIN, "test_failing_service", {"arg1": True}, blocking=True ) assert "invalid argument" in str(exc_info.value) async def test_execute_service_invalid_json_response( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test execute_service with invalid JSON in response data.""" service = UserService( name="bad_json_service", key=1, args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], supports_response=SupportsResponseType.ONLY, ) # Set up mock response with invalid JSON mock_client.execute_service = AsyncMock( return_value=ExecuteServiceResponse( call_id=1, success=True, error_message="", response_data=b"not valid json {{{", ) ) await mock_esphome_device( mock_client=mock_client, user_service=[service], device_info={"name": "test"}, ) await hass.async_block_till_done() with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( DOMAIN, "test_bad_json_service", {"arg1": True}, blocking=True, return_response=True, ) assert "Invalid JSON response" in str(exc_info.value) async def test_service_registration_response_types( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that services are registered with correct SupportsResponse types.""" services = [ UserService( name="none_service", key=1, args=[], supports_response=SupportsResponseType.NONE, ), UserService( name="optional_service", key=2, args=[], supports_response=SupportsResponseType.OPTIONAL, ), UserService( name="only_service", key=3, args=[], supports_response=SupportsResponseType.ONLY, ), UserService( name="status_service", key=4, args=[], supports_response=SupportsResponseType.STATUS, ), ] await mock_esphome_device( mock_client=mock_client, user_service=services, device_info={"name": "test"}, ) await hass.async_block_till_done() # Verify all services are registered assert hass.services.has_service(DOMAIN, "test_none_service") assert hass.services.has_service(DOMAIN, "test_optional_service") assert hass.services.has_service(DOMAIN, "test_only_service") assert hass.services.has_service(DOMAIN, "test_status_service") # Verify response types are correctly mapped using public API # NONE -> SupportsResponse.NONE # OPTIONAL -> SupportsResponse.OPTIONAL # ONLY -> SupportsResponse.ONLY # STATUS -> SupportsResponse.NONE (no data returned to HA) assert ( hass.services.supports_response(DOMAIN, "test_none_service") == SupportsResponse.NONE ) assert ( hass.services.supports_response(DOMAIN, "test_optional_service") == SupportsResponse.OPTIONAL ) assert ( hass.services.supports_response(DOMAIN, "test_only_service") == SupportsResponse.ONLY ) assert ( hass.services.supports_response(DOMAIN, "test_status_service") == SupportsResponse.NONE ) def _create_cached_bluetooth_proxy_entry( hass: HomeAssistant, hass_storage: dict[str, Any], bluetooth_proxy_feature_flags: BluetoothProxyFeature, ) -> tuple[MockConfigEntry, DeviceInfo]: """Create an entry with cached device info so setup knows the proxy state.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="11:22:33:44:55:aa", data={ CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: "", CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC", }, ) entry.add_to_hass(hass) device_info = DeviceInfo( name="test", mac_address="11:22:33:44:55:AA", bluetooth_mac_address="AA:BB:CC:DD:EE:FC", bluetooth_proxy_feature_flags=bluetooth_proxy_feature_flags, ) storage_key = f"{DOMAIN}.{entry.entry_id}" hass_storage[storage_key] = { "version": 1, "minor_version": 1, "key": storage_key, "data": { "device_info": device_info.to_dict(), "api_version": APIVersion(1, 9).to_dict(), }, } return entry, device_info @pytest.mark.usefixtures("mock_zeroconf") async def test_bluetooth_proxy_waits_for_scanner_at_startup( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], ) -> None: """Test setup waits for a cached bluetooth proxy to register its scanner.""" entry, device_info = _create_cached_bluetooth_proxy_entry( hass, hass_storage, BluetoothProxyFeature.PASSIVE_SCAN ) connect_event = asyncio.Event() reached_connect = asyncio.Event() async def _block_until_released() -> tuple[DeviceInfo, list[Any], list[Any]]: reached_connect.set() await connect_event.wait() return (device_info, [], []) mock_client.device_info_and_list_entities = _block_until_released setup_task = hass.async_create_task(hass.config_entries.async_setup(entry.entry_id)) async with asyncio.timeout(2): await reached_connect.wait() # Setup must still be waiting for the scanner to be registered. assert not setup_task.done() connect_event.set() async with asyncio.timeout(2): assert await setup_task is True assert entry.runtime_data.first_connect_done.is_set() @pytest.mark.usefixtures("mock_zeroconf") async def test_bluetooth_proxy_startup_wait_times_out( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], ) -> None: """Test setup finishes if a cached bluetooth proxy never connects.""" entry, device_info = _create_cached_bluetooth_proxy_entry( hass, hass_storage, BluetoothProxyFeature.PASSIVE_SCAN ) connect_event = asyncio.Event() async def _never_returns() -> tuple[DeviceInfo, list[Any], list[Any]]: await connect_event.wait() return (device_info, [], []) mock_client.device_info_and_list_entities = _never_returns with patch("homeassistant.components.esphome.manager.STARTUP_SCANNER_WAIT", 0.05): async with asyncio.timeout(2): assert await hass.config_entries.async_setup(entry.entry_id) is True connect_event.set() await hass.async_block_till_done() @pytest.mark.usefixtures("mock_zeroconf") async def test_non_bluetooth_device_does_not_wait_at_startup( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], ) -> None: """Test setup does not wait for a device that is not a bluetooth proxy.""" entry, device_info = _create_cached_bluetooth_proxy_entry( hass, hass_storage, BluetoothProxyFeature(0) ) connect_event = asyncio.Event() async def _never_returns() -> tuple[DeviceInfo, list[Any], list[Any]]: await connect_event.wait() return (device_info, [], []) mock_client.device_info_and_list_entities = _never_returns # The connection is blocked, but without proxy flags setup must not wait. async with asyncio.timeout(2): assert await hass.config_entries.async_setup(entry.entry_id) is True connect_event.set() await hass.async_block_till_done()