hass-core/homeassistant/components/esphome/update.py
J. Nick Koston 4f213f6df3
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
2024-01-03 19:58:04 -05:00

192 lines
6.6 KiB
Python

"""Update platform for ESPHome."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .dashboard import ESPHomeDashboard, async_get_dashboard
from .domain_data import DomainData
from .entry_data import RuntimeEntryData
KEY_UPDATE_LOCK = "esphome_update_lock"
NO_FEATURES = UpdateEntityFeature(0)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up ESPHome update based on a config entry."""
if (dashboard := async_get_dashboard(hass)) is None:
return
entry_data = DomainData.get(hass).get_entry_data(entry)
unsubs: list[CALLBACK_TYPE] = []
@callback
def _async_setup_update_entity() -> None:
"""Set up the update entity."""
nonlocal unsubs
assert dashboard is not None
# Keep listening until device is available
if not entry_data.available or not dashboard.last_update_success:
return
for unsub in unsubs:
unsub()
unsubs.clear()
async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)])
if entry_data.available and dashboard.last_update_success:
_async_setup_update_entity()
return
unsubs = [
async_dispatcher_connect(
hass, entry_data.signal_device_updated, _async_setup_update_entity
),
dashboard.async_add_listener(_async_setup_update_entity),
]
class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
"""Defines an ESPHome update entity."""
_attr_has_entity_name = True
_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
) -> None:
"""Initialize the update entity."""
super().__init__(coordinator=coordinator)
assert entry_data.device_info is not None
self._entry_data = entry_data
self._attr_unique_id = entry_data.device_info.mac_address
self._attr_device_info = DeviceInfo(
connections={
(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 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:
"""Return the device info."""
assert self._entry_data.device_info is not None
return self._entry_data.device_info
@property
def available(self) -> bool:
"""Return if update is available.
During deep sleep the ESP will not be connectable (by design)
and thus, even when unavailable, we'll show it as available.
"""
return super().available and (
self._entry_data.available
or self._entry_data.expected_disconnect
or self._device_info.has_deep_sleep
)
@callback
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(
hass,
entry_data.signal_static_info_updated,
self._handle_device_update,
)
)
self.async_on_remove(
async_dispatcher_connect(
hass,
entry_data.signal_device_updated,
self._handle_device_update,
)
)
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()):
coordinator = self.coordinator
api = coordinator.api
device = coordinator.data.get(self._device_info.name)
assert device is not None
try:
if not await api.compile(device["configuration"]):
raise HomeAssistantError(
f"Error compiling {device['configuration']}; "
"Try again in ESPHome dashboard for more information."
)
if not await api.upload(device["configuration"], "OTA"):
raise HomeAssistantError(
f"Error updating {device['configuration']} via OTA; "
"Try again in ESPHome dashboard for more information."
)
finally:
await self.coordinator.async_request_refresh()