"""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 ( Folder, FullBackupOptions, FullRestoreOptions, PartialBackupOptions, PartialRestoreOptions, ) import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID, ATTR_LOCATION, ATTR_NAME from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse, callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, issue_registry as ir, selector, ) from homeassistant.helpers.service import async_register_admin_service from homeassistant.util.dt import now from .const import ( ATTR_ADDON, ATTR_ADDONS, ATTR_APP, ATTR_APPS, ATTR_COMPRESSED, ATTR_FOLDERS, ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, ATTR_INPUT, ATTR_PASSWORD, ATTR_SLUG, DOMAIN, ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER, MAIN_COORDINATOR, SupervisorEntityModel, ) from .coordinator import HassioMainDataUpdateCoordinator from .handler import get_supervisor_client 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]+$")) # Legacy alias used by the Supervisor API for the homeassistant flag, kept # for backwards compatibility with existing automations. LEGACY_FOLDER_HOMEASSISTANT = "homeassistant" def _normalize_partial_options_data( hass: HomeAssistant, data: dict[str, Any] ) -> dict[str, Any]: """Map legacy aliases used by both partial backup and partial restore handlers.""" if ATTR_APPS in data: data[ATTR_ADDONS] = data.pop(ATTR_APPS) if ATTR_FOLDERS in data: folders: set[Any] = set(data[ATTR_FOLDERS]) if LEGACY_FOLDER_HOMEASSISTANT in folders: folders.discard(LEGACY_FOLDER_HOMEASSISTANT) if data.get(ATTR_HOMEASSISTANT) is False: raise ServiceValidationError( f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy " f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}" ) data[ATTR_HOMEASSISTANT] = True ir.async_create_issue( hass, DOMAIN, ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER, breaks_in_ha_version="2026.12.0", is_fixable=True, is_persistent=True, severity=ir.IssueSeverity.WARNING, translation_key=ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER, ) if folders: data[ATTR_FOLDERS] = folders else: data.pop(ATTR_FOLDERS) return data SCHEMA_NO_DATA = vol.Schema({}) SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): VALID_ADDON_SLUG}) 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_SLUG}) 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, [vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))], 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, [vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))], 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) -> None: """Register the Supervisor services.""" supervisor_client = get_supervisor_client(hass) 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: async_register_admin_service( hass, 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 async_register_admin_service( hass, 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: async_register_admin_service( hass, 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 async_register_admin_service( hass, 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: async_register_admin_service( hass, 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} async_register_admin_service( hass, 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 = _normalize_partial_options_data(hass, service.data.copy()) 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} async_register_admin_service( hass, 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 async_register_admin_service( hass, 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) data = _normalize_partial_options_data(hass, data) 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 async_register_admin_service( hass, 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: HassioMainDataUpdateCoordinator | None = None if ( device := dev_reg.async_get( service.data[ATTR_DEVICE_ID], include_child_devices=False ) ) 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(MAIN_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 async_register_admin_service( hass, DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD )