From c9d68ddd5cf6e162308f5ebb3d36373969502fe9 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 28 Oct 2025 03:54:02 -0400 Subject: [PATCH] Add progress reporting for addon and core update entities (#153268) Co-authored-by: Stefan Agner --- homeassistant/components/hassio/const.py | 3 + .../components/hassio/coordinator.py | 5 + homeassistant/components/hassio/issues.py | 3 + homeassistant/components/hassio/jobs.py | 160 +++++++++ homeassistant/components/hassio/update.py | 52 ++- tests/components/conftest.py | 12 + tests/components/hassio/conftest.py | 6 + tests/components/hassio/test_binary_sensor.py | 1 + tests/components/hassio/test_diagnostics.py | 1 + tests/components/hassio/test_init.py | 31 +- tests/components/hassio/test_issues.py | 48 ++- tests/components/hassio/test_sensor.py | 1 + tests/components/hassio/test_switch.py | 1 + tests/components/hassio/test_update.py | 311 +++++++++++++++++- 14 files changed, 616 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/hassio/jobs.py diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index b93f25142c5..c5ff1f0cb10 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -36,6 +36,7 @@ ATTR_METHOD = "method" ATTR_PANELS = "panels" ATTR_PASSWORD = "password" ATTR_RESULT = "result" +ATTR_STARTUP = "startup" ATTR_SUGGESTIONS = "suggestions" ATTR_SUPPORTED = "supported" ATTR_TIMEOUT = "timeout" @@ -68,8 +69,10 @@ EVENT_HEALTH_CHANGED = "health_changed" EVENT_SUPPORTED_CHANGED = "supported_changed" EVENT_ISSUE_CHANGED = "issue_changed" EVENT_ISSUE_REMOVED = "issue_removed" +EVENT_JOB = "job" UPDATE_KEY_SUPERVISOR = "supervisor" +STARTUP_COMPLETE = "complete" ADDONS_COORDINATOR = "hassio_addons_coordinator" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 2a41bbc2bda..a49c7b88580 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -56,6 +56,7 @@ from .const import ( SupervisorEntityModel, ) from .handler import HassioAPIError, get_supervisor_client +from .jobs import SupervisorJobs if TYPE_CHECKING: from .issues import SupervisorIssues @@ -311,6 +312,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): lambda: defaultdict(set) ) self.supervisor_client = get_supervisor_client(hass) + self.jobs = SupervisorJobs(hass) async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" @@ -485,6 +487,9 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): ) ) + # Refresh jobs data + await self.jobs.refresh_data(first_update) + async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]: """Update single addon stats.""" try: diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 3ec1f5389ce..fe842f8ddc4 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -29,6 +29,7 @@ from homeassistant.helpers.issue_registry import ( from .const import ( ATTR_DATA, ATTR_HEALTHY, + ATTR_STARTUP, ATTR_SUPPORTED, ATTR_UNHEALTHY_REASONS, ATTR_UNSUPPORTED_REASONS, @@ -54,6 +55,7 @@ from .const import ( PLACEHOLDER_KEY_FREE_SPACE, PLACEHOLDER_KEY_REFERENCE, REQUEST_REFRESH_DELAY, + STARTUP_COMPLETE, UPDATE_KEY_SUPERVISOR, ) from .coordinator import get_addons_info, get_host_info @@ -383,6 +385,7 @@ class SupervisorIssues: if ( event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE ): self._hass.async_create_task(self._update()) diff --git a/homeassistant/components/hassio/jobs.py b/homeassistant/components/hassio/jobs.py new file mode 100644 index 00000000000..a3cfb695296 --- /dev/null +++ b/homeassistant/components/hassio/jobs.py @@ -0,0 +1,160 @@ +"""Track Supervisor job data and allow subscription to updates.""" + +from collections.abc import Callable +from dataclasses import dataclass, replace +from functools import partial +from typing import Any +from uuid import UUID + +from aiohasupervisor.models import Job + +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + callback, + is_callback_check_partial, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + ATTR_DATA, + ATTR_STARTUP, + ATTR_UPDATE_KEY, + ATTR_WS_EVENT, + EVENT_JOB, + EVENT_SUPERVISOR_EVENT, + EVENT_SUPERVISOR_UPDATE, + STARTUP_COMPLETE, + UPDATE_KEY_SUPERVISOR, +) +from .handler import get_supervisor_client + + +@dataclass(slots=True, frozen=True) +class JobSubscription: + """Subscribe for updates on jobs which match filters. + + UUID is preferred match but only available in cases of a background API that + returns the UUID before taking the action. Others are used to match jobs only + if UUID is omitted. Either name or UUID is required to be able to match. + + event_callback must be safe annotated as a homeassistant.core.callback + and safe to call in the event loop. + """ + + event_callback: Callable[[Job], Any] + uuid: str | None = None + name: str | None = None + reference: str | None | type[Any] = Any + + def __post_init__(self) -> None: + """Validate at least one filter option is present.""" + if not self.name and not self.uuid: + raise ValueError("Either name or uuid must be provided!") + if not is_callback_check_partial(self.event_callback): + raise ValueError("event_callback must be a homeassistant.core.callback!") + + def matches(self, job: Job) -> bool: + """Return true if job matches subscription filters.""" + if self.uuid: + return job.uuid == self.uuid + return job.name == self.name and self.reference in (Any, job.reference) + + +class SupervisorJobs: + """Manage access to Supervisor jobs.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize object.""" + self._hass = hass + self._supervisor_client = get_supervisor_client(hass) + self._jobs: dict[UUID, Job] = {} + self._subscriptions: set[JobSubscription] = set() + + @property + def current_jobs(self) -> list[Job]: + """Return current jobs.""" + return list(self._jobs.values()) + + def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE: + """Subscribe to updates for job. Return callback is used to unsubscribe. + + If any jobs match the subscription at the time this is called, creates + tasks to run their callback on it. + """ + self._subscriptions.add(subscription) + + # As these are callbacks they are safe to run in the event loop + # We wrap these in an asyncio task so subscribing does not wait on the logic + if matches := [job for job in self._jobs.values() if subscription.matches(job)]: + + async def event_callback_async(job: Job) -> Any: + return subscription.event_callback(job) + + for match in matches: + self._hass.async_create_task(event_callback_async(match)) + + return partial(self._subscriptions.discard, subscription) + + async def refresh_data(self, first_update: bool = False) -> None: + """Refresh job data.""" + job_data = await self._supervisor_client.jobs.info() + job_queue: list[Job] = job_data.jobs.copy() + new_jobs: dict[UUID, Job] = {} + changed_jobs: list[Job] = [] + + # Rebuild our job cache from new info and compare to find changes + while job_queue: + job = job_queue.pop(0) + job_queue.extend(job.child_jobs) + job = replace(job, child_jobs=[]) + + if job.uuid not in self._jobs or job != self._jobs[job.uuid]: + changed_jobs.append(job) + new_jobs[job.uuid] = replace(job, child_jobs=[]) + + # For any jobs that disappeared which weren't done, tell subscribers they + # changed to done. We don't know what else happened to them so leave the + # rest of their state as is rather then guessing + changed_jobs.extend( + [ + replace(job, done=True) + for uuid, job in self._jobs.items() + if uuid not in new_jobs and job.done is False + ] + ) + + # Replace our cache and inform subscribers of all changes + self._jobs = new_jobs + for job in changed_jobs: + self._process_job_change(job) + + # If this is the first update register to receive Supervisor events + if first_update: + async_dispatcher_connect( + self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs + ) + + @callback + def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None: + """Update job data cache from supervisor events.""" + if ATTR_WS_EVENT not in event: + return + + if ( + event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE + and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE + ): + self._hass.async_create_task(self.refresh_data()) + + elif event[ATTR_WS_EVENT] == EVENT_JOB: + job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []}) + self._jobs[job.uuid] = job + self._process_job_change(job) + + def _process_job_change(self, job: Job) -> None: + """Process a job change by triggering callbacks on subscribers.""" + for sub in self._subscriptions: + if sub.matches(job): + sub.event_callback(job) diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 2515ee04ab3..b9db22d558d 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -6,6 +6,7 @@ import re from typing import Any from aiohasupervisor import SupervisorError +from aiohasupervisor.models import Job from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( @@ -15,7 +16,7 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON, ATTR_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -35,6 +36,7 @@ from .entity import ( HassioOSEntity, HassioSupervisorEntity, ) +from .jobs import JobSubscription from .update_helper import update_addon, update_core, update_os ENTITY_DESCRIPTION = UpdateEntityDescription( @@ -89,6 +91,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): UpdateEntityFeature.INSTALL | UpdateEntityFeature.BACKUP | UpdateEntityFeature.RELEASE_NOTES + | UpdateEntityFeature.PROGRESS ) @property @@ -154,6 +157,30 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): ) await self.coordinator.async_refresh() + @callback + def _update_job_changed(self, job: Job) -> None: + """Process update for this entity's update job.""" + if job.done is False: + self._attr_in_progress = True + self._attr_update_percentage = job.progress + else: + self._attr_in_progress = False + self._attr_update_percentage = None + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to progress updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.jobs.subscribe( + JobSubscription( + self._update_job_changed, + name="addon_manager_update", + reference=self._addon_slug, + ) + ) + ) + class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): """Update entity to handle updates for the Home Assistant Operating System.""" @@ -250,6 +277,7 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION | UpdateEntityFeature.BACKUP + | UpdateEntityFeature.PROGRESS ) _attr_title = "Home Assistant Core" @@ -281,3 +309,25 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): ) -> None: """Install an update.""" await update_core(self.hass, version, backup) + + @callback + def _update_job_changed(self, job: Job) -> None: + """Process update for this entity's update job.""" + if job.done is False: + self._attr_in_progress = True + self._attr_update_percentage = job.progress + else: + self._attr_in_progress = False + self._attr_update_percentage = None + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to progress updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.jobs.subscribe( + JobSubscription( + self._update_job_changed, name="home_assistant_core_update" + ) + ) + ) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 48198757c25..eddd4f4b2a8 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -14,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor.models import ( Discovery, + JobsInfo, Repository, ResolutionInfo, StoreAddon, @@ -509,6 +510,13 @@ def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> As return supervisor_client.resolution.suggestions_for_issue +@pytest.fixture(name="jobs_info") +def jobs_info_fixture(supervisor_client: AsyncMock) -> AsyncMock: + """Mock jobs info from supervisor.""" + supervisor_client.jobs.info.return_value = JobsInfo(ignore_conditions=[], jobs=[]) + return supervisor_client.jobs.info + + @pytest.fixture(name="supervisor_client") def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" @@ -554,6 +562,10 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.issues.get_supervisor_client", return_value=supervisor_client, ), + patch( + "homeassistant.components.hassio.jobs.get_supervisor_client", + return_value=supervisor_client, + ), patch( "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 476062ab6af..e29346878f9 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -79,6 +79,7 @@ def all_setup_requests( store_info: AsyncMock, addon_changelog: AsyncMock, addon_stats: AsyncMock, + jobs_info: AsyncMock, ) -> None: """Mock all setup requests.""" include_addons = hasattr(request, "param") and request.param.get( @@ -261,3 +262,8 @@ def all_setup_requests( }, }, ) + + aioclient_mock.get( + "http://127.0.0.1/jobs/info", + json={"result": "ok", "data": {"ignore_conditions": [], "jobs": []}}, + ) diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index 9878dd67a21..df9a180bdfb 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -26,6 +26,7 @@ def mock_all( addon_changelog: AsyncMock, addon_stats: AsyncMock, resolution_info: AsyncMock, + jobs_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index c95cde67b8a..a889e28b7f0 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -25,6 +25,7 @@ def mock_all( addon_stats: AsyncMock, addon_changelog: AsyncMock, resolution_info: AsyncMock, + jobs_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 9d7c66b0d24..dc2b2f873a3 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -72,6 +72,7 @@ def mock_all( addon_stats: AsyncMock, addon_changelog: AsyncMock, resolution_info: AsyncMock, + jobs_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -232,7 +233,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 19 assert get_core_info(hass)["version_latest"] == "1.0.0" assert is_hassio(hass) @@ -279,7 +280,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 19 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 9999 assert "watchdog" not in aioclient_mock.mock_calls[0][2] @@ -300,7 +301,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 19 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 9999 assert not aioclient_mock.mock_calls[0][2]["watchdog"] @@ -321,7 +322,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 19 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[0][2]["refresh_token"] @@ -402,7 +403,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 19 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 8123 assert aioclient_mock.mock_calls[0][2]["refresh_token"] == token.token @@ -421,7 +422,7 @@ async def test_setup_core_push_config( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 19 assert aioclient_mock.mock_calls[1][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -445,7 +446,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 19 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -527,14 +528,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 22 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 23 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 24 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 25 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -549,7 +550,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 26 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 27 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -574,7 +575,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 28 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -593,7 +594,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 30 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -609,7 +610,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 30 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 31 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -628,7 +629,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 32 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 33 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -1074,7 +1075,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done(wait_background_tasks=True) assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 19 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 20473ff4041..fbe15e5f6f3 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -417,7 +417,7 @@ async def test_reset_issues_supervisor_restart( "data": { "event": "supervisor_update", "update_key": "supervisor", - "data": {}, + "data": {"startup": "complete"}, }, } ) @@ -431,6 +431,50 @@ async def test_reset_issues_supervisor_restart( assert msg["result"] == {"issues": []} +@pytest.mark.usefixtures("all_setup_requests") +async def test_no_reset_issues_supervisor_update_found( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Issues do not reset because a supervisor update was found.""" + mock_resolution_info( + supervisor_client, + unsupported=[UnsupportedReason.OS], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + + mock_resolution_info(supervisor_client) + await client.send_json( + { + "id": 2, + "type": "supervisor/event", + "data": { + "event": "supervisor_update", + "update_key": "supervisor", + "data": {}, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 3, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + + @pytest.mark.usefixtures("all_setup_requests") async def test_reasons_added_and_removed( hass: HomeAssistant, @@ -468,7 +512,7 @@ async def test_reasons_added_and_removed( "data": { "event": "supervisor_update", "update_key": "supervisor", - "data": {}, + "data": {"startup": "complete"}, }, } ) diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index f4b01a85900..21eac41f291 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -34,6 +34,7 @@ def mock_all( addon_stats: AsyncMock, addon_changelog: AsyncMock, resolution_info: AsyncMock, + jobs_info: AsyncMock, ) -> None: """Mock all setup requests.""" _install_default_mocks(aioclient_mock) diff --git a/tests/components/hassio/test_switch.py b/tests/components/hassio/test_switch.py index 7963389e8ca..7c203142b8a 100644 --- a/tests/components/hassio/test_switch.py +++ b/tests/components/hassio/test_switch.py @@ -60,6 +60,7 @@ def mock_all( addon_changelog: AsyncMock, addon_stats: AsyncMock, resolution_info: AsyncMock, + jobs_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index cfc3a923399..cba9fb2fa30 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -1,9 +1,10 @@ """The tests for the hassio update entities.""" -from datetime import timedelta +from datetime import datetime, timedelta import os from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 from aiohasupervisor import ( SupervisorBadRequestError, @@ -12,6 +13,8 @@ from aiohasupervisor import ( ) from aiohasupervisor.models import ( HomeAssistantUpdateOptions, + Job, + JobsInfo, OSUpdate, StoreAddonUpdate, ) @@ -44,6 +47,7 @@ def mock_all( addon_stats: AsyncMock, addon_changelog: AsyncMock, resolution_info: AsyncMock, + jobs_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -243,6 +247,131 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +async def test_update_addon_progress( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test progress reporting for addon update.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + message_id = 0 + job_uuid = uuid4().hex + + def make_job_message(progress: float, done: bool | None): + nonlocal message_id + message_id += 1 + return { + "id": message_id, + "type": "supervisor/event", + "data": { + "event": "job", + "data": { + "uuid": job_uuid, + "created": "2025-09-29T00:00:00.000000+00:00", + "name": "addon_manager_update", + "reference": "test", + "progress": progress, + "done": done, + "stage": None, + "extra": {"total": 1234567890} if progress > 0 else None, + "errors": [], + }, + }, + } + + await client.send_json(make_job_message(progress=0, done=None)) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + assert hass.states.get("update.test_update").attributes.get("in_progress") is False + assert ( + hass.states.get("update.test_update").attributes.get("update_percentage") + is None + ) + + await client.send_json(make_job_message(progress=5, done=False)) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + assert hass.states.get("update.test_update").attributes.get("in_progress") is True + assert ( + hass.states.get("update.test_update").attributes.get("update_percentage") == 5 + ) + + await client.send_json(make_job_message(progress=50, done=False)) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + assert hass.states.get("update.test_update").attributes.get("in_progress") is True + assert ( + hass.states.get("update.test_update").attributes.get("update_percentage") == 50 + ) + + await client.send_json(make_job_message(progress=100, done=True)) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + assert hass.states.get("update.test_update").attributes.get("in_progress") is False + assert ( + hass.states.get("update.test_update").attributes.get("update_percentage") + is None + ) + + +async def test_addon_update_progress_startup( + hass: HomeAssistant, jobs_info: AsyncMock +) -> None: + """Test addon update in progress during home assistant startup.""" + jobs_info.return_value = JobsInfo( + ignore_conditions=[], + jobs=[ + Job( + name="addon_manager_update", + reference="test", + uuid=uuid4().hex, + progress=50, + stage=None, + done=False, + errors=[], + created=datetime.now(), + child_jobs=[], + extra={"total": 1234567890}, + ) + ], + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + assert hass.states.get("update.test_update").attributes.get("in_progress") is True + assert ( + hass.states.get("update.test_update").attributes.get("update_percentage") == 50 + ) + + async def setup_backup_integration(hass: HomeAssistant) -> None: """Set up the backup integration.""" assert await async_setup_component(hass, "backup", {}) @@ -630,6 +759,186 @@ async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> ) +async def test_update_core_progress( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test progress reporting for core update.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + message_id = 0 + job_uuid = uuid4().hex + + def make_job_message( + progress: float, done: bool | None, errors: list[dict[str, str]] | None = None + ): + nonlocal message_id + message_id += 1 + return { + "id": message_id, + "type": "supervisor/event", + "data": { + "event": "job", + "data": { + "uuid": job_uuid, + "created": "2025-09-29T00:00:00.000000+00:00", + "name": "home_assistant_core_update", + "reference": None, + "progress": progress, + "done": done, + "stage": None, + "extra": {"total": 1234567890} if progress > 0 else None, + "errors": errors if errors else [], + }, + }, + } + + await client.send_json(make_job_message(progress=0, done=None)) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "in_progress" + ) + is False + ) + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "update_percentage" + ) + is None + ) + + await client.send_json(make_job_message(progress=5, done=False)) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "in_progress" + ) + is True + ) + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "update_percentage" + ) + == 5 + ) + + await client.send_json(make_job_message(progress=50, done=False)) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "in_progress" + ) + is True + ) + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "update_percentage" + ) + == 50 + ) + + # During a successful update Home Assistant is stopped before the update job + # reaches the end. An error ends it early so we use that for test + await client.send_json( + make_job_message( + progress=70, + done=True, + errors=[ + {"type": "HomeAssistantUpdateError", "message": "bad", "stage": None} + ], + ) + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "in_progress" + ) + is False + ) + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "update_percentage" + ) + is None + ) + + +async def test_core_update_progress_startup( + hass: HomeAssistant, jobs_info: AsyncMock +) -> None: + """Test core update in progress during home assistant startup. + + This is an odd test, it's very unlikely core will be starting during an update. + It is technically possible though as core isn't stopped until the docker portion + is complete and updates can be started from CLI. + """ + jobs_info.return_value = JobsInfo( + ignore_conditions=[], + jobs=[ + Job( + name="home_assistant_core_update", + reference=None, + uuid=uuid4().hex, + progress=50, + stage=None, + done=False, + errors=[], + created=datetime.now(), + child_jobs=[], + extra={"total": 1234567890}, + ) + ], + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "in_progress" + ) + is True + ) + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "update_percentage" + ) + == 50 + ) + + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), [