Compare commits

...

5 Commits

Author SHA1 Message Date
Mike Degatano
f74256db2d Remove unused logging variable 2026-03-30 19:20:34 +00:00
Mike Degatano
0c5a830ae9 make strings into json in write_stdin 2026-03-30 19:11:55 +00:00
Mike Degatano
23ae787051 Fix test and docstring 2026-03-30 19:11:51 +00:00
Mike Degatano
2cbc4f7893 Fix tests 2026-03-30 19:11:48 +00:00
Mike Degatano
78b8d19174 Use aiohasupervisor for service calls in Supervisor component 2026-03-30 19:11:48 +00:00
5 changed files with 580 additions and 375 deletions

View File

@@ -3,14 +3,12 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from dataclasses import replace
from datetime import datetime
import logging
import os
import re
import struct
from typing import Any, NamedTuple, cast
from typing import Any, cast
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
@@ -41,35 +39,23 @@ from homeassistant.components.http import (
)
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_NAME,
EVENT_CORE_CONFIG_UPDATE,
HASSIO_USER_NAME,
SERVER_PORT,
Platform,
)
from homeassistant.core import (
Event,
HassJob,
HomeAssistant,
ServiceCall,
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.core import Event, HassJob, HomeAssistant, callback
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery_flow,
issue_registry as ir,
selector,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.dt import now
# config_flow, diagnostics, system_health, and entity platforms are imported to
# ensure other dependencies that wait for hassio are not waiting
@@ -92,19 +78,7 @@ from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
ATTR_APPS,
ATTR_COMPRESSED,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_REPOSITORIES,
ATTR_SLUG,
DATA_ADDONS_LIST,
DATA_COMPONENT,
DATA_CONFIG_STORE,
@@ -118,7 +92,6 @@ from .const import (
DATA_SUPERVISOR_INFO,
DOMAIN,
HASSIO_UPDATE_INTERVAL,
SupervisorEntityModel,
)
from .coordinator import (
HassioDataUpdateCoordinator,
@@ -136,15 +109,11 @@ from .coordinator import (
get_supervisor_stats,
)
from .discovery import async_setup_discovery_view
from .handler import (
HassIO,
HassioAPIError,
async_update_diagnostics,
get_supervisor_client,
)
from .handler import HassIO, async_update_diagnostics, get_supervisor_client
from .http import HassIOView
from .ingress import async_setup_ingress_view
from .issues import SupervisorIssues
from .services import async_setup_services
from .websocket_api import async_load_websocket_api
# Expose the future safe name now so integrations can use it
@@ -190,23 +159,6 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_APP_START = "app_start"
SERVICE_APP_STOP = "app_stop"
SERVICE_APP_RESTART = "app_restart"
SERVICE_APP_STDIN = "app_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot"
SERVICE_BACKUP_FULL = "backup_full"
SERVICE_BACKUP_PARTIAL = "backup_partial"
SERVICE_RESTORE_FULL = "restore_full"
SERVICE_RESTORE_PARTIAL = "restore_partial"
SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
DEPRECATION_URL = (
"https://www.home-assistant.io/blog/2025/05/22/"
@@ -214,148 +166,11 @@ DEPRECATION_URL = (
)
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
value = VALID_ADDON_SLUG(value)
hass = async_get_hass_or_none()
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
raise vol.Invalid("Not a valid app slug")
return value
SCHEMA_NO_DATA = vol.Schema({})
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean,
vol.Optional(ATTR_LOCATION): vol.All(
cv.string, lambda v: None if v == "/backup" else v
),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
}
)
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
}
)
SCHEMA_RESTORE_FULL = vol.Schema(
{
vol.Required(ATTR_SLUG): cv.slug,
vol.Optional(ATTR_PASSWORD): cv.string,
}
)
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
}
)
SCHEMA_MOUNT_RELOAD = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(
filter=selector.DeviceFilterSelectorConfig(
integration=DOMAIN,
model=SupervisorEntityModel.MOUNT,
)
)
)
}
)
def _is_32_bit() -> bool:
size = struct.calcsize("P")
return size * 8 == 32
class APIEndpointSettings(NamedTuple):
"""Settings for API endpoint."""
command: str
schema: vol.Schema
timeout: int | None = 60
pass_data: bool = False
MAP_SERVICE_API = {
# Legacy addon services
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
SERVICE_ADDON_STDIN: APIEndpointSettings(
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
),
# New app services
SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP),
SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP),
SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP),
SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN),
SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA),
SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA),
SERVICE_BACKUP_FULL: APIEndpointSettings(
"/backups/new/full",
SCHEMA_BACKUP_FULL,
None,
True,
),
SERVICE_BACKUP_PARTIAL: APIEndpointSettings(
"/backups/new/partial",
SCHEMA_BACKUP_PARTIAL,
None,
True,
),
SERVICE_RESTORE_FULL: APIEndpointSettings(
"/backups/{slug}/restore/full",
SCHEMA_RESTORE_FULL,
None,
True,
),
SERVICE_RESTORE_PARTIAL: APIEndpointSettings(
"/backups/{slug}/restore/partial",
SCHEMA_RESTORE_PARTIAL,
None,
True,
),
}
HARDWARE_INTEGRATIONS = {
"green": "homeassistant_green",
"odroid-c2": "hardkernel",
@@ -397,7 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)
hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host)
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
supervisor_client = get_supervisor_client(hass)
try:
@@ -510,74 +325,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
async def async_service_handler(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
api_endpoint = MAP_SERVICE_API[service.service]
data = service.data.copy()
addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None)
slug = data.pop(ATTR_SLUG, None)
if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None):
data[ATTR_ADDONS] = addons
payload = None
# Pass data to Hass.io API
if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN):
payload = data[ATTR_INPUT]
elif api_endpoint.pass_data:
payload = data
# Call API
# The exceptions are logged properly in hassio.send_command
with suppress(HassioAPIError):
await hassio.send_command(
api_endpoint.command.format(addon=addon, slug=slug),
payload=payload,
timeout=api_endpoint.timeout,
)
for service, settings in MAP_SERVICE_API.items():
hass.services.async_register(
DOMAIN, service, async_service_handler, schema=settings.schema
)
dev_reg = dr.async_get(hass)
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
)
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_invalid_device",
)
try:
await supervisor_client.mounts.reload_mount(device.name)
except SupervisorError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mount_reload_error",
translation_placeholders={"name": device.name, "error": str(error)},
) from error
hass.services.async_register(
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
)
# Register services
async_setup_services(hass, supervisor_client)
async def update_info_data(_: datetime | None = None) -> None:
"""Update last available supervisor information."""

