Skip to content

Commit

Permalink
Add Timestamp Sensor (zigpy#224)
Browse files Browse the repository at this point in the history
Use Subclass Sensor - Add Timestamp Sensor
  • Loading branch information
prairiesnpr authored Oct 17, 2024
1 parent ff8c635 commit 3d34a30
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 3 deletions.
92 changes: 92 additions & 0 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
import math
from typing import Any, Optional
from unittest.mock import AsyncMock, MagicMock
Expand Down Expand Up @@ -394,6 +395,18 @@ async def async_test_pi_heating_demand(
assert_state(entity, 1, "%")


async def async_test_change_source_timestamp(
zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity
):
"""Test change source timestamp is correctly returned."""
await send_attributes_report(
zha_gateway,
cluster,
{hvac.Thermostat.AttributeDefs.setpoint_change_source_timestamp.id: 2674725315},
)
assert entity.state["state"] == datetime(2024, 10, 4, 11, 15, 15, tzinfo=UTC)


@pytest.mark.parametrize(
"cluster_id, entity_type, test_func, read_plug, unsupported_attrs",
(
Expand Down Expand Up @@ -547,6 +560,13 @@ async def async_test_pi_heating_demand(
None,
None,
),
(
hvac.Thermostat.cluster_id,
sensor.SetpointChangeSourceTimestamp,
async_test_change_source_timestamp,
None,
None,
),
),
)
async def test_sensor(
Expand Down Expand Up @@ -1126,6 +1146,78 @@ async def test_elec_measurement_skip_unsupported_attribute(
assert read_attrs == supported_attributes


class TimestampCluster(CustomCluster, ManufacturerSpecificCluster):
"""Timestamp Quirk V2 Cluster."""

cluster_id = 0xEF00
ep_attribute = "time_test_cluster"
attributes = {
0xEF65: ("start_time", t.uint32_t, True),
}

def __init__(self, *args, **kwargs) -> None:
"""Initialize."""
super().__init__(*args, **kwargs)
# populate cache to create config entity
self._attr_cache.update({0xEF65: 10})


(
QuirkBuilder("Fake_Timestamp_sensor", "Fake_Model_sensor")
.replaces(TimestampCluster)
.sensor(
"start_time",
TimestampCluster.cluster_id,
device_class=SensorDeviceClass.TIMESTAMP,
translation_key="start_time",
fallback_name="Start Time",
)
.add_to_registry()
)


@pytest.fixture
async def zigpy_device_timestamp_sensor_v2(
zha_gateway: Gateway, # pylint: disable=unused-argument
):
"""Timestamp Test device."""

zigpy_device = create_mock_zigpy_device(
zha_gateway,
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
TimestampCluster.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
}
},
manufacturer="Fake_Timestamp_sensor",
model="Fake_Model_sensor",
)

zigpy_device = get_device(zigpy_device)

zha_device = await join_zigpy_device(zha_gateway, zigpy_device)
return zha_device, zigpy_device.endpoints[1].time_test_cluster


async def test_timestamp_sensor_v2(
zha_gateway: Gateway,
zigpy_device_timestamp_sensor_v2, # pylint: disable=redefined-outer-name
) -> None:
"""Test quirks defined sensor."""

zha_device, cluster = zigpy_device_timestamp_sensor_v2
assert isinstance(zha_device.device, CustomDeviceV2)
entity = get_entity(zha_device, platform=Platform.SENSOR, qualifier="start_time")

await send_attributes_report(zha_gateway, cluster, {0xEF65: 2674725315})
assert entity.state["state"] == datetime(2024, 10, 4, 11, 15, 15, tzinfo=UTC)


class OppleCluster(CustomCluster, ManufacturerSpecificCluster):
"""Aqara manufacturer specific cluster."""

Expand Down
10 changes: 10 additions & 0 deletions zha/application/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
switch,
update,
)
from zha.application.platforms.sensor.const import SensorDeviceClass
from zha.application.registries import (
DEVICE_CLASS,
PLATFORM_ENTITIES,
Expand Down Expand Up @@ -163,6 +164,10 @@
): switch.ConfigurableAttributeSwitch,
}

