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): class SysMonitorBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes System Monitor binary sensor entities.""" """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]] add_to_update: Callable[[SystemMonitorSensor], tuple[str, str]]
SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = ( PROCESS_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
SysMonitorBinarySensorEntityDescription( SysMonitorBinarySensorEntityDescription(
key="binary_process", key="binary_process",
translation_key="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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -96,18 +107,30 @@ async def async_setup_entry(
"""Set up System Monitor binary sensors based on a config entry.""" """Set up System Monitor binary sensors based on a config entry."""
coordinator = entry.runtime_data.coordinator coordinator = entry.runtime_data.coordinator
async_add_entities( entities: list[SystemMonitorSensor] = []
entities.extend(
SystemMonitorSensor( SystemMonitorSensor(
coordinator, coordinator,
sensor_description, sensor_description,
entry.entry_id, entry.entry_id,
argument, argument,
) )
for sensor_description in SENSOR_TYPES for sensor_description in PROCESS_TYPES
for argument in entry.options.get(BINARY_SENSOR_DOMAIN, {}).get( for argument in entry.options.get(BINARY_SENSOR_DOMAIN, {}).get(
CONF_PROCESS, [] CONF_PROCESS, []
) )
) )
entities.extend(
SystemMonitorSensor(
coordinator,
sensor_description,
entry.entry_id,
"",
)
for sensor_description in BINARY_SENSOR_TYPES
)
async_add_entities(entities)
class SystemMonitorSensor( class SystemMonitorSensor(

View File

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

View File

@@ -14,6 +14,8 @@ import sys
import time import time
from typing import Any, Literal from typing import Any, Literal
from psutil._common import POWER_TIME_UNKNOWN, POWER_TIME_UNLIMITED
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN, DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass, SensorDeviceClass,
@@ -28,6 +30,7 @@ from homeassistant.const import (
UnitOfDataRate, UnitOfDataRate,
UnitOfInformation, UnitOfInformation,
UnitOfTemperature, UnitOfTemperature,
UnitOfTime,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@@ -55,6 +58,8 @@ SENSOR_TYPE_MANDATORY_ARG = 4
SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update"
BATTERY_REMAIN_UNKNOWNS = (POWER_TIME_UNKNOWN, POWER_TIME_UNLIMITED)
@lru_cache @lru_cache
def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]: def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]:
@@ -119,6 +124,14 @@ def get_ip_address(
return None 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) @dataclass(frozen=True, kw_only=True)
class SysMonitorSensorEntityDescription(SensorEntityDescription): class SysMonitorSensorEntityDescription(SensorEntityDescription):
"""Describes System Monitor sensor entities.""" """Describes System Monitor sensor entities."""
@@ -133,7 +146,6 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription):
SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
"battery": SysMonitorSensorEntityDescription( "battery": SysMonitorSensorEntityDescription(
key="battery", key="battery",
translation_key="battery",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY, device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -143,6 +155,17 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
none_is_unavailable=True, none_is_unavailable=True,
add_to_update=lambda entity: ("battery", ""), 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( "disk_free": SysMonitorSensorEntityDescription(
key="disk_free", key="disk_free",
translation_key="disk_free", translation_key="disk_free",
@@ -190,7 +213,7 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
"fan_rpm": SysMonitorSensorEntityDescription( "fan_rpm": SysMonitorSensorEntityDescription(
key="fan_rpm", key="fan_rpm",
translation_key="fan_rpm", translation_key="fan_rpm",
placeholder="name", placeholder="fan_name",
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.coordinator.data.fan_rpm[entity.argument], 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 = "" argument = ""
loaded_resources.add(slugify(f"{_type}_{argument}")) loaded_resources.add(slugify(f"{_type}_{argument}"))
entities.append( entities.append(

View File

@@ -29,7 +29,7 @@
} }
}, },
"sensor": { "sensor": {
"battery": { "battery_left": {
"name": "Battery" "name": "Battery"
}, },
"disk_free": { "disk_free": {
@@ -42,7 +42,7 @@
"name": "Disk usage {mount_point}" "name": "Disk usage {mount_point}"
}, },
"fan_rpm": { "fan_rpm": {
"name": "{name} fan RPM" "name": "{fan_name} fan RPM"
}, },
"ipv4_address": { "ipv4_address": {
"name": "IPv4 address {ip_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.NoSuchProcess = NoSuchProcess
# mock_psutil.sensors_fans = Mock() # mock_psutil.sensors_fans = Mock()
mock_psutil.sensors_fans.return_value = { mock_psutil.sensors_fans.return_value = {
"fan1": [sfan("fan1", 1200)], "asus": [sfan("cpu-fan", 1200), sfan("another-fan", 1300)],
"fan2": [sfan("fan2", 1300)],
} }
mock_psutil.sensors_battery.return_value = sbattery( mock_psutil.sensors_battery.return_value = sbattery(
percent=93, secsleft=16628, power_plugged=False percent=93, secsleft=16628, power_plugged=False

View File

@@ -1,4 +1,13 @@
# serializer version: 1 # 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] # name: test_binary_sensor[System Monitor Process pip - attributes]
ReadOnlyDict({ ReadOnlyDict({
'device_class': 'running', '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)]", '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)]", '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', 'boot_time': '2024-02-24 15:00:00+00:00',
'cpu_percent': '10.0', 'cpu_percent': '10.0',
'disk_usage': dict({ 'disk_usage': dict({
@@ -15,6 +16,10 @@
'/home/notexist/': 'sdiskusage(total=536870912000, used=322122547200, free=214748364800, percent=60.0)', '/home/notexist/': 'sdiskusage(total=536870912000, used=322122547200, free=214748364800, percent=60.0)',
'/media/share': '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({ '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)', '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)', '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({ 'coordinators': dict({
'data': dict({ 'data': dict({
'addresses': None, 'addresses': None,
'battery': 'sbattery(percent=93, secsleft=16628, power_plugged=False)',
'boot_time': '2024-02-24 15:00:00+00:00', 'boot_time': '2024-02-24 15:00:00+00:00',
'cpu_percent': '10.0', 'cpu_percent': '10.0',
'disk_usage': dict({ 'disk_usage': dict({
@@ -76,6 +82,10 @@
'/home/notexist/': 'sdiskusage(total=536870912000, used=322122547200, free=214748364800, percent=60.0)', '/home/notexist/': 'sdiskusage(total=536870912000, used=322122547200, free=214748364800, percent=60.0)',
'/media/share': '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, 'io_counters': None,
'load': '(1, 2, 3)', 'load': '(1, 2, 3)',
'memory': 'VirtualMemory(total=104857600, available=41943040, percent=40.0, used=62914560, free=31457280)', 'memory': 'VirtualMemory(total=104857600, available=41943040, percent=40.0, used=62914560, free=31457280)',

View File

@@ -1,4 +1,15 @@
# serializer version: 1 # 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] # name: test_sensor[System Monitor Disk free / - attributes]
ReadOnlyDict({ ReadOnlyDict({
'device_class': 'data_size', 'device_class': 'data_size',
@@ -73,6 +84,17 @@
# name: test_sensor[System Monitor Disk use /media/share - state] # name: test_sensor[System Monitor Disk use /media/share - state]
'300.0' '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] # name: test_sensor[System Monitor IPv4 address eth0 - attributes]
ReadOnlyDict({ ReadOnlyDict({
'friendly_name': 'System Monitor IPv4 address eth0', 'friendly_name': 'System Monitor IPv4 address eth0',
@@ -114,16 +136,6 @@
# name: test_sensor[System Monitor Last boot - state] # name: test_sensor[System Monitor Last boot - state]
'2024-02-24T15:00:00+00:00' '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] # name: test_sensor[System Monitor Load (1 min) - attributes]
ReadOnlyDict({ ReadOnlyDict({
'friendly_name': 'System Monitor Load (1 min)', 'friendly_name': 'System Monitor Load (1 min)',
@@ -134,6 +146,16 @@
# name: test_sensor[System Monitor Load (1 min) - state] # name: test_sensor[System Monitor Load (1 min) - state]
'1' '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] # name: test_sensor[System Monitor Load (5 min) - attributes]
ReadOnlyDict({ ReadOnlyDict({
'friendly_name': 'System Monitor Load (5 min)', 'friendly_name': 'System Monitor Load (5 min)',
@@ -354,3 +376,23 @@
# name: test_sensor[System Monitor Swap use - state] # name: test_sensor[System Monitor Swap use - state]
'60.0' '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 mock_added_config_entry.entry_id
) )
) )
== 37 == 42
) )
entity_registry.async_update_entity( entity_registry.async_update_entity(
@@ -494,7 +494,7 @@ async def test_remove_obsolete_entities(
mock_added_config_entry.entry_id mock_added_config_entry.entry_id
) )
) )
== 38 == 43
) )
assert ( assert (