diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 11d99a1558a9a12d176eee6ce2ac5b8eb21ddb94..43691c8594a77148a5f11b3ccc0c1e782aef4d36 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import TYPE_CHECKING +from aiogithubapi import GitHubAPI from pynecil import Pynecil from homeassistant.components import bluetooth @@ -12,13 +14,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -from .coordinator import IronOSCoordinator +from .coordinator import IronOSFirmwareUpdateCoordinator, IronOSLiveDataCoordinator -PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.UPDATE] -type IronOSConfigEntry = ConfigEntry[IronOSCoordinator] + +@dataclass +class IronOSCoordinators: + """IronOS data class holding coordinators.""" + + live_data: IronOSLiveDataCoordinator + firmware: IronOSFirmwareUpdateCoordinator + + +type IronOSConfigEntry = ConfigEntry[IronOSCoordinators] _LOGGER = logging.getLogger(__name__) @@ -39,10 +51,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo device = Pynecil(ble_device) - coordinator = IronOSCoordinator(hass, device) + coordinator = IronOSLiveDataCoordinator(hass, device) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + session = async_get_clientsession(hass) + github = GitHubAPI(session=session) + + firmware_update_coordinator = IronOSFirmwareUpdateCoordinator(hass, device, github) + await firmware_update_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = IronOSCoordinators( + live_data=coordinator, + firmware=firmware_update_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index aefb14b689ba1e002c70b1db7d049fece8bdc382..175de484870210511818f0d4cade89b91b616fb3 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -4,7 +4,9 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TYPE_CHECKING +from aiogithubapi import GitHubAPI, GitHubException, GitHubReleaseModel from pynecil import CommunicationError, DeviceInfoResponse, LiveDataResponse, Pynecil from homeassistant.config_entries import ConfigEntry @@ -16,24 +18,43 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL_GITHUB = timedelta(hours=3) -class IronOSCoordinator(DataUpdateCoordinator[LiveDataResponse]): - """IronOS coordinator.""" +class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """IronOS base coordinator.""" device_info: DeviceInfoResponse config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, device: Pynecil) -> None: + def __init__( + self, + hass: HomeAssistant, + device: Pynecil, + update_interval: timedelta, + ) -> None: """Initialize IronOS coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, - update_interval=SCAN_INTERVAL, + update_interval=update_interval, ) self.device = device + async def _async_setup(self) -> None: + """Set up the coordinator.""" + + self.device_info = await self.device.get_device_info() + + +class IronOSLiveDataCoordinator(IronOSBaseCoordinator): + """IronOS live data coordinator.""" + + def __init__(self, hass: HomeAssistant, device: Pynecil) -> None: + """Initialize IronOS coordinator.""" + super().__init__(hass, device=device, update_interval=SCAN_INTERVAL) + async def _async_update_data(self) -> LiveDataResponse: """Fetch data from Device.""" @@ -43,11 +64,27 @@ class IronOSCoordinator(DataUpdateCoordinator[LiveDataResponse]): except CommunicationError as e: raise UpdateFailed("Cannot connect to device") from e - async def _async_setup(self) -> None: - """Set up the coordinator.""" + +class IronOSFirmwareUpdateCoordinator(IronOSBaseCoordinator): + """IronOS coordinator for retrieving update information from github.""" + + def __init__(self, hass: HomeAssistant, device: Pynecil, github: GitHubAPI) -> None: + """Initialize IronOS coordinator.""" + super().__init__(hass, device=device, update_interval=SCAN_INTERVAL_GITHUB) + self.github = github + + async def _async_update_data(self) -> GitHubReleaseModel: + """Fetch data from Github.""" try: - self.device_info = await self.device.get_device_info() + release = await self.github.repos.releases.latest("Ralim/IronOS") - except CommunicationError as e: - raise UpdateFailed("Cannot connect to device") from e + except GitHubException as e: + raise UpdateFailed( + "Failed to retrieve latest release data from Github" + ) from e + + if TYPE_CHECKING: + assert release.data + + return release.data diff --git a/homeassistant/components/iron_os/entity.py b/homeassistant/components/iron_os/entity.py index 5a24b0a55671efbdf77196e495185684369e9b89..d1c9a9aa0ee362a855cdd5b6d76fad47d986423a 100644 --- a/homeassistant/components/iron_os/entity.py +++ b/homeassistant/components/iron_os/entity.py @@ -9,17 +9,17 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import MANUFACTURER, MODEL -from .coordinator import IronOSCoordinator +from .coordinator import IronOSBaseCoordinator -class IronOSBaseEntity(CoordinatorEntity[IronOSCoordinator]): +class IronOSBaseEntity(CoordinatorEntity[IronOSBaseCoordinator]): """Base IronOS entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: IronOSCoordinator, + coordinator: IronOSBaseCoordinator, entity_description: EntityDescription, ) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index cfaf36880f2db9ddeb4fc3d7bef26e395a53eae8..9fcb84e0f6a0597421d36eba10ce443088614ac4 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -12,6 +12,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", - "loggers": ["pynecil"], - "requirements": ["pynecil==0.2.0"] + "loggers": ["pynecil", "aiogithubapi"], + "requirements": ["pynecil==0.2.0", "aiogithubapi==24.6.0"] } diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 9230faec1f13ff761ed989abdae2aef544eb4fb1..bc8da968187f90b43c4caeba4abbad9ddcdea071 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -61,7 +61,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up number entities from a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.live_data async_add_entities( IronOSNumberEntity(coordinator, description) diff --git a/homeassistant/components/iron_os/sensor.py b/homeassistant/components/iron_os/sensor.py index 095ffd254df96bb873f6ef303fa7378c0514cafe..a44e61c4de35dd827bcf2560faeaaae7bcbfd5bf 100644 --- a/homeassistant/components/iron_os/sensor.py +++ b/homeassistant/components/iron_os/sensor.py @@ -180,7 +180,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors from a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.live_data async_add_entities( IronOSSensorEntity(coordinator, description) diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py new file mode 100644 index 0000000000000000000000000000000000000000..9086dc0b7b5951ce0c6f82da8a6649c1c4554288 --- /dev/null +++ b/homeassistant/components/iron_os/update.py @@ -0,0 +1,76 @@ +"""Update platform for IronOS integration.""" + +from __future__ import annotations + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IronOSConfigEntry +from .coordinator import IronOSBaseCoordinator +from .entity import IronOSBaseEntity + +UPDATE_DESCRIPTION = UpdateEntityDescription( + key="firmware", + device_class=UpdateDeviceClass.FIRMWARE, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IronOSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up IronOS update platform.""" + + coordinator = entry.runtime_data.firmware + + async_add_entities([IronOSUpdate(coordinator, UPDATE_DESCRIPTION)]) + + +class IronOSUpdate(IronOSBaseEntity, UpdateEntity): + """Representation of an IronOS update entity.""" + + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + + def __init__( + self, + coordinator: IronOSBaseCoordinator, + entity_description: UpdateEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entity_description) + + @property + def installed_version(self) -> str | None: + """IronOS version on the device.""" + + return self.coordinator.device_info.build + + @property + def title(self) -> str | None: + """Title of the IronOS release.""" + + return f"IronOS {self.coordinator.data.name}" + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest IronOS version available.""" + + return self.coordinator.data.html_url + + @property + def latest_version(self) -> str | None: + """Latest IronOS version available for install.""" + + return self.coordinator.data.tag_name + + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + + return self.coordinator.data.body diff --git a/requirements_all.txt b/requirements_all.txt index 1fa221b60fe56f7a67e624575650239c2f07462a..69e3ed97e74f23e1316a70975a918304f25688c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -249,6 +249,7 @@ aioflo==2021.11.0 aioftp==0.21.3 # homeassistant.components.github +# homeassistant.components.iron_os aiogithubapi==24.6.0 # homeassistant.components.guardian diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5b2ea0b9731fb2ceb8cfef2620b74f16730ab46..5a1daaad5d62af4589f7c1ed470b341ba304a702 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -234,6 +234,7 @@ aioesphomeapi==27.0.0 aioflo==2021.11.0 # homeassistant.components.github +# homeassistant.components.iron_os aiogithubapi==24.6.0 # homeassistant.components.guardian diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index f489d7b7bb53cdf3e0057efa55c5556834832a18..a7c3592ae7343a3da3496cbfa4c776f363c396e1 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -107,6 +107,29 @@ def mock_ble_device() -> Generator[MagicMock]: yield ble_device +@pytest.fixture(autouse=True) +def mock_githubapi() -> Generator[AsyncMock]: + """Mock aiogithubapi.""" + + with patch( + "homeassistant.components.iron_os.GitHubAPI", + autospec=True, + ) as mock_client: + client = mock_client.return_value + client.repos.releases.latest = AsyncMock() + + client.repos.releases.latest.return_value.data.html_url = ( + "https://github.com/Ralim/IronOS/releases/tag/v2.22" + ) + client.repos.releases.latest.return_value.data.name = ( + "V2.22 | TS101 & S60 Added | PinecilV2 improved" + ) + client.repos.releases.latest.return_value.data.tag_name = "v2.22" + client.repos.releases.latest.return_value.data.body = "**RELEASE_NOTES**" + + yield client + + @pytest.fixture def mock_pynecil() -> Generator[AsyncMock]: """Mock Pynecil library.""" diff --git a/tests/components/iron_os/snapshots/test_update.ambr b/tests/components/iron_os/snapshots/test_update.ambr new file mode 100644 index 0000000000000000000000000000000000000000..fbfc490e121ac37fded90ac64753ff2fc5ed2fba --- /dev/null +++ b/tests/components/iron_os/snapshots/test_update.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_update.2 + '**RELEASE_NOTES**' +# --- +# name: test_update[update.pinecil_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'update', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'update.pinecil_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>, + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': <UpdateEntityFeature: 16>, + 'translation_key': None, + 'unique_id': 'c0:ff:ee:c0:ff:ee_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.pinecil_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/iron_os/icon.png', + 'friendly_name': 'Pinecil Firmware', + 'in_progress': False, + 'installed_version': 'v2.22', + 'latest_version': 'v2.22', + 'release_summary': None, + 'release_url': 'https://github.com/Ralim/IronOS/releases/tag/v2.22', + 'skipped_version': None, + 'supported_features': <UpdateEntityFeature: 16>, + 'title': 'IronOS V2.22 | TS101 & S60 Added | PinecilV2 improved', + 'update_percentage': None, + }), + 'context': <ANY>, + 'entity_id': 'update.pinecil_firmware', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- diff --git a/tests/components/iron_os/test_update.py b/tests/components/iron_os/test_update.py new file mode 100644 index 0000000000000000000000000000000000000000..70336e6962045b94059bd67326827638b042e22b --- /dev/null +++ b/tests/components/iron_os/test_update.py @@ -0,0 +1,73 @@ +"""Tests for IronOS update platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from aiogithubapi import GitHubException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def update_only() -> AsyncGenerator[None]: + """Enable only the update platform.""" + with patch( + "homeassistant.components.iron_os.PLATFORMS", + [Platform.UPDATE], + ): + yield + + +@pytest.mark.usefixtures("mock_pynecil", "ble_device", "mock_githubapi") +async def test_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the IronOS update platform.""" + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.pinecil_firmware", + } + ) + result = await ws_client.receive_json() + assert result["result"] == snapshot + + +@pytest.mark.usefixtures("ble_device", "mock_pynecil") +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_githubapi: AsyncMock, +) -> None: + """Test config entry not ready.""" + + mock_githubapi.repos.releases.latest.side_effect = GitHubException + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY