Skip to content

Commit

Permalink
Add supervisor APIs to client library (#14)
Browse files Browse the repository at this point in the history
* Add supervisor APIs to client library

* Common ContainerStats model

* Use IPv4Address for ip address

* Add test for update with version
  • Loading branch information
mdegat01 authored Oct 8, 2024
1 parent f895eba commit 31cbaed
Show file tree
Hide file tree
Showing 8 changed files with 328 additions and 0 deletions.
10 changes: 10 additions & 0 deletions aiohasupervisor/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
UpdateChannel,
UpdateType,
)
from aiohasupervisor.models.supervisor import (
SupervisorInfo,
SupervisorOptions,
SupervisorStats,
SupervisorUpdateOptions,
)

__all__ = [
"HostFeature",
Expand Down Expand Up @@ -86,4 +92,8 @@
"SuggestionType",
"UnhealthyReason",
"UnsupportedReason",
"SupervisorInfo",
"SupervisorOptions",
"SupervisorStats",
"SupervisorUpdateOptions",
]
14 changes: 14 additions & 0 deletions aiohasupervisor/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,17 @@ class Response(DataClassORJSONMixin):
data: Any | None = None
message: str | None = None
job_id: str | None = None


@dataclass(frozen=True, slots=True)
class ContainerStats(ResponseData):
"""ContainerStats model."""

cpu_percent: float
memory_usage: int
memory_limit: int
memory_percent: float
network_rx: int
network_tx: int
blk_read: int
blk_write: int
54 changes: 54 additions & 0 deletions aiohasupervisor/models/supervisor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Models for supervisor component."""

from dataclasses import dataclass
from ipaddress import IPv4Address

from .base import ContainerStats, Options, Request, ResponseData
from .root import LogLevel, UpdateChannel


@dataclass(frozen=True, slots=True)
class SupervisorInfo(ResponseData):
"""SupervisorInfo model."""

version: str
version_latest: str
update_available: bool
channel: UpdateChannel
arch: str
supported: bool
healthy: bool
ip_address: IPv4Address
timezone: str | None
logging: LogLevel
debug: bool
debug_block: bool
diagnostics: bool | None
auto_update: bool


@dataclass(frozen=True, slots=True)
class SupervisorStats(ContainerStats):
"""SupervisorStats model."""


@dataclass(frozen=True, slots=True)
class SupervisorUpdateOptions(Request):
"""SupervisorUpdateOptions model."""

version: str


@dataclass(frozen=True, slots=True)
class SupervisorOptions(Options):
"""SupervisorOptions model."""

channel: UpdateChannel | None = None
timezone: str | None = None
logging: LogLevel | None = None
debug: bool | None = None
debug_block: bool | None = None
diagnostics: bool | None = None
content_trust: bool | None = None
force_security: bool | None = None
auto_update: bool | None = None
7 changes: 7 additions & 0 deletions aiohasupervisor/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .models.root import AvailableUpdate, AvailableUpdates, RootInfo
from .resolution import ResolutionClient
from .store import StoreClient
from .supervisor import SupervisorManagementClient


class SupervisorClient:
Expand All @@ -26,6 +27,7 @@ def __init__(
self._addons = AddonsClient(self._client)
self._resolution = ResolutionClient(self._client)
self._store = StoreClient(self._client)
self._supervisor = SupervisorManagementClient(self._client)

@property
def addons(self) -> AddonsClient:
Expand All @@ -42,6 +44,11 @@ def store(self) -> StoreClient:
"""Get store component client."""
return self._store

@property
def supervisor(self) -> SupervisorManagementClient:
"""Get supervisor component client."""
return self._supervisor

async def info(self) -> RootInfo:
"""Get root info."""
result = await self._client.get("info")
Expand Down
55 changes: 55 additions & 0 deletions aiohasupervisor/supervisor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Supervisor client for supervisor."""

from .client import _SupervisorComponentClient
from .const import ResponseType
from .models.supervisor import (
SupervisorInfo,
SupervisorOptions,
SupervisorStats,
SupervisorUpdateOptions,
)


class SupervisorManagementClient(_SupervisorComponentClient):
"""Handles supervisor access in supervisor."""

async def ping(self) -> None:
"""Check connection to supervisor."""
await self._client.get("supervisor/ping", response_type=ResponseType.NONE)

async def info(self) -> SupervisorInfo:
"""Get supervisor info."""
result = await self._client.get("supervisor/info")
return SupervisorInfo.from_dict(result.data)

async def stats(self) -> SupervisorStats:
"""Get supervisor stats."""
result = await self._client.get("supervisor/stats")
return SupervisorStats.from_dict(result.data)

async def update(self, options: SupervisorUpdateOptions | None = None) -> None:
"""Update supervisor.
Providing a target version in options only works on development systems.
On non-development systems this API will always update supervisor to the
latest version and ignore that field.
"""
await self._client.post(
"supervisor/update", json=options.to_dict() if options else None
)

async def reload(self) -> None:
"""Reload supervisor (add-ons, configuration, etc)."""
await self._client.post("supervisor/reload")

async def restart(self) -> None:
"""Restart supervisor."""
await self._client.post("supervisor/restart")

async def options(self, options: SupervisorOptions) -> None:
"""Set supervisor options."""
await self._client.post("supervisor/options", json=options.to_dict())

