1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-14 12:31:04 +01:00

Refactor hassio coordinators to use typed dataclasses instead of dicts (#168847)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Mike Degatano
2026-05-04 14:24:33 -04:00
committed by GitHub
parent fcd23353f2
commit 6f28902a4f
11 changed files with 678 additions and 314 deletions
@@ -1,8 +1,9 @@
"""Binary sensor platform for Hass.io addons."""
from collections.abc import Callable
from dataclasses import dataclass
import itertools
from aiohasupervisor.models import AddonState
from aiohasupervisor.models.mounts import MountState
from homeassistant.components.binary_sensor import (
@@ -14,41 +15,46 @@ 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,
DATA_KEY_MOUNTS,
MAIN_COORDINATOR,
)
from .const import ADDONS_COORDINATOR, MAIN_COORDINATOR
from .entity import HassioAddonEntity, HassioMountEntity
@dataclass(frozen=True)
class HassioBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Hassio binary sensor entity description."""
@dataclass(frozen=True, kw_only=True)
class HassioAddonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Hass.io add-on binary sensor entity description."""
target: str | None = None
value_fn: Callable[[HassioAddonBinarySensor], bool]
@dataclass(frozen=True, kw_only=True)
class HassioMountBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Hass.io mount binary sensor entity description."""
value_fn: Callable[[HassioMountBinarySensor], bool]
ADDON_ENTITY_DESCRIPTIONS = (
HassioBinarySensorEntityDescription(
HassioAddonBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.RUNNING,
entity_registry_enabled_default=False,
key=ATTR_STATE,
key="state",
translation_key="state",
target=ATTR_STARTED,
value_fn=lambda entity: (
entity.coordinator.data.addons[entity.addon_slug].addon.state
== AddonState.STARTED
),
),
)
MOUNT_ENTITY_DESCRIPTIONS = (
HassioBinarySensorEntityDescription(
HassioMountBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_registry_enabled_default=False,
key=ATTR_STATE,
key="state",
translation_key="mount",
target=MountState.ACTIVE.value,
value_fn=lambda entity: (
entity.coordinator.data.mounts[entity.mount_name].state == MountState.ACTIVE
),
),
)
@@ -63,57 +69,46 @@ async def async_setup_entry(
coordinator = hass.data[MAIN_COORDINATOR]
async_add_entities(
itertools.chain(
[
[
*[
HassioAddonBinarySensor(
addon=addon,
coordinator=addons_coordinator,
entity_description=entity_description,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for addon in addons_coordinator.data.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 mount in coordinator.data.mounts.values()
for entity_description in MOUNT_ENTITY_DESCRIPTIONS
],
)
]
)
class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity):
"""Binary sensor for Hass.io add-ons."""
entity_description: HassioBinarySensorEntityDescription
entity_description: HassioAddonBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
value = self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][
self.entity_description.key
]
if self.entity_description.target is None:
return value
return value == self.entity_description.target
return self.entity_description.value_fn(self)
class HassioMountBinarySensor(HassioMountEntity, BinarySensorEntity):
"""Binary sensor for Hass.io mount."""
entity_description: HassioBinarySensorEntityDescription
entity_description: HassioMountBinarySensorEntityDescription
@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
return self.entity_description.value_fn(self)
+8 -2
View File
@@ -8,9 +8,11 @@ from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from aiohasupervisor.models import (
AddonsStats,
HomeAssistantInfo,
HostInfo,
InstalledAddon,
InstalledAddonComplete,
NetworkInfo,
OSInfo,
RootInfo,
@@ -112,8 +114,12 @@ DATA_OS_INFO: HassKey[OSInfo] = HassKey("hassio_os_info")
DATA_NETWORK_INFO: HassKey[NetworkInfo] = HassKey("hassio_network_info")
DATA_SUPERVISOR_INFO: HassKey[SupervisorInfo] = HassKey("hassio_supervisor_info")
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats"
DATA_ADDONS_INFO: HassKey[dict[str, InstalledAddonComplete | None]] = HassKey(
"hassio_addons_info"
)
DATA_ADDONS_STATS: HassKey[dict[str, AddonsStats | None]] = HassKey(
"hassio_addons_stats"
)
DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list")
HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5)
HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15)
+223 -109
View File
@@ -3,17 +3,20 @@
import asyncio
from collections import defaultdict
from collections.abc import Awaitable
from copy import deepcopy
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, Any, cast
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
from aiohasupervisor.models import (
AddonsStats,
AddonState,
CIFSMountResponse,
HomeAssistantInfo,
HomeAssistantStats,
HostInfo,
InstalledAddon,
InstalledAddonComplete,
NetworkInfo,
NFSMountResponse,
OSInfo,
@@ -21,10 +24,11 @@ from aiohasupervisor.models import (
RootInfo,
StoreInfo,
SupervisorInfo,
SupervisorStats,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
from homeassistant.const import ATTR_MANUFACTURER
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
@@ -34,15 +38,10 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
ATTR_ADDONS,
ATTR_AUTO_UPDATE,
ATTR_DATA,
ATTR_REPOSITORIES,
ATTR_REPOSITORY,
ATTR_SLUG,
ATTR_STARTUP,
ATTR_UPDATE_KEY,
ATTR_URL,
ATTR_VERSION,
ATTR_WS_EVENT,
CONTAINER_STATS,
CORE_CONTAINER,
@@ -53,12 +52,6 @@ from .const import (
DATA_CORE_STATS,
DATA_HOST_INFO,
DATA_INFO,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DATA_KEY_SUPERVISOR_ISSUES,
DATA_NETWORK_INFO,
DATA_OS_INFO,
@@ -86,6 +79,106 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
@dataclass
class HassioMainData:
"""Data class for HassioMainDataUpdateCoordinator."""
core: HomeAssistantInfo
supervisor: SupervisorInfo
host: HostInfo
mounts: dict[str, CIFSMountResponse | NFSMountResponse]
os: OSInfo | None
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the data."""
return {
"core": self.core.to_dict(),
"supervisor": self.supervisor.to_dict(),
"host": self.host.to_dict(),
"mounts": {name: mount.to_dict() for name, mount in self.mounts.items()},
"os": self.os.to_dict() if self.os is not None else None,
}
@dataclass
class AddonData:
"""Data for a single installed addon."""
addon: InstalledAddon
auto_update: bool
repository: str
@dataclass
class HassioAddonData:
"""Data class for HassioAddOnDataUpdateCoordinator."""
addons: dict[str, AddonData]
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the data."""
return {
"addons": {
slug: {
"addon": addon_data.addon.to_dict(),
"auto_update": addon_data.auto_update,
"repository": addon_data.repository,
}
for slug, addon_data in self.addons.items()
},
}
@dataclass
class HassioStatsData:
"""Data class for HassioStatsDataUpdateCoordinator."""
core: HomeAssistantStats | None
supervisor: SupervisorStats | None
addons: dict[str, AddonsStats | None]
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the data."""
return {
"core": self.core.to_dict() if self.core is not None else None,
"supervisor": (
self.supervisor.to_dict() if self.supervisor is not None else None
),
"addons": {
slug: stats.to_dict() if stats is not None else None
for slug, stats in self.addons.items()
},
}
def _installed_addon_from_complete(info: InstalledAddonComplete) -> InstalledAddon:
"""Build an InstalledAddon from an InstalledAddonComplete object.
InstalledAddonComplete contains a superset of InstalledAddon fields.
This helper extracts only the fields needed for InstalledAddon so fresh
data from an addon_info call can be stored in AddonData.addon.
"""
return InstalledAddon(
advanced=info.advanced,
available=info.available,
build=info.build,
description=info.description,
homeassistant=info.homeassistant,
icon=info.icon,
logo=info.logo,
name=info.name,
repository=info.repository,
slug=info.slug,
stage=info.stage,
update_available=info.update_available,
url=info.url,
version_latest=info.version_latest,
version=info.version,
detached=info.detached,
state=info.state,
)
@callback
def get_info(hass: HomeAssistant) -> dict[str, Any] | None:
"""Return generic information from Supervisor.
@@ -151,7 +244,25 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | N
Async friendly.
"""
return hass.data.get(DATA_ADDONS_INFO)
addons_info: dict[str, InstalledAddonComplete | None] | None = hass.data.get(
DATA_ADDONS_INFO
)
if addons_info is None:
return None
# Converting these fields for compatibility as that is what was returned here.
# We'll leave it this way as long as these component APIs continue to return
# dictionaries. If/when we switch to using the aiohasupervisor models for everything
# internally and externally that will be dropped.
return {
slug: dict(
hassio_api=info.supervisor_api,
hassio_role=info.supervisor_role,
**info.to_dict(),
)
if info is not None
else None
for slug, info in addons_info.items()
}
@callback
@@ -170,7 +281,11 @@ def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]:
Async friendly.
"""
return hass.data.get(DATA_ADDONS_STATS) or {}
addons_stats: dict[str, AddonsStats | None] = hass.data.get(DATA_ADDONS_STATS) or {}
return {
slug: stats.to_dict() if stats is not None else None
for slug, stats in addons_stats.items()
}
@callback
@@ -179,7 +294,8 @@ def get_core_stats(hass: HomeAssistant) -> dict[str, Any]:
Async friendly.
"""
return hass.data.get(DATA_CORE_STATS) or {}
stats = hass.data.get(DATA_CORE_STATS)
return stats.to_dict() if stats is not None else {}
@callback
@@ -188,7 +304,8 @@ def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]:
Async friendly.
"""
return hass.data.get(DATA_SUPERVISOR_STATS) or {}
stats = hass.data.get(DATA_SUPERVISOR_STATS)
return stats.to_dict() if stats is not None else {}
@callback
@@ -222,19 +339,20 @@ def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
@callback
def async_register_addons_in_dev_reg(
entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[dict[str, Any]]
entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[AddonData]
) -> None:
"""Register addons in the device registry."""
for addon in addons:
for addon_data in addons:
addon = addon_data.addon
params = DeviceInfo(
identifiers={(DOMAIN, addon[ATTR_SLUG])},
identifiers={(DOMAIN, addon.slug)},
model=SupervisorEntityModel.ADDON,
sw_version=addon[ATTR_VERSION],
name=addon[ATTR_NAME],
sw_version=addon.version,
name=addon.name,
entry_type=dr.DeviceEntryType.SERVICE,
configuration_url=f"homeassistant://hassio/addon/{addon[ATTR_SLUG]}",
configuration_url=f"homeassistant://hassio/addon/{addon.slug}",
)
if manufacturer := addon.get(ATTR_REPOSITORY) or addon.get(ATTR_URL):
if manufacturer := addon_data.repository or addon.url:
params[ATTR_MANUFACTURER] = manufacturer
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
@@ -260,14 +378,14 @@ def async_register_mounts_in_dev_reg(
@callback
def async_register_os_in_dev_reg(
entry_id: str, dev_reg: dr.DeviceRegistry, os_dict: dict[str, Any]
entry_id: str, dev_reg: dr.DeviceRegistry, os_info: OSInfo
) -> None:
"""Register OS in the device registry."""
params = DeviceInfo(
identifiers={(DOMAIN, "OS")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.OS,
sw_version=os_dict[ATTR_VERSION],
sw_version=os_info.version,
name="Home Assistant Operating System",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -294,14 +412,14 @@ def async_register_host_in_dev_reg(
def async_register_core_in_dev_reg(
entry_id: str,
dev_reg: dr.DeviceRegistry,
core_dict: dict[str, Any],
core_info: HomeAssistantInfo,
) -> None:
"""Register OS in the device registry."""
"""Register core in the device registry."""
params = DeviceInfo(
identifiers={(DOMAIN, "core")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.CORE,
sw_version=core_dict[ATTR_VERSION],
sw_version=core_info.version,
name="Home Assistant Core",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -312,14 +430,14 @@ def async_register_core_in_dev_reg(
def async_register_supervisor_in_dev_reg(
entry_id: str,
dev_reg: dr.DeviceRegistry,
supervisor_dict: dict[str, Any],
supervisor_info: SupervisorInfo,
) -> None:
"""Register OS in the device registry."""
"""Register supervisor in the device registry."""
params = DeviceInfo(
identifiers={(DOMAIN, "supervisor")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.SUPERVISOR,
sw_version=supervisor_dict[ATTR_VERSION],
sw_version=supervisor_info.version,
name="Home Assistant Supervisor",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -336,7 +454,7 @@ def async_remove_devices_from_dev_reg(
dev_reg.async_remove_device(dev.id)
class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]):
"""Class to retrieve Hass.io container stats."""
config_entry: ConfigEntry
@@ -358,18 +476,18 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
lambda: defaultdict(set)
)
async def _async_update_data(self) -> dict[str, Any]:
async def _async_update_data(self) -> HassioStatsData:
"""Update stats data via library."""
try:
await self._fetch_stats()
except SupervisorError as err:
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
new_data: dict[str, Any] = {}
new_data[DATA_KEY_CORE] = get_core_stats(self.hass)
new_data[DATA_KEY_SUPERVISOR] = get_supervisor_stats(self.hass)
new_data[DATA_KEY_ADDONS] = get_addons_stats(self.hass)
return new_data
return HassioStatsData(
core=self.hass.data.get(DATA_CORE_STATS),
supervisor=self.hass.data.get(DATA_SUPERVISOR_STATS),
addons=self.hass.data.get(DATA_ADDONS_STATS) or {},
)
async def _fetch_stats(self) -> None:
"""Fetch container stats for subscribed entities."""
@@ -387,7 +505,7 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if updates:
api_results: list[ResponseData] = await asyncio.gather(*updates.values())
for key, result in zip(updates, api_results, strict=True):
data[key] = result.to_dict()
data[key] = result
# Fetch addon stats
addons_list: list[InstalledAddon] = self.hass.data.get(DATA_ADDONS_LIST) or []
@@ -397,7 +515,9 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if addon.state in {AddonState.STARTED, AddonState.STARTUP}
}
addons_stats: dict[str, Any] = data.setdefault(DATA_ADDONS_STATS, {})
addons_stats: dict[str, AddonsStats | None] = data.setdefault(
DATA_ADDONS_STATS, {}
)
# Clean up cache for stopped/removed addons
for slug in addons_stats.keys() - started_addons:
@@ -415,14 +535,14 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
addons_stats.update(addon_stats_results)
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
async def _update_addon_stats(self, slug: str) -> tuple[str, AddonsStats | None]:
"""Update single addon stats."""
try:
stats = await self.supervisor_client.addons.addon_stats(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch stats for %s: %s", slug, err)
return (slug, None)
return (slug, stats.to_dict())
return (slug, stats)
@callback
def async_enable_container_updates(
@@ -445,7 +565,7 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return _remove
class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
"""Class to retrieve Hass.io Add-on status."""
config_entry: ConfigEntry
@@ -476,7 +596,7 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.supervisor_client = get_supervisor_client(hass)
self.jobs = jobs
async def _async_update_data(self) -> dict[str, Any]:
async def _async_update_data(self) -> HassioAddonData:
"""Update data via library."""
is_first_update = not self.data
client = self.supervisor_client
@@ -487,7 +607,7 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Fetch addon info for all addons on first update, or only
# for addons with subscribed entities on subsequent updates.
addon_info_results = dict(
addon_info_results: dict[str, InstalledAddonComplete | None] = dict(
await asyncio.gather(
*[
self._update_addon_info(slug)
@@ -503,39 +623,35 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.hass.data[DATA_ADDONS_LIST] = installed_addons
# Update addon info cache in hass.data
addon_info_cache: dict[str, Any] = self.hass.data.setdefault(
DATA_ADDONS_INFO, {}
)
addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {})
for slug in addon_info_cache.keys() - all_addons:
del addon_info_cache[slug]
addon_info_cache.update(addon_info_results)
# Build clean coordinator data
# Build repository name lookup from store data
store = self.hass.data.get(DATA_STORE)
if store:
repositories = {repo.slug: repo.name for repo in store.repositories}
else:
repositories = {}
repositories: dict[str, str] = (
{repo.slug: repo.name for repo in store.repositories} if store else {}
)
addons_list_dicts = [addon.to_dict() for addon in installed_addons]
new_data: dict[str, Any] = {}
new_data[DATA_KEY_ADDONS] = {
(slug := addon[ATTR_SLUG]): {
**addon,
ATTR_AUTO_UPDATE: (addon_info_cache.get(slug) or {}).get(
ATTR_AUTO_UPDATE, False
),
ATTR_REPOSITORY: repositories.get(
repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug
),
}
for addon in addons_list_dicts
}
# Build clean coordinator data
new_addons: dict[str, AddonData] = {}
for addon in installed_addons:
addon_info = addon_info_cache.get(addon.slug)
auto_update = addon_info.auto_update if addon_info is not None else False
repo_slug = addon.repository
repository = repositories.get(repo_slug, repo_slug)
new_addons[addon.slug] = AddonData(
addon=addon,
auto_update=auto_update,
repository=repository,
)
new_data = HassioAddonData(addons=new_addons)
# If this is the initial refresh, register all addons
if is_first_update:
async_register_addons_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values()
self.entry_id, self.dev_reg, list(new_data.addons.values())
)
# Remove add-ons that are no longer installed from device registry
@@ -546,19 +662,16 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
if device.model == SupervisorEntityModel.ADDON
}
if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
if stale_addons := supervisor_addon_devices - set(new_data.addons):
async_remove_devices_from_dev_reg(self.dev_reg, stale_addons)
# If there are new add-ons, we should reload the config entry so we can
# create new devices and entities. We can return an empty dict because
# create new devices and entities. We can return the new data 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.addons) - set(self.data.addons)):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
)
return {}
return new_data
@@ -569,18 +682,16 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except SupervisorNotFoundError:
return None
async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]:
async def _update_addon_info(
self, slug: str
) -> tuple[str, InstalledAddonComplete | None]:
"""Return the info for an addon."""
try:
info = await self.supervisor_client.addons.addon_info(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch info for %s: %s", slug, err)
return (slug, None)
# Translate to legacy hassio names for compatibility
info_dict = info.to_dict()
info_dict["hassio_api"] = info_dict.pop("supervisor_api")
info_dict["hassio_role"] = info_dict.pop("supervisor_role")
return (slug, info_dict)
return (slug, info)
@callback
def async_enable_addon_info_updates(
@@ -627,16 +738,26 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Force refresh of addon info data for a specific addon."""
try:
slug, info = await self._update_addon_info(addon_slug)
if info is not None and DATA_KEY_ADDONS in self.data:
if slug in self.data[DATA_KEY_ADDONS]:
data = deepcopy(self.data)
data[DATA_KEY_ADDONS][slug].update(info)
self.async_set_updated_data(data)
except SupervisorError as err:
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
return
if info is not None and self.data and slug in self.data.addons:
updated = AddonData(
addon=_installed_addon_from_complete(info),
auto_update=info.auto_update,
repository=self.data.addons[slug].repository,
)
self.async_set_updated_data(
HassioAddonData(addons={**self.data.addons, slug: updated})
)
# Update addon info cache in hass.data
addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {})
addon_info_cache[slug] = info
class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
"""Class to retrieve Hass.io status."""
config_entry: ConfigEntry
@@ -679,7 +800,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
):
self.config_entry.async_create_task(self.hass, self.async_request_refresh())
async def _async_update_data(self) -> dict[str, Any]:
async def _async_update_data(self) -> HassioMainData:
"""Update data via library."""
is_first_update = not self.data
client = self.supervisor_client
@@ -722,13 +843,13 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
# Build clean coordinator data
new_data: dict[str, Any] = {}
new_data[DATA_KEY_CORE] = core_info.to_dict()
new_data[DATA_KEY_SUPERVISOR] = supervisor_info.to_dict()
new_data[DATA_KEY_HOST] = host_info.to_dict()
new_data[DATA_KEY_MOUNTS] = {mount.name: mount for mount in mounts_info.mounts}
if self.is_hass_os:
new_data[DATA_KEY_OS] = os_info.to_dict()
new_data = HassioMainData(
core=core_info,
supervisor=supervisor_info,
host=host_info,
mounts={mount.name: mount for mount in mounts_info.mounts},
os=os_info if self.is_hass_os else None,
)
# Update hass.data for legacy accessor functions
self.hass.data[DATA_INFO] = info
@@ -742,19 +863,15 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# If this is the initial refresh, register all main components
if is_first_update:
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]
self.entry_id, self.dev_reg, list(new_data.mounts.values())
)
async_register_core_in_dev_reg(self.entry_id, self.dev_reg, new_data.core)
async_register_supervisor_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR]
self.entry_id, self.dev_reg, new_data.supervisor
)
async_register_host_in_dev_reg(self.entry_id, self.dev_reg)
if self.is_hass_os:
async_register_os_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_OS]
)
async_register_os_in_dev_reg(self.entry_id, self.dev_reg, os_info)
# Remove mounts that no longer exists from device registry
supervisor_mount_devices = {
@@ -764,7 +881,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
if device.model == SupervisorEntityModel.MOUNT
}
if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]):
if stale_mounts := supervisor_mount_devices - set(new_data.mounts):
async_remove_devices_from_dev_reg(
self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts}
)
@@ -776,15 +893,12 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.dev_reg.async_remove_device(dev.id)
# If there are new mounts, we should reload the config entry so we can
# create new devices and entities. We can return an empty dict because
# create new devices and entities. We can return the new data because
# coordinator will be recreated.
if self.data and (
set(new_data[DATA_KEY_MOUNTS]) - set(self.data.get(DATA_KEY_MOUNTS, {}))
):
if self.data and (set(new_data.mounts) - set(self.data.mounts)):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
)
return {}
return new_data
@@ -56,8 +56,8 @@ async def async_get_config_entry_diagnostics(
devices.append({"device": asdict(device), "entities": entities})
return {
"coordinator_data": coordinator.data,
"addons_coordinator_data": addons_coordinator.data,
"stats_coordinator_data": stats_coordinator.data,
"coordinator_data": coordinator.data.to_dict(),
"addons_coordinator_data": addons_coordinator.data.to_dict(),
"stats_coordinator_data": stats_coordinator.data.to_dict(),
"devices": devices,
}
+54 -74
View File
@@ -1,27 +1,20 @@
"""Base for Hass.io entities."""
from typing import Any
from collections.abc import Callable
from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse
from aiohasupervisor.models import CIFSMountResponse, HostInfo, NFSMountResponse, OSInfo
from aiohasupervisor.models.base import ContainerStats
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_SLUG,
CONTAINER_STATS,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DOMAIN,
)
from .const import CONTAINER_STATS, DOMAIN
from .coordinator import (
AddonData,
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
HassioStatsData,
HassioStatsDataUpdateCoordinator,
)
@@ -37,7 +30,7 @@ class HassioStatsEntity(CoordinatorEntity[HassioStatsDataUpdateCoordinator]):
entity_description: EntityDescription,
*,
container_id: str,
data_key: str,
stats_fn: Callable[[HassioStatsData], ContainerStats | None],
device_id: str,
unique_id_prefix: str,
) -> None:
@@ -45,27 +38,25 @@ class HassioStatsEntity(CoordinatorEntity[HassioStatsDataUpdateCoordinator]):
super().__init__(coordinator)
self.entity_description = entity_description
self._container_id = container_id
self._data_key = data_key
self._stats_fn = stats_fn
self._attr_unique_id = f"{unique_id_prefix}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
@property
def _stats(self) -> ContainerStats | None:
"""Return the stats object for this entity's container."""
return self._stats_fn(self.coordinator.data)
@property
def stats(self) -> ContainerStats:
"""Return the stats object, asserting it is available."""
assert self._stats is not None
return self._stats
@property
def available(self) -> bool:
"""Return True if entity is available."""
if self._data_key == DATA_KEY_ADDONS:
return (
super().available
and DATA_KEY_ADDONS in self.coordinator.data
and self.entity_description.key
in (
self.coordinator.data[DATA_KEY_ADDONS].get(self._container_id) or {}
)
)
return (
super().available
and self._data_key in self.coordinator.data
and self.entity_description.key in self.coordinator.data[self._data_key]
)
return super().available and self._stats is not None
async def async_added_to_hass(self) -> None:
"""Subscribe to stats updates."""
@@ -92,24 +83,31 @@ class HassioAddonEntity(CoordinatorEntity[HassioAddOnDataUpdateCoordinator]):
self,
coordinator: HassioAddOnDataUpdateCoordinator,
entity_description: EntityDescription,
addon: dict[str, Any],
addon: AddonData,
) -> None:
"""Initialize base entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._addon_slug = addon[ATTR_SLUG]
self._attr_unique_id = f"{addon[ATTR_SLUG]}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon[ATTR_SLUG])})
self._addon_slug = addon.addon.slug
self._attr_unique_id = f"{addon.addon.slug}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon.addon.slug)})
@property
def addon_slug(self) -> str:
"""Return the add-on slug."""
return self._addon_slug
@property
def addon_data(self) -> AddonData:
"""Return the add-on data, asserting it is available."""
data = self.coordinator.data
assert self._addon_slug in data.addons
return data.addons[self._addon_slug]
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_ADDONS in self.coordinator.data
and self.entity_description.key
in self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
)
return super().available and self._addon_slug in self.coordinator.data.addons
async def async_added_to_hass(self) -> None:
"""Subscribe to addon info updates."""
@@ -140,11 +138,13 @@ class HassioOSEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_OS in self.coordinator.data
and self.entity_description.key in self.coordinator.data[DATA_KEY_OS]
)
return super().available and self.coordinator.data.os is not None
@property
def os(self) -> OSInfo:
"""Return the OS info object, asserting it is available."""
assert self.coordinator.data.os is not None
return self.coordinator.data.os
class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
@@ -164,13 +164,10 @@ class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "host")})
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_HOST in self.coordinator.data
and self.entity_description.key in self.coordinator.data[DATA_KEY_HOST]
)
def host(self) -> HostInfo:
"""Return the host info, asserting it is available."""
assert self.coordinator.data.host is not None
return self.coordinator.data.host
class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
@@ -189,16 +186,6 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator])
self._attr_unique_id = f"home_assistant_supervisor_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "supervisor")})
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_SUPERVISOR in self.coordinator.data
and self.entity_description.key
in self.coordinator.data[DATA_KEY_SUPERVISOR]
)
class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
"""Base Entity for Core."""
@@ -216,15 +203,6 @@ class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
self._attr_unique_id = f"home_assistant_core_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "core")})
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_CORE in self.coordinator.data
and self.entity_description.key in self.coordinator.data[DATA_KEY_CORE]
)
class HassioMountEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
"""Base Entity for Mount."""
@@ -248,10 +226,12 @@ class HassioMountEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
)
self._mount = mount
@property
def mount_name(self) -> str:
"""Return the mount name."""
return self._mount.name
@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]
)
return super().available and self.mount_name in self.coordinator.data.mounts
+106 -44
View File
@@ -1,5 +1,10 @@
"""Sensor platform for Hass.io addons."""
from collections.abc import Callable
from dataclasses import dataclass
from aiohasupervisor.models.base import ContainerStats
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -15,19 +20,12 @@ from .const import (
ADDONS_COORDINATOR,
ATTR_CPU_PERCENT,
ATTR_MEMORY_PERCENT,
ATTR_SLUG,
ATTR_VERSION,
ATTR_VERSION_LATEST,
CORE_CONTAINER,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
MAIN_COORDINATOR,
STATS_COORDINATOR,
SUPERVISOR_CONTAINER,
)
from .coordinator import HassioStatsData
from .entity import (
HassioAddonEntity,
HassioHostEntity,
@@ -35,74 +33,125 @@ from .entity import (
HassioStatsEntity,
)
COMMON_ENTITY_DESCRIPTIONS = (
SensorEntityDescription(
@dataclass(frozen=True, kw_only=True)
class HassioAddonSensorEntityDescription(SensorEntityDescription):
"""Hass.io add-on sensor entity description."""
value_fn: Callable[[HassioAddonSensor], str | None]
@dataclass(frozen=True, kw_only=True)
class HassioStatsSensorEntityDescription(SensorEntityDescription):
"""Hass.io stats sensor entity description."""
value_fn: Callable[[HassioStatsSensor], float]
@dataclass(frozen=True, kw_only=True)
class HassioOSSensorEntityDescription(SensorEntityDescription):
"""Hass.io OS sensor entity description."""
value_fn: Callable[[HassioOSSensor], str | None]
@dataclass(frozen=True, kw_only=True)
class HassioHostSensorEntityDescription(SensorEntityDescription):
"""Hass.io host sensor entity description."""
value_fn: Callable[[HostSensor], str | float | None]
ADDON_ENTITY_DESCRIPTIONS = (
HassioAddonSensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_VERSION,
key="version",
translation_key="version",
value_fn=lambda entity: entity.addon_data.addon.version,
),
SensorEntityDescription(
HassioAddonSensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_VERSION_LATEST,
key="version_latest",
translation_key="version_latest",
value_fn=lambda entity: entity.addon_data.addon.version_latest,
),
)
STATS_ENTITY_DESCRIPTIONS = (
SensorEntityDescription(
HassioStatsSensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_CPU_PERCENT,
translation_key="cpu_percent",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.stats.cpu_percent,
),
SensorEntityDescription(
HassioStatsSensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_MEMORY_PERCENT,
translation_key="memory_percent",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.stats.memory_percent,
),
)
OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS
OS_ENTITY_DESCRIPTIONS = (
HassioOSSensorEntityDescription(
entity_registry_enabled_default=False,
key="version",
translation_key="version",
value_fn=lambda entity: entity.os.version,
),
HassioOSSensorEntityDescription(
entity_registry_enabled_default=False,
key="version_latest",
translation_key="version_latest",
value_fn=lambda entity: entity.os.version_latest,
),
)
HOST_ENTITY_DESCRIPTIONS = (
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="agent_version",
translation_key="agent_version",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.agent_version,
),
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="apparmor_version",
translation_key="apparmor_version",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.apparmor_version,
),
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="disk_total",
translation_key="disk_total",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.disk_total,
),
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="disk_used",
translation_key="disk_used",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.disk_used,
),
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="disk_free",
translation_key="disk_free",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.disk_free,
),
)
@@ -126,21 +175,32 @@ async def async_setup_entry(
coordinator=addons_coordinator,
entity_description=entity_description,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in COMMON_ENTITY_DESCRIPTIONS
for addon in addons_coordinator.data.addons.values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
)
# Add-on stats sensors (cpu_percent, memory_percent)
def stats_fn_factory(
addon_slug: str,
) -> Callable[[HassioStatsData], ContainerStats | None]:
"""Return a stats_fn for the given add-on slug."""
def stats_fn(data: HassioStatsData) -> ContainerStats | None:
"""Return the stats for the given add-on."""
return data.addons.get(addon_slug)
return stats_fn
entities.extend(
HassioStatsSensor(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=addon[ATTR_SLUG],
data_key=DATA_KEY_ADDONS,
device_id=addon[ATTR_SLUG],
unique_id_prefix=addon[ATTR_SLUG],
container_id=addon.addon.slug,
stats_fn=stats_fn_factory(addon.addon.slug),
device_id=addon.addon.slug,
unique_id_prefix=addon.addon.slug,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for addon in addons_coordinator.data.addons.values()
for entity_description in STATS_ENTITY_DESCRIPTIONS
)
@@ -150,7 +210,7 @@ async def async_setup_entry(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=CORE_CONTAINER,
data_key=DATA_KEY_CORE,
stats_fn=lambda data: data.core,
device_id="core",
unique_id_prefix="home_assistant_core",
)
@@ -163,7 +223,7 @@ async def async_setup_entry(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=SUPERVISOR_CONTAINER,
data_key=DATA_KEY_SUPERVISOR,
stats_fn=lambda data: data.supervisor,
device_id="supervisor",
unique_id_prefix="home_assistant_supervisor",
)
@@ -195,40 +255,42 @@ async def async_setup_entry(
class HassioAddonSensor(HassioAddonEntity, SensorEntity):
"""Sensor to track a Hass.io add-on attribute."""
entity_description: HassioAddonSensorEntityDescription
@property
def native_value(self) -> str:
def native_value(self) -> str | None:
"""Return native value of entity."""
return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][
self.entity_description.key
]
return self.entity_description.value_fn(self)
class HassioStatsSensor(HassioStatsEntity, SensorEntity):
"""Sensor to track container stats."""
entity_description: HassioStatsSensorEntityDescription
@property
def native_value(self) -> str:
def native_value(self) -> float:
"""Return native value of entity."""
if self._data_key == DATA_KEY_ADDONS:
return self.coordinator.data[DATA_KEY_ADDONS][self._container_id][
self.entity_description.key
]
return self.coordinator.data[self._data_key][self.entity_description.key]
return self.entity_description.value_fn(self)
class HassioOSSensor(HassioOSEntity, SensorEntity):
"""Sensor to track a Hass.io OS attribute."""
entity_description: HassioOSSensorEntityDescription
@property
def native_value(self) -> str:
def native_value(self) -> str | None:
"""Return native value of entity."""
return self.coordinator.data[DATA_KEY_OS][self.entity_description.key]
return self.entity_description.value_fn(self)
class HostSensor(HassioHostEntity, SensorEntity):
"""Sensor to track a host attribute."""
entity_description: HassioHostSensorEntityDescription
@property
def native_value(self) -> str:
def native_value(self) -> str | float | None:
"""Return native value of entity."""
return self.coordinator.data[DATA_KEY_HOST][self.entity_description.key]
return self.entity_description.value_fn(self)
+10 -10
View File
@@ -4,15 +4,15 @@ import logging
from typing import Any
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import AddonState
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
from .const import ADDONS_COORDINATOR
from .entity import HassioAddonEntity
from .handler import get_supervisor_client
@@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
ENTITY_DESCRIPTION = SwitchEntityDescription(
key=ATTR_STATE,
key="state",
name=None,
icon="mdi:puzzle",
entity_registry_enabled_default=False,
@@ -41,7 +41,7 @@ async def async_setup_entry(
coordinator=coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for addon in coordinator.data.addons.values()
)
@@ -49,19 +49,19 @@ class HassioAddonSwitch(HassioAddonEntity, SwitchEntity):
"""Switch for Hass.io add-ons."""
@property
def is_on(self) -> bool | None:
def is_on(self) -> bool:
"""Return true if the add-on is on."""
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
state = addon_data.get(self.entity_description.key)
return state == ATTR_STARTED
return (
self.coordinator.data.addons[self._addon_slug].addon.state
== AddonState.STARTED
)
@property
def entity_picture(self) -> str | None:
"""Return the icon of the add-on if any."""
if not self.available:
return None
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
if addon_data.get(ATTR_ICON):
if self.coordinator.data.addons[self._addon_slug].addon.icon:
return f"/api/hassio/addons/{self._addon_slug}/icon"
return None
+24 -32
View File
@@ -13,22 +13,12 @@ from homeassistant.components.update import (
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON, ATTR_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ADDONS_COORDINATOR,
ATTR_AUTO_UPDATE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
MAIN_COORDINATOR,
)
from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR
from .coordinator import AddonData
from .entity import (
HassioAddonEntity,
HassioCoreEntity,
@@ -78,7 +68,7 @@ async def async_setup_entry(
coordinator=addons_coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for addon in addons_coordinator.data.addons.values()
)
async_add_entities(entities)
@@ -108,29 +98,29 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
_version_before_update: str | None = None
@property
def _addon_data(self) -> dict:
def _addon_data(self) -> AddonData:
"""Return the add-on data."""
return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug]
return self.coordinator.data.addons[self._addon_slug]
@property
def auto_update(self) -> bool:
"""Return true if auto-update is enabled for the add-on."""
return self._addon_data[ATTR_AUTO_UPDATE]
return self._addon_data.auto_update
@property
def title(self) -> str | None:
"""Return the title of the update."""
return self._addon_data[ATTR_NAME]
return self._addon_data.addon.name
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
return self._addon_data[ATTR_VERSION_LATEST]
return self._addon_data.addon.version_latest
@property
def installed_version(self) -> str | None:
"""Version installed and in use."""
return self._addon_data[ATTR_VERSION]
return self._addon_data.addon.version
@property
def in_progress(self) -> bool | None:
@@ -144,7 +134,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
"""Return the icon of the add-on if any."""
if not self.available:
return None
if self._addon_data[ATTR_ICON]:
if self._addon_data.addon.icon:
return f"/api/hassio/addons/{self._addon_slug}/icon"
return None
@@ -236,14 +226,16 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
_attr_title = "Home Assistant Operating System"
@property
def latest_version(self) -> str:
def latest_version(self) -> str | None:
"""Return the latest version."""
return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION_LATEST]
assert self.coordinator.data.os is not None
return self.coordinator.data.os.version_latest
@property
def installed_version(self) -> str:
def installed_version(self) -> str | None:
"""Return the installed version."""
return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION]
assert self.coordinator.data.os is not None
return self.coordinator.data.os.version
@property
def entity_picture(self) -> str | None:
@@ -293,19 +285,19 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
return self._attr_in_progress
@property
def latest_version(self) -> str:
def latest_version(self) -> str | None:
"""Return the latest version."""
return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION_LATEST]
return self.coordinator.data.supervisor.version_latest
@property
def installed_version(self) -> str:
"""Return the installed version."""
return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION]
return self.coordinator.data.supervisor.version
@property
def auto_update(self) -> bool:
"""Return true if auto-update is enabled for supervisor."""
return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_AUTO_UPDATE]
return self.coordinator.data.supervisor.auto_update
@property
def release_url(self) -> str | None:
@@ -389,14 +381,14 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
_attr_title = "Home Assistant Core"
@property
def latest_version(self) -> str:
def latest_version(self) -> str | None:
"""Return the latest version."""
return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION_LATEST]
return self.coordinator.data.core.version_latest
@property
def installed_version(self) -> str:
def installed_version(self) -> str | None:
"""Return the installed version."""
return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION]
return self.coordinator.data.core.version
@property
def entity_picture(self) -> str | None:
+1
View File
@@ -120,6 +120,7 @@ def mock_addon_info(
supervisor_api=False,
supervisor_role="default",
icon=False,
auto_update=False,
)
addon_info.name = "test"
addon_info.to_dict = MethodType(
+207
View File
@@ -37,8 +37,18 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI
from homeassistant.components.hassio import (
ADDONS_COORDINATOR,
DOMAIN,
get_addons_info,
get_addons_list,
get_addons_stats,
get_core_info,
get_core_stats,
get_host_info,
get_info,
get_network_info,
get_os_info,
get_store,
get_supervisor_info,
get_supervisor_stats,
hostname_from_addon_slug,
)
from homeassistant.components.hassio.config import STORAGE_KEY
@@ -1522,3 +1532,200 @@ async def test_get_supervisor_info(hass: HomeAssistant) -> None:
assert "addons" in result
assert isinstance(result["addons"], list)
assert all(isinstance(addon, dict) for addon in result["addons"])
@pytest.mark.usefixtures("mock_all")
async def test_get_info(hass: HomeAssistant) -> None:
"""Test get_info returns serialized dict with expected values."""
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()
result = get_info(hass)
assert isinstance(result, dict)
assert result["supervisor"] == "222"
assert result["homeassistant"] == "0.110.0"
assert result["hassos"] == "1.2.3"
@pytest.mark.usefixtures("mock_all")
async def test_get_host_info(hass: HomeAssistant) -> None:
"""Test get_host_info returns serialized dict with expected values."""
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()
result = get_host_info(hass)
assert isinstance(result, dict)
assert result["chassis"] == "vm"
assert result["disk_total"] == 100.0
assert result["kernel"] == "4.19.0-6-amd64"
@pytest.mark.usefixtures("mock_all")
async def test_get_store(hass: HomeAssistant) -> None:
"""Test get_store returns serialized dict with expected values."""
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()
result = get_store(hass)
assert isinstance(result, dict)
assert "addons" in result
assert "repositories" in result
assert isinstance(result["addons"], list)
assert isinstance(result["repositories"], list)
@pytest.mark.usefixtures("mock_all")
async def test_get_network_info(hass: HomeAssistant) -> None:
"""Test get_network_info returns serialized dict with expected values."""
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()
result = get_network_info(hass)
assert isinstance(result, dict)
assert result["host_internet"] is True
assert result["supervisor_internet"] is True
assert isinstance(result["interfaces"], list)
@pytest.mark.usefixtures("mock_all")
async def test_get_addons_info(hass: HomeAssistant) -> None:
"""Test get_addons_info returns serialized dicts, not model objects."""
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()
result = get_addons_info(hass)
assert isinstance(result, dict)
assert "test" in result
assert isinstance(result["test"], dict)
assert result["test"]["slug"] == "test"
assert result["test"]["version"] == "1.0.0"
assert result["test"]["hassio_api"] is False
assert result["test"]["supervisor_api"] is False
assert result["test"]["hassio_role"] == "default"
assert result["test"]["supervisor_role"] == "default"
@pytest.mark.usefixtures("mock_all")
async def test_get_addons_list(hass: HomeAssistant) -> None:
"""Test get_addons_list returns a list of serialized dicts."""
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()
result = get_addons_list(hass)
assert isinstance(result, list)
assert all(isinstance(addon, dict) for addon in result)
slugs = {addon["slug"] for addon in result}
assert "test" in slugs
assert "test2" in slugs
@pytest.mark.usefixtures("mock_all", "entity_registry_enabled_by_default")
async def test_get_addons_stats(hass: HomeAssistant) -> None:
"""Test get_addons_stats returns serialized dicts, not model objects.
Both test addons are STOPPED in mock_all so no addon stats are fetched;
the result is an empty dict which is the correct return type.
"""
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()
result = get_addons_stats(hass)
assert isinstance(result, dict)
# All values must be plain dicts, never AddonsStats model objects
for stats in result.values():
assert isinstance(stats, dict)
@pytest.mark.usefixtures("mock_all", "entity_registry_enabled_by_default")
async def test_get_core_stats(hass: HomeAssistant) -> None:
"""Test get_core_stats returns serialized dict with expected values."""
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()
# Stats entities subscribe during setup and trigger a debounced refresh
async_fire_time_changed(
hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY)
)
await hass.async_block_till_done()
result = get_core_stats(hass)
assert isinstance(result, dict)
assert result["cpu_percent"] == 0.99
assert result["memory_percent"] == 4.59
@pytest.mark.usefixtures("mock_all", "entity_registry_enabled_by_default")
async def test_get_supervisor_stats(hass: HomeAssistant) -> None:
"""Test get_supervisor_stats returns serialized dict with expected values."""
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()
# Stats entities subscribe during setup and trigger a debounced refresh
async_fire_time_changed(
hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY)
)
await hass.async_block_till_done()
result = get_supervisor_stats(hass)
assert isinstance(result, dict)
assert result["cpu_percent"] == 0.99
assert result["memory_percent"] == 4.59
@pytest.mark.usefixtures("mock_all")
async def test_get_os_info(hass: HomeAssistant) -> None:
"""Test get_os_info returns serialized dict with expected values."""
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()
result = get_os_info(hass)
assert isinstance(result, dict)
assert result["version"] == "1.0.0"
assert result["version_latest"] == "1.0.0"
assert result["update_available"] is False
@pytest.mark.usefixtures("mock_all")
async def test_get_core_info(hass: HomeAssistant) -> None:
"""Test get_core_info returns serialized dict with expected values."""
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()
result = get_core_info(hass)
assert isinstance(result, dict)
assert result["version"] == "1.0.0"
assert result["version_latest"] == "1.0.0"
assert result["image"] == "homeassistant"
+7
View File
@@ -251,6 +251,13 @@ async def test_ip_ban_manager_never_started(
"os_info",
"store_info",
"supervisor_info",
"homeassistant_info",
"host_info",
"network_info",
"addons_list",
"addon_info",
"homeassistant_stats",
"supervisor_stats",
"ingress_panels",
)
async def test_access_from_supervisor_ip(