Add num open fds sensor to systemmonitor (#152441)

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
Stefan Agner
2025-10-03 10:53:02 +02:00
committed by GitHub
parent 7355799030
commit ec3dd7d1e5
8 changed files with 247 additions and 16 deletions
@@ -50,7 +50,10 @@ async def async_setup_entry(
_LOGGER.debug("disk arguments to be added: %s", disk_arguments)
coordinator: SystemMonitorCoordinator = SystemMonitorCoordinator(
hass, entry, psutil_wrapper, disk_arguments
hass,
entry,
psutil_wrapper,
disk_arguments,
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = SystemMonitorData(coordinator, psutil_wrapper)
@@ -8,7 +8,7 @@ import logging
import os
from typing import TYPE_CHECKING, Any, NamedTuple
from psutil import Process
from psutil import AccessDenied, NoSuchProcess, Process
from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap
import psutil_home_assistant as ha_psutil
@@ -40,6 +40,7 @@ class SensorData:
boot_time: datetime
processes: list[Process]
temperatures: dict[str, list[shwtemp]]
process_fds: dict[str, int]
def as_dict(self) -> dict[str, Any]:
"""Return as dict."""
@@ -66,6 +67,7 @@ class SensorData:
"boot_time": str(self.boot_time),
"processes": str(self.processes),
"temperatures": temperatures,
"process_fds": str(self.process_fds),
}
@@ -161,6 +163,7 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
boot_time=_data["boot_time"],
processes=_data["processes"],
temperatures=_data["temperatures"],
process_fds=_data["process_fds"],
)
def update_data(self) -> dict[str, Any]:
@@ -233,6 +236,28 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
)
continue
# Collect file descriptor counts only for selected processes
process_fds: dict[str, int] = {}
for proc in selected_processes:
try:
process_name = proc.name()
# Our sensors are a per-process name aggregation. Not ideal, but the only
# way to do it without user specifying PIDs which are not static.
process_fds[process_name] = (
process_fds.get(process_name, 0) + proc.num_fds()
)
except (NoSuchProcess, AccessDenied):
_LOGGER.warning(
"Failed to get file descriptor count for process %s: access denied or process not found",
proc.pid,
)
except OSError as err:
_LOGGER.warning(
"OS error getting file descriptor count for process %s: %s",
proc.pid,
err,
)
temps: dict[str, list[shwtemp]] = {}
if self.update_subscribers[("temperatures", "")] or self._initial_update:
try:
@@ -250,4 +275,5 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
"boot_time": self.boot_time,
"processes": selected_processes,
"temperatures": temps,
"process_fds": process_fds,
}
@@ -37,7 +37,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from . import SystemMonitorConfigEntry
from .const import DOMAIN, NET_IO_TYPES
from .binary_sensor import BINARY_SENSOR_DOMAIN
from .const import CONF_PROCESS, DOMAIN, NET_IO_TYPES
from .coordinator import SystemMonitorCoordinator
from .util import get_all_disk_mounts, get_all_network_interfaces, read_cpu_temperature
@@ -125,6 +126,12 @@ def get_ip_address(
return None
def get_process_num_fds(entity: SystemMonitorSensor) -> int | None:
"""Return the number of file descriptors opened by the process."""
process_fds = entity.coordinator.data.process_fds
return process_fds.get(entity.argument)
@dataclass(frozen=True, kw_only=True)
class SysMonitorSensorEntityDescription(SensorEntityDescription):
"""Describes System Monitor sensor entities."""
@@ -376,6 +383,16 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
value_fn=lambda entity: entity.coordinator.data.swap.percent,
add_to_update=lambda entity: ("swap", ""),
),
"process_num_fds": SysMonitorSensorEntityDescription(
key="process_num_fds",
translation_key="process_num_fds",
placeholder="process",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
mandatory_arg=True,
value_fn=get_process_num_fds,
add_to_update=lambda entity: ("processes", ""),
),
}
@@ -482,6 +499,38 @@ async def async_setup_entry(
)
continue
if _type == "process_num_fds":
# Create sensors for processes configured in binary_sensor section
processes = entry.options.get(BINARY_SENSOR_DOMAIN, {}).get(
CONF_PROCESS, []
)
_LOGGER.debug(
"Creating process_num_fds sensors for processes: %s", processes
)
for process in processes:
argument = process
is_enabled = check_legacy_resource(
f"{_type}_{argument}", legacy_resources
)
unique_id = slugify(f"{_type}_{argument}")
loaded_resources.add(unique_id)
_LOGGER.debug(
"Creating process_num_fds sensor: type=%s, process=%s, unique_id=%s, enabled=%s",
_type,
process,
unique_id,
is_enabled,
)
entities.append(
SystemMonitorSensor(
coordinator,
sensor_description,
entry.entry_id,
argument,
is_enabled,
)
)
continue
# Ensure legacy imported disk_* resources are loaded if they are not part
# of mount points automatically discovered
for resource in legacy_resources:
@@ -100,6 +100,9 @@
},
"swap_use_percent": {
"name": "Swap usage"
},
"process_num_fds": {
"name": "Open file descriptors {process}"
}
}
}
+28 -1
View File
@@ -27,12 +27,20 @@ def mock_sys_platform() -> Generator[None]:
class MockProcess(Process):
"""Mock a Process class."""
def __init__(self, name: str, ex: bool = False) -> None:
def __init__(
self,
name: str,
ex: bool = False,
num_fds: int | None = None,
raise_os_error: bool = False,
) -> None:
"""Initialize the process."""
super().__init__(1)
self._name = name
self._ex = ex
self._create_time = 1708700400
self._num_fds = num_fds
self._raise_os_error = raise_os_error
def name(self):
"""Return a name."""
@@ -40,6 +48,25 @@ class MockProcess(Process):
raise NoSuchProcess(1, self._name)
return self._name
def num_fds(self):
"""Return the number of file descriptors opened by this process."""
if self._ex:
raise NoSuchProcess(1, self._name)
if self._raise_os_error:
raise OSError("Permission denied")
# Use explicit num_fds if provided, otherwise use defaults
if self._num_fds is not None:
return self._num_fds
# Return different values for different processes for testing
if self._name == "python3":
return 42
if self._name == "pip":
return 15
return 10
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
@@ -22,6 +22,7 @@
}),
'load': '(1, 2, 3)',
'memory': 'VirtualMemory(total=104857600, available=41943040, percent=40.0, used=62914560, free=31457280)',
'process_fds': "{'python3': 42, 'pip': 15}",
'processes': "[tests.components.systemmonitor.conftest.MockProcess(pid=1, name='python3', status='sleeping', started='2024-02-23 15:00:00'), tests.components.systemmonitor.conftest.MockProcess(pid=1, name='pip', status='sleeping', started='2024-02-23 15:00:00')]",
'swap': 'sswap(total=104857600, used=62914560, free=41943040, percent=60.0, sin=1, sout=1)',
'temperatures': dict({
@@ -79,6 +80,7 @@
'io_counters': None,
'load': '(1, 2, 3)',
'memory': 'VirtualMemory(total=104857600, available=41943040, percent=40.0, used=62914560, free=31457280)',
'process_fds': "{'python3': 42, 'pip': 15}",
'processes': "[tests.components.systemmonitor.conftest.MockProcess(pid=1, name='python3', status='sleeping', started='2024-02-23 15:00:00'), tests.components.systemmonitor.conftest.MockProcess(pid=1, name='pip', status='sleeping', started='2024-02-23 15:00:00')]",
'swap': 'sswap(total=104857600, used=62914560, free=41943040, percent=60.0, sin=1, sout=1)',
'temperatures': dict({
@@ -114,16 +114,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 +124,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)',
@@ -264,6 +264,24 @@
# name: test_sensor[System Monitor Network throughput out eth1 - state]
'unknown'
# ---
# name: test_sensor[System Monitor Open file descriptors pip - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor Open file descriptors pip',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
})
# ---
# name: test_sensor[System Monitor Open file descriptors pip - state]
'15'
# ---
# name: test_sensor[System Monitor Open file descriptors python3 - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor Open file descriptors python3',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
})
# ---
# name: test_sensor[System Monitor Open file descriptors python3 - state]
'42'
# ---
# name: test_sensor[System Monitor Packets in eth0 - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor Packets in eth0',
+105 -2
View File
@@ -18,6 +18,8 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import MockProcess
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -420,6 +422,107 @@ async def test_cpu_percentage_is_zero_returns_unknown(
assert cpu_sensor.state == "15"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_python3_num_fds(
hass: HomeAssistant,
mock_psutil: Mock,
mock_os: Mock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test python3 open file descriptors sensor."""
mock_config_entry = MockConfigEntry(
title="System Monitor",
domain=DOMAIN,
data={},
options={
"binary_sensor": {"process": ["python3", "pip"]},
"resources": [
"disk_use_percent_/",
"disk_use_percent_/home/notexist/",
"memory_free_",
"network_out_eth0",
"process_num_fds_python3",
],
},
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
num_fds_sensor = hass.states.get(
"sensor.system_monitor_open_file_descriptors_python3"
)
assert num_fds_sensor is not None
assert num_fds_sensor.state == "42"
assert num_fds_sensor.attributes == {
"state_class": "measurement",
"friendly_name": "System Monitor Open file descriptors python3",
}
_process = MockProcess("python3", num_fds=5)
assert _process.num_fds() == 5
mock_psutil.process_iter.return_value = [_process]
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
num_fds_sensor = hass.states.get(
"sensor.system_monitor_open_file_descriptors_python3"
)
assert num_fds_sensor is not None
assert num_fds_sensor.state == "5"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_python3_num_fds_os_error(
hass: HomeAssistant,
mock_psutil: Mock,
mock_os: Mock,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test python3 open file descriptors sensor handles OSError gracefully."""
mock_config_entry = MockConfigEntry(
title="System Monitor",
domain=DOMAIN,
data={},
options={
"binary_sensor": {"process": ["python3", "pip"]},
"resources": [
"process_num_fds_python3",
],
},
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
num_fds_sensor = hass.states.get(
"sensor.system_monitor_open_file_descriptors_python3"
)
assert num_fds_sensor is not None
assert num_fds_sensor.state == "42"
_process = MockProcess("python3", raise_os_error=True)
mock_psutil.process_iter.return_value = [_process]
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Sensor should still exist but have no data (unavailable or previous value)
num_fds_sensor = hass.states.get(
"sensor.system_monitor_open_file_descriptors_python3"
)
assert num_fds_sensor is not None
assert num_fds_sensor.state == STATE_UNKNOWN
# Check that warning was logged
assert "OS error getting file descriptor count for process 1" in caplog.text
async def test_remove_obsolete_entities(
hass: HomeAssistant,
mock_psutil: Mock,
@@ -440,7 +543,7 @@ async def test_remove_obsolete_entities(
mock_added_config_entry.entry_id
)
)
== 37
== 39
)
entity_registry.async_update_entity(
@@ -481,7 +584,7 @@ async def test_remove_obsolete_entities(
mock_added_config_entry.entry_id
)
)
== 38
== 40
)
assert (