Add services using "apps" instead of "addons" to hassio integration (#161689)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Jan Čermák
2026-01-28 18:08:52 +01:00
committed by GitHub
parent a978e3c199
commit b07adc03d2
6 changed files with 227 additions and 18 deletions

View File

@@ -70,6 +70,8 @@ from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
ATTR_APPS,
ATTR_COMPRESSED,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
@@ -135,6 +137,10 @@ 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"
@@ -156,7 +162,7 @@ def valid_addon(value: Any) -> str:
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 add-on slug")
raise vol.Invalid("Not a valid app slug")
return value
@@ -168,6 +174,12 @@ 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(
@@ -186,7 +198,13 @@ 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.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [VALID_ADDON_SLUG]),
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]
),
}
)
@@ -201,7 +219,13 @@ 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.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [VALID_ADDON_SLUG]),
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]
),
}
)
@@ -221,12 +245,18 @@ class APIEndpointSettings(NamedTuple):
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(
@@ -386,12 +416,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
api_endpoint = MAP_SERVICE_API[service.service]
data = service.data.copy()
addon = data.pop(ATTR_ADDON, None)
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 == SERVICE_ADDON_STDIN:
if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN):
payload = data[ATTR_INPUT]
elif api_endpoint.pass_data:
payload = data

View File

@@ -17,6 +17,8 @@ DOMAIN = "hassio"
ATTR_ADDON = "addon"
ATTR_ADDONS = "addons"
ATTR_APP = "app"
ATTR_APPS = "apps"
ATTR_ADMIN = "admin"
ATTR_COMPRESSED = "compressed"
ATTR_CONFIG = "config"

View File

@@ -22,6 +22,18 @@
"addon_stop": {
"service": "mdi:stop"
},
"app_restart": {
"service": "mdi:restart"
},
"app_start": {
"service": "mdi:play"
},
"app_stdin": {
"service": "mdi:console"
},
"app_stop": {
"service": "mdi:stop"
},
"backup_full": {
"service": "mdi:content-save"
},

View File

@@ -30,6 +30,42 @@ addon_stop:
selector:
addon:
app_start:
fields:
app:
required: true
example: core_ssh
selector:
app:
app_restart:
fields:
app:
required: true
example: core_ssh
selector:
app:
app_stdin:
fields:
app:
required: true
example: core_ssh
selector:
app:
input:
required: true
selector:
object:
app_stop:
fields:
app:
required: true
example: core_ssh
selector:
app:
host_reboot:
host_shutdown:
backup_full:
@@ -64,6 +100,10 @@ backup_partial:
default: false
selector:
boolean:
apps:
example: ["core_ssh", "core_samba", "core_mosquitto"]
selector:
object:
addons:
example: ["core_ssh", "core_samba", "core_mosquitto"]
selector:
@@ -113,6 +153,10 @@ restore_partial:
example: ["homeassistant", "share"]
selector:
object:
apps:
example: ["core_ssh", "core_samba", "core_mosquitto"]
selector:
object:
addons:
example: ["core_ssh", "core_samba", "core_mosquitto"]
selector:

View File

