Add binary sensor for HassOS share mount status (#149197)

This commit is contained in:
Amit Finkelstein
2025-11-04 12:14:10 +02:00
committed by GitHub
parent 5012aa5cb0
commit b706430e66
6 changed files with 249 additions and 18 deletions
@@ -3,6 +3,9 @@
from __future__ import annotations
from dataclasses import dataclass
import itertools
from aiohasupervisor.models.mounts import MountState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -13,8 +16,14 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
from .entity import HassioAddonEntity
from .const import (
ADDONS_COORDINATOR,
ATTR_STARTED,
ATTR_STATE,
DATA_KEY_ADDONS,
DATA_KEY_MOUNTS,
)
from .entity import HassioAddonEntity, HassioMountEntity
@dataclass(frozen=True)
@@ -34,6 +43,16 @@ ADDON_ENTITY_DESCRIPTIONS = (
),
)
MOUNT_ENTITY_DESCRIPTIONS = (
HassioBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_registry_enabled_default=False,
key=ATTR_STATE,
translation_key="mount",
target=MountState.ACTIVE.value,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -44,13 +63,26 @@ async def async_setup_entry(
coordinator = hass.data[ADDONS_COORDINATOR]
async_add_entities(
HassioAddonBinarySensor(
addon=addon,
coordinator=coordinator,
entity_description=entity_description,
itertools.chain(
[
HassioAddonBinarySensor(
addon=addon,
coordinator=coordinator,
entity_description=entity_description,
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
],
[
HassioMountBinarySensor(
mount=mount,
coordinator=coordinator,
entity_description=entity_description,
)
for mount in coordinator.data[DATA_KEY_MOUNTS].values()
for entity_description in MOUNT_ENTITY_DESCRIPTIONS
],
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
)
@@ -68,3 +100,20 @@ class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity):
if self.entity_description.target is None:
return value
return value == self.entity_description.target
class HassioMountBinarySensor(HassioMountEntity, BinarySensorEntity):
"""Binary sensor for Hass.io mount."""
entity_description: HassioBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
value = getattr(
self.coordinator.data[DATA_KEY_MOUNTS][self._mount.name],
self.entity_description.key,
)
if self.entity_description.target is None:
return value
return value == self.entity_description.target
+3
View File
@@ -90,6 +90,7 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info"
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats"
DATA_MOUNTS_INFO = "hassio_mounts_info"
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
ATTR_AUTO_UPDATE = "auto_update"
@@ -110,6 +111,7 @@ DATA_KEY_SUPERVISOR = "supervisor"
DATA_KEY_CORE = "core"
DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
DATA_KEY_MOUNTS = "mounts"
PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
@@ -174,3 +176,4 @@ class SupervisorEntityModel(StrEnum):
CORE = "Home Assistant Core"
SUPERVISOR = "Home Assistant Supervisor"
HOST = "Home Assistant Host"
MOUNT = "Home Assistant Mount"
+67 -9
View File
@@ -10,6 +10,11 @@ from typing import TYPE_CHECKING, Any
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
from aiohasupervisor.models import StoreInfo
from aiohasupervisor.models.mounts import (
CIFSMountResponse,
MountsInfo,
NFSMountResponse,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
@@ -41,9 +46,11 @@ from .const import (
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DATA_KEY_SUPERVISOR_ISSUES,
DATA_MOUNTS_INFO,
DATA_NETWORK_INFO,
DATA_OS_INFO,
DATA_STORE,
@@ -174,6 +181,16 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None:
return hass.data.get(DATA_CORE_INFO)
@callback
@bind_hass
def get_mounts_info(hass: HomeAssistant) -> MountsInfo | None:
"""Return Home Assistant mounts information from Supervisor.
Async friendly.
"""
return hass.data.get(DATA_MOUNTS_INFO)
@callback
@bind_hass
def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
@@ -203,6 +220,25 @@ def async_register_addons_in_dev_reg(
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
@callback
def async_register_mounts_in_dev_reg(
entry_id: str,
dev_reg: dr.DeviceRegistry,
mounts: list[CIFSMountResponse | NFSMountResponse],
) -> None:
"""Register mounts in the device registry."""
for mount in mounts:
params = DeviceInfo(
identifiers={(DOMAIN, f"mount_{mount.name}")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.MOUNT,
model_id=f"{mount.usage}/{mount.type}",
name=mount.name,
entry_type=dr.DeviceEntryType.SERVICE,
)
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
@callback
def async_register_os_in_dev_reg(
entry_id: str, dev_reg: dr.DeviceRegistry, os_dict: dict[str, Any]
@@ -272,12 +308,12 @@ def async_register_supervisor_in_dev_reg(
@callback
def async_remove_addons_from_dev_reg(
dev_reg: dr.DeviceRegistry, addons: set[str]
def async_remove_devices_from_dev_reg(
dev_reg: dr.DeviceRegistry, devices: set[str]
) -> None:
"""Remove addons from the device registry."""
for addon_slug in addons:
if dev := dev_reg.async_get_device(identifiers={(DOMAIN, addon_slug)}):
"""Remove devices from the device registry."""
for device in devices:
if dev := dev_reg.async_get_device(identifiers={(DOMAIN, device)}):
dev_reg.async_remove_device(dev.id)
@@ -362,12 +398,19 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
**get_supervisor_stats(self.hass),
}
new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {}
new_data[DATA_KEY_MOUNTS] = {
mount.name: mount
for mount in getattr(get_mounts_info(self.hass), "mounts", [])
}
# If this is the initial refresh, register all addons and return the dict
if is_first_update:
async_register_addons_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values()
)
async_register_mounts_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_MOUNTS].values()
)
async_register_core_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE]
)
@@ -389,7 +432,20 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
if device.model == SupervisorEntityModel.ADDON
}
if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
async_remove_addons_from_dev_reg(self.dev_reg, stale_addons)
async_remove_devices_from_dev_reg(self.dev_reg, stale_addons)
# Remove mounts that no longer exists from device registry
supervisor_mount_devices = {
device.name
for device in self.dev_reg.devices.get_devices_for_config_entry_id(
self.entry_id
)
if device.model == SupervisorEntityModel.MOUNT
}
if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]):
async_remove_devices_from_dev_reg(
self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts}
)
if not self.is_hass_os and (
dev := self.dev_reg.async_get_device(identifiers={(DOMAIN, "OS")})
@@ -397,11 +453,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
# Remove the OS device if it exists and the installation is not hassos
self.dev_reg.async_remove_device(dev.id)
# If there are new add-ons, we should reload the config entry so we can
# If there are new add-ons or mounts, we should reload the config entry so we can
# create new devices and entities. We can return an empty dict because
# coordinator will be recreated.
if self.data and set(new_data[DATA_KEY_ADDONS]) - set(
self.data[DATA_KEY_ADDONS]
if self.data and (
set(new_data[DATA_KEY_ADDONS]) - set(self.data[DATA_KEY_ADDONS])
or set(new_data[DATA_KEY_MOUNTS]) - set(self.data[DATA_KEY_MOUNTS])
):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
@@ -428,6 +485,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
DATA_CORE_INFO: hassio.get_core_info(),
DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(),
DATA_OS_INFO: hassio.get_os_info(),
DATA_MOUNTS_INFO: self.supervisor_client.mounts.info(),
}
if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
updates[DATA_CORE_STATS] = hassio.get_core_stats()
+34
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -15,6 +17,7 @@ from .const import (
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DOMAIN,
@@ -192,3 +195,34 @@ class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
)
if CONTAINER_STATS in update_types:
await self.coordinator.async_request_refresh()
class HassioMountEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
"""Base Entity for Mount."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioDataUpdateCoordinator,
entity_description: EntityDescription,
mount: CIFSMountResponse | NFSMountResponse,
) -> None:
"""Initialize base entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = (
f"home_assistant_mount_{mount.name}_{entity_description.key}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"mount_{mount.name}")}
)
self._mount = mount
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and self._mount.name in self.coordinator.data[DATA_KEY_MOUNTS]
)
@@ -3,6 +3,9 @@
"binary_sensor": {
"state": {
"name": "Running"
},
"mount": {
"name": "Connected"
}
},
"sensor": {
+85 -1
View File
@@ -1,18 +1,22 @@
"""The tests for the hassio binary sensors."""
from dataclasses import replace
from datetime import timedelta
import os
from unittest.mock import AsyncMock, patch
from aiohasupervisor.models.mounts import CIFSMountResponse, MountsInfo, MountState
import pytest
from homeassistant.components.hassio import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS
from tests.common import MockConfigEntry
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"}
@@ -91,6 +95,7 @@ def mock_all(
"version_latest": "2.0.1",
"repository": "core",
"url": "https://github.com/home-assistant/addons/test",
"icon": False,
},
{
"name": "test2",
@@ -102,6 +107,7 @@ def mock_all(
"version_latest": "3.1.0",
"repository": "core",
"url": "https://github.com",
"icon": False,
},
],
},
@@ -198,3 +204,81 @@ async def test_binary_sensor(
# Verify that the entity have the expected state.
state = hass.states.get(entity_id)
assert state.state == expected
async def test_mount_binary_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
supervisor_client: AsyncMock,
) -> None:
"""Test hassio mounts binary sensor."""
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
config_entry.add_to_hass(hass)
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(
hass,
"hassio",
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
)
assert result
await hass.async_block_till_done()
entity_id = "binary_sensor.nas_connected"
# Verify that the entity doesn't exist.
assert hass.states.get(entity_id) is None
# Add a mount.
mock_mounts = [
CIFSMountResponse(
share="files",
server="1.2.3.4",
name="NAS",
type="cifs",
usage="share",
read_only=False,
state=MountState.ACTIVE,
user_path="/share/nas",
)
]
supervisor_client.mounts.info = AsyncMock(
return_value=MountsInfo(default_backup_mount=None, mounts=mock_mounts)
)
# Let it reload.
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1000))
await hass.async_block_till_done(wait_background_tasks=True)
# Verify that the entity is disabled by default.
assert hass.states.get(entity_id) is None
# Enable the entity.
entity_registry.async_update_entity(entity_id, disabled_by=None)
await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
# Test new entity.
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == "on"
# Change state and test again.
mock_mounts[0] = replace(mock_mounts[0], state=MountState.FAILED)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1000))
await hass.async_block_till_done(wait_background_tasks=True)
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == "off"
# Remove mount and test again.
mount = mock_mounts.pop()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1000))
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id) is None
# Recreate mount with the same name.
mock_mounts.append(mount)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1000))
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id) is not None