Skip to content

Commit 3c72429

Browse files
committed
Merge branch 'feat/utec-last-push-sensor'
2 parents 4ccaca3 + b6c236c commit 3c72429

4 files changed

Lines changed: 89 additions & 5 deletions

File tree

custom_components/u_tec/coordinator.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Data coordinator for Uhome integration."""
22

3-
from datetime import timedelta
3+
from datetime import datetime, timedelta
44
import logging
55

66
from custom_components.u_tec.const import (
@@ -16,6 +16,7 @@
1616
from homeassistant.helpers.dispatcher import async_dispatcher_send
1717
from homeassistant.helpers.event import async_track_time_interval
1818
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
19+
from homeassistant.util import dt as dt_util
1920
from utec_py.api import UHomeApi
2021
from utec_py.devices.device import BaseDevice
2122
from utec_py.devices.light import Light
@@ -78,6 +79,7 @@ def __init__(
7879
self.added_sensor_entities = set()
7980
self.push_devices = []
8081
self.blacklisted_devices = []
82+
self.last_push_received: datetime | None = None
8183
self._discovery_interval = timedelta(seconds=discovery_interval)
8284
self._cancel_discovery: callable | None = None
8385
_LOGGER.info(
@@ -203,6 +205,10 @@ async def _async_update_data(self) -> dict[str, dict]:
203205

204206
async def update_push_data(self, push_data):
205207
"""Process push update from webhook."""
208+
# Reaching here means the handler already passed Bearer-token auth, so a
209+
# genuine push was delivered. Stamp before payload guards so even an empty
210+
# keepalive counts as "push channel alive".
211+
self.last_push_received = dt_util.utcnow()
206212

207213
_LOGGER.debug("Processing push update: %s", push_data)
208214

custom_components/u_tec/sensor.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
SensorStateClass,
99
)
1010
from homeassistant.config_entries import ConfigEntry
11-
from homeassistant.const import PERCENTAGE
11+
from homeassistant.const import PERCENTAGE, EntityCategory
1212
from homeassistant.core import HomeAssistant, callback
1313
from homeassistant.helpers.dispatcher import async_dispatcher_connect
1414
from homeassistant.helpers.entity import DeviceInfo
@@ -32,6 +32,7 @@ async def async_setup_entry(
3232
]
3333

3434
entities = _create_battery_entities(coordinator)
35+
entities.append(UhomeLastPushSensor(coordinator))
3536
async_add_entities(entities)
3637

3738
@callback
@@ -119,3 +120,32 @@ async def async_added_to_hass(self):
119120
def _handle_push_update(self, push_data):
120121
"""Update device from push data."""
121122
self.async_write_ha_state()
123+
124+
125+
class UhomeLastPushSensor(CoordinatorEntity, SensorEntity):
126+
"""Diagnostic sensor: timestamp of the most recent webhook push received.
127+
128+
Coordinator-level (not per-device): there is no physical device, so it ties
129+
its device_info to the config entry. A stale value here while locks are still
130+
changing state (caught by the 30s poll) is the signal that U-Tec push delivery
131+
has died — surfaced by the ha-configs push-health-monitor automation.
132+
"""
133+
134+
_attr_has_entity_name = False
135+
_attr_device_class = SensorDeviceClass.TIMESTAMP
136+
_attr_entity_category = EntityCategory.DIAGNOSTIC
137+
138+
def __init__(self, coordinator: UhomeDataUpdateCoordinator) -> None:
139+
super().__init__(coordinator)
140+
entry_id = coordinator.config_entry.entry_id
141+
self._attr_unique_id = f"{DOMAIN}_last_push_{entry_id}"
142+
self._attr_name = "Utec Last Push"
143+
self._attr_device_info = DeviceInfo(
144+
identifiers={(DOMAIN, f"{entry_id}_service")},
145+
name="U-Tec Integration",
146+
manufacturer="U-Tec",
147+
)
148+
149+
@property
150+
def native_value(self):
151+
return self.coordinator.last_push_received

tests/test_coordinator.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,21 @@ async def test_update_push_data_unrecognised_top_level_type_is_noop(coordinator)
117117
sw.update_state_data.assert_not_awaited()
118118

119119

120+
async def test_update_push_data_stamps_last_push_received(coordinator):
121+
"""Any authenticated push arrival records a recent UTC timestamp."""
122+
from homeassistant.util import dt as dt_util
123+
124+
assert coordinator.last_push_received is None # nothing received yet
125+
126+
before = dt_util.utcnow()
127+
# Minimal valid push payload shape (devices list); content irrelevant to the stamp.
128+
await coordinator.update_push_data({"payload": {"devices": []}})
129+
after = dt_util.utcnow()
130+
131+
assert coordinator.last_push_received is not None
132+
assert before <= coordinator.last_push_received <= after
133+
134+
120135
# --- _async_update_data ---
121136

122137
from homeassistant.exceptions import ConfigEntryAuthFailed

tests/test_sensor.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ def test_battery_sensor_unique_id(coord_with_locks):
3737

3838

3939
async def test_async_setup_entry_adds_one_per_lock(hass, coord_with_locks):
40-
"""Initial setup should add battery sensors for all locks."""
41-
from custom_components.u_tec.sensor import async_setup_entry
40+
"""Initial setup should add battery sensors for all locks plus one last-push sensor."""
41+
from custom_components.u_tec.sensor import UhomeBatterySensorEntity, async_setup_entry
4242

4343
coord, entry = coord_with_locks
4444
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"coordinator": coord}
@@ -48,7 +48,9 @@ def _add(entities):
4848
added.extend(list(entities))
4949

5050
await async_setup_entry(hass, entry, _add)
51-
assert len(added) == 2
51+
battery_sensors = [e for e in added if isinstance(e, UhomeBatterySensorEntity)]
52+
assert len(battery_sensors) == 2
53+
assert len(added) == 3 # 2 battery + 1 last-push
5254
assert coord.added_sensor_entities == {"u_tec_battery_lock-1", "u_tec_battery_lock-2"}
5355

5456

@@ -97,3 +99,34 @@ def _add(entities):
9799

98100
# Same devices -> no additions
99101
assert len(added) == initial
102+
103+
104+
async def test_last_push_sensor_added_once(hass, coord_with_locks):
105+
"""async_setup_entry adds exactly one coordinator-level last-push sensor."""
106+
from custom_components.u_tec.sensor import UhomeLastPushSensor, async_setup_entry
107+
108+
coord, entry = coord_with_locks
109+
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"coordinator": coord}
110+
added = []
111+
await async_setup_entry(hass, entry, lambda ents: added.extend(list(ents)))
112+
113+
last_push = [e for e in added if isinstance(e, UhomeLastPushSensor)]
114+
assert len(last_push) == 1
115+
116+
117+
async def test_last_push_sensor_native_value_and_class(hass, coord_with_locks):
118+
"""The sensor reports the coordinator's last_push_received as a timestamp."""
119+
from homeassistant.components.sensor import SensorDeviceClass
120+
from homeassistant.util import dt as dt_util
121+
122+
from custom_components.u_tec.sensor import UhomeLastPushSensor
123+
124+
coord, _ = coord_with_locks
125+
coord.last_push_received = None
126+
sensor = UhomeLastPushSensor(coord)
127+
assert sensor.device_class == SensorDeviceClass.TIMESTAMP
128+
129+
assert sensor.native_value is None # no push yet
130+
stamp = dt_util.utcnow()
131+
coord.last_push_received = stamp
132+
assert sensor.native_value == stamp

0 commit comments

Comments
 (0)