mirror of
https://github.com/home-assistant/core.git
synced 2026-05-03 19:41:15 +02:00
Add num open fds sensor to systemmonitor (#152441)
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user