README.md

Bench test rig

govee_rig.py talks to a Govee light from a plain Linux box (a Raspberry Pi Zero W next to the strip is the intended setup) using bleak on BlueZ. It imports protocol.py, models.py and probe.py from this checkout, so the frames it sends and the way it decodes replies are exactly what the Home Assistant integration does. There is no second implementation to drift.

Use it to confirm a model's colour sub-mode and brightness scale before adding it to the table in models.py, to try frames the integration does not send yet (music, segments, scene IDs on RGBIC firmware), and to capture a clean TX/RX log that can be diffed against an HA diagnostics download.

Setting up the Pi

Raspberry Pi OS Bookworm (Python 3.11) or newer. The rig needs 3.11+.

sh
1sudo apt install -y git python3-venv bluez
2git clone https://github.com/Mezzle/govee-bluetooth.git
3cd govee-bluetooth
4python3 -m venv ~/rig && ~/rig/bin/pip install bleak
5bluetoothctl power on
6~/rig/bin/python tools/govee_rig.py scan

Two Pi Zero W notes. The onboard radio is Bluetooth 4.1 and shares its antenna with Wi-Fi, so a busy Wi-Fi link makes BLE flaky; SSH in over USB gadget Ethernet or accept the odd dropped connection. And make sure the Govee app on your phone is closed: the light accepts one connection at a time.

Commands

sh
1RIG="~/rig/bin/python tools/govee_rig.py"
2
3$RIG scan --seconds 10 # address, name, model, table dialect, pact, encryption flag, RSSI
4$RIG status A4:C1:38:12:34:56 # the four reads, decoded next to the hex
5$RIG --log bench/h617a.jsonl census A4:C1:38:12:34:56 # the shared read battery, evidence records
6$RIG --log bench/h617a.jsonl census A4:C1:38:12:34:56 --probe # plus one colour per dialect, then restore
7$RIG status A4:C1:38:12:34:56 --active # write-and-echo probe, same code HA runs
8$RIG status A4:C1:38:12:34:56 --active --candidates 15,0D,02
9$RIG send A4:C1:38:12:34:56 on rgb 255 0 0 bri 50
10$RIG verify A4:C1:38:12:34:56 rgb 18 52 86 ct 4300 scene 5 bri 30
11$RIG send A4:C1:38:12:34:56 raw 33 05 15 01 ff 00 00 00 00 00 00 00 ff 7f
12$RIG shell A4:C1:38:12:34:56 --verify # one connection, type commands

Every connect first does a short scan for the advertised name so the model and its table profile are known. --model H6167 skips that, --submode 0B overrides the colour sub-mode the table gives, and --no-scan connects blind.

The command vocabulary is the same for send, verify and shell:

tokenwhat goes out
on / off33 01 01 / 33 01 00
bri N33 04 N, N is the raw device byte, not the HA 1-255 value
rgb R G Bprotocol.rgb_frame in the current sub-mode
ct Kprotocol.color_temp_frame in the current sub-mode (0x0B has none)
scene ID33 05 04 <ID lo> <ID hi> (16-bit little-endian)
raw HEX...padded to 19 bytes and checksummed, or sent as-is if 20 bytes
readversion, power, brightness and mode queries
mode, power, brightness, versionone query
submode 02/0B/0D/15switch the sub-mode for later rgb / ct tokens
sleep Spause, heartbeat still running

verify reads back after each write and prints one of ECHOED, DIFFERS or NO REPLY per field, using the same echo rules as probe.py. A 0x15 rgb write only confirms the sub-mode byte, because that family's mode reply carries kelvin and no RGB; follow it with a ct write for a real echo.

shell keeps one connection open with the 2 s heartbeat, which saves the few seconds a reconnect costs between experiments. verify on / verify off toggles read-back, quit leaves.

Census and the compatibility report

