diff --git a/aiohasupervisor/jobs.py b/aiohasupervisor/jobs.py new file mode 100644 index 0000000..74c0f3e --- /dev/null +++ b/aiohasupervisor/jobs.py @@ -0,0 +1,32 @@ +"""Jobs client for supervisor.""" + +from uuid import UUID + +from .client import _SupervisorComponentClient +from .models.jobs import Job, JobsInfo, JobsOptions + + +class JobsClient(_SupervisorComponentClient): + """Handles Jobs access in Supervisor.""" + + async def info(self) -> JobsInfo: + """Get Jobs info.""" + result = await self._client.get("jobs/info") + return JobsInfo.from_dict(result.data) + + async def set_options(self, options: JobsOptions) -> None: + """Set Jobs options.""" + await self._client.post("jobs/options", json=options.to_dict()) + + async def reset(self) -> None: + """Reset Jobs options (primarily clears previously ignored job conditions).""" + await self._client.post("jobs/reset") + + async def get_job(self, job: UUID) -> Job: + """Get details of a job.""" + result = await self._client.get(f"jobs/{job.hex}") + return Job.from_dict(result.data) + + async def delete_job(self, job: UUID) -> None: + """Remove a done job from Supervisor's cache.""" + await self._client.delete(f"jobs/{job.hex}") diff --git a/aiohasupervisor/models/__init__.py b/aiohasupervisor/models/__init__.py index b2e72d3..2d451df 100644 --- a/aiohasupervisor/models/__init__.py +++ b/aiohasupervisor/models/__init__.py @@ -66,6 +66,13 @@ ServiceState, ShutdownOptions, ) +from aiohasupervisor.models.jobs import ( + Job, + JobCondition, + JobError, + JobsInfo, + JobsOptions, +) from aiohasupervisor.models.mounts import ( CIFSMountRequest, CIFSMountResponse, @@ -248,6 +255,11 @@ "Service", "ServiceState", "ShutdownOptions", + "Job", + "JobCondition", + "JobError", + "JobsInfo", + "JobsOptions", "CIFSMountRequest", "CIFSMountResponse", "MountCifsVersion", diff --git a/aiohasupervisor/models/jobs.py b/aiohasupervisor/models/jobs.py new file mode 100644 index 0000000..9d45287 --- /dev/null +++ b/aiohasupervisor/models/jobs.py @@ -0,0 +1,77 @@ +"""Models for Supervisor jobs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from uuid import UUID # noqa: TCH003 + +from .base import Request, ResponseData + +# --- ENUMS ---- + + +class JobCondition(StrEnum): + """JobCondition type. + + This is an incomplete list. Supervisor regularly adds support for new + job conditions as they are found to be needed. Therefore when returning + a list of job conditions, there may be some which are not in + this list parsed as strings on older versions of the client. + """ + + AUTO_UPDATE = "auto_update" + FREE_SPACE = "free_space" + FROZEN = "frozen" + HAOS = "haos" + HEALTHY = "healthy" + HOST_NETWORK = "host_network" + INTERNET_HOST = "internet_host" + INTERNET_SYSTEM = "internet_system" + MOUNT_AVAILABLE = "mount_available" + OS_AGENT = "os_agent" + PLUGINS_UPDATED = "plugins_updated" + RUNNING = "running" + SUPERVISOR_UPDATED = "supervisor_updated" + + +# --- OBJECTS ---- + + +@dataclass(slots=True, frozen=True) +class JobError(ResponseData): + """JobError model.""" + + type: str + message: str + + +@dataclass(slots=True, frozen=True) +class Job(ResponseData): + """Job model.""" + + name: str | None + reference: str | None + uuid: UUID + progress: float + stage: str | None + done: bool | None + errors: list[JobError] + child_jobs: list[Job] + + +@dataclass(slots=True, frozen=True) +class JobsInfo(ResponseData): + """JobsInfo model.""" + + ignore_conditions: list[JobCondition | str] + jobs: list[Job] + + +@dataclass(slots=True, frozen=True) +class JobsOptions(Request): + """JobsOptions model.""" + + # We only do `| str` in responses since we can't control what supervisor returns + # Support for ignoring new job conditions will wait for a new version of library + ignore_conditions: list[JobCondition] diff --git a/aiohasupervisor/root.py b/aiohasupervisor/root.py index e2e7d48..6f01456 100644 --- a/aiohasupervisor/root.py +++ b/aiohasupervisor/root.py @@ -10,6 +10,7 @@ from .discovery import DiscoveryClient from .homeassistant import HomeAssistantClient from .host import HostClient +from .jobs import JobsClient from .models.root import AvailableUpdate, AvailableUpdates, RootInfo from .mounts import MountsClient from .network import NetworkClient @@ -34,6 +35,7 @@ def __init__( self._os = OSClient(self._client) self._backups = BackupsClient(self._client) self._discovery = DiscoveryClient(self._client) + self._jobs = JobsClient(self._client) self._mounts = MountsClient(self._client) self._network = NetworkClient(self._client) self._host = HostClient(self._client) @@ -67,6 +69,11 @@ def discovery(self) -> DiscoveryClient: """Get discovery component client.""" return self._discovery + @property + def jobs(self) -> JobsClient: + """Get jobs component client.""" + return self._jobs + @property def mounts(self) -> MountsClient: """Get mounts component client.""" diff --git a/tests/fixtures/jobs_get_job.json b/tests/fixtures/jobs_get_job.json new file mode 100644 index 0000000..00d6dd9 --- /dev/null +++ b/tests/fixtures/jobs_get_job.json @@ -0,0 +1,35 @@ +{ + "result": "ok", + "data": { + "name": "backup_manager_partial_backup", + "reference": "89cafa67", + "uuid": "2febe59311f94d6ba36f6f9f73357ca8", + "progress": 0, + "stage": "finishing_file", + "done": true, + "errors": [], + "child_jobs": [ + { + "name": "backup_store_folders", + "reference": "89cafa67", + "uuid": "f4bac7d9240f434a9f6a21aff7f7ade3", + "progress": 0, + "stage": null, + "done": true, + "errors": [], + "child_jobs": [ + { + "name": "backup_folder_save", + "reference": "ssl", + "uuid": "fb328a1a048d4b9b982bf6b9070c03a6", + "progress": 0, + "stage": null, + "done": true, + "errors": [], + "child_jobs": [] + } + ] + } + ] + } +} diff --git a/tests/fixtures/jobs_info.json b/tests/fixtures/jobs_info.json new file mode 100644 index 0000000..d126afb --- /dev/null +++ b/tests/fixtures/jobs_info.json @@ -0,0 +1,55 @@ +{ + "result": "ok", + "data": { + "ignore_conditions": ["free_space"], + "jobs": [ + { + "name": "backup_manager_partial_backup", + "reference": "89cafa67", + "uuid": "2febe59311f94d6ba36f6f9f73357ca8", + "progress": 0, + "stage": "finishing_file", + "done": true, + "errors": [], + "child_jobs": [ + { + "name": "backup_store_folders", + "reference": "89cafa67", + "uuid": "f4bac7d9240f434a9f6a21aff7f7ade3", + "progress": 0, + "stage": null, + "done": true, + "errors": [], + "child_jobs": [ + { + "name": "backup_folder_save", + "reference": "ssl", + "uuid": "fb328a1a048d4b9b982bf6b9070c03a6", + "progress": 0, + "stage": null, + "done": true, + "errors": [], + "child_jobs": [] + } + ] + } + ] + }, + { + "name": "backup_manager_partial_restore", + "reference": "cfddca18", + "uuid": "6e61e9629669499981780b700ec730ec", + "progress": 0, + "stage": null, + "done": true, + "errors": [ + { + "type": "BackupInvalidError", + "message": "Invalid password for backup cfddca18" + } + ], + "child_jobs": [] + } + ] + } +} diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..79a02ef --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,109 @@ +"""Test jobs supervisor client.""" + +from uuid import UUID + +from aioresponses import aioresponses +from yarl import URL + +from aiohasupervisor import SupervisorClient +from aiohasupervisor.models import JobCondition, JobsOptions + +from . import load_fixture +from .const import SUPERVISOR_URL + + +async def test_jobs_info( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test jobs info API.""" + responses.get( + f"{SUPERVISOR_URL}/jobs/info", status=200, body=load_fixture("jobs_info.json") + ) + info = await supervisor_client.jobs.info() + assert info.ignore_conditions == [JobCondition.FREE_SPACE] + + assert info.jobs[0].name == "backup_manager_partial_backup" + assert info.jobs[0].reference == "89cafa67" + assert info.jobs[0].uuid.hex == "2febe59311f94d6ba36f6f9f73357ca8" + assert info.jobs[0].progress == 0 + assert info.jobs[0].stage == "finishing_file" + assert info.jobs[0].done is True + assert info.jobs[0].errors == [] + assert info.jobs[0].child_jobs[0].name == "backup_store_folders" + assert info.jobs[0].child_jobs[0].child_jobs[0].name == "backup_folder_save" + assert info.jobs[0].child_jobs[0].child_jobs[0].reference == "ssl" + assert info.jobs[0].child_jobs[0].child_jobs[0].child_jobs == [] + + assert info.jobs[1].name == "backup_manager_partial_restore" + assert info.jobs[1].reference == "cfddca18" + assert info.jobs[1].errors[0].type == "BackupInvalidError" + assert info.jobs[1].errors[0].message == "Invalid password for backup cfddca18" + + +async def test_jobs_set_options( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test jobs set options API.""" + responses.post(f"{SUPERVISOR_URL}/jobs/options", status=200) + assert ( + await supervisor_client.jobs.set_options( + JobsOptions(ignore_conditions=[JobCondition.FREE_SPACE]) + ) + is None + ) + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/jobs/options")) + } + + +async def test_jobs_reset( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test jobs reset API.""" + responses.post(f"{SUPERVISOR_URL}/jobs/reset", status=200) + assert await supervisor_client.jobs.reset() is None + assert responses.requests.keys() == {("POST", URL(f"{SUPERVISOR_URL}/jobs/reset"))} + + +async def test_jobs_get_job( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test jobs get job API.""" + responses.get( + f"{SUPERVISOR_URL}/jobs/2febe59311f94d6ba36f6f9f73357ca8", + status=200, + body=load_fixture("jobs_get_job.json"), + ) + info = await supervisor_client.jobs.get_job( + UUID("2febe59311f94d6ba36f6f9f73357ca8") + ) + + assert info.name == "backup_manager_partial_backup" + assert info.reference == "89cafa67" + assert info.uuid.hex == "2febe59311f94d6ba36f6f9f73357ca8" + assert info.progress == 0 + assert info.stage == "finishing_file" + assert info.done is True + assert info.errors == [] + assert info.child_jobs[0].name == "backup_store_folders" + assert info.child_jobs[0].child_jobs[0].name == "backup_folder_save" + assert info.child_jobs[0].child_jobs[0].reference == "ssl" + assert info.child_jobs[0].child_jobs[0].child_jobs == [] + + +async def test_jobs_delete_job( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test jobs delete job API.""" + responses.delete( + f"{SUPERVISOR_URL}/jobs/2febe59311f94d6ba36f6f9f73357ca8", status=200 + ) + assert ( + await supervisor_client.jobs.delete_job( + UUID("2febe59311f94d6ba36f6f9f73357ca8") + ) + is None + ) + assert responses.requests.keys() == { + ("DELETE", URL(f"{SUPERVISOR_URL}/jobs/2febe59311f94d6ba36f6f9f73357ca8")) + }