# BLE console

Reach the kodeOS console over Bluetooth from a computer: the GATT service, the pairing code on the glass, flow control and the kble.py client.

## What it is

The console of a Kode Dot also answers over Bluetooth Low Energy. The commands, the grammar and the answers are the ones of the USB-C cable. A line a central writes is a command. A line the board notifies is a record, a terminator or a `!` log line. `internals/console-protocol` describes the lines, and this page describes what is different on the Bluetooth port.

A cable keeps the console to whoever holds it. Any Bluetooth radio in range hears the board advertise, so this port requires a pairing before it sends a line. With the default build, the first pairing shows six digits on the glass and you type them on the computer. The bond is stored on both sides, and every later attach is silent. Once a device is bonded the board hides from everyone else, and Pair new device on its Bluetooth page shows it again for a new one.

`tools/kble.py` is the client for macOS. It holds the Bluetooth link between commands, so the first command takes about 2.3 s to attach and each later command about 0.18 s, measured on a Kode Dot.

## How it is connected

```mermaid
sequenceDiagram
  autonumber
  participant C as Central<br/>kble.py
  box "kodeOS"
    participant D as kdrv_ble<br/>NimBLE task
    participant L as AO_Log
    participant K as AO_Console
  end
  C->>D: GATT write on RX
  D->>D: console_feed assembles a line
  D->>L: log_on_ble_line
  L-->>K: CONSOLE_LINE_SIG, src BLE
  K->>L: kode_log_reply, records and terminator
  Note over D,L: AO_Log owns both console ports
  L->>D: kdrv_ble_console_write, into the ring
  D-->>C: notify on TX, at most MTU minus 3 bytes
```

*One command over Bluetooth*

### AO_Log owns both console ports

The driver only carries bytes. AO_Log installs the callbacks in its initial transition, before Bluetooth starts, so the radio never advertises while nobody listens to the port. A finished line becomes a `ConsoleLineEvt` tagged `KODE_CONSOLE_SRC_BLE` and is published.

AO_Console receives every `CONSOLE_LINE_SIG` and keeps the tag, which only `install`, `file_put` and a few verbs that need the cable look at. AO_Log calls `kdrv_ble_console_write()` from one place only, right after the same line went out on the cable. So a log line, a record and a terminator reach both ports, whoever asked.

### AO_Radio starts the stack and keeps the board advertising

When the `ble` flag is on, the worker is free and the driver is idle, AO_Radio submits an advert job. The board stops advertising when a central connects, when it leaves or while a scan uses the radio, and the same rule restarts it. The same rule picks a visible or a hidden advert (see Advertising below). The driver cannot do this alone: restarting an advert is a chain of RPCs to the C5, and only the worker task of `kdrv_radio` makes them.

### Build options and files

The NimBLE host runs on the P4 and reaches the controller on the C5 over esp-hosted. Three options shape the port:

- `CONFIG_KODEOS_BLE_CONSOLE` builds the port. It depends on `KODEOS_BLE` and `KODEOS_CONSOLE`, and defaults to on.
- `CONFIG_KODEOS_BLE_CONSOLE_PAIRING` (default on) requires pairing.
- `CONFIG_KODEOS_BLE_PAIRING_PASSKEY` (default on) makes the pairing authenticated with the six digits.

These files hold the port:

| File | What it holds |
| --- | --- |
| `kdrv/radio/src/kdrv_ble.c` | The GATT service, the line assembler `console_feed()`, the 2 KB output ring, the drain, the pairing policy |
| `kaos/log/src/log_ao.c` | The callbacks, the copy of each line to Bluetooth, the Bluetooth half of raw mode, `console_congested()` |
| `kaos/radio/src/radio_ao.c` | The rule that keeps the board advertising |
| `kbsp/sdkconfig.board` | The NimBLE build options |
| `tools/kble.py` | The client: terminal, one command, install, screenshot, file transfer, the bridge |
| `tools/kblebundle.py` | The macOS app bundle and Python environment that `kble.py` builds |
| `tools/tests/test_kble.py` | Tests of the bridge against a fake radio, no board needed |

