|
| 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