mirror of
https://github.com/home-assistant/core.git
synced 2025-07-10 14:57:09 +00:00
Add ESPHome update entities (#85717)
This commit is contained in:
parent
06bc9c7b22
commit
c8cd41b5d4
@ -59,6 +59,23 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]):
|
|||||||
self.addon_slug = addon_slug
|
self.addon_slug = addon_slug
|
||||||
self.api = ESPHomeDashboardAPI(url, session)
|
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:
|
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()
|
||||||
|
@ -37,6 +37,8 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
|
from .dashboard import async_get_dashboard
|
||||||
|
|
||||||
SAVE_DELAY = 120
|
SAVE_DELAY = 120
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -147,6 +149,10 @@ class RuntimeEntryData:
|
|||||||
"""Distribute an update of static infos to all platforms."""
|
"""Distribute an update of static infos to all platforms."""
|
||||||
# First, load all platforms
|
# First, load all platforms
|
||||||
needed_platforms = set()
|
needed_platforms = set()
|
||||||
|
|
||||||
|
if async_get_dashboard(hass):
|
||||||
|
needed_platforms.add("update")
|
||||||
|
|
||||||
for info in infos:
|
for info in infos:
|
||||||
for info_type, platform in INFO_TYPE_TO_PLATFORM.items():
|
for info_type, platform in INFO_TYPE_TO_PLATFORM.items():
|
||||||
if isinstance(info, info_type):
|
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
|
@pytest.fixture
|
||||||
async def init_integration(
|
async def init_integration(
|
||||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||||
@ -54,7 +66,7 @@ async def init_integration(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_client():
|
def mock_client(mock_device_info):
|
||||||
"""Mock APIClient."""
|
"""Mock APIClient."""
|
||||||
mock_client = Mock(spec=APIClient)
|
mock_client = Mock(spec=APIClient)
|
||||||
|
|
||||||
@ -78,14 +90,7 @@ def mock_client():
|
|||||||
return mock_client
|
return mock_client
|
||||||
|
|
||||||
mock_client.side_effect = mock_constructor
|
mock_client.side_effect = mock_constructor
|
||||||
mock_client.device_info = AsyncMock(
|
mock_client.device_info = AsyncMock(return_value=mock_device_info)
|
||||||
return_value=DeviceInfo(
|
|
||||||
uses_password=False,
|
|
||||||
name="test",
|
|
||||||
bluetooth_proxy_version=0,
|
|
||||||
mac_address="11:22:33:44:55:aa",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
mock_client.connect = AsyncMock()
|
mock_client.connect = AsyncMock()
|
||||||
mock_client.disconnect = 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…
x
Reference in New Issue
Block a user