Skip to content

Commit 2f05a06

Browse files
author
Tom Lasswell
committed
feat(diagnostics): dump raw redacted REST + MQTT payloads
Diagnostics previously serialized only a curated 6-field subset of parsed state, omitting sensor_temperature/humidity — so a #83 thermometer dump couldn't show whether readings were arriving. Now each device carries: - state: the FULL parsed GoveeDeviceState (dataclasses.asdict) - raw_api_state: the verbatim /device/state payload it parsed from - last_mqtt_message: the verbatim AWS IoT push it last received plus top-level raw_api_devices (verbatim device-list response) and an MQTT tracked_devices count. Capture points: GoveeApiClient retains last_raw_devices + last_raw_state; GoveeAwsIotClient retains last_messages per device. Exposed via coordinator.api_client. Redaction widened: 'device'/'deviceName' added to TO_REDACT so the MAC and user-chosen name inside raw payloads are scrubbed; whole tree is run through async_redact_data before MAC-key anonymization. New test asserts raw captures are present AND MAC-free. Bump version to 2026.5.12.
1 parent 9a7681f commit 2f05a06

6 files changed

Lines changed: 185 additions & 26 deletions

File tree

custom_components/govee/api/client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ def __init__(
9393
self.rate_limit_total: int = 100
9494
self.rate_limit_reset: int = 0
9595

96+
# Last raw API responses, retained for diagnostics (redacted at dump
97+
# time). Lets a diagnostics download include exactly what the device
98+
# list and /device/state endpoints returned — essential for debugging
99+
# state-shape issues the parsed model hides (e.g. thermometers, #83).
100+
self._last_raw_devices: list[dict[str, Any]] | None = None
101+
self._last_raw_state: dict[str, dict[str, Any]] = {}
102+
96103
async def __aenter__(self) -> GoveeApiClient:
97104
"""Async context manager entry."""
98105
await self._ensure_client()
@@ -267,6 +274,7 @@ async def get_devices(self) -> list[GoveeDevice]:
267274
err,
268275
)
269276

277+
self._last_raw_devices = data.get("data", [])
270278
_LOGGER.debug("Fetched %d devices from Govee API", len(devices))
271279
return devices
272280