## Specifications

### The GATT service

The service and its characteristics have Kode's own 128-bit UUIDs. The first group spells `KODE` in ASCII and the second is the index. The characteristics behave like the Nordic UART Service (one pipe in, one pipe out), so a generic BLE terminal can drive the board once you enter these UUIDs by hand. A terminal that knows only the Nordic UUIDs does not find it.

| Attribute | UUID | Properties |
| --- | --- | --- |
| Console service | `4b4f4445-0001-4f53-a2d1-7c0e9b5f3a61` | primary |
| RX, central to board | `4b4f4445-0002-4f53-a2d1-7c0e9b5f3a61` | write, write without response |
| TX, board to central | `4b4f4445-0003-4f53-a2d1-7c0e9b5f3a61` | notify |
| Mirror, board to central | `4b4f4445-0004-4f53-a2d1-7c0e9b5f3a61` | notify. Exists only with `KODEOS_MIRROR` |

With the passkey option on, RX and the TX and mirror notifications demand encryption and authentication (`WRITE_ENC` and `WRITE_AUTHEN`, and the matching notify flags). With only pairing on, they demand encryption. With pairing off, the permission masks are empty and the service is open, and the log line "console attached" ends with `(no pairing)`.

- Neither characteristic is readable. A read answers `BLE_ATT_ERR_READ_NOT_PERMITTED`.
- One write can carry part of a line, a whole line or several lines. `console_feed()` assembles whole lines, ends one at CR or LF and ignores empty lines.
- A line of 240 bytes or more (`BLE_LINE_MAX`) is dropped whole, with a warning in the log and no terminator. A command that is cut and then run would not be the command the client sent.
- There is no echo, no prompt and no line editor, and a backspace is an ordinary byte. The parser of a Bluetooth client is the parser of the console protocol without the prompt and the erase sequence.
- The board counts a central as attached when it subscribes to TX. It still runs the lines written to RX before that, but the answers go nowhere.
- The CCCD of a characteristic does not inherit its permissions, so the driver gives TX the `NOTIFY_INDICATE` encryption flags too. Without them an unpaired stranger could subscribe and read the console in clear text.
- NimBLE destroys the service table when it stops, so the driver registers it again on every start.

### Advertising

| Thing | Value |
| --- | --- |
| Visible | While the board has no bond, and for 5 minutes after Pair new device. General-discoverable, and pairing is open |
| Hidden | While the board has a bond. Connectable for the devices that know it, non-discoverable, no name, and a new pairing is refused |
| Interval | About 150 ms (`KDRV_BLE_ITVL_FAST`) for 30 s after Bluetooth is switched on, after Pair new device and after a bonded device leaves. About 1 s (`KDRV_BLE_ITVL_SLOW`) otherwise and at rest |
| Duration | None. The advert lasts until a central connects. Only Pair new device's 5 minutes end, and the board then hides |
| Advert packet | Flags, the 128-bit service UUID and the transmit power: 24 of the 31 bytes. A client filters on the service UUID, which a hidden advert keeps |
| Scan response | Visible: the name, the serial number of the board without dashes, or `KODE-DOT` when the board does not know its serial. Hidden: empty |
| Verbs | `ble_up` starts and advertises, `ble_down` stops advertising and disconnects, `ble_pair` is Pair new device |

### Pairing

The board asks for security from the GAP connect handler (`ble_gap_security_initiate()`). It does not wait for a protected access, because NimBLE drops a write without response that fails a permission check, silently, and a client would wait forever for a pairing nobody started.

| Setting | Value | Why |
| --- | --- | --- |
| I/O capability | DisplayOnly | The board shows six digits on the AMOLED. Against a central that has a keyboard (macOS, Windows, Android) this selects Passkey Entry |
| Secure Connections | on | The ECDH exchange defeats a passive listener |
| MITM | on with the passkey, off without it | A person carries the digits between the two ends |
| Bonding | on | One pairing per computer. Keys are distributed both ways, and the bond survives a reboot (`CONFIG_BT_NIMBLE_NVS_PERSIST`) |
| Crypto | mbedTLS (`CONFIG_BT_NIMBLE_CRYPTO_STACK_MBEDTLS`) | With the default TinyCrypt backend the pairing fails at the DHKey check even with a correct passkey |
| Bond store full | kodeOS drops the oldest bond while pairing is open. A hidden board and an app refuse the new pairing | NimBLE keeps up to `CONFIG_BT_NIMBLE_MAX_BONDS` bonds |
| Passkey | Six digits from `esp_random()`, 0 to 999999 | They appear only on the glass |

