From d4f9ad9dd365232fe48953de69a2a4ba875f90ca Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 17 Jan 2024 02:07:55 +0100 Subject: [PATCH] Create update coordinator for Systemmonitor (#106693) --- .../components/systemmonitor/const.py | 30 +- .../components/systemmonitor/coordinator.py | 166 ++++ .../components/systemmonitor/sensor.py | 710 +++++++++--------- .../components/systemmonitor/util.py | 23 +- tests/components/systemmonitor/conftest.py | 8 +- .../systemmonitor/snapshots/test_sensor.ambr | 8 +- tests/components/systemmonitor/test_sensor.py | 131 +++- 7 files changed, 678 insertions(+), 398 deletions(-) create mode 100644 homeassistant/components/systemmonitor/coordinator.py diff --git a/homeassistant/components/systemmonitor/const.py b/homeassistant/components/systemmonitor/const.py index c92647f9c8e..798cb82f8ef 100644 --- a/homeassistant/components/systemmonitor/const.py +++ b/homeassistant/components/systemmonitor/const.py @@ -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", ] diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py new file mode 100644 index 00000000000..9143d31f163 --- /dev/null +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -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 diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index c7fdb4226d1..b5cccc20b6a 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -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,18 +640,52 @@ 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}_") - entities.append( - SystemMonitorSensor( - sensor_registry, - sensor_description, - entry.entry_id, - "", - is_enabled, + if _type == "processor_use": + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + 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, + ) ) - ) # Ensure legacy imported disk_* resources are loaded if they are not part # of mount points automatically discovered @@ -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) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 75b437c19eb..293492b90e8 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -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 diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index b349e5cf5e1..c03c3fff2ca 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -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" diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index d39b23c8107..3708ca1e53a 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -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({ diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index 35ee331c699..8beeddbefdc 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -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"] == "%"