"""Test ESPHome update entities.""" import asyncio from collections.abc import Awaitable, Callable import dataclasses from unittest.mock import Mock, patch from aioesphomeapi import APIClient, EntityInfo, EntityState, UserService import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard from homeassistant.components.update import UpdateEntityFeature from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import MockESPHomeDevice @pytest.fixture def stub_reconnect(): """Stub reconnect.""" with patch("homeassistant.components.esphome.manager.ReconnectLogic.start"): yield @pytest.mark.parametrize( ("devices_payload", "expected_state", "expected_attributes"), [ ( [ { "name": "test", "current_version": "2023.2.0-dev", "configuration": "test.yaml", } ], STATE_ON, { "latest_version": "2023.2.0-dev", "installed_version": "1.0.0", "supported_features": UpdateEntityFeature.INSTALL, }, ), ( [ { "name": "test", "current_version": "1.0.0", }, ], STATE_OFF, { "latest_version": "1.0.0", "installed_version": "1.0.0", "supported_features": 0, }, ), ( [], STATE_UNKNOWN, # dashboard is available but device is unknown {"supported_features": 0}, ), ], ) async def test_update_entity( hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info, mock_dashboard, devices_payload, expected_state, expected_attributes, ) -> None: """Test ESPHome update entity.""" mock_dashboard["configured"] = devices_payload await async_get_dashboard(hass).async_refresh() with patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", return_value=Mock(available=True, device_info=mock_device_info), ): assert await hass.config_entries.async_forward_entry_setup( mock_config_entry, "update" ) state = hass.states.get("update.none_firmware") assert state is not None assert state.state == expected_state for key, expected_value in expected_attributes.items(): assert state.attributes.get(key) == expected_value if expected_state != "on": return # Compile failed, don't try to upload with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=False ) as mock_compile, patch( "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True ) as mock_upload, pytest.raises( HomeAssistantError, match="compiling" ): await hass.services.async_call( "update", "install", {"entity_id": "update.none_firmware"}, blocking=True, ) assert len(mock_compile.mock_calls) == 1 assert mock_compile.mock_calls[0][1][0] == "test.yaml" assert len(mock_upload.mock_calls) == 0 # Compile success, upload fails with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True ) as mock_compile, patch( "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=False ) as mock_upload, pytest.raises( HomeAssistantError, match="OTA" ): await hass.services.async_call( "update", "install", {"entity_id": "update.none_firmware"}, blocking=True, ) assert len(mock_compile.mock_calls) == 1 assert mock_compile.mock_calls[0][1][0] == "test.yaml" assert len(mock_upload.mock_calls) == 1 assert mock_upload.mock_calls[0][1][0] == "test.yaml" # Everything works with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True ) as mock_compile, patch( "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True ) as mock_upload: await hass.services.async_call( "update", "install", {"entity_id": "update.none_firmware"}, blocking=True, ) assert len(mock_compile.mock_calls) == 1 assert mock_compile.mock_calls[0][1][0] == "test.yaml" assert len(mock_upload.mock_calls) == 1 assert mock_upload.mock_calls[0][1][0] == "test.yaml" async def test_update_static_info( hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info, mock_dashboard, ) -> None: """Test ESPHome update entity.""" mock_dashboard["configured"] = [ { "name": "test", "current_version": "1.2.3", }, ] await async_get_dashboard(hass).async_refresh() signal_static_info_updated = f"esphome_{mock_config_entry.entry_id}_on_list" runtime_data = Mock( available=True, device_info=mock_device_info, signal_static_info_updated=signal_static_info_updated, ) with patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", return_value=runtime_data, ): assert await hass.config_entries.async_forward_entry_setup( mock_config_entry, "update" ) state = hass.states.get("update.none_firmware") assert state is not None assert state.state == "on" runtime_data.device_info = dataclasses.replace( runtime_data.device_info, esphome_version="1.2.3" ) async_dispatcher_send(hass, signal_static_info_updated, []) state = hass.states.get("update.none_firmware") assert state.state == "off" @pytest.mark.parametrize( "expected_disconnect_state", [(True, STATE_ON), (False, STATE_UNAVAILABLE)] ) async def test_update_device_state_for_availability( hass: HomeAssistant, stub_reconnect, expected_disconnect_state: tuple[bool, str], mock_config_entry, mock_device_info, mock_dashboard, ) -> None: """Test ESPHome update entity changes availability with the device.""" mock_dashboard["configured"] = [ { "name": "test", "current_version": "1.2.3", }, ] await async_get_dashboard(hass).async_refresh() signal_device_updated = f"esphome_{mock_config_entry.entry_id}_on_device_update" runtime_data = Mock( available=True, expected_disconnect=False, device_info=mock_device_info, signal_device_updated=signal_device_updated, ) with patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", return_value=runtime_data, ): assert await hass.config_entries.async_forward_entry_setup( mock_config_entry, "update" ) state = hass.states.get("update.none_firmware") assert state is not None assert state.state == "on" expected_disconnect, expected_state = expected_disconnect_state runtime_data.available = False runtime_data.expected_disconnect = expected_disconnect async_dispatcher_send(hass, signal_device_updated) state = hass.states.get("update.none_firmware") assert state.state == expected_state # Deep sleep devices should still be available runtime_data.device_info = dataclasses.replace( runtime_data.device_info, has_deep_sleep=True ) async_dispatcher_send(hass, signal_device_updated) state = hass.states.get("update.none_firmware") assert state.state == "on" async def test_update_entity_dashboard_not_available_startup( hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info, mock_dashboard, ) -> None: """Test ESPHome update entity when dashboard is not available at startup.""" with patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", return_value=Mock(available=True, device_info=mock_device_info), ), patch( "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", side_effect=asyncio.TimeoutError, ): await async_get_dashboard(hass).async_refresh() assert await hass.config_entries.async_forward_entry_setup( mock_config_entry, "update" ) # We have a dashboard but it is not available state = hass.states.get("update.none_firmware") assert state is None mock_dashboard["configured"] = [ { "name": "test", "current_version": "2023.2.0-dev", "configuration": "test.yaml", } ] await async_get_dashboard(hass).async_refresh() await hass.async_block_till_done() state = hass.states.get("update.none_firmware") assert state.state == STATE_ON expected_attributes = { "latest_version": "2023.2.0-dev", "installed_version": "1.0.0", "supported_features": UpdateEntityFeature.INSTALL, } for key, expected_value in expected_attributes.items(): assert state.attributes.get(key) == expected_value async def test_update_entity_dashboard_discovered_after_startup_but_update_failed( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: Callable[ [APIClient, list[EntityInfo], list[UserService], list[EntityState]], Awaitable[MockESPHomeDevice], ], mock_dashboard, ) -> None: """Test ESPHome update entity when dashboard is discovered after startup and the first update fails.""" with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", side_effect=asyncio.TimeoutError, ): await async_get_dashboard(hass).async_refresh() await hass.async_block_till_done() mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], states=[], ) await hass.async_block_till_done() state = hass.states.get("update.test_firmware") assert state is None await mock_device.mock_disconnect(False) mock_dashboard["configured"] = [ { "name": "test", "current_version": "2023.2.0-dev", "configuration": "test.yaml", } ] # Device goes unavailable, and dashboard becomes available await async_get_dashboard(hass).async_refresh() await hass.async_block_till_done() state = hass.states.get("update.test_firmware") assert state is None # Finally both are available await mock_device.mock_connect() await async_get_dashboard(hass).async_refresh() await hass.async_block_till_done() state = hass.states.get("update.test_firmware") assert state is not None async def test_update_entity_not_present_without_dashboard( hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info ) -> None: """Test ESPHome update entity does not get created if there is no dashboard.""" with patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", return_value=Mock(available=True, device_info=mock_device_info), ): assert await hass.config_entries.async_forward_entry_setup( mock_config_entry, "update" ) state = hass.states.get("update.none_firmware") assert state is None