mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
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:
parent
962c449009
commit
4f213f6df3
@ -28,6 +28,8 @@ KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager"
|
|||||||
STORAGE_KEY = "esphome.dashboard"
|
STORAGE_KEY = "esphome.dashboard"
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
|
|
||||||
|
MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0")
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant) -> None:
|
async def async_setup(hass: HomeAssistant) -> None:
|
||||||
"""Set up the ESPHome dashboard."""
|
"""Set up the ESPHome dashboard."""
|
||||||
@ -177,22 +179,20 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]):
|
|||||||
self.addon_slug = addon_slug
|
self.addon_slug = addon_slug
|
||||||
self.url = url
|
self.url = url
|
||||||
self.api = ESPHomeDashboardAPI(url, session)
|
self.api = ESPHomeDashboardAPI(url, session)
|
||||||
|
self.supports_update: bool | None = None
|
||||||
@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")
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict:
|
async def _async_update_data(self) -> dict:
|
||||||
"""Fetch device data."""
|
"""Fetch device data."""
|
||||||
devices = await self.api.get_devices()
|
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}
|
||||||
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo
|
from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo
|
||||||
|
|
||||||
@ -27,6 +27,7 @@ from .entry_data import RuntimeEntryData
|
|||||||
|
|
||||||
KEY_UPDATE_LOCK = "esphome_update_lock"
|
KEY_UPDATE_LOCK = "esphome_update_lock"
|
||||||
|
|
||||||
|
NO_FEATURES = UpdateEntityFeature(0)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -76,6 +77,7 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
|||||||
_attr_device_class = UpdateDeviceClass.FIRMWARE
|
_attr_device_class = UpdateDeviceClass.FIRMWARE
|
||||||
_attr_title = "ESPHome"
|
_attr_title = "ESPHome"
|
||||||
_attr_name = "Firmware"
|
_attr_name = "Firmware"
|
||||||
|
_attr_release_url = "https://esphome.io/changelog/"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard
|
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)
|
(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
|
# If the device has deep sleep, we can't assume we can install updates
|
||||||
# as the ESP will not be connectable (by design).
|
# 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 (
|
if (
|
||||||
coordinator.last_update_success
|
coordinator.last_update_success
|
||||||
and coordinator.supports_update
|
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
|
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
|
@property
|
||||||
def _device_info(self) -> ESPHomeDeviceInfo:
|
def _device_info(self) -> ESPHomeDeviceInfo:
|
||||||
@ -119,44 +142,29 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
|||||||
or self._device_info.has_deep_sleep
|
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
|
@callback
|
||||||
def _async_static_info_updated(self, _: list[EntityInfo]) -> None:
|
def _handle_device_update(self, static_info: EntityInfo | None = None) -> None:
|
||||||
"""Handle static info update."""
|
"""Handle updated data from the device."""
|
||||||
|
self._update_attrs()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Handle entity added to Home Assistant."""
|
"""Handle entity added to Home Assistant."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
hass = self.hass
|
||||||
|
entry_data = self._entry_data
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
self.hass,
|
hass,
|
||||||
self._entry_data.signal_static_info_updated,
|
entry_data.signal_static_info_updated,
|
||||||
self._async_static_info_updated,
|
self._handle_device_update,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
self.hass,
|
hass,
|
||||||
self._entry_data.signal_device_updated,
|
entry_data.signal_device_updated,
|
||||||
self.async_write_ha_state,
|
self._handle_device_update,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -45,6 +45,25 @@ async def test_restore_dashboard_storage(
|
|||||||
assert mock_get_or_create.call_count == 1
|
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(
|
async def test_setup_dashboard_fails(
|
||||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage
|
||||||
) -> MockConfigEntry:
|
) -> MockConfigEntry:
|
||||||
@ -168,6 +187,9 @@ async def test_dashboard_supports_update(hass: HomeAssistant, mock_dashboard) ->
|
|||||||
# No data
|
# No data
|
||||||
assert not dash.supports_update
|
assert not dash.supports_update
|
||||||
|
|
||||||
|
await dash.async_refresh()
|
||||||
|
assert dash.supports_update is None
|
||||||
|
|
||||||
# supported version
|
# supported version
|
||||||
mock_dashboard["configured"].append(
|
mock_dashboard["configured"].append(
|
||||||
{
|
{
|
||||||
@ -177,11 +199,11 @@ async def test_dashboard_supports_update(hass: HomeAssistant, mock_dashboard) ->
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
await dash.async_refresh()
|
await dash.async_refresh()
|
||||||
|
assert dash.supports_update is True
|
||||||
assert dash.supports_update
|
|
||||||
|
|
||||||
# unsupported version
|
# unsupported version
|
||||||
|
dash.supports_update = None
|
||||||
mock_dashboard["configured"][0]["current_version"] = "2023.1.0"
|
mock_dashboard["configured"][0]["current_version"] = "2023.1.0"
|
||||||
await dash.async_refresh()
|
await dash.async_refresh()
|
||||||
|
|
||||||
assert not dash.supports_update
|
assert dash.supports_update is False
|
||||||
|
@ -9,7 +9,13 @@ import pytest
|
|||||||
|
|
||||||
from homeassistant.components.esphome.dashboard import async_get_dashboard
|
from homeassistant.components.esphome.dashboard import async_get_dashboard
|
||||||
from homeassistant.components.update import UpdateEntityFeature
|
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.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
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")
|
state = hass.states.get("update.none_firmware")
|
||||||
assert state is None
|
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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user