---
name: govee-lan-protocol
description: Hard-won lessons for controlling Govee smart lights beyond the official API — the undocumented LAN music-mode palette protocol, per-style parameter tails on newer string lights, why HTTP 200 lies about device liveness, why profile IDs are not portable between devices, and the pitfalls that cause silent failures. Use this skill when building any Govee integration, Home Assistant bridge, or bot that needs music mode with custom palettes, honest device-liveness detection, or reliable LAN control.
license: MIT
compatibility: Claude Code, any LLM-based coding agent
metadata:
  author: Paulo Silveira
  version: "1.2"
  hardware_tested: "H607C, H60B0, H6020, H60B2, H7025, H6009"
---

# govee-lan-protocol — Controlling Govee Lights Beyond the Official API

Practical guide for controlling Govee smart lights when the official Platform API isn't enough — specifically **music mode with a custom color palette**, honest liveness detection, and reliable LAN control. Based on live reverse-engineering against real hardware. Companion narrative: [reverse-engineering-govee-music-mode](https://paulo.com.br/blog/reverse-engineering-govee-music-mode).

## The four channels

Govee devices are reachable four ways, and they disagree with each other. Know which one to ask:

| Channel | Transport | Use it for | Knows active mode? |
|---|---|---|---|
| **LAN API** | UDP 4001 (scan) / 4003 (control) | fast local `turn`/`brightness`/`colorwc`/`ptReal` | No |
| **Platform API** | HTTPS openapi.api.govee.com | scenes, music mode on/off, the only reliable *exit* from music mode | No for lights |
| **AWS IoT push** | MQTT via account | reading the real current mode (`state.mode`) | **Yes** |
| **Account API** | HTTPS app2.govee.com (undocumented) | reading snapshot contents | Indirectly |

## Pitfall 1: HTTP 200 lies about liveness

The Platform API returns `200` + `online: true` + a full cached state for devices that are physically offline. **Never trust it for status.** The only honest liveness signal is a timestamp on device-originated data: the AWS IoT push or the LAN poll response. If neither has moved recently, the device is not really there — regardless of what the cloud says.

## Pitfall 2: the LAN API has no "mode" command

LAN supports only `scan`, `turn`, `brightness`, `colorwc`. When a light is in music mode and you send a color over LAN, many firmwares treat it as a *parameter of the current mode* — the light ACKs, reports the new color, and keeps animating. **To leave music mode, send a color via the Platform API** (`color_setting`/`colorRgb`), then optionally follow with a LAN color for speed. (Color *temperature* over LAN often does exit music mode; RGB does not. Verify per device.)

## The music-mode palette protocol

Official APIs only enable music mode in "auto color". Custom palettes require the device's internal command language, recoverable by reading back a saved snapshot through the account API:

```
GET https://app2.govee.com/bff-app/v1/devices/snapshots?sku=<SKU>&device=<ID>&snapshotId=-1
Authorization: Bearer <account token>
```

`cmds[].bleCmds` are base64 packets. Format:

- **20 bytes each**: payload zero-padded, last byte = XOR of all previous bytes. Bad checksum = silently dropped.
- **Prefixes**: `33` = single-packet write, `a3` = fragmented multi-packet write (`a3 00` first, `a3 01..` middle, `a3 ff` always last, even if empty), `aa` = device-originated **report**. Replaying an `aa` frame as a command is silently ignored — status frames mirror write opcodes but are not writes.
- Opcodes:

```
33 04 <brightness>                          brightness %
a3 00 01 <nfrags> 41 <profile> <count> <RGB>...   palette, first fragment
a3 ff <RGB>... [01]                          palette continuation (ff = last)
33 05 13 <profile> <sensitivity>            activate music mode
33 05 04 <scene id>                          activate a scene
33 36 <zone> 01                              enable a zone (multi-head only)
```

The byte after `01` in the first fragment is the **total fragment count** — it reads as a constant `02` on lamps whose palettes always fit in two fragments, which is exactly how we mislabeled it for a week.

Working sender (packets go over LAN as `ptReal`, UDP 4003):

