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
This commit is contained in:
J. Nick Koston 2024-01-03 14:58:04 -10:00 committed by GitHub
parent 962c449009
commit 4f213f6df3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 126 additions and 47 deletions

View File

@ -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}

View File

@ -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,
)
)

View File

@ -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

View File

@ -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