Add ESPHome update entities (#85717)

This commit is contained in:
Paulus Schoutsen 2023-01-11 16:26:13 -05:00 committed by GitHub
parent 06bc9c7b22
commit c8cd41b5d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 210 additions and 9 deletions

View file

@ -59,6 +59,23 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]):
self.addon_slug = addon_slug
self.api = ESPHomeDashboardAPI(url, session)
async def ensure_data(self) -> None:
"""Ensure the update coordinator has data when this call finishes."""
if self.data:
return
if self._first_fetch_lock is not None:
async with self._first_fetch_lock:
# We know the data is fetched when lock is done
return
self._first_fetch_lock = asyncio.Lock()
async with self._first_fetch_lock:
await self.async_request_refresh()
self._first_fetch_lock = None
async def _async_update_data(self) -> dict:
"""Fetch device data."""
devices = await self.api.get_devices()

View file

@ -37,6 +37,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
from .dashboard import async_get_dashboard
SAVE_DELAY = 120
_LOGGER = logging.getLogger(__name__)
@ -147,6 +149,10 @@ class RuntimeEntryData:
"""Distribute an update of static infos to all platforms."""
# First, load all platforms
needed_platforms = set()
if async_get_dashboard(hass):
needed_platforms.add("update")
for info in infos:
for info_type, platform in INFO_TYPE_TO_PLATFORM.items():
if isinstance(info, info_type):

View file

@ -0,0 +1,109 @@
"""Update platform for ESPHome."""
from __future__ import annotations
from typing import cast
from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
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
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up ESPHome update based on a config entry."""
dashboard = async_get_dashboard(hass)
if dashboard is None:
return
entry_data = DomainData.get(hass).get_entry_data(entry)
unsub = None
async def setup_update_entity() -> None:
"""Set up the update entity."""
nonlocal unsub
# Keep listening until device is available
if not entry_data.available:
return
if unsub is not None:
unsub() # type: ignore[unreachable]
assert dashboard is not None
await dashboard.ensure_data()
async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)])
if entry_data.available:
await setup_update_entity()
return
signal = f"esphome_{entry_data.entry_id}_on_device_update"
unsub = async_dispatcher_connect(hass, signal, setup_update_entity)
class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
"""Defines an ESPHome update entity."""
_attr_has_entity_name = True
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = UpdateEntityFeature.SPECIFIC_VERSION
_attr_title = "ESPHome"
_attr_name = "Firmware"
_device_info: ESPHomeDeviceInfo
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._device_info = entry_data.device_info
self._attr_unique_id = f"{entry_data.entry_id}_update"
self._attr_device_info = DeviceInfo(
connections={
(dr.CONNECTION_NETWORK_MAC, entry_data.device_info.mac_address)
}
)
@property
def available(self) -> bool:
"""Return if update is available."""
return super().available and self._device_info.name in self.coordinator.data
@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/"

View file

@ -40,6 +40,18 @@ def mock_config_entry() -> MockConfigEntry:
)
@pytest.fixture
def mock_device_info() -> DeviceInfo:
"""Return the default mocked device info."""
return DeviceInfo(
uses_password=False,
name="test",
bluetooth_proxy_version=0,
mac_address="11:22:33:44:55:aa",
esphome_version="1.0.0",
)
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
@ -54,7 +66,7 @@ async def init_integration(
@pytest.fixture
def mock_client():
def mock_client(mock_device_info):
"""Mock APIClient."""
mock_client = Mock(spec=APIClient)
@ -78,14 +90,7 @@ def mock_client():
return mock_client
mock_client.side_effect = mock_constructor
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(
uses_password=False,
name="test",
bluetooth_proxy_version=0,
mac_address="11:22:33:44:55:aa",
)
)
mock_client.device_info = AsyncMock(return_value=mock_device_info)
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()

View file

@ -0,0 +1,64 @@
"""Test ESPHome update entities."""
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.esphome.dashboard import async_set_dashboard_info
@pytest.fixture(autouse=True)
def stub_reconnect():
"""Stub reconnect."""
with patch("homeassistant.components.esphome.ReconnectLogic.start"):
yield
@pytest.mark.parametrize(
"devices_payload,expected_state,expected_attributes",
[
(
[{"name": "test", "current_version": "1.2.3"}],
"on",
{"latest_version": "1.2.3", "installed_version": "1.0.0"},
),
(
[{"name": "test", "current_version": "1.0.0"}],
"off",
{"latest_version": "1.0.0", "installed_version": "1.0.0"},
),
(
[],
"unavailable",
{},
),
],
)
async def test_update_entity(
hass,
mock_config_entry,
mock_device_info,
devices_payload,
expected_state,
expected_attributes,
):
"""Test ESPHome update entity."""
async_set_dashboard_info(hass, "mock-addon-slug", "mock-addon-host", 1234)
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.esphome.update.DomainData.get_entry_data",
return_value=Mock(available=True, device_info=mock_device_info),
), patch(
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices",
return_value={"configured": devices_payload},
):
assert await hass.config_entries.async_forward_entry_setup(
mock_config_entry, "update"
)
state = hass.states.get("update.none_firmware")
assert state is not None
assert state.state == expected_state
for key, expected_value in expected_attributes.items():
assert state.attributes.get(key) == expected_value