```python
import base64, json, socket

def pkt(data: bytes) -> bytes:
    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], times: int = 2):
    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)
    try:
        for _ in range(times):        # UDP has no ACK — see pitfall 5
            sock.sendto(data, (ip, 4003))
    finally:
        sock.close()
```

Sending these through the cloud (`iotSendMsgs`) returns `"service is busy"` — LAN is the working path. **"LAN Control" must be enabled per device in the app**, or commands vanish silently.

## Newer string lights (H7025): per-style tails and fragmented streams

On the H7025 (outdoor string light) the palette command is a single byte **stream** — `41 <profile> <count> <RGB×count> <TAIL>` — sliced across `a3` fragments, and **every music style has its own tail** after the colors:

| Style | ID | Tail | Meaning |
|---|---|---|---|
| Hopping | `0x48` | `<bg R G B> <brightness>` | background color: unlit bulbs glow faintly in it (`01 01 01` = off) |
| Star | `0x98` | `<bg R G B> <brightness>` | same |
| Sway | `0x8E` | `00 00 00` | style parameters (zeroed) |
| Rolling | `0x5A` | `08` | likely speed |
| Piano Keys | `0x34` | `00 05 0a 04 04` | gradient, key count, constants |

Slicing rule (byte-exact against every app snapshot we captured): the `a3 00` fragment carries `01 <nfrags>` plus the first **15** bytes of the stream; a leftover of **≤8 bytes goes straight into `a3 ff`**; anything bigger opens 17-byte `a3 01..` fragments first.

```python
def music_palette_h7025(profile, colors, tail, sensitivity=70, brightness=80):
    stream = bytes([0x41, profile, len(colors)]) \
        + b"".join(bytes(c) for c in colors) + tail
    body, rest, middle = stream[:15], stream[15:], []
    while len(rest) > 8:
        middle.append(pkt(bytes([0xA3, len(middle) + 1]) + rest[:17]))
        rest = rest[17:]
    return [
        pkt(b"\x33\x04" + bytes([brightness])),
        pkt(b"\xa3\x00\x01" + bytes([2 + len(middle)]) + stream[:15]),
        *middle,
        pkt(b"\xa3\xff" + rest),
        pkt(b"\x33\x05\x13" + bytes([profile, sensitivity])),
    ]
```

Two hard-won warnings:

- **Sending one style's tail with another style's ID blacks out or wedges the light** (LEDs off while the bridge still reports "on"; recovery = power cycle + resend a valid look). This single bug masqueraded as both "random blackouts" and "the LAN transport only accepts 2 colors" before we captured one snapshot per style.
- A bare `33 05 13`-family activation without a coherent stored color config can freeze the firmware too. Send the full sequence, always.

## Pitfall 0: capture one snapshot per STYLE, not per lamp

The single most expensive lesson here. Style parameters hide *after* the colors, and they differ per style within the same firmware. Generalizing from one captured style produced two confident, wrong conclusions ("the firmware only does auto-color", "LAN caps palettes at 2 colors") that each cost an evening. Corollaries: an empty Wi-Fi capture usually means the app went over **BLE**, not that the feature doesn't exist; and before blaming the transport or the firmware, diff your payload against a byte-exact app capture.

## Pitfall 3: profile IDs are NOT portable between devices

The `<profile>` byte is a per-device internal ID, not a global enum. Measured:

| Style | H607C | H60B0 | H6020 | H60B2 |
|---|---|---|---|---|
| Rhythm | `0x72` | `0x72` | `0x38` | `0x4b` |
| Stippling | `0x83` | `0x83` | — | `0x83` |

Some coincide (a trap), but `0x4b` is "Rhythm" on one lamp and "Dandelion" on another. **Calibrate per device**: set the style via the Platform API (or app), then read the status push — the active style appears in `op.command` as `aa 05 13 <profile> <sensitivity>`. Never copy IDs across devices.