QUIRKS_SENSOR_DEV_CLASS_TO_ENTITY_CLASS = {
SensorDeviceClass.TIMESTAMP: sensor.TimestampSensor
}


class DeviceProbe:
"""Probe to discover entities for a device."""
Expand Down Expand Up @@ -280,6 +285,11 @@ def discover_quirks_v2_entities(self, device: Device) -> None:
)
continue

if entity_class is sensor.Sensor:
entity_class = QUIRKS_SENSOR_DEV_CLASS_TO_ENTITY_CLASS.get(
entity_metadata.device_class, entity_class
)

# automatically add the attribute to ZCL_INIT_ATTRS for the cluster
# handler if it is not already in the list
if (
Expand Down
35 changes: 32 additions & 3 deletions zha/application/platforms/sensor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from asyncio import Task
from dataclasses import dataclass
from datetime import UTC, date, datetime
import enum
import functools
import logging
Expand All @@ -29,7 +30,11 @@
)
from zha.application.platforms.climate.const import HVACAction
from zha.application.platforms.helpers import validate_device_class
from zha.application.platforms.sensor.const import SensorDeviceClass, SensorStateClass
from zha.application.platforms.sensor.const import (
UNIX_EPOCH_TO_ZCL_EPOCH,
SensorDeviceClass,
SensorStateClass,
)
from zha.application.registries import PLATFORM_ENTITIES
from zha.decorators import periodic
from zha.units import (
Expand Down Expand Up @@ -240,7 +245,7 @@ def state(self) -> dict:
return response

@property
def native_value(self) -> str | int | float | None:
def native_value(self) -> date | datetime | str | int | float | None:
"""Return the state of the entity."""
assert self._attribute_name is not None
raw_state = self._cluster_handler.cluster.get(self._attribute_name)
Expand All @@ -264,7 +269,9 @@ def handle_cluster_handler_attribute_updated(
):
self.maybe_emit_state_changed_event()

def formatter(self, value: int | enum.IntEnum) -> int | float | str | None:
def formatter(
self, value: int | enum.IntEnum
) -> datetime | int | float | str | None:
"""Numeric pass-through formatter."""
if self._decimals > 0:
return round(
Expand All @@ -273,6 +280,14 @@ def formatter(self, value: int | enum.IntEnum) -> int | float | str | None:
return round(float(value * self._multiplier) / self._divisor)


class TimestampSensor(Sensor):
"""Timestamp ZHA sensor."""

def formatter(self, value: int | enum.IntEnum) -> datetime | None:
"""Pass-through formatter."""
return datetime.fromtimestamp(value - UNIX_EPOCH_TO_ZCL_EPOCH, tz=UTC)


class PollableSensor(Sensor):
"""Base ZHA sensor that polls for state."""

Expand Down Expand Up @@ -1631,6 +1646,20 @@ class SetpointChangeSource(EnumSensor):
_enum = SetpointChangeSourceEnum


@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT)
class SetpointChangeSourceTimestamp(TimestampSensor):
"""Sensor that displays the timestamp the setpoint change.
Optional thermostat attribute.
"""

_unique_id_suffix = "setpoint_change_source_timestamp"
_attribute_name = "setpoint_change_source_timestamp"
_attr_translation_key: str = "setpoint_change_source_timestamp"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_device_class = SensorDeviceClass.TIMESTAMP


@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER)
class WindowCoveringTypeSensor(EnumSensor):
"""Sensor that displays the type of a cover device."""
Expand Down
2 changes: 2 additions & 0 deletions zha/application/platforms/sensor/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,5 @@ class SensorDeviceClass(enum.StrEnum):
SensorDeviceClass.ENUM,
SensorDeviceClass.TIMESTAMP,
}

UNIX_EPOCH_TO_ZCL_EPOCH = 946684800

0 comments on commit 3d34a30

Please sign in to comment.