@@ -313,11 +321,22 @@ async def get_device_state(
313321
payload_data = data.get("payload", {})
314322
state.update_from_api(payload_data)
315323

324+
self._last_raw_state[device_id] = payload_data
316325
return state
317326

318327
except aiohttp.ClientError as err:
319328
raise GoveeConnectionError(f"Connection error: {err}") from err
320329

330+
@property
331+
def last_raw_devices(self) -> list[dict[str, Any]] | None:
332+
"""Raw device-list payload from the most recent get_devices() call."""
333+
return self._last_raw_devices
334+
335+
@property
336+
def last_raw_state(self) -> dict[str, dict[str, Any]]:
337+
"""Raw /device/state payloads keyed by device_id (latest per device)."""
338+
return self._last_raw_state
339+
321340
async def control_device(
322341
self,
323342
device_id: str,

custom_components/govee/api/mqtt.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ def __init__(
119119
self._temp_dir: tempfile.TemporaryDirectory[str] | None = None
120120
self._max_backoff_count = 0
121121
self._client: Any | None = None # aiomqtt.Client when connected
122+
# Last raw state payload seen per device, retained for diagnostics so a
123+
# dump shows exactly what AWS IoT pushed (redacted at dump time).
124+
self._last_messages: dict[str, dict[str, Any]] = {}
125+
126+
@property
127+
def last_messages(self) -> dict[str, dict[str, Any]]:
128+
"""Most recent raw MQTT state payload per device_id (diagnostics)."""
129+
return self._last_messages
122130

123131
@property
124132
def connected(self) -> bool:
@@ -402,6 +410,8 @@ async def _handle_message(self, message: Any) -> None:
402410
state.get("brightness"),
403411
)
404412

413+
self._last_messages[device_id] = state
414+
405415
# Invoke callback with device ID and state dict
406416
try:
407417
self._on_state_update(device_id, state)

custom_components/govee/coordinator.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ def mqtt_client(self) -> GoveeAwsIotClient | None:
243243
"""Return MQTT client instance."""
244244
return self._mqtt_client
245245

246+
@property
247+
def api_client(self) -> GoveeApiClient:
248+
"""Return the REST API client (diagnostics reads its raw captures)."""
249+
return self._api_client
250+
246251
@property
247252
def scene_cache_count(self) -> int:
248253
"""Return number of devices with cached scenes."""

custom_components/govee/diagnostics.py

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import dataclasses
89
import hashlib
910
import re
1011
from typing import Any
@@ -16,7 +17,9 @@
1617
from .const import CONF_API_KEY, CONF_EMAIL, CONF_PASSWORD
1718
from .coordinator import GoveeCoordinator
1819

19-
# Keys to redact from diagnostic output
20+
# Keys to redact from diagnostic output. Includes the raw-response identity
21+
# fields ("device" = MAC in /device/state + device-list, "deviceName" = the
22+
# user's chosen name) so the captured raw API/MQTT payloads stay PII-free.
2023
TO_REDACT = {
2124
CONF_API_KEY,
2225
CONF_EMAIL,
@@ -29,9 +32,29 @@
2932
"client_id",
3033
"account_topic",
3134
"device_id",
35+
"device",
36+
"deviceName",
3237
"mac",
3338
}
3439

40+
41+
def _serialize_state(state: Any) -> dict[str, Any] | None:
42+
"""Full dump of a GoveeDeviceState (all fields, incl. sensor readings).
43+
44+
Never raises — diagnostics must always produce output.
45+
"""
46+
if state is None:
47+
return None
48+
try:
49+
return dataclasses.asdict(state)
50+
except Exception: # pragma: no cover - defensive
51+
return {
52+
"online": getattr(state, "online", None),
53+
"power_state": getattr(state, "power_state", None),
54+
"source": getattr(state, "source", None),
55+
}
56+
57+
3558
# Govee device IDs are MAC-derived: 8 colon-separated hex octets
3659
# (e.g., "03:9C:DC:06:75:4B:10:7C"). Group device IDs are numeric-only.
3760
_MAC_PATTERN = re.compile(r"^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5,7}$")
@@ -62,7 +85,16 @@ async def async_get_config_entry_diagnostics(
6285
"""Return diagnostics for a config entry."""
6386
coordinator: GoveeCoordinator = entry.runtime_data
6487

65-
# Collect device information
88+
mqtt_client = coordinator.mqtt_client
89+
raw_state = coordinator.api_client.last_raw_state
90+
raw_mqtt = mqtt_client.last_messages if mqtt_client else {}
91+
92+
# Collect device information. Each device carries:
93+
# - parsed state (ALL fields, including sensor_temperature/humidity)
94+
# - raw_api_state: the verbatim /device/state payload it was parsed from
95+
# - last_mqtt_message: the verbatim AWS IoT push it last received
96+
# These let us debug state-shape issues (e.g. thermometers #83) directly
97+
# from a diagnostics download instead of asking for a debug log.
6698
devices_info: dict[str, Any] = {}
6799
for device_id, device in coordinator.devices.items():
68100
state = coordinator.get_state(device_id)
@@ -79,14 +111,9 @@ async def async_get_config_entry_diagnostics(
79111
}
80112
for cap in device.capabilities
81113
],
82-
"state": {
83-
"online": state.online if state else None,
84-
"power_state": state.power_state if state else None,
85-
"brightness": state.brightness if state else None,
86-
"color": state.color.as_tuple if state and state.color else None,
87-
"color_temp_kelvin": state.color_temp_kelvin if state else None,
88-
"source": state.source if state else None,
89-
},
114+
"state": _serialize_state(state),
115+
"raw_api_state": raw_state.get(device_id),
116+
"last_mqtt_message": raw_mqtt.get(device_id),
90117
"transport": {
91118
"cloud_api": True,
92119
"mqtt": coordinator.mqtt_connected,
@@ -95,12 +122,12 @@ async def async_get_config_entry_diagnostics(
95122
}
96123

97124
# Collect MQTT status
98-
mqtt_client = coordinator.mqtt_client
99125
mqtt_info = None
100126
if mqtt_client:
101127
mqtt_info = {
102128
"available": mqtt_client.available,
103129
"connected": mqtt_client.connected,
130+
"tracked_devices": len(raw_mqtt),
104131
}
105132

106133
# Collect API client info
@@ -110,20 +137,25 @@ async def async_get_config_entry_diagnostics(
110137
"rate_limit_reset": coordinator.api_rate_limit_reset,
111138
}
112139

113-
# Build diagnostics data — anonymize MAC-format device-id keys before
114-
# exposing the device map (MAC = PII per HA diagnostics guidance).
115140
diagnostics_data = {
116141
"config_entry": {
117142
"entry_id": entry.entry_id,
118143
"version": entry.version,
119-
"data": async_redact_data(dict(entry.data), TO_REDACT),
144+
"data": dict(entry.data),
120145
"options": dict(entry.options),
121146
},
122-
"devices": _anonymize_device_keys(devices_info),
147+
"devices": devices_info,
148+
# Verbatim device-list response from the most recent discovery poll.
149+
"raw_api_devices": coordinator.api_client.last_raw_devices,
123150
"device_count": len(coordinator.devices),
124151
"mqtt": mqtt_info,
125152
"api": api_info,
126153
"scene_cache_count": coordinator.scene_cache_count,
127154
}
128155

129-
return diagnostics_data
156+
# Redact known-sensitive keys anywhere in the tree (covers the raw API/MQTT
157+
# payloads' "device"/"deviceName" fields), then hash the MAC-format device
158+
# map keys (MAC = PII per HA diagnostics guidance).
159+
redacted: dict[str, Any] = async_redact_data(diagnostics_data, TO_REDACT)
160+
redacted["devices"] = _anonymize_device_keys(redacted["devices"])
161+
return redacted

custom_components/govee/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,5 @@
2424
"bleak-retry-connector>=3.0.0",
2525
"cryptography>=41.0.0"
2626
],
27-
"version": "2026.5.11"
27+
"version": "2026.5.12"
2828
}

tests/test_diagnostics.py

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
_looks_like_mac,
1616
async_get_config_entry_diagnostics,
1717
)
18+
from custom_components.govee.models import GoveeDeviceState
1819

