Create update coordinator for Systemmonitor (#106693)

This commit is contained in:
G Johansson 2024-01-17 02:07:55 +01:00 committed by GitHub
parent d5c1049bfe
commit d4f9ad9dd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 678 additions and 398 deletions

View file

@ -5,13 +5,37 @@ DOMAIN = "systemmonitor"
CONF_INDEX = "index"
CONF_PROCESS = "process"
NETWORK_TYPES = [
NET_IO_TYPES = [
"network_in",
"network_out",
"throughput_network_in",
"throughput_network_out",
"packets_in",
"packets_out",
"ipv4_address",
"ipv6_address",
]
# There might be additional keys to be added for different
# platforms / hardware combinations.
# Taken from last version of "glances" integration before they moved to
# a generic temperature sensor logic.
# https://github.com/home-assistant/core/blob/5e15675593ba94a2c11f9f929cdad317e27ce190/homeassistant/components/glances/sensor.py#L199
CPU_SENSOR_PREFIXES = [
"amdgpu 1",
"aml_thermal",
"Core 0",
"Core 1",
"CPU Temperature",
"CPU",
"cpu-thermal 1",
"cpu_thermal 1",
"exynos-therm 1",
"Package id 0",
"Physical id 0",
"radeon 1",
"soc-thermal 1",
"soc_thermal 1",
"Tctl",
"cpu0-thermal",
"cpu0_thermal",
"k10temp 1",
]

View file

@ -0,0 +1,166 @@
"""DataUpdateCoordinators for the System monitor integration."""
from __future__ import annotations
from abc import abstractmethod
from datetime import datetime
import logging
import os
from typing import NamedTuple, TypeVar
import psutil
from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
class VirtualMemory(NamedTuple):
"""Represents virtual memory.
psutil defines virtual memory by platform.
Create our own definition here to be platform independent.
"""
total: float
available: float
percent: float
used: float
free: float
dataT = TypeVar(
"dataT",
bound=datetime
| dict[str, list[shwtemp]]
| dict[str, list[snicaddr]]
| dict[str, snetio]
| float
| list[psutil.Process]
| sswap
| VirtualMemory
| tuple[float, float, float]
| sdiskusage,
)
class MonitorCoordinator(DataUpdateCoordinator[dataT]):
"""A System monitor Base Data Update Coordinator."""
def __init__(self, hass: HomeAssistant, name: str) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"System Monitor {name}",
update_interval=DEFAULT_SCAN_INTERVAL,
always_update=False,
)
async def _async_update_data(self) -> dataT:
"""Fetch data."""
return await self.hass.async_add_executor_job(self.update_data)
@abstractmethod
def update_data(self) -> dataT:
"""To be extended by data update coordinators."""
class SystemMonitorDiskCoordinator(MonitorCoordinator[sdiskusage]):
"""A System monitor Disk Data Update Coordinator."""
def __init__(self, hass: HomeAssistant, name: str, argument: str) -> None:
"""Initialize the disk coordinator."""
super().__init__(hass, name)
self._argument = argument
def update_data(self) -> sdiskusage:
"""Fetch data."""
try:
return psutil.disk_usage(self._argument)
except PermissionError as err:
raise UpdateFailed(f"No permission to access {self._argument}") from err
except OSError as err:
raise UpdateFailed(f"OS error for {self._argument}") from err
class SystemMonitorSwapCoordinator(MonitorCoordinator[sswap]):
"""A System monitor Swap Data Update Coordinator."""
def update_data(self) -> sswap:
"""Fetch data."""
return psutil.swap_memory()
class SystemMonitorMemoryCoordinator(MonitorCoordinator[VirtualMemory]):
"""A System monitor Memory Data Update Coordinator."""
def update_data(self) -> VirtualMemory:
"""Fetch data."""
memory = psutil.virtual_memory()
return VirtualMemory(
memory.total, memory.available, memory.percent, memory.used, memory.free
)
class SystemMonitorNetIOCoordinator(MonitorCoordinator[dict[str, snetio]]):
"""A System monitor Network IO Data Update Coordinator."""
def update_data(self) -> dict[str, snetio]:
"""Fetch data."""
return psutil.net_io_counters(pernic=True)
class SystemMonitorNetAddrCoordinator(MonitorCoordinator[dict[str, list[snicaddr]]]):
"""A System monitor Network Address Data Update Coordinator."""
def update_data(self) -> dict[str, list[snicaddr]]:
"""Fetch data."""
return psutil.net_if_addrs()
class SystemMonitorLoadCoordinator(MonitorCoordinator[tuple[float, float, float]]):
"""A System monitor Load Data Update Coordinator."""
def update_data(self) -> tuple[float, float, float]:
"""Fetch data."""
return os.getloadavg()
class SystemMonitorProcessorCoordinator(MonitorCoordinator[float]):
"""A System monitor Processor Data Update Coordinator."""
def update_data(self) -> float:
"""Fetch data."""
return psutil.cpu_percent(interval=None)
class SystemMonitorBootTimeCoordinator(MonitorCoordinator[datetime]):
"""A System monitor Processor Data Update Coordinator."""
def update_data(self) -> datetime:
"""Fetch data."""
return dt_util.utc_from_timestamp(psutil.boot_time())
class SystemMonitorProcessCoordinator(MonitorCoordinator[list[psutil.Process]]):
"""A System monitor Process Data Update Coordinator."""
def update_data(self) -> list[psutil.Process]:
"""Fetch data."""
processes = psutil.process_iter()
return list(processes)
class SystemMonitorCPUtempCoordinator(MonitorCoordinator[dict[str, list[shwtemp]]]):
"""A System monitor CPU Temperature Data Update Coordinator."""
def update_data(self) -> dict[str, list[shwtemp]]:
"""Fetch data."""
try:
return psutil.sensors_temperatures()
except AttributeError as err:
raise UpdateFailed("OS does not provide temperature sensors") from err

View file

@ -1,17 +1,18 @@
"""Support for monitoring the local system."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from functools import cache, lru_cache
from datetime import datetime
from functools import lru_cache
import logging
import os
import socket
import sys
from typing import Any, Literal
import time
from typing import Any, Generic, Literal
import psutil
from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap
import voluptuous as vol
from homeassistant.components.sensor import (
@ -26,7 +27,6 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_RESOURCES,
CONF_TYPE,
EVENT_HOMEASSISTANT_STOP,
PERCENTAGE,
STATE_OFF,
STATE_ON,
@ -35,22 +35,31 @@ from homeassistant.const import (
UnitOfInformation,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
import homeassistant.util.dt as dt_util
from .const import CONF_PROCESS, DOMAIN, NETWORK_TYPES
from .util import get_all_disk_mounts, get_all_network_interfaces
from .const import CONF_PROCESS, DOMAIN, NET_IO_TYPES
from .coordinator import (
MonitorCoordinator,
SystemMonitorBootTimeCoordinator,
SystemMonitorCPUtempCoordinator,
SystemMonitorDiskCoordinator,
SystemMonitorLoadCoordinator,
SystemMonitorMemoryCoordinator,
SystemMonitorNetAddrCoordinator,
SystemMonitorNetIOCoordinator,
SystemMonitorProcessCoordinator,
SystemMonitorProcessorCoordinator,
SystemMonitorSwapCoordinator,
VirtualMemory,
dataT,
)
from .util import get_all_disk_mounts, get_all_network_interfaces, read_cpu_temperature
_LOGGER = logging.getLogger(__name__)
@ -74,16 +83,92 @@ def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]:
return "mdi:cpu-32-bit"
@dataclass(frozen=True)
class SysMonitorSensorEntityDescription(SensorEntityDescription):
"""Description for System Monitor sensor entities."""
def get_processor_temperature(
entity: SystemMonitorSensor[dict[str, list[shwtemp]]],
) -> float | None:
"""Return processor temperature."""
return read_cpu_temperature(entity.coordinator.data)
def get_process(entity: SystemMonitorSensor[list[psutil.Process]]) -> str:
"""Return process."""
state = STATE_OFF
for proc in entity.coordinator.data:
try:
_LOGGER.debug("process %s for argument %s", proc.name(), entity.argument)
if entity.argument == proc.name():
state = STATE_ON
break
except psutil.NoSuchProcess as err:
_LOGGER.warning(
"Failed to load process with ID: %s, old name: %s",
err.pid,
err.name,
)
return state
def get_network(entity: SystemMonitorSensor[dict[str, snetio]]) -> float | None:
"""Return network in and out."""
counters = entity.coordinator.data
if entity.argument in counters:
counter = counters[entity.argument][IO_COUNTER[entity.entity_description.key]]
return round(counter / 1024**2, 1)
return None
def get_packets(entity: SystemMonitorSensor[dict[str, snetio]]) -> float | None:
"""Return packets in and out."""
counters = entity.coordinator.data
if entity.argument in counters:
return counters[entity.argument][IO_COUNTER[entity.entity_description.key]]
return None
def get_throughput(entity: SystemMonitorSensor[dict[str, snetio]]) -> float | None:
"""Return network throughput in and out."""
counters = entity.coordinator.data
state = None
if entity.argument in counters:
counter = counters[entity.argument][IO_COUNTER[entity.entity_description.key]]
now = time.monotonic()
if (
(value := entity.value)
and (update_time := entity.update_time)
and value < counter
):
state = round(
(counter - value) / 1000**2 / (now - update_time),
3,
)
entity.update_time = now
entity.value = counter
return state
def get_ip_address(
entity: SystemMonitorSensor[dict[str, list[snicaddr]]],
) -> str | None:
"""Return network ip address."""
addresses = entity.coordinator.data
if entity.argument in addresses:
for addr in addresses[entity.argument]:
if addr.family == IF_ADDRS_FAMILY[entity.entity_description.key]:
return addr.address
return None
@dataclass(frozen=True, kw_only=True)
class SysMonitorSensorEntityDescription(SensorEntityDescription, Generic[dataT]):
"""Describes System Monitor sensor entities."""
value_fn: Callable[[SystemMonitorSensor[dataT]], StateType | datetime]
mandatory_arg: bool = False
placeholder: str | None = None
SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
"disk_free": SysMonitorSensorEntityDescription(
SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription[Any]] = {
"disk_free": SysMonitorSensorEntityDescription[sdiskusage](
key="disk_free",
translation_key="disk_free",
placeholder="mount_point",
@ -91,8 +176,9 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
device_class=SensorDeviceClass.DATA_SIZE,
icon="mdi:harddisk",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data.free / 1024**3, 1),
),
"disk_use": SysMonitorSensorEntityDescription(
"disk_use": SysMonitorSensorEntityDescription[sdiskusage](
key="disk_use",
translation_key="disk_use",
placeholder="mount_point",
@ -100,76 +186,91 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
device_class=SensorDeviceClass.DATA_SIZE,
icon="mdi:harddisk",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data.used / 1024**3, 1),
),
"disk_use_percent": SysMonitorSensorEntityDescription(
"disk_use_percent": SysMonitorSensorEntityDescription[sdiskusage](
key="disk_use_percent",
translation_key="disk_use_percent",
placeholder="mount_point",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:harddisk",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.coordinator.data.percent,
),
"ipv4_address": SysMonitorSensorEntityDescription(
"ipv4_address": SysMonitorSensorEntityDescription[dict[str, list[snicaddr]]](
key="ipv4_address",
translation_key="ipv4_address",
placeholder="ip_address",
icon="mdi:ip-network",
mandatory_arg=True,
value_fn=get_ip_address,
),
"ipv6_address": SysMonitorSensorEntityDescription(
"ipv6_address": SysMonitorSensorEntityDescription[dict[str, list[snicaddr]]](
key="ipv6_address",
translation_key="ipv6_address",
placeholder="ip_address",
icon="mdi:ip-network",
mandatory_arg=True,
value_fn=get_ip_address,
),
"last_boot": SysMonitorSensorEntityDescription(
"last_boot": SysMonitorSensorEntityDescription[datetime](
key="last_boot",
translation_key="last_boot",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda entity: entity.coordinator.data,
),
"load_15m": SysMonitorSensorEntityDescription(
"load_15m": SysMonitorSensorEntityDescription[tuple[float, float, float]](
key="load_15m",
translation_key="load_15m",
icon=get_cpu_icon(),
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data[2], 2),
),
"load_1m": SysMonitorSensorEntityDescription(
"load_1m": SysMonitorSensorEntityDescription[tuple[float, float, float]](
key="load_1m",
translation_key="load_1m",
icon=get_cpu_icon(),
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data[0], 2),
),
"load_5m": SysMonitorSensorEntityDescription(
"load_5m": SysMonitorSensorEntityDescription[tuple[float, float, float]](
key="load_5m",
translation_key="load_5m",
icon=get_cpu_icon(),
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data[1], 2),
),
"memory_free": SysMonitorSensorEntityDescription(
"memory_free": SysMonitorSensorEntityDescription[VirtualMemory](
key="memory_free",
translation_key="memory_free",
native_unit_of_measurement=UnitOfInformation.MEBIBYTES,
device_class=SensorDeviceClass.DATA_SIZE,
icon="mdi:memory",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data.available / 1024**2, 1),
),
"memory_use": SysMonitorSensorEntityDescription(
"memory_use": SysMonitorSensorEntityDescription[VirtualMemory](
key="memory_use",
translation_key="memory_use",
native_unit_of_measurement=UnitOfInformation.MEBIBYTES,
device_class=SensorDeviceClass.DATA_SIZE,
icon="mdi:memory",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(
(entity.coordinator.data.total - entity.coordinator.data.available)
/ 1024**2,
1,
),
"memory_use_percent": SysMonitorSensorEntityDescription(
),
"memory_use_percent": SysMonitorSensorEntityDescription[VirtualMemory](
key="memory_use_percent",
translation_key="memory_use_percent",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:memory",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.coordinator.data.percent,
),
"network_in": SysMonitorSensorEntityDescription(
"network_in": SysMonitorSensorEntityDescription[dict[str, snetio]](
key="network_in",
translation_key="network_in",
placeholder="interface",
@ -178,8 +279,9 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
icon="mdi:server-network",
state_class=SensorStateClass.TOTAL_INCREASING,
mandatory_arg=True,
value_fn=get_network,
),
"network_out": SysMonitorSensorEntityDescription(
"network_out": SysMonitorSensorEntityDescription[dict[str, snetio]](
key="network_out",
translation_key="network_out",
placeholder="interface",
@ -188,24 +290,27 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
icon="mdi:server-network",
state_class=SensorStateClass.TOTAL_INCREASING,
mandatory_arg=True,
value_fn=get_network,
),
"packets_in": SysMonitorSensorEntityDescription(
"packets_in": SysMonitorSensorEntityDescription[dict[str, snetio]](
key="packets_in",
translation_key="packets_in",
placeholder="interface",
icon="mdi:server-network",
state_class=SensorStateClass.TOTAL_INCREASING,
mandatory_arg=True,
value_fn=get_packets,
),
"packets_out": SysMonitorSensorEntityDescription(
"packets_out": SysMonitorSensorEntityDescription[dict[str, snetio]](
key="packets_out",
translation_key="packets_out",
placeholder="interface",
icon="mdi:server-network",
state_class=SensorStateClass.TOTAL_INCREASING,
mandatory_arg=True,
value_fn=get_packets,
),
"throughput_network_in": SysMonitorSensorEntityDescription(
"throughput_network_in": SysMonitorSensorEntityDescription[dict[str, snetio]](
key="throughput_network_in",
translation_key="throughput_network_in",
placeholder="interface",
@ -213,8 +318,9 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
mandatory_arg=True,
value_fn=get_throughput,
),
"throughput_network_out": SysMonitorSensorEntityDescription(
"throughput_network_out": SysMonitorSensorEntityDescription[dict[str, snetio]](
key="throughput_network_out",
translation_key="throughput_network_out",
placeholder="interface",
@ -222,50 +328,59 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
mandatory_arg=True,
value_fn=get_throughput,
),
"process": SysMonitorSensorEntityDescription(
"process": SysMonitorSensorEntityDescription[list[psutil.Process]](
key="process",
translation_key="process",
placeholder="process",
icon=get_cpu_icon(),
mandatory_arg=True,
value_fn=get_process,
),
"processor_use": SysMonitorSensorEntityDescription(
"processor_use": SysMonitorSensorEntityDescription[float](
key="processor_use",
translation_key="processor_use",
native_unit_of_measurement=PERCENTAGE,
icon=get_cpu_icon(),
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data),
),
"processor_temperature": SysMonitorSensorEntityDescription(
"processor_temperature": SysMonitorSensorEntityDescription[
dict[str, list[shwtemp]]
](
key="processor_temperature",
translation_key="processor_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_processor_temperature,
),
"swap_free": SysMonitorSensorEntityDescription(
"swap_free": SysMonitorSensorEntityDescription[sswap](
key="swap_free",
translation_key="swap_free",
native_unit_of_measurement=UnitOfInformation.MEBIBYTES,
device_class=SensorDeviceClass.DATA_SIZE,
icon="mdi:harddisk",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data.free / 1024**2, 1),
),
"swap_use": SysMonitorSensorEntityDescription(
"swap_use": SysMonitorSensorEntityDescription[sswap](
key="swap_use",
translation_key="swap_use",
native_unit_of_measurement=UnitOfInformation.MEBIBYTES,
device_class=SensorDeviceClass.DATA_SIZE,
icon="mdi:harddisk",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data.used / 1024**2, 1),
),
"swap_use_percent": SysMonitorSensorEntityDescription(
"swap_use_percent": SysMonitorSensorEntityDescription[sswap](
key="swap_use_percent",
translation_key="swap_use_percent",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:harddisk",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.coordinator.data.percent,
),
}
@ -320,46 +435,8 @@ IO_COUNTER = {
"throughput_network_out": 0,
"throughput_network_in": 1,
}
IF_ADDRS_FAMILY = {"ipv4_address": socket.AF_INET, "ipv6_address": socket.AF_INET6}
# There might be additional keys to be added for different
# platforms / hardware combinations.
# Taken from last version of "glances" integration before they moved to
# a generic temperature sensor logic.
# https://github.com/home-assistant/core/blob/5e15675593ba94a2c11f9f929cdad317e27ce190/homeassistant/components/glances/sensor.py#L199
CPU_SENSOR_PREFIXES = [
"amdgpu 1",
"aml_thermal",
"Core 0",
"Core 1",
"CPU Temperature",
"CPU",
"cpu-thermal 1",
"cpu_thermal 1",
"exynos-therm 1",
"Package id 0",
"Physical id 0",
"radeon 1",
"soc-thermal 1",
"soc_thermal 1",
"Tctl",
"cpu0-thermal",
"cpu0_thermal",
"k10temp 1",
]
@dataclass
class SensorData:
"""Data for a sensor."""
argument: Any
state: str | datetime | None
value: Any | None
update_time: datetime | None
last_exception: BaseException | None
async def async_setup_platform(
hass: HomeAssistant,
@ -399,33 +476,69 @@ async def async_setup_platform(
)
async def async_setup_entry(
async def async_setup_entry( # noqa: C901
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up System Montor sensors based on a config entry."""
entities = []
sensor_registry: dict[tuple[str, str], SensorData] = {}
entities: list[SystemMonitorSensor] = []
legacy_resources: set[str] = set(entry.options.get("resources", []))
loaded_resources: set[str] = set()
disk_arguments = await hass.async_add_executor_job(get_all_disk_mounts)
network_arguments = await hass.async_add_executor_job(get_all_network_interfaces)
cpu_temperature = await hass.async_add_executor_job(_read_cpu_temperature)
def get_arguments() -> dict[str, Any]:
"""Return startup information."""
disk_arguments = get_all_disk_mounts()
network_arguments = get_all_network_interfaces()
cpu_temperature = read_cpu_temperature()
return {
"disk_arguments": disk_arguments,
"network_arguments": network_arguments,
"cpu_temperature": cpu_temperature,
}
startup_arguments = await hass.async_add_executor_job(get_arguments)
disk_coordinators: dict[str, SystemMonitorDiskCoordinator] = {}
for argument in startup_arguments["disk_arguments"]:
disk_coordinators[argument] = SystemMonitorDiskCoordinator(
hass, f"Disk {argument} coordinator", argument
)
swap_coordinator = SystemMonitorSwapCoordinator(hass, "Swap coordinator")
memory_coordinator = SystemMonitorMemoryCoordinator(hass, "Memory coordinator")
net_io_coordinator = SystemMonitorNetIOCoordinator(hass, "Net IO coordnator")
net_addr_coordinator = SystemMonitorNetAddrCoordinator(
hass, "Net address coordinator"
)
system_load_coordinator = SystemMonitorLoadCoordinator(
hass, "System load coordinator"
)
processor_coordinator = SystemMonitorProcessorCoordinator(
hass, "Processor coordinator"
)
boot_time_coordinator = SystemMonitorBootTimeCoordinator(
hass, "Boot time coordinator"
)
process_coordinator = SystemMonitorProcessCoordinator(hass, "Process coordinator")
cpu_temp_coordinator = SystemMonitorCPUtempCoordinator(
hass, "CPU temperature coordinator"
)
for argument in startup_arguments["disk_arguments"]:
disk_coordinators[argument] = SystemMonitorDiskCoordinator(
hass, f"Disk {argument} coordinator", argument
)
_LOGGER.debug("Setup from options %s", entry.options)
for _type, sensor_description in SENSOR_TYPES.items():
if _type.startswith("disk_"):
for argument in disk_arguments:
sensor_registry[(_type, argument)] = SensorData(
argument, None, None, None, None
)
for argument in startup_arguments["disk_arguments"]:
is_enabled = check_legacy_resource(
f"{_type}_{argument}", legacy_resources
)
loaded_resources.add(slugify(f"{_type}_{argument}"))
entities.append(
SystemMonitorSensor(
sensor_registry,
disk_coordinators[argument],
sensor_description,
entry.entry_id,
argument,
@ -434,18 +547,15 @@ async def async_setup_entry(
)
continue
if _type in NETWORK_TYPES:
for argument in network_arguments:
sensor_registry[(_type, argument)] = SensorData(
argument, None, None, None, None
)
if _type.startswith("ipv"):
for argument in startup_arguments["network_arguments"]:
is_enabled = check_legacy_resource(
f"{_type}_{argument}", legacy_resources
)
loaded_resources.add(slugify(f"{_type}_{argument}"))
loaded_resources.add(f"{_type}_{argument}")
entities.append(
SystemMonitorSensor(
sensor_registry,
net_addr_coordinator,
sensor_description,
entry.entry_id,
argument,
@ -454,22 +564,74 @@ async def async_setup_entry(
)
continue
# Verify if we can retrieve CPU / processor temperatures.
# If not, do not create the entity and add a warning to the log
if _type == "processor_temperature" and cpu_temperature is None:
_LOGGER.warning("Cannot read CPU / processor temperature information")
if _type == "last_boot":
argument = ""
is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
loaded_resources.add(f"{_type}_{argument}")
entities.append(
SystemMonitorSensor(
boot_time_coordinator,
sensor_description,
entry.entry_id,
argument,
is_enabled,
)
)
continue
if _type.startswith("load_"):
argument = ""
is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
loaded_resources.add(f"{_type}_{argument}")
entities.append(
SystemMonitorSensor(
system_load_coordinator,
sensor_description,
entry.entry_id,
argument,
is_enabled,
)
)
continue
if _type.startswith("memory_"):
argument = ""
is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
loaded_resources.add(f"{_type}_{argument}")
entities.append(
SystemMonitorSensor(
memory_coordinator,
sensor_description,
entry.entry_id,
argument,
is_enabled,
)
)
if _type in NET_IO_TYPES:
for argument in startup_arguments["network_arguments"]:
is_enabled = check_legacy_resource(
f"{_type}_{argument}", legacy_resources
)
loaded_resources.add(f"{_type}_{argument}")
entities.append(
SystemMonitorSensor(
net_io_coordinator,
sensor_description,
entry.entry_id,
argument,
is_enabled,
)
)
continue
if _type == "process":
_entry: dict[str, list] = entry.options.get(SENSOR_DOMAIN, {})
_entry = entry.options.get(SENSOR_DOMAIN, {})
for argument in _entry.get(CONF_PROCESS, []):
sensor_registry[(_type, argument)] = SensorData(
argument, None, None, None, None
)
loaded_resources.add(slugify(f"{_type}_{argument}"))
entities.append(
SystemMonitorSensor(
sensor_registry,
process_coordinator,
sensor_description,
entry.entry_id,
argument,
@ -478,15 +640,49 @@ async def async_setup_entry(
)
continue
sensor_registry[(_type, "")] = SensorData("", None, None, None, None)
is_enabled = check_legacy_resource(f"{_type}_", legacy_resources)
loaded_resources.add(f"{_type}_")
if _type == "processor_use":
argument = ""
is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
loaded_resources.add(f"{_type}_{argument}")
entities.append(
SystemMonitorSensor(
sensor_registry,
processor_coordinator,
sensor_description,
entry.entry_id,
"",
argument,
is_enabled,
)
)
continue
if _type == "processor_temperature":
if not startup_arguments["cpu_temperature"]:
# Don't load processor temperature sensor if we can't read it.
continue
argument = ""
is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
loaded_resources.add(f"{_type}_{argument}")
entities.append(
SystemMonitorSensor(
cpu_temp_coordinator,
sensor_description,
entry.entry_id,
argument,
is_enabled,
)
)
continue
if _type.startswith("swap_"):
argument = ""
is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
loaded_resources.add(f"{_type}_{argument}")
entities.append(
SystemMonitorSensor(
swap_coordinator,
sensor_description,
entry.entry_id,
argument,
is_enabled,
)
)
@ -506,12 +702,13 @@ async def async_setup_entry(
_type = resource[:split_index]
argument = resource[split_index + 1 :]
_LOGGER.debug("Loading legacy %s with argument %s", _type, argument)
sensor_registry[(_type, argument)] = SensorData(
argument, None, None, None, None
if not disk_coordinators.get(argument):
disk_coordinators[argument] = SystemMonitorDiskCoordinator(
hass, f"Disk {argument} coordinator", argument
)
entities.append(
SystemMonitorSensor(
sensor_registry,
disk_coordinators[argument],
SENSOR_TYPES[_type],
entry.entry_id,
argument,
@ -519,94 +716,45 @@ async def async_setup_entry(
)
)
scan_interval = DEFAULT_SCAN_INTERVAL
await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval)
# No gathering to avoid swamping the executor
for coordinator in disk_coordinators.values():
await coordinator.async_request_refresh()
await boot_time_coordinator.async_request_refresh()
await cpu_temp_coordinator.async_request_refresh()
await memory_coordinator.async_request_refresh()
await net_addr_coordinator.async_request_refresh()
await net_io_coordinator.async_request_refresh()
await process_coordinator.async_request_refresh()
await processor_coordinator.async_request_refresh()
await swap_coordinator.async_request_refresh()
await system_load_coordinator.async_request_refresh()
async_add_entities(entities)
async def async_setup_sensor_registry_updates(
hass: HomeAssistant,
sensor_registry: dict[tuple[str, str], SensorData],
scan_interval: timedelta,
) -> None:
"""Update the registry and create polling."""
_update_lock = asyncio.Lock()
def _update_sensors() -> None:
"""Update sensors and store the result in the registry."""
for (type_, argument), data in sensor_registry.items():
try:
state, value, update_time = _update(type_, data)
except Exception as ex: # pylint: disable=broad-except
_LOGGER.exception("Error updating sensor: %s (%s)", type_, argument)
data.last_exception = ex
else:
data.state = state
data.value = value
data.update_time = update_time
data.last_exception = None
# Only fetch these once per iteration as we use the same
# data source multiple times in _update
_disk_usage.cache_clear()
_swap_memory.cache_clear()
_virtual_memory.cache_clear()
_net_io_counters.cache_clear()
_net_if_addrs.cache_clear()
_getloadavg.cache_clear()
async def _async_update_data(*_: Any) -> None:
"""Update all sensors in one executor jump."""
if _update_lock.locked():
_LOGGER.warning(
(
"Updating systemmonitor took longer than the scheduled update"
" interval %s"
),
scan_interval,
)
return
async with _update_lock:
await hass.async_add_executor_job(_update_sensors)
async_dispatcher_send(hass, SIGNAL_SYSTEMMONITOR_UPDATE)
polling_remover = async_track_time_interval(hass, _async_update_data, scan_interval)
@callback
def _async_stop_polling(*_: Any) -> None:
polling_remover()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_polling)
await _async_update_data()
class SystemMonitorSensor(SensorEntity):
class SystemMonitorSensor(CoordinatorEntity[MonitorCoordinator[dataT]], SensorEntity):
"""Implementation of a system monitor sensor."""
should_poll = False
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
entity_description: SysMonitorSensorEntityDescription
def __init__(
self,
sensor_registry: dict[tuple[str, str], SensorData],
coordinator: MonitorCoordinator,
sensor_description: SysMonitorSensorEntityDescription,
entry_id: str,
argument: str = "",
argument: str,
legacy_enabled: bool = False,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = sensor_description
if self.entity_description.placeholder:
self._attr_translation_placeholders = {
self.entity_description.placeholder: argument
}
self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}")
self._sensor_registry = sensor_registry
self._argument: str = argument
self._attr_entity_registry_enabled_default = legacy_enabled
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
@ -614,177 +762,11 @@ class SystemMonitorSensor(SensorEntity):
manufacturer="System Monitor",
name="System Monitor",
)
self.argument = argument
self.value: int | None = None
self.update_time: float | None = None
@property
def native_value(self) -> str | datetime | None:
"""Return the state of the device."""
return self.data.state
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.data.last_exception is None
@property
def data(self) -> SensorData:
"""Return registry entry for the data."""
return self._sensor_registry[(self.entity_description.key, self._argument)]
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_SYSTEMMONITOR_UPDATE, self.async_write_ha_state
)
)
def _update( # noqa: C901
type_: str, data: SensorData
) -> tuple[str | datetime | None, str | None, datetime | None]:
"""Get the latest system information."""
state = None
value = None
update_time = None
if type_ == "disk_use_percent":
state = _disk_usage(data.argument).percent
elif type_ == "disk_use":
state = round(_disk_usage(data.argument).used / 1024**3, 1)
elif type_ == "disk_free":
state = round(_disk_usage(data.argument).free / 1024**3, 1)
elif type_ == "memory_use_percent":
state = _virtual_memory().percent
elif type_ == "memory_use":
virtual_memory = _virtual_memory()
state = round((virtual_memory.total - virtual_memory.available) / 1024**2, 1)
elif type_ == "memory_free":
state = round(_virtual_memory().available / 1024**2, 1)
elif type_ == "swap_use_percent":
state = _swap_memory().percent
elif type_ == "swap_use":
state = round(_swap_memory().used / 1024**2, 1)
elif type_ == "swap_free":
state = round(_swap_memory().free / 1024**2, 1)
elif type_ == "processor_use":
state = round(psutil.cpu_percent(interval=None))
elif type_ == "processor_temperature":
state = _read_cpu_temperature()
elif type_ == "process":
state = STATE_OFF
for proc in psutil.process_iter():
try:
if data.argument == proc.name():
state = STATE_ON
break
except psutil.NoSuchProcess as err:
_LOGGER.warning(
"Failed to load process with ID: %s, old name: %s",
err.pid,
err.name,
)
elif type_ in ("network_out", "network_in"):
counters = _net_io_counters()
if data.argument in counters:
counter = counters[data.argument][IO_COUNTER[type_]]
state = round(counter / 1024**2, 1)
else:
state = None
elif type_ in ("packets_out", "packets_in"):
counters = _net_io_counters()
if data.argument in counters:
state = counters[data.argument][IO_COUNTER[type_]]
else:
state = None
elif type_ in ("throughput_network_out", "throughput_network_in"):
counters = _net_io_counters()
if data.argument in counters:
counter = counters[data.argument][IO_COUNTER[type_]]
now = dt_util.utcnow()
if data.value and data.value < counter:
state = round(
(counter - data.value)
/ 1000**2
/ (now - (data.update_time or now)).total_seconds(),
3,
)
else:
state = None
update_time = now
value = counter
else:
state = None
elif type_ in ("ipv4_address", "ipv6_address"):
addresses = _net_if_addrs()
if data.argument in addresses:
for addr in addresses[data.argument]:
if addr.family == IF_ADDRS_FAMILY[type_]:
state = addr.address
else:
state = None
elif type_ == "last_boot":
# Only update on initial setup
if data.state is None:
state = dt_util.utc_from_timestamp(psutil.boot_time())
else:
state = data.state
elif type_ == "load_1m":
state = round(_getloadavg()[0], 2)
elif type_ == "load_5m":
state = round(_getloadavg()[1], 2)
elif type_ == "load_15m":
state = round(_getloadavg()[2], 2)
return state, value, update_time
@cache
def _disk_usage(path: str) -> Any:
return psutil.disk_usage(path)
@cache
def _swap_memory() -> Any:
return psutil.swap_memory()
@cache
def _virtual_memory() -> Any:
return psutil.virtual_memory()
@cache
def _net_io_counters() -> Any:
return psutil.net_io_counters(pernic=True)
@cache
def _net_if_addrs() -> Any:
return psutil.net_if_addrs()
@cache
def _getloadavg() -> tuple[float, float, float]:
return os.getloadavg()
def _read_cpu_temperature() -> float | None:
"""Attempt to read CPU / processor temperature."""
try:
temps = psutil.sensors_temperatures()
except AttributeError:
# Linux, macOS
return None
for name, entries in temps.items():
for i, entry in enumerate(entries, start=1):
# In case the label is empty (e.g. on Raspberry PI 4),
# construct it ourself here based on the sensor key name.
_label = f"{name} {i}" if not entry.label else entry.label
# check both name and label because some systems embed cpu# in the
# name, which makes label not match because label adds cpu# at end.
if _label in CPU_SENSOR_PREFIXES or name in CPU_SENSOR_PREFIXES:
return round(entry.current, 1)
return None
def native_value(self) -> StateType | datetime:
"""Return the state."""
return self.entity_description.value_fn(self)

View file

@ -1,9 +1,11 @@
"""Utils for System Monitor."""
import logging
import os
import psutil
from psutil._common import shwtemp
from .const import CPU_SENSOR_PREFIXES
_LOGGER = logging.getLogger(__name__)
@ -61,3 +63,22 @@ def get_all_running_processes() -> set[str]:
processes.add(proc.name())
_LOGGER.debug("Running processes: %s", ", ".join(processes))
return processes
def read_cpu_temperature(temps: dict[str, list[shwtemp]] | None = None) -> float | None:
"""Attempt to read CPU / processor temperature."""
if not temps:
temps = psutil.sensors_temperatures()
entry: shwtemp
for name, entries in temps.items():
for i, entry in enumerate(entries, start=1):
# In case the label is empty (e.g. on Raspberry PI 4),
# construct it ourself here based on the sensor key name.
_label = f"{name} {i}" if not entry.label else entry.label
# check both name and label because some systems embed cpu# in the
# name, which makes label not match because label adds cpu# at end.
if _label in CPU_SENSOR_PREFIXES or name in CPU_SENSOR_PREFIXES:
return round(entry.current, 1)
return None

View file

@ -115,11 +115,11 @@ def mock_process() -> list[MockProcess]:
def mock_psutil(mock_process: list[MockProcess]) -> Mock:
"""Mock psutil."""
with patch(
"homeassistant.components.systemmonitor.sensor.psutil",
"homeassistant.components.systemmonitor.coordinator.psutil",
autospec=True,
) as mock_psutil:
mock_psutil.disk_usage.return_value = sdiskusage(
500 * 1024**2, 300 * 1024**2, 200 * 1024**2, 60.0
500 * 1024**3, 300 * 1024**3, 200 * 1024**3, 60.0
)
mock_psutil.swap_memory.return_value = sswap(
100 * 1024**2, 60 * 1024**2, 40 * 1024**2, 60.0, 1, 1
@ -240,7 +240,9 @@ def mock_util(mock_process) -> Mock:
@pytest.fixture
def mock_os() -> Mock:
"""Mock os."""
with patch("homeassistant.components.systemmonitor.sensor.os") as mock_os, patch(
with patch(
"homeassistant.components.systemmonitor.coordinator.os"
) as mock_os, patch(
"homeassistant.components.systemmonitor.util.os"
) as mock_os_util:
mock_os_util.name = "nt"

View file

@ -9,7 +9,7 @@
})
# ---
# name: test_sensor[System Monitor Disk free / - state]
'0.2'
'200.0'
# ---
# name: test_sensor[System Monitor Disk free /media/share - attributes]
ReadOnlyDict({
@ -21,7 +21,7 @@
})
# ---
# name: test_sensor[System Monitor Disk free /media/share - state]
'0.2'
'200.0'
# ---
# name: test_sensor[System Monitor Disk usage / - attributes]
ReadOnlyDict({
@ -66,7 +66,7 @@
})
# ---
# name: test_sensor[System Monitor Disk use / - state]
'0.3'
'300.0'
# ---
# name: test_sensor[System Monitor Disk use /media/share - attributes]
ReadOnlyDict({
@ -78,7 +78,7 @@
})
# ---
# name: test_sensor[System Monitor Disk use /media/share - state]
'0.3'
'300.0'
# ---
# name: test_sensor[System Monitor IPv4 address eth0 - attributes]
ReadOnlyDict({

View file

@ -4,14 +4,11 @@ import socket
from unittest.mock import Mock, patch
from freezegun.api import FrozenDateTimeFactory
from psutil._common import shwtemp, snetio, snicaddr
from psutil._common import sdiskusage, shwtemp, snetio, snicaddr
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.systemmonitor.sensor import (
_read_cpu_temperature,
get_cpu_icon,
)
from homeassistant.components.systemmonitor.sensor import get_cpu_icon
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
@ -218,11 +215,11 @@ async def test_sensor_process_fails(
async def test_sensor_network_sensors(
freezer: FrozenDateTimeFactory,
hass: HomeAssistant,
entity_registry_enabled_by_default: None,
mock_added_config_entry: ConfigEntry,
mock_psutil: Mock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test process not exist failure."""
network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1")
@ -306,41 +303,129 @@ async def test_missing_cpu_temperature(
mock_psutil.sensors_temperatures.return_value = {
"not_exist": [shwtemp("not_exist", 50.0, 60.0, 70.0)]
}
mock_util.sensors_temperatures.return_value = {
"not_exist": [shwtemp("not_exist", 50.0, 60.0, 70.0)]
}
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert "Cannot read CPU / processor temperature information" in caplog.text
# assert "Cannot read CPU / processor temperature information" in caplog.text
temp_sensor = hass.states.get("sensor.system_monitor_processor_temperature")
assert temp_sensor is None
async def test_processor_temperature() -> None:
async def test_processor_temperature(
hass: HomeAssistant,
entity_registry_enabled_by_default: None,
mock_util: Mock,
mock_psutil: Mock,
mock_os: Mock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the disk failures."""
with patch("sys.platform", "linux"), patch(
"homeassistant.components.systemmonitor.sensor.psutil"
) as mock_psutil:
with patch("sys.platform", "linux"):
mock_psutil.sensors_temperatures.return_value = {
"cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)]
}
temperature = _read_cpu_temperature()
assert temperature == 50.0
mock_psutil.sensors_temperatures.side_effect = None
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
temp_entity = hass.states.get("sensor.system_monitor_processor_temperature")
assert temp_entity.state == "50.0"
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
with patch("sys.platform", "nt"), patch(
"homeassistant.components.systemmonitor.sensor.psutil",
) as mock_psutil:
with patch("sys.platform", "nt"):
mock_psutil.sensors_temperatures.return_value = None
mock_psutil.sensors_temperatures.side_effect = AttributeError(
"sensors_temperatures not exist"
)
temperature = _read_cpu_temperature()
assert temperature is None
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
temp_entity = hass.states.get("sensor.system_monitor_processor_temperature")
assert temp_entity.state == STATE_UNAVAILABLE
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
with patch("sys.platform", "darwin"), patch(
"homeassistant.components.systemmonitor.sensor.psutil"
) as mock_psutil:
with patch("sys.platform", "darwin"):
mock_psutil.sensors_temperatures.return_value = {
"cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)]
}
temperature = _read_cpu_temperature()
assert temperature == 50.0
mock_psutil.sensors_temperatures.side_effect = None
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
temp_entity = hass.states.get("sensor.system_monitor_processor_temperature")
assert temp_entity.state == "50.0"
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
async def test_exception_handling_disk_sensor(
hass: HomeAssistant,
entity_registry_enabled_by_default: None,
mock_psutil: Mock,
mock_added_config_entry: ConfigEntry,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the sensor."""
disk_sensor = hass.states.get("sensor.system_monitor_disk_free")
assert disk_sensor is not None
assert disk_sensor.state == "200.0" # GiB
mock_psutil.disk_usage.return_value = None
mock_psutil.disk_usage.side_effect = OSError("Could not update /")
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
"Error fetching System Monitor Disk / coordinator data: OS error for /"
in caplog.text
)
disk_sensor = hass.states.get("sensor.system_monitor_disk_free")
assert disk_sensor is not None
assert disk_sensor.state == STATE_UNAVAILABLE
mock_psutil.disk_usage.return_value = None
mock_psutil.disk_usage.side_effect = PermissionError("No access to /")
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
"Error fetching System Monitor Disk / coordinator data: OS error for /"
in caplog.text
)
disk_sensor = hass.states.get("sensor.system_monitor_disk_free")
assert disk_sensor is not None
assert disk_sensor.state == STATE_UNAVAILABLE
mock_psutil.disk_usage.return_value = sdiskusage(
500 * 1024**3, 350 * 1024**3, 150 * 1024**3, 70.0
)
mock_psutil.disk_usage.side_effect = None
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
disk_sensor = hass.states.get("sensor.system_monitor_disk_free")
assert disk_sensor is not None
assert disk_sensor.state == "150.0"
assert disk_sensor.attributes["unit_of_measurement"] == "GiB"
disk_sensor = hass.states.get("sensor.system_monitor_disk_usage")
assert disk_sensor is not None
assert disk_sensor.state == "70.0"
assert disk_sensor.attributes["unit_of_measurement"] == "%"