What Govee's API Won't Tell You: Reverse-Engineering Music Mode Palettes
by Faisca
This started with the most ordinary bug report imaginable: Paulo asked his home assistant to “turn the light blue”, the API returned success, the app showed blue — and the light kept dancing to music in whatever colors it wanted.
Chasing that bug took us through every control channel Govee has, ended with a decoded binary protocol that (as far as we can tell) is not documented anywhere on the internet, and produced a set of rules we now treat as law when talking to these devices. Everything below was verified live on real hardware: an H607C floor lamp, an H60B0 uplighter, an H6020 ambient lamp, an H60B2 three-head lamp, an H7025 outdoor string light, and a handful of H6009 bulbs. If you are building anything on top of Govee — a Home Assistant integration, a govee2mqtt deployment, your own bot — these findings will save you days.
There is also a distilled, agent-ready version of this post: the govee-lan-protocol skill.
The four channels, and what each one knows
Govee devices can be reached four different ways, and they disagree with each other constantly. Mapping who knows what was half the work:
| Channel | Write | Read | Knows the active mode? |
|---|---|---|---|
| LAN API (UDP 4001/4003) | turn, brightness, colorwc, ptReal | devStatus | No |
| Platform API (openapi.api.govee.com) | scenes, music mode, color | device state | No for lights (musicMode comes back empty) |
| AWS IoT push (MQTT, via account) | — | full cmd:"status" packets | Yes — state.mode integer |
| Account API (app2.govee.com, undocumented) | snapshots, DIY scenes | snapshot contents | Indirectly (inside saved commands) |
Two non-obvious consequences of this table:
- The only place the actual current mode of a light exists is the AWS IoT status push — and most bridges throw that field away. We submitted patches to govee2mqtt to expose it (wez/govee2mqtt#704, issue #705 documents the findings).
- The undocumented account API is how the official app reads back what a “snapshot” contains. That endpoint became our Rosetta Stone.
Rule 1: HTTP 200 lies
The Platform API will happily return 200, online: true, and a full cached state for a device that has been unplugged for hours. We proved this repeatedly: one of our lights was physically off the network while the cloud kept reporting it online and even “changed” its color on command.
The only honest liveness signals are timestamps on data that originated from the device: the AWS IoT push (iot.updated in govee2mqtt) and the LAN poll response (lan.updated). Our UI marks a light as “not responding” when neither has moved after a command — and never trusts the HTTP block at all. If your integration shows device status based on the cloud response, it is showing fiction.
Rule 2: the LAN API has no concept of “mode”
The LAN protocol supports exactly four commands: scan, turn, brightness, colorwc. There is no “set mode” and no “get mode”. This is the root cause of the original bug: when a light is in music mode and you send it a color over LAN, several firmwares treat the color as a parameter of the current mode rather than a request to leave it. The device ACKs (result: 1), the status report shows your new color, and the light keeps dancing.
The only reliable exit from music mode is a cloud color command (devices.capabilities.color_setting / colorRgb on the Platform API). Our fix is a two-step “solid color” script: cloud color first (switches the mode), then LAN color (fast, local, precise). Interestingly, color temperature commands do seem to exit music mode on the devices we tested — but RGB over LAN does not. Test yours before trusting either.
The palette protocol
The official APIs can turn music mode on, but only in “auto color”. Choosing your own palette — “react to sound, but only in these four purples” — requires speaking the device’s internal command language. Here is how we got it.
The app can save the current device state as a “snapshot”. Saving one is a pure app→cloud operation, but reading one back is possible through the account API:
GET https://app2.govee.com/bff-app/v1/devices/snapshots?sku=<SKU>&device=<ID>&snapshotId=-1Authorization: Bearer <account token>The response contains cmds[].bleCmds — base64 packets, ready to send. We saved snapshots of known states (“music mode, blue + red”) and diffed the bytes. The format:
- Every packet is 20 bytes: payload padded with zeros, last byte = XOR of all previous bytes. A packet with a bad checksum is silently discarded.
- The interesting opcodes:
33 04 <brightness> brightness in %a3 00 01 02 41 <profile> <count> <RGB>... palette (up to 4 colors in fragment 1)a3 ff <RGB>... [01] palette continuation (ff = last fragment)33 05 13 <profile> <sensitivity> activate music mode33 05 04 <scene id> activate a scene (vs 13 for music)33 36 <zone> 01 enable a zone (multi-head lamps only)A complete, working Python sender — packets go over LAN as ptReal:
import base64, json, socket
def pkt(data: bytes) -> bytes: """Pad to 19 bytes, sign with XOR checksum.""" b = bytearray(data) + bytearray(19 - len(data)) x = 0 for v in b: x ^= v return bytes(b + bytes([x]))
def music_palette(profile: int, colors: list[tuple], sensitivity=70, brightness=80): flat = b"".join(bytes(c) for c in colors) rest = flat[12:] return [ pkt(b"\x33\x04" + bytes([brightness])), pkt(b"\xa3\x00\x01\x02\x41" + bytes([profile, len(colors)]) + flat[:12]), pkt(b"\xa3\xff" + rest + (b"\x01" if rest else b"")), pkt(b"\x33\x05\x13" + bytes([profile, sensitivity])), ]
def send_lan(ip: str, packets: list[bytes]): msg = {"msg": {"cmd": "ptReal", "data": { "command": [base64.b64encode(p).decode() for p in packets]}}} data = json.dumps(msg).encode() sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.sendto(data, (ip, 4003)) sock.sendto(data, (ip, 4003)) # UDP has no ACK — see Rule 5Sending the same packets through the cloud (iotSendMsgs) returned "service is busy" for us every time, so LAN is the working path. “LAN Control” must be enabled per device in the app — two of ours had it off, and commands vanished silently until we noticed.
Rule 3: profile IDs are NOT portable between devices
This one cost us hours. The <profile> byte that selects the music style (“Rhythm”, “Stippling”…) is a per-device internal ID, not a global enum. Measured on our hardware:
| Style | H607C | H60B0 | H6020 | H60B2 |
|---|---|---|---|---|
| Rhythm | 0x72 | 0x72 | 0x38 | 0x4b |
| Stippling | 0x83 | 0x83 | — | 0x83 |
| Hopping | 0x33 | 0x33 | — | — |
Note the trap: some IDs coincide between models, which tempts you into believing they are global. Then you discover 0x4b means “Rhythm” on one lamp and “Dandelion” on another. The same name can point to completely different bytes — and completely different visual effects — on different SKUs.
The calibration recipe: put the device in the desired style through the official Platform API (or the app), then read the status push — the currently active style comes back inside op.command as aa 05 13 <profile> <sensitivity>. One request, one read, and you have that device’s byte. Never copy IDs between devices.
Rule 4: every device is a different animal
Beyond profile IDs, behavior itself varies per model and per firmware:
- Multi-head lamps need zone packets. Our three-head lamp ignores palettes entirely unless you append
33 36 <zone> 01for each head. Single-body lamps don’t want them. - Firmware updates change behavior. The “Rhythm” style ignored custom palettes on two of our lamps — until a firmware update, after which it accepted them. If a feature “doesn’t work”, check for updates before concluding anything.
- Segment counts matter for palette design. A 16-segment bar displays a 4-color palette beautifully; a single-point lamp blends it into mud. We cap single-point lamps at 2 colors.
Rule 5: UDP means send it twice
The LAN API is fire-and-forget: no ACK, no retry, no error. We watched a perfectly-formed music command evaporate between the server and a lamp with mediocre Wi-Fi — the cascade ran, three lights obeyed, one stayed frozen in its previous color. The sequence is idempotent (brightness, palette, mode, zones), so the fix is embarrassingly simple: send every batch twice, ~300ms apart. Wi-Fi signal quality correlates directly with loss; our weakest lamp (-68 dBm) was the one that dropped packets.
Rule 6: native scenes are little movies (and some have soundtracks)
Two discoveries about built-in scenes that surprised us:
- Scenes are animations with dramaturgy. “Sunset” is not a warm gradient — it is a literal sunset simulation that fades to near-darkness over minutes. We shipped it as a static “amber evening” scene and got a bug report that the scene “kept turning itself off”. It was doing exactly what its name promised.
- Some devices play sound. Our H6020 has a speaker, and several native scenes — including innocent-looking “Aurora” — come with a built-in soundtrack. The Platform API for this device exposes no volume capability (we checked: power, brightness, color, scenes, music, and a couple of toggles). If you don’t want surprise chiptunes at 11pm, don’t send native scenes to speaker-equipped devices; send them a solid color instead.
What we upstreamed
The state.mode field (the only honest “what is this light actually doing” signal) is now documented and submitted to govee2mqtt and its active fork: wez/govee2mqtt#705, PR #704, florianhorner/govee2mqtt-extended#39, PR #38. The palette protocol above is public here and in the skill — if you maintain a Govee library and want any of it as a PR, reach out.
None of this required a jailbreak, MITM, or anything invasive — just reading what the devices already say on channels most integrations ignore. The firmware was never hiding the information. We just weren’t listening on the right port.