The digits never appear in a log line or a verb answer, because every line reaches every attached central and a bonded peer would read the code of the next one. Without the passkey option the pairing is Just Works and the characteristics ask for encryption alone.

**A hidden board serves its bonded devices and pairs nobody new.** A pairing that reaches it is refused the way an app refuses one, below, and the log says `the board is hidden; Pair new device makes it visible`. Pair new device opens pairing until the first device pairs, so one press pairs one device.

**An app serves bonded phones and never pairs a new one.** The same BLE is in every SDK app. A phone bonded in kodeOS works in the app with no new pairing, since both the C5 and the P4 keep the bond across the jump. In an app built with `CONFIG_KODEOS_BOOT_ROLE_APP`, a new pairing is refused: the passkey action picks no digits and hangs up, a repeat pairing keeps the old bond, an encrypted but unauthenticated link has its bond deleted and a full store answers `BLE_HS_ENOMEM`. Pair a new phone in kodeOS.

### Reconnecting

A computer pairs once. After that it reconnects the way headphones do: no code on the glass, and no need to forget the Kode Dot in the Bluetooth settings. Every reset of the P4 drops the link, because NimBLE runs on the P4 and the controller on the C5. The board advertises again as soon as kodeOS is up, and the computer attaches with the bond it already has.

A bond has two halves on the board. The P4 keeps one record per peer in its NVS, and that record holds tokens, not keys. The long-term key and the identity key of the peer sit in the vault of the C5. A delete goes through both: the store callbacks of `kdrv_ble.c` tell the C5 to forget the keys, and the P4 drops its record. Because the record is in NVS and the keys are in the C5, the bond does not depend on the image that created it.

| Event on the board | Computer reattaches without pairing |
| --- | --- |
| A reset of the P4 (the cable port opening, a reboot) | Yes, in 2.6 s |
| An app launch and the return to kodeOS | Yes. While the app runs, the board does not advertise the console service |
| A kodeOS update, `install dst=os` | Yes |
| A C5 update, `install dst=c5` | Yes |

These four were run on one Kode Dot on 2026-10-29 with a Mac that held the bond. `ble_info` read `bonds=1` before and after each one, and no pairing card appeared. **TBD**: the board log of a reconnect, and a run of the two updates with the `install` verb itself.

If a computer asks to pair while the board still holds a bond for it, NimBLE raises a repeat-pairing event. In kodeOS the board deletes the old bond, keys included, and lets the pairing run again. With the passkey option on, that puts the pairing card on the glass and asks for six digits. An app keeps the bond, as described under Pairing. **TBD**: this path has no host test and no bench run.

### Flow control

The link is far slower than the console can produce. The numbers below keep a producer from outrunning it.

| Thing | Value |
| --- | --- |
| Output ring | 2,048 bytes (`KDRV_BLE_RING_BYTES`) in PSRAM, under a mutex. Each record has its length in front. A record that does not fit is refused whole and counted as `tx_drop` |
| Line cap in the ring | 256 bytes (`KDRV_BLE_REC_MAX`) |
| Notification size | At most MTU minus 3 bytes |
| Queue limit for producers | 4 lines of AO_Log when a Bluetooth console is attached (`LOG_PENDING_HIGH_BLE`), 24 otherwise. The ring is 8 lines of 256 bytes and half of that leaves the drain room |
| Ring backlog limit | 512 bytes (`LOG_BLE_BACKLOG_HIGH`) |
| mbuf pool | `CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT` is 24. At the default of 12 one `help` listing caused 123 waits |
| Drain | When the pool is empty the record stays pending and `retry` counts. It does not drop |
| NimBLE log level | Warning (`CONFIG_BT_NIMBLE_LOG_LEVEL_WARNING`). At info, each notification logs a line that is itself notified: 28 lines for one `ping` |
| Mirror ring | 8,192 bytes in PSRAM and its own retry callout, with a slice of 214 bytes. See `internals/mirror` |

