Skip to content

Commit 31cbaed

Browse files
authored
Add supervisor APIs to client library (#14)
* Add supervisor APIs to client library * Common ContainerStats model * Use IPv4Address for ip address * Add test for update with version
1 parent f895eba commit 31cbaed

File tree

8 files changed

+328
-0
lines changed

8 files changed

+328
-0
lines changed

aiohasupervisor/models/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@
4545
UpdateChannel,
4646
UpdateType,
4747
)
48+
from aiohasupervisor.models.supervisor import (
49+
SupervisorInfo,
50+
SupervisorOptions,
51+
SupervisorStats,
52+
SupervisorUpdateOptions,
53+
)
4854

4955
__all__ = [
5056
"HostFeature",
@@ -86,4 +92,8 @@
8692
"SuggestionType",
8793
"UnhealthyReason",
8894
"UnsupportedReason",
95+
"SupervisorInfo",
96+
"SupervisorOptions",
97+
"SupervisorStats",
98+
"SupervisorUpdateOptions",
8999
]

aiohasupervisor/models/base.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,17 @@ class Response(DataClassORJSONMixin):
8484
data: Any | None = None
8585
message: str | None = None
8686
job_id: str | None = None
87+
88+
89+
@dataclass(frozen=True, slots=True)
90+
class ContainerStats(ResponseData):
91+
"""ContainerStats model."""
92+
93+
cpu_percent: float
94+
memory_usage: int
95+
memory_limit: int
96+
memory_percent: float
97+
network_rx: int
98+
network_tx: int
99+
blk_read: int
100+
blk_write: int
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Models for supervisor component."""
2+
3+
from dataclasses import dataclass
4+
from ipaddress import IPv4Address
5+
6+
from .base import ContainerStats, Options, Request, ResponseData
7+
from .root import LogLevel, UpdateChannel
8+
9+
10+
@dataclass(frozen=True, slots=True)
11+
class SupervisorInfo(ResponseData):
12+
"""SupervisorInfo model."""
13+
14+
version: str
15+
version_latest: str
16+
update_available: bool
17+
channel: UpdateChannel
18+
arch: str
19+
supported: bool
20+
healthy: bool
21+
ip_address: IPv4Address
22+
timezone: str | None
23+
logging: LogLevel
24+
debug: bool
25+
debug_block: bool
26+
diagnostics: bool | None
27+
auto_update: bool
28+
29+
30+
@dataclass(frozen=True, slots=True)
31+
class SupervisorStats(ContainerStats):
32+
"""SupervisorStats model."""
33+
34+
35+
@dataclass(frozen=True, slots=True)
36+
class SupervisorUpdateOptions(Request):
37+
"""SupervisorUpdateOptions model."""
38+
39+
version: str
40+
41+
42+
@dataclass(frozen=True, slots=True)
43+
class SupervisorOptions(Options):
44+
"""SupervisorOptions model."""
45+
46+
channel: UpdateChannel | None = None
47+
timezone: str | None = None
48+
logging: LogLevel | None = None
49+
debug: bool | None = None
50+
debug_block: bool | None = None
51+
diagnostics: bool | None = None
52+
content_trust: bool | None = None
53+
force_security: bool | None = None
54+
auto_update: bool | None = None

aiohasupervisor/root.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .models.root import AvailableUpdate, AvailableUpdates, RootInfo
1010
from .resolution import ResolutionClient
1111
from .store import StoreClient
12+
from .supervisor import SupervisorManagementClient
1213

1314

1415
class SupervisorClient:
@@ -26,6 +27,7 @@ def __init__(
2627
self._addons = AddonsClient(self._client)
2728
self._resolution = ResolutionClient(self._client)
2829
self._store = StoreClient(self._client)
30+
self._supervisor = SupervisorManagementClient(self._client)
2931

3032
@property
3133
def addons(self) -> AddonsClient:
@@ -42,6 +44,11 @@ def store(self) -> StoreClient:
4244
"""Get store component client."""
4345
return self._store
4446

47+
@property
48+
def supervisor(self) -> SupervisorManagementClient:
49+
"""Get supervisor component client."""
50+
return self._supervisor
51+
4552
async def info(self) -> RootInfo:
4653
"""Get root info."""
4754
result = await self._client.get("info")

aiohasupervisor/supervisor.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Supervisor client for supervisor."""
2+
3+
from .client import _SupervisorComponentClient
4+
from .const import ResponseType
5+
from .models.supervisor import (
6+
SupervisorInfo,
7+
SupervisorOptions,
8+
SupervisorStats,
9+
SupervisorUpdateOptions,
10+
)
11+
12+
13+
class SupervisorManagementClient(_SupervisorComponentClient):
14+
"""Handles supervisor access in supervisor."""
15+
16+
async def ping(self) -> None:
17+
"""Check connection to supervisor."""
18+
await self._client.get("supervisor/ping", response_type=ResponseType.NONE)
19+
20+
async def info(self) -> SupervisorInfo:
21+
"""Get supervisor info."""
22+
result = await self._client.get("supervisor/info")
23+
return SupervisorInfo.from_dict(result.data)
24+
25+
async def stats(self) -> SupervisorStats:
26+
"""Get supervisor stats."""
27+
result = await self._client.get("supervisor/stats")
28+
return SupervisorStats.from_dict(result.data)
29+
30+
async def update(self, options: SupervisorUpdateOptions | None = None) -> None:
31+
"""Update supervisor.
32+
33+
Providing a target version in options only works on development systems.
34+
On non-development systems this API will always update supervisor to the
35+
latest version and ignore that field.
36+
"""
37+
await self._client.post(
38+
"supervisor/update", json=options.to_dict() if options else None
39+
)
40+
41+
async def reload(self) -> None:
42+
"""Reload supervisor (add-ons, configuration, etc)."""
43+
await self._client.post("supervisor/reload")
44+
45+
async def restart(self) -> None:
46+
"""Restart supervisor."""
47+
await self._client.post("supervisor/restart")
48+
49+
async def options(self, options: SupervisorOptions) -> None:
50+
"""Set supervisor options."""
51+
await self._client.post("supervisor/options", json=options.to_dict())
52+
53+
async def repair(self) -> None:
54+
"""Repair local supervisor and docker setup."""
55+
await self._client.post("supervisor/repair")
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"result": "ok",
3+
"data": {
4+
"version": "2024.09.1",
5+
"version_latest": "2024.09.1",
6+
"update_available": true,
7+
"channel": "stable",
8+
"arch": "aarch64",
9+
"supported": true,
10+
"healthy": true,
11+
"ip_address": "172.30.32.2",
12+
"timezone": "America/New_York",
13+
"logging": "info",
14+
"debug": true,
15+
"debug_block": false,
16+
"diagnostics": false,
17+
"auto_update": true,
18+
"wait_boot": 5,
19+
"addons": [
20+
{
21+
"name": "Terminal & SSH",
22+
"slug": "core_ssh",
23+
"version": "9.14.0",
24+
"version_latest": "9.14.0",
25+
"update_available": false,
26+
"state": "started",
27+
"repository": "core",
28+
"icon": true
29+
},
30+
{
31+
"name": "Mosquitto broker",
32+
"slug": "core_mosquitto",
33+
"version": "6.4.1",
34+
"version_latest": "6.4.1",
35+
"update_available": false,
36+
"state": "started",
37+
"repository": "core",
38+
"icon": true
39+
}
40+
],
41+
"addons_repositories": [
42+
{ "name": "Local add-ons", "slug": "local" },
43+
{ "name": "Music Assistant", "slug": "d5369777" },
44+
{ "name": "Official add-ons", "slug": "core" },
45+
{ "name": "ESPHome", "slug": "5c53de3b" },
46+
{ "name": "Home Assistant Community Add-ons", "slug": "a0d7b954" }
47+
]
48+
}
49+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"result": "ok",
3+
"data": {
4+
"cpu_percent": 0.04,
5+
"memory_usage": 243982336,
6+
"memory_limit": 3899138048,
7+
"memory_percent": 6.26,
8+
"network_rx": 176623,
9+
"network_tx": 114204,
10+
"blk_read": 0,
11+
"blk_write": 0
12+
}
13+
}

