From f56c38d69b82fe359539e386f9a1ead72470a0de Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 5 Sep 2024 04:31:56 +1000 Subject: [PATCH] Add uptime sensors for Smlight (#124408) * Add uptime sensor as derived sensor class * Add strings for uptime sensors * Update sensor tests to include uptime sensors * test zigbee uptime when disconnected --- homeassistant/components/smlight/const.py | 1 + homeassistant/components/smlight/sensor.py | 70 ++++++- homeassistant/components/smlight/strings.json | 6 + .../smlight/snapshots/test_sensor.ambr | 188 ++++++++++++++++++ tests/components/smlight/test_sensor.py | 25 ++- 5 files changed, 286 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index de3270fe3be..791b00c3e93 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -9,3 +9,4 @@ ATTR_MANUFACTURER = "SMLIGHT" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) +UPTIME_DEVIATION = timedelta(seconds=5) diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index d9c03760fb8..f5193522c4c 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta +from itertools import chain from pysmlight import Sensors @@ -16,8 +18,10 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import utcnow from . import SmConfigEntry +from .const import UPTIME_DEVIATION from .coordinator import SmDataUpdateCoordinator from .entity import SmEntity @@ -67,6 +71,23 @@ SENSORS = [ ), ] +UPTIME = [ + SmSensorEntityDescription( + key="core_uptime", + translation_key="core_uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + value_fn=lambda x: x.uptime, + ), + SmSensorEntityDescription( + key="socket_uptime", + translation_key="socket_uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + value_fn=lambda x: x.socket_uptime, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -77,7 +98,10 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - SmSensorEntity(coordinator, description) for description in SENSORS + chain( + (SmSensorEntity(coordinator, description) for description in SENSORS), + (SmUptimeSensorEntity(coordinator, description) for description in UPTIME), + ) ) @@ -98,6 +122,48 @@ class SmSensorEntity(SmEntity, SensorEntity): self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" @property - def native_value(self) -> float | None: + def native_value(self) -> datetime | float | None: """Return the sensor value.""" return self.entity_description.value_fn(self.coordinator.data.sensors) + + +class SmUptimeSensorEntity(SmSensorEntity): + """Representation of a slzb uptime sensor.""" + + def __init__( + self, + coordinator: SmDataUpdateCoordinator, + description: SmSensorEntityDescription, + ) -> None: + "Initialize uptime sensor instance." + super().__init__(coordinator, description) + self._last_uptime: datetime | None = None + + def get_uptime(self, uptime: float | None) -> datetime | None: + """Return device uptime or zigbee socket uptime. + + Converts uptime from seconds to a datetime value, allow up to 5 + seconds deviation. This avoids unnecessary updates to sensor state, + that may be caused by clock jitter. + """ + if uptime is None: + # reset to unknown state + self._last_uptime = None + return None + + new_uptime = utcnow() - timedelta(seconds=uptime) + + if ( + not self._last_uptime + or abs(new_uptime - self._last_uptime) > UPTIME_DEVIATION + ): + self._last_uptime = new_uptime + + return self._last_uptime + + @property + def native_value(self) -> datetime | None: + """Return the sensor value.""" + value = self.entity_description.value_fn(self.coordinator.data.sensors) + + return self.get_uptime(value) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index f81e977b40c..41f84c49bf9 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -43,6 +43,12 @@ }, "ram_usage": { "name": "RAM usage" + }, + "core_uptime": { + "name": "Core uptime" + }, + "socket_uptime": { + "name": "Zigbee uptime" } }, "button": { diff --git a/tests/components/smlight/snapshots/test_sensor.ambr b/tests/components/smlight/snapshots/test_sensor.ambr index 0ff3d37b735..6895a8473bd 100644 --- a/tests/components/smlight/snapshots/test_sensor.ambr +++ b/tests/components/smlight/snapshots/test_sensor.ambr @@ -53,6 +53,53 @@ 'state': '35.0', }) # --- +# name: test_sensors[sensor.mock_title_core_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': , + 'entity_id': 'sensor.mock_title_core_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': 'Core uptime', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'core_uptime', + 'unique_id': 'aa:bb:cc:dd:ee:ff_core_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_core_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Core uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_core_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-25T02:51:15+00:00', + }) +# --- # name: test_sensors[sensor.mock_title_filesystem_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -149,6 +196,100 @@ 'state': '99', }) # --- +# name: test_sensors[sensor.mock_title_timestamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_timestamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timestamp', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'core_uptime', + 'unique_id': 'aa:bb:cc:dd:ee:ff_core_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_timestamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Timestamp', + }), + 'context': , + 'entity_id': 'sensor.mock_title_timestamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-25T02:51:15+00:00', + }) +# --- +# name: test_sensors[sensor.mock_title_timestamp_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_timestamp_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timestamp', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'socket_uptime', + 'unique_id': 'aa:bb:cc:dd:ee:ff_socket_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_timestamp_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Timestamp', + }), + 'context': , + 'entity_id': 'sensor.mock_title_timestamp_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-30T23:57:53+00:00', + }) +# --- # name: test_sensors[sensor.mock_title_zigbee_chip_temp-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -203,6 +344,53 @@ 'state': '32.7', }) # --- +# name: test_sensors[sensor.mock_title_zigbee_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': , + 'entity_id': 'sensor.mock_title_zigbee_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': 'Zigbee uptime', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'socket_uptime', + 'unique_id': 'aa:bb:cc:dd:ee:ff_socket_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_zigbee_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Zigbee uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_zigbee_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-30T23:57:53+00:00', + }) +# --- # name: test_sensors[sensor.slzb_06_core_chip_temp-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smlight/test_sensor.py b/tests/components/smlight/test_sensor.py index e1239c99e32..f130d7ccf30 100644 --- a/tests/components/smlight/test_sensor.py +++ b/tests/components/smlight/test_sensor.py @@ -1,9 +1,12 @@ """Tests for the SMLIGHT sensor platform.""" +from unittest.mock import MagicMock + +from pysmlight import Sensors import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -25,6 +28,7 @@ def platforms() -> list[Platform]: @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.freeze_time("2024-07-01 00:00:00+00:00") async def test_sensors( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -46,9 +50,26 @@ async def test_disabled_by_default_sensors( """Test the disabled by default SMLIGHT sensors.""" await setup_integration(hass, mock_config_entry) - for sensor in ("ram_usage", "filesystem_usage"): + for sensor in ("core_uptime", "filesystem_usage", "ram_usage", "zigbee_uptime"): assert not hass.states.get(f"sensor.mock_title_{sensor}") assert (entry := entity_registry.async_get(f"sensor.mock_title_{sensor}")) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_zigbee_uptime_disconnected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test for uptime when zigbee socket is disconnected. + + In this case zigbee uptime state should be unknown. + """ + mock_smlight_client.get_sensors.return_value = Sensors(socket_uptime=0) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.mock_title_zigbee_uptime") + assert state.state == STATE_UNKNOWN