# 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_wifi`** is the Wi-Fi station. It scans both bands, joins a network, leaves it and reports where the connection stands.
- **`kdrv_ble`** is Bluetooth LE. It advertises, scans, pairs and bonds, and carries the console as a Bluetooth service.
- **`kdrv_net`** is 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

```mermaid
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
```

*Three drivers over one link*

### 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:

1. Initialise and enable the controller over RPC.
2. Call `nimble_port_init()`.
3. Configure NimBLE: callbacks, pairing policy, the GAP and GATT services, the device name, the console service table, the output rings and the bond store.
4. 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

> **WARNING**
>
> **A Wi-Fi scan holds the whole link for about 38 s.** The C5 serves one RPC at a time, so a sweep delays every other request to it, including a Bluetooth start and a status refresh. This is why the radio worker runs one job at a time and why `kdrv_wifi` keeps no locks. Run the scan from the owner and expect the wait.

> **WARNING**
>
> **The blocking calls never run on an Active Object.** `kdrv_wifi_*`, `kdrv_ble_*` and the network one-shots wait seconds on the C5 or a socket. An AO step that waits stalls every priority beneath it. From an app, use the `kode_net_ping()` and `kode_http_get()` helpers on a task or a `kode_worker` job, or send AO_Net a request and wait for its event.

> **NOTE**
>
> **"Bluetooth off" leaves the stack running.** `kdrv_ble_quiet()` stops the advert and any connection and keeps NimBLE up, so Bluetooth is back on the air in one step. `kdrv_ble_stop()` is for the paths that take the C5 or the link away anyway, such as a link teardown or a C5 update, so a switch never calls it.

> **NOTE**
>
> **An HTTP error status is a successful call.** `KODE_OK` means the server answered. Read `status` for what it said, and treat `KODE_TIMEOUT` as a body that was cut short by the network, never as the end of the file.

## 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.

```c
/*
 * 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`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/radio): the three drivers beside the link.
- [`kdrv_net.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kdrv/radio/include/kdrv_net.h): the one-shots, the jobs, the MQTT session.
- [`components/kaos/net`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/net): AO_Net, which owns the network (`sdk/kaos/net`).