1920
# Govee device-id MAC pattern: 6-8 colon-separated hex octets
2021
_MAC_RE = re.compile(r"\b[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5,7}\b")
@@ -113,20 +114,43 @@ async def test_no_mac_in_diagnostics_output(self) -> None:
113114
device_group.is_group = True
114115
device_group.capabilities = []
115116

116-
state_mock = MagicMock()
117-
state_mock.online = True
118-
state_mock.power_state = True
119-
state_mock.brightness = 80
120-
state_mock.color = None
121-
state_mock.color_temp_kelvin = 4000
122-
state_mock.source = "cloud_api"
117+
# Real state object so the full asdict dump path runs (incl. the
118+
# device_id MAC field, which must be redacted).
119+
state = GoveeDeviceState.create_empty(mac_id)
120+
state.sensor_temperature = 23.4
121+
state.sensor_humidity = 48.0
122+
123+
# Realistic raw API/MQTT captures whose "device"/"deviceName" carry the
124+
# MAC + user name — must be redacted out of the dump.
125+
raw_state_payload = {
126+
"device": mac_id,
127+
"sku": "H6601",
128+
"capabilities": [
129+
{
130+
"type": "devices.capabilities.property",
131+
"instance": "sensorTemperature",
132+
"state": {"value": 23.4},
133+
}
134+
],
135+
}
136+
api_client = MagicMock()
137+
api_client.last_raw_state = {mac_id: raw_state_payload}
138+
api_client.last_raw_devices = [
139+
{"device": mac_id, "sku": "H6601", "deviceName": "Living Room Lamp"}
140+
]
141+
142+
mqtt_client = MagicMock()
143+
mqtt_client.available = True
144+
mqtt_client.connected = True
145+
mqtt_client.last_messages = {mac_id: {"onOff": 1, "sensorTemperature": 2340}}
123146

