Skip to content

Commit 9288995

Browse files
Add fans and battery sensor to systemmonitor (#151066)
1 parent 4d2abb4 commit 9288995

File tree

11 files changed

+227
-13
lines changed

11 files changed

+227
-13
lines changed

homeassistant/components/systemmonitor/binary_sensor.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ def get_process(entity: SystemMonitorSensor) -> bool:
6666
class SysMonitorBinarySensorEntityDescription(BinarySensorEntityDescription):
6767
"""Describes System Monitor binary sensor entities."""
6868

69-
value_fn: Callable[[SystemMonitorSensor], bool]
69+
value_fn: Callable[[SystemMonitorSensor], bool | None]
7070
add_to_update: Callable[[SystemMonitorSensor], tuple[str, str]]
7171

7272

73-
SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
73+
PROCESS_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
7474
SysMonitorBinarySensorEntityDescription(
7575
key="binary_process",
7676
translation_key="process",
@@ -81,6 +81,20 @@ class SysMonitorBinarySensorEntityDescription(BinarySensorEntityDescription):
8181
),
8282
)
8383

84+
BINARY_SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
85+
SysMonitorBinarySensorEntityDescription(
86+
key="battery_plugged",
87+
value_fn=(
88+
lambda entity: entity.coordinator.data.battery.power_plugged
89+
if entity.coordinator.data.battery
90+
else None
91+
),
92+
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
93+
add_to_update=lambda entity: ("battery", ""),
94+
entity_registry_enabled_default=False,
95+
),
96+
)
97+
8498