A producer cannot ask the ring how full it is: AO_Log has a lower priority than the producer, so the ring still reads empty when a slice is posted. The check runs on the queue of AO_Log and on the backlog once AO_Log has run, for each record. Without that limit, a frame of 2,542 records loses 765 of them as `tx_drop`.

`ble_info` is the single record that reports all of this:

| Field | Meaning |
| --- | --- |
| `ble` | `off`, `idle`, `advertising` or `connected`. `off` comes from the stored flag. `idle` is the short gap between starting the stack and the advert |
| `addr` | The address of the board, or `unknown` before the stack syncs |
| `console` | A central is subscribed to TX |
| `tx_drop` | Records the ring refused or the drain gave up. Reset to 0 at a disconnect |
| `bonds` | Bonded peers in the store |
| `backlog` | Ring bytes plus the record in hand |
| `retry` | Times the drain found no mbufs. A busy link, not an error |
| `hidden` | `on` when the advert hides the board, `off` when it is visible |
| `pair` | `on` while Pair new device's 5 minutes run |

`log_dropped=0` in `ps` and `tx_drop=0` in `ble_info` are the direct proof that a screenshot or a file arrived intact.

### Payloads over Bluetooth

`install src="terminal"` runs the same stop-and-wait conversation as on the cable: `ready chunk=4096`, then one chunk per `ack`. The destination `c5` is refused with `msg=cable`, since the reboot of the C5 takes the link with it.

On Bluetooth there is no reader task to divert. The driver stops assembling lines, gathers the GATT writes into 4 KB chunks on the NimBLE task and posts them to the receiver. The board holds the byte count, so a host that keeps sending finds the line assembler again. If the pool is empty mid-image, the driver logs an error and the transfer fails on the CRC instead of installing a wrong image.

`screenshot` and `file_get` work because their answer is base64 text in ordinary records. A frame takes about 22 s over Bluetooth.

### kble.py

| Command | What it does |
| --- | --- |
| `kble.py term` | An interactive terminal. Ctrl-D leaves |
| `kble.py cmd "<line>"` | One command, then its terminator. Exit code 0 for `>ok`, 1 for `>err`. `--cmd-timeout` defaults to 15 s |
| `kble.py install FILE` | Installs an image. `--dst` is app (default), os or pending. `--no-run` with app. There is no c5 |
| `kble.py assets-install DIR` | Sends Kody's pack: begin, one `file_put` per file, commit |
| `kble.py screenshot -o shot.png` | A `.png` name is converted, any other is saved as the BMP the board sent. `--dst file` saves on the board instead. `--idle` gives up after 30 s of silence |
| `kble.py file-get NAME` | Fetches a file off the card or the FAT. `-o` names the output |
| `kble.py detach` | Lets go of the board, which goes back on the air by itself |

With several Kode Dots in range, the first one scanned is not always the one that holds your bond. A connection to the wrong board fails with `Insufficient Authentication`, which looks like a broken bond and is not. Give `kble.py` the serial of the board, which `info` prints as `serial=`:

```bash
python3 tools/kble.py --serial KODE1R8JZ0FSJM5 cmd "ping"
```

The environment variable `KODE_SERIAL` does the same when `--serial` is absent. The scan skips every advert whose name is a different serial. A hidden board's advert carries no name, so the scan reads the name the computer kept from the board's visible advert, which every computer that paired has. A bridge that already holds another board is refused with a message that names both. Without either, `kble.py` takes the first Kode Dot it finds. The serial has to be the one the running kodeOS advertises, since the advert name is that serial.

### The bridge

