Metadata-Version: 2.4
Name: govee-ble-local
Version: 1.10.0
Summary: Local Bluetooth LE control of Govee devices — no cloud, no LAN API.
Author: Brady Woods
License-Expression: MIT
Project-URL: Homepage, https://github.com/Brady-Woods/govee-ble-local
Project-URL: Repository, https://github.com/Brady-Woods/govee-ble-local
Project-URL: Changelog, https://github.com/Brady-Woods/govee-ble-local/blob/master/CHANGELOG.md
Project-URL: Issues, https://github.com/Brady-Woods/govee-ble-local/issues
Keywords: govee,bluetooth,ble,home-assistant,led,light
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Home Automation
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Framework :: AsyncIO
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: bleak>=0.21
Requires-Dist: bleak-retry-connector>=3.0.0
Requires-Dist: cryptography>=41.0.0
Requires-Dist: kaitaistruct>=0.11
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-asyncio; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Requires-Dist: jsonschema>=4.0; extra == "test"
Requires-Dist: PyYAML>=6.0; extra == "test"
Requires-Dist: aiohttp>=3.9; extra == "test"
Requires-Dist: pycryptodome; extra == "test"
Provides-Extra: cloud
Requires-Dist: aiohttp>=3.9; extra == "cloud"
Provides-Extra: typing
Requires-Dist: mypy; extra == "typing"
Requires-Dist: types-PyYAML; extra == "typing"
Provides-Extra: lint
Requires-Dist: ruff; extra == "lint"
Requires-Dist: bandit; extra == "lint"
Requires-Dist: pip-audit; extra == "lint"
Provides-Extra: docs
Requires-Dist: pdoc; extra == "docs"
Provides-Extra: audit
Requires-Dist: vulture; extra == "audit"
Requires-Dist: radon; extra == "audit"
Provides-Extra: dev
Requires-Dist: govee-ble-local[audit,cloud,lint,test,typing]; extra == "dev"
Requires-Dist: uv; extra == "dev"
Dynamic: license-file

# govee-ble-local

**Local Bluetooth LE control of Govee devices — no cloud, no LAN API.**