tests/test_supervisor.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Test for supervisor management client."""
2+
3+
from ipaddress import IPv4Address
4+
5+
from aioresponses import aioresponses
6+
import pytest
7+
from yarl import URL
8+
9+
from aiohasupervisor import SupervisorClient
10+
from aiohasupervisor.models import SupervisorOptions, SupervisorUpdateOptions
11+
12+
from . import load_fixture
13+
from .const import SUPERVISOR_URL
14+
15+
16+
async def test_supervisor_ping(
17+
responses: aioresponses, supervisor_client: SupervisorClient
18+
) -> None:
19+
"""Test supervisor ping API."""
20+
responses.get(f"{SUPERVISOR_URL}/supervisor/ping", status=200)
21+
assert await supervisor_client.supervisor.ping() is None
22+
assert responses.requests.keys() == {
23+
("GET", URL(f"{SUPERVISOR_URL}/supervisor/ping"))
24+
}
25+
26+
27+
async def test_supervisor_info(
28+
responses: aioresponses, supervisor_client: SupervisorClient
29+
) -> None:
30+
"""Test supervisor info API."""
31+
responses.get(
32+
f"{SUPERVISOR_URL}/supervisor/info",
33+
status=200,
34+
body=load_fixture("supervisor_info.json"),
35+
)
36+
info = await supervisor_client.supervisor.info()
37+
38+
assert info.version == "2024.09.1"
39+
assert info.channel == "stable"
40+
assert info.arch == "aarch64"
41+
assert info.supported is True
42+
assert info.healthy is True
43+
assert info.logging == "info"
44+
assert info.ip_address == IPv4Address("172.30.32.2")
45+
46+
47+
async def test_supervisor_stats(
48+
responses: aioresponses, supervisor_client: SupervisorClient
49+
) -> None:
50+
"""Test supervisor stats API."""
51+
responses.get(
52+
f"{SUPERVISOR_URL}/supervisor/stats",
53+
status=200,
54+
body=load_fixture("supervisor_stats.json"),
55+
)
56+
stats = await supervisor_client.supervisor.stats()
57+
58+
assert stats.cpu_percent == 0.04
59+
assert stats.memory_usage == 243982336
60+
assert stats.memory_limit == 3899138048
61+
assert stats.memory_percent == 6.26
62+
63+
64+
@pytest.mark.parametrize(
65+
"options", [None, SupervisorUpdateOptions(version="2024.01.0")]
66+
)
67+
async def test_supervisor_update(
68+
responses: aioresponses,
69+
supervisor_client: SupervisorClient,
70+
options: SupervisorUpdateOptions | None,
71+
) -> None:
72+
"""Test supervisor update API."""
73+
responses.post(f"{SUPERVISOR_URL}/supervisor/update", status=200)
74+
assert await supervisor_client.supervisor.update(options) is None
75+
assert responses.requests.keys() == {
76+
("POST", URL(f"{SUPERVISOR_URL}/supervisor/update"))
77+
}
78+
79+
80+
async def test_supervisor_reload(
81+
responses: aioresponses, supervisor_client: SupervisorClient
82+
) -> None:
83+
"""Test supervisor reload API."""
84+
responses.post(f"{SUPERVISOR_URL}/supervisor/reload", status=200)
85+
assert await supervisor_client.supervisor.reload() is None
86+
assert responses.requests.keys() == {
87+
("POST", URL(f"{SUPERVISOR_URL}/supervisor/reload"))
88+
}
89+
90+
91+
async def test_supervisor_restart(
92+
responses: aioresponses, supervisor_client: SupervisorClient
93+
) -> None:
94+
"""Test supervisor restart API."""
95+
responses.post(f"{SUPERVISOR_URL}/supervisor/restart", status=200)
96+
assert await supervisor_client.supervisor.restart() is None
97+
assert responses.requests.keys() == {
98+
("POST", URL(f"{SUPERVISOR_URL}/supervisor/restart"))
99+
}
100+
101+
102+
async def test_supervisor_options(
103+
responses: aioresponses, supervisor_client: SupervisorClient
104+
) -> None:
105+
"""Test supervisor options API."""
106+
responses.post(f"{SUPERVISOR_URL}/supervisor/options", status=200)
107+
assert (
108+
await supervisor_client.supervisor.options(
109+
SupervisorOptions(debug=True, debug_block=True)
110+
)
111+
is None
112+
)
113+
assert responses.requests.keys() == {
114+
("POST", URL(f"{SUPERVISOR_URL}/supervisor/options"))
115+
}
116+
117+
118+
async def test_supervisor_repair(
119+
responses: aioresponses, supervisor_client: SupervisorClient
120+
) -> None:
121+
"""Test supervisor repair API."""
122+
responses.post(f"{SUPERVISOR_URL}/supervisor/repair", status=200)
123+
assert await supervisor_client.supervisor.repair() is None
124+
assert responses.requests.keys() == {
125+
("POST", URL(f"{SUPERVISOR_URL}/supervisor/repair"))
126+
}

0 commit comments

Comments
 (0)