Create update coordinator for Systemmonitor (#106693)

pull/104982/head^2
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

File diff suppressed because it is too large Load Diff

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"] == "%"