Add uptime sensor to Glances (#111402)

* Add uptime sensor to Glances

* Merge upstream

* Merge upstream

* Fix coverage

* Add uptime sensor to Glances

* Merge upstream

* Merge upstream

* Fix coverage

* Move most uptime specific code to DataUpdateCoordinator

* Add last_reported after merge with upstream

* Add unit tests for uptime sensor

* Add unit tests for uptime sensor

* Add unit tests for uptime sensor

* Add unit tests for uptime sensor

* Move update code out of getter native_value()

* Add unit tests for uptime sensor

* Update uptime method signatures

* Set uptime icon in icons.json

* Use freezer.tick for uptime tests

* Frozen time test fails on github

* Add MIN_UPTIME_VARIATION const value

* Only update uptime on startup or when remote server restarts

* Fix for 0 values

* Set value to None to set state to Unknown if key is not found

* Add unit test for uptime change

* Code reduction

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
wittypluck 2024-03-31 20:04:39 +02:00 committed by GitHub
parent 8ebdd46509
commit 7919ca63d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 139 additions and 15 deletions

View File

@ -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 {}

View File

@ -45,6 +45,9 @@
},
"raid_used": {
"default": "mdi:harddisk"
},
"uptime": {
"default": "mdi:clock-time-eight-outline"
}
}
}

View File

@ -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

View File

@ -100,6 +100,9 @@
},
"raid_used": {
"name": "{sensor_label} used"
},
"uptime": {
"name": "Uptime"
}
}
},

View File

@ -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",
}

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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': <ANY>,
'entity_id': 'sensor.0_0_0_0_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2024-02-10T03:47:52+00:00',
})
# ---

View File

@ -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"