A standalone Python library implementing Govee's BLE control protocol (AES-ECB+RC4
handshake, 20-byte command framing, multi-packet scene upload, and status read-back),
derived from the decompiled Govee Home app and verified against real hardware. It's the
protocol engine behind the
[`hass-govee-ble-local`](https://github.com/Brady-Woods/hass-govee-ble-local)
Home Assistant integration, but has no Home Assistant dependency and can be used on its own.

> ⚠️ **Unofficial.** Not affiliated with or endorsed by Govee. Reverse-engineered from the
> app + hardware; behavior may change with device firmware. Each supported SKU's frames are
> traced from the app; a few are also checked on hardware (see "Supported SKUs" below).

## Install

```bash
pip install govee-ble-local
# optional Govee cloud-account helpers (pulls in aiohttp):
pip install 'govee-ble-local[cloud]'
# or, from a clone (editable, for development; needs the JVM kaitai-struct-compiler 0.11:
#   brew install kaitai-struct-compiler, or bash scripts/install_ksc.sh on Debian/Ubuntu;
#   see CONTRIBUTING.md):
git clone https://github.com/Brady-Woods/govee-ble-local.git
cd govee-ble-local && pip install -e '.[dev]'
```

Requires Python 3.11+. Installing from PyPI needs no compiler: the wheel ships the generated Kaitai classes. Runtime dependencies are deliberately minimal and Home-Assistant-friendly:
`bleak`, `bleak-retry-connector`, `cryptography` (all shipped with HA), plus `kaitaistruct` (a
pure-Python, stdlib-only wire-parser runtime). Dev extras: `.[dev]` (everything a contributor needs), or individually `.[test]`, `.[typing]`,
`.[lint]`, `.[audit]`, `.[docs]`.

## Quick start

```python
import asyncio
from govee_ble_local import create_device, discover

async def main():
    # Active-scan for supported Govee devices (strongest advertisement per address).
    devices = await discover(timeout=8.0)
    found = next(d for d in devices if d.sku == "H60A6")

    dev = create_device(found.ble_device, found.sku, found.advertisement)

    await dev.turn_on()
    await dev.set_brightness(60)             # percent
    await dev.set_rgb((255, 0, 0))
    await dev.set_zone_power("background", False)

    state = await dev.update()               # read-back into dev.state
    print(state.is_on, state.brightness, state.rgb_color, state.segments)

    await dev.stop()

asyncio.run(main())
```

## API

The library follows Home-Assistant BLE-library conventions: identify a device from its
advertisement, build a capability-driven `Device`, then drive it.

- **`discover(timeout=10.0, *, supported_only=True, allow_modelled=False)`** — active-scan →
  `list[DiscoveredDevice]` (`.ble_device`, `.advertisement`, `.sku`, `.rssi`, `.supported`); raises
  `GoveeBleConnectionError` when the scan itself fails (no adapter, adapter off, no BlueZ/D-Bus);
  that error is also the `BleakError` / `OSError` / `EOFError` 1.3.0 raised, so either handler matches.
  `supported(name, mfg)` and `match(ble_device, adv)` are the passive-matcher hooks for a
  HA-style scanner.
- **`create_device(ble_device, sku, advertisement_data=None, *, secret=None, frame_log=None,
  idle_disconnect=None, goods_type=None, allow_modelled=False,
  write_copies=1)`** — construct a `Device` for the SKU
  (raises `GoveeBleNotSupported` if unsupported). Pass the advertisement (`DiscoveredDevice.advertisement`
  or bleak's own): its encrypt flag selects the command channel, which some firmwares set differently
  from the SKU's default (H6008), and its pact the SKU's devices.yaml row. Both are read whatever the
  advertised name; `bound_advertisement(advertisement_data, sku, *, fallback_name=None)` is that read on
  its own, for a caller with no `Device` yet: the `GoveeAdvertisement` (`.pact`, `.encrypted`,
  `.encryption_known`) `create_device` routes by, e.g. the pact it refused. `is_supported_sku(sku)` /
  `supported_skus()` / `profile_for(sku)` query the table; `profile_for` and `is_supported_sku` take
  `pact=` / `goods_type=` to pick between rows. A SKU served only on a pact-gated row (the BK-firmware
  rows, H615A-E, H6076 / H8076 / R16D0, H70B6, H6198 / H6199, the h70b3 rows' H70B3 / H70B4 / H70B5 / H6810 /
  H6811 / H8811, and H611A / H6143 / H6144, admitted by default at pact (2,1) only) needs `pact=`:
  `supported_skus()` lists it, but without the advertisement's pact (`GoveeAdvertisement.pact`)
  `is_supported_sku` and `profile_for` refuse it (`create_device` with the advertisement, `supported`, `match`
  and `discover` pass it already). Those four also read the name: under a Govee name that names the SKU and
  isn't a `GBK_` one (`ihoment_`, `Govee_`, `Minger_`, `GV` / `GVH` / `GVR`) they refuse the BK-firmware rows
  the app reaches only from goodsType 0 (H6159, H6110, H6109, H614B, H614E, H6160, H6182, H6113, H6114, H6138,
  H6139, H6181, H6119), as the app reads that pact only from a `GBK_` name; `profile_for` and
  `is_supported_sku`, which take no name, route it as for a `GBK_` unit. A pact no row of the SKU lists is
  refused. `allow_modelled=True` (also on `supported_skus`, `create_device`,
  `supported`, `match` and `discover`) admits what the spec marks `modelled`: a row, a SKU of a row or a row's
  pact that is inferred from the app and not traced, or traced but held because the library does not yet send
  what the app sends. These are off by default. (`device_profile_for(sku)` is a deprecated alias of `profile_for`,
  removed in 2.0.) `write_copies` (also on `Device`; an `int` from 1 to `const.WRITE_COPIES_MAX`, 7; any other value
  raises `GoveeBleInvalidValue`) is how many times each single-frame write that gets no ACK goes out, on the
  same link, each copy waiting 6 s (`const.WRITE_ACK_TIMEOUT`). The default, 1, is the library's one retry
  policy for every device; raise it for a device with a weak link. Reads, 0xA3 multi-packet units (a run of
  frames acked as one), frames sent with no ACK wait and every write of a `session="per_command"` row (the
  H70B2, H6630 / H8630 and H6631) go out once whatever it is, and a refused write is never resent. A light's
  poll sends its `33 09` time sync and `33 25 01` effect stop as acked writes, so up to `write_copies` copies,
  the link dropped with no ACK: a device that never acks them costs `write_copies` × 6 s a poll. With no ACK after
  the last copy a command fails (`GoveeBleTimeout`) and the link is dropped; an unanswered or refused `33 b2`
  secret check never lets a frame out. A plug's `33 b5` time sync at session setup or after a live secret check is
  only logged with no ACK, and the session goes on; on goodsType 50 / 90 (H5082, H5160 / H5161) a poll sends it
  after its version reads instead, as their screens do, the same way, outside the poll's connect limit (the 45 s
  deadline bounds its copies; a later poll, with no version read due, sends it first), and another operation on
  that link (but a `read_secret()`) sends it before its own frames. The 45 s operation deadline, or a poll's connect
  limit for the session's setup writes, can cut a run of copies short, and that fails the operation
  (`GoveeBleTimeout`), a `33 b5`'s run included: each copy adds 6 s to a session's setup. Firmware-sourced: the
  H5083 firmware ignores a `33 b5` from an unpaired session, so on that firmware a goodsType 43 plug with no
  secret pays that on every session, and under a Home Assistant poll's 15 s limit every poll that connects fails
  from 3 copies (2 once the connect takes more than about 3 s). A write acked or refused on a later copy, or
  cancelled once it went out (at any `write_copies`), may still get a reply on the link, so while that reply is
  still due (a secret check's: for the rest of the session) the next ack-verified write of that command on the
  link goes out on a fresh link (a command reconnects; a live secret check is sent by the next session's setup; a
  session's `33 b5` is not checked): one reconnect (assumed: the device sends one reply per copy and drops a
  pending one with the link, untraced). A caller whose own timeout is shorter than a connect can then fail every
  call after a cut write whose reply never comes, as it does after any lost link.
- **`Device`** — one capability-gated class (behaviour comes from its `DeviceProfile`, not
  subclasses). Commands are ACK-confirmed on the wire:
  `turn_on` / `turn_off` / `set_power`, `set_brightness`, `set_rgb`, `set_color_temp`,
  `set_segment_rgb` / `set_segment_brightness` / `set_segment_color_temp`,
  `set_zone_power` / `set_zone_rgb` / `set_zone_color_temp`,
  `set_scene` / `set_scene_by_name`, and `update()` (read-back). Introspection:
  `capabilities`, `zones`, `min_kelvin` / `max_kelvin`, `scene_names` (the scenes the Govee app lists at
  the device's scene set, `scene_versions`), `supports_segment_brightness` (whether
  `set_segment_brightness` works: a device with `SEGMENTS` may have segment colour without it, as the
  H61D2-H61D6 colour page does; on the SKUs whose colour page refuses it while segments show a colour
  temperature, the H6047's bars among them, it raises `GoveeBleNotSupported` then, as the app does), `state`,
  `register_callback`, `power_read_at` (when a read last returned the on/off, while `state.is_on` still holds
  it). `advertised_on(advertisement_data)` decodes an advertisement's on/off as the device does, without
  changing state; `ingest_advertisement` folds it in.
  Methods for unsupported capabilities raise `GoveeBleNotSupported`, and so does `set_segment_color_temp`
  where `DeviceProfile.segment_color_temp` is false: where the SKU's colour page has no Part-tab Kelvin
  item, and where that item widens every pick to whole zones (the field's docstring lists those SKUs).
  SEGMENTS and COLOR_TEMP together don't promise it; `set_zone_color_temp` doesn't read the field. Both
  raise it where the SKU's screen writes colour temperature over the whole device only
  (`DeviceProfile.whole_device_kelvin`; a table profile with it and `SEGMENTS` has `segment_color_temp` false
  too). The link
  comes up with the first operation (or `connect()`) and drops `idle_disconnect` seconds after the last one
  (8 s by default), or 30 s after a command (`const.ACTIVE_IDLE_DELAY`, so quick follow-ups skip the
  reconnect). While it is up the `Device` sends the keep-alive read the SKU's control screen sends every 2 s
  (`DeviceProfile.heartbeat`), which never keeps the link longer. On a row whose screen follows its held link
  (`DeviceProfile.heartbeat_power`: most lights and every plug) the `Device` follows it too: a poll on a link whose
  state it has already read reads nothing more (only a firmware version a gate still needs, or a first device-info
  read that failed), and each keep-alive reply that carries the switch (on H6047 / H6042 the bars, on a plug its
  outlets) and each `ee 20` brightness or `ee 30` switch push the screen follows (`DeviceProfile.push_brightness` /
  `push_power`; on H60A6 the zones' power too) sets the state and reaches the callbacks. Where the screen re-reads
  on a switch change, or a plug's screen sends its connect list again, the `Device` does too
  (`DeviceProfile.heartbeat_reread_reads` / `heartbeat_reread_trigger` / `heartbeat_reread_pieces`). To make a poll
  read there, `release()` the link first; at the default `idle_disconnect` a link mostly drops between polls anyway.
  The rows whose held link it doesn't follow (H6185, the H1162 family and the rows whose screen re-walks on an IC
  push or re-reads camera state, among others) read on every poll. To send no keep-alive, build the `Device` from
  `dataclasses.replace(profile, heartbeat=None)` (`create_device`'s docstring has the recipe). `release()` drops it
  now and ends that window, for a caller that knows no follow-up is coming (a voice command, an automation, a burst
  across many devices): it waits for each link operation already running or queued (FIFO) and never splits a scene's
  upload from its activation or an H6047 bar read from its write, but it can land between a poll's reads, a
  keep-alive re-read's, or a plug's power write and its post-power list (a poll's later steps reconnect; a re-read
  or a plug's list ends there, as its frames go on its own link alone); a command called after it that writes on the
  link first keeps the link and its window. It returns whether it dropped a link and never raises a `GoveeBleError`;
  the next operation reconnects. `stop()` closes the device for good: a command still running is cut off, and its
  link operation raises `GoveeBleConnectionError`.
- **`DeviceState`** — parsed state: `is_on`, `brightness` (0–100), `rgb_color`,
  `color_temp_kelvin`, `segments` (`list[Segment]`), `zone_power`, `zone_power_confirmed` (when each
  zone's power was last learned from the device), `zone_color_temp_kelvin` (H6047
  reads one Kelvin per bar), `gradual`, `scene_code`, `gradient` (below), plus
  device-info (`device_id` / `serial_number`, `wifi_mac`, `hardware_version`, `firmware_version`,
  `ble_mac`). It comes from the `aa 06`, `aa 07 03` and `aa 07 02` reads (the Govee app's own frames)
  and, on a status-read SKU, from the status reply's TLV 0x07. A status row's `device_info_reads` in
  `spec/devices.yaml` says whether the library sends the three reads there too: at `none` it sends none
  and takes these fields from TLV 0x07 alone, as the app's screen does. Every status row is at `none` but
  the H60A6's and the H6076's (`firmware`: their firmware answers the reads). `wifi_firmware_version`,
  `wifi_hardware_version` (`aa 21` / `aa 20`) and `dsp_version` (`aa 07 07`) come from a Device's first poll,
  where the device's control screen reads them in its connect, and on a status row from the status reply's TLV
  0x07; they stay None where the screen reads none (and the Wi-Fi pair on H6142 at pact (2,1), and the DSP version
  on the modelled H706A / H706B / H706C / H806A / H806C, whose screen gates its read on the firmware).
- **`Capability`** — `POWER`, `BRIGHTNESS`, `RGB`, `COLOR_TEMP`, `SEGMENTS`, `SCENES`.

Supported SKUs: `supported_skus()` lists them, and each row of `spec/devices.yaml` gives its evidence as
`support`: `app_traced` (the frames traced on the Govee app's control screen), `hardware_verified` (also
observed on a device; the row's doc names the device, the date and what was seen) or `modelled` (behind
`allow_modelled`, above). The `hardware_verified` rows are the **H60A6**, the **H61A8**, the **H6006** /
**H6008** bulbs with the **H6004** (the BLE revision, from a user's field report; the Wi-Fi-only one can't be
controlled over BLE), and the **H5083** plug; the H5080 and H5085 share its row but stay `app_traced` until a
pass checks them. Other bench SKUs have partial live checks (the H6047's bar power, the H6641's gradual), and
every live check is recorded in its row's doc and in `CHANGELOG.md`. Govee's public catalogue (a snapshot in
`spec/govee_catalog.json`) also routes some SKUs that no row lists to a row by their goodsType alone, at
`app_traced` at most: H6009, H600A-H600D, H6010, H6011, H6013 and H800B take the H6004 / H6006 / H6008 row,
not bench-checked, and offer no scenes until a scene catalog is bundled for them (`set_scene` raises
`GoveeBleNotSupported`). Segment-colour read-back and some scene dialects on SKUs other than the H60A6 are
source-modelled and not yet hardware-verified.

Colour temperature matches the Govee app's frames: like the app, an out-of-range Kelvin is clamped to
the device range, not rejected (`set_color_temp(1500)` on a 2700-6500 K device sends 2700 K), and the
result is rounded to a whole 100 K step (`set_color_temp(2750)` sends and reports 2800 K). `state`
holds the Kelvin actually sent. Only a non-integer raises `GoveeBleInvalidValue`. Every CCT frame
carries the app's Kelvin tint, and whole-device CCT is one frame (on the H60A6 at pact (1,3)
the all-segments mask, as the app's Kelvin sliders send it; at (1,1) the app's whole-light 0x0d frame,
with no zone or segment colour), except on the H60A6 at pact (1,2): there it is the app's illumination-tab
pair, that all-segments frame and then `33 04` <the held brightness>, with no zone or segment Kelvin.

Like the app, a picked RGB colour that is exactly one of its Kelvin tint colours (178 of them, two per 100 K
step) and whose Kelvin is inside the SKU's range is sent as a colour-temperature frame instead of an RGB
frame, and `state` then reports `color_temp_kelvin` with `rgb_color` unset: `set_rgb`, `turn_on(rgb=)`,
`set_zone_rgb` and `set_segment_rgb` on the 0x15 devices (H60A6 at pact (1,2) / (1,3), H61A8, H6641, H6062,
H6047, with the same mask the RGB frame would use), the 0x0d bulbs and strip (H6004, H6006, H6008, H6052,
H6188, and the H60A6 at pact (1,1), whole-light only) and H6061. Colours outside the range or one channel off
the table stay RGB. Not modelled: colour-set / mixed-colour picks and the real-time drag frames (RGB only).
The H61A8's Kelvin range is [2000, 9000], or [2700, 6500] from hardware 3.08.01; until its hardware version is
read (once per device, on the first poll) the range valid on both, [2700, 6500], applies (`Device.min_kelvin`
/ `max_kelvin` give the range in force). Its mode report carries no Kelvin, so after a colour-temperature
write `state` keeps the Kelvin that was sent until an RGB or scene write, a scene report, or a poll whose
`aa a5` walk shows every segment one colour: it reports the same `15 01 00 00` after an RGB write too, so, as
the app's colour page does, a poll reads a colour changed in the Govee app from the walk (one colour of the
segments, its colour temperature where it is a tint in range); segments that differ keep the Kelvin. The first
poll reads the versions before the mode and the walk, as the screen's connect read does, so the walk is tested in
the range the hardware version gives, as the app's page tests it.

Read-back decodes the `aa 05` mode report the way the app's control screen does for each SKU, and reads a
colour the report carries the way the screen's colour page shows it. On the stock colour page
(`DeviceProfile.color_page == "stock"`) of a device whose report carries a colour, and the H61A8's, a report
of one colour that is a Kelvin tint in the SKU's range is that colour temperature (`color_temp_kelvin`, no
`rgb_color`), whatever the decode, as a pick of that colour is sent as one. Where the same read brings the segments (a status burst's TLV 0xA5 groups on the H60A6 at
pact (1,2) / (1,3) and the H60A4, or a poll's `aa a5` walk after its `aa 05` on the H60A1, the Glide panels
and the H61A8), that holds only when every segment shows that colour, as the page tests the pieces; segments
that differ keep the `rgb_color`, and segments that all show another colour are what the page shows (their
colour, or its colour temperature if it is a tint in range). These segmented devices write a colour as the 0x15
segment frame, and the report after such a write can be the 0x15 sub-mode (the bench H60A6's status burst
carries `15 01 00 00`), a Kelvin and no colour. There too, when the same read's segments all show one
colour, the page shows it, read the same way, whatever the report's Kelvin; with segments that differ, or
none, the report reads as decoded (a Kelvin above 0 as `color_temp_kelvin`; a Kelvin of 0, or the Glide panels'
decode, which reads nothing from 0x15, leaves `rgb_color` as it was). The same goes on the H60A1, H60A4 and
H60A6 (and, behind `allow_modelled`, the H1270, H1232 and H1252) for a 0x0d report of a Kelvin above 0, whose
tint their screen replaces with the same read's segments; the Glide panels' screen keeps that tint, so there it
reads as its Kelvin (as their page shows a Kelvin on a 100 K step in the device's range; a known gap for any
other, which their page shows as the fill's colour). Wherever the page's colour is shown, black counts as
(1, 1, 1), as the app's page shows
it: a black segment, and a black report where the same read brings no segments. Every device whose reads bring
segments on a screen whose view model is an `IColorModeFuc` has `color_page == "stock"` (from 1.10.0, about 240
SKUs, among them the bench H6047, H61A8, H6641 and H6062; on one whose report carries no colour that is all the
value means: its report reads as decoded), and its `segments` take what a read brings as the page's copy maps
it: a black segment as (1, 1, 1) and a segment brightness of 0 as 100. A segment the library writes keeps its pick (a
black write reads (0, 0, 0)) until the next read. Where the screen repaints the walk with the Kelvin tint it
holds (the H6047's bars, and every segment of the H7090 / H7092, H16C0, the Glide panels, H61D2-H61D6, H6097,
H6198 / H6199, the h6609 screen's H70C9 / H7084 / H6871 / R6871 / H80C5 (and the modelled H70C4 / H70C5 / H70C7 /
H6870 / H80C4) and the strips on the app's rgbiclight screen: H70A1-3, H6145-H6147,
H6167-H6169, H616C-H616E, H6172 / H6173, H6175 / H6176, H617A / H617C / H617E / H617F, H618A / H618C / H618E /
H618F, H619A-H619E / H619Z, H61A0-H61A3 / H61A5 / H61A9, H61B1 / H61B2 / H61B5 / H80B5, H61BA / H61BC / H61BE,
H61C2 / H61C3 / H61C5, H61E0 / H61E1 and H802A, with H611A / H6143 / H6144 (modelled except at pact (2,1)) and
the modelled H6119), the segments take that tint after a poll, from a colour-temperature report
until a report carries another Kelvin (on the Glide panels, until a colour report), and a Kelvin off the app's
tint table as (0, 0, 0). On the strips whose walk the IC sections size (H61A0-H61A2, H61B5, H61A9 and H61C2 /
H61C3 / H61C5) only a walk of their static pieces takes it: above them the app's read fails, and below them the
library keeps the walk's colours where the app's connect walk takes the tint (the conservative side). Other screens
show the walk's colours, and so does the library (on the H617G / H618G and H6076 / H8076 / R16D0 the view model
still holds the tint over a keep-alive re-walk, which the open page doesn't show; on the H6062, H610B, H6087, H6088
and H608A-H608D / H808A, whose screens copy the tint the same way, the app's connect read fails while a Kelvin is
held). Only the H604B / H604D, whose screen the decompile lacks, keep the device's values. So the H6052, whose
screen decodes its colour report as RGB only, reports a tint colour in range (the colour of a colour-temperature
write, should the lamp echo it) as `color_temp_kelvin`, and any other colour (the white `ff ff ff` it reported for a
colour temperature set elsewhere, for one; black as (1, 1, 1)) as `rgb_color`. On the H6050 / H6051 / H6073 a 0x0d
Kelvin that is not one of the app's tint steps in range reads as the colour their screen then shows: one outside the
range as the range end's colour temperature, any other as the report's tint colour, read the same way. H8015, H6071
and H6075 show no colour page (`color_page == "none"`), so their reports read as decoded. The H703A / H703B (behind
`allow_modelled`) are the one exception for now: their screen shows the stock page too, but their colour comes from
the status read's one-record TLV 0xA5, not a mode report, and a tint there still reads as `rgb_color` where the app
shows its Kelvin (their profile has no `color_page`).

H3751, H1630 / R1630, H7045 / H7046 and H707A-C / R707A-C read their colour from the status read's TLV 0xA5
gradient records: one record per piece, three colours each. A piece whose three colours are one colour reads as that
colour (black beside (1, 1, 1) counts as one, as the app's colour page shows it); one whose colours differ reads as
a gradient: `Segment.gradient` (the three colours, with `rgb` None) on the H707A family's segments, and
`DeviceState.gradient` once every piece shows that same gradient, with `rgb_color` and `color_temp_kelvin` None.
Pieces that all show one colour set `rgb_color`, or `color_temp_kelvin` where that colour is a Kelvin tint in the
device's range, as the app's colour page shows it (black reads as (1, 1, 1), as the page's copy holds it); pieces
that differ clear `rgb_color`, `color_temp_kelvin` and `gradient`, and so does a burst whose pieces contradict the
held value (a tint of a held Kelvin in range agrees with it). A colour, colour-temperature or scene write or report
clears `DeviceState.gradient`, and a colour or colour-temperature write onto a segment clears its `Segment.gradient`.

H70B2, H6630, H8630 and H6631 follow the Govee app's device-list card, as their control screens have no
BLE control (`DeviceProfile.session == "per_command"`): power and brightness only, each write a session of
its own (connect, one frame, disconnect, at least 1.1 s apart), with no read-back. `update()` and
`connect()` open no link; on/off comes from the advertisement and the last command, brightness is the last
one sent, and `state.optimistic` stays True. A brightness while the light is not known to be on is sent
after a power-on. Not live-verified; a unit that reports AES-GCM (BGC version 2) gets
`GoveeBleNotSupported` on every command.

The H6047 follows the app's frames too: whole-device power is `33 01` (`33 36` is only the per-bar
buttons, `set_zone_power`), read-back covers its 10 segments, `Device.zone_color_temp_kelvin(name)`
gives each bar's Kelvin, and `set_gradual` toggles the fade flag. A bar in colour temperature shows that
Kelvin's tint on its segments, after a poll and after `set_zone_color_temp`, and its segment brightness is
refused then, as in the app. After a scene report the poll keeps the tint of the bar Kelvins the app's screen
holds, until a colour-mode (0x15) report carries others, 0 included.

The full API reference is generated from the docstrings with [`pdoc`](https://pdoc.dev)
(`pip install '.[docs]' && bash tools/gen_docs.sh` → `docs/api/`); `govee_ble_local.__all__` is the
public contract.

## Security notes

The Govee BLE protocol itself has well-known limits that this library cannot fix:

- Its encryption uses a public pre-shared key, AES-ECB for the handshake and an RC4 stream for
  traffic, and integrity is only an XOR checksum. Anyone within radio range can drive an
  encrypted device.
- Captures made with `GOVEE_FRAME_LOG` contain wire frames from which the session key can be
  derived. Treat them as sensitive and do not post them publicly. Frame-log files are created
  with owner-only permissions.

## Known issue: short power-ups can factory-reset some lights

The H6008, H60A1 and H60A6 count their power-ups. Each time the light gets power it adds one to a count
kept in its saved settings, and staying powered for about 10 seconds (8 on the H60A6) clears it. On the
fourth power-up in a row with no such run in between, the light blinks or plays a light effect, then
resets itself: it wipes its Wi-Fi and Matter setup and its settings, and reboots. To recover, set the light
up again in the Govee app and re-pair it with Matter. Bluetooth control through this library needs no
pairing secret on these models, so it may keep working after the reset (not tested).

Turning the light off and on over Bluetooth (`Device.set_power`) does not count: the light stays powered
and the count runs only at boot. What counts is cutting its power: a smart plug (including a Govee plug
this library switches), a wall switch, an automation or a flaky supply. Each time such a light comes back
on, keep it powered for at least 15 seconds before cutting its power again.

Firmware-sourced, read from one firmware version per model (H6008 1.01.25, H60A1 1.02.20, H60A6
1.00.41), not tested on hardware. Other models were not checked. The firmware sites are in
`spec/docs/FIRMWARE_VALIDATION.md` §7.3.

## Diagnostics & session capture

The library logs under the `govee_ble_local.*` hierarchy and can capture a full protocol
session (over a local adapter or a Home Assistant Bluetooth proxy) for analysis. Enable the
flow trace, or the per-frame firehose, and decode a capture with the bundled
`govee-ble-analyze` CLI. See [`docs/DIAGNOSTICS.md`](docs/DIAGNOSTICS.md).

## Protocol

The wire protocol is specified as Kaitai Struct definitions in [`spec/`](spec/): one root per message
class — [`govee_ble_frame.ksy`](spec/govee_ble_frame.ksy) (every command/reply frame, typed by its
direction and the device's [`govee_profile`](spec/govee_profile.ksy)), the decoded state
([`govee_state.ksy`](spec/govee_state.ksy), [`govee_status_stream.ksy`](spec/govee_status_stream.ksy)),
the upload values ([`govee_upload.ksy`](spec/govee_upload.ksy)), the advertisement
([`govee_manufacturer_data.ksy`](spec/govee_manufacturer_data.ksy),
[`govee_scan_record.ksy`](spec/govee_scan_record.ksy)) and the BGC-info value
([`govee_bgc_info.ksy`](spec/govee_bgc_info.ksy)), sharing [`govee_common.ksy`](spec/govee_common.ksy)
— with the per-device table in [`spec/devices.yaml`](spec/devices.yaml). The Kaitai read-write classes
under `govee_ble_local/_generated/` are generated from those, and the library parses and builds every
frame with them; its supported-device table is generated from `devices.yaml` too (source of truth: the
decompiled Govee app).

## Contributing & versioning

See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, branching, commit, and release
conventions. Changes are tracked in [`CHANGELOG.md`](CHANGELOG.md); the project follows
[Semantic Versioning](https://semver.org/). Live hardware tests run on the bench SKUs the row docs name.

## License

[MIT](LICENSE) © Brady Woods
