Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"))
}
Loading