From b07adc03d2a2fa8dd771840a4d17edec3f74c19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Wed, 28 Jan 2026 18:08:52 +0100 Subject: [PATCH] Add services using "apps" instead of "addons" to hassio integration (#161689) Co-authored-by: Martin Hjelmare --- homeassistant/components/hassio/__init__.py | 44 ++++++++-- homeassistant/components/hassio/const.py | 2 + homeassistant/components/hassio/icons.json | 12 +++ homeassistant/components/hassio/services.yaml | 44 ++++++++++ homeassistant/components/hassio/strings.json | 58 ++++++++++++- tests/components/hassio/test_init.py | 85 ++++++++++++++++--- 6 files changed, 227 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 860f938ef35..9f164a3d8f1 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -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 diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index c99ba1b9bfd..8e9f291d8fe 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -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" diff --git a/homeassistant/components/hassio/icons.json b/homeassistant/components/hassio/icons.json index b5f382e7ac5..49111914c81 100644 --- a/homeassistant/components/hassio/icons.json +++ b/homeassistant/components/hassio/icons.json @@ -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" }, diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 43143fe6889..0d00255264e 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -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: diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 761c845bf6f..9fc452cfddd 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -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%]" diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index e4b8bb88e5f..7825ba19f91 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -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")