diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py index ad84e727129ad6..68665b2eecf00f 100644 --- a/homeassistant/components/systemmonitor/binary_sensor.py +++ b/homeassistant/components/systemmonitor/binary_sensor.py @@ -66,11 +66,11 @@ def get_process(entity: SystemMonitorSensor) -> bool: class SysMonitorBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes System Monitor binary sensor entities.""" - value_fn: Callable[[SystemMonitorSensor], bool] + value_fn: Callable[[SystemMonitorSensor], bool | None] add_to_update: Callable[[SystemMonitorSensor], tuple[str, str]] -SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = ( +PROCESS_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = ( SysMonitorBinarySensorEntityDescription( key="binary_process", translation_key="process", @@ -81,6 +81,20 @@ class SysMonitorBinarySensorEntityDescription(BinarySensorEntityDescription): ), ) +BINARY_SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = ( + SysMonitorBinarySensorEntityDescription( + key="battery_plugged", + value_fn=( + lambda entity: entity.coordinator.data.battery.power_plugged + if entity.coordinator.data.battery + else None + ), + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + add_to_update=lambda entity: ("battery", ""), + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -90,18 +104,30 @@ async def async_setup_entry( """Set up System Monitor binary sensors based on a config entry.""" coordinator = entry.runtime_data.coordinator - async_add_entities( + entities: list[SystemMonitorSensor] = [] + + entities.extend( SystemMonitorSensor( coordinator, sensor_description, entry.entry_id, argument, ) - for sensor_description in SENSOR_TYPES + for sensor_description in PROCESS_TYPES for argument in entry.options.get(BINARY_SENSOR_DOMAIN, {}).get( CONF_PROCESS, [] ) ) + entities.extend( + SystemMonitorSensor( + coordinator, + sensor_description, + entry.entry_id, + "", + ) + for sensor_description in BINARY_SENSOR_TYPES + ) + async_add_entities(entities) class SystemMonitorSensor( diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index eeccddf949fe7c..9478ab8a738685 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from psutil import Process -from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap +from psutil._common import sbattery, sdiskusage, shwtemp, snetio, snicaddr, sswap import psutil_home_assistant as ha_psutil from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -22,6 +22,7 @@ if TYPE_CHECKING: from . import SystemMonitorConfigEntry +from .util import read_fan_speed _LOGGER = logging.getLogger(__name__) @@ -31,9 +32,11 @@ class SensorData: """Sensor data.""" addresses: dict[str, list[snicaddr]] + battery: sbattery | None boot_time: datetime cpu_percent: float | None disk_usage: dict[str, sdiskusage] + fan_speed: dict[str, int] io_counters: dict[str, snetio] load: tuple[float, float, float] memory: VirtualMemory @@ -50,17 +53,23 @@ def as_dict(self) -> dict[str, Any]: disk_usage = None if self.disk_usage: disk_usage = {k: str(v) for k, v in self.disk_usage.items()} + fan_speed = None + if self.fan_speed: + fan_speed = {k: str(v) for k, v in self.fan_speed.items()} io_counters = None if self.io_counters: io_counters = {k: str(v) for k, v in self.io_counters.items()} temperatures = None if self.temperatures: temperatures = {k: str(v) for k, v in self.temperatures.items()} + return { "addresses": addresses, + "battery": str(self.battery), "boot_time": str(self.boot_time), "cpu_percent": str(self.cpu_percent), "disk_usage": disk_usage, + "fan_speed": fan_speed, "io_counters": io_counters, "load": str(self.load), "memory": str(self.memory), @@ -125,8 +134,10 @@ def set_subscribers_tuples( return { **_disk_defaults, ("addresses", ""): set(), + ("battery", ""): set(), ("boot", ""): set(), ("cpu_percent", ""): set(), + ("fan_speed", ""): set(), ("io_counters", ""): set(), ("load", ""): set(), ("memory", ""): set(), @@ -154,9 +165,11 @@ async def _async_update_data(self) -> SensorData: self._initial_update = False return SensorData( addresses=_data["addresses"], + battery=_data["battery"], boot_time=_data["boot_time"], cpu_percent=cpu_percent, disk_usage=_data["disks"], + fan_speed=_data["fan_speed"], io_counters=_data["io_counters"], load=load, memory=_data["memory"], @@ -255,10 +268,29 @@ def update_data(self) -> dict[str, Any]: except AttributeError: _LOGGER.debug("OS does not provide temperature sensors") + fan_speed: dict[str, int] = {} + if self.update_subscribers[("fan_speed", "")] or self._initial_update: + try: + fan_sensors = self._psutil.sensors_fans() + fan_speed = read_fan_speed(fan_sensors) + _LOGGER.debug("fan_speed: %s", fan_speed) + except AttributeError: + _LOGGER.debug("OS does not provide fan sensors") + + battery: sbattery | None = None + if self.update_subscribers[("battery", "")] or self._initial_update: + try: + battery = self._psutil.sensors_battery() + _LOGGER.debug("battery: %s", battery) + except AttributeError: + _LOGGER.debug("OS does not provide battery sensors") + return { "addresses": addresses, + "battery": battery, "boot_time": self.boot_time, "disks": disks, + "fan_speed": fan_speed, "io_counters": io_counters, "memory": memory, "process_fds": process_fds, diff --git a/homeassistant/components/systemmonitor/icons.json b/homeassistant/components/systemmonitor/icons.json index b0ea54acc98348..7e8807917379a0 100644 --- a/homeassistant/components/systemmonitor/icons.json +++ b/homeassistant/components/systemmonitor/icons.json @@ -1,6 +1,9 @@ { "entity": { "sensor": { + "battery_empty": { + "default": "mdi:battery-clock" + }, "disk_free": { "default": "mdi:harddisk" }, @@ -10,6 +13,9 @@ "disk_use_percent": { "default": "mdi:harddisk" }, + "fan_speed": { + "default": "mdi:fan" + }, "ipv4_address": { "default": "mdi:ip-network" }, diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 403b17ae5fcdac..74401d5b59f6cd 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable import contextlib from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from functools import lru_cache import ipaddress import logging @@ -14,6 +14,8 @@ import time from typing import Any, Literal +from psutil._common import POWER_TIME_UNKNOWN, POWER_TIME_UNLIMITED + from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, @@ -23,6 +25,7 @@ ) from homeassistant.const import ( PERCENTAGE, + REVOLUTIONS_PER_MINUTE, EntityCategory, UnitOfDataRate, UnitOfInformation, @@ -34,7 +37,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify from . import SystemMonitorConfigEntry from .binary_sensor import BINARY_SENSOR_DOMAIN @@ -55,7 +58,11 @@ SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" +BATTERY_REMAIN_UNKNOWNS = (POWER_TIME_UNKNOWN, POWER_TIME_UNLIMITED) + SENSORS_NO_ARG = ( + "battery_empty", + "battery", "last_boot", "load_", "memory_", @@ -64,6 +71,7 @@ ) SENSORS_WITH_ARG = { "disk_": "disk_arguments", + "fan_speed": "fan_speed_arguments", "ipv": "network_arguments", "process_num_fds": "processes", **dict.fromkeys(NET_IO_TYPES, "network_arguments"), @@ -139,6 +147,17 @@ def get_process_num_fds(entity: SystemMonitorSensor) -> int | None: return process_fds.get(entity.argument) +def battery_time_ends(entity: SystemMonitorSensor) -> datetime | None: + """Return when battery runs out, rounded to minute.""" + battery = entity.coordinator.data.battery + if not battery or battery.secsleft in BATTERY_REMAIN_UNKNOWNS: + return None + + return (dt_util.utcnow() + timedelta(seconds=battery.secsleft)).replace( + second=0, microsecond=0 + ) + + @dataclass(frozen=True, kw_only=True) class SysMonitorSensorEntityDescription(SensorEntityDescription): """Describes System Monitor sensor entities.""" @@ -151,6 +170,28 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { + "battery": SysMonitorSensorEntityDescription( + key="battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda entity: entity.coordinator.data.battery.percent + if entity.coordinator.data.battery + else None + ), + none_is_unavailable=True, + add_to_update=lambda entity: ("battery", ""), + ), + "battery_empty": SysMonitorSensorEntityDescription( + key="battery_empty", + translation_key="battery_empty", + device_class=SensorDeviceClass.TIMESTAMP, + state_class=SensorStateClass.MEASUREMENT, + value_fn=battery_time_ends, + none_is_unavailable=True, + add_to_update=lambda entity: ("battery", ""), + ), "disk_free": SysMonitorSensorEntityDescription( key="disk_free", translation_key="disk_free", @@ -199,6 +240,16 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription): none_is_unavailable=True, add_to_update=lambda entity: ("disks", entity.argument), ), + "fan_speed": SysMonitorSensorEntityDescription( + key="fan_speed", + translation_key="fan_speed", + placeholder="fan_name", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.coordinator.data.fan_speed[entity.argument], + none_is_unavailable=True, + add_to_update=lambda entity: ("fan_speed", ""), + ), "ipv4_address": SysMonitorSensorEntityDescription( key="ipv4_address", translation_key="ipv4_address", @@ -252,8 +303,8 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda entity: round( - entity.coordinator.data.memory.available / 1024**2, 1 + value_fn=( + lambda entity: round(entity.coordinator.data.memory.available / 1024**2, 1) ), add_to_update=lambda entity: ("memory", ""), ), @@ -454,6 +505,7 @@ def get_arguments() -> dict[str, Any]: return { "disk_arguments": get_all_disk_mounts(hass, psutil_wrapper), "network_arguments": get_all_network_interfaces(hass, psutil_wrapper), + "fan_speed_arguments": list(sensor_data.fan_speed), } cpu_temperature: float | None = None diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index 861f23fbba2fce..38d0b63dc51066 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -16,6 +16,9 @@ } }, "sensor": { + "battery_empty": { + "name": "Battery empty" + }, "disk_free": { "name": "Disk free {mount_point}" }, @@ -25,6 +28,9 @@ "disk_use_percent": { "name": "Disk usage {mount_point}" }, + "fan_speed": { + "name": "{fan_name} fan speed" + }, "ipv4_address": { "name": "IPv4 address {ip_address}" }, diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index dec0508bb646f3..1118445dab122f 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -3,7 +3,7 @@ import logging import os -from psutil._common import shwtemp +from psutil._common import sfan, shwtemp import psutil_home_assistant as ha_psutil from homeassistant.core import HomeAssistant @@ -89,3 +89,19 @@ def read_cpu_temperature(temps: dict[str, list[shwtemp]]) -> float | None: return round(entry.current, 1) return None + + +def read_fan_speed(fans: dict[str, list[sfan]]) -> dict[str, int]: + """Attempt to read fan speed.""" + entry: sfan + + _LOGGER.debug("Fan speed: %s", fans) + if not fans: + return {} + sensor_fans: dict[str, int] = {} + for name, entries in fans.items(): + for entry in entries: + _label = name if not entry.label else entry.label + sensor_fans[_label] = round(entry.current, 0) + + return sensor_fans diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index c44eed77c26a89..0dad6670587382 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -7,7 +7,16 @@ from unittest.mock import AsyncMock, Mock, NonCallableMock, patch from psutil import NoSuchProcess, Process -from psutil._common import sdiskpart, sdiskusage, shwtemp, snetio, snicaddr, sswap +from psutil._common import ( + sbattery, + sdiskpart, + sdiskusage, + sfan, + shwtemp, + snetio, + snicaddr, + sswap, +) import pytest from homeassistant.components.systemmonitor.const import DOMAIN @@ -208,6 +217,12 @@ def mock_psutil(mock_process: list[MockProcess]) -> Generator: ] mock_psutil.boot_time.return_value = 1708786800.0 mock_psutil.NoSuchProcess = NoSuchProcess + mock_psutil.sensors_fans.return_value = { + "asus": [sfan("cpu-fan", 1200), sfan("another-fan", 1300)], + } + mock_psutil.sensors_battery.return_value = sbattery( + percent=93, secsleft=16628, power_plugged=False + ) yield mock_psutil diff --git a/tests/components/systemmonitor/snapshots/test_binary_sensor.ambr b/tests/components/systemmonitor/snapshots/test_binary_sensor.ambr index 0c04cfcfa06d61..2c914c53afb910 100644 --- a/tests/components/systemmonitor/snapshots/test_binary_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_binary_sensor.ambr @@ -1,4 +1,13 @@ # serializer version: 1 +# name: test_binary_sensor[System Monitor Charging - attributes] + ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'System Monitor Charging', + }) +# --- +# name: test_binary_sensor[System Monitor Charging - state] + 'off' +# --- # name: test_binary_sensor[System Monitor Process pip - attributes] ReadOnlyDict({ 'device_class': 'running', diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index d306fa6551400c..97d806614e1fea 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -8,6 +8,7 @@ 'eth1': "[snicaddr(family=, address='192.168.10.1', netmask='255.255.255.0', broadcast='255.255.255.255', ptp=None)]", 'vethxyzxyz': "[snicaddr(family=, address='172.16.10.1', netmask='255.255.255.0', broadcast='255.255.255.255', ptp=None)]", }), + 'battery': 'sbattery(percent=93, secsleft=16628, power_plugged=False)', 'boot_time': '2024-02-24 15:00:00+00:00', 'cpu_percent': '10.0', 'disk_usage': dict({ @@ -15,6 +16,10 @@ '/home/notexist/': 'sdiskusage(total=536870912000, used=322122547200, free=214748364800, percent=60.0)', '/media/share': 'sdiskusage(total=536870912000, used=322122547200, free=214748364800, percent=60.0)', }), + 'fan_speed': dict({ + 'another-fan': '1300', + 'cpu-fan': '1200', + }), 'io_counters': dict({ 'eth0': 'snetio(bytes_sent=104857600, bytes_recv=104857600, packets_sent=50, packets_recv=50, errin=0, errout=0, dropin=0, dropout=0)', 'eth1': 'snetio(bytes_sent=209715200, bytes_recv=209715200, packets_sent=150, packets_recv=150, errin=0, errout=0, dropin=0, dropout=0)', @@ -73,6 +78,7 @@ 'coordinators': dict({ 'data': dict({ 'addresses': None, + 'battery': 'sbattery(percent=93, secsleft=16628, power_plugged=False)', 'boot_time': '2024-02-24 15:00:00+00:00', 'cpu_percent': '10.0', 'disk_usage': dict({ @@ -80,6 +86,10 @@ '/home/notexist/': 'sdiskusage(total=536870912000, used=322122547200, free=214748364800, percent=60.0)', '/media/share': 'sdiskusage(total=536870912000, used=322122547200, free=214748364800, percent=60.0)', }), + 'fan_speed': dict({ + 'another-fan': '1300', + 'cpu-fan': '1200', + }), 'io_counters': None, 'load': '(1, 2, 3)', 'memory': 'VirtualMemory(total=104857600, available=41943040, percent=40.0, used=62914560, free=31457280)', diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index 0ef5375341dbbf..9d54f7bfc2af15 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -1,4 +1,25 @@ # serializer version: 1 +# name: test_sensor[System Monitor Battery - attributes] + ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'System Monitor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Battery - state] + '93' +# --- +# name: test_sensor[System Monitor Battery empty - attributes] + ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'System Monitor Battery empty', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Battery empty - state] + '2024-02-24T19:37:00+00:00' +# --- # name: test_sensor[System Monitor Disk free / - attributes] ReadOnlyDict({ 'device_class': 'data_size', @@ -372,3 +393,23 @@ # name: test_sensor[System Monitor Swap use - state] '60.0' # --- +# name: test_sensor[System Monitor another-fan fan speed - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor another-fan fan speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor[System Monitor another-fan fan speed - state] + '1300' +# --- +# name: test_sensor[System Monitor cpu-fan fan speed - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor cpu-fan fan speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor[System Monitor cpu-fan fan speed - state] + '1200' +# --- diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index e22f8e14d3d000..10b7a853a917f7 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -23,6 +23,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.freeze_time("2024-02-24 15:00:00", tz_offset=0) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, @@ -543,7 +544,7 @@ async def test_remove_obsolete_entities( mock_added_config_entry.entry_id ) ) - == 39 + == 44 ) entity_registry.async_update_entity( @@ -584,7 +585,7 @@ async def test_remove_obsolete_entities( mock_added_config_entry.entry_id ) ) - == 40 + == 45 ) assert (