**Cheapest calibration path — the cloud echo.** When you activate a music mode through the Platform API (`music_setting`/`musicMode`), the Govee cloud translates the public enum value into the device's internal profile ID and forwards it over AWS IoT — and a bridge like govee2mqtt logs that forward at debug level as `Decoded: Generic([A3, 41, <profile>, ...])`. Read the ID from the echo of your own command: no app, no snapshot, no dependence on the device pushing status (it usually doesn't, and govee2mqtt never requests IoT status for LAN-reachable devices). Caveats: the API rejects `musicMode: 0` ("Parameter value out of range") and, on some SKUs, everything outside a contiguous per-SKU range — those styles are only reachable via app capture. The echo gives you the ID but not the per-style tail (newer string lights): tails still require one snapshot per style.

## Pitfall 4: every device is a different animal

- **Multi-head lamps need zone packets** (`33 36 <zone> 01` per head) or they ignore palettes.
- **Firmware changes behavior** — a style that ignores palettes may accept them after an update. Check for updates before concluding a feature is missing.
- **Segment count drives palette width** — 16-segment bars show 4 colors well; single-point lamps blend palettes to mud (cap them at 2).

## Pitfall 5: UDP means send it twice

LAN is fire-and-forget: no ACK, no retry. A well-formed command can evaporate, especially to lamps with weak Wi-Fi (loss correlates directly with signal strength). The sequence is idempotent, so **send every batch twice, ~300ms apart**.

## Pitfall 6: native scenes are animations, and some play sound

- **Scenes have dramaturgy.** "Sunset" fades to near-darkness over minutes — it is not a static warm color. Don't use animated scenes ("Sunset", "Dreamland", "Soothing") as fixed looks.
- **Speaker-equipped devices play soundtracks** in some native scenes (including innocuous-looking ones), and there may be **no volume capability** in the Platform API to silence them. For those devices, send a solid color instead of a native scene.

## Reading the real active mode

The only channel that reports the true current mode is the AWS IoT `cmd:"status"` push, in top-level `state.mode` (values are SKU-specific; commonly 4 = music, 5 = manual color, 1/11 = scene). Most bridges discard this field. If you run [govee2mqtt](https://github.com/wez/govee2mqtt), expose it — see the upstream discussion at issue #705 / PR #704.

## Quick checklist for a new device

1. Enable "LAN Control" in the app.
2. Find its IP (LAN `scan` on UDP 4001, or your DHCP table).
3. Calibrate each music style you want: save an app **snapshot per style** and read it back through the account API (best — full recipe with checksums), or set the style via the Platform API and read the **cloud echo** (`A3 41 <profile>` in your bridge's debug log — cheapest, see Pitfall 3) or the status push (`aa 05 13 <profile> <sensitivity>`).
4. Note zone count (multi-head) and segment count (palette width).
5. Replay the captured bytes over LAN `ptReal` **before** writing your own encoder — validates transport and content separately.
6. Send palettes twice (UDP), keep under ~20 packets/s, and never mix LAN `colorwc` with `ptReal` sequences (reported to wedge some firmwares until power-cycled).
7. To set a solid color reliably, go through the Platform API first (exits music mode), then LAN.

## Prior art & further reading

- [teh-hippo/ha-govee-led-ble](https://github.com/teh-hippo/ha-govee-led-ble) — formal **Kaitai Struct specs** of the BLE protocol (H617A/H6199), the only other public source naming the per-style tail fields (`tools/ble/kaitai/music_body.ksy`: background + relative brightness, gradient, key count, speed).
- [AlgoClaw/Govee](https://github.com/AlgoClaw/Govee) — the canonical write-up of the `a3` multi-packet framing and scene codes (`decoded/v1.2/explanation_v1.2.md`); note some SKUs (H70C4) use an `a4` prefix instead.
- [lasswellt/govee-homeassistant protocol reference](https://github.com/lasswellt/govee-homeassistant/blob/master/docs/govee-protocol-reference.md) — broad opcode/status reference from packet captures. Caveat: opcode semantics vary per SKU (`33 36` is nightlight there, zone select on multi-head lamps here).
- [egold555/Govee-Reverse-Engineering](https://github.com/egold555/Govee-Reverse-Engineering) — the historical hub (older `33 05 01` music format, per-product notes, ptReal pitfalls).
- [wez/govee2mqtt](https://github.com/wez/govee2mqtt) — production Rust implementation of the framing (`src/ble.rs`); also the bridge whose IoT logs and token cache make all of this capturable.
