Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/airthings/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"id": "ID",
"secret": "Secret"
},
"description": "Login at {url} to find your credentials"
"description": "Log in at {url} to find your credentials"
}
},
"error": {
Expand Down
5 changes: 4 additions & 1 deletion homeassistant/components/systemmonitor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 27 additions & 1 deletion homeassistant/components/systemmonitor/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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

Expand Down Expand Up @@ -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."""
Expand All @@ -66,6 +67,7 @@ def as_dict(self) -> dict[str, Any]:
"boot_time": str(self.boot_time),
"processes": str(self.processes),
"temperatures": temperatures,
"process_fds": str(self.process_fds),
}


Expand Down Expand Up @@ -161,6 +163,7 @@ async def _async_update_data(self) -> SensorData:
boot_time=_data["boot_time"],
processes=_data["processes"],
temperatures=_data["temperatures"],
process_fds=_data["process_fds"],
)

def update_data(self) -> dict[str, Any]:
Expand Down Expand Up @@ -233,6 +236,28 @@ def update_data(self) -> dict[str, Any]:
)
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:
Expand All @@ -250,4 +275,5 @@ def update_data(self) -> dict[str, Any]:
"boot_time": self.boot_time,
"processes": selected_processes,
"temperatures": temps,
"process_fds": process_fds,
}
51 changes: 50 additions & 1 deletion homeassistant/components/systemmonitor/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -376,6 +383,16 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription):
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", ""),
),
}


Expand Down Expand Up @@ -482,6 +499,38 @@ def get_arguments() -> dict[str, Any]:
)
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:
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/systemmonitor/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@
},
"swap_use_percent": {
"name": "Swap usage"
},
"process_num_fds": {
"name": "Open file descriptors {process}"
}
}
}
Expand Down
29 changes: 28 additions & 1 deletion tests/components/systemmonitor/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,46 @@ 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."""
if self._ex:
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]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
34 changes: 26 additions & 8 deletions tests/components/systemmonitor/snapshots/test_sensor.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -114,25 +114,25 @@
# name: test_sensor[System Monitor Last boot - state]
'2024-02-24T15:00:00+00:00'
# ---
# name: test_sensor[System Monitor Load (15 min) - attributes]
# name: test_sensor[System Monitor Load (1 min) - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor Load (15 min)',
'friendly_name': 'System Monitor Load (1 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) - state]
'1'
# ---
# name: test_sensor[System Monitor Load (1 min) - attributes]
# name: test_sensor[System Monitor Load (15 min) - attributes]
ReadOnlyDict({
'friendly_name': 'System Monitor Load (1 min)',
'friendly_name': 'System Monitor Load (15 min)',
'icon': 'mdi:cpu-64-bit',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
})
# ---
# name: test_sensor[System Monitor Load (1 min) - state]
'1'
# name: test_sensor[System Monitor Load (15 min) - state]
'3'
# ---
# name: test_sensor[System Monitor Load (5 min) - attributes]
ReadOnlyDict({
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading