Skip to content
34 changes: 30 additions & 4 deletions homeassistant/components/systemmonitor/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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(
Expand Down
34 changes: 33 additions & 1 deletion homeassistant/components/systemmonitor/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +22,7 @@

if TYPE_CHECKING:
from . import SystemMonitorConfigEntry
from .util import read_fan_speed

_LOGGER = logging.getLogger(__name__)

Expand All @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/components/systemmonitor/icons.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"entity": {
"sensor": {
"battery_empty": {
"default": "mdi:battery-clock"
},
"disk_free": {
"default": "mdi:harddisk"
},
Expand All @@ -10,6 +13,9 @@
"disk_use_percent": {
"default": "mdi:harddisk"
},
"fan_speed": {
"default": "mdi:fan"
},
"ipv4_address": {
"default": "mdi:ip-network"
},
Expand Down
60 changes: 56 additions & 4 deletions homeassistant/components/systemmonitor/sensor.py
Comment thread
gjohansson-ST marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -23,6 +25,7 @@
)
from homeassistant.const import (
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
EntityCategory,
UnitOfDataRate,
UnitOfInformation,
Expand All @@ -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
Expand All @@ -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_",
Expand All @@ -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"),
Expand Down Expand Up @@ -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."""
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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", ""),
),
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/components/systemmonitor/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
}
},
"sensor": {
"battery_empty": {
"name": "Battery empty"
},
"disk_free": {
"name": "Disk free {mount_point}"
},
Expand All @@ -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}"
},
Expand Down
18 changes: 17 additions & 1 deletion homeassistant/components/systemmonitor/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading
Loading