diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 9e8911f7efe..ac7a5c43e79 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -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() diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 959bf2f2877..ef18df33474 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -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): diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py new file mode 100644 index 00000000000..1aa21623cb9 --- /dev/null +++ b/homeassistant/components/esphome/update.py @@ -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/" diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index c3f7fdd281c..44915befdcb 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -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() diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py new file mode 100644 index 00000000000..3a01245de41 --- /dev/null +++ b/tests/components/esphome/test_update.py @@ -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