Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
# AirCube BLE GATT Protocol (v1)
Protocol between an AirCube in **BLE mode** (its default radio mode when not
joined to a Zigbee network) and client apps (iOS app, future desktop BLE).
The firmware implements this in `firmware/main/ble_gatt.c`. The iOS client is
maintained in a separate repository — there is no `ios/` directory in this repo.
All multi-byte fields are **little-endian** (native ESP32 byte order).
## Advertising
| Packet | Contents |
|---|---|
| Advertisement (connectable) | Flags (0x06) + BTHome v2 service data (UUID 0xFCD2 — same payload format as the reference implementation in `ble_bthome.c`, so HA Bluetooth proxies keep working) |
| Scan response | Complete local name `AirCube` + 128-bit AirCube service UUID |
`ble_bthome.c` exists in the source tree as a standalone reference but is **not** part of the
current build (it's not listed in `firmware/main/CMakeLists.txt`). The live BTHome v2 payload is
generated inline by `ble_gatt.c`'s `do_advertise()`. This advertisement is only broadcast while
the device is in BLE mode — it stops once the device joins a Zigbee network.
iOS clients scan with `withServices: [AIRCUBE_SERVICE]`; CoreBluetooth matches
UUIDs from the scan response.
## Service and characteristics
Base UUID suffix: `-1D0F-4E7C-8E4B-2A3D5F6B7C80`
| UUID prefix | Name | Properties | Size |
|---|---|---|---|
| `A17C0DE0` | AirCube Service | — | — |
| `A17C0DE1` | Device Info | Read | 14 B |
| `A17C0DE2` | Live Data | Read, Notify | 20 B |
| `A17C0DE3` | History Request | Write | 4 B |
| `A17C0DE4` | History Data | Notify | ≤ MTU−3 |
| `A17C0DE5` | Brightness | Read, Write, Notify | 1 B |
Firmware prefers ATT MTU 256 (iOS typically negotiates 185).
## Device Info (`A17C0DE1`, read)
| Offset | Type | Field |
|---|---|---|
| 0 | u8 | protocol_version (= 1) |
| 1 | u8 | model: 0 = Base, 1 = Pro |
| 2 | u8 | fw_major |
| 3 | u8 | fw_minor |
| 4 | u8 | fw_patch |
| 5 | u8 | reserved (0) |
| 6 | u16 | history_capacity (2016) |
| 8 | u16 | history_entry_count (current valid entries) |
| 10 | u16 | history_window_s (300) |
| 12 | u16 | newest_seq (sequence of newest slot; 0xFFFF if history empty) |
## Live Data (`A17C0DE2`, read + notify)
Notified once per sensor readout (~1 s). 20 bytes:
| Offset | Type | Field |
|---|---|---|
| 0 | i16 | temperature, °C × 100 |
| 2 | u16 | humidity, % × 100 |
| 4 | u16 | VOC Level (0–500) |
| 6 | u16 | eCO2, ppm (ENS16X estimate) |
| 8 | u16 | eTVOC, ppb |
| 10 | u16 | CO2, ppm (SCD41 NDIR; 0 on Base) |
| 12 | u16 | ambient light, lux × 10 (VCNL4040; 0 on Base) |
| 14 | u8 | AQI-UBA (1–5) |
| 15 | u8 | flags: bit0 = Pro model |
| 16 | u32 | uptime, ms |
## Brightness (`A17C0DE5`, read + write + notify)
Single byte: LED brightness percent, 0–100 (writes above 100 are clamped).
Writes apply immediately and persist to NVS across reboots. The device
notifies the confirmed value after any brightness change — including local
ones (button press) and Zigbee writes — so subscribed clients stay in sync.
The hardware button cycles 0 → 10 → 30 → 60 → 100 → 0, snapping to the next
level above the current (possibly BLE-written) percent.
## History sync (streaming, not paged)
1. Client subscribes to **History Data** notifications.
2. Client writes a request to **History Request**:
| Offset | Type | Field |
|---|---|---|
| 0 | u8 | opcode: 0x01 = start stream, 0x02 = abort |
| 1 | u8 | reserved (0) |
| 2 | u16 | after_seq: stream only slots with sequence > this. 0xFFFF = full history |
3. Firmware streams **History Data** notifications back-to-back until caught
up, then sends a done frame. No per-page round trips.
### History Data frames
Every frame starts with a 4-byte header:
| Offset | Type | Field |
|---|---|---|
| 0 | u8 | frame_type: 0x01 data, 0x02 done, 0x03 error |
| 1 | u8 | slot_count (data frames) / 0 |
| 2 | u16 | data: logical index of first slot in frame · done: total slots sent · error: reason code |
**Data frame** (0x01): header + `slot_count` × 32-byte slots. `slot_count`
is the largest n with `4 + 32n ≤ MTU−3` (5 slots at MTU 185).
**Done frame** (0x02): stream complete. The client anchors wall-clock time
now: newest slot = time of done frame; each older slot is `history_window_s`
earlier. Slots have no absolute timestamps (the device has no RTC).
**Error frame** (0x03) reason codes: 1 = busy (another history stream is
active, e.g. over USB serial — retry after a short delay), 2 = bad request.
### Slot binary layout (32 bytes, packed)
Matches `history_slot_t` in `firmware/main/history.h`:
| Offset | Type | Field |
|---|---|---|
| 0 | u16 | sequence (0xFFFF = empty/invalid slot, skip it) |
| 2 | i16 ×3 | temperature avg/min/max, °C × 100 |
| 8 | i16 ×3 | humidity avg/min/max, % × 100 |
| 14 | u16 ×3 | VOC Level avg/min/max |
| 20 | u16 ×3 | CO2 avg/min/max, ppm (true CO2 on Pro, eCO2 on Base) |
| 26 | u16 ×3 | eTVOC avg/min/max, ppb |
### Client sync algorithm
```
info = read Device Info
if info.newest_seq == 0xFFFF: nothing to sync
last = highest sequence cached for this device (or none)
write History Request {0x01, after_seq: last or 0xFFFF}
collect data frames until done frame
store slots keyed by (device_id, sequence) # idempotent
anchor timestamps: newest slot = now, each older slot -5 min
```
Sequence numbers are monotonic u16 and wrap at 0xFFFE. If the device does not
find `after_seq` in its buffer (client was away longer than 7 days, or a wrap
occurred), it streams the **full** history; dedup by sequence on the client
handles overlap.
## Concurrency rules
- One history stream at a time device-wide (BLE and USB serial share this
lock). The loser gets error/busy and retries.
- Only one BLE central may connect (NimBLE max connections = 1).
- `clear_history` is serial-only in v1 and is refused while a stream runs.
## Sizing
Full sync: 2016 slots × 32 B = 63 KB ≈ 2–5 s at typical iOS throughput.
Incremental daily sync: 288 slots ≈ 9 KB, sub-second.
0e3b0bd1dThis file is pinned to the selected release. Download the original to open it in your design tool.