diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json index 4135e3fd387b73..2994c25ed4362e 100644 --- a/homeassistant/components/airthings/strings.json +++ b/homeassistant/components/airthings/strings.json @@ -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": { diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index 98620d957d289f..25027048c72e45 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -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) diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index 36dfff898f7aed..87e7a3eb5915e6 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -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 @@ -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 @@ 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), } @@ -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]: @@ -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: @@ -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, } diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 31e6b0f6572b29..6e3fac7d6354a4 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -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 @@ -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 @@ 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", ""), + ), } @@ -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: diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index 134fe390357905..442b9f60790291 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -100,6 +100,9 @@ }, "swap_use_percent": { "name": "Swap usage" + }, + "process_num_fds": { + "name": "Open file descriptors {process}" } } } diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index a5aa15d8b0a5e4..c44eed77c26a89 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -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 @@ def name(self): 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]: diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index afa508cc0048ce..7f53bef3fef2dd 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -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({ diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index 8108e4777c8a00..0ef5375341dbbf 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -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': , }) # --- -# 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': , }) # --- -# 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({ @@ -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': , + }) +# --- +# 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': , + }) +# --- +# 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', diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index 9b942257ec17ac..e22f8e14d3d000 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -18,6 +18,8 @@ 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 (