diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 9a1b281eec2..4e5bdcc1543 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -1,5 +1,6 @@ """Coordinator for Glances integration.""" +from datetime import datetime, timedelta import logging from typing import Any @@ -10,6 +11,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import parse_duration, utcnow from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -42,4 +44,16 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise ConfigEntryAuthFailed from err except exceptions.GlancesApiError as err: raise UpdateFailed from err + # Update computed values + uptime: datetime | None = self.data["computed"]["uptime"] if self.data else None + up_duration: timedelta | None = None + if up_duration := parse_duration(data.get("uptime")): + # Update uptime if previous value is None or previous uptime is bigger than + # new uptime (i.e. server restarted) + if ( + self.data is None + or self.data["computed"]["uptime_duration"] > up_duration + ): + uptime = utcnow() - up_duration + data["computed"] = {"uptime_duration": up_duration, "uptime": uptime} return data or {} diff --git a/homeassistant/components/glances/icons.json b/homeassistant/components/glances/icons.json index 6a8c2fa728c..06f8cd98a07 100644 --- a/homeassistant/components/glances/icons.json +++ b/homeassistant/components/glances/icons.json @@ -45,6 +45,9 @@ }, "raid_used": { "default": "mdi:harddisk" + }, + "uptime": { + "default": "mdi:clock-time-eight-outline" } } } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 7db06a08496..5c22154aeef 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -3,14 +3,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import cast from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, - StateType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,7 +17,7 @@ from homeassistant.const import ( UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -212,6 +210,12 @@ SENSOR_TYPES = { translation_key="raid_used", state_class=SensorStateClass.MEASUREMENT, ), + ("computed", "uptime"): GlancesSensorEntityDescription( + key="uptime", + type="computed", + translation_key="uptime", + device_class=SensorDeviceClass.TIMESTAMP, + ), } @@ -276,6 +280,7 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit self._attr_unique_id = ( f"{coordinator.config_entry.entry_id}-{sensor_label}-{description.key}" ) + self._update_native_value() @property def available(self) -> bool: @@ -289,13 +294,18 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit ) return False - @property - def native_value(self) -> StateType: - """Return the state of the resources.""" - value = self.coordinator.data[self.entity_description.type] + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_native_value() + super()._handle_coordinator_update() - if isinstance(value.get(self._sensor_label), dict): - return cast( - StateType, value[self._sensor_label][self.entity_description.key] - ) - return cast(StateType, value[self.entity_description.key]) + def _update_native_value(self) -> None: + """Update sensor native value from coordinator data.""" + data = self.coordinator.data[self.entity_description.type] + if dict_val := data.get(self._sensor_label): + self._attr_native_value = dict_val.get(self.entity_description.key) + elif self.entity_description.key in data: + self._attr_native_value = data.get(self.entity_description.key) + else: + self._attr_native_value = None diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index b0b535ce8ed..10a4cb7ed00 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -100,6 +100,9 @@ }, "raid_used": { "name": "{sensor_label} used" + }, + "uptime": { + "name": "Uptime" } } }, diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index f0f1fe01796..fd0df3be3a9 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -1,5 +1,6 @@ """Tests for Glances.""" +from datetime import datetime from typing import Any MOCK_USER_INPUT: dict[str, Any] = { @@ -173,6 +174,8 @@ MOCK_DATA = { "uptime": "3 days, 10:25:20", } +MOCK_REFERENCE_DATE: datetime = datetime.fromisoformat("2024-02-13T14:13:12") + HA_SENSOR_DATA: dict[str, Any] = { "fs": { "/ssl": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, @@ -207,4 +210,5 @@ HA_SENSOR_DATA: dict[str, Any] = { "config": "UU", }, }, + "uptime": "3 days, 10:25:20", } diff --git a/tests/components/glances/snapshots/test_sensor.ambr b/tests/components/glances/snapshots/test_sensor.ambr index 23242f66071..cf74e91f613 100644 --- a/tests/components/glances/snapshots/test_sensor.ambr +++ b/tests/components/glances/snapshots/test_sensor.ambr @@ -954,3 +954,50 @@ 'state': '30.7', }) # --- +# name: test_sensor_states[sensor.0_0_0_0_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'test--uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '0.0.0.0 Uptime', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-10T03:47:52+00:00', + }) +# --- diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index ebe8b75b618..7dee47680ed 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -1,25 +1,36 @@ """Tests for glances sensors.""" +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion from homeassistant.components.glances.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MOCK_USER_INPUT +from . import HA_SENSOR_DATA, MOCK_REFERENCE_DATE, MOCK_USER_INPUT -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_sensor_states( - hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test sensor states are correctly collected from library.""" + freezer.move_to(MOCK_REFERENCE_DATE) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test") entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) assert entity_entries @@ -28,3 +39,35 @@ async def test_sensor_states( assert hass.states.get(entity_entry.entity_id) == snapshot( name=f"{entity_entry.entity_id}-state" ) + + +async def test_uptime_variation( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_api: AsyncMock +) -> None: + """Test uptime small variation update.""" + + # Init with reference time + freezer.move_to(MOCK_REFERENCE_DATE) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test") + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + uptime_state = hass.states.get("sensor.0_0_0_0_uptime").state + + # Time change should not change uptime (absolute date) + freezer.tick(delta=timedelta(seconds=120)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + uptime_state2 = hass.states.get("sensor.0_0_0_0_uptime").state + assert uptime_state2 == uptime_state + + mock_data = HA_SENSOR_DATA.copy() + mock_data["uptime"] = "1:25:20" + mock_api.return_value.get_ha_sensor_data = AsyncMock(return_value=mock_data) + + # Server has been restarted so therefore we should have a new state + freezer.move_to(MOCK_REFERENCE_DATE + timedelta(days=2)) + freezer.tick(delta=timedelta(seconds=120)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("sensor.0_0_0_0_uptime").state == "2024-02-15T12:49:52+00:00"