Skip to content

Commit e062880

Browse files
author
Tom Lasswell
committed
docs(research): ha_carrier freshness pattern -> Last MQTT Received sensor
1 parent 30b9596 commit e062880

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<!-- no-registry: single diagnostic-sensor feature, not a countable migration scope -->
2+
3+
# Research: "Last MQTT Received" status-update fields — ha_carrier pattern → Govee integration
4+
5+
**Date:** 2026-05-30
6+
**Type:** Feature Investigation
7+
**Topic slug:** mqtt-last-received-status-fields
8+
**Context:** Adopt `dahlb/ha_carrier`'s freshness-diagnostic pattern (timestamp sensors showing when push/poll data last arrived) into this Govee integration.
9+
10+
---
11+
12+
## Summary
13+
14+
`ha_carrier` exposes data freshness via 3 hub/system-level `SensorDeviceClass.TIMESTAMP` + `EntityCategory.DIAGNOSTIC` sensors ("All Data / Websocket / Energy Last Updated"), each backed by a `datetime | None` coordinator field stamped `datetime.now(UTC)` at its success point (full poll, websocket push, energy refresh), plus a `BinarySensorDeviceClass.CONNECTIVITY` `OnlineSensor`. This Govee integration already has the connectivity half (`GoveeTransportConnectivity` binary sensor, per-device `last_success_ts`) and a `GoveeMqttStatusSensor` ENUM (connected/disconnected) — but **no hub-level timestamp showing when the last MQTT push actually arrived**. Recommend adding a single `GoveeMqttLastReceivedSensor` (TIMESTAMP, diagnostic) backed by a new `_last_message_ts` field on `GoveeAwsIotClient`, stamped in `_handle_message`. Optionally add a parallel "Last Poll" timestamp for the REST path (ha_carrier's "All Data" equivalent), which HA's `DataUpdateCoordinator` does not track natively.
15+
16+
---
17+
18+
## Research Questions
19+
20+
1. **How does ha_carrier surface "last update" freshness?**
21+
3 `TimestampSensor` instances per system (`TIMESTAMP_TYPES = ("all_data", "websocket", "energy")`), `SensorDeviceClass.TIMESTAMP`, `EntityCategory.DIAGNOSTIC`, no icon, no translation_key. `native_value = getattr(coordinator, f"timestamp_{type}")`; `available = value is not None`. Source: `custom_components/ha_carrier/sensor.py` ~638-672.
22+
23+
2. **Where are the timestamps stamped?**
24+
Coordinator fields `timestamp_all_data` / `timestamp_websocket` / `timestamp_energy` (`datetime | None`), all `datetime.now(UTC)`. Stamped in `_async_full_refresh` (poll), `updated_callback` (websocket push), `_async_energy_refresh`. `carrier_data_update_coordinator.py`.
25+
26+
3. **Connectivity exposure?**
27+
`OnlineSensor` (`binary_sensor.py`): `BinarySensorDeviceClass.CONNECTIVITY` + `EntityCategory.DIAGNOSTIC`, `is_on = not system.status.is_disconnected`, dynamic icon `mdi:wifi-check` / `mdi:wifi-strength-outline`.
28+
29+
4. **What does Govee already have vs. what's missing?**
30+
Have: `GoveeMqttStatusSensor` (ENUM connected/disconnected/unavailable), `GoveeTransportConnectivity` (CONNECTIVITY binary, per-device, exposes mqtt `last_success_ts` as ISO attribute), `GoveeSensorReadingTimestampSensor` (per-thermometer reading freshness). Missing: hub-level timestamp of last inbound MQTT message; no per-message arrival timestamp tracked anywhere (`api/mqtt.py` `_last_messages` stores raw state dict only, no `datetime`).
31+
32+
5. **Is `transport.py` `last_success_ts` reusable for a hub-level "last MQTT received"?**
33+
No. `TransportHealth.last_success_ts` (transport="mqtt") is **per-device**, stamped by `coordinator._record_transport_success(device_id, "mqtt")` (`coordinator.py:834`); `refresh_mqtt_for_devices` deliberately does NOT update it. New hub-level `_last_message_ts` on `GoveeAwsIotClient` is the correct approach. (Alternate: `max(last_success_ts)` across mqtt transports — no new state, but cold-start gap before first per-device push.)
34+
35+
---
36+
37+
## Findings
38+
39+
### ha_carrier freshness pattern (verbatim)
40+
41+
`custom_components/ha_carrier/sensor.py`:
42+
```python
43+
class TimestampSensor(CarrierSensor):
44+
_attr_entity_category = EntityCategory.DIAGNOSTIC
45+
_attr_device_class = SensorDeviceClass.TIMESTAMP
46+
47+
def __init__(self, coordinator, system_serial, timestamp_type):
48+
self.timestamp_type = timestamp_type
49+
super().__init__(
50+
entity_name=f"{timestamp_type.replace('_', ' ').title()} Last Updated",
51+
coordinator=coordinator, system_serial=system_serial,
52+
)
53+
54+
def _update_entity_attrs(self) -> None:
55+
self._attr_native_value = getattr(self.coordinator, f"timestamp_{self.timestamp_type}")
56+
self._attr_available = self._attr_native_value is not None
57+
```
58+
59+
Coordinator (`carrier_data_update_coordinator.py`):
60+
```python
61+
self.timestamp_all_data: datetime | None = None
62+
self.timestamp_websocket: datetime | None = None
63+
self.timestamp_energy: datetime | None = None
64+
# websocket push:
65+
async def updated_callback(self, _message: str) -> None:
66+
self.timestamp_websocket = datetime.now(UTC)
67+
...
68+
self.async_update_listeners()
69+
```
70+
71+
Key design points: timezone-aware UTC; `None` until first event (sensor reports `available=False`); 3 distinct types so users see which transport is stale.
72+
73+
### Govee current state
74+
75+
`GoveeMqttStatusSensor` (`sensor.py:143-183`) — hub-level diagnostic, the exact pattern to clone:
76+
```python
77+
class GoveeMqttStatusSensor(CoordinatorEntity["GoveeCoordinator"], SensorEntity):
78+
_attr_has_entity_name = True
79+
_attr_translation_key = "mqtt_status"
80+
_attr_entity_category = EntityCategory.DIAGNOSTIC
81+
_attr_device_class = SensorDeviceClass.ENUM
82+
_attr_options = ["connected", "disconnected", "unavailable"]
83+
_attr_icon = "mdi:cloud-sync"
84+
85+
def __init__(self, coordinator, entry_id):
86+
super().__init__(coordinator)
87+
self._attr_unique_id = f"{entry_id}_mqtt_status"
88+
89+
@property
90+
def device_info(self) -> DeviceInfo:
91+
return DeviceInfo(identifiers={(DOMAIN, "hub")}, name="Govee Integration",
92+
manufacturer="Govee", model="Cloud API")
93+
```
94+
95+
`api/mqtt.py``_last_messages: dict[str, dict]` retains last raw payload per device but stamps **no time**. `_handle_message` (line ~413) writes `self._last_messages[device_id] = state` — the insertion point for a timestamp stamp.
96+
97+
Connectivity already covered: `GoveeTransportConnectivity` (`binary_sensor.py:133`, `BinarySensorDeviceClass.CONNECTIVITY`) + `_TRANSPORT_SPECS` loop already mirrors ha_carrier's `OnlineSensor`. No new binary sensor needed.
98+
99+
### Convergence / Dissent
100+
101+
No contradictions between agents. ha_carrier uses literal entity names (no translation_key); this repo uses `translation_key` + `strings.json`/`translations/en.json` mirrors — follow the **repo's** convention (translation_key), not ha_carrier's.
102+
103+
---
104+
105+
## Compatibility Analysis
106+
107+
- **Stack:** Home Assistant custom component, Python 3.12+. `SensorDeviceClass.TIMESTAMP` requires `native_value` to be a tz-aware `datetime` — HA renders it as relative "X minutes ago". Matches existing `GoveeSensorReadingTimestampSensor` (already TIMESTAMP, tz-aware UTC) — pattern proven in this repo.
108+
- **No new deps.** `datetime`/`timezone` already imported in `sensor.py`; `api/mqtt.py` imports `time` and needs `from datetime import datetime, timezone` added.
109+
- **Integration complexity:** Low. Additive only — new field, new property, new sensor, 2 translation keys. No change to existing entity behavior. Hub `DeviceInfo` already established.
110+
- **Cold start:** sensor reports `None`/unavailable until first MQTT push — consistent with ha_carrier semantics and acceptable.
111+
- **MQTT-optional:** register only inside the existing `if coordinator.mqtt_client is not None:` block so polling-only installs don't get a permanently-`None` sensor.
112+
113+
---
114+
115+
## Recommendation
116+
117+
Add **`GoveeMqttLastReceivedSensor`** (hub-level, `SensorDeviceClass.TIMESTAMP`, `EntityCategory.DIAGNOSTIC`) backed by a new `_last_message_ts` on `GoveeAwsIotClient`. This is the direct Govee analogue of ha_carrier's "Websocket Last Updated" and is the user's primary ask ("last mqtt received").
118+
119+
| Option | New state | Hub-level | Cold-start gap | Verdict |
120+
|---|---|---|---|---|
121+
| New `_last_message_ts` on MQTT client | yes (1 field) | yes | none after first msg | **Recommended** |
122+
| Reuse `max(transport mqtt last_success_ts)` | no | derived | per-device gap; misses non-device msgs | Fallback only |
123+
| Per-device timestamp sensors | yes (N) | no | none | Entity bloat; rejected |
124+
125+
**Optional follow-on (ha_carrier "All Data Last Updated" equivalent):** add `GoveePollLastUpdatedSensor` stamped at end of `_async_update_data` — HA's `DataUpdateCoordinator` does not natively expose a last-success timestamp. Defer unless users want REST-poll freshness visibility too.
126+
127+
Connectivity binary already exists (`GoveeTransportConnectivity`) — no work needed.
128+
129+
---
130+
131+
## Implementation Sketch
132+
133+
1. **`custom_components/govee/api/mqtt.py`**
134+
- Add `from datetime import datetime, timezone`.
135+
- `__init__` (~line 124): `self._last_message_ts: datetime | None = None`.
136+
- `_handle_message` (~line 413) after `self._last_messages[device_id] = state`:
137+
```python
138+
self._last_message_ts = datetime.now(timezone.utc)
139+
```
140+
- Add property after `last_messages`:
141+
```python
142+
@property
143+
def last_message_ts(self) -> datetime | None:
144+
"""UTC timestamp of the most recent inbound MQTT state message."""
145+
return self._last_message_ts
146+
```
147+
- Consider stamping in `_handle_multisync` too (~lines 501/507) if multiSync should count as activity.
148+
149+
2. **`custom_components/govee/coordinator.py`** — after `mqtt_connected` (~line 270):
150+
```python
151+
@property
152+
def mqtt_last_message_ts(self) -> datetime | None:
153+
"""UTC timestamp of the last inbound MQTT state message, or None."""
154+
if self._mqtt_client is None:
155+
return None
156+
return self._mqtt_client.last_message_ts
157+
```
158+
159+
3. **`custom_components/govee/sensor.py`** — add after `GoveeMqttStatusSensor` (~line 184):
160+
```python
161+
class GoveeMqttLastReceivedSensor(CoordinatorEntity["GoveeCoordinator"], SensorEntity):
162+
"""Timestamp of the last inbound MQTT state message (hub-level diagnostic)."""
163+
164+
_attr_has_entity_name = True
165+
_attr_translation_key = "mqtt_last_received"
166+
_attr_entity_category = EntityCategory.DIAGNOSTIC
167+
_attr_device_class = SensorDeviceClass.TIMESTAMP
168+
_attr_icon = "mdi:cloud-sync-outline"
169+
170+
def __init__(self, coordinator: GoveeCoordinator, entry_id: str) -> None:
171+
super().__init__(coordinator)
172+
self._attr_unique_id = f"{entry_id}_mqtt_last_received"
173+
174+
@property
175+
def device_info(self) -> DeviceInfo:
176+
return DeviceInfo(identifiers={(DOMAIN, "hub")}, name="Govee Integration",
177+
manufacturer="Govee", model="Cloud API")
178+
179+
@property
180+
def native_value(self) -> datetime | None:
181+
return self.coordinator.mqtt_last_message_ts
182+
```
183+
Register in `async_setup_entry` (~line 61), same `if coordinator.mqtt_client is not None:` block as `GoveeMqttStatusSensor`.
184+
185+
4. **Translations** — add under `entity.sensor` in BOTH `strings.json` and `translations/en.json` (identical mirrors), after `mqtt_status`:
186+
```json
187+
"mqtt_last_received": { "name": "Last MQTT Received" },
188+
```
189+
190+
5. **Tests** — match `tests/test_coordinator.py::TestSensorReadingChangeTracking` style (`object.__new__(GoveeCoordinator)` + direct attribute patching). Add `TestMqttLastMessageTracking`:
191+
- `coord._mqtt_client = None``mqtt_last_message_ts is None`.
192+
- `coord._mqtt_client = Mock(last_message_ts=ts)` → returns `ts`.
193+
- MQTT client: `_handle_message` stamps `last_message_ts` (mock pattern per `tests/test_diagnostics.py:187`).
194+
195+
---
196+
197+
## Risks
198+
199+
- **Multi-path activity definition.** If only `_handle_message` is stamped, a stream of multiSync-only messages would leave "Last MQTT Received" looking stale even though the connection is live. Decide explicitly whether multiSync counts; if yes, stamp both handlers. This is a semantics choice the implementer must make consciously, not silently.
200+
- **Cold start / polling-only installs.** Sensor stays `None` until the first push. Gating registration on `coordinator.mqtt_client is not None` avoids a permanently-unavailable entity on polling-only setups. Acceptable and matches ha_carrier.
201+
- **Reusing per-device `last_success_ts` is a trap.** It is per-device and not updated by `refresh_mqtt_for_devices`; using it for a hub-level sensor would under-report. The new dedicated field avoids this.
202+
- **Timezone correctness.** `native_value` MUST be tz-aware (`datetime.now(timezone.utc)`); a naive datetime raises in HA's TIMESTAMP sensor. The existing `GoveeSensorReadingTimestampSensor` already follows this — mirror it.
203+
204+
## Open Questions
205+
206+
- Should a companion "Last Poll" (REST) timestamp ship in the same change for symmetry with ha_carrier's "All Data Last Updated", or be deferred? Recommendation defers it; confirm with user.
207+
- Does multiSync (`_handle_multisync`) count as MQTT activity for freshness purposes? Needs a decision before implementation.
208+
209+
---
210+
211+
## References
212+
213+
- ha_carrier repo: https://github.com/dahlb/ha_carrier
214+
- ha_carrier `sensor.py` (TimestampSensor ~638-672): https://raw.githubusercontent.com/dahlb/ha_carrier/main/custom_components/ha_carrier/sensor.py
215+
- ha_carrier `binary_sensor.py` (OnlineSensor): https://raw.githubusercontent.com/dahlb/ha_carrier/main/custom_components/ha_carrier/binary_sensor.py
216+
- ha_carrier `carrier_data_update_coordinator.py`: https://raw.githubusercontent.com/dahlb/ha_carrier/main/custom_components/ha_carrier/carrier_data_update_coordinator.py
217+
- HA `SensorDeviceClass.TIMESTAMP` docs: https://developers.home-assistant.io/docs/core/entity/sensor/#available-device-classes
218+
- This repo: `custom_components/govee/sensor.py:143` (`GoveeMqttStatusSensor`), `:258` (`GoveeSensorReadingTimestampSensor`)
219+
- This repo: `custom_components/govee/api/mqtt.py` (`_last_messages`, `_handle_message` ~413)
220+
- This repo: `custom_components/govee/binary_sensor.py:133` (`GoveeTransportConnectivity`), `models/transport.py` (`TransportHealth`)

0 commit comments

Comments
 (0)