# realme Buds T310: control protocol
This is budctl's clean-room description of how to control the realme Buds T310. Code in `crates/budctl-core/src/devices/realme_t310/` is written from this file, not from any other project's source (CLAUDE.md rules 2 and 3). Each constant in that module cites an anchor here (`Spec: realme_t310.md#anchor`).
The T310 speaks the protocol family shared by many OPPO, OnePlus and realme buds. Linux socket, SDP and BlueZ details are in [`transport.md`](transport.md).
## 0. Evidence
Every fact carries an evidence level that says how far it can be trusted. A fact that is not yet *ours* is tested on the owner's buds before budctl relies on it (CLAUDE.md rule 4).
| Level | Meaning |
|---|---|
| **t310** | seen on a real T310 by other users, in a report or log. Not yet verified by budctl |
| **ours-YYYY-MM-DD** | verified by budctl on the owner's T310 (see the [verification log](#verification-log)) |
| **vendor** | how the vendor apps (realme Link, HeyMelody) behave. Not seen on a T310 |
| **buds4** | seen on a OnePlus Buds 4. The framing is shared, but the values may differ on the T310 |
| **derived** | computed by us from the facts above. Never seen on the wire |
| **guess** | unverified anywhere. budctl must not send it |
## 1. Identity
- **Bluetooth name:** `realme Buds T310`. budctl recognizes the buds by BlueZ's `Name` property, compared exactly. *vendor*
- **Product id:** `063C12`. The reply to `0x0103` should be `00 12 3C 06`, which is the id in 3 bytes, little endian. The reply format is *buds4*; the T310 bytes are *derived*.
- **Not a T310.** If the product id is different, budctl stops: the device is renamed, or it is some other model.
## 2. Transport
- **Link:** classic Bluetooth RFCOMM (SPP), not BLE. The connection is one long-lived byte stream, and the buds may send at any time.
- **Service UUIDs.** Try them in this order; each is resolved to a channel through SDP ([transport.md#sdp](transport.md#sdp)). The owner's T310 offers the first, on channel 6 (*ours-2026-10-27*).
1. `0000079A-D102-11E1-9B23-00025B00A5A5`. The vendor app tries it first. *buds4/vendor*
2. `00001107-D102-11E1-9B23-00025B00A5A5`. It is kept for other models; on Buds 4 it never connects.
- **Fixed channel.** Android clients sometimes use a fixed channel 15. budctl does not use a fixed channel.
- **One client at a time.** The buds serve one control app at a time. budctl reports a busy channel instead of retrying.
- **Lid close.** Closing the case lid ends the link. *buds4*
## 3. Frame format
Every message is wrapped in one frame.
```
AA len slice 00 [fsn] cmd(2, LE) seq paylen(2, LE) payload
```
| Field | Size | Meaning |
|---|---|---|
| `AA` | 1 | start of frame |
| len | 1-3 | **LEB128**: 7 bits per byte, low bits first, top bit = another byte follows. It counts every byte after the length itself |
| slice | 1 | `00` = the whole message in one frame; `01` / `02` / `03` = first / middle / last slice of a long message |
| `00` | 1 | always zero |
| fsn | 1 | slice number from `00`; present only in slices (`01`-`03`) |
| cmd | 2 | command id, little endian |
| seq | 1 | sequence byte (see [§4](#seq)) |
| paylen | 2 | payload length, little endian |
| payload | paylen | message body |
**Whole message.** `len = 7 + paylen`. There is no checksum. *buds4*
Examples (*buds4*):
```
AA 07 00 00 00 01 01 00 00 handshake 0x0100, seq 01, no payload
AA 0A 00 00 04 04 06 03 00 01 01 01 0x0404 seq 06, payload 01 01 01
AA 0C 00 00 04 02 FF 05 00 03 01 01 08 00 0x0204 push (seq FF), payload 03 01 01 08 00
```
**Long lengths.** A 124-byte payload gives `len = 131`, sent as `AA 83 01`. A 250-byte reply arrives as `AA 81 02` (257). *buds4*
**Slices** (*vendor*):
- A slice frame is `AA len slice 00 fsn `, with `len = 3 + part length`.
- The receiver joins the parts in order and parses the result when the `03` slice arrives. A middle or last slice with no first slice is dropped.
- Senders slice only above about 660 bytes, so budctl never sends slices.
**Frames the buds start** (*ours-2026-10-27*). On the owner's T310, every frame the buds send on their own (requests such as `0x0501` and `0x0204` pushes) has `08` in the slice byte, not `00`. The rest of the frame is laid out as a whole message, and `len = 7 + paylen` holds. Replies carry `00`. budctl therefore reads a slice byte of `00` or `08` as a whole message. Other values are not seen and are still dropped. Captured examples:
```
AA 07 08 00 01 05 0F 00 00 0x0501 seq 0F, no payload
AA 0F 08 00 04 02 10 08 00 01 03 02 64 01 64 03 00 0x0204 seq 10, battery push 01 03 02 64 01 64 03 00
```
**Receiving**:
- Treat the input as a byte stream: one read may hold several frames or part of one.
- Throw away bytes before `AA`.
- If the length is implausible (more than 3 length bytes, below 4, or above 4096), drop that `AA` and look for the next one.
- If the paylen claims more bytes than the frame holds, keep the bytes that are there.
## 4. Sequence numbers and replies
- **Replies.** The sender picks `seq`. The reply has the same seq and the command id with bit 15 set (`0x0106` → `0x8106`). budctl matches a reply on (seq, cmd), which is also how HeyMelody does it. *buds4/vendor*
- **budctl's seq values.** budctl uses `01`-`BF`, counting up and wrapping. Pushes from Buds 4 use `FF` (*buds4*); the T310 uses its own counter ([§3](#frame-from-buds)).
- **Status byte.** The first payload byte of a reply is a status, and `00` means success. A read reply with any other status carries no data.
- **Command groups:**
| Range | Group |
|---|---|
| `0x01xx` | queries |
| `0x02xx` | event registration and pushes |
| `0x03xx` | firmware update (budctl never sends these; see the non-goals) |
| `0x04xx` | writes |
| `0x05xx` | requests from the buds |
| `0x0Fxx` | private |
## 5. Command bitmap
- **Reading it.** The reply to the handshake `0x0100` is `00` followed by the bitmap bytes. Read them least-significant bit first: bit *n* is bit `n % 8` of byte `n / 8`.
- **Using it.** A set bit enables a fixed group of commands. **budctl never sends a command whose bit is clear**, as the vendor apps don't. Bits beyond the end of the bitmap count as clear. *vendor*
- **Always allowed:** `0x0100`-`0x0104`, `0x0106`, `0x010B`, `0x010D`. `0x0200` belongs to no bit; the vendor app sends it on every connect. *vendor/buds4*
Gate table for every command budctl uses or documents here:
| Command(s) | Gate |
|---|---|
| `0x0100` handshake, `0x0103` product id, `0x0106` battery, `0x010D` switch read, `0x0200` offered events | always |
| `0x0105` firmware | bit 0 |
| `0x0108` gesture read, `0x0401` gesture write | bit 3 |
| `0x0109` wearing | bit 4 |
| `0x0400` find my earbuds | bit 5 |
| `0x0403` switch write | bit 7 |
| `0x010C` noise-control read, `0x0404` noise-control write | bit 8 |
| `0x0405` fit test | bit 9 |
| `0x010F` EQ read, `0x0406` EQ select | bit 10 |
| `0x0114` current codec | bit 17 |
| `0x0205` subscribe | bit 22 |
| `0x0122` custom EQ list, `0x0418` custom EQ write | bit 34 |
| `0x0124` bass level read, `0x041B` bass level write | bit 38 |
**The T310's command set** (*t310*), from a log of a real T310, as a decoded list:
`0100 0101 0102 0103 0104 0105 0106 0107 0108 0109 010B 010C 010D 010F 0112 0114 0115 0116 0122 0124 0205 0400 0401 0403 0404 0405 0406 0407 040B 040D 040E 040F 0416 0418 041B 0F00 0F03 0F04 EF02`
- **Bits that must be set** for that list (*derived*): 0, 2, 3, 4, 5, 7, 8, 9, 10, 11, 17, 19, 20, 22, 29, 34, 38, 42.
- **A bitmap reply with exactly those bits:** `00 BD 0F 5A 20 44 04` (*derived*).
- **The owner's T310 answers** `00 BF 1F 5A 20 45 04 40 00` (*ours-2026-10-27*; the unit tests use it). That is every bit above plus bits 1, 12, 32 and 54, which gate no command budctl uses. Every bit listed under "what the T310 lacks" is clear.
- **What the T310 lacks.** These commands are absent, so budctl never sends them:
- `0x012F` batch query (bit 56): every read goes out alone.
- `0x041E` 3D-audio source (bit 43).
- `0x0413` phone state (bit 27).
- `0x0130`/`0x0427` alert volume (bit 57).
- `0x0133`/`0x042D` tap sensitivity (bit 61).
- `0x0423` game sound (bit 49).
- `0x012A`/`0x0422` spatial type (bit 47).
## 6. Session start
**The vendor sequence** (*buds4*, matching HeyMelody):
- Wait 300 ms after the socket connects, then send one message every 200 ms.
- Order: `0x0100` → `0x0103` → `0x0200` → `0x0205` → reads.
- There is no authentication or key exchange.
**budctl does:**
1. Connect, then wait 300 ms. During the wait, keep answering the buds' requests ([§7](#buds-requests)).
2. `0x0100`. Keep the bitmap.
3. `0x0103`. The id must be `063C12` ([§1](#identity)).
4. `0x0200` → `00 `: the event codes the buds can push. Buds 4 offers `01 02 03 04 08 0B F1 F2 F3` (*buds4*). The owner's T310 offers `01 02 03 04 05 06 0D F1 F4` (*ours-2026-10-27*).
5. `0x0205 ` (count first) subscribes. budctl asks for these codes, keeping only the ones the buds offered (*vendor*):
- `01` battery, `02` wearing and `03` noise control;
- `04` (fit-test result) if `0x0405` is allowed.
Other clients also subscribe to `08` when `0x040D` is allowed. budctl does not: this spec does not say what event `08` carries or what `0x040D` does, and budctl handles no such event. Add it only once both are documented here with a source.
Never send `01 01 02 02`: that means "count 1, code 01". The `0x8205` ack may never arrive (*buds4*, Nord Buds 3 Pro), so budctl does not wait for it. The T310 does send it, about 50 ms later: `00 04 01 00 02 00 03 00 04 00`, a `00` result for each code (*ours-2026-10-27*).
6. Reads, one per request.
**Spacing.** budctl keeps the captured spacing: at least **200 ms** between any two frames it sends. It waits for each reply before sending the next request.
## 7. Requests from the buds
The buds send some requests of their own. The answer repeats the request's seq and sets bit 15 of its command. Some firmware resets the link 3-5 s after connecting if the time request goes unanswered (Nord Buds 3 Pro, *buds4*-family capture).
| From the buds | budctl answers | Meaning |
|---|---|---|
| `0x0500` | `00 ` | time request, sent on connect |
| `0x050C` | `00 01 00` | "is the foreground app on my list?" — no |
| `0x051C` | `00 00 00` | host app capabilities: fast discovery off |
| `0x051D` | `01` | the phone's spatial mode (budctl always answers `01`) |
| a command outside groups `01`-`05` and `0F` | `01` | unknown request |
- **Test vector** (*buds4*): at Unix time 1750000000, the request `AA 07 00 00 00 05 02 00 00` gets the answer `AA 0C 00 00 00 85 02 05 00 00 80 E1 4E 68`.
- **Not answered:** other `0x05xx` messages and all pushes. Neither vendor app answers them.
- **Pushes `F2` and `F4`:** other clients acknowledge these. budctl does not yet; see [open questions](#open).
- **On the T310** (*ours-2026-10-27*): right after connecting, the buds send `0x0501` with no payload (slice byte `08`, their own seq). budctl does not answer it (it is an "other `0x05xx`" message), and no link reset followed in a 5 s session. No `0x0500` time request was seen.
## 8. Pushes
budctl reads none of these yet. It skips them while waiting for replies (and prints them with `--debug`).
- **`0x0204` event push.** Seq is `FF`; classify by cmd, because one Buds 4 push used another seq. `payload[0]` is the event code:
| Code | Event | Layout after the code |
|---|---|---|
| `01` | battery | ` ()…` (as in [§9.1](#battery)) |
| `02` | wearing | ` ()…` |
| `03` | noise control | `01 01 ` = new mode; `02 ` = new hold cycle (not a mode) |
| `04` | fit-test result | `()…` |
| `05` | game mode | ``. **The T310 never sends this** (*t310*) |
| `F1` | a gesture fired | `