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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
160
homeassistant/components/hassio/jobs.py
Normal file
160
homeassistant/components/hassio/jobs.py
Normal 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)
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": []}},
|
||||
)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user