@@ -336,6 +336,50 @@
},
"name": "Stop add-on"
},
"app_restart": {
"description": "Restarts a Home Assistant app.",
"fields": {
"app": {
"description": "The app to restart.",
"name": "[%key:component::hassio::services::app_start::fields::app::name%]"
}
},
"name": "Restart app"
},
"app_start": {
"description": "Starts a Home Assistant app.",
"fields": {
"app": {
"description": "The app to start.",
"name": "App"
}
},
"name": "Start app"
},
"app_stdin": {
"description": "Writes data to the standard input of a Home Assistant app.",
"fields": {
"app": {
"description": "The app to write to.",
"name": "[%key:component::hassio::services::app_start::fields::app::name%]"
},
"input": {
"description": "The data to write.",
"name": "Input"
}
},
"name": "Write data to app stdin"
},
"app_stop": {
"description": "Stops a Home Assistant app.",
"fields": {
"app": {
"description": "The app to stop.",
"name": "[%key:component::hassio::services::app_start::fields::app::name%]"
}
},
"name": "Stop app"
},
"backup_full": {
"description": "Creates a full backup.",
"fields": {
@@ -366,8 +410,12 @@
"description": "Creates a partial backup.",
"fields": {
"addons": {
"description": "List of add-ons to include in the backup. Use the name slug of each add-on.",
"name": "Add-ons"
"description": "List of apps (formerly add-ons) to include in the backup. Use the slug of each app. Legacy option - use apps instead.",
"name": "Add-ons (legacy)"
},
"apps": {
"description": "List of apps to include in the backup. Use the slug of each app.",
"name": "Apps"
},
"compressed": {
"description": "[%key:component::hassio::services::backup_full::fields::compressed::description%]",
@@ -426,9 +474,13 @@
"description": "Restores from a partial backup.",
"fields": {
"addons": {
"description": "List of add-ons to restore from the backup. Use the name slug of each add-on.",
"description": "List of apps (formerly add-ons) to restore from the backup. Use the slug of each app. Legacy option - use apps instead.",
"name": "[%key:component::hassio::services::backup_partial::fields::addons::name%]"
},
"apps": {
"description": "List of apps to restore from the backup. Use the slug of each app.",
"name": "[%key:component::hassio::services::backup_partial::fields::apps::name%]"
},
"folders": {
"description": "List of directories to restore from the backup.",
"name": "[%key:component::hassio::services::backup_partial::fields::folders::name%]"

View File

@@ -490,10 +490,17 @@ async def test_warn_when_cannot_connect(
async def test_service_register(hass: HomeAssistant) -> None:
"""Check if service will be setup."""
assert await async_setup_component(hass, "hassio", {})
# New app services
assert hass.services.has_service("hassio", "app_start")
assert hass.services.has_service("hassio", "app_stop")
assert hass.services.has_service("hassio", "app_restart")
assert hass.services.has_service("hassio", "app_stdin")
# Legacy addon services (deprecated)
assert hass.services.has_service("hassio", "addon_start")
assert hass.services.has_service("hassio", "addon_stop")
assert hass.services.has_service("hassio", "addon_restart")
assert hass.services.has_service("hassio", "addon_stdin")
# Other services
assert hass.services.has_service("hassio", "host_shutdown")
assert hass.services.has_service("hassio", "host_reboot")
assert hass.services.has_service("hassio", "host_reboot")
@@ -503,14 +510,18 @@ async def test_service_register(hass: HomeAssistant) -> None:
assert hass.services.has_service("hassio", "restore_partial")
@pytest.mark.parametrize(
"app_or_addon",
["app", "addon"],
)
@pytest.mark.freeze_time("2021-11-13 11:48:00")
async def test_service_calls(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
caplog: pytest.LogCaptureFixture,
supervisor_client: AsyncMock,
addon_installed: AsyncMock,
supervisor_is_connected: AsyncMock,
app_or_addon: str,
) -> None:
"""Call service and check the API calls behind that."""
supervisor_is_connected.side_effect = SupervisorError
@@ -534,11 +545,17 @@ async def test_service_calls(
"http://127.0.0.1/backups/test/restore/partial", json={"result": "ok"}
)
await hass.services.async_call("hassio", "addon_start", {"addon": "test"})
await hass.services.async_call("hassio", "addon_stop", {"addon": "test"})
await hass.services.async_call("hassio", "addon_restart", {"addon": "test"})
await hass.services.async_call(
"hassio", "addon_stdin", {"addon": "test", "input": "test"}
"hassio", f"{app_or_addon}_start", {app_or_addon: "test"}
)
await hass.services.async_call(
"hassio", f"{app_or_addon}_stop", {app_or_addon: "test"}
)
await hass.services.async_call(
"hassio", f"{app_or_addon}_restart", {app_or_addon: "test"}
)
await hass.services.async_call(
"hassio", f"{app_or_addon}_stdin", {app_or_addon: "test", "input": "test"}
)
await hass.async_block_till_done()
@@ -557,7 +574,7 @@ async def test_service_calls(
"backup_partial",
{
"homeassistant": True,
"addons": ["test"],
"apps": ["test"],
"folders": ["ssl"],
"password": "123456",
},
@@ -565,6 +582,7 @@ async def test_service_calls(
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 28
# API receives "addons" even when we pass "apps"
assert aioclient_mock.mock_calls[-1][2] == {
"name": "2021-11-13 03:48:00",
"homeassistant": True,
@@ -582,7 +600,7 @@ async def test_service_calls(
{
"slug": "test",
"homeassistant": False,
"addons": ["test"],
"apps": ["test"],
"folders": ["ssl"],
"password": "123456",
},
@@ -590,6 +608,7 @@ async def test_service_calls(
await hass.async_block_till_done()
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 30
# API receives "addons" even when we pass "apps"
assert aioclient_mock.mock_calls[-1][2] == {
"addons": ["test"],
"folders": ["ssl"],
@@ -650,10 +669,15 @@ async def test_service_calls(
}
@pytest.mark.parametrize(
"app_or_addon",
["app", "addon"],
)
async def test_invalid_service_calls(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
supervisor_is_connected: AsyncMock,
app_or_addon: str,
) -> None:
"""Call service with invalid input and check that it raises."""
supervisor_is_connected.side_effect = SupervisorError
@@ -663,18 +687,57 @@ async def test_invalid_service_calls(
with pytest.raises(Invalid):
await hass.services.async_call(
"hassio", "addon_start", {"addon": "does_not_exist"}
"hassio", f"{app_or_addon}_start", {app_or_addon: "does_not_exist"}
)
with pytest.raises(Invalid):
await hass.services.async_call(
"hassio", "addon_stdin", {"addon": "does_not_exist", "input": "test"}
"hassio",
f"{app_or_addon}_stdin",
{app_or_addon: "does_not_exist", "input": "test"},
)
@pytest.mark.parametrize(
("service", "service_data"),
[
(
"backup_partial",
{"apps": ["test"], "addons": ["test"]},
),
(
"restore_partial",
{"apps": ["test"], "addons": ["test"], "slug": "test"},
),
],
)
@pytest.mark.usefixtures("addon_installed")
async def test_service_calls_apps_addons_exclusive(
hass: HomeAssistant,
supervisor_is_connected: AsyncMock,
service: str,
service_data: dict[str, Any],
) -> None:
"""Test that apps and addons parameters are mutually exclusive."""
supervisor_is_connected.side_effect = SupervisorError
with patch.dict(os.environ, MOCK_ENVIRON):
assert await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
with pytest.raises(
Invalid, match="two or more values in the same group of exclusion"
):
await hass.services.async_call("hassio", service, service_data)
@pytest.mark.parametrize(
"app_or_addon",
["app", "addon"],
)
async def test_addon_service_call_with_complex_slug(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
supervisor_is_connected: AsyncMock,
app_or_addon: str,
) -> None:
"""Addon slugs can have ., - and _, confirm that passes validation."""
supervisor_mock_data = {
@@ -705,7 +768,9 @@ async def test_addon_service_call_with_complex_slug(
assert await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
await hass.services.async_call("hassio", "addon_start", {"addon": "test.a_1-2"})
await hass.services.async_call(
"hassio", f"{app_or_addon}_start", {app_or_addon: "test.a_1-2"}
)
@pytest.mark.usefixtures("hassio_env")