Skip to content

Commit f7d6204

Browse files
committed
Add Host APIs from supervisor
1 parent f895eba commit f7d6204

File tree

7 files changed

+342
-0
lines changed

7 files changed

+342
-0
lines changed

aiohasupervisor/host.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Host client for supervisor."""
2+
3+
from .client import _SupervisorComponentClient
4+
from .models.host import HostInfo, HostOptions, Service, ServiceList, ShutdownOptions
5+
6+
7+
class HostClient(_SupervisorComponentClient):
8+
"""Handles host access in supervisor."""
9+
10+
async def info(self) -> HostInfo:
11+
"""Get host info."""
12+
result = await self._client.get("host/info")
13+
return HostInfo.from_dict(result.data)
14+
15+
async def reboot(self, options: ShutdownOptions | None = None) -> None:
16+
"""Reboot host."""
17+
await self._client.post(
18+
"host/reboot", json=options.to_dict() if options else None
19+
)
20+
21+
async def shutdown(self, options: ShutdownOptions | None = None) -> None:
22+
"""Shutdown host."""
23+
await self._client.post(
24+
"host/shutdown", json=options.to_dict() if options else None
25+
)
26+
27+
async def reload(self) -> None:
28+
"""Reload host info cache."""
29+
await self._client.post("host/reload")
30+
31+
async def options(self, options: HostOptions) -> None:
32+
"""Set host options."""
33+
await self._client.post("host/options", json=options.to_dict())
34+
35+
async def services(self) -> list[Service]:
36+
"""Get list of available services on host."""
37+
result = await self._client.get("host/services")
38+
return ServiceList.from_dict(result.data).services
39+
40+
# Omitted for now - Log endpoints

aiohasupervisor/models/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
StoreInfo,
2424
SupervisorRole,
2525
)
26+
from aiohasupervisor.models.host import (
27+
HostInfo,
28+
HostOptions,
29+
Service,
30+
ServiceState,
31+
ShutdownOptions,
32+
)
2633
from aiohasupervisor.models.resolution import (
2734
Check,
2835
CheckOptions,
@@ -86,4 +93,9 @@
8693
"SuggestionType",
8794
"UnhealthyReason",
8895
"UnsupportedReason",
96+
"HostInfo",
97+
"HostOptions",
98+
"Service",
99+
"ServiceState",
100+
"ShutdownOptions",
89101
]

aiohasupervisor/models/host.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Models for host APIs."""
2+
3+
from dataclasses import dataclass
4+
from datetime import datetime
5+
from enum import StrEnum
6+
7+
from .base import Request, ResponseData
8+
from .root import HostFeature
9+
10+
# --- ENUMS ----
11+
12+
13+
class ServiceState(StrEnum):
14+
"""ServiceState type.
15+
16+
The service state is determined by systemd, not supervisor. The list below
17+
is pulled from `systemctl --state=help`. It may be incomplete and it may
18+
change based on the host. Therefore within a list of services there may be
19+
some with a state not in this list parsed as string. If you find this
20+
please create an issue or pr to get the state added.
21+
"""
22+
23+
ACTIVE = "active"
24+
RELOADING = "reloading"
25+
INACTIVE = "inactive"
26+
FAILED = "failed"
27+
ACTIVATING = "activating"
28+
DEACTIVATING = "deactivating"
29+
MAINTENANCE = "maintenance"
30+
31+
32+
# --- OBJECTS ----
33+
34+
35+
@dataclass(frozen=True, slots=True)
36+
class HostInfo(ResponseData):
37+
"""HostInfo model."""
38+
39+
agent_version: str | None
40+
apparmor_version: str | None
41+
chassis: str | None
42+
virtualization: str | None
43+
cpe: str | None
44+
deployment: str | None
45+
disk_free: float
46+
disk_total: float
47+
disk_used: float
48+
disk_life_time: float
49+
features: list[HostFeature]
50+
hostname: str | None
51+
llmnr_hostname: str | None
52+
kernel: str | None
53+
operating_system: str | None
54+
timezone: str | None
55+
dt_utc: datetime | None
56+
dt_synchronized: bool | None
57+
use_ntp: bool | None
58+
startup_time: float | None
59+
boot_timestamp: int | None
60+
broadcast_llmnr: bool | None
61+
broadcast_mdns: bool | None
62+
63+
64+
@dataclass(frozen=True, slots=True)
65+
class ShutdownOptions(Request):
66+
"""ShutdownOptions model."""
67+
68+
force: bool
69+
70+
71+
@dataclass(frozen=True, slots=True)
72+
class HostOptions(Request):
73+
"""HostOptions model."""
74+
75+
hostname: str
76+
77+
78+
@dataclass(frozen=True, slots=True)
79+
class Service(ResponseData):
80+
"""Service model."""
81+
82+
name: str
83+
description: str
84+
state: ServiceState | str
85+
86+
87+
@dataclass(frozen=True, slots=True)
88+
class ServiceList(ResponseData):
89+
"""ServiceList model."""
90+
91+
services: list[Service]