The script runs as two halves joined by a Unix socket at `~/Library/Caches/kode-kble/bridge.sock`. The half inside a macOS app bundle holds the Bluetooth link and is a plain byte pipe. The other half runs your command and knows the protocol. A command connects, says `kble 1 attach`, and bytes cross verbatim until it hangs up.

The bridge serves one client at a time and answers a second one `busy`. A command waits 1 s before believing `busy`, because the bridge frees its slot a moment after the previous command exits. A bridge that hears another version in the hello answers `stale`, drops the link and exits, and the command starts a current one. The link stays open until `kble.py detach`, until the board drops it (`ble_down`, a reset, out of range) or until Bluetooth goes away. It never closes on idle.

### Writes and the first attach

Writes are cut into pieces of MTU minus 3 bytes and sent as write without response. The first attach scans for the service UUID, connects and subscribes to TX. If the subscription is refused for want of encryption, the client writes one empty line to RX with response and retries up to six times, a second apart. On a Mac that write is what makes CoreBluetooth start the pairing, while a subscription alone does not. The client waits for payloads with an idle deadline that restarts at every line, matching the watchdog of the board.

### The macOS app bundle

The macOS scaffolding is needed because CoreBluetooth aborts any process whose `Info.plist` has no `NSBluetoothAlwaysUsageDescription`, with no message. `kble.py` builds a small signed `KodeBle.app` under `~/Library/Caches/kode-kble`, launches itself inside it with `open -a` so the bundle has its own Bluetooth permission, and creates a Python environment with `bleak` there. These files live in the cache because a bundle started by launchd cannot read `~/Documents`.

## Gotchas

> **WARNING**
>
> **The attach fails half a second in with "Peer removed pairing information".** The computer kept its half of a bond the board no longer has, for example after a full flash. On the cable the log reads `connected` then `disconnected (reason 531)`. Forget the board on the computer (`blueutil --unpair <addr>` or System Settings), then run `ble_unpair` on the cable to clear the board's half.

> **WARNING**
>
> **`ble_down` over Bluetooth never answers.** It disconnects the console that asked, so the `>ok` has nowhere to go. That is the command working. Off is the stored flag plus a quiet stack: NimBLE is never stopped, because a second start in the same boot panics.

> **NOTE**
>
> **A stuck bridge, or `found KODE-DOT` for a board that advertises its serial.** Stop a stuck bridge with `pkill -f 'kble.*--hold'`. The log is `bridge.log` next to the socket. macOS caches the name of the peripheral, so the old name is CoreBluetooth's memory and not the board's. `--serial` compares the name inside the advert, not the cached one, but right after an image that changes the serial the first scan can still read the old name once.

> **NOTE**
>
> **Only one central at a time, and the mirror needs its own.** A second client is told `busy`. `tools/kmirror.py --ble` builds a separate bridge that subscribes to the mirror characteristic, so stop a `kble.py` bridge first.

## Example code

Send a command over Bluetooth from a Mac.

1. Turn Bluetooth on at the computer and make sure the Kode Dot is advertising. `ble_up` over the cable starts it, and a stored `ble` setting of on starts it at boot. A board that already has a bond is hidden: press Pair new device on its Bluetooth page, or run `ble_pair` on the cable.
2. Run `python3 tools/kble.py cmd "ping"`. The first run builds the app bundle, scans, connects and asks for the pairing.
3. Read the six digits on the glass of the Kode Dot and type them in the dialog of the computer.
4. The terminal prints `attached`, then `pong` and `ok`. Run it again: the second run attaches in under a second.
5. Run `python3 tools/kble.py screenshot -o shot.png` and wait about 20 s for the picture of the glass.
6. Run `python3 tools/kble.py detach` to let go of the board.

## In the SDK

- [`components/kdrv/radio`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/radio): `kdrv_ble.c` holds the service, the assembler, the ring and the pairing policy.
- [`log_ao.c`](https://github.com/kodediy/kode-sdk/blob/main/components/kaos/log/src/log_ao.c): the callbacks, raw mode and `console_congested()`.
- [`tools/kble.py`](https://github.com/kodediy/KodeOS/blob/main/tools/kble.py): the client and the bridge.
