This commit is contained in:
G Johansson
2025-08-23 13:40:37 +00:00
parent 7ec7d85067
commit 9681b508ce
9 changed files with 131 additions and 25 deletions

View File

@@ -72,11 +72,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",
@@ -87,6 +87,17 @@ SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
),
)
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", ""),
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -96,18 +107,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(

View File

@@ -1,8 +1,8 @@
{
"entity": {
"sensor": {
"battery": {
"default": "mdi:battery"
"battery_left": {
"default": "mdi:battery-clock"
},
"disk_free": {
"default": "mdi:harddisk"

View File

@@ -14,6 +14,8 @@ import sys
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,
@@ -28,6 +30,7 @@ from homeassistant.const import (
UnitOfDataRate,
UnitOfInformation,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
@@ -55,6 +58,8 @@ SENSOR_TYPE_MANDATORY_ARG = 4
SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update"
BATTERY_REMAIN_UNKNOWNS = (POWER_TIME_UNKNOWN, POWER_TIME_UNLIMITED)
@lru_cache
def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]:
@@ -119,6 +124,14 @@ def get_ip_address(
return None
def battery_seconds_left(entity: SystemMonitorSensor) -> int | None:
"""Return remaining battery time in seconds."""
battery = entity.coordinator.data.battery
if not battery or battery.secsleft in BATTERY_REMAIN_UNKNOWNS:
return None
return battery.secsleft
@dataclass(frozen=True, kw_only=True)
class SysMonitorSensorEntityDescription(SensorEntityDescription):
"""Describes System Monitor sensor entities."""
@@ -133,7 +146,6 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription):
SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
"battery": SysMonitorSensorEntityDescription(
key="battery",
translation_key="battery",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
@@ -143,6 +155,17 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
none_is_unavailable=True,
add_to_update=lambda entity: ("battery", ""),
),
"battery_left": SysMonitorSensorEntityDescription(
key="battery_left",
translation_key="battery_left",
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=battery_seconds_left,
none_is_unavailable=True,
add_to_update=lambda entity: ("battery", ""),
suggested_unit_of_measurement=UnitOfTime.MINUTES,
),
"disk_free": SysMonitorSensorEntityDescription(
key="disk_free",
translation_key="disk_free",
@@ -190,7 +213,7 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
"fan_rpm": SysMonitorSensorEntityDescription(
key="fan_rpm",
translation_key="fan_rpm",
placeholder="name",
placeholder="fan_name",
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.coordinator.data.fan_rpm[entity.argument],
@@ -590,7 +613,7 @@ async def async_setup_entry( # noqa: C901
)
)
if _type == "battery":
if _type.startswith("battery"):
argument = ""
loaded_resources.add(slugify(f"{_type}_{argument}"))
entities.append(

View File

@@ -29,7 +29,7 @@
}
},
"sensor": {
"battery": {
"battery_left": {
"name": "Battery"
},
"disk_free": {
@@ -42,7 +42,7 @@
"name": "Disk usage {mount_point}"
},
"fan_rpm": {
"name": "{name} fan RPM"
"name": "{fan_name} fan RPM"
},
"ipv4_address": {
"name": "IPv4 address {ip_address}"

View File

@@ -193,8 +193,7 @@ def mock_psutil(mock_process: list[MockProcess]) -> Generator:
mock_psutil.NoSuchProcess = NoSuchProcess
# mock_psutil.sensors_fans = Mock()
mock_psutil.sensors_fans.return_value = {
"fan1": [sfan("fan1", 1200)],
"fan2": [sfan("fan2", 1300)],
"asus": [sfan("cpu-fan", 1200), sfan("another-fan", 1300)],
}
mock_psutil.sensors_battery.return_value = sbattery(
percent=93, secsleft=16628, power_plugged=False

View File

@@ -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',

View File

@@ -8,6 +8,7 @@
'eth1': "[snicaddr(family=<AddressFamily.AF_INET: 2>, address='192.168.10.1', netmask='255.255.255.0', broadcast='255.255.255.255', ptp=None)]",
'vethxyzxyz': "[snicaddr(family=<AddressFamily.AF_INET: 2>, 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_rpm': 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)',
@@ -69,6 +74,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({
@@ -76,6 +82,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_rpm': 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)',

View File

@@ -1,4 +1,15 @@
# serializer version: 1
# name: test_sensor[System Monitor Battery - attributes]
ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'System Monitor Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
})
# ---
# name: test_sensor[System Monitor Battery - state]
'93'
# ---
# name: test_sensor[System Monitor Disk free / - attributes]
ReadOnlyDict({
'device_class': 'data_size',
@@ -73,6 +84,17 @@
# name: test_sensor[System Monitor Disk use /media/share - state]
'300.0'
# ---
# name: test_sensor[System Monitor Duration - attributes]
ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'System Monitor Duration',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_sensor[System Monitor Duration - state]
'16628'
# ---
# name: test_sensor[System Monitor IPv4 address eth0 - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor IPv4 address eth0',
@@ -114,16 +136,6 @@
# name: test_sensor[System Monitor Last boot - state]
'2024-02-24T15:00:00+00:00'
# ---
# name: test_sensor[System Monitor Load (15 min) - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor Load (15 min)',
'icon': 'mdi:cpu-64-bit',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
})
# ---
# name: test_sensor[System Monitor Load (15 min) - state]
'3'
# ---
# name: test_sensor[System Monitor Load (1 min) - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor Load (1 min)',
@@ -134,6 +146,16 @@
# name: test_sensor[System Monitor Load (1 min) - state]
'1'
# ---
# name: test_sensor[System Monitor Load (15 min) - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor Load (15 min)',
'icon': 'mdi:cpu-64-bit',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
})
# ---
# name: test_sensor[System Monitor Load (15 min) - state]
'3'
# ---
# name: test_sensor[System Monitor Load (5 min) - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor Load (5 min)',
@@ -354,3 +376,23 @@
# name: test_sensor[System Monitor Swap use - state]
'60.0'
# ---
# name: test_sensor[System Monitor another-fan fan RPM - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor another-fan fan RPM',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'rpm',
})
# ---
# name: test_sensor[System Monitor another-fan fan RPM - state]
'1300'
# ---
# name: test_sensor[System Monitor cpu-fan fan RPM - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor cpu-fan fan RPM',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'rpm',
})
# ---
# name: test_sensor[System Monitor cpu-fan fan RPM - state]
'1200'
# ---

View File

@@ -453,7 +453,7 @@ async def test_remove_obsolete_entities(
mock_added_config_entry.entry_id
)
)
== 37
== 42
)
entity_registry.async_update_entity(
@@ -494,7 +494,7 @@ async def test_remove_obsolete_entities(
mock_added_config_entry.entry_id
)
)
== 38
== 43
)
assert (