aiohasupervisor/root.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from .addons import AddonsClient
88
from .client import _SupervisorClient
9+
from .host import HostClient
910
from .models.root import AvailableUpdate, AvailableUpdates, RootInfo
1011
from .resolution import ResolutionClient
1112
from .store import StoreClient
@@ -24,6 +25,7 @@ def __init__(
2425
"""Initialize client."""
2526
self._client = _SupervisorClient(api_host, token, request_timeout, session)
2627
self._addons = AddonsClient(self._client)
28+
self._host = HostClient(self._client)
2729
self._resolution = ResolutionClient(self._client)
2830
self._store = StoreClient(self._client)
2931

@@ -32,6 +34,11 @@ def addons(self) -> AddonsClient:
3234
"""Get addons component client."""
3335
return self._addons
3436

37+
@property
38+
def host(self) -> HostClient:
39+
"""Get host component client."""
40+
return self._host
41+
3542
@property
3643
def resolution(self) -> ResolutionClient:
3744
"""Get resolution center component client."""

tests/fixtures/host_info.json

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"result": "ok",
3+
"data": {
4+
"agent_version": "1.6.0",
5+
"apparmor_version": "3.1.2",
6+
"chassis": "embedded",
7+
"virtualization": "",
8+
"cpe": "cpe:2.3:o:home-assistant:haos:12.4.dev20240527:*:development:*:*:*:odroid-n2:*",
9+
"deployment": "development",
10+
"disk_free": 20.1,
11+
"disk_total": 27.9,
12+
"disk_used": 6.7,
13+
"disk_life_time": 10.0,
14+
"features": [
15+
"reboot",
16+
"shutdown",
17+
"services",
18+
"network",
19+
"hostname",
20+
"timedate",
21+
"os_agent",
22+
"haos",
23+
"resolved",
24+
"journal",
25+
"disk",
26+
"mount"
27+
],
28+
"hostname": "homeassistant",
29+
"llmnr_hostname": "homeassistant3",
30+
"kernel": "6.6.32-haos",
31+
"operating_system": "Home Assistant OS 12.4.dev20240527",
32+
"timezone": "Etc/UTC",
33+
"dt_utc": "2024-10-03T00:00:00.000000+00:00",
34+
"dt_synchronized": true,
35+
"use_ntp": true,
36+
"startup_time": 1.966311,
37+
"boot_timestamp": 1716927644219811,
38+
"broadcast_llmnr": true,
39+
"broadcast_mdns": true
40+
}
41+
}

tests/fixtures/host_services.json

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"result": "ok",
3+
"data": {
4+
"services": [
5+
{
6+
"name": "emergency.service",
7+
"description": "Emergency Shell",
8+
"state": "inactive"
9+
},
10+
{
11+
"name": "bluetooth.service",
12+
"description": "Bluetooth service",
13+
"state": "inactive"
14+
},
15+
{
16+
"name": "haos-swapfile.service",
17+
"description": "HAOS swap",
18+
"state": "inactive"
19+
},
20+
{
21+
"name": "hassos-config.service",
22+
"description": "HassOS Configuration Manager",
23+
"state": "inactive"
24+
},
25+
{
26+
"name": "dropbear.service",
27+
"description": "Dropbear SSH daemon",
28+
"state": "active"
29+
},
30+
{
31+
"name": "systemd-time-wait-sync.service",
32+
"description": "Wait Until Kernel Time Synchronized",
33+
"state": "active"
34+
},
35+
{
36+
"name": "systemd-journald.service",
37+
"description": "Journal Service",
38+
"state": "active"
39+
},
40+
{
41+
"name": "systemd-resolved.service",
42+
"description": "Network Name Resolution",
43+
"state": "active"
44+
}
45+
]
46+
}
47+
}

