mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Add firmware update entity to IronOS integration (#123031)
This commit is contained in:
parent
1eaaa5c6d3
commit
3e8f3cfb49
@ -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
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
76
homeassistant/components/iron_os/update.py
Normal file
76
homeassistant/components/iron_os/update.py
Normal file
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
62
tests/components/iron_os/snapshots/test_update.ambr
Normal file
62
tests/components/iron_os/snapshots/test_update.ambr
Normal file
@ -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',
|
||||
})
|
||||
# ---
|
73
tests/components/iron_os/test_update.py
Normal file
73
tests/components/iron_os/test_update.py
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user