1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 02:48:57 +00:00

Add progress reporting for addon and core update entities (#153268)

Co-authored-by: Stefan Agner <stefan@agner.ch>
This commit is contained in:
Mike Degatano
2025-10-28 03:54:02 -04:00
committed by GitHub
parent ac6dddc895
commit c9d68ddd5c
14 changed files with 616 additions and 19 deletions

View File

@@ -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"

View File

@@ -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:

View File

@@ -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())

View File

@@ -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)

View File

@@ -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"
)
)
)

View File

@@ -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,

View File

@@ -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": []}},
)

View File

@@ -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"})

View File

@@ -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"})

View File

@@ -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

View File

@@ -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"},
},
}
)

View File

@@ -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)

View File

@@ -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"})

View File

@@ -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"),
[