kodeOS internals
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
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
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_CONSOLEbuilds the port. It depends onKODEOS_BLEandKODEOS_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_INDICATEencryption 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=:
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
Example code
Send a command over Bluetooth from a Mac.
- Turn Bluetooth on at the computer and make sure the Kode Dot is advertising.
ble_upover the cable starts it, and a storedblesetting of on starts it at boot. A board that already has a bond is hidden: press Pair new device on its Bluetooth page, or runble_pairon the cable. - Run
python3 tools/kble.py cmd "ping". The first run builds the app bundle, scans, connects and asks for the pairing. - Read the six digits on the glass of the Kode Dot and type them in the dialog of the computer.
- The terminal prints
attached, thenpongandok. Run it again: the second run attaches in under a second. - Run
python3 tools/kble.py screenshot -o shot.pngand wait about 20 s for the picture of the glass. - Run
python3 tools/kble.py detachto let go of the board.
In the SDK
components/kdrv/radio:kdrv_ble.cholds the service, the assembler, the ring and the pairing policy.log_ao.c: the callbacks, raw mode andconsole_congested().tools/kble.py: the client and the bridge.