diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index e70bccf0833..be277f8b565 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -118,6 +118,17 @@ def get_ip_address( return None +def get_python3_num_fds( + entity: SystemMonitorSensor, +) -> int | None: + """Return num_fds from python3 process.""" + processes = entity.coordinator.data.processes + for proc in processes: + if proc.name() == "python3": + return proc.num_fds() + return None + + @dataclass(frozen=True, kw_only=True) class SysMonitorSensorEntityDescription(SensorEntityDescription): """Describes System Monitor sensor entities.""" @@ -369,6 +380,13 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { value_fn=lambda entity: entity.coordinator.data.swap.percent, add_to_update=lambda entity: ("swap", ""), ), + "python3_num_fds": SysMonitorSensorEntityDescription( + key="python3_num_fds", + translation_key="python3_num_fds", + state_class=SensorStateClass.MEASUREMENT, + value_fn=get_python3_num_fds, + add_to_update=lambda entity: ("processes", ""), + ), } @@ -566,6 +584,18 @@ async def async_setup_entry( is_enabled, ) ) + if _type == "python3_num_fds": + argument = "" + loaded_resources.add(slugify(f"{_type}_{argument}")) + entities.append( + SystemMonitorSensor( + coordinator, + sensor_description, + entry.entry_id, + argument, + True, # Enabled by default + ) + ) # Ensure legacy imported disk_* resources are loaded if they are not part # of mount points automatically discovered diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index 134fe390357..d0108993b6e 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -100,6 +100,9 @@ }, "swap_use_percent": { "name": "Swap usage" + }, + "python3_num_fds": { + "name": "Python3 open file descriptors" } } } diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index 5f0a7a5c76d..0efbe6327a2 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -27,12 +27,13 @@ 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 = 3) -> None: """Initialize the process.""" super().__init__(1) self._name = name self._ex = ex self._create_time = 1708700400 + self._num_fds = num_fds def name(self): """Return a name.""" @@ -40,6 +41,10 @@ class MockProcess(Process): raise NoSuchProcess(1, self._name) return self._name + def num_fds(self): + """Return open file descriptors.""" + return self._num_fds + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index 8108e4777c8..de2eb56f88c 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -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': , - }) -# --- -# 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': , + }) +# --- +# 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)', @@ -322,6 +322,15 @@ # name: test_sensor[System Monitor Processor use - state] '10' # --- +# name: test_sensor[System Monitor Python3 open file descriptors - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Python3 open file descriptors', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Python3 open file descriptors - state] + '3' +# --- # name: test_sensor[System Monitor Swap free - attributes] ReadOnlyDict({ 'device_class': 'data_size', diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index a5f5e7623e9..3c4d41b8a58 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -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 @@ -453,7 +455,7 @@ async def test_remove_obsolete_entities( mock_added_config_entry.entry_id ) ) - == 37 + == 38 ) entity_registry.async_update_entity( @@ -494,7 +496,7 @@ async def test_remove_obsolete_entities( mock_added_config_entry.entry_id ) ) - == 38 + == 39 ) assert ( @@ -544,3 +546,56 @@ async def test_no_duplicate_disk_entities( assert disk_sensor.state == "60.0" assert "Platform systemmonitor does not generate unique IDs." not in caplog.text + + +@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={ + "resources": [ + "disk_use_percent_/", + "disk_use_percent_/home/notexist/", + "memory_free_", + "network_out_eth0", + "process_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_python3_open_file_descriptors" + ) + assert num_fds_sensor is not None + assert num_fds_sensor.state == "3" + assert num_fds_sensor.attributes == { + "state_class": "measurement", + "friendly_name": "System Monitor Python3 open file descriptors", + } + + _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_python3_open_file_descriptors" + ) + assert num_fds_sensor is not None + assert num_fds_sensor.state == "5"