tests/test_host.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Test host supervisor client."""
2+
3+
from datetime import UTC, datetime
4+
5+
from aioresponses import aioresponses
6+
from yarl import URL
7+
8+
from aiohasupervisor import SupervisorClient
9+
from aiohasupervisor.models import HostOptions
10+
11+
from . import load_fixture
12+
from .const import SUPERVISOR_URL
13+
14+
15+
async def test_host_info(
16+
responses: aioresponses, supervisor_client: SupervisorClient
17+
) -> None:
18+
"""Test host info API."""
19+
responses.get(
20+
f"{SUPERVISOR_URL}/host/info", status=200, body=load_fixture("host_info.json")
21+
)
22+
result = await supervisor_client.host.info()
23+
assert result.agent_version == "1.6.0"
24+
assert result.chassis == "embedded"
25+
assert result.virtualization == ""
26+
assert result.disk_total == 27.9
27+
assert result.disk_life_time == 10
28+
assert result.features == [
29+
"reboot",
30+
"shutdown",
31+
"services",
32+
"network",
33+
"hostname",
34+
"timedate",
35+
"os_agent",
36+
"haos",
37+
"resolved",
38+
"journal",
39+
"disk",
40+
"mount",
41+
]
42+
assert result.hostname == "homeassistant"
43+
assert result.llmnr_hostname == "homeassistant3"
44+
assert result.dt_utc == datetime(2024, 10, 3, 0, 0, 0, 0, UTC)
45+
assert result.dt_synchronized is True
46+
assert result.startup_time == 1.966311
47+
48+
49+
async def test_host_reboot(
50+
responses: aioresponses, supervisor_client: SupervisorClient
51+
) -> None:
52+
"""Test host reboot API."""
53+
responses.post(f"{SUPERVISOR_URL}/host/reboot", status=200)
54+
assert await supervisor_client.host.reboot() is None
55+
assert responses.requests.keys() == {("POST", URL(f"{SUPERVISOR_URL}/host/reboot"))}
56+
57+
58+
async def test_host_shutdown(
59+
responses: aioresponses, supervisor_client: SupervisorClient
60+
) -> None:
61+
"""Test host shutdown API."""
62+
responses.post(f"{SUPERVISOR_URL}/host/shutdown", status=200)
63+
assert await supervisor_client.host.shutdown() is None
64+
assert responses.requests.keys() == {
65+
("POST", URL(f"{SUPERVISOR_URL}/host/shutdown"))
66+
}
67+
68+
69+
async def test_host_reload(
70+
responses: aioresponses, supervisor_client: SupervisorClient
71+
) -> None:
72+
"""Test host reload API."""
73+
responses.post(f"{SUPERVISOR_URL}/host/reload", status=200)
74+
assert await supervisor_client.host.reload() is None
75+
assert responses.requests.keys() == {("POST", URL(f"{SUPERVISOR_URL}/host/reload"))}
76+
77+
78+
async def test_host_options(
79+
responses: aioresponses, supervisor_client: SupervisorClient
80+
) -> None:
81+
"""Test host options API."""
82+
responses.post(f"{SUPERVISOR_URL}/host/options", status=200)
83+
assert await supervisor_client.host.options(HostOptions(hostname="test")) is None
84+
assert responses.requests.keys() == {
85+
("POST", URL(f"{SUPERVISOR_URL}/host/options"))
86+
}
87+
88+
89+
async def test_host_services(
90+
responses: aioresponses, supervisor_client: SupervisorClient
91+
) -> None:
92+
"""Test host services API."""
93+
responses.get(
94+
f"{SUPERVISOR_URL}/host/services",
95+
status=200,
96+
body=load_fixture("host_services.json"),
97+
)
98+
result = await supervisor_client.host.services()
99+
assert result[0].name == "emergency.service"
100+
assert result[0].description == "Emergency Shell"
101+
assert result[0].state == "inactive"
102+
assert result[-1].name == "systemd-resolved.service"
103+
assert result[-1].description == "Network Name Resolution"
104+
assert result[-1].state == "active"

0 commit comments

Comments
 (0)