Add esphome native device update entities (#119339)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Jesse Hills 2024-06-18 16:31:50 +12:00 committed by GitHub
parent faf2a447a4
commit f8711dbfbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 205 additions and 8 deletions

View File

@ -38,6 +38,7 @@ from aioesphomeapi import (
TextInfo, TextInfo,
TextSensorInfo, TextSensorInfo,
TimeInfo, TimeInfo,
UpdateInfo,
UserService, UserService,
ValveInfo, ValveInfo,
build_unique_id, build_unique_id,
@ -82,6 +83,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
TextInfo: Platform.TEXT, TextInfo: Platform.TEXT,
TextSensorInfo: Platform.SENSOR, TextSensorInfo: Platform.SENSOR,
TimeInfo: Platform.TIME, TimeInfo: Platform.TIME,
UpdateInfo: Platform.UPDATE,
ValveInfo: Platform.VALVE, ValveInfo: Platform.VALVE,
} }

View File

@ -5,7 +5,12 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any from typing import Any
from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo from aioesphomeapi import (
DeviceInfo as ESPHomeDeviceInfo,
EntityInfo,
UpdateInfo,
UpdateState,
)
from homeassistant.components.update import ( from homeassistant.components.update import (
UpdateDeviceClass, UpdateDeviceClass,
@ -19,10 +24,17 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.enum import try_parse_enum
from .coordinator import ESPHomeDashboardCoordinator from .coordinator import ESPHomeDashboardCoordinator
from .dashboard import async_get_dashboard from .dashboard import async_get_dashboard
from .domain_data import DomainData from .domain_data import DomainData
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
esphome_state_property,
platform_async_setup_entry,
)
from .entry_data import RuntimeEntryData from .entry_data import RuntimeEntryData
KEY_UPDATE_LOCK = "esphome_update_lock" KEY_UPDATE_LOCK = "esphome_update_lock"
@ -36,6 +48,15 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up ESPHome update based on a config entry.""" """Set up ESPHome update based on a config entry."""
await platform_async_setup_entry(
hass,
entry,
async_add_entities,
info_type=UpdateInfo,
entity_type=ESPHomeUpdateEntity,
state_type=UpdateState,
)
if (dashboard := async_get_dashboard(hass)) is None: if (dashboard := async_get_dashboard(hass)) is None:
return return
entry_data = DomainData.get(hass).get_entry_data(entry) entry_data = DomainData.get(hass).get_entry_data(entry)
@ -54,7 +75,7 @@ async def async_setup_entry(
unsub() unsub()
unsubs.clear() unsubs.clear()
async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)]) async_add_entities([ESPHomeDashboardUpdateEntity(entry_data, dashboard)])
if entry_data.available and dashboard.last_update_success: if entry_data.available and dashboard.last_update_success:
_async_setup_update_entity() _async_setup_update_entity()
@ -66,7 +87,9 @@ async def async_setup_entry(
] ]
class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboardCoordinator], UpdateEntity): class ESPHomeDashboardUpdateEntity(
CoordinatorEntity[ESPHomeDashboardCoordinator], UpdateEntity
):
"""Defines an ESPHome update entity.""" """Defines an ESPHome update entity."""
_attr_has_entity_name = True _attr_has_entity_name = True
@ -179,3 +202,65 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboardCoordinator], Update
) )
finally: finally:
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
"""A update implementation for esphome."""
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
@callback
def _on_static_info_update(self, static_info: EntityInfo) -> None:
"""Set attrs from static info."""
super()._on_static_info_update(static_info)
static_info = self._static_info
self._attr_device_class = try_parse_enum(
UpdateDeviceClass, static_info.device_class
)
@property
@esphome_state_property
def installed_version(self) -> str | None:
"""Return the installed version."""
return self._state.current_version
@property
@esphome_state_property
def in_progress(self) -> bool | int | None:
"""Return if the update is in progress."""
if self._state.has_progress:
return int(self._state.progress)
return self._state.in_progress
@property
@esphome_state_property
def latest_version(self) -> str | None:
"""Return the latest version."""
return self._state.latest_version
@property
@esphome_state_property
def release_summary(self) -> str | None:
"""Return the release summary."""
return self._state.release_summary
@property
@esphome_state_property
def release_url(self) -> str | None:
"""Return the release URL."""
return self._state.release_url
@property
@esphome_state_property
def title(self) -> str | None:
"""Return the title of the update."""
return self._state.title
@convert_api_error_ha_error
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Update the current value."""
self._client.update_command(key=self._key, install=True)

View File

@ -3,12 +3,21 @@
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from aioesphomeapi import APIClient, EntityInfo, EntityState, UserService from aioesphomeapi import (
APIClient,
EntityInfo,
EntityState,
UpdateInfo,
UpdateState,
UserService,
)
import pytest 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 DOMAIN as UPDATE_DOMAIN, UpdateEntityFeature
from homeassistant.components.update.const import SERVICE_INSTALL
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES, ATTR_SUPPORTED_FEATURES,
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
@ -83,7 +92,7 @@ async def test_update_entity(
with patch( with patch(
"homeassistant.components.esphome.update.DomainData.get_entry_data", "homeassistant.components.esphome.update.DomainData.get_entry_data",
return_value=Mock(available=True, device_info=mock_device_info), return_value=Mock(available=True, device_info=mock_device_info, info={}),
): ):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id) assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -266,7 +275,7 @@ async def test_update_entity_dashboard_not_available_startup(
with ( with (
patch( patch(
"homeassistant.components.esphome.update.DomainData.get_entry_data", "homeassistant.components.esphome.update.DomainData.get_entry_data",
return_value=Mock(available=True, device_info=mock_device_info), return_value=Mock(available=True, device_info=mock_device_info, info={}),
), ),
patch( patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices",
@ -358,7 +367,7 @@ async def test_update_entity_not_present_without_dashboard(
"""Test ESPHome update entity does not get created if there is no dashboard.""" """Test ESPHome update entity does not get created if there is no dashboard."""
with patch( with patch(
"homeassistant.components.esphome.update.DomainData.get_entry_data", "homeassistant.components.esphome.update.DomainData.get_entry_data",
return_value=Mock(available=True, device_info=mock_device_info), return_value=Mock(available=True, device_info=mock_device_info, info={}),
): ):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id) assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -408,3 +417,104 @@ async def test_update_becomes_available_at_runtime(
# We now know the version so install is enabled # We now know the version so install is enabled
features = state.attributes[ATTR_SUPPORTED_FEATURES] features = state.attributes[ATTR_SUPPORTED_FEATURES]
assert features is UpdateEntityFeature.INSTALL assert features is UpdateEntityFeature.INSTALL
async def test_generic_device_update_entity(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry,
) -> None:
"""Test a generic device update entity."""
entity_info = [
UpdateInfo(
object_id="myupdate",
key=1,
name="my update",
unique_id="my_update",
)
]
states = [
UpdateState(
key=1,
current_version="2024.6.0",
latest_version="2024.6.0",
title="ESPHome Project",
release_summary="This is a release summary",
release_url="https://esphome.io/changelog",
)
]
user_service = []
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
user_service=user_service,
states=states,
)
state = hass.states.get("update.test_myupdate")
assert state is not None
assert state.state == STATE_OFF
async def test_generic_device_update_entity_has_update(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: Callable[
[APIClient, list[EntityInfo], list[UserService], list[EntityState]],
Awaitable[MockESPHomeDevice],
],
) -> None:
"""Test a generic device update entity with an update."""
entity_info = [
UpdateInfo(
object_id="myupdate",
key=1,
name="my update",
unique_id="my_update",
)
]
states = [
UpdateState(
key=1,
current_version="2024.6.0",
latest_version="2024.6.1",
title="ESPHome Project",
release_summary="This is a release summary",
release_url="https://esphome.io/changelog",
)
]
user_service = []
mock_device: MockESPHomeDevice = await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
user_service=user_service,
states=states,
)
state = hass.states.get("update.test_myupdate")
assert state is not None
assert state.state == STATE_ON
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: "update.test_myupdate"},
blocking=True,
)
mock_device.set_state(
UpdateState(
key=1,
in_progress=True,
has_progress=True,
progress=50,
current_version="2024.6.0",
latest_version="2024.6.1",
title="ESPHome Project",
release_summary="This is a release summary",
release_url="https://esphome.io/changelog",
)
)
state = hass.states.get("update.test_myupdate")
assert state is not None
assert state.state == STATE_ON
assert state.attributes["in_progress"] == 50