async def repair(self) -> None:
"""Repair local supervisor and docker setup."""
await self._client.post("supervisor/repair")
49 changes: 49 additions & 0 deletions tests/fixtures/supervisor_info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"result": "ok",
"data": {
"version": "2024.09.1",
"version_latest": "2024.09.1",
"update_available": true,
"channel": "stable",
"arch": "aarch64",
"supported": true,
"healthy": true,
"ip_address": "172.30.32.2",
"timezone": "America/New_York",
"logging": "info",
"debug": true,
"debug_block": false,
"diagnostics": false,
"auto_update": true,
"wait_boot": 5,
"addons": [
{
"name": "Terminal & SSH",
"slug": "core_ssh",
"version": "9.14.0",
"version_latest": "9.14.0",
"update_available": false,
"state": "started",
"repository": "core",
"icon": true
},
{
"name": "Mosquitto broker",
"slug": "core_mosquitto",
"version": "6.4.1",
"version_latest": "6.4.1",
"update_available": false,
"state": "started",
"repository": "core",
"icon": true
}
],
"addons_repositories": [
{ "name": "Local add-ons", "slug": "local" },
{ "name": "Music Assistant", "slug": "d5369777" },
{ "name": "Official add-ons", "slug": "core" },
{ "name": "ESPHome", "slug": "5c53de3b" },
{ "name": "Home Assistant Community Add-ons", "slug": "a0d7b954" }
]
}
}
13 changes: 13 additions & 0 deletions tests/fixtures/supervisor_stats.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"result": "ok",
"data": {
"cpu_percent": 0.04,
"memory_usage": 243982336,
"memory_limit": 3899138048,
"memory_percent": 6.26,
"network_rx": 176623,
"network_tx": 114204,
"blk_read": 0,
"blk_write": 0
}
}
126 changes: 126 additions & 0 deletions tests/test_supervisor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Test for supervisor management client."""

from ipaddress import IPv4Address

from aioresponses import aioresponses
import pytest
from yarl import URL

from aiohasupervisor import SupervisorClient
from aiohasupervisor.models import SupervisorOptions, SupervisorUpdateOptions

from . import load_fixture
from .const import SUPERVISOR_URL


async def test_supervisor_ping(
responses: aioresponses, supervisor_client: SupervisorClient
) -> None:
"""Test supervisor ping API."""
responses.get(f"{SUPERVISOR_URL}/supervisor/ping", status=200)
assert await supervisor_client.supervisor.ping() is None
assert responses.requests.keys() == {
("GET", URL(f"{SUPERVISOR_URL}/supervisor/ping"))
}


async def test_supervisor_info(
responses: aioresponses, supervisor_client: SupervisorClient
) -> None:
"""Test supervisor info API."""
responses.get(
f"{SUPERVISOR_URL}/supervisor/info",
status=200,
body=load_fixture("supervisor_info.json"),
)
info = await supervisor_client.supervisor.info()

assert info.version == "2024.09.1"
assert info.channel == "stable"
assert info.arch == "aarch64"
assert info.supported is True
assert info.healthy is True
assert info.logging == "info"
assert info.ip_address == IPv4Address("172.30.32.2")


async def test_supervisor_stats(
responses: aioresponses, supervisor_client: SupervisorClient
) -> None:
"""Test supervisor stats API."""
responses.get(
f"{SUPERVISOR_URL}/supervisor/stats",
status=200,
body=load_fixture("supervisor_stats.json"),
)
stats = await supervisor_client.supervisor.stats()

assert stats.cpu_percent == 0.04
assert stats.memory_usage == 243982336
assert stats.memory_limit == 3899138048
assert stats.memory_percent == 6.26


@pytest.mark.parametrize(
"options", [None, SupervisorUpdateOptions(version="2024.01.0")]
)
async def test_supervisor_update(
responses: aioresponses,
supervisor_client: SupervisorClient,
options: SupervisorUpdateOptions | None,
) -> None:
"""Test supervisor update API."""
responses.post(f"{SUPERVISOR_URL}/supervisor/update", status=200)
assert await supervisor_client.supervisor.update(options) is None
assert responses.requests.keys() == {
("POST", URL(f"{SUPERVISOR_URL}/supervisor/update"))
}


async def test_supervisor_reload(
responses: aioresponses, supervisor_client: SupervisorClient
) -> None:
"""Test supervisor reload API."""
responses.post(f"{SUPERVISOR_URL}/supervisor/reload", status=200)
assert await supervisor_client.supervisor.reload() is None
assert responses.requests.keys() == {
("POST", URL(f"{SUPERVISOR_URL}/supervisor/reload"))
}


async def test_supervisor_restart(
responses: aioresponses, supervisor_client: SupervisorClient
) -> None:
"""Test supervisor restart API."""
responses.post(f"{SUPERVISOR_URL}/supervisor/restart", status=200)
assert await supervisor_client.supervisor.restart() is None
assert responses.requests.keys() == {
("POST", URL(f"{SUPERVISOR_URL}/supervisor/restart"))
}


async def test_supervisor_options(
responses: aioresponses, supervisor_client: SupervisorClient
) -> None:
"""Test supervisor options API."""
responses.post(f"{SUPERVISOR_URL}/supervisor/options", status=200)
assert (
await supervisor_client.supervisor.options(
SupervisorOptions(debug=True, debug_block=True)
)
is None
)
assert responses.requests.keys() == {
("POST", URL(f"{SUPERVISOR_URL}/supervisor/options"))
}


async def test_supervisor_repair(
responses: aioresponses, supervisor_client: SupervisorClient
) -> None:
"""Test supervisor repair API."""
responses.post(f"{SUPERVISOR_URL}/supervisor/repair", status=200)
assert await supervisor_client.supervisor.repair() is None
assert responses.requests.keys() == {
("POST", URL(f"{SUPERVISOR_URL}/supervisor/repair"))
}

0 comments on commit 31cbaed

Please sign in to comment.