View File

@@ -26,7 +26,7 @@ from aiohasupervisor.models import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .handler import HassioAPIError, get_supervisor_client
from .handler import get_supervisor_client
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]]
type _ReturnFuncType[_T, **_P, _R] = Callable[
@@ -36,18 +36,15 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[
def api_error[_AddonManagerT: AddonManager, **_P, _R](
error_message: str,
*,
expected_error_type: type[HassioAPIError | SupervisorError] | None = None,
) -> Callable[
[_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R]
]:
"""Handle HassioAPIError and raise a specific AddonError."""
error_type = expected_error_type or (HassioAPIError, SupervisorError)
"""Handle SupervisorError and raise a specific AddonError."""
def handle_hassio_api_error(
def handle_supervisor_error(
func: _FuncType[_AddonManagerT, _P, _R],
) -> _ReturnFuncType[_AddonManagerT, _P, _R]:
"""Handle a HassioAPIError."""
"""Handle a SupervisorError."""
@wraps(func)
async def wrapper(
@@ -56,7 +53,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
"""Wrap an add-on manager method."""
try:
return_value = await func(self, *args, **kwargs)
except error_type as err:
except SupervisorError as err:
raise AddonError(
f"{error_message.format(addon_name=self.addon_name)}: {err}"
) from err
@@ -65,7 +62,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
return wrapper
return handle_hassio_api_error
return handle_supervisor_error
@dataclass
@@ -128,10 +125,7 @@ class AddonManager:
)
)
@api_error(
"Failed to get the {addon_name} app discovery info",
expected_error_type=SupervisorError,
)
@api_error("Failed to get the {addon_name} app discovery info")
async def async_get_addon_discovery_info(self) -> dict:
"""Return add-on discovery info."""
discovery_info = next(
@@ -148,10 +142,7 @@ class AddonManager:
return discovery_info.config
@api_error(
"Failed to get the {addon_name} app info",
expected_error_type=SupervisorError,
)
@api_error("Failed to get the {addon_name} app info")
async def async_get_addon_info(self) -> AddonInfo:
"""Return and cache manager add-on info."""
addon_store_info = await self._supervisor_client.store.addon_info(
@@ -199,19 +190,14 @@ class AddonManager:
version=addon_info.version,
)
@api_error(
"Failed to set the {addon_name} app options",
expected_error_type=SupervisorError,
)
@api_error("Failed to set the {addon_name} app options")
async def async_set_addon_options(self, config: dict) -> None:
"""Set manager add-on options."""
await self._supervisor_client.addons.set_addon_options(
self.addon_slug, AddonsOptions(config=config)
)
@api_error(
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
)
@api_error("Failed to install the {addon_name} app")
async def async_install_addon(self) -> None:
"""Install the managed add-on."""
try:
@@ -221,10 +207,7 @@ class AddonManager:
f"{self.addon_name} app is not available: {err!s}"
) from None
@api_error(
"Failed to uninstall the {addon_name} app",
expected_error_type=SupervisorError,
)
@api_error("Failed to uninstall the {addon_name} app")
async def async_uninstall_addon(self) -> None:
"""Uninstall the managed add-on."""
await self._supervisor_client.addons.uninstall_addon(self.addon_slug)
@@ -259,31 +242,22 @@ class AddonManager:
self.addon_slug, StoreAddonUpdate(backup=False)
)
@api_error(
"Failed to start the {addon_name} app", expected_error_type=SupervisorError
)
@api_error("Failed to start the {addon_name} app")
async def async_start_addon(self) -> None:
"""Start the managed add-on."""
await self._supervisor_client.addons.start_addon(self.addon_slug)
@api_error(
"Failed to restart the {addon_name} app", expected_error_type=SupervisorError
)
@api_error("Failed to restart the {addon_name} app")
async def async_restart_addon(self) -> None:
"""Restart the managed add-on."""
await self._supervisor_client.addons.restart_addon(self.addon_slug)
@api_error(
"Failed to stop the {addon_name} app", expected_error_type=SupervisorError
)
@api_error("Failed to stop the {addon_name} app")
async def async_stop_addon(self) -> None:
"""Stop the managed add-on."""
await self._supervisor_client.addons.stop_addon(self.addon_slug)
@api_error(
"Failed to create a backup of the {addon_name} app",
expected_error_type=SupervisorError,
)
@api_error("Failed to create a backup of the {addon_name} app")
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
"""Create a partial backup of the managed add-on."""
if addon_info:

View File

@@ -0,0 +1,439 @@
"""Set up Supervisor services."""
from collections.abc import Awaitable, Callable
import json
import re
from typing import Any
from aiohasupervisor import SupervisorClient, SupervisorError
from aiohasupervisor.models import (
FullBackupOptions,
FullRestoreOptions,
PartialBackupOptions,
PartialRestoreOptions,
)
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
selector,
)
from homeassistant.util.dt import now
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
ATTR_APPS,
ATTR_COMPRESSED,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
DOMAIN,
SupervisorEntityModel,
)
from .coordinator import HassioDataUpdateCoordinator, get_addons_info
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_APP_START = "app_start"
SERVICE_APP_STOP = "app_stop"
SERVICE_APP_RESTART = "app_restart"
SERVICE_APP_STDIN = "app_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot"
SERVICE_BACKUP_FULL = "backup_full"
SERVICE_BACKUP_PARTIAL = "backup_partial"
SERVICE_RESTORE_FULL = "restore_full"
SERVICE_RESTORE_PARTIAL = "restore_partial"
SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
value = VALID_ADDON_SLUG(value)
hass = async_get_hass_or_none()
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
raise vol.Invalid("Not a valid app slug")
return value
SCHEMA_NO_DATA = vol.Schema({})
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean,
vol.Optional(ATTR_LOCATION): vol.All(
cv.string, lambda v: None if v == "/backup" else v
),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
}
)
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
}
)
SCHEMA_RESTORE_FULL = vol.Schema(
{
vol.Required(ATTR_SLUG): cv.slug,
vol.Optional(ATTR_PASSWORD): cv.string,
}
)
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
}
)
SCHEMA_MOUNT_RELOAD = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(
filter=selector.DeviceFilterSelectorConfig(
integration=DOMAIN,
model=SupervisorEntityModel.MOUNT,
)
)
)
}
)
@callback
def async_setup_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register the Supervisor services."""
async_register_app_services(hass, supervisor_client)
async_register_host_services(hass, supervisor_client)
async_register_backup_restore_services(hass, supervisor_client)
async_register_network_storage_services(hass, supervisor_client)
@callback
def async_register_app_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register app services."""
simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
SERVICE_APP_START: ("start", supervisor_client.addons.start_addon),
SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon),
SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon),
}
async def async_simple_app_service_handler(service: ServiceCall) -> None:
"""Handles app services which only take a slug and have no response."""
action, api_method = simple_app_services[service.service]
app_slug = service.data[ATTR_APP]
try:
await api_method(app_slug)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to {action} app {app_slug}: {err}"
) from err
for service in simple_app_services:
hass.services.async_register(
DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP
)
async def async_app_stdin_service_handler(service: ServiceCall) -> None:
"""Handles app stdin service."""
app_slug = service.data[ATTR_APP]
data: dict | str = service.data[ATTR_INPUT]
# For backwards compatibility the payload here must be valid json
# This is sensible when a dictionary is provided, it must be serialized
# If user provides a string though, we wrap it in quotes before encoding
# This is purely for legacy reasons, Supervisor has no json requirement
# Supervisor just hands the raw request as binary to the container
data = json.dumps(data)
payload = data.encode(encoding="utf-8")
try:
await supervisor_client.addons.write_addon_stdin(app_slug, payload)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to write stdin to app {app_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_APP_STDIN,
async_app_stdin_service_handler,
schema=SCHEMA_APP_STDIN,
)
# LEGACY - Register equivalent addon services for compatibility
simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon),
SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon),
SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon),
}
async def async_simple_addon_service_handler(service: ServiceCall) -> None:
"""Handles addon services which only take a slug and have no response."""
action, api_method = simple_addon_services[service.service]
addon_slug = service.data[ATTR_ADDON]
try:
await api_method(addon_slug)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to {action} app {addon_slug}: {err}"
) from err
for service in simple_addon_services:
hass.services.async_register(
DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON
)
async def async_addon_stdin_service_handler(service: ServiceCall) -> None:
"""Handles addon stdin service."""
addon_slug = service.data[ATTR_ADDON]
data: dict | str = service.data[ATTR_INPUT]
# See explanation for why we make strings into json in async_app_stdin_service_handler
data = json.dumps(data)
payload = data.encode(encoding="utf-8")
try:
await supervisor_client.addons.write_addon_stdin(addon_slug, payload)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to write stdin to app {addon_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_ADDON_STDIN,
async_addon_stdin_service_handler,
schema=SCHEMA_ADDON_STDIN,
)
@callback
def async_register_host_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register host services."""
simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = {
SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot),
SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown),
}
async def async_simple_host_service_handler(service: ServiceCall) -> None:
"""Handler for host services that take no input and return no response."""
action, api_method = simple_host_services[service.service]
try:
await api_method()
except SupervisorError as err:
raise HomeAssistantError(f"Failed to {action} the host: {err}") from err
for service in simple_host_services:
hass.services.async_register(
DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA
)
@callback
def async_register_backup_restore_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register backup and restore services."""
async def async_full_backup_service_handler(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create full backup service. Returns the new backup's ID."""
options = FullBackupOptions(**service.data)
try:
backup = await supervisor_client.backups.full_backup(options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to create full backup {options.name}: {err}"
) from err
return {"backup": backup.slug}
hass.services.async_register(
DOMAIN,
SERVICE_BACKUP_FULL,
async_full_backup_service_handler,
schema=SCHEMA_BACKUP_FULL,
supports_response=SupportsResponse.OPTIONAL,
)
async def async_partial_backup_service_handler(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create partial backup service. Returns the new backup's ID."""
data = service.data.copy()
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialBackupOptions(**data)
try:
backup = await supervisor_client.backups.partial_backup(options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to create partial backup {options.name}: {err}"
) from err
return {"backup": backup.slug}
hass.services.async_register(
DOMAIN,
SERVICE_BACKUP_PARTIAL,
async_partial_backup_service_handler,
schema=SCHEMA_BACKUP_PARTIAL,
supports_response=SupportsResponse.OPTIONAL,
)
async def async_full_restore_service_handler(service: ServiceCall) -> None:
"""Handler for full restore service."""
backup_slug = service.data[ATTR_SLUG]
options: FullRestoreOptions | None = None
if ATTR_PASSWORD in service.data:
options = FullRestoreOptions(password=service.data[ATTR_PASSWORD])
try:
await supervisor_client.backups.full_restore(backup_slug, options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to full restore from backup {backup_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_RESTORE_FULL,
async_full_restore_service_handler,
schema=SCHEMA_RESTORE_FULL,
)
async def async_partial_restore_service_handler(service: ServiceCall) -> None:
"""Handler for partial restore service."""
data = service.data.copy()
backup_slug = data.pop(ATTR_SLUG)
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialRestoreOptions(**data)
try:
await supervisor_client.backups.partial_restore(backup_slug, options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to partial restore from backup {backup_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_RESTORE_PARTIAL,
async_partial_restore_service_handler,
schema=SCHEMA_RESTORE_PARTIAL,
)
@callback
def async_register_network_storage_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register network storage (or mount) services."""
dev_reg = dr.async_get(hass)
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
)
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_invalid_device",
)
try:
await supervisor_client.mounts.reload_mount(device.name)
except SupervisorError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mount_reload_error",
translation_placeholders={"name": device.name, "error": str(error)},
) from error
hass.services.async_register(
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
)

View File

@@ -18,7 +18,6 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from . import HassioAPIError
from .config import HassioUpdateParametersDict
from .const import (
ATTR_DATA,
@@ -40,6 +39,7 @@ from .const import (
WS_TYPE_SUBSCRIBE,
)
from .coordinator import get_addons_list
from .handler import HassioAPIError
from .update_helper import update_addon, update_core
SCHEMA_WEBSOCKET_EVENT = vol.Schema(

View File

@@ -5,7 +5,8 @@ from datetime import timedelta
import os
from pathlib import PurePath
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock, patch
from unittest.mock import ANY, AsyncMock, Mock, call, patch
from uuid import uuid4
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
@@ -13,6 +14,7 @@ from aiohasupervisor.models import (
AddonStage,
AddonState,
CIFSMountResponse,
FullBackupOptions,
HomeAssistantOptions,
InstalledAddon,
InstalledAddonComplete,
@@ -20,6 +22,9 @@ from aiohasupervisor.models import (
MountState,
MountType,
MountUsage,
NewBackup,
PartialBackupOptions,
PartialRestoreOptions,
SupervisorOptions,
)
from freezegun.api import FrozenDateTimeFactory
@@ -54,7 +59,6 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.yaml import load_yaml_dict
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"}
@@ -461,7 +465,6 @@ async def test_service_register(hass: HomeAssistant) -> None:
@pytest.mark.freeze_time("2021-11-13 11:48:00")
async def test_service_calls(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
supervisor_client: AsyncMock,
supervisor_is_connected: AsyncMock,
app_or_addon: str,
@@ -472,21 +475,7 @@ async def test_service_calls(
assert await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
aioclient_mock.post("http://127.0.0.1/addons/test/start", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/addons/test/restart", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/addons/test/update", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/addons/test/stdin", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/host/shutdown", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/host/reboot", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/backups/new/full", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/backups/new/partial", json={"result": "ok"})
aioclient_mock.post(
"http://127.0.0.1/backups/test/restore/full", json={"result": "ok"}
)
aioclient_mock.post(
"http://127.0.0.1/backups/test/restore/partial", json={"result": "ok"}
)
supervisor_client.reset_mock()
await hass.services.async_call(
"hassio", f"{app_or_addon}_start", {app_or_addon: "test"}
@@ -500,64 +489,90 @@ async def test_service_calls(
await hass.services.async_call(
"hassio", f"{app_or_addon}_stdin", {app_or_addon: "test", "input": "test"}
)
await hass.services.async_call(
"hassio",
f"{app_or_addon}_stdin",
{app_or_addon: "test", "input": {"hello": "world"}},
)
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 27
assert aioclient_mock.mock_calls[-1][2] == "test"
supervisor_client.addons.start_addon.assert_called_once_with("test")
supervisor_client.addons.stop_addon.assert_called_once_with("test")
supervisor_client.addons.restart_addon.assert_called_once_with("test")
assert (
call("test", b'"test"') in supervisor_client.addons.write_addon_stdin.mock_calls
)
assert (
call("test", b'{"hello": "world"}')
in supervisor_client.addons.write_addon_stdin.mock_calls
)
await hass.services.async_call("hassio", "host_shutdown", {})
await hass.services.async_call("hassio", "host_reboot", {})
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29
supervisor_client.host.shutdown.assert_called_once_with()
supervisor_client.host.reboot.assert_called_once_with()
await hass.services.async_call("hassio", "backup_full", {})
await hass.services.async_call(
supervisor_client.backups.full_backup.return_value = NewBackup(
job_id=uuid4(), slug="full"
)
supervisor_client.backups.partial_backup.return_value = NewBackup(
job_id=uuid4(), slug="partial"
)
full_backup = await hass.services.async_call(
"hassio", "backup_full", {}, blocking=True, return_response=True
)
supervisor_client.backups.full_backup.assert_called_once_with(
FullBackupOptions(name="2021-11-13 03:48:00")
)
assert full_backup == {"backup": "full"}
partial_backup = await hass.services.async_call(
"hassio",
"backup_partial",
{
"homeassistant": True,
"apps": ["test"],
f"{app_or_addon}s": ["test"],
"folders": ["ssl"],
"password": "123456",
},
blocking=True,
return_response=True,
)
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 31
# API receives "addons" even when we pass "apps"
assert aioclient_mock.mock_calls[-1][2] == {
"name": "2021-11-13 03:48:00",
"homeassistant": True,
"addons": ["test"],
"folders": ["ssl"],
"password": "123456",
}
supervisor_client.backups.partial_backup.assert_called_once_with(
PartialBackupOptions(
name="2021-11-13 03:48:00",
homeassistant=True,
addons={"test"},
folders={"ssl"},
password="123456",
)
)
assert partial_backup == {"backup": "partial"}
await hass.services.async_call("hassio", "restore_full", {"slug": "test"})
await hass.async_block_till_done()
await hass.services.async_call(
"hassio",
"restore_partial",
{
"slug": "test",
"homeassistant": False,
"apps": ["test"],
f"{app_or_addon}s": ["test"],
"folders": ["ssl"],
"password": "123456",
},
)
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 33
# API receives "addons" even when we pass "apps"
assert aioclient_mock.mock_calls[-1][2] == {
"addons": ["test"],
"folders": ["ssl"],
"homeassistant": False,
"password": "123456",
}
supervisor_client.backups.full_restore.assert_called_once_with("test", None)
supervisor_client.backups.partial_restore.assert_called_once_with(
"test",
PartialRestoreOptions(
homeassistant=False, addons={"test"}, folders={"ssl"}, password="123456"
),
)
await hass.services.async_call(
"hassio",
@@ -569,13 +584,13 @@ async def test_service_calls(
},
)
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 34
assert aioclient_mock.mock_calls[-1][2] == {
"name": "backup_name",
"location": "backup_share",
"homeassistant_exclude_database": True,
}
supervisor_client.backups.full_backup.assert_called_with(
FullBackupOptions(
name="backup_name",
location="backup_share",
homeassistant_exclude_database=True,
)
)
await hass.services.async_call(
"hassio",
@@ -585,12 +600,9 @@ async def test_service_calls(
},
)
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 35
assert aioclient_mock.mock_calls[-1][2] == {
"name": "2021-11-13 03:48:00",
"location": None,
}
supervisor_client.backups.full_backup.assert_called_with(
FullBackupOptions(name="2021-11-13 03:48:00", location=None)
)
# check backup with different timezone
await hass.config.async_update(time_zone="Europe/London")
@@ -604,12 +616,9 @@ async def test_service_calls(
},
)
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 37
assert aioclient_mock.mock_calls[-1][2] == {
"name": "2021-11-13 11:48:00",
"location": None,
}
supervisor_client.backups.full_backup.assert_called_with(
FullBackupOptions(name="2021-11-13 11:48:00", location=None)
)
@pytest.mark.parametrize(
@@ -673,7 +682,6 @@ async def test_service_calls_apps_addons_exclusive(
"app_or_addon",
["app", "addon"],
)
@pytest.mark.usefixtures("aioclient_mock")
async def test_addon_service_call_with_complex_slug(
hass: HomeAssistant,
supervisor_is_connected: AsyncMock,
@@ -714,26 +722,22 @@ async def test_addon_service_call_with_complex_slug(
@pytest.mark.usefixtures("hassio_env")
async def test_service_calls_core(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
supervisor_client: AsyncMock,
hass: HomeAssistant, supervisor_client: AsyncMock
) -> None:
"""Call core service and check the API calls behind that."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "hassio", {})
aioclient_mock.post("http://127.0.0.1/homeassistant/restart", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/homeassistant/stop", json={"result": "ok"})
await hass.services.async_call("homeassistant", "stop")
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20
supervisor_client.homeassistant.stop.assert_called_once_with()
assert len(supervisor_client.mock_calls) == 20
await hass.services.async_call("homeassistant", "check_config")
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20
assert len(supervisor_client.mock_calls) == 20
with patch(
"homeassistant.config.async_check_ha_config_file", return_value=None
@@ -742,7 +746,46 @@ async def test_service_calls_core(
await hass.async_block_till_done()
assert mock_check_config.called
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 21
supervisor_client.homeassistant.restart.assert_called_once_with()
assert len(supervisor_client.mock_calls) == 21
@pytest.mark.parametrize(
"app_or_addon",
["apps", "addons"],
)
@pytest.mark.usefixtures("hassio_env", "supervisor_client")
async def test_invalid_service_calls_app_duplicates(
hass: HomeAssistant, app_or_addon: str
) -> None:
"""Test invalid backup/restore service calls due to duplicates in apps list."""
assert await async_setup_component(hass, "hassio", {})
with pytest.raises(Invalid, match="contains duplicate items"):
await hass.services.async_call(
"hassio", "backup_partial", {app_or_addon: ["test", "test"]}
)
with pytest.raises(Invalid, match="contains duplicate items"):
await hass.services.async_call(
"hassio", "restore_partial", {app_or_addon: ["test", "test"]}
)
@pytest.mark.usefixtures("hassio_env", "supervisor_client")
async def test_invalid_service_calls_folder_duplicates(hass: HomeAssistant) -> None:
"""Test invalid backup/restore service calls due to duplicates in folder list."""
assert await async_setup_component(hass, "hassio", {})
with pytest.raises(Invalid, match="contains duplicate items"):
await hass.services.async_call(
"hassio", "backup_partial", {"folders": ["ssl", "ssl"]}
)
with pytest.raises(Invalid, match="contains duplicate items"):
await hass.services.async_call(
"hassio", "restore_partial", {"folders": ["ssl", "ssl"]}
)
@pytest.mark.usefixtures("addon_installed")