mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
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:
parent
8ebdd46509
commit
7919ca63d0
@ -1,5 +1,6 @@
|
|||||||
"""Coordinator for Glances integration."""
|
"""Coordinator for Glances integration."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ from homeassistant.const import CONF_HOST
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
from homeassistant.util.dt import parse_duration, utcnow
|
||||||
|
|
||||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||||
|
|
||||||
@ -42,4 +44,16 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
raise ConfigEntryAuthFailed from err
|
raise ConfigEntryAuthFailed from err
|
||||||
except exceptions.GlancesApiError as err:
|
except exceptions.GlancesApiError as err:
|
||||||
raise UpdateFailed from 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 {}
|
return data or {}
|
||||||
|
@ -45,6 +45,9 @@
|
|||||||
},
|
},
|
||||||
"raid_used": {
|
"raid_used": {
|
||||||
"default": "mdi:harddisk"
|
"default": "mdi:harddisk"
|
||||||
|
},
|
||||||
|
"uptime": {
|
||||||
|
"default": "mdi:clock-time-eight-outline"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,14 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
SensorEntityDescription,
|
SensorEntityDescription,
|
||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
StateType,
|
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
@ -19,7 +17,7 @@ from homeassistant.const import (
|
|||||||
UnitOfInformation,
|
UnitOfInformation,
|
||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
@ -212,6 +210,12 @@ SENSOR_TYPES = {
|
|||||||
translation_key="raid_used",
|
translation_key="raid_used",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
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 = (
|
self._attr_unique_id = (
|
||||||
f"{coordinator.config_entry.entry_id}-{sensor_label}-{description.key}"
|
f"{coordinator.config_entry.entry_id}-{sensor_label}-{description.key}"
|
||||||
)
|
)
|
||||||
|
self._update_native_value()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
@ -289,13 +294,18 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@callback
|
||||||
def native_value(self) -> StateType:
|
def _handle_coordinator_update(self) -> None:
|
||||||
"""Return the state of the resources."""
|
"""Handle updated data from the coordinator."""
|
||||||
value = self.coordinator.data[self.entity_description.type]
|
self._update_native_value()
|
||||||
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
if isinstance(value.get(self._sensor_label), dict):
|
def _update_native_value(self) -> None:
|
||||||
return cast(
|
"""Update sensor native value from coordinator data."""
|
||||||
StateType, value[self._sensor_label][self.entity_description.key]
|
data = self.coordinator.data[self.entity_description.type]
|
||||||
)
|
if dict_val := data.get(self._sensor_label):
|
||||||
return cast(StateType, value[self.entity_description.key])
|
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
|
||||||
|
@ -100,6 +100,9 @@
|
|||||||
},
|
},
|
||||||
"raid_used": {
|
"raid_used": {
|
||||||
"name": "{sensor_label} used"
|
"name": "{sensor_label} used"
|
||||||
|
},
|
||||||
|
"uptime": {
|
||||||
|
"name": "Uptime"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""Tests for Glances."""
|
"""Tests for Glances."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
MOCK_USER_INPUT: dict[str, Any] = {
|
MOCK_USER_INPUT: dict[str, Any] = {
|
||||||
@ -173,6 +174,8 @@ MOCK_DATA = {
|
|||||||
"uptime": "3 days, 10:25:20",
|
"uptime": "3 days, 10:25:20",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MOCK_REFERENCE_DATE: datetime = datetime.fromisoformat("2024-02-13T14:13:12")
|
||||||
|
|
||||||
HA_SENSOR_DATA: dict[str, Any] = {
|
HA_SENSOR_DATA: dict[str, Any] = {
|
||||||
"fs": {
|
"fs": {
|
||||||
"/ssl": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5},
|
"/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",
|
"config": "UU",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"uptime": "3 days, 10:25:20",
|
||||||
}
|
}
|
||||||
|
@ -954,3 +954,50 @@
|
|||||||
'state': '30.7',
|
'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',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
@ -1,25 +1,36 @@
|
|||||||
"""Tests for glances sensors."""
|
"""Tests for glances sensors."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
from syrupy import SnapshotAssertion
|
from syrupy import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components.glances.const import DOMAIN
|
from homeassistant.components.glances.const import DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
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(
|
async def test_sensor_states(
|
||||||
hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry
|
hass: HomeAssistant,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test sensor states are correctly collected from library."""
|
"""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 = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test")
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
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)
|
entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
|
||||||
|
|
||||||
assert entity_entries
|
assert entity_entries
|
||||||
@ -28,3 +39,35 @@ async def test_sensor_states(
|
|||||||
assert hass.states.get(entity_entry.entity_id) == snapshot(
|
assert hass.states.get(entity_entry.entity_id) == snapshot(
|
||||||
name=f"{entity_entry.entity_id}-state"
|
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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user