SDK
Wi-Fi, BLE and network
The Wi-Fi station, the Bluetooth LE host with its console service, and the network stack with HTTP, MQTT and ping, all over the ESP32-C5's radio.
What it is
A Kode Dot has no radio on the ESP32-P4, so three drivers turn the radio of the ESP32-C5 into things an app can use:
kdrv_wifiis the Wi-Fi station. It scans both bands, joins a network, leaves it and reports where the connection stands.kdrv_bleis Bluetooth LE. It advertises, scans, pairs and bonds, and carries the console as a Bluetooth service.kdrv_netis the network above an address: DNS, ping, HTTP and HTTPS, UDP, an MQTT client and the time.
The first two drive the C5 with remote calls over the link described in sdk/kdrv/c5. The third never touches the link. lwIP runs on the P4, and the C5 only carries frames.
How it is connected
flowchart LR subgraph p4["ESP32-P4"] ao["AO_Radio<br/>radio_worker"] --> run["kdrv_radio_run()"] run --> wifi["kdrv_wifi"] run --> ble["kdrv_ble<br/>NimBLE"] net["AO_Net<br/>net_worker"] --> knet["kdrv_net<br/>lwIP"] end wifi -- "RPC" --> c5["ESP32-C5"] ble -- "HCI over SDIO" --> c5 knet -- "frames" --> c5 class ao,net ao class c5 chip
The two owners
AO_Radio (sdk/kaos/radio) owns Wi-Fi and Bluetooth. It hands each call to its radio worker as a job of kdrv_radio_run(), one at a time, and the driver functions run only there. Nothing in them takes a lock: correctness rests on that one worker, and on the C5's RPC channel serving one request at a time. A call from another task brings back the failure the design avoids, an intermittent "0 networks".
AO_Net (sdk/kaos/net) owns the network. It runs kdrv_net_run() from its own worker, net_worker, at the lowest Active Object priority (2), so network I/O only ever delays itself. lwIP can serve several requests at once, so its one-job-at-a-time rule protects only the driver's result tables.
Build options
Three Kconfig options gate the build: KODEOS_WIFI compiles kdrv_wifi, KODEOS_BLE compiles kdrv_ble and kdrv_ble_tokens, and KODEOS_NET (which needs Wi-Fi) compiles kdrv_net. KODEOS_MQTT gates the MQTT client inside kdrv_net. With it off the public MQTT calls still link and refuse with KODE_DENIED.
Wi-Fi
The driver stores no credentials. It takes a name and a password as arguments and remembers only the network it has joined. Saved networks live in the C5's vault.
The station netif is created late, on purpose. The default station netif sends its own RPCs when it starts. During a blocking scan those RPCs time out and the channel loses its order. So kdrv_wifi_start() starts a station with no netif, which is enough to scan, and the first join creates the netif with the stack stopped and restarted around it. That costs two extra RPCs, once.
The netif must exist before the station starts, because it sets itself up from WIFI_EVENT_STA_START. One created afterwards never gets an input path.
A scan sweeps the channels in seven steps, each its own non-blocking start and wait: three for 2.4 GHz and four for the 28 slots of 5 GHz, with the country deciding which slots the stack keeps. The scan is active, shows hidden networks and dwells 200 to 500 ms per channel.
Each answer from the C5 causes cache invalidations on the P4, and under heavy SDIO traffic they can fault the tick interrupt with an illegal instruction. Seven steps cut those invalidations by a factor of six, and the first results still arrive within seconds. A sweep takes about 38 s on a Kode Dot.
Results travel two ways:
- Everything found goes through a hand-over ring of 96 entries that AO_Radio drains as the sweep runs, so a crowded street lists all its networks.
- A table keeps the strongest 24 for autojoin and the production test.
The sweep does not stop when the table is full, because a crowded lower band would otherwise hide the whole upper one.
A join waits for the lease. It returns when DHCP has given an address, which comes after the association. The driver has no reconnect policy, because only AO_Radio knows whether anyone still wants the network.
Bluetooth LE
Bluetooth is split across the two chips. NimBLE, the host, runs on the P4. The controller and the radio run on the C5. HCI messages cross the SDIO link through esp-hosted. The C5 supports LE only.
kdrv_ble_start() runs four steps in order:
- Initialise and enable the controller over RPC.
- Call
nimble_port_init(). - Configure NimBLE: callbacks, pairing policy, the GAP and GATT services, the device name, the console service table, the output rings and the bond store.
- Call
nimble_port_freertos_init()and wait up to 5 s for the host and controller to synchronise.
The configuration is rebuilt on every start, because a deinit frees what the previous one registered. The device name is the serial number of the board, or KODE-DOT if it does not know it.
The console service carries the same text the USB-C cable carries. Kode's own UUIDs name it, so a client can filter for Kode Dots alone. The characteristics behave like the Nordic UART service, a write pipe and a notify pipe. With pairing built in, both require an encrypted and authenticated link.
In kodeOS a new peer sees a six-digit code on the screen and types it on the phone. An app has no card for the code and refuses new pairings, serving only phones already bonded in kodeOS. internals/ble-console covers the user's side.
The output side is built around one fact: ble_gatts_notify_custom() waits with no limit, and AO_Log, the only writer of both consoles, must never wait on it. So kdrv_ble_console_write() copies a line into a ring of 2048 bytes under a mutex and posts one NimBLE event, whole or not at all, and the drain runs on the NimBLE task. The ring's bytes are in PSRAM, taken once when the service is set up and kept for the boot. Only tasks touch them, never an interrupt.
An entry that meets an empty buffer pool stays pending and the drain returns on a timed callout, never by posting to itself.
When screen mirroring is built in, the service gets a third characteristic of its own with a second ring of 8192 bytes, also in PSRAM, so pictures and console lines never take each other's room.
The keys of a bond live on the C5. NimBLE's store on the P4 keeps a 16-byte token in place of each key, and the C5 swaps the token for the key on the HCI path into its controller. The wrapped store callbacks send the key to the C5 (BTKEYS_PUT) before they store the token, and the pairing fails if the C5 refuses.
kdrv_ble_tokens.c makes and tests the tokens with no NimBLE in it. A bond written before tokens existed is moved to the C5 when the host synchronises.
Network
kdrv_net has three parts. The one-shots (lookup, ping, HTTP GET, UDP send) are reentrant: each opens its own socket or client, keeps nothing between calls and blocks for seconds, so they never run on an Active Object. The jobs run one at a time on AO_Net's worker and leave their results in tables the owner reads between the done event and the next job. The MQTT client exists once, with one connection and subscriptions that outlast any call, so AO_Net owns it.
No job touches internal flash, which is what lets both stacks live in PSRAM.
TLS buffers are in PSRAM as well (CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC), and kdrv_net.c stops the build with an #error if that option is off. The certificate bundle also accepts a cross-signed chain, because servers hosted by Google, api.kode.diy among them, send a root that the stock bundle does not hold.
A ping waits for the whole session with a limit of count × 2000 ms + 5000 ms. If the limit expires, the driver abandons the session's context and does not free it, because esp_ping_delete_session() does not wait for the ping task. About 50 bytes leak on a path that normally never runs, in exchange for no use-after-free.
An HTTP client reads to the end of the body even past your buffer, counting the bytes it drops, so body_len is the true length and truncated is not a guess. A zero-length read does not end the body. Only esp_http_client_is_complete_data_received() does.
The files
The files of the component:
| File | What it does |
|---|---|
include/kdrv_wifi.h, src/kdrv_wifi.c | The station: start, stop, forget, sweep scan, connect, prepare/await/adopt for C5-side joins, RSSI, power save |
include/kdrv_ble.h, src/kdrv_ble.c | NimBLE on the P4: start, quiet, stop, advertise, scan, pairing policy, the console and mirror services, bond store hooks |
include/kdrv_ble_tokens.h, src/kdrv_ble_tokens.c | Bond key tokens and the decisions about forgetting a bond. No NimBLE |
include/kdrv_net.h, src/kdrv_net.c | Lookup, ping, HTTP GET, POST, PUT and download stream, UDP, MQTT, SNTP, and kdrv_net_run() for the jobs |
The link, the status table, the KODE services and the handover note are in sdk/kdrv/c5.
Specifications
The Wi-Fi station
| Thing | Value |
|---|---|
| Bands | 2.4 GHz and 5 GHz. Sweep in 7 steps (KDRV_WIFI_SCAN_24_STEPS 3, KDRV_WIFI_SCAN_5G_STEPS 4); 28 5 GHz bitmap slots |
| Scan | Active, hidden networks shown, 200 ms minimum and 500 ms maximum dwell per channel |
| Limits | 12 s per step (KDRV_WIFI_SCAN_STEP_TIMEOUT_MS), 90 s for the sweep (KDRV_WIFI_SCAN_TIMEOUT_MS). A sweep takes about 38 s |
| Results | Hand-over ring of 96 (KDRV_WIFI_LIST_MAX); strongest 24 kept (KDRV_WIFI_SCAN_MAX, must stay under 32) |
| A network | kdrv_wifi_ap_t: ssid[33], rssi in dBm, channel, authmode (0 is open), bssid[6] |
| States | KDRV_WIFI_IDLE, CONNECTING, GOT_IP, DISCONNECTED. Idle covers "never started" and "started, on no network"; kdrv_wifi_started() tells them apart |
| Join wait | 20 s (RADIO_JOIN_MS), for the DHCP lease |
| Power save | Default WIFI_PS_MIN_MODEM: the C5 sleeps between beacons and a frame sent to an idle board can arrive 3 to 6 s late. kdrv_wifi_power_save(false) answers at once for about 27 mA more |
| Call | What it does |
|---|---|
kdrv_wifi_start(), _stop() | Station on with no netif, or torn down in three steps (disconnect, stop, deinit). Idempotent. _stop() needs a live link |
kdrv_wifi_scan() | The sweep. KODE_TIMEOUT when a step or the sweep ran out, with what was found kept |
kdrv_wifi_scan_next(&ap), _scan_dropped() | Drain the ring on the owner's thread; count what the ring could not hold |
kdrv_wifi_scan_count(), _scan_get(i, &ap) | Read the strongest 24 |
kdrv_wifi_connect(ssid, pass, ms) | Join and wait for an address. KODE_DENIED when the network refused, KODE_TIMEOUT when nothing arrived, KODE_ERROR when the attempt never started |
kdrv_wifi_prepare(ssid), _await(ssid, ms) | Ready this side for a join the C5 makes from its vault, then wait for the lease |
kdrv_wifi_adopt(ms) | Take over a station a parked C5 still holds, with no disconnect |
kdrv_wifi_disconnect(), _forget() | Leave the network; or clear every local belief with no RPC, for a dead link |
kdrv_wifi_state(&ip4), _ssid(), _rssi_last(), _started() | Local reads, no RPC, safe anywhere |
kdrv_wifi_rssi(), _power_save(on) | RPCs: worker only |
kdrv_wifi_keep_station_on_restart() | Takes esp_wifi_stop off the shutdown handlers so a restart does not end the association a parked C5 keeps |
Bluetooth LE
| Thing | Value |
|---|---|
| Controller | On the C5, LE only. Host on the P4 |
| Link limit | One connection at a time |
| Advertising | Connectable, legacy. Interval and time come from kdrv_ble_adv_plan(itvl, ms, hidden): KDRV_BLE_ITVL_FAST or KDRV_BLE_ITVL_SLOW, 0 ms for no end. Visible: general-discoverable, the console service UUID in the advert and the name in the scan response. Hidden: non-discoverable, the UUID alone, an empty scan response |
| Start wait | 5 s for host and controller to synchronise (BLE_SYNC_MS); KODE_TIMEOUT otherwise |
| Scan | Active, controller duplicate filter off, this driver keeps the strongest RSSI per device. Up to 24 devices (KDRV_BLE_SCAN_MAX), 30 bytes of manufacturer data, 4 16-bit service UUIDs, public or random address type |
| States | KDRV_BLE_OFF, IDLE, ADVERTISING, CONNECTED |
| Pairing | LE Secure Connections, bonded, MITM-protected with a six-digit passkey the board displays (DISP_ONLY). Open in kodeOS while the board is visible, refused while it is hidden and always refused in an app. Both characteristic permission bits ENC and AUTHEN are set. A firmware with KODEOS_BLE_PAIRING_PASSKEY off gets Just Works |
| Pair outcomes | KDRV_BLE_PAIR_OK, GONE, FAILED, REJECTED |
| Console service | 4b4f4445-0001-4f53-a2d1-7c0e9b5f3a61 |
| RX, central to board | 4b4f4445-0002-…, write and write-without-response, encrypted and authenticated. Writes up to 256 bytes (BLE_RX_SCRATCH), lines up to 240 (BLE_LINE_MAX) |
| TX, board to central | 4b4f4445-0003-…, notify, encrypted and authenticated |
| Mirror pipe | 4b4f4445-0004-…, notify only, present when the screen mirror is built in |
| Console ring | 2048 bytes (KDRV_BLE_RING_BYTES) in PSRAM, entries whole or not at all; drain of at most 12 entries a pass; retry after 2 ticks |
| Mirror ring | 8192 bytes in PSRAM, same drain and retry rules. A full ring is back-pressure and is not counted as a drop |
| Counters | kdrv_ble_console_backlog(), _retry(), _tx_drop() |
| Key move to the C5 | 3 s per key (BLE_KEY_PUT_MS) |
| Call | What it does |
|---|---|
kdrv_ble_start() | Controller, then NimBLE. Idempotent. Needs the link |
kdrv_ble_quiet() | Stop advertising, end any connection, leave the host running. What a Bluetooth switch calls. State goes to IDLE |
kdrv_ble_stop() | Real teardown: host stopped, controller disabled. For paths that take the C5 or the link away anyway |
kdrv_ble_adv_plan(itvl, ms, hidden) | The next advert's interval, time on the air and whether it hides the board. AO_Radio plans every advert |
kdrv_ble_advertise(on) | Start or stop the advert; starts the stack if needed. KODE_DENIED while a central is connected. The driver never re-arms the advert itself |
kdrv_ble_scan(ms), _scan_count(), _scan_get(i, &dev) | Blocking scan for about ms, then read the table |
kdrv_ble_state(), _addr(), _bonds() | State; this board's address once synchronised; number of bonds |
kdrv_ble_unpair() | Clear the bond store |
kdrv_ble_pair_take(&p), _pair_reject() | Collect the pairing record on the reader's thread; hang up on a pairing |
kdrv_ble_console_write(buf, len) | Copy a line into the ring. KODE_NO_RESOURCES when it does not fit |
kdrv_ble_console_set_rx_cb(), _set_raw_cb(), _raw(on) | Line callback; raw bytes while the payload of an install arrives |
kdrv_ble_mirror_write(), _mirror_attached() | The mirror pipe |
kdrv_ble_forget() | Clear local state with no RPC |
The key tokens are 16 bytes: the 4-byte magic KBTL (a long-term key) or KBTI (an identity resolving key), the peer's identity address (6 bytes), its type (1), whose key it is (1, KBT_ROLE_OUR or KBT_ROLE_PEER), then zeros. The board's long-term key and the peer's get different tokens, because with legacy pairing they are different keys.
kbt_forget_on_enc_fail() is true for a disconnect reason of "PIN or key missing" or a MIC failure on a bonded peer, and the driver then asks the C5 whether it holds the key (BTKEYS_HAS). kbt_c5_lost_key() is true only for a clear "not found" answer. A spoofer can end a link with either reason, and cannot make the C5 lose a key it holds.
The network
| Thing | Value |
|---|---|
| Host name, URL | 64 and 80 bytes (KDRV_NET_HOST_MAX, KDRV_NET_URL_MAX) |
| Ping | Up to 10 echoes, one per second, 1 s per echo. Session limit count × 2000 + 5000 ms |
| HTTP GET | Follows up to 3 redirects. Verifies https:// against the Mozilla bundle. A 404 returns KODE_OK; status carries it. Reads in chunks of 256 bytes, 15 s timeout |
| Job body | 1528 bytes (KDRV_NET_BODY_MAX): 8 records of 191 characters |
| POST | JSON body of at most 1024 bytes, stored first with kdrv_net_http_post_body() |
| PUT | Binary body of at most 64 KB, URL of at most 260 bytes, content type 32, Authorization: Bearer header 520. The URL must start with https://. req.keep leaves the connection open for the next PUT to the same URL; a different URL or a failure closes it |
| Download stream | One at a time. Open requires status 200. Each network wait is 15 s, and a whole open or a whole read is capped at 20 s (KDRV_NET_STREAM_OPEN_MAX_MS, KDRV_NET_STREAM_READ_MAX_MS) |
| UDP | Payload of at most 64 bytes on the console path |
| MQTT | One client. Topic 80 bytes, payload 240, URI 96, client id 48, password 512, last-will message 64. QoS 0 throughout |
| MQTT states | KDRV_MQTT_DOWN, CONNECTING, UP. Reasons: NONE, NO_NET, REFUSED, TIMEOUT, BUSY |
| SNTP | Server pool.ntp.org, started once, callback on the client's own thread |
| Jobs | PING, HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_CLOSE, UDP_SEND, MQTT_START, MQTT_STOP |
| Call | What it does |
|---|---|
kdrv_net_lookup(host, &ip4) | getaddrinfo, IPv4 only, address in network byte order. Blocks |
kdrv_net_ping(host, count, rows, cap, &written, &stats) | Resolve, then run a session. KODE_OK even if every echo timed out (stats.rx says). KODE_TIMEOUT on the abandon path |
kdrv_net_http_get(url, buf, cap, &out, ms) | One-shot GET into your buffer. KODE_OK means the transport worked |
kdrv_net_stream_open(), _read(), _close() | Pull a download chunk by chunk, with the shape of the storage open, read and close |
kdrv_net_udp_send(host, port, data, len) | One datagram. KODE_OK only when sendto sent exactly len bytes |
kdrv_net_info(&info) | Address, gateway, mask, DNS of the station. Does not block |
kdrv_net_run(req) | Run one job on the calling task. KODE_DENIED for a NULL request or an unknown job |
kdrv_net_http_post_body(), _http_put_prepare(), _http_forget() | Store a POST body or a PUT's five arguments before the job. Refused while a job runs. The PUT wipes its URL and bearer when the job ends |
kdrv_net_url_host(url, out, cap) | The host of an address, for log lines. Never log a PUT's address: the token is in its path |
kdrv_net_mqtt_session(), _publish(), _subscribe(), _unsubscribe(), _state(), _uri(), _set_cb() | The MQTT client's interface for its owner. A session applies to the next start only and is wiped as soon as the client is created |
kdrv_net_sntp_start(cb), _sntp_sync_now() | The time client |
Gotchas
Example code
An app can check that a Kode Dot is online by pinging a public address. Join a network once from the console first, with wifi_up and then wifi_join ssid="<network>" pass="<password>"; both are stored. This example retries every five seconds until the name resolves, then logs how many echoes came back and the average time.
/*
* ping_check.c - 2026-10-28 - from Kode with love
*
* Pings 1.1.1.1 four times and logs the result. Join a Wi-Fi network from
* the console first. The log shows a line like "4 of 4 replies, average
* 23 ms" when the Kode Dot is online.
*/
#include "kode.h"
#define TAG "ping_check"
/* Bring the system up, then ping until the network answers. */
void app_main(void)
{
kode_ping_stats_t stats;
kode_init();
/* A failed lookup means no address yet, so wait and ask again. */
while (kode_net_ping("1.1.1.1", 4, &stats) != KODE_OK) {
KLOG_I(TAG, "no network yet");
kode_delay_ms(5000);
}
KLOG_I(TAG, "%u of %u replies, average %u ms", (unsigned)stats.rx,
(unsigned)stats.tx, (unsigned)stats.avg_ms);
while (1) {
kode_delay_ms(1000);
}
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/kdrv/radio: the three drivers beside the link.kdrv_net.h: the one-shots, the jobs, the MQTT session.components/kaos/net: AO_Net, which owns the network (sdk/kaos/net).