From 4f213f6df346a8a07b38f79074799612af614e93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jan 2024 14:58:04 -1000 Subject: [PATCH] Fix first ESPHome device update entity not offering install feature (#106993) In the case where the user gets their first ESPHome device such as a RATGDO, they will usually add the device first in HA, and than find the dashboard. The install function will be missing because we do not know if the dashboard supports updating devices until the first device is added. We now set the supported features when we learn the version when the first device is added --- homeassistant/components/esphome/dashboard.py | 30 ++++----- homeassistant/components/esphome/update.py | 64 +++++++++++-------- tests/components/esphome/test_dashboard.py | 28 +++++++- tests/components/esphome/test_update.py | 51 ++++++++++++++- 4 files changed, 126 insertions(+), 47 deletions(-) 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