8599
async def async_setup_entry(
86100
hass: HomeAssistant,
@@ -90,18 +104,30 @@ async def async_setup_entry(
90104
"""Set up System Monitor binary sensors based on a config entry."""
91105
coordinator = entry.runtime_data.coordinator
92106

93-
async_add_entities(
107+
entities: list[SystemMonitorSensor] = []
108+
109+
entities.extend(
94110
SystemMonitorSensor(
95111
coordinator,
96112
sensor_description,
97113
entry.entry_id,
98114
argument,
99115
)
100-
for sensor_description in SENSOR_TYPES
116+
for sensor_description in PROCESS_TYPES
101117
for argument in entry.options.get(BINARY_SENSOR_DOMAIN, {}).get(
102118
CONF_PROCESS, []
103119
)
104120
)
121+
entities.extend(
122+
SystemMonitorSensor(
123+
coordinator,
124+
sensor_description,
125+
entry.entry_id,
126+
"",
127+
)
128+
for sensor_description in BINARY_SENSOR_TYPES
129+
)
130+
async_add_entities(entities)
105131

106132

107133
class SystemMonitorSensor(

homeassistant/components/systemmonitor/coordinator.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import TYPE_CHECKING, Any, NamedTuple
1010

1111
from psutil import Process
12-
from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap
12+
from psutil._common import sbattery, sdiskusage, shwtemp, snetio, snicaddr, sswap
1313
import psutil_home_assistant as ha_psutil
1414

1515
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
@@ -22,6 +22,7 @@
2222

2323
if TYPE_CHECKING:
2424
from . import SystemMonitorConfigEntry
25+
from .util import read_fan_speed
2526

2627
_LOGGER = logging.getLogger(__name__)
2728

@@ -31,9 +32,11 @@ class SensorData:
3132
"""Sensor data."""
3233

3334
addresses: dict[str, list[snicaddr]]
35+
battery: sbattery | None
3436
boot_time: datetime
3537
cpu_percent: float | None
3638
disk_usage: dict[str, sdiskusage]
39+
fan_speed: dict[str, int]
3740
io_counters: dict[str, snetio]
3841
load: tuple[float, float, float]
3942
memory: VirtualMemory
@@ -50,17 +53,23 @@ def as_dict(self) -> dict[str, Any]:
5053
disk_usage = None
5154
if self.disk_usage:
5255
disk_usage = {k: str(v) for k, v in self.disk_usage.items()}
56+
fan_speed = None
57+
if self.fan_speed:
58+
fan_speed = {k: str(v) for k, v in self.fan_speed.items()}
5359
io_counters = None
5460
if self.io_counters:
5561
io_counters = {k: str(v) for k, v in self.io_counters.items()}
5662
temperatures = None
5763
if self.temperatures:
5864
temperatures = {k: str(v) for k, v in self.temperatures.items()}
65+
5966
return {
6067
"addresses": addresses,
68+
"battery": str(self.battery),
6169
"boot_time": str(self.boot_time),
6270
"cpu_percent": str(self.cpu_percent),
6371
"disk_usage": disk_usage,
72+
"fan_speed": fan_speed,
6473
"io_counters": io_counters,
6574
"load": str(self.load),
6675
"memory": str(self.memory),
@@ -125,8 +134,10 @@ def set_subscribers_tuples(
125134
return {
126135
**_disk_defaults,
127136
("addresses", ""): set(),
137+
("battery", ""): set(),
128138
("boot", ""): set(),
129139
("cpu_percent", ""): set(),
140+
("fan_speed", ""): set(),
130141
("io_counters", ""): set(),
131142
("load", ""): set(),
132143
("memory", ""): set(),
@@ -154,9 +165,11 @@ async def _async_update_data(self) -> SensorData:
154165
self._initial_update = False
155166
return SensorData(
156167
addresses=_data["addresses"],
168+
battery=_data["battery"],
157169
boot_time=_data["boot_time"],
158170
cpu_percent=cpu_percent,
159171
disk_usage=_data["disks"],
172+
fan_speed=_data["fan_speed"],
160173
io_counters=_data["io_counters"],
161174
load=load,
162175
memory=_data["memory"],
@@ -255,10 +268,29 @@ def update_data(self) -> dict[str, Any]:
255268
except AttributeError:
256269
_LOGGER.debug("OS does not provide temperature sensors")
257270

271+
fan_speed: dict[str, int] = {}
272+
if self.update_subscribers[("fan_speed", "")] or self._initial_update:
273+
try:
274+
fan_sensors = self._psutil.sensors_fans()
275+
fan_speed = read_fan_speed(fan_sensors)
276+
_LOGGER.debug("fan_speed: %s", fan_speed)
277+
except AttributeError:
278+
_LOGGER.debug("OS does not provide fan sensors")
279+
280+
battery: sbattery | None = None
281+
if self.update_subscribers[("battery", "")] or self._initial_update:
282+
try:
283+
battery = self._psutil.sensors_battery()
284+
_LOGGER.debug("battery: %s", battery)
285+
except AttributeError:
286+
_LOGGER.debug("OS does not provide battery sensors")
287+
258288
return {
259289
"addresses": addresses,
290+
"battery": battery,
260291
"boot_time": self.boot_time,
261292
"disks": disks,
293+
"fan_speed": fan_speed,
262294
"io_counters": io_counters,
263295
"memory": memory,
264296
"process_fds": process_fds,

homeassistant/components/systemmonitor/icons.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{
22
"entity": {
33
"sensor": {
4+
"battery_empty": {
5+
"default": "mdi:battery-clock"
6+
},
47
"disk_free": {
58
"default": "mdi:harddisk"
69
},
@@ -10,6 +13,9 @@
1013
"disk_use_percent": {
1114
"default": "mdi:harddisk"
1215
},
16+
"fan_speed": {
17+
"default": "mdi:fan"
18+
},
1319
"ipv4_address": {
1420
"default": "mdi:ip-network"
1521
},

homeassistant/components/systemmonitor/sensor.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import Callable
66
import contextlib
77
from dataclasses import dataclass
8-
from datetime import datetime
8+
from datetime import datetime, timedelta
99
from functools import lru_cache
1010
import ipaddress
1111
import logging
@@ -14,6 +14,8 @@
1414
import time
1515
from typing import Any, Literal
1616

17+
from psutil._common import POWER_TIME_UNKNOWN, POWER_TIME_UNLIMITED
18+
1719
from homeassistant.components.sensor import (
1820
DOMAIN as SENSOR_DOMAIN,
1921
SensorDeviceClass,
@@ -23,6 +25,7 @@
2325
)
2426
from homeassistant.const import (
2527
PERCENTAGE,
28+
REVOLUTIONS_PER_MINUTE,
2629
EntityCategory,
2730
UnitOfDataRate,
2831
UnitOfInformation,
@@ -34,7 +37,7 @@
3437
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
3538
from homeassistant.helpers.typing import StateType
3639
from homeassistant.helpers.update_coordinator import CoordinatorEntity
37-
from homeassistant.util import slugify
40+
from homeassistant.util import dt as dt_util, slugify
3841

3942
from . import SystemMonitorConfigEntry
4043
from .binary_sensor import BINARY_SENSOR_DOMAIN
@@ -55,7 +58,11 @@
5558

5659
SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update"
5760

61+
BATTERY_REMAIN_UNKNOWNS = (POWER_TIME_UNKNOWN, POWER_TIME_UNLIMITED)
62+
5863
SENSORS_NO_ARG = (
64+
"battery_empty",
65+
"battery",
5966
"last_boot",
6067
"load_",
6168
"memory_",
@@ -64,6 +71,7 @@
6471
)
6572
SENSORS_WITH_ARG = {
6673
"disk_": "disk_arguments",
74+
"fan_speed": "fan_speed_arguments",
6775
"ipv": "network_arguments",
6876
"process_num_fds": "processes",
6977
**dict.fromkeys(NET_IO_TYPES, "network_arguments"),
@@ -139,6 +147,17 @@ def get_process_num_fds(entity: SystemMonitorSensor) -> int | None:
139147
return process_fds.get(entity.argument)
140148

141149

150+
def battery_time_ends(entity: SystemMonitorSensor) -> datetime | None:
151+
"""Return when battery runs out, rounded to minute."""
152+
battery = entity.coordinator.data.battery
153+
if not battery or battery.secsleft in BATTERY_REMAIN_UNKNOWNS:
154+
return None
155+
156+
return (dt_util.utcnow() + timedelta(seconds=battery.secsleft)).replace(
157+
second=0, microsecond=0
158+
)
159+
160+
142161
@dataclass(frozen=True, kw_only=True)
143162
class SysMonitorSensorEntityDescription(SensorEntityDescription):
144163
"""Describes System Monitor sensor entities."""
@@ -151,6 +170,28 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription):
151170

152171

153172
SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
173+
"battery": SysMonitorSensorEntityDescription(
174+
key="battery",
175+
native_unit_of_measurement=PERCENTAGE,
176+
device_class=SensorDeviceClass.BATTERY,
177+
state_class=SensorStateClass.MEASUREMENT,
178+
value_fn=(
179+
lambda entity: entity.coordinator.data.battery.percent
180+
if entity.coordinator.data.battery
181+
else None
182+
),
183+
none_is_unavailable=True,
184+
add_to_update=lambda entity: ("battery", ""),
185+
),
186+
"battery_empty": SysMonitorSensorEntityDescription(
187+
key="battery_empty",
188+
translation_key="battery_empty",
189+
device_class=SensorDeviceClass.TIMESTAMP,
190+
state_class=SensorStateClass.MEASUREMENT,
191+
value_fn=battery_time_ends,
192+
none_is_unavailable=True,
193+
add_to_update=lambda entity: ("battery", ""),
194+
),
154195
"disk_free": SysMonitorSensorEntityDescription(
155196
key="disk_free",
156197
translation_key="disk_free",
@@ -199,6 +240,16 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription):
199240
none_is_unavailable=True,
200241
add_to_update=lambda entity: ("disks", entity.argument),
201242
),
243+
"fan_speed": SysMonitorSensorEntityDescription(
244+
key="fan_speed",
245+
translation_key="fan_speed",
246+
placeholder="fan_name",
247+
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
248+
state_class=SensorStateClass.MEASUREMENT,
249+
value_fn=lambda entity: entity.coordinator.data.fan_speed[entity.argument],
250+
none_is_unavailable=True,
251+
add_to_update=lambda entity: ("fan_speed", ""),
252+
),
202253
"ipv4_address": SysMonitorSensorEntityDescription(
203254
key="ipv4_address",
204255
translation_key="ipv4_address",
@@ -252,8 +303,8 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription):
252303
native_unit_of_measurement=UnitOfInformation.MEBIBYTES,
253304
device_class=SensorDeviceClass.DATA_SIZE,
254305
state_class=SensorStateClass.MEASUREMENT,
255-
value_fn=lambda entity: round(
256-
entity.coordinator.data.memory.available / 1024**2, 1
306+
value_fn=(
307+
lambda entity: round(entity.coordinator.data.memory.available / 1024**2, 1)
257308
),
258309
add_to_update=lambda entity: ("memory", ""),
259310
),
@@ -454,6 +505,7 @@ def get_arguments() -> dict[str, Any]:
454505
return {
455506
"disk_arguments": get_all_disk_mounts(hass, psutil_wrapper),
456507
"network_arguments": get_all_network_interfaces(hass, psutil_wrapper),
508+
"fan_speed_arguments": list(sensor_data.fan_speed),
457509
}
458510

459511
cpu_temperature: float | None = None

homeassistant/components/systemmonitor/strings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
}
1717
},
1818
"sensor": {
19+
"battery_empty": {
20+
"name": "Battery empty"
21+
},
1922
"disk_free": {
2023
"name": "Disk free {mount_point}"
2124
},
@@ -25,6 +28,9 @@
2528
"disk_use_percent": {
2629
"name": "Disk usage {mount_point}"
2730
},
31+
"fan_speed": {
32+
"name": "{fan_name} fan speed"
33+
},
2834
"ipv4_address": {
2935
"name": "IPv4 address {ip_address}"
3036
},

homeassistant/components/systemmonitor/util.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import os
55

6-
from psutil._common import shwtemp
6+
from psutil._common import sfan, shwtemp
77
import psutil_home_assistant as ha_psutil
88

99
from homeassistant.core import HomeAssistant
@@ -89,3 +89,19 @@ def read_cpu_temperature(temps: dict[str, list[shwtemp]]) -> float | None:
8989
return round(entry.current, 1)
9090

9191
return None
92+
93+
94+
def read_fan_speed(fans: dict[str, list[sfan]]) -> dict[str, int]:
95+
"""Attempt to read fan speed."""
96+
entry: sfan
97+
98+
_LOGGER.debug("Fan speed: %s", fans)
99+
if not fans:
100+
return {}
101+
sensor_fans: dict[str, int] = {}
102+
for name, entries in fans.items():
103+
for entry in entries:
104+
_label = name if not entry.label else entry.label
105+
sensor_fans[_label] = round(entry.current, 0)
106+
107+
return sensor_fans

0 commit comments

Comments
 (0)