diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py
index 8fbf57d56..fd5d7f29c 100644
--- a/supervisor/api/__init__.py
+++ b/supervisor/api/__init__.py
@@ -8,6 +8,7 @@ from aiohttp import web
from ..const import AddonState
from ..coresys import CoreSys, CoreSysAttributes
+from ..dbus.agent.boards.const import BOARD_NAME_SUPERVISED, BOARD_NAME_YELLOW
from ..exceptions import APIAddonNotInstalled
from .addons import APIAddons
from .audio import APIAudio
@@ -179,6 +180,34 @@ class RestAPI(CoreSysAttributes):
]
)
+ # Boards endpoints
+ def get_board_routes(
+ board: str,
+ info_handler,
+ options_handler=None,
+ ) -> list[web.RouteDef]:
+ routes = [
+ web.get(f"/os/boards/{board}", info_handler),
+ web.get(f"/os/boards/{board.lower()}", info_handler),
+ ]
+ if options_handler:
+ routes.insert(1, web.post(f"/os/boards/{board}", options_handler))
+ routes.append(web.post(f"/os/boards/{board.lower()}", options_handler))
+
+ return routes
+
+ self.webapp.add_routes(
+ [
+ *get_board_routes(
+ BOARD_NAME_YELLOW,
+ api_os.boards_yellow_info,
+ api_os.boards_yellow_options,
+ ),
+ *get_board_routes(BOARD_NAME_SUPERVISED, api_os.boards_supervised_info),
+ web.get("/os/boards/{board}", api_os.boards_other_info),
+ ]
+ )
+
def _register_security(self) -> None:
"""Register Security functions."""
api_security = APISecurity()
diff --git a/supervisor/api/const.py b/supervisor/api/const.py
index 01cee8f85..c2efcfd26 100644
--- a/supervisor/api/const.py
+++ b/supervisor/api/const.py
@@ -21,14 +21,17 @@ ATTR_BROADCAST_LLMNR = "broadcast_llmnr"
ATTR_BROADCAST_MDNS = "broadcast_mdns"
ATTR_DATA_DISK = "data_disk"
ATTR_DEVICE = "device"
+ATTR_DISK_LED = "disk_led"
ATTR_DT_SYNCHRONIZED = "dt_synchronized"
ATTR_DT_UTC = "dt_utc"
ATTR_FALLBACK = "fallback"
+ATTR_HEARTBEAT_LED = "heartbeat_led"
ATTR_IDENTIFIERS = "identifiers"
ATTR_LLMNR = "llmnr"
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
ATTR_MDNS = "mdns"
ATTR_PANEL_PATH = "panel_path"
+ATTR_POWER_LED = "power_led"
ATTR_SIGNED = "signed"
ATTR_STARTUP_TIME = "startup_time"
ATTR_UPDATE_TYPE = "update_type"
diff --git a/supervisor/api/os.py b/supervisor/api/os.py
index e5fdf8788..3ec5809db 100644
--- a/supervisor/api/os.py
+++ b/supervisor/api/os.py
@@ -10,14 +10,23 @@ import voluptuous as vol
from ..const import (
ATTR_BOARD,
ATTR_BOOT,
+ ATTR_CPE_BOARD,
ATTR_DEVICES,
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
)
from ..coresys import CoreSysAttributes
+from ..exceptions import BoardInvalidError
+from ..resolution.const import ContextType, IssueType, SuggestionType
from ..validate import version_tag
-from .const import ATTR_DATA_DISK, ATTR_DEVICE
+from .const import (
+ ATTR_DATA_DISK,
+ ATTR_DEVICE,
+ ATTR_DISK_LED,
+ ATTR_HEARTBEAT_LED,
+ ATTR_POWER_LED,
+)
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -25,6 +34,15 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): vol.All(str, vol.Coerce(Path))})
+# pylint: disable=no-value-for-parameter
+SCHEMA_YELLOW_OPTIONS = vol.Schema(
+ {
+ vol.Optional(ATTR_DISK_LED): vol.Boolean(),
+ vol.Optional(ATTR_HEARTBEAT_LED): vol.Boolean(),
+ vol.Optional(ATTR_POWER_LED): vol.Boolean(),
+ }
+)
+
class APIOS(CoreSysAttributes):
"""Handle RESTful API for OS functions."""
@@ -36,9 +54,10 @@ class APIOS(CoreSysAttributes):
ATTR_VERSION: self.sys_os.version,
ATTR_VERSION_LATEST: self.sys_os.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_os.need_update,
- ATTR_BOARD: self.sys_os.board,
+ ATTR_BOARD: self.sys_dbus.agent.board.board,
ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
ATTR_DATA_DISK: self.sys_os.datadisk.disk_used,
+ ATTR_CPE_BOARD: self.sys_os.board,
}
@api_process
@@ -67,3 +86,49 @@ class APIOS(CoreSysAttributes):
return {
ATTR_DEVICES: self.sys_os.datadisk.available_disks,
}
+
+ @api_process
+ async def boards_yellow_info(self, request: web.Request) -> dict[str, Any]:
+ """Get yellow board settings."""
+ return {
+ ATTR_DISK_LED: self.sys_dbus.agent.board.yellow.disk_led,
+ ATTR_HEARTBEAT_LED: self.sys_dbus.agent.board.yellow.heartbeat_led,
+ ATTR_POWER_LED: self.sys_dbus.agent.board.yellow.power_led,
+ }
+
+ @api_process
+ async def boards_yellow_options(self, request: web.Request) -> None:
+ """Update yellow board settings."""
+ body = await api_validate(SCHEMA_YELLOW_OPTIONS, request)
+
+ if ATTR_DISK_LED in body:
+ self.sys_dbus.agent.board.yellow.disk_led = body[ATTR_DISK_LED]
+
+ if ATTR_HEARTBEAT_LED in body:
+ self.sys_dbus.agent.board.yellow.heartbeat_led = body[ATTR_HEARTBEAT_LED]
+
+ if ATTR_POWER_LED in body:
+ self.sys_dbus.agent.board.yellow.power_led = body[ATTR_POWER_LED]
+
+ self.sys_resolution.create_issue(
+ IssueType.REBOOT_REQUIRED,
+ ContextType.SYSTEM,
+ suggestions=[SuggestionType.EXECUTE_REBOOT],
+ )
+
+ @api_process
+ async def boards_supervised_info(self, request: web.Request) -> dict[str, Any]:
+ """Get supervised board settings."""
+ # There are none currently, this rasises an error if a different board is in use
+ if self.sys_dbus.agent.board.supervised:
+ return {}
+
+ @api_process
+ async def boards_other_info(self, request: web.Request) -> dict[str, Any]:
+ """Empty success return if board is in use, error otherwise."""
+ if request.match_info["board"] != self.sys_dbus.agent.board.board:
+ raise BoardInvalidError(
+ f"{request.match_info['board']} board is not in use", _LOGGER.error
+ )
+
+ return {}
diff --git a/supervisor/const.py b/supervisor/const.py
index a515f8495..162aba177 100644
--- a/supervisor/const.py
+++ b/supervisor/const.py
@@ -128,6 +128,7 @@ ATTR_CONTAINERS = "containers"
ATTR_CONTENT = "content"
ATTR_CONTENT_TRUST = "content_trust"
ATTR_CPE = "cpe"
+ATTR_CPE_BOARD = "cpe_board"
ATTR_CPU_PERCENT = "cpu_percent"
ATTR_CRYPTO = "crypto"
ATTR_DATA = "data"
diff --git a/supervisor/dbus/agent/__init__.py b/supervisor/dbus/agent/__init__.py
index 0b326d863..2fd4dbd85 100644
--- a/supervisor/dbus/agent/__init__.py
+++ b/supervisor/dbus/agent/__init__.py
@@ -14,9 +14,10 @@ from ..const import (
DBUS_NAME_HAOS,
DBUS_OBJECT_HAOS,
)
-from ..interface import DBusInterfaceProxy, dbus_property
+from ..interface import DBusInterface, DBusInterfaceProxy, dbus_property
from ..utils import dbus_connected
from .apparmor import AppArmor
+from .boards import BoardManager
from .cgroup import CGroup
from .datadisk import DataDisk
from .system import System
@@ -36,10 +37,11 @@ class OSAgent(DBusInterfaceProxy):
"""Initialize Properties."""
self.properties: dict[str, Any] = {}
- self._cgroup: CGroup = CGroup()
self._apparmor: AppArmor = AppArmor()
- self._system: System = System()
+ self._board: BoardManager = BoardManager()
+ self._cgroup: CGroup = CGroup()
self._datadisk: DataDisk = DataDisk()
+ self._system: System = System()
@property
def cgroup(self) -> CGroup:
@@ -61,6 +63,11 @@ class OSAgent(DBusInterfaceProxy):
"""Return DataDisk DBUS object."""
return self._datadisk
+ @property
+ def board(self) -> BoardManager:
+ """Return board manager."""
+ return self._board
+
@property
@dbus_property
def version(self) -> AwesomeVersion:
@@ -79,15 +86,17 @@ class OSAgent(DBusInterfaceProxy):
"""Enable or disable OS-Agent diagnostics."""
asyncio.create_task(self.dbus.set_diagnostics(value))
+ @property
+ def all(self) -> list[DBusInterface]:
+ """Return all managed dbus interfaces."""
+ return [self.apparmor, self.board, self.cgroup, self.datadisk, self.system]
+
async def connect(self, bus: MessageBus) -> None:
"""Connect to system's D-Bus."""
_LOGGER.info("Load dbus interface %s", self.name)
try:
await super().connect(bus)
- await self.cgroup.connect(bus)
- await self.apparmor.connect(bus)
- await self.system.connect(bus)
- await self.datadisk.connect(bus)
+ await asyncio.gather(*[dbus.connect(bus) for dbus in self.all])
except DBusError:
_LOGGER.warning("Can't connect to OS-Agent")
except DBusInterfaceError:
@@ -100,19 +109,21 @@ class OSAgent(DBusInterfaceProxy):
"""Update Properties."""
await super().update(changed)
- if not changed and self.apparmor.is_connected:
- await self.apparmor.update()
-
- if not changed and self.datadisk.is_connected:
- await self.datadisk.update()
+ if not changed:
+ await asyncio.gather(
+ *[
+ dbus.update()
+ for dbus in [self.apparmor, self.board, self.datadisk]
+ if dbus.is_connected
+ ]
+ )
def shutdown(self) -> None:
"""Shutdown the object and disconnect from D-Bus.
This method is irreversible.
"""
- self.cgroup.shutdown()
- self.apparmor.shutdown()
- self.system.shutdown()
- self.datadisk.shutdown()
+ for dbus in self.all:
+ dbus.shutdown()
+
super().shutdown()
diff --git a/supervisor/dbus/agent/boards/__init__.py b/supervisor/dbus/agent/boards/__init__.py
new file mode 100644
index 000000000..5b6a6cb77
--- /dev/null
+++ b/supervisor/dbus/agent/boards/__init__.py
@@ -0,0 +1,68 @@
+"""Board management for OS Agent."""
+import logging
+from typing import Any
+
+from dbus_fast.aio.message_bus import MessageBus
+
+from ....exceptions import BoardInvalidError
+from ...const import (
+ DBUS_ATTR_BOARD,
+ DBUS_IFACE_HAOS_BOARDS,
+ DBUS_NAME_HAOS,
+ DBUS_OBJECT_HAOS_BOARDS,
+)
+from ...interface import DBusInterfaceProxy, dbus_property
+from .const import BOARD_NAME_SUPERVISED, BOARD_NAME_YELLOW
+from .interface import BoardProxy
+from .supervised import Supervised
+from .yellow import Yellow
+
+_LOGGER: logging.Logger = logging.getLogger(__name__)
+
+
+class BoardManager(DBusInterfaceProxy):
+ """Board manager object."""
+
+ bus_name: str = DBUS_NAME_HAOS
+ object_path: str = DBUS_OBJECT_HAOS_BOARDS
+ properties_interface: str = DBUS_IFACE_HAOS_BOARDS
+ sync_properties: bool = False
+
+ def __init__(self) -> None:
+ """Initialize properties."""
+ self._board_proxy: BoardProxy | None = None
+ self.properties: dict[str, Any] = {}
+
+ @property
+ @dbus_property
+ def board(self) -> str:
+ """Get board name."""
+ return self.properties[DBUS_ATTR_BOARD]
+
+ @property
+ def supervised(self) -> Supervised:
+ """Get Supervised board."""
+ if self.board != BOARD_NAME_SUPERVISED:
+ raise BoardInvalidError("Supervised board is not in use", _LOGGER.error)
+
+ return self._board_proxy
+
+ @property
+ def yellow(self) -> Yellow:
+ """Get Yellow board."""
+ if self.board != BOARD_NAME_YELLOW:
+ raise BoardInvalidError("Yellow board is not in use", _LOGGER.error)
+
+ return self._board_proxy
+
+ async def connect(self, bus: MessageBus) -> None:
+ """Connect to D-Bus."""
+ await super().connect(bus)
+
+ if self.board == BOARD_NAME_YELLOW:
+ self._board_proxy = Yellow()
+ elif self.board == BOARD_NAME_SUPERVISED:
+ self._board_proxy = Supervised()
+
+ if self._board_proxy:
+ await self._board_proxy.connect(bus)
diff --git a/supervisor/dbus/agent/boards/const.py b/supervisor/dbus/agent/boards/const.py
new file mode 100644
index 000000000..a968b3cc9
--- /dev/null
+++ b/supervisor/dbus/agent/boards/const.py
@@ -0,0 +1,4 @@
+"""Constants for boards."""
+
+BOARD_NAME_SUPERVISED = "Supervised"
+BOARD_NAME_YELLOW = "Yellow"
diff --git a/supervisor/dbus/agent/boards/interface.py b/supervisor/dbus/agent/boards/interface.py
new file mode 100644
index 000000000..46edc4b00
--- /dev/null
+++ b/supervisor/dbus/agent/boards/interface.py
@@ -0,0 +1,24 @@
+"""Board dbus proxy interface."""
+
+from typing import Any
+
+from ...const import DBUS_IFACE_HAOS_BOARDS, DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_BOARDS
+from ...interface import DBusInterfaceProxy
+
+
+class BoardProxy(DBusInterfaceProxy):
+ """DBus interface proxy for os board."""
+
+ bus_name: str = DBUS_NAME_HAOS
+
+ def __init__(self, name: str) -> None:
+ """Initialize properties."""
+ self._name: str = name
+ self.object_path: str = f"{DBUS_OBJECT_HAOS_BOARDS}/{name}"
+ self.properties_interface: str = f"{DBUS_IFACE_HAOS_BOARDS}.{name}"
+ self.properties: dict[str, Any] = {}
+
+ @property
+ def name(self) -> str:
+ """Get name."""
+ return self._name
diff --git a/supervisor/dbus/agent/boards/supervised.py b/supervisor/dbus/agent/boards/supervised.py
new file mode 100644
index 000000000..0ab4af796
--- /dev/null
+++ b/supervisor/dbus/agent/boards/supervised.py
@@ -0,0 +1,13 @@
+"""Supervised board management."""
+
+from .const import BOARD_NAME_SUPERVISED
+from .interface import BoardProxy
+
+
+class Supervised(BoardProxy):
+ """Supervised board manager object."""
+
+ def __init__(self) -> None:
+ """Initialize properties."""
+ super().__init__(BOARD_NAME_SUPERVISED)
+ self.sync_properties: bool = False
diff --git a/supervisor/dbus/agent/boards/yellow.py b/supervisor/dbus/agent/boards/yellow.py
new file mode 100644
index 000000000..95a97d923
--- /dev/null
+++ b/supervisor/dbus/agent/boards/yellow.py
@@ -0,0 +1,49 @@
+"""Yellow board management."""
+
+import asyncio
+
+from ...const import DBUS_ATTR_DISK_LED, DBUS_ATTR_HEARTBEAT_LED, DBUS_ATTR_POWER_LED
+from ...interface import dbus_property
+from .const import BOARD_NAME_YELLOW
+from .interface import BoardProxy
+
+
+class Yellow(BoardProxy):
+ """Yellow board manager object."""
+
+ def __init__(self) -> None:
+ """Initialize properties."""
+ super().__init__(BOARD_NAME_YELLOW)
+
+ @property
+ @dbus_property
+ def heartbeat_led(self) -> bool:
+ """Get heartbeat LED enabled."""
+ return self.properties[DBUS_ATTR_HEARTBEAT_LED]
+
+ @heartbeat_led.setter
+ def heartbeat_led(self, enabled: bool) -> None:
+ """Enable/disable heartbeat LED."""
+ asyncio.create_task(self.dbus.Boards.Yellow.set_heartbeat_led(enabled))
+
+ @property
+ @dbus_property
+ def power_led(self) -> bool:
+ """Get power LED enabled."""
+ return self.properties[DBUS_ATTR_POWER_LED]
+
+ @power_led.setter
+ def power_led(self, enabled: bool) -> None:
+ """Enable/disable power LED."""
+ asyncio.create_task(self.dbus.Boards.Yellow.set_power_led(enabled))
+
+ @property
+ @dbus_property
+ def disk_led(self) -> bool:
+ """Get disk LED enabled."""
+ return self.properties[DBUS_ATTR_DISK_LED]
+
+ @disk_led.setter
+ def disk_led(self, enabled: bool) -> None:
+ """Enable/disable disk LED."""
+ asyncio.create_task(self.dbus.Boards.Yellow.set_disk_led(enabled))
diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py
index 52af34ea5..b80ca4e7c 100644
--- a/supervisor/dbus/const.py
+++ b/supervisor/dbus/const.py
@@ -18,6 +18,7 @@ DBUS_IFACE_DEVICE_WIRELESS = "org.freedesktop.NetworkManager.Device.Wireless"
DBUS_IFACE_DNS = "org.freedesktop.NetworkManager.DnsManager"
DBUS_IFACE_HAOS = "io.hass.os"
DBUS_IFACE_HAOS_APPARMOR = "io.hass.os.AppArmor"
+DBUS_IFACE_HAOS_BOARDS = "io.hass.os.Boards"
DBUS_IFACE_HAOS_CGROUP = "io.hass.os.CGroup"
DBUS_IFACE_HAOS_DATADISK = "io.hass.os.DataDisk"
DBUS_IFACE_HAOS_SYSTEM = "io.hass.os.System"
@@ -40,6 +41,7 @@ DBUS_OBJECT_BASE = "/"
DBUS_OBJECT_DNS = "/org/freedesktop/NetworkManager/DnsManager"
DBUS_OBJECT_HAOS = "/io/hass/os"
DBUS_OBJECT_HAOS_APPARMOR = "/io/hass/os/AppArmor"
+DBUS_OBJECT_HAOS_BOARDS = "/io/hass/os/Boards"
DBUS_OBJECT_HAOS_CGROUP = "/io/hass/os/CGroup"
DBUS_OBJECT_HAOS_DATADISK = "/io/hass/os/DataDisk"
DBUS_OBJECT_HAOS_SYSTEM = "/io/hass/os/System"
@@ -55,6 +57,7 @@ DBUS_ATTR_ACTIVE_ACCESSPOINT = "ActiveAccessPoint"
DBUS_ATTR_ACTIVE_CONNECTION = "ActiveConnection"
DBUS_ATTR_ACTIVE_CONNECTIONS = "ActiveConnections"
DBUS_ATTR_ADDRESS_DATA = "AddressData"
+DBUS_ATTR_BOARD = "Board"
DBUS_ATTR_BOOT_SLOT = "BootSlot"
DBUS_ATTR_CACHE_STATISTICS = "CacheStatistics"
DBUS_ATTR_CHASSIS = "Chassis"
@@ -72,6 +75,7 @@ DBUS_ATTR_DEVICE_INTERFACE = "Interface"
DBUS_ATTR_DEVICE_TYPE = "DeviceType"
DBUS_ATTR_DEVICES = "Devices"
DBUS_ATTR_DIAGNOSTICS = "Diagnostics"
+DBUS_ATTR_DISK_LED = "DiskLED"
DBUS_ATTR_DNS = "DNS"
DBUS_ATTR_DNS_EX = "DNSEx"
DBUS_ATTR_DNS_OVER_TLS = "DNSOverTLS"
@@ -88,6 +92,7 @@ DBUS_ATTR_FINISH_TIMESTAMP = "FinishTimestamp"
DBUS_ATTR_FIRMWARE_TIMESTAMP_MONOTONIC = "FirmwareTimestampMonotonic"
DBUS_ATTR_FREQUENCY = "Frequency"
DBUS_ATTR_GATEWAY = "Gateway"
+DBUS_ATTR_HEARTBEAT_LED = "HeartbeatLED"
DBUS_ATTR_HWADDRESS = "HwAddress"
DBUS_ATTR_ID = "Id"
DBUS_ATTR_IP4CONFIG = "Ip4Config"
@@ -108,6 +113,7 @@ DBUS_ATTR_NTPSYNCHRONIZED = "NTPSynchronized"
DBUS_ATTR_OPERATING_SYSTEM_PRETTY_NAME = "OperatingSystemPrettyName"
DBUS_ATTR_OPERATION = "Operation"
DBUS_ATTR_PARSER_VERSION = "ParserVersion"
+DBUS_ATTR_POWER_LED = "PowerLED"
DBUS_ATTR_PRIMARY_CONNECTION = "PrimaryConnection"
DBUS_ATTR_RESOLV_CONF_MODE = "ResolvConfMode"
DBUS_ATTR_RCMANAGER = "RcManager"
diff --git a/supervisor/dbus/manager.py b/supervisor/dbus/manager.py
index 706262acf..0ef8fcf63 100644
--- a/supervisor/dbus/manager.py
+++ b/supervisor/dbus/manager.py
@@ -88,13 +88,13 @@ class DBusManager(CoreSysAttributes):
"""Return all managed dbus interfaces."""
return [
self.agent,
- self.systemd,
- self.logind,
self.hostname,
- self.timedate,
+ self.logind,
self.network,
self.rauc,
self.resolved,
+ self.systemd,
+ self.timedate,
]
async def load(self) -> None:
diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py
index af07a6184..bf420d2e0 100644
--- a/supervisor/exceptions.py
+++ b/supervisor/exceptions.py
@@ -362,6 +362,13 @@ class AppArmorInvalidError(AppArmorError):
"""AppArmor profile validate error."""
+# util/boards
+
+
+class BoardInvalidError(DBusObjectError):
+ """System does not use the board specified."""
+
+
# util/common
diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py
index 09ba3ad6a..9373236ac 100644
--- a/supervisor/resolution/const.py
+++ b/supervisor/resolution/const.py
@@ -32,13 +32,13 @@ class UnsupportedReason(str, Enum):
"""Reasons for unsupported status."""
APPARMOR = "apparmor"
+ CGROUP_VERSION = "cgroup_version"
CONNECTIVITY_CHECK = "connectivity_check"
CONTENT_TRUST = "content_trust"
DBUS = "dbus"
DNS_SERVER = "dns_server"
DOCKER_CONFIGURATION = "docker_configuration"
DOCKER_VERSION = "docker_version"
- CGROUP_VERSION = "cgroup_version"
JOB_CONDITIONS = "job_conditions"
LXC = "lxc"
NETWORK_MANAGER = "network_manager"
@@ -79,6 +79,7 @@ class IssueType(str, Enum):
MISSING_IMAGE = "missing_image"
NO_CURRENT_BACKUP = "no_current_backup"
PWNED = "pwned"
+ REBOOT_REQUIRED = "reboot_required"
SECURITY = "security"
TRUST = "trust"
UPDATE_FAILED = "update_failed"
@@ -90,11 +91,12 @@ class SuggestionType(str, Enum):
CLEAR_FULL_BACKUP = "clear_full_backup"
CREATE_FULL_BACKUP = "create_full_backup"
- EXECUTE_UPDATE = "execute_update"
- EXECUTE_REPAIR = "execute_repair"
- EXECUTE_RESET = "execute_reset"
+ EXECUTE_INTEGRITY = "execute_integrity"
+ EXECUTE_REBOOT = "execute_reboot"
EXECUTE_RELOAD = "execute_reload"
EXECUTE_REMOVE = "execute_remove"
+ EXECUTE_REPAIR = "execute_repair"
+ EXECUTE_RESET = "execute_reset"
EXECUTE_STOP = "execute_stop"
- EXECUTE_INTEGRITY = "execute_integrity"
+ EXECUTE_UPDATE = "execute_update"
REGISTRY_LOGIN = "registry_login"
diff --git a/supervisor/resolution/fixups/system_execute_reboot.py b/supervisor/resolution/fixups/system_execute_reboot.py
new file mode 100644
index 000000000..98de80433
--- /dev/null
+++ b/supervisor/resolution/fixups/system_execute_reboot.py
@@ -0,0 +1,38 @@
+"""Reboot host fixup."""
+import asyncio
+import logging
+
+from ...coresys import CoreSys
+from ..const import ContextType, IssueType, SuggestionType
+from .base import FixupBase
+
+_LOGGER: logging.Logger = logging.getLogger(__name__)
+
+
+def setup(coresys: CoreSys) -> FixupBase:
+ """Check setup function."""
+ return FixupSystemExecuteReboot(coresys)
+
+
+class FixupSystemExecuteReboot(FixupBase):
+ """Storage class for fixup."""
+
+ async def process_fixup(self, reference: str | None = None) -> None:
+ """Initialize the fixup class."""
+ _LOGGER.info("Rebooting the host")
+ await asyncio.shield(self.sys_host.control.reboot())
+
+ @property
+ def suggestion(self) -> SuggestionType:
+ """Return a SuggestionType enum."""
+ return SuggestionType.EXECUTE_REBOOT
+
+ @property
+ def context(self) -> ContextType:
+ """Return a ContextType enum."""
+ return ContextType.SYSTEM
+
+ @property
+ def issues(self) -> list[IssueType]:
+ """Return a IssueType enum list."""
+ return [IssueType.REBOOT_REQUIRED]
diff --git a/tests/api/test_os.py b/tests/api/test_os.py
index 198f955db..8dcc50da2 100644
--- a/tests/api/test_os.py
+++ b/tests/api/test_os.py
@@ -1,16 +1,23 @@
"""Test OS API."""
-from pathlib import Path
+import asyncio
+from pathlib import Path
+from unittest.mock import PropertyMock, patch
+
+from aiohttp.test_utils import TestClient
import pytest
from supervisor.coresys import CoreSys
+from supervisor.dbus.agent.boards import BoardManager
from supervisor.hardware.data import Device
+from supervisor.resolution.const import ContextType, IssueType, SuggestionType
+from supervisor.resolution.data import Issue, Suggestion
# pylint: disable=protected-access
@pytest.mark.asyncio
-async def test_api_os_info(api_client):
+async def test_api_os_info(api_client: TestClient):
"""Test docker info api."""
resp = await api_client.get("/os/info")
result = await resp.json()
@@ -22,12 +29,13 @@ async def test_api_os_info(api_client):
"board",
"boot",
"data_disk",
+ "cpe_board",
):
assert attr in result["data"]
@pytest.mark.asyncio
-async def test_api_os_info_with_agent(api_client, coresys: CoreSys):
+async def test_api_os_info_with_agent(api_client: TestClient, coresys: CoreSys):
"""Test docker info api."""
await coresys.dbus.agent.connect(coresys.dbus.bus)
await coresys.dbus.agent.update()
@@ -39,7 +47,7 @@ async def test_api_os_info_with_agent(api_client, coresys: CoreSys):
@pytest.mark.asyncio
-async def test_api_os_datadisk_move(api_client, coresys: CoreSys):
+async def test_api_os_datadisk_move(api_client: TestClient, coresys: CoreSys):
"""Test datadisk move without exists disk."""
await coresys.dbus.agent.connect(coresys.dbus.bus)
await coresys.dbus.agent.update()
@@ -52,7 +60,7 @@ async def test_api_os_datadisk_move(api_client, coresys: CoreSys):
@pytest.mark.asyncio
-async def test_api_os_datadisk_list(api_client, coresys: CoreSys):
+async def test_api_os_datadisk_list(api_client: TestClient, coresys: CoreSys):
"""Test datadisk list function."""
await coresys.dbus.agent.connect(coresys.dbus.bus)
await coresys.dbus.agent.update()
@@ -86,3 +94,81 @@ async def test_api_os_datadisk_list(api_client, coresys: CoreSys):
result = await resp.json()
assert result["data"]["devices"] == ["/dev/sda"]
+
+
+@pytest.mark.parametrize("name", ["Yellow", "yellow"])
+async def test_api_board_yellow_info(
+ api_client: TestClient, coresys: CoreSys, name: str
+):
+ """Test yellow board info."""
+ await coresys.dbus.agent.board.connect(coresys.dbus.bus)
+
+ resp = await api_client.get(f"/os/boards/{name}")
+ assert resp.status == 200
+
+ result = await resp.json()
+ assert result["data"]["disk_led"] is True
+ assert result["data"]["heartbeat_led"] is True
+ assert result["data"]["power_led"] is True
+
+ assert (await api_client.get("/os/boards/supervised")).status == 400
+ assert (await api_client.get("/os/boards/NotReal")).status == 400
+
+
+@pytest.mark.parametrize("name", ["Yellow", "yellow"])
+async def test_api_board_yellow_options(
+ api_client: TestClient, coresys: CoreSys, dbus: list[str], name: str
+):
+ """Test yellow board options."""
+ await coresys.dbus.agent.board.connect(coresys.dbus.bus)
+
+ assert len(coresys.resolution.issues) == 0
+ dbus.clear()
+ resp = await api_client.post(
+ f"/os/boards/{name}",
+ json={"disk_led": False, "heartbeat_led": False, "power_led": False},
+ )
+ assert resp.status == 200
+
+ await asyncio.sleep(0)
+ assert dbus == [
+ "/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.DiskLED",
+ "/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.HeartbeatLED",
+ "/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.PowerLED",
+ ]
+
+ assert (
+ Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM)
+ in coresys.resolution.issues
+ )
+ assert (
+ Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM)
+ in coresys.resolution.suggestions
+ )
+
+
+@pytest.mark.parametrize("name", ["Supervised", "supervised"])
+async def test_api_board_supervised_info(
+ api_client: TestClient, coresys: CoreSys, name: str
+):
+ """Test supervised board info."""
+ with patch.object(
+ BoardManager, "board", new=PropertyMock(return_value="Supervised")
+ ):
+ await coresys.dbus.agent.board.connect(coresys.dbus.bus)
+
+ assert (await api_client.get(f"/os/boards/{name}")).status == 200
+ assert (await api_client.post(f"/os/boards/{name}", json={})).status == 405
+ assert (await api_client.get("/os/boards/yellow")).status == 400
+ assert (await api_client.get("/os/boards/NotReal")).status == 400
+
+
+async def test_api_board_other_info(api_client: TestClient, coresys: CoreSys):
+ """Test info for other board without dbus object."""
+ with patch.object(BoardManager, "board", new=PropertyMock(return_value="NotReal")):
+ await coresys.dbus.agent.board.connect(coresys.dbus.bus)
+
+ assert (await api_client.get("/os/boards/NotReal")).status == 200
+ assert (await api_client.post("/os/boards/NotReal", json={})).status == 405
+ assert (await api_client.get("/os/boards/yellow")).status == 400
+ assert (await api_client.get("/os/boards/supervised")).status == 400
diff --git a/tests/dbus/agent/boards/__init__.py b/tests/dbus/agent/boards/__init__.py
new file mode 100644
index 000000000..e44781490
--- /dev/null
+++ b/tests/dbus/agent/boards/__init__.py
@@ -0,0 +1 @@
+"""Test for Boards D-Bus interfaces."""
diff --git a/tests/dbus/agent/boards/test_board.py b/tests/dbus/agent/boards/test_board.py
new file mode 100644
index 000000000..5a9b0295f
--- /dev/null
+++ b/tests/dbus/agent/boards/test_board.py
@@ -0,0 +1,78 @@
+"""Test Boards manager."""
+
+from unittest.mock import patch
+
+from dbus_fast.aio.message_bus import MessageBus
+from dbus_fast.aio.proxy_object import ProxyInterface
+import pytest
+
+from supervisor.dbus.agent.boards import BoardManager
+from supervisor.exceptions import BoardInvalidError
+from supervisor.utils.dbus import DBUS_INTERFACE_PROPERTIES, DBus
+
+
+@pytest.fixture(name="dbus_mock_board")
+async def fixture_dbus_mock_board(request: pytest.FixtureRequest, dbus: list[str]):
+ """Mock Boards dbus object to particular board name for tests."""
+ call_dbus = DBus.call_dbus
+
+ async def mock_call_dbus_specify_board(
+ proxy_interface: ProxyInterface,
+ method: str,
+ *args,
+ unpack_variants: bool = True,
+ ):
+ if (
+ proxy_interface.introspection.name == DBUS_INTERFACE_PROPERTIES
+ and method == "call_get_all"
+ and proxy_interface.path == "/io/hass/os/Boards"
+ ):
+ return {"Board": request.param}
+
+ return call_dbus(
+ proxy_interface, method, *args, unpack_variants=unpack_variants
+ )
+
+ with patch(
+ "supervisor.utils.dbus.DBus.call_dbus", new=mock_call_dbus_specify_board
+ ):
+ yield dbus
+
+
+async def test_dbus_board(dbus: list[str], dbus_bus: MessageBus):
+ """Test DBus Board load."""
+ board = BoardManager()
+ await board.connect(dbus_bus)
+
+ assert board.board == "Yellow"
+ assert board.yellow.power_led is True
+
+ with pytest.raises(BoardInvalidError):
+ assert not board.supervised
+
+
+@pytest.mark.parametrize("dbus_mock_board", ["Supervised"], indirect=True)
+async def test_dbus_board_supervised(dbus_mock_board: list[str], dbus_bus: MessageBus):
+ """Test DBus Board load with supervised board."""
+ board = BoardManager()
+ await board.connect(dbus_bus)
+
+ assert board.board == "Supervised"
+ assert board.supervised
+
+ with pytest.raises(BoardInvalidError):
+ assert not board.yellow
+
+
+@pytest.mark.parametrize("dbus_mock_board", ["NotReal"], indirect=True)
+async def test_dbus_board_other(dbus_mock_board: list[str], dbus_bus: MessageBus):
+ """Test DBus Board load with board that has no dbus object."""
+ board = BoardManager()
+ await board.connect(dbus_bus)
+
+ assert board.board == "NotReal"
+
+ with pytest.raises(BoardInvalidError):
+ assert not board.yellow
+ with pytest.raises(BoardInvalidError):
+ assert not board.supervised
diff --git a/tests/dbus/agent/boards/test_yellow.py b/tests/dbus/agent/boards/test_yellow.py
new file mode 100644
index 000000000..b74e27a29
--- /dev/null
+++ b/tests/dbus/agent/boards/test_yellow.py
@@ -0,0 +1,54 @@
+"""Test Yellow board."""
+
+import asyncio
+
+from dbus_fast.aio.message_bus import MessageBus
+
+from supervisor.dbus.agent.boards.yellow import Yellow
+
+
+async def test_dbus_yellow(dbus: list[str], dbus_bus: MessageBus):
+ """Test Yellow board load."""
+ yellow = Yellow()
+ await yellow.connect(dbus_bus)
+
+ assert yellow.name == "Yellow"
+ assert yellow.disk_led is True
+ assert yellow.heartbeat_led is True
+ assert yellow.power_led is True
+
+
+async def test_dbus_yellow_set_disk_led(dbus: list[str], dbus_bus: MessageBus):
+ """Test setting disk led for Yellow board."""
+ yellow = Yellow()
+ await yellow.connect(dbus_bus)
+
+ dbus.clear()
+ yellow.disk_led = False
+ await asyncio.sleep(0)
+
+ assert dbus == ["/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.DiskLED"]
+
+
+async def test_dbus_yellow_set_heartbeat_led(dbus: list[str], dbus_bus: MessageBus):
+ """Test setting heartbeat led for Yellow board."""
+ yellow = Yellow()
+ await yellow.connect(dbus_bus)
+
+ dbus.clear()
+ yellow.heartbeat_led = False
+ await asyncio.sleep(0)
+
+ assert dbus == ["/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.HeartbeatLED"]
+
+
+async def test_dbus_yellow_set_power_led(dbus: list[str], dbus_bus: MessageBus):
+ """Test setting power led for Yellow board."""
+ yellow = Yellow()
+ await yellow.connect(dbus_bus)
+
+ dbus.clear()
+ yellow.power_led = False
+ await asyncio.sleep(0)
+
+ assert dbus == ["/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.PowerLED"]
diff --git a/tests/dbus/agent/test_apparmor.py b/tests/dbus/agent/test_apparmor.py
index fb2f52bd1..fcc58357d 100644
--- a/tests/dbus/agent/test_apparmor.py
+++ b/tests/dbus/agent/test_apparmor.py
@@ -23,7 +23,7 @@ async def test_dbus_osagent_apparmor(coresys: CoreSys):
await asyncio.sleep(0)
assert coresys.dbus.agent.apparmor.version == "1.0.0"
- fire_property_change_signal(coresys.dbus.agent, {}, ["ParserVersion"])
+ fire_property_change_signal(coresys.dbus.agent.apparmor, {}, ["ParserVersion"])
await asyncio.sleep(0)
assert coresys.dbus.agent.apparmor.version == "2.13.2"
diff --git a/tests/fixtures/io_hass_os_Boards.json b/tests/fixtures/io_hass_os_Boards.json
new file mode 100644
index 000000000..830a06edd
--- /dev/null
+++ b/tests/fixtures/io_hass_os_Boards.json
@@ -0,0 +1,3 @@
+{
+ "Board": "Yellow"
+}
diff --git a/tests/fixtures/io_hass_os_Boards.xml b/tests/fixtures/io_hass_os_Boards.xml
new file mode 100644
index 000000000..4b21e9e7a
--- /dev/null
+++ b/tests/fixtures/io_hass_os_Boards.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/io_hass_os_Boards_Supervised.json b/tests/fixtures/io_hass_os_Boards_Supervised.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/tests/fixtures/io_hass_os_Boards_Supervised.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/fixtures/io_hass_os_Boards_Supervised.xml b/tests/fixtures/io_hass_os_Boards_Supervised.xml
new file mode 100644
index 000000000..4b6e43840
--- /dev/null
+++ b/tests/fixtures/io_hass_os_Boards_Supervised.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/io_hass_os_Boards_Yellow.json b/tests/fixtures/io_hass_os_Boards_Yellow.json
new file mode 100644
index 000000000..4a864cec9
--- /dev/null
+++ b/tests/fixtures/io_hass_os_Boards_Yellow.json
@@ -0,0 +1,5 @@
+{
+ "HeartbeatLED": true,
+ "PowerLED": true,
+ "DiskLED": true
+}
diff --git a/tests/fixtures/io_hass_os_Boards_Yellow.xml b/tests/fixtures/io_hass_os_Boards_Yellow.xml
new file mode 100644
index 000000000..9ef9b36dd
--- /dev/null
+++ b/tests/fixtures/io_hass_os_Boards_Yellow.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/resolution/fixup/test_system_execute_reboot.py b/tests/resolution/fixup/test_system_execute_reboot.py
new file mode 100644
index 000000000..cb6eff606
--- /dev/null
+++ b/tests/resolution/fixup/test_system_execute_reboot.py
@@ -0,0 +1,33 @@
+"""Test fixup system reboot."""
+
+from unittest.mock import PropertyMock, patch
+
+from supervisor.coresys import CoreSys
+from supervisor.host.const import HostFeature
+from supervisor.host.manager import HostManager
+from supervisor.resolution.const import ContextType, IssueType, SuggestionType
+from supervisor.resolution.data import Issue, Suggestion
+from supervisor.resolution.fixups.system_execute_reboot import FixupSystemExecuteReboot
+
+
+async def test_fixup(coresys: CoreSys, dbus: list[str]):
+ """Test fixup."""
+ await coresys.dbus.logind.connect(coresys.dbus.bus)
+ dbus.clear()
+
+ system_execute_reboot = FixupSystemExecuteReboot(coresys)
+ assert system_execute_reboot.auto is False
+
+ coresys.resolution.suggestions = Suggestion(
+ SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM
+ )
+ coresys.resolution.issues = Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM)
+
+ with patch.object(
+ HostManager, "features", new=PropertyMock(return_value=[HostFeature.REBOOT])
+ ):
+ await system_execute_reboot()
+
+ assert dbus == ["/org/freedesktop/login1-org.freedesktop.login1.Manager.Reboot"]
+ assert len(coresys.resolution.suggestions) == 0
+ assert len(coresys.resolution.issues) == 0