1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add HassOS "mount_reload" action (#155996)

Co-authored-by: Shay Levy <levyshay1@gmail.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Amit Finkelstein
2026-02-26 09:04:58 +02:00
committed by GitHub
parent eaae64fa12
commit 31f7961437
5 changed files with 247 additions and 1 deletions
@@ -21,6 +21,7 @@ from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_NAME,
EVENT_CORE_CONFIG_UPDATE,
HASSIO_USER_NAME,
@@ -34,11 +35,13 @@ from homeassistant.core import (
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
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
@@ -92,6 +95,7 @@ from .const import (
DATA_SUPERVISOR_INFO,
DOMAIN,
HASSIO_UPDATE_INTERVAL,
SupervisorEntityModel,
)
from .coordinator import (
HassioDataUpdateCoordinator,
@@ -147,6 +151,7 @@ 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]+$"))
@@ -229,6 +234,19 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
}
)
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")
@@ -444,6 +462,42 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
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
)
async def update_info_data(_: datetime | None = None) -> None:
"""Update last available supervisor information."""
supervisor_client = get_supervisor_client(hass)
@@ -46,6 +46,9 @@
"host_shutdown": {
"service": "mdi:power"
},
"mount_reload": {
"service": "mdi:reload"
},
"restore_full": {
"service": "mdi:backup-restore"
},
@@ -165,3 +165,13 @@ restore_partial:
example: "password"
selector:
text:
mount_reload:
fields:
device_id:
required: true
selector:
device:
filter:
integration: hassio
model: Home Assistant Mount
@@ -43,6 +43,17 @@
}
}
},
"exceptions": {
"mount_reload_error": {
"message": "Failed to reload mount {name}: {error}"
},
"mount_reload_invalid_device": {
"message": "Device is not a supervisor mount point"
},
"mount_reload_unknown_device_id": {
"message": "Device ID not found"
}
},
"issues": {
"issue_addon_boot_fail": {
"fix_flow": {
@@ -456,6 +467,16 @@
"description": "Powers off the host system.",
"name": "Power off the host system"
},
"mount_reload": {
"description": "Reloads a network storage mount.",
"fields": {
"device_id": {
"description": "The device ID of the network storage mount to reload.",
"name": "Device ID"
}
},
"name": "Reload network storage mount"
},
"restore_full": {
"description": "Restores from full backup.",
"fields": {
+159 -1
View File
@@ -2,17 +2,25 @@
from datetime import timedelta
import os
from pathlib import PurePath
from typing import Any
from unittest.mock import AsyncMock, patch
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import AddonsStats
from aiohasupervisor.models.mounts import (
CIFSMountResponse,
MountsInfo,
MountState,
MountType,
MountUsage,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from voluptuous import Invalid
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components import frontend
from homeassistant.components import frontend, hassio
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.hassio import (
ADDONS_COORDINATOR,
@@ -31,10 +39,12 @@ from homeassistant.components.homeassistant import (
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.hassio import is_hassio
from homeassistant.setup import async_setup_component
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
@@ -514,6 +524,7 @@ async def test_service_register(hass: HomeAssistant) -> None:
assert hass.services.has_service("hassio", "backup_partial")
assert hass.services.has_service("hassio", "restore_full")
assert hass.services.has_service("hassio", "restore_partial")
assert hass.services.has_service("hassio", "mount_reload")
@pytest.mark.parametrize(
@@ -1484,3 +1495,150 @@ async def test_deprecated_installation_issue_supported_board(
await hass.async_block_till_done()
assert len(issue_registry.issues) == 0
async def mount_reload_test_setup(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
supervisor_client: AsyncMock,
) -> dr.DeviceEntry:
"""Set up mount reload test and return the device entry."""
supervisor_client.mounts.info = AsyncMock(
return_value=MountsInfo(
default_backup_mount=None,
mounts=[
CIFSMountResponse(
share="files",
server="1.2.3.4",
name="NAS",
type=MountType.CIFS,
usage=MountUsage.SHARE,
read_only=False,
state=MountState.ACTIVE,
user_path=PurePath("/share/nas"),
)
],
)
)
with patch.dict(os.environ, MOCK_ENVIRON):
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, "mount_NAS")})
assert device is not None
return device
async def test_mount_reload_action(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
supervisor_client: AsyncMock,
) -> None:
"""Test reload_mount service call."""
device = await mount_reload_test_setup(hass, device_registry, supervisor_client)
await hass.services.async_call(
"hassio", "mount_reload", {"device_id": device.id}, blocking=True
)
supervisor_client.mounts.reload_mount.assert_awaited_once_with("NAS")
async def test_mount_reload_action_failure(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
supervisor_client: AsyncMock,
) -> None:
"""Test reload_mount service call failure."""
device = await mount_reload_test_setup(hass, device_registry, supervisor_client)
supervisor_client.mounts.reload_mount = AsyncMock(
side_effect=SupervisorError("test failure")
)
with pytest.raises(HomeAssistantError) as exc:
await hass.services.async_call(
"hassio", "mount_reload", {"device_id": device.id}, blocking=True
)
assert str(exc.value) == "Failed to reload mount NAS: test failure"
async def test_mount_reload_unknown_device_id(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
supervisor_client: AsyncMock,
) -> None:
"""Test reload_mount with unknown device ID."""
await mount_reload_test_setup(hass, device_registry, supervisor_client)
with pytest.raises(ServiceValidationError) as exc:
await hass.services.async_call(
"hassio", "mount_reload", {"device_id": "1234"}, blocking=True
)
assert str(exc.value) == "Device ID not found"
async def test_mount_reload_no_name(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
supervisor_client: AsyncMock,
) -> None:
"""Test reload_mount with an unnamed device."""
device = await mount_reload_test_setup(hass, device_registry, supervisor_client)
device_registry.async_update_device(device.id, name=None)
with pytest.raises(ServiceValidationError) as exc:
await hass.services.async_call(
"hassio", "mount_reload", {"device_id": device.id}, blocking=True
)
assert str(exc.value) == "Device is not a supervisor mount point"
async def test_mount_reload_invalid_model(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
supervisor_client: AsyncMock,
) -> None:
"""Test reload_mount with an invalid model."""
device = await mount_reload_test_setup(hass, device_registry, supervisor_client)
device_registry.async_update_device(device.id, model=None)
with pytest.raises(ServiceValidationError) as exc:
await hass.services.async_call(
"hassio", "mount_reload", {"device_id": device.id}, blocking=True
)
assert str(exc.value) == "Device is not a supervisor mount point"
async def test_mount_reload_not_supervisor_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
supervisor_client: AsyncMock,
) -> None:
"""Test reload_mount with a device not belonging to the supervisor."""
device = await mount_reload_test_setup(hass, device_registry, supervisor_client)
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "test")},
name=device.name,
model=device.model,
)
with pytest.raises(ServiceValidationError) as exc:
await hass.services.async_call(
"hassio", "mount_reload", {"device_id": device2.id}, blocking=True
)
assert str(exc.value) == "Device is not a supervisor mount point"
async def test_mount_reload_selector_matches_device_name(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
supervisor_client: AsyncMock,
) -> None:
"""Test that the model name in the selector of mount reload is valid."""
device = await mount_reload_test_setup(hass, device_registry, supervisor_client)
services = load_yaml_dict(f"{hassio.__path__[0]}/services.yaml")
assert (
services["mount_reload"]["fields"]["device_id"]["selector"]["device"]["filter"][
"model"
]
== device.model
)