124147
coordinator = MagicMock()
125148
coordinator.devices = {mac_id: device_mac, group_id: device_group}
126-
coordinator.get_state = lambda _did: state_mock
149+
coordinator.get_state = lambda did: state if did == mac_id else None
127150
coordinator.mqtt_connected = True
128151
coordinator.is_ble_available = lambda _did: False
129-
coordinator.mqtt_client = None
152+
coordinator.mqtt_client = mqtt_client
153+
coordinator.api_client = api_client
130154
coordinator.api_rate_limit_remaining = 100
131155
coordinator.api_rate_limit_total = 100
132156
coordinator.api_rate_limit_reset = 0
@@ -158,3 +182,72 @@ async def test_no_mac_in_diagnostics_output(self) -> None:
158182
# API key and email must also be redacted.
159183
assert "secret" not in rendered
160184
assert "user@example.com" not in rendered
185+
186+
@pytest.mark.asyncio
187+
async def test_raw_captures_present_and_redacted(self) -> None:
188+
"""Raw API/MQTT payloads are included for debugging but redacted.
189+
190+
The full parsed state (incl. sensor readings) and the verbatim
191+
/device/state + MQTT payloads must appear, while the MAC inside their
192+
"device" field is redacted.
193+
"""
194+
mac_id = "03:9C:DC:06:75:4B:10:7C"
195+
196+
device = MagicMock()
197+
device.sku = "H5075"
198+
device.name = "Office Thermo"
199+
device.device_type = "devices.types.thermometer"
200+
device.is_group = False
201+
device.capabilities = []
202+
203+
state = GoveeDeviceState.create_empty(mac_id)
204+
state.sensor_temperature = 23.4
205+
state.sensor_humidity = 48.0
206+
207+
api_client = MagicMock()
208+
api_client.last_raw_state = {
209+
mac_id: {"device": mac_id, "sku": "H5075", "capabilities": []}
210+
}
211+
api_client.last_raw_devices = [{"device": mac_id, "sku": "H5075"}]
212+
213+
mqtt_client = MagicMock()
214+
mqtt_client.available = True
215+
mqtt_client.connected = True
216+
mqtt_client.last_messages = {mac_id: {"onOff": 1}}
217+
218+
coordinator = MagicMock()
219+
coordinator.devices = {mac_id: device}
220+
coordinator.get_state = lambda _did: state
221+
coordinator.mqtt_connected = True
222+
coordinator.is_ble_available = lambda _did: False
223+
coordinator.mqtt_client = mqtt_client
224+
coordinator.api_client = api_client
225+
coordinator.api_rate_limit_remaining = 100
226+
coordinator.api_rate_limit_total = 100
227+
coordinator.api_rate_limit_reset = 0
228+
coordinator.scene_cache_count = 0
229+
230+
entry = MagicMock()
231+
entry.entry_id = "e"
232+
entry.version = 1
233+
entry.data = {}
234+
entry.options = {}
235+
entry.runtime_data = coordinator
236+
237+
out = await async_get_config_entry_diagnostics(MagicMock(), entry)
238+
239+
dev = next(iter(out["devices"].values()))
240+
# Full parsed state carries the sensor readings (the #83 debug signal).
241+
assert dev["state"]["sensor_temperature"] == 23.4
242+
assert dev["state"]["sensor_humidity"] == 48.0
243+
# Raw captures are attached per-device + the device-list at top level.
244+
assert dev["raw_api_state"] is not None
245+
assert dev["last_mqtt_message"] == {"onOff": 1}
246+
assert out["raw_api_devices"] is not None
247+
assert out["mqtt"]["tracked_devices"] == 1
248+
249+
# But the MAC in the raw payloads' "device" field is redacted, and the
250+
# parsed-state device_id MAC is gone too.
251+
rendered = json.dumps(out, default=str)
252+
assert mac_id not in rendered
253+
assert _MAC_RE.search(rendered) is None

0 commit comments

Comments
 (0)