Add ESPHome update entities (#85717)
This commit is contained in:
parent
06bc9c7b22
commit
c8cd41b5d4
5 changed files with 210 additions and 9 deletions
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
109
homeassistant/components/esphome/update.py
Normal file
109
homeassistant/components/esphome/update.py
Normal 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/"
|
|
@ -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()
|
||||
|
||||
|
|
64
tests/components/esphome/test_update.py
Normal file
64
tests/components/esphome/test_update.py
Normal 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
|
Loading…
Add table
Reference in a new issue