diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 41b0617e630..3d7bfef6ddb 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -28,6 +28,8 @@ KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" STORAGE_KEY = "esphome.dashboard" STORAGE_VERSION = 1 +MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") + async def async_setup(hass: HomeAssistant) -> None: """Set up the ESPHome dashboard.""" @@ -177,22 +179,20 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): self.addon_slug = addon_slug self.url = url self.api = ESPHomeDashboardAPI(url, session) - - @property - def supports_update(self) -> bool: - """Return whether the dashboard supports updates.""" - if self.data is None: - raise RuntimeError("Data needs to be loaded first") - - if len(self.data) == 0: - return False - - esphome_version: str = next(iter(self.data.values()))["current_version"] - - # There is no January release - return AwesomeVersion(esphome_version) > AwesomeVersion("2023.1.0") + self.supports_update: bool | None = None async def _async_update_data(self) -> dict: """Fetch device data.""" devices = await self.api.get_devices() - return {dev["name"]: dev for dev in devices["configured"]} + configured_devices = devices["configured"] + + if ( + self.supports_update is None + and configured_devices + and (current_version := configured_devices[0].get("current_version")) + ): + self.supports_update = ( + AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE + ) + + return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 859b28a53b5..ea052522e76 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging -from typing import Any, cast +from typing import Any from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo @@ -27,6 +27,7 @@ from .entry_data import RuntimeEntryData KEY_UPDATE_LOCK = "esphome_update_lock" +NO_FEATURES = UpdateEntityFeature(0) _LOGGER = logging.getLogger(__name__) @@ -76,6 +77,7 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_title = "ESPHome" _attr_name = "Firmware" + _attr_release_url = "https://esphome.io/changelog/" def __init__( self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard @@ -90,15 +92,36 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): (dr.CONNECTION_NETWORK_MAC, entry_data.device_info.mac_address) } ) + self._update_attrs() + @callback + def _update_attrs(self) -> None: + """Update the supported features.""" # If the device has deep sleep, we can't assume we can install updates # as the ESP will not be connectable (by design). + coordinator = self.coordinator + device_info = self._device_info + # Install support can change at run time if ( coordinator.last_update_success and coordinator.supports_update - and not self._device_info.has_deep_sleep + and not device_info.has_deep_sleep ): self._attr_supported_features = UpdateEntityFeature.INSTALL + else: + self._attr_supported_features = NO_FEATURES + self._attr_installed_version = device_info.esphome_version + device = coordinator.data.get(device_info.name) + if device is None: + self._attr_latest_version = None + else: + self._attr_latest_version = device["current_version"] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + super()._handle_coordinator_update() @property def _device_info(self) -> ESPHomeDeviceInfo: @@ -119,44 +142,29 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): or self._device_info.has_deep_sleep ) - @property - def installed_version(self) -> str | None: - """Version currently installed and in use.""" - return self._device_info.esphome_version - - @property - def latest_version(self) -> str | None: - """Latest version available for install.""" - device = self.coordinator.data.get(self._device_info.name) - if device is None: - return None - return cast(str, device["current_version"]) - - @property - def release_url(self) -> str | None: - """URL to the full release notes of the latest version available.""" - return "https://esphome.io/changelog/" - @callback - def _async_static_info_updated(self, _: list[EntityInfo]) -> None: - """Handle static info update.""" + def _handle_device_update(self, static_info: EntityInfo | None = None) -> None: + """Handle updated data from the device.""" + self._update_attrs() self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Handle entity added to Home Assistant.""" await super().async_added_to_hass() + hass = self.hass + entry_data = self._entry_data self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_static_info_updated, - self._async_static_info_updated, + hass, + entry_data.signal_static_info_updated, + self._handle_device_update, ) ) self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_device_updated, - self.async_write_ha_state, + hass, + entry_data.signal_device_updated, + self._handle_device_update, ) ) diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index d8732ea0453..320b20832c8 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -45,6 +45,25 @@ async def test_restore_dashboard_storage( assert mock_get_or_create.call_count == 1 +async def test_restore_dashboard_storage_end_to_end( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage +) -> MockConfigEntry: + """Restore dashboard url and slug from storage.""" + hass_storage[dashboard.STORAGE_KEY] = { + "version": dashboard.STORAGE_VERSION, + "minor_version": dashboard.STORAGE_VERSION, + "key": dashboard.STORAGE_KEY, + "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, + } + with patch( + "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI" + ) as mock_dashboard_api: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == ConfigEntryState.LOADED + assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052" + + async def test_setup_dashboard_fails( hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage ) -> MockConfigEntry: @@ -168,6 +187,9 @@ async def test_dashboard_supports_update(hass: HomeAssistant, mock_dashboard) -> # No data assert not dash.supports_update + await dash.async_refresh() + assert dash.supports_update is None + # supported version mock_dashboard["configured"].append( { @@ -177,11 +199,11 @@ async def test_dashboard_supports_update(hass: HomeAssistant, mock_dashboard) -> } ) await dash.async_refresh() - - assert dash.supports_update + assert dash.supports_update is True # unsupported version + dash.supports_update = None mock_dashboard["configured"][0]["current_version"] = "2023.1.0" await dash.async_refresh() - assert not dash.supports_update + assert dash.supports_update is False diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 9ab00421cbc..d267a13145f 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -9,7 +9,13 @@ 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.const import ( + ATTR_SUPPORTED_FEATURES, + 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 @@ -370,3 +376,46 @@ async def test_update_entity_not_present_without_dashboard( state = hass.states.get("update.none_firmware") assert state is None + + +async def test_update_becomes_available_at_runtime( + 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 the dashboard has no device at startup but gets them later.""" + 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 not None + features = state.attributes[ATTR_SUPPORTED_FEATURES] + # There are no devices on the dashboard so no + # way to tell the version so install is disabled + assert features is UpdateEntityFeature(0) + + # A device gets added to the dashboard + 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.test_firmware") + assert state is not None + # We now know the version so install is enabled + features = state.attributes[ATTR_SUPPORTED_FEATURES] + assert features is UpdateEntityFeature.INSTALL