custom_components/govee_bluetooth/census.py
"""The census: what a light speaks, with the evidence for each claim.
The same battery the Dance Party Flipper app and ESP32 image run
(``govee_census.c`` there), over the :class:`probe.ProbeLink` interface so the
bench rig and the tests can drive it. Read-only by default; the opt-in probe
writes one colour per dialect and judges each dialect only by its own
colour appearing where it was not showing before.
Nothing here decides what the light *is*; :func:`classify` turns a
:class:`CensusResult` into a protocol-family verdict with a confidence and
the observations that support it, and the report generator renders that.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass, field
from . import protocol
from .advertisement import GoveeAdvertisement
from .probe import ProbeConnectionLost, ProbeLink
from .readback import PageLayout
SCHEMA = "govee-evidence/1"
READ_TIMEOUT_S = 1.5
SETTLE_S = 0.15
# After this many unanswered core reads (power, brightness, firmware) the
# light is silent and the remaining reads are skipped.
SILENT_AFTER = 3
# One probe colour per dialect; none is a kelvin-tint colour, no channel is
# zero or full. Same values as govee_probe_color() in Dance Party.
PROBE_COLORS: dict[int, tuple[int, int, int]] = {
protocol.SUBMODE_COLOR_LEGACY: (0xC8, 0x1E, 0x28), # red
protocol.SUBMODE_COLOR_DREAMCOLOR: (0x1E, 0xC8, 0x3C), # green
protocol.SUBMODE_COLOR_RGBWW: (0x28, 0x3C, 0xC8), # blue
protocol.SUBMODE_COLOR_RGBIC: (0xB4, 0x1E, 0xC8), # magenta
}
# The first H6006 session used four near-identical dark blues: once one
# dialect landed, a later one could not be seen to work. These are
# saturated, distinct to the eye and named in the operator's question.
PROBE_COLOR_NAMES: dict[int, str] = {
protocol.SUBMODE_COLOR_LEGACY: "red",
protocol.SUBMODE_COLOR_DREAMCOLOR: "green",
protocol.SUBMODE_COLOR_RGBWW: "blue",
protocol.SUBMODE_COLOR_RGBIC: "magenta",
}
# After going back to the pre-probe state between dialects, wait this long
# so the reset's own write ack arrives before the next probe write is
# attributed. With a person answering, wait long enough for them to see the
# light settle at the baseline: in the H6006 session a 0.6 s gap made the
# blue -> white reset look like the 0x15 write's effect.
RESET_SETTLE_S = 0.6
RESET_SETTLE_ASKING_S = 2.5
PROBE_ORDER: tuple[int, ...] = (
protocol.SUBMODE_COLOR_LEGACY,
protocol.SUBMODE_COLOR_DREAMCOLOR,
protocol.SUBMODE_COLOR_RGBWW,
protocol.SUBMODE_COLOR_RGBIC,
)
# Families tagged secret_key_pairing in the decompiled app (docs 10.1), as
# tabled by Dance Party's govee_protocol.c. A table fact, not a measurement.
SECRET_FAMILY_MODELS: frozenset[str] = frozenset(
{
# dreamcolorlightv1
"H611B", "H611Z", "H6123", "H612A", "H612B", "H612C", "H612D", "H612E", "H612F",
"H6168", "H617A", "H619A", "H61A0", "H61A8", "H61A9", "H61E0", "H61E1", "H70A1",
"H70A2", "H70A3",
# h61d3
"H61B5", "H61E5", "H61D3", "H6640", "H6641",
# stringlightv2 (SKUs not also in a plain legacy family)
"H6141", "H6142", "H6154", "H6170", "H6178", "H6192", "H6195", "H6196", "H6197",
# hollowlamp
"H6050", "H6051", "H6055", "H6058", "H6059", "H6073",
# h60ax
"H60A0", "H60A1", "H60A4", "H60A6", "H60C1",
# h6047
"H6047", "H6043", "H6042", "H6039", "H6038", "H6048",
}
) # fmt: skip
def needs_secret(model: str | None) -> bool:
return (model or "").upper() in SECRET_FAMILY_MODELS
@dataclass(slots=True)
class CensusResult:
"""Everything one visit learned, each field a verified reply or absent."""
connected: bool = False
subscribed: bool = False
aborted: bool = False
reads: int = 0
replies: int = 0
bad_frames: int = 0
acks: int = 0
power: bool | None = None
brightness: int | None = None
mode: protocol.ModeReport | None = None
mode_raw: bytes | None = None # bytes 2..6 of the AA 05 reply
firmware: str | None = None
hardware: str | None = None
ic_count: int | None = None
segments: int | None = None
light_count: int | None = None
secret_present: bool = False
secret: bytes | None = None # kept only for the operator's own use
segment_page: protocol.Reply | None = None
probed: bool = False
echoed: list[int] = field(default_factory=list)
# Per probe dialect, what the firmware's write ack said: "accepted"
# (33 05 00), "rejected" (33 05 <non-zero>) or "none". The H6006 rejects
# 02 / 0B / 15 with 33 05 01 and accepts 0D with 33 05 00 (bench).
probe_acks: dict[int, str] = field(default_factory=dict)
# Per probe dialect, what the operator saw when asked: "yes", "no",
# "wrong" (changed, but not to the probe colour) or "partial".
observed: dict[int, str] = field(default_factory=dict)
restored: str | None = None # "full", "power", "none"
restore_note: str | None = None
error: str | None = None
@property
def silent(self) -> bool:
return self.connected and self.subscribed and self.replies == 0 and not self.aborted
def ota_capabilities(services: Iterable[object]) -> dict[str, object]:
"""Inspect discovered GATT metadata only; never enter OTA or read/write its chars.
The V1 data UUID is also used for BGC-info under the control service:
only characteristics belonging to the OTA service count as evidence.
Absence in normal mode does not prove the device lacks an OTA bootloader.
"""
layouts = {
"govee_v1": (
"00010203-0405-0607-0809-0a0b0c0d1912",
("00010203-0405-0607-0809-0a0b0c0d2b12",),
),
"ti_oad_v2": (
"f000ffc0-0451-4000-b000-000000000000",
("f000ffc1-0451-4000-b000-000000000000", "f000ffc2-0451-4000-b000-000000000000"),
),
}
found = []
for service in services:
service_uuid = str(service.uuid).lower()
for transport, (expected_service, expected_chars) in layouts.items():
if service_uuid != expected_service:
continue
chars = [
{"uuid": str(char.uuid).lower(), "properties": sorted(char.properties)}
for char in service.characteristics
if str(char.uuid).lower() in expected_chars
]
found.append(
{
"transport": transport,
"service": service_uuid,
"characteristics": chars,
"complete": set(expected_chars) <= {char["uuid"] for char in chars},
}
)
return {
"status": "services_detected" if found else "not_observed",
"transports": found,
"update_verified": False,
}
async def async_census(
link: ProbeLink,
*,
probe_writes: bool = False,
abort: Callable[[], bool] | None = None,
ask: Callable[[int, tuple[int, int, int]], Awaitable[str | None]] | None = None,
notify: Callable[[str], None] | None = None,
page_layout: PageLayout | None = None,
) -> CensusResult:
"""Run the read battery (and the probe if asked) over a connected, subscribed link.
``ask(submode, rgb)`` is called right after each probe write, before the
read-back, so a person can say whether the light changed; it returns
"yes", "no", "wrong", "partial", or None to stop (the light is still
restored). Links that expose an ``ack_log`` list of ``(cmd, status)``
pairs get each probe write's application ack recorded too.
Colour-page echoes require an explicit qualified ``page_layout``.
"""
result = CensusResult(connected=True, subscribed=True)
try:
await _reads(link, result, abort, page_layout)
if probe_writes and not result.silent and not result.aborted:
await _probe(link, result, abort, ask, notify, page_layout)
except ProbeConnectionLost as err:
result.error = f"disconnected: {err}"
return result
async def _query(
link: ProbeLink,
result: CensusResult,
frame: bytes,
abort: Callable[[], bool] | None,
page_layout: PageLayout | None = None,
):
if abort is not None and abort():
result.aborted = True
return None
result.reads += 1
reply = await link.query(frame, frame[1])
if reply is not None:
result.replies += 1
if page_layout is not None and frame[1] == page_layout.command:
reply.segment_records = page_layout.decode(reply.raw)
return reply
async def _reads(
link: ProbeLink,
result: CensusResult,
abort: Callable[[], bool] | None,
page_layout: PageLayout | None = None,
) -> None:
core_timeouts = 0
reply = await _query(link, result, protocol.power_query_frame(), abort)
if reply is not None:
result.power = reply.power
else:
core_timeouts += 1
reply = await _query(link, result, protocol.brightness_query_frame(), abort)
if reply is not None:
result.brightness = reply.brightness_level
else:
core_timeouts += 1
reply = await _query(link, result, protocol.version_query_frame(), abort)
if reply is not None:
result.firmware = reply.version
else:
core_timeouts += 1
if core_timeouts >= SILENT_AFTER and result.replies == 0:
return # locked, encrypted or a foreign dialect: more reads buy nothing
reply = await _query(link, result, protocol.mode_query_frame(), abort)
if reply is not None:
result.mode = reply.mode
reply = await _query(link, result, protocol.hardware_query_frame(), abort)
if reply is not None:
result.hardware = reply.hardware
reply = await _query(link, result, protocol.ic_count_query_frame(), abort)
if reply is not None:
result.ic_count, result.segments = reply.ic_count, reply.segments
reply = await _query(link, result, protocol.light_count_query_frame(), abort)
if reply is not None:
result.light_count = reply.light_count
reply = await _query(link, result, protocol.secret_query_frame(), abort)
if reply is not None and reply.secret_present:
result.secret_present = True
result.secret = reply.secret
reply = await _query(link, result, protocol.segment_colors_query_frame(1), abort, page_layout)
if reply is not None:
result.segment_page = reply
def _mode_rgb(mode: protocol.ModeReport | None) -> tuple[int, int, int] | None:
"""The RGB a mode reply carries, on the dialects whose reply carries one."""
if mode is None or mode.submode not in (
protocol.SUBMODE_COLOR_LEGACY,
protocol.SUBMODE_COLOR_RGBWW,
):
return None
return mode.rgb
def census_state_name(mode: protocol.ModeReport | None) -> str:
"""A short name for the pre-probe state, for messages."""
if mode is None:
return "an unknown state"
name = submode_name(mode.submode)
if name == "scene":
return f"scene {mode.scene_id:#04x}" if mode.scene_id is not None else "a scene"
if name.startswith("music"):
return "a music mode"
if name.startswith("colour"):
return f"colour sub-mode {mode.submode:#04x}"
return f"mode {mode.submode:#04x}"
def _restore_frame(result: CensusResult) -> bytes | None:
"""The pre-probe 0x02 / 0x0D state as a write, or None when it cannot be rewritten.
The reported body is written back verbatim: an RGB write would turn a
white (kelvin) 0x0D state into RGB 00 00 00, i.e. dark. The H6006
reported 0D 000000 0A8C 000000 (2700 K white) on the bench.
"""
if result.mode is None or _mode_rgb(result.mode) is None:
return None
return protocol.build_frame(protocol.CMD_MODE, [result.mode.submode, *result.mode.body])
def _ack_since(link: ProbeLink, start: int, command: int) -> str:
ack_log = getattr(link, "ack_log", None)
if ack_log is None:
return "none"
for cmd, status in ack_log[start:]:
if cmd == command:
return "accepted" if status == 0 else "rejected"
return "none"
async def _probe(
link: ProbeLink,
result: CensusResult,
abort: Callable[[], bool] | None,
ask: Callable[[int, tuple[int, int, int]], Awaitable[str | None]] | None = None,
notify: Callable[[str], None] | None = None,
page_layout: PageLayout | None = None,
) -> None:
result.probed = True
powered_on = result.power is False
if powered_on:
await link.write(protocol.power_frame(True))
page = result.segment_page
last_mode_rgb = _mode_rgb(result.mode)
if notify is not None and _restore_frame(result) is None:
state = census_state_name(result.mode)
notify(
f"the light started in {state}, which the census cannot write back: there is no "
"reset between colours, so compare each with the colour before it"
)
for submode in PROBE_ORDER:
if abort is not None and abort():
result.aborted = True
break
rgb = PROBE_COLORS[submode]
stale_page = protocol.segment_page_has(page, rgb)
stale_mode = last_mode_rgb == rgb
acks_before = len(getattr(link, "ack_log", None) or [])
await link.write(protocol.rgb_frame(*rgb, submode=submode))
if ask is not None:
answer = await ask(submode, rgb)
if answer is None:
result.aborted = True
result.probe_acks[submode] = _ack_since(link, acks_before, protocol.CMD_MODE)
break
result.observed[submode] = answer
echoed = False
reply = await _query(link, result, protocol.mode_query_frame(), abort)
if reply is not None and reply.mode is not None and reply.mode.submode == submode:
if submode in (protocol.SUBMODE_COLOR_LEGACY, protocol.SUBMODE_COLOR_RGBWW):
echoed = reply.mode.rgb == rgb and not stale_mode
last_mode_rgb = _mode_rgb(reply.mode)
reply = await _query(
link, result, protocol.segment_colors_query_frame(1), abort, page_layout
)
if reply is not None:
if protocol.segment_page_has(reply, rgb) and not stale_page:
echoed = True
page = reply
if echoed:
result.echoed.append(submode)
result.probe_acks[submode] = _ack_since(link, acks_before, protocol.CMD_MODE)
if submode != PROBE_ORDER[-1] and _restore_frame(result) is not None:
# Back to the starting state, so the next dialect's change is
# judged against the same baseline (by eye and by read-back).
if notify is not None:
notify("back to the starting state; watch it settle before the next colour")
await link.write(_restore_frame(result))
await asyncio.sleep(RESET_SETTLE_ASKING_S if ask is not None else RESET_SETTLE_S)
last_mode_rgb = _mode_rgb(result.mode)
restore = _restore_frame(result)
if restore is not None and result.mode is not None:
await link.write(restore)
result.restored = "full"
if result.mode.submode not in result.echoed:
result.restore_note = "colour rewritten in the reported dialect, which did not echo"
elif result.mode is not None and result.mode.submode in PROBE_COLORS:
result.restored = "power"
result.restore_note = "pre-probe colour not readable from the mode reply (0B/15); left on the last probe colour"
elif result.mode is not None:
result.restored = "power"
result.restore_note = "pre-probe mode was not a static colour; not rewritten"
else:
result.restored = "power"
result.restore_note = "pre-probe mode unknown; left on the last probe colour"
if result.power is not None:
await link.write(protocol.power_frame(result.power))
else:
result.restored = "none"
result.restore_note = "pre-probe power unknown; nothing restored"
# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------
@dataclass(slots=True)
class Verdict:
"""What the evidence supports, and how strongly."""
family: str # plaintext-legacy, plaintext-rgbww, plaintext-rgbic, plaintext-dreamcolor,
# secret-locked, encrypted-v1, encrypted-v2, encrypted-unknown, silent-unknown,
# unreachable, unknown
security: str # none, secret-key, aes-ecb-rc4, aes-gcm, unknown
confidence: str # verified, probable, inferred, unknown
reasons: list[str] = field(default_factory=list)
control_dialects: list[int] = field(default_factory=list) # sub-modes with an echo
_DIALECT_FAMILY = {
protocol.SUBMODE_COLOR_LEGACY: "plaintext-legacy",
protocol.SUBMODE_COLOR_RGBWW: "plaintext-rgbww",
protocol.SUBMODE_COLOR_RGBIC: "plaintext-rgbic",
protocol.SUBMODE_COLOR_DREAMCOLOR: "plaintext-dreamcolor",
}
def classify(
result: CensusResult,
*,
model: str | None,
adv: GoveeAdvertisement | None,
bgc: bytes | None = None,
) -> Verdict:
"""Turn one visit into a family / security / confidence verdict with its reasons."""
reasons: list[str] = []
encrypted_adv = adv.encrypted if adv is not None else None
bgc_version: int | None = None
if bgc:
from .cipher import bgc_encrypt_version
bgc_version, rule = bgc_encrypt_version(bgc)
reasons.append(f"2b12 bytes {bgc.hex()}: encryptVersion {bgc_version} ({rule})")
if not result.connected:
return Verdict("unreachable", "unknown", "unknown", ["no connection was made"])
if encrypted_adv or (bgc_version or 0) > 0:
security = {1: "aes-ecb-rc4", 2: "aes-gcm"}.get(bgc_version or 0, "unknown")
family = {1: "encrypted-v1", 2: "encrypted-v2"}.get(bgc_version or 0, "encrypted-unknown")
if encrypted_adv:
reasons.append("advertisement flags bit 6 set: AES-wrapped command channel")
confidence = "probable" if bgc_version else "inferred"
if result.replies:
reasons.append(
f"{result.replies} plaintext replies arrived anyway; the flag may be stale"
)
confidence = "inferred"
return Verdict(family, security, confidence, reasons)
rejected = [s for s, a in result.probe_acks.items() if a == "rejected"]
accepted = [s for s, a in result.probe_acks.items() if a == "accepted"]
seen = [s for s, a in result.observed.items() if a in ("yes", "partial")]
if accepted or rejected:
reasons.append(
"write acks: accepted "
+ (", ".join(f"{s:#04x}" for s in accepted) or "none")
+ "; rejected "
+ (", ".join(f"{s:#04x}" for s in rejected) or "none")
)
if result.observed:
reasons.append(
"operator saw a change on " + (", ".join(f"{s:#04x}" for s in seen) or "no dialect")
)
if result.echoed:
best = result.echoed[0]
reasons.append("probe colour echoed on " + ", ".join(f"{s:#04x}" for s in result.echoed))
return Verdict(
_DIALECT_FAMILY[best], "none", "verified", reasons, control_dialects=list(result.echoed)
)
if result.silent:
reasons.append(f"{result.reads} reads, no verified reply")
if needs_secret(model):
reasons.append("model family is tagged secret_key_pairing; AA B1 did not answer")
return Verdict("secret-locked", "secret-key", "probable", reasons)
if adv is None:
reasons.append("no manufacturer data seen; encryption flag unknown")
return Verdict("silent-unknown", "unknown", "unknown", reasons)
if result.replies and result.mode is not None and result.mode.submode in _DIALECT_FAMILY:
reasons.append(f"mode reply names sub-mode {result.mode.submode:#04x} (no write was tried)")
if result.secret_present:
reasons.append("AA B1 answered: unbound light handing out its secret")
return Verdict(
_DIALECT_FAMILY[result.mode.submode],
"secret-key" if needs_secret(model) else "none",
"inferred",
reasons,
)
if result.replies:
reasons.append(f"{result.replies} replies but the mode reply names no colour dialect")
return Verdict(
"unknown", "secret-key" if needs_secret(model) else "none", "inferred", reasons
)
reasons.append("no evidence beyond the connection")
return Verdict("unknown", "unknown", "unknown", reasons)
# ---------------------------------------------------------------------------
# Summary record (govee-evidence/1, same keys as govee_census_summary_json)
# ---------------------------------------------------------------------------
def summary_record(
result: CensusResult,
*,
address: str,
name: str | None,
model: str | None,
adv: GoveeAdvertisement | None,
rssi: int | None,
source: str,
at: str,
software: str,
dialect: int | None = None,
dialect_source: str | None = None,
known_model: bool | None = None,
include_secret: bool = False,
) -> dict[str, object]:
"""One ``{"summary":"light", ...}`` object in the shared shape."""
rec: dict[str, object] = {
"summary": "light",
"schema": SCHEMA,
"source": source,
"at": at,
"software": software,
"address": address,
"name": name or "",
"model": model or "",
}
if known_model is not None:
rec["known_model"] = known_model
rec["rssi"] = rssi
if adv is not None:
rec["pact"] = adv.pact
rec["flags"] = adv.flags
rec["encrypted"] = adv.encrypted
else:
rec["pact"] = rec["flags"] = rec["encrypted"] = None
if dialect is not None:
rec["dialect"] = {0x02: "RGB", 0x0D: "RGBWW", 0x15: "RGBIC", 0x0B: "Dream"}.get(
dialect, f"{dialect:#04x}"
)
rec["dialect_source"] = dialect_source or "default"
rec["secret_family"] = needs_secret(model)
rec["connected"] = result.connected
rec["subscribed"] = result.subscribed
rec["silent"] = result.silent
if result.aborted:
rec["aborted"] = True
rec["reads"] = result.reads
rec["replies"] = result.replies
if result.bad_frames:
rec["bad_frames"] = result.bad_frames
if result.acks:
rec["acks"] = result.acks
if result.power is not None:
rec["power"] = result.power
if result.brightness is not None:
rec["brightness"] = result.brightness
if result.mode is not None:
rec["submode"] = f"{result.mode.submode:02X}"
if result.mode_raw is not None:
rec["submode"] = f"{result.mode.submode:02X} {result.mode_raw[1:5].hex().upper()}"
rec["submode_name"] = submode_name(result.mode.submode)
if result.firmware:
rec["firmware"] = result.firmware
if result.hardware:
rec["hardware"] = result.hardware
if result.ic_count is not None:
rec["ic_count"] = result.ic_count
rec["segments"] = result.segments
if result.light_count is not None:
rec["light_count"] = result.light_count
rec["secret_present"] = result.secret_present
if result.secret_present and include_secret and result.secret is not None:
rec["secret"] = result.secret.hex()
rec["segment_page"] = result.segment_page is not None
rec["probed"] = result.probed
if result.probed:
rec["echoed"] = ",".join(f"{s:02X}" for s in result.echoed)
if result.probe_acks:
rec["probe_acks"] = {f"{s:02X}": a for s, a in result.probe_acks.items()}
if result.observed:
rec["observed"] = {f"{s:02X}": a for s, a in result.observed.items()}
rec["restored"] = result.restored
if result.restore_note:
rec["restore_note"] = result.restore_note
if result.error:
rec["error"] = result.error
return rec
_SUBMODE_NAMES = {
0x00: "video", 0x01: "music01", 0x02: "colour02", 0x03: "music03", 0x04: "scene",
0x05: "mic", 0x07: "diy07", 0x09: "scene09", 0x0A: "diy", 0x0B: "colour0B", 0x0C: "music0C",
0x0D: "colour0D", 0x0E: "music0E", 0x0F: "music0F", 0x11: "music11", 0x12: "music12",
0x13: "music13", 0x14: "music14", 0x15: "colour15", 0x16: "music", 0x17: "music",
0x19: "music", 0x22: "music", 0xFF: "mic",
} # fmt: skip
def submode_name(submode: int) -> str:
"""The short names Dance Party's govee_submode_name() uses."""
return _SUBMODE_NAMES.get(submode, "?")