Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 139bacb814 | |||
| 7e87ac49b0 | |||
| be020ada62 | |||
| 7ff4eafa40 |
@@ -149,6 +149,10 @@ DATA_HASSIO_HTTP_CONFIG: HassKey[dict[str, Any]] = HassKey("hassio_http_config")
|
||||
DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host")
|
||||
DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user")
|
||||
|
||||
# Supervisor `os_info.board` values for boards on which the os-agent exposes
|
||||
# `io.hass.os.Boards.RaspberryPi.Firmware` (Raspberry Pi 4/5 and Yellow).
|
||||
BOARDS_WITH_RASPBERRYPI_FIRMWARE = frozenset({"rpi4-64", "rpi5-64", "yellow"})
|
||||
|
||||
PLACEHOLDER_KEY_ADDON = "addon"
|
||||
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
|
||||
PLACEHOLDER_KEY_ADDON_DOCUMENTATION = "addon_documentation"
|
||||
@@ -206,3 +210,4 @@ class SupervisorEntityModel(StrEnum):
|
||||
SUPERVISOR = "Home Assistant Supervisor"
|
||||
HOST = "Home Assistant Host"
|
||||
MOUNT = "Home Assistant Mount"
|
||||
RPI_FIRMWARE = "Raspberry Pi"
|
||||
|
||||
@@ -407,6 +407,26 @@ def async_register_os_in_dev_reg(
|
||||
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_rpi_firmware_in_dev_reg(
|
||||
entry_id: str, dev_reg: dr.DeviceRegistry
|
||||
) -> None:
|
||||
"""Register the Raspberry Pi firmware as a device.
|
||||
|
||||
Nested under the OS device so the firmware update entity lives on its own
|
||||
device rather than cluttering the OS one.
|
||||
"""
|
||||
params = DeviceInfo(
|
||||
identifiers={(DOMAIN, "rpi_firmware")},
|
||||
manufacturer="Raspberry Pi",
|
||||
model=SupervisorEntityModel.RPI_FIRMWARE,
|
||||
name="Raspberry Pi Firmware",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
via_device=(DOMAIN, "OS"),
|
||||
)
|
||||
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_host_in_dev_reg(
|
||||
entry_id: str,
|
||||
@@ -856,6 +876,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
|
||||
# Build clean coordinator data
|
||||
self.is_hass_os = info.hassos is not None
|
||||
|
||||
new_data = HassioMainData(
|
||||
core=core_info,
|
||||
supervisor=supervisor_info,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Update platform for Supervisor."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import Job
|
||||
from aiohasupervisor.models import Job, RaspberryPiFirmwareInfo
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
|
||||
|
||||
from homeassistant.components.update import (
|
||||
@@ -15,24 +16,60 @@ from homeassistant.components.update import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR
|
||||
from .coordinator import AddonData
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_VERSION_LATEST,
|
||||
BOARDS_WITH_RASPBERRYPI_FIRMWARE,
|
||||
DOMAIN,
|
||||
MAIN_COORDINATOR,
|
||||
)
|
||||
from .coordinator import AddonData, async_register_rpi_firmware_in_dev_reg
|
||||
from .entity import (
|
||||
HassioAddonEntity,
|
||||
HassioCoreEntity,
|
||||
HassioOSEntity,
|
||||
HassioSupervisorEntity,
|
||||
)
|
||||
from .handler import get_supervisor_client
|
||||
from .jobs import JobSubscription
|
||||
from .update_helper import update_addon, update_core, update_os
|
||||
from .update_helper import update_addon, update_core, update_os, update_rpi_firmware
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ENTITY_DESCRIPTION = UpdateEntityDescription(
|
||||
translation_key="update",
|
||||
key=ATTR_VERSION_LATEST,
|
||||
)
|
||||
|
||||
RPI_FIRMWARE_RELEASE_URL = (
|
||||
"https://github.com/raspberrypi/rpi-eeprom/blob/master/releases.md"
|
||||
)
|
||||
|
||||
|
||||
def _humanize_rpi_firmware_version(version: str | None) -> str | None:
|
||||
"""Turn a raw firmware version into a human-readable string.
|
||||
|
||||
The Supervisor reports the bootloader EEPROM build as a Unix timestamp,
|
||||
optionally suffixed with the VL805 EEPROM revision (`timestamp-hexstring`).
|
||||
Render the timestamp as a UTC `YYYY-MM-DD` date, appending
|
||||
`(VL805 hexstring)` when a VL805 revision is present.
|
||||
"""
|
||||
if version is None:
|
||||
return None
|
||||
timestamp, _, vl805 = version.partition("-")
|
||||
try:
|
||||
date = dt_util.utc_from_timestamp(int(timestamp)).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
return version
|
||||
if vl805:
|
||||
return f"{date} (VL805 {vl805})"
|
||||
return date
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -61,6 +98,25 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
# Firmware state only changes after a reboot (which restarts Core) or
|
||||
# as a result of the install action. Fetch the data here and create
|
||||
# the device with the RPi firmware update entity (unless the update
|
||||
# is blocked).
|
||||
os_info = coordinator.data.os
|
||||
if os_info is not None and os_info.board in BOARDS_WITH_RASPBERRYPI_FIRMWARE:
|
||||
client = get_supervisor_client(hass)
|
||||
try:
|
||||
rpi_firmware = await client.os.raspberry_pi_firmware_info()
|
||||
except SupervisorError as err:
|
||||
# Older supervisors (pre OS 18) don't expose the endpoint.
|
||||
rpi_firmware = None
|
||||
_LOGGER.debug("Raspberry Pi firmware info unavailable: %s", err)
|
||||
if rpi_firmware is not None and not rpi_firmware.update_blocked:
|
||||
async_register_rpi_firmware_in_dev_reg(
|
||||
config_entry.entry_id, dr.async_get(hass)
|
||||
)
|
||||
entities.append(SupervisorRPiFirmwareUpdateEntity(rpi_firmware))
|
||||
|
||||
addons_coordinator = hass.data[ADDONS_COORDINATOR]
|
||||
entities.extend(
|
||||
SupervisorAddonUpdateEntity(
|
||||
@@ -260,6 +316,102 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
|
||||
await update_os(self.hass, version, backup)
|
||||
|
||||
|
||||
class SupervisorRPiFirmwareUpdateEntity(UpdateEntity):
|
||||
"""Update entity for the Raspberry Pi firmware (bootloader EEPROM and VL805).
|
||||
|
||||
Available on RPi4/RPi5/Yellow and uses `rpi-eeprom-update` via OS Agent.
|
||||
To apply the update, a reboot is required - the issue is raised by
|
||||
Supervisor after a successful update action.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.RELEASE_NOTES
|
||||
)
|
||||
_attr_title = "Raspberry Pi Firmware"
|
||||
|
||||
def __init__(self, firmware: RaspberryPiFirmwareInfo) -> None:
|
||||
"""Initialize entity.
|
||||
|
||||
No coordinator is used. The firmware state only changes after a reboot
|
||||
(which restarts Core and re-fetches at setup) or as a direct result of
|
||||
the install action (re-fetched in `async_install`), so periodic polling
|
||||
would never show anything new.
|
||||
"""
|
||||
self._firmware = firmware
|
||||
self._attr_unique_id = "home_assistant_os_rpi_firmware"
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "rpi_firmware")})
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
"""Composite installed firmware version.
|
||||
|
||||
Once an update is applied (`update_pending`) the new version is
|
||||
reported as installed so the entity reads "up to date".
|
||||
REBOOT_REQUIRED is indicated after the update and actual switch to the
|
||||
new version applies after the reboot.
|
||||
"""
|
||||
if self._firmware.update_pending:
|
||||
return _humanize_rpi_firmware_version(self._firmware.latest_version)
|
||||
return _humanize_rpi_firmware_version(self._firmware.current_version)
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str | None:
|
||||
"""Composite available firmware version."""
|
||||
return _humanize_rpi_firmware_version(self._firmware.latest_version)
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the icon of the entity (the HA OS device icon)."""
|
||||
return "/api/brands/integration/homeassistant/icon.png?placeholder=no"
|
||||
|
||||
@property
|
||||
def release_url(self) -> str | None:
|
||||
"""Return a link to the official Raspberry Pi bootloader docs."""
|
||||
return RPI_FIRMWARE_RELEASE_URL
|
||||
|
||||
async def async_release_notes(self) -> str | None:
|
||||
"""Return the pre-install warning and reboot notice as ha-alert boxes."""
|
||||
return (
|
||||
"<ha-alert alert-type='warning'>"
|
||||
"Do not interrupt the firmware flash. "
|
||||
"Power loss during the EEPROM update can brick your device."
|
||||
"</ha-alert>\n\n"
|
||||
"<ha-alert alert-type='info'>"
|
||||
"A reboot is required after install for the new firmware to "
|
||||
"take effect."
|
||||
"</ha-alert>\n"
|
||||
)
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
# The flash is a single blocking host call with no progress output, so
|
||||
# only a boolean in-progress state is available for the duration.
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
try:
|
||||
await update_rpi_firmware(self.hass)
|
||||
except HomeAssistantError:
|
||||
self._attr_in_progress = False
|
||||
self.async_write_ha_state()
|
||||
raise
|
||||
self._attr_in_progress = False
|
||||
# The install staged/flashed the new firmware: re-fetch so the entity
|
||||
# reflects `update_pending` (reads "up to date") without a coordinator.
|
||||
client = get_supervisor_client(self.hass)
|
||||
try:
|
||||
self._firmware = await client.os.raspberry_pi_firmware_info()
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Error fetching Raspberry Pi firmware info: {err}"
|
||||
) from err
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
|
||||
"""Update entity to handle updates for the Home Assistant Supervisor.
|
||||
|
||||
|
||||
@@ -77,3 +77,18 @@ async def update_os(hass: HomeAssistant, version: str | None, backup: bool) -> N
|
||||
raise HomeAssistantError(
|
||||
f"Error updating Home Assistant Operating System: {err}"
|
||||
) from err
|
||||
|
||||
|
||||
async def update_rpi_firmware(hass: HomeAssistant) -> None:
|
||||
"""Trigger the Raspberry Pi firmware (bootloader EEPROM and VL805) update.
|
||||
|
||||
The Supervisor always raises a reboot-required notice on success - the new
|
||||
firmware only runs after the next reboot.
|
||||
"""
|
||||
client = get_supervisor_client(hass)
|
||||
try:
|
||||
await client.os.update_raspberry_pi_firmware()
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Error updating Raspberry Pi firmware: {err}"
|
||||
) from err
|
||||
|
||||
@@ -12,7 +12,11 @@ import string
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from aiohasupervisor import SupervisorClient, SupervisorNotFoundError
|
||||
from aiohasupervisor import (
|
||||
SupervisorBadRequestError,
|
||||
SupervisorClient,
|
||||
SupervisorNotFoundError,
|
||||
)
|
||||
from aiohasupervisor.addons import AddonsClient
|
||||
from aiohasupervisor.backups import BackupsClient
|
||||
from aiohasupervisor.discovery import DiscoveryClient
|
||||
@@ -862,6 +866,10 @@ def supervisor_client() -> Generator[AsyncMock]:
|
||||
)
|
||||
supervisor_client.network = AsyncMock(spec=NetworkClient)
|
||||
supervisor_client.os = AsyncMock(spec=OSClient)
|
||||
supervisor_client.os.raspberry_pi_firmware_info.side_effect = (
|
||||
# default for most targets, to be overridden in RPi FW update tests
|
||||
SupervisorBadRequestError
|
||||
)
|
||||
supervisor_client.resolution = AsyncMock(spec=ResolutionClient)
|
||||
supervisor_client.supervisor = AsyncMock(spec=SupervisorManagementClient)
|
||||
supervisor_client.store = AsyncMock(spec=StoreClient)
|
||||
@@ -915,6 +923,10 @@ def supervisor_client() -> Generator[AsyncMock]:
|
||||
"homeassistant.components.hassio.update_helper.get_supervisor_client",
|
||||
return_value=supervisor_client,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.update.get_supervisor_client",
|
||||
return_value=supervisor_client,
|
||||
),
|
||||
):
|
||||
yield supervisor_client
|
||||
|
||||
|
||||
@@ -1164,19 +1164,21 @@ async def test_coordinator_updates_stats_entities_enabled(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("board", "integration"),
|
||||
("board", "integration", "supervisor_calls"),
|
||||
[
|
||||
("green", "homeassistant_green"),
|
||||
("odroid-c2", "hardkernel"),
|
||||
("odroid-c4", "hardkernel"),
|
||||
("odroid-n2", "hardkernel"),
|
||||
("odroid-xu4", "hardkernel"),
|
||||
("rpi2", "raspberry_pi"),
|
||||
("rpi3", "raspberry_pi"),
|
||||
("rpi3-64", "raspberry_pi"),
|
||||
("rpi4", "raspberry_pi"),
|
||||
("rpi4-64", "raspberry_pi"),
|
||||
("yellow", "homeassistant_yellow"),
|
||||
("green", "homeassistant_green", 16),
|
||||
("odroid-c2", "hardkernel", 16),
|
||||
("odroid-c4", "hardkernel", 16),
|
||||
("odroid-n2", "hardkernel", 16),
|
||||
("odroid-xu4", "hardkernel", 16),
|
||||
("rpi2", "raspberry_pi", 16),
|
||||
("rpi3", "raspberry_pi", 16),
|
||||
("rpi3-64", "raspberry_pi", 16),
|
||||
("rpi4", "raspberry_pi", 16),
|
||||
# rpi4-64, rpi5-64 and yellow add a raspberry_pi_firmware_info
|
||||
("rpi4-64", "raspberry_pi", 17),
|
||||
("rpi5-64", "raspberry_pi", 17),
|
||||
("yellow", "homeassistant_yellow", 17),
|
||||
],
|
||||
)
|
||||
async def test_setup_hardware_integration(
|
||||
@@ -1185,6 +1187,7 @@ async def test_setup_hardware_integration(
|
||||
os_info: AsyncMock,
|
||||
board: str,
|
||||
integration: str,
|
||||
supervisor_calls: int,
|
||||
) -> None:
|
||||
"""Test setup initiates hardware integration."""
|
||||
os_info.return_value = replace(os_info.return_value, board=board)
|
||||
@@ -1204,7 +1207,7 @@ async def test_setup_hardware_integration(
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == supervisor_calls
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from aiohasupervisor.models import (
|
||||
Job,
|
||||
JobsInfo,
|
||||
OSUpdate,
|
||||
RaspberryPiFirmwareInfo,
|
||||
StoreAddonUpdate,
|
||||
)
|
||||
import pytest
|
||||
@@ -32,6 +33,7 @@ from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY
|
||||
from homeassistant.const import __version__ as HAVERSION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -1982,3 +1984,350 @@ async def test_setting_up_core_update_when_addon_fails(
|
||||
state = hass.states.get("update.home_assistant_core_update")
|
||||
assert state
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
RPI_FIRMWARE_ENTITY_ID = "update.raspberry_pi_firmware"
|
||||
|
||||
|
||||
def _set_rpi_firmware_mock(
|
||||
os_info: AsyncMock,
|
||||
supervisor_client: AsyncMock,
|
||||
*,
|
||||
board: str,
|
||||
update_blocked: bool = False,
|
||||
update_pending: bool = False,
|
||||
current_version: str = "1765222194",
|
||||
latest_version: str = "1778498402",
|
||||
blocked_reason: str | None = None,
|
||||
) -> None:
|
||||
"""Configure the Supervisor mocks for an RPi firmware scenario."""
|
||||
os_info.return_value = replace(os_info.return_value, board=board)
|
||||
supervisor_client.os.raspberry_pi_firmware_info.side_effect = None
|
||||
supervisor_client.os.raspberry_pi_firmware_info.return_value = (
|
||||
RaspberryPiFirmwareInfo(
|
||||
current_version=current_version,
|
||||
latest_version=latest_version,
|
||||
update_available=current_version != latest_version,
|
||||
update_blocked=update_blocked,
|
||||
update_pending=update_pending,
|
||||
blocked_reason=blocked_reason
|
||||
or ("unsupported_boot_device" if update_blocked else None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("board", ["rpi4-64", "rpi5-64", "yellow"])
|
||||
async def test_rpi_firmware_entity_registered(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
board: str,
|
||||
) -> None:
|
||||
"""The Raspberry Pi firmware update entity is registered on RPi4/5/Yellow."""
|
||||
_set_rpi_firmware_mock(os_info, supervisor_client, board=board)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
# wait_background_tasks drains the home_assistant_yellow auto-discovery
|
||||
# cascade so it doesn't leak into teardown when board == "yellow".
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get(RPI_FIRMWARE_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
assert state.attributes["installed_version"] == "2025-12-08"
|
||||
assert state.attributes["latest_version"] == "2026-05-11"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_version", "latest_version", "expected_installed", "expected_latest"),
|
||||
[
|
||||
pytest.param(
|
||||
"1765222194",
|
||||
"1778498402",
|
||||
"2025-12-08",
|
||||
"2026-05-11",
|
||||
id="eeprom_only",
|
||||
),
|
||||
pytest.param(
|
||||
"1765222194-000138a1",
|
||||
"1778498402-000138c0",
|
||||
"2025-12-08 (VL805 000138a1)",
|
||||
"2026-05-11 (VL805 000138c0)",
|
||||
id="with_vl805",
|
||||
),
|
||||
pytest.param(
|
||||
"not-a-timestamp",
|
||||
"1778498402",
|
||||
"not-a-timestamp",
|
||||
"2026-05-11",
|
||||
id="unparseable_passthrough",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_rpi_firmware_version_humanized(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
current_version: str,
|
||||
latest_version: str,
|
||||
expected_installed: str,
|
||||
expected_latest: str,
|
||||
) -> None:
|
||||
"""Firmware versions are rendered as UTC dates with an optional VL805 suffix."""
|
||||
_set_rpi_firmware_mock(
|
||||
os_info,
|
||||
supervisor_client,
|
||||
board="rpi5-64",
|
||||
current_version=current_version,
|
||||
latest_version=latest_version,
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(RPI_FIRMWARE_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.attributes["installed_version"] == expected_installed
|
||||
assert state.attributes["latest_version"] == expected_latest
|
||||
|
||||
|
||||
async def test_rpi_firmware_device(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""The firmware entity lives on its own device nested under the OS device."""
|
||||
_set_rpi_firmware_mock(os_info, supervisor_client, board="rpi5-64")
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, "rpi_firmware")})
|
||||
assert device is not None
|
||||
assert device.name == "Raspberry Pi Firmware"
|
||||
|
||||
os_device = device_registry.async_get_device(identifiers={(DOMAIN, "OS")})
|
||||
assert os_device is not None
|
||||
assert device.via_device_id == os_device.id
|
||||
|
||||
entity_entry = er.async_get(hass).async_get(RPI_FIRMWARE_ENTITY_ID)
|
||||
assert entity_entry is not None
|
||||
assert entity_entry.device_id == device.id
|
||||
|
||||
|
||||
async def test_rpi_firmware_entity_absent_on_older_supervisor(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
) -> None:
|
||||
"""If the supervisor doesn't expose firmware info yet, no entity is created."""
|
||||
os_info.return_value = replace(os_info.return_value, board="rpi5-64")
|
||||
supervisor_client.os.raspberry_pi_firmware_info.side_effect = (
|
||||
SupervisorNotFoundError("Not found")
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(RPI_FIRMWARE_ENTITY_ID) is None
|
||||
|
||||
|
||||
async def test_rpi_firmware_install(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
) -> None:
|
||||
"""Install action delegates to the supervisor's update_raspberry_pi_firmware."""
|
||||
_set_rpi_firmware_mock(os_info, supervisor_client, board="rpi5-64")
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
supervisor_client.os.update_raspberry_pi_firmware.return_value = None
|
||||
await hass.services.async_call(
|
||||
"update",
|
||||
"install",
|
||||
{"entity_id": RPI_FIRMWARE_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
supervisor_client.os.update_raspberry_pi_firmware.assert_called_once_with()
|
||||
|
||||
|
||||
async def test_rpi_firmware_entity_hidden_when_update_blocked(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""No firmware entity or device is created when the update is blocked."""
|
||||
_set_rpi_firmware_mock(
|
||||
os_info,
|
||||
supervisor_client,
|
||||
board="rpi4-64",
|
||||
update_blocked=True,
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(RPI_FIRMWARE_ENTITY_ID) is None
|
||||
# The device must not be registered without an entity (no orphaned device).
|
||||
assert (
|
||||
device_registry.async_get_device(identifiers={(DOMAIN, "rpi_firmware")}) is None
|
||||
)
|
||||
|
||||
|
||||
async def test_rpi_firmware_release_notes_warning(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Release notes carry the pre-install firmware-flash warning."""
|
||||
_set_rpi_firmware_mock(os_info, supervisor_client, board="rpi5-64")
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{"type": "update/release_notes", "entity_id": RPI_FIRMWARE_ENTITY_ID}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert "Do not interrupt" in msg["result"]
|
||||
|
||||
|
||||
async def test_rpi_firmware_entity_up_to_date_when_update_pending(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
) -> None:
|
||||
"""A pending (applied) update reads as up to date until the reboot."""
|
||||
_set_rpi_firmware_mock(
|
||||
os_info, supervisor_client, board="rpi5-64", update_pending=True
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(RPI_FIRMWARE_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == "off"
|
||||
# The applied version is reported as installed even though the running
|
||||
# firmware only changes after the reboot.
|
||||
assert state.attributes["installed_version"] == "2026-05-11"
|
||||
assert state.attributes["latest_version"] == "2026-05-11"
|
||||
|
||||
|
||||
async def test_rpi_firmware_up_to_date_after_install(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
) -> None:
|
||||
"""The entity reads up to date once the flash reports an update pending."""
|
||||
_set_rpi_firmware_mock(os_info, supervisor_client, board="rpi5-64")
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(RPI_FIRMWARE_ENTITY_ID).state == "on"
|
||||
|
||||
# The supervisor reports update_pending once the flash completes; the
|
||||
# post-install coordinator refresh then picks that up.
|
||||
async def mark_pending() -> None:
|
||||
_set_rpi_firmware_mock(
|
||||
os_info, supervisor_client, board="rpi5-64", update_pending=True
|
||||
)
|
||||
|
||||
supervisor_client.os.update_raspberry_pi_firmware.side_effect = mark_pending
|
||||
await hass.services.async_call(
|
||||
"update",
|
||||
"install",
|
||||
{"entity_id": RPI_FIRMWARE_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert hass.states.get(RPI_FIRMWARE_ENTITY_ID).state == "off"
|
||||
|
||||
|
||||
async def test_rpi_firmware_install_sets_in_progress(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
) -> None:
|
||||
"""The firmware entity reports in_progress while the flash runs."""
|
||||
_set_rpi_firmware_mock(os_info, supervisor_client, board="rpi5-64")
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(RPI_FIRMWARE_ENTITY_ID).attributes["in_progress"] is False
|
||||
|
||||
async def check_progress(hass: HomeAssistant) -> None:
|
||||
assert hass.states.get(RPI_FIRMWARE_ENTITY_ID).attributes["in_progress"] is True
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hassio.update.update_rpi_firmware",
|
||||
side_effect=check_progress,
|
||||
) as mock_update:
|
||||
await hass.services.async_call(
|
||||
"update",
|
||||
"install",
|
||||
{"entity_id": RPI_FIRMWARE_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_update.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user