mirror of
https://github.com/home-assistant/core.git
synced 2026-03-31 03:25:49 +00:00
Compare commits
5 Commits
dev
...
replace-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f74256db2d | ||
|
|
0c5a830ae9 | ||
|
|
23ae787051 | ||
|
|
2cbc4f7893 | ||
|
|
78b8d19174 |
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
439
homeassistant/components/hassio/services.py
Normal file
439
homeassistant/components/hassio/services.py
Normal 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
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user