census ADDR runs the same read battery the Dance Party Flipper app and ESP32 image run (power, brightness, firmware, mode, hardware, IC/segment count, light count, pairing secret, one segment-colour page), reads the 2b12 BGC-info characteristic, and prints a verdict: protocol family, session security and a confidence with its reasons. --probe adds one colour write per dialect (0x02, 0x0B, 0x0D, 0x15), each with its own probe colour, and counts a dialect only when its colour shows up where it was not showing before; a powered-off light is switched on for the probes and off again, and the pre-probe colour is put back when the mode reply carried it. With --log the run writes govee-evidence/1 records (see docs/evidence-schema.md): a session header, every frame, and one summary line per light.

tools/govee_report.py turns any mix of those logs, Dance Party census.jsonl files, ESP32 USB logs and Home Assistant diagnostics downloads into a Markdown report and a JSON evidence document:

sh
1python3 tools/govee_report.py bench/*.jsonl census.jsonl --markdown report.md --json evidence.json
2python3 tools/govee_report.py census.jsonl --observe "A4:C1:38:12:34:56=whole strip went dark blue on the 0x15 write;capability=rgb;outcome=worked"

Each device gets a verdict with the sources behind it, a capability table (verified / inferred / observed / untested), the reads that failed, and what was restored. Evidence from a Flipper session carries the 🪩 Dance Party Certified badge: a provenance mark, nothing more.

The Blinkt (optional)

A Pimoroni Blinkt on the Pi's header turns into a status strip with --blinkt: a cyan sweep while scanning, LED 0 green while connected, LED 7 flashing as frames go out and LED 6 as replies come in (red on a timeout, magenta on a bad checksum), LED 5 green on a write ack, the whole strip mirroring every colour written (the dark probe colours brightened so you can see them), and the census verdict at the end: a disco sparkle for verified, amber for inferred, red for silent, purple for encrypted. tools/discover.sh turns it on by itself when it can.

It drives the LEDs through libgpiod 2, so it needs the gpio group and the system's python3-libgpiod visible in the venv (python3 -m venv --system-site-packages ~/rig), but no root. python3 tools/blinkt.py walks a colour along the strip to check the wiring. Without the board it does nothing.

Logging

--log bench.jsonl appends every frame in both directions as one JSON object per line with at, direction, frame, note, decoded and address. The first four keys match the frames list in a Home Assistant diagnostics download, so the two can be compared directly. Heartbeats are logged but not printed unless --show-heartbeat is given. Write acknowledgements the light pushes (33 xx echoes) are logged as ack, corrupt frames as bad_checksum. Frames carrying the 8-byte pairing secret (AA B1 01 ... replies, 33 B2 writes) are written with the secret zeroed and "redacted": true; --keep-secrets turns that off for a private capture.

For a full HCI capture of the rig's own connection, run btmon in a second terminal before connecting (it sees only what passes through the Pi's radio):

sh
1sudo btmon -w govee-$(date +%Y%m%d-%H%M).btsnoop

Open the .btsnoop file in Wireshark; filter btatt to see just the GATT writes and notifications. btmon with no arguments prints live to the terminal instead.

Confirming a new model

  1. scan to get the address and the model from the advertised name.
  2. status ADDR with the light showing a plain colour set from the Govee app. The mode reply names the sub-mode without writing anything.
  3. If the light was in a scene or music mode, status ADDR --active writes a test colour in each candidate sub-mode and reports which one echoed.
  4. verify ADDR bri 100 then verify ADDR bri 200: a light on the 0-100 scale reports the second as unchanged or clamped.
  5. verify ADDR scene 0 scene 1 scene 4 ... to check which legacy scene IDs an RGBIC firmware accepts.
  6. Add the model to models.py with what you found, and put the rig's log in the pull request.

Music mode is not in protocol.py yet because the decompiled app shows six different music sub-mode bytes across families. Use raw to try candidates (33 05 03 ... legacy, 33 05 11 ... and 33 05 13 ... for the RGBIC families) and watch the mode reply.

If the light connects but ignores every write

Some families (the dreamcolor RGBIC strips, h61d3 neon/net lights, string lights, hollow lamps) gate control on an 8-byte per-device secret that the app writes as 33 B2 <8 bytes> right after every connect. See docs/govee-ble-protocol.md section 10. In order of effort:

  1. send ADDR raw aa b1 00: a light in its pairing window answers AA B1 01 followed by the 8 secret bytes. A factory-reset light should answer.
  2. Capture on the phone while the Govee app sets a colour (Android HCI snoop log, or PacketLogger with Apple's Bluetooth profile on an iPhone; a capture on the Pi cannot see the phone's link): the 33 B2 write is the first or second frame after connect and is plaintext on these families.
  3. The Govee account device record carries it as bleExt.secretCode (base64), through the app's private API on device.govee.com, not the public key API.

Then send ADDR raw 33 b2 <8 hex bytes> rgb 255 0 0 to test whether the secret unlocks writes.

Encrypted lights (experimental)

A light whose advertisement flags bit 6 or whose 2b12 bytes announce a cipher ignores plaintext frames. --cipher v1 runs the AES-ECB+RC4 handshake (E7 01 / E7 02 under the fixed PSK) before anything else and wraps every frame with the negotiated session key; the handshake frames are logged, the key never is. It is implemented against a replayed H600B phone capture and has not yet been run on a light here; status, census and send all accept it. The V2 AES-GCM cipher is implemented in cipher.py with a known-answer test but not wired to the rig. The per-model state of play is in docs/encrypted-protocol-matrix.md.

Guided discovery session

tools/discover.sh LABEL is the bench session for one light built on the census: pre-flight, power on without pairing, scan (pact and encryption columns), the read-only census, the opt-in probe with a prompt for what the light visibly did on each dialect, brightness scale, colour temperature, legacy scene codes, an optional app pairing with an HCI capture and a post-pairing re-check, then govee_report.py with your observations folded in. To keep the app's own frames from the pairing stage, capture on the phone; the wizard explains how and offers to inspect the extracted file once copied over. You can also run python3 tools/analyse_capture.py FILE later. See phone capture guidance for Android/iPhone steps, extraction and privacy limits. Findings go to bench/LABEL.env, evidence to bench/LABEL-*.jsonl, the report to bench/LABEL-report.md. The last stage prints the fixtures/govee-models.json row to add. Re-running keeps recorded values. PYTHON=/path/to/python overrides ~/rig/bin/python.

Characterising a new light end to end

tools/characterise.sh LABEL is a guided bench session for one light, from the box to a filled-in record. It runs every rig command itself and stops only for the physical steps: power on, pair in the app, pick a colour. It reads the pairing secret with AA B1 before the app ever binds the light, explains how to capture the app's first frames on the phone if you want them, reads back what the app set for red, white, a scene and music, runs the active probe, tries a colour write in all four dialects with an AA A5 segment read, then brightness scale, colour temperature and scene codes.

Findings go to bench/LABEL.env and frame logs to bench/LABEL-*.jsonl. bench/ is git-ignored because the record may hold the secret. Re-running offers each recorded value as the default. PYTHON=/path/to/python overrides the default ~/rig/bin/python.

Firmware update capabilities

Run the normal census to inspect the discovered GATT metadata:

sh
1python3 tools/govee_rig.py --log census.jsonl census ADDRESS
2python3 tools/govee_report.py census.jsonl --markdown report.md --json evidence.json

The console and light summary's ota field report Govee V1 and TI OAD V2 services, matching characteristics and their properties. The report preserves this evidence alongside model, firmware and hardware versions. Older captures show “not inspected”.

This inspection sends no OTA prepare commands and does not read, subscribe to, or write OTA characteristics. Normal census status reads still run. Leave off --probe for the read-only census. The V1 data characteristic shares a UUID with control-service BGC-info, so only its presence under the OTA service counts.

services_detected means metadata was found, not that installation is verified. complete means all expected characteristic UUIDs were present. not_observed means no recognised OTA service was discovered in the current mode; a bootloader or another update transport may still exist. update_verified is always false. No firmware is downloaded or installed.