SDK

ESP32-C5 coprocessor

The second processor that is the only radio of a Kode Dot: its wiring, the esp-hosted link, the 21-row status table and the handover across a reset.

What it is

The ESP32-P4 that runs your app has no radio. Every wireless feature of a Kode Dot lives in a second chip beside it, an ESP32-C5 from Espressif: Wi-Fi on both bands and Bluetooth LE (it has no classic Bluetooth). The C5 also does four jobs that are not radio jobs, because it owns the pins behind them:

The C5 runs its own firmware, the kode_c5 project in the SDK's c5/ submodule. It talks to the P4 over esp-hosted, an Espressif protocol that lets one chip use the radio and the pins of another. In esp-hosted's words the C5 is the slave and the P4 the host. Both sides run esp-hosted 2.12.9 and must stay on the same version.

The C5 also keeps the secrets of the board: the saved Wi-Fi passwords, the key that proves which unit this is, and the keys of the Bluetooth bonds. The P4 never holds them.

How it is connected

flowchart LR
p4["ESP32-P4<br/>kdrv_radio"] -- "SDIO, 4 bits, 40 MHz" --> c5["ESP32-C5<br/>kode_c5"]
p4 -- "GPIO23, C5_EN" --> c5
subgraph pins["Pins the C5 owns"]
  amp["speaker<br/>amplifier"]
  cd["microSD<br/>card detect"]
  ext["J3<br/>+3.3V_EXT"]
  ir["IR emitters,<br/>receiver"]
end
c5 -- "IO4" --> amp
c5 -- "IO6" --> cd
c5 -- "IO5" --> ext
c5 -- "IO24, IO23" --> ir
class p4 chip
class c5 chip
The C5 and what it drives

The C5 is U6 on the main PCB, with its own crystal and antenna. TBD: the exact module part number, from the bill of materials.

Six wires join the two chips, all on the P4's SDMMC controller (slot 1). The reset line is C5_EN, GPIO23 of the P4 (KBSP_PIN_C5_EN), pulled up by R20 (10 kΩ), so a C5 that nobody holds starts by itself at power-up.

The board file sdkconfig.board states every one of these facts to esp-hosted, and kdrv_c5.c holds the clock and the width to KBSP_C5_SDIO_KHZ and KBSP_C5_SDIO_WIDTH with _Static_assert, so the two sources cannot disagree and still build.

AO_Radio owns the C5 (KBSP_RES_C5: the enable line, the link, its pins and its flash). It hands every blocking job to its own worker, radio_worker, which calls kdrv_radio_run(). No other part of the system touches the link. The driver keeps no task, queue or flag of its own.

The pieces, from the bottom:

Reset, hold and the SD controller

Starting the link pulses C5_EN low, so starting the link reboots the C5, unless the handover note below keeps it. esp-hosted also takes the P4's one SDMMC controller, which the microSD slot shares. AO_Radio unmounts the card before every start, and the card mounts again through a second path while the link is up. The card's own supply is the P4's pin (KBSP_PIN_SD_PWR, GPIO22), so a C5 reboot never affects it.

With CONFIG_KODEOS_C5_HOLD (off by default, on in kodeOS), AO_Radio drives C5_EN low at the start of a boot where neither Wi-Fi nor Bluetooth is wanted. A held C5 is off, and so is the speaker amplifier, because R16 (10 kΩ) pulls its enable to ground. The C5 is released when the link starts, or when AO_Audio asks for it before the first sound of a boot. When the last user of the radio goes away, AO_Radio ends the link and holds the C5 again.

The handover note

A deliberate restart can leave the C5 parked with its Wi-Fi station still associated, so the next firmware skips the join. The note is one 32-bit word in LP_SYSTEM_REG_LP_STORE15_REG, a register of the low-power domain that survives esp_restart() and every reset except a power-on. The high 16 bits are the magic 0x4B50 ("KP"), the low 16 bits are the role of the firmware the restart goes to: KDRV_RADIO_NOTE_OS (1) or KDRV_RADIO_NOTE_APP (2).

The first CONNECT of a boot reads the note and erases it. The C5 is kept only when every condition holds:

  1. The magic is there.
  2. The reset reason is a software reset.
  3. The target is this firmware's own role.
  4. This boot wants Wi-Fi (req.wants_wifi).

Any other case pulses C5_EN and starts from a fresh C5: a panic, a watchdog, a power cut, a boot that does not want Wi-Fi, or a firmware built before the note. Only the first CONNECT of a boot can keep the C5. kdrv_radio_adopted() tells whether it did. The rule itself is kradio_note_keeps_c5(), a pure function in kdrv_radio_note.c that the host tests run.

The sleep sentinel

The sentinel keeps the esp-hosted link alive while the P4 light-sleeps. It exists only with CONFIG_KODE_LOWPOWER (the files kdrv_radio_sleep.c and kdrv_radio_sleep_policy.c). While the P4 sleeps, the C5 is told, esp-hosted's SDIO tasks are suspended, and the C5's wake line (KBSP_PIN_C5_WAKE_P4, GPIO2) or the P4's own outgoing traffic ends the sleep. Design rule 9 holds: the sentinel is a worker. It runs the handshake and reports what happened, and it decides no state of the system. The job is KDRV_RADIO_JOB_SLEEP. It runs through kdrv_radio_sleep() on the worker of AO_Radio (sdk/kaos/radio), and kdrv_radio_run() refuses it, because the note of a wake must reach the owner's own post.

kdrv_radio_sleep(true, note, arg) goes down. One task of the sentinel does all of it, so the bus has one conductor. The order is fixed:

  1. It marks the bus silent, before the C5 is told anything. A frame that the P4 queues from then on must wake the sentinel, or its caller would wait for an answer that cannot come. This step also arms the C5's wake line.
  2. It announces power save to the C5 and waits 20 ms (KRADIO_SLEEP_PS_WAIT_MS), so that the C5 finishes what it was sending.
  3. It suspends the three SDIO tasks of esp-hosted (sdio_write, sdio_read, sdio_process_rx), but only when all of them are blocked. A task that stops in the middle of an SDIO transaction would hold the mutex of the bus, and the way back up would wait for it for ever. The sentinel looks up to 21 times, 5 ms apart (KRADIO_SLEEP_SETTLE_TRIES, KRADIO_SLEEP_SETTLE_MS).

If the wake line does not arm, the C5 does not take the announcement, or the SDIO tasks never settle, the bus is left as it was (a C5 that was told is told again that power save is off) and the call returns KODE_ERROR, KODE_IO_ERROR or KODE_DENIED. A bus that nobody told the C5 about must not go quiet.

The way up starts on the C5's line or on a frame the P4 queued. A hook on esp_hosted_tx() (the linker's --wrap) catches every outgoing frame and counts it by interface: station, serial (RPC) and HCI (BLE). The sentinel knocks the C5 awake first, because a message to a sleeping C5 is not answered and an SDIO write that reaches it times out, which makes esp-hosted restart the board. The first power-save-off message only wakes the C5, which wakes on its CMD line, so it is repeated up to 100 more times, 10 ms apart, until the C5 answers (KRADIO_SLEEP_OFF_RETRIES, KRADIO_SLEEP_OFF_RETRY_MS). Then the sentinel waits 50 ms (KRADIO_SLEEP_RESUME_WAIT_MS) and resumes the SDIO tasks. A short awake window follows: 100 ms (KRADIO_SLEEP_AWAKE_MS), and another 3 s (KRADIO_SLEEP_GAP_MS) each time the BLE GAP count of kdrv_ble_activity() has moved. A connect and its encryption need the board up longer than the traffic that announced them. A command that waits for the sentinel's task ends the repeated window, so the wake does not wait for the link to fall silent. Then the note is called. The bus goes down again, unless a command waits: then it stays up and the command decides.

A C5 that never answers leaves the SDIO tasks suspended, and the wake is noted as KDRV_RADIO_WOKE_DEAF. The transport is never resumed against a deaf slave. If the bus cannot go down again, the sentinel stands down: the bus stays up, its power-management lock stays held so the P4 does not light-sleep over a live bus, and the note is KDRV_RADIO_WOKE_STOOD_DOWN. The next start or stop of the sentinel lets the lock go. kdrv_radio_sleep(false, ...) serves the C5 one last time and leaves the bus awake. A stop returns KODE_IO_ERROR when the C5 did not answer, and the sentinel stays armed.

The note (kdrv_radio_sleep_note_t) has the kind (WOKE_C5, WOKE_TX, WOKE_DEAF or WOKE_STOOD_DOWN), the knocks that the wake needed, and the totals of frames queued while the bus was silent (tx_sta, tx_serial, tx_hci). It is called in the sentinel's task and must be short. kdrv_radio_sleep_stats() gives the counters since boot, which lowpower_info prints. The rules (the order, the retries, the windows) are in kdrv_radio_sleep_policy.c, free of ESP-IDF and driven through a table of functions, so the host tests run the real rules over fakes.

The C5's half of the sleep is in its firmware: light sleep on the host's announcement, a wake on its SDIO CMD line, a frame filter and the amplifier's enable (sdk/kaos/lowpower).

The C5 firmware

The firmware is the slave example of esp-hosted-mcu 2.12.9 with Kode's additions, built by tools/build_c5.sh in the kodeOS repository (IDF 6.0.2) and shipped as c5_slave.bin in the factory FAT. The additions are the card-detect input, the amplifier enable, the infrared service, the KODE services (vault, identity, Bluetooth keys, park) and a measurement of the 32.768 kHz crystal at boot. The image's project name is kode_c5; stock esp-hosted says network_adapter.

Kode's additions live in c5/main/kode_*. The esp-hosted files keep their names and Espressif's style, so a new esp-hosted release still merges.

FileWhat it does
kode_svc.cThe KODE services' dispatcher. esp-hosted's receive thread copies a request and queues it, and one task answers the requests one at a time, so no service needs a lock. It answers INFO and PARK itself and hands every other request to its service's handler
kode_svc_frame.cThe frame check, and the rule that the first well-formed request marks an updated C5 image valid
kode_vault.c, kode_vault_list.cThe Wi-Fi vault. The host tells it a password once, after a join it made itself worked (REMEMBER). From then on the host asks it to JOIN a network by name and the C5 sets its own station configuration. LIST answers names only
kode_id.c, kode_id_nonce.c, kode_id_serial.cThe unit's identity: the key, the CSR, the certificate, the signed challenge and the serial
kode_btkeys.c, kode_btkeys_cache.c, kode_hci_filter.cBluetooth bond keys behind tokens, and the swap on the way into the controller
kode_ir.cThe infrared block: one task owns the RMT on IO24 and IO23
kode_park.cThe park
kode_wire.h, kir_wire.hThe two wires, identical copies of the SDK's (kdrv/radio/include/, kdrv/ir/include/)

Six of them hold no ESP-IDF at all (kode_vault_list.c, kode_id_nonce.c, kode_id_serial.c, kode_svc_frame.c, kode_btkeys_cache.c, kode_hci_filter.c), and c5/test/run.sh tests them on the computer.

The vault keeps one NVS blob, kvault/nets, behind a magic and a version. Any other blob reads as an empty vault. A password lives there and nowhere else on the Kode Dot.

The identity key is born in the C5's Key Manager, in random mode. NVS keeps the recovery information that loads the key again on this chip (kid/ri), never the key. Every signature goes through a PSA opaque key on the ECDSA peripheral, so mbedTLS never sees the scalar. A new key is the Key Manager's second in a row: its first random key after a C5 reset comes from a small set of values that is the same on every chip.

The key signs two things: the CSR the C5 builds itself, and the cloud's challenge as kode-auth/v1\n plus the nonce. The C5 adds the prefix, so no program on the P4 can have the key sign anything else. LOCK, sent by the factory after the unit's first login, closes CSR and CERT_SET for good. The identity has an NVS partition of its own, fctry, because app_main() erases the default one when its format changes or it fills, and that would lose the key. Nothing in it is secret, so it is not encrypted. The serial in the CSR is the C5's MAC in the encoding khal_identity_encode() uses on the P4, and the host tests pin the same vectors on both sides.

The Bluetooth keys. NimBLE on the P4 keeps a 16-byte token where a bond's key would be. A key reaches the controller in two HCI commands only, LE Long Term Key Request Reply and LE Add Device To Resolving List. The filter swaps the token in either for the key just before the packet enters the controller. A sketch's own keys pass untouched. The C5 keeps the keys in NVS namespace kbtkeys and in a RAM table of twelve slots: three bonds on the P4 (CONFIG_BT_NIMBLE_MAX_BONDS), each with at most the Kode Dot's own LTK, the peer's LTK and the peer's IRK. One short spinlock guards the table, because the receive task reads it and the service task writes it.

The infrared block opens nothing until the host first asks, so a C5 whose host never asks leaves IO24 and IO23 as they reset. The carrier duty is 0.50, the most a TSOP-class receiver is tuned for. R22 and R23 (39 Ω) set the peak current, about 69 mA per emitter, so the duty raises only the average. TBD: the range at 0.50 has not been measured against Kode-Dot-Val/10-IR, which was taken at 0.33.

A park keeps the Wi-Fi station associated, so the next firmware adopts the association instead of joining again. It gives up everything that belonged to the host that is leaving: Bluetooth (if the host started it), the infrared channels, the drive of the J3 supply pin, the station frames and the SDIO link. The C5 answers the park first, then parks. The next ESP_OPEN_DATA_PATH un-parks it and hands the station back.

The files of the component:

FileWhat it does
include/kdrv_radio.h, src/kdrv_radio.cThe link: CONNECT, DISCONNECT, every job (Wi-Fi, BLE, C5 update, status snapshot, J3 supply), the peer-data seam, C5_EN hold, the handover note, the link-lost report
src/kdrv_radio_sleep.c, src/kdrv_radio_sleep.h, src/kdrv_radio_sleep_policy.cThe sleep sentinel and its rules, with CONFIG_KODE_LOWPOWER
src/kdrv_radio_note.h, src/kdrv_radio_note.cThe note's word layout and the rule that decides whether the C5 stays. No ESP-IDF in it
include/kdrv_c5.h, src/kdrv_c5.cThe status table, its snapshot, the string getters, the J3 supply call
include/kdrv_c5svc.h, src/kdrv_c5svc.cThe P4's end of the KODE services: send, call, listen, abandon, forget
include/kode_wire.hThe KODE wire format. The same file lives in the C5 firmware

Specifications

The board

ThingValue
ChipESP32-C5, target esp32c5, esp_chip_model_t 23
Linkesp-hosted 2.12.9 on both sides, SDIO slot 1, 4 data lines, 40 MHz
SDIO pins (P4)CLK GPIO50, CMD GPIO51, D0 GPIO49, D1 GPIO48, D2 GPIO53, D3 GPIO52
Reset lineC5_EN = GPIO23 (KBSP_PIN_C5_EN), R20 pull-up, high runs, low resets
C5 pins used for the P4IO4 amplifier enable (output, high from boot), IO6 card detect (input, pull-up), IO5 J3 supply enable (R72 pull-up holds the rail on), IO24 IR transmit, IO23 IR receive
Reaching the pinsRPC GPIO service, about 66 edges per second. Never use it where timing matters. The IR pins go through the C5's own infrared service instead
Link-up wait10 s for CP_INIT (RADIO_CP_INIT_MS)
Reset pulseC5_EN low for 10 ms, then 1500 ms for the C5 to boot (the same figures esp-hosted uses)
CurrentMeasured on a Kode Dot: 46.5 mA with the C5 held in reset, 73.8 mA with the link up and the station idle, 141.1 mA while the C5 scans

The status table

kdrv_c5_get(field, &value) reads one row, in the unit of kdrv_c5_field_info(). Every row is read-only; kdrv_c5_set() refuses all of them. A row is refused with KODE_DENIED when its group was not filled by the last refresh. Seven rows always answer: those marked "own, always".

FieldGroupUnit and rangeWhat it is
validown, alwaysboolA refresh has succeeded since the last forget
chip_ididentitycode 0..65535esp_chip_model_t; 23 is an ESP32-C5
fw_major, fw_minor, fw_patchidentitycode 0..255The esp-hosted version the slave runs. The slave sets the finer fields to -1
host_major, host_minor, host_patchown, alwayscode 0..255The esp-hosted version this firmware was built against
app_descown, alwaysboolThe slave answered the image-descriptor RPC
project_kodedescriptorboolThe image's project name is kode_c5
mac_hi, mac_mid, mac_loidentitycode 0..65535The station MAC, two bytes a row
reset_reasonown, alwayscode 0..255esp_reset_reason_t of the C5's last boot, carried by CP_INIT; 0 before any link-up
link_msown, alwaysms 0..60000Connect to CP_INIT at the last start; 0 before any
heap_free_kb, heap_largest_kbheapKBFree internal heap of the C5 and its largest free block
sdio_khzidentitykHzThe clock the transport negotiated
sdio_widthidentitybits 0..8Data lines in use, 1 or 4. A link on one line has three that do not reach the C5
sd_cdpinsboolIO6 as read: the card-detect level. Its polarity is not confirmed on the board, so it is a reading and never a verdict
pwr_extpinsboolIO5 as read: the J3 supply enable, 1 is on

The identity group (chip, version, MAC, SDIO configuration) fails together: if any of its four RPCs does not answer, kdrv_c5_refresh() returns KODE_ERROR and the snapshot stays invalid. The descriptor, heap and pin groups are independent. One that does not answer is recorded as absent and the refresh still succeeds, which is how an older slave without the descriptor RPC is told apart from a missing one.

What a table cannot hold has its own getter, each refused with KODE_DENIED when the slave did not send it: kdrv_c5_target(), _project_name(), _app_version(), _idf_ver(), _build_date(), _build_time(), _elf_sha() (32 bytes) and _mac() (6 bytes).

The driver

CallWhat it does
kdrv_radio_run(req)Runs one job to its end and returns its status. Blocks. Worker only. KODE_DENIED for a NULL request or a job this build lacks
kdrv_radio_link_up()True from a finished CONNECT until a drop, a DISCONNECT or a C5 image activation
kdrv_radio_c5_hold(hold), _c5_held()Drive C5_EN low or high. KODE_DENIED once esp-hosted has the line, from CONNECT until its DISCONNECT ends
kdrv_radio_note_leave(target)Write the handover note. Call it only after the C5 answered the park with OK, right before the restart
kdrv_radio_adopted()True when the last CONNECT kept a parked C5
kdrv_radio_fwversion(&ver)The version cached by the last CONNECT; KODE_DENIED before one
kdrv_radio_image_header_ok(head, len)Cheap gate before a C5 update: length, ESP image magic, and chip id ESP_CHIP_ID_ESP32C5. A kodeOS or app image is refused by name. Not a verification
kdrv_radio_on_link_lost(cb)One callback for a dropped transport (KODE_ERROR) or a mute C5 (KODE_TIMEOUT). It must post and return
kdrv_radio_sleep(sleep, note, arg), kdrv_radio_sleep_stats(&out)Start or stop the sleep sentinel, and read its counters. Only with CONFIG_KODE_LOWPOWER. The call blocks until the sentinel's task has done it, so it belongs to a worker, one caller at a time
kdrv_radio_peer_listen(id, cb, up, ctx), _peer_send(id, data, len)The seam for another driver's own service on the C5 (infrared uses it). Three listener slots. The callback runs on esp-hosted's thread
kdrv_c5_refresh(), _forget()Take or drop the snapshot. Refresh blocks: worker only
kdrv_c5_exp_power(on)Drive IO5 over the RPC GPIO service and read it back. Worker only. The C5 holds the level until told again or rebooted

The jobs kdrv_radio_run() knows, by group:

GroupJobs
LinkCONNECT, DISCONNECT
Wi-FiWIFI_SCAN, WIFI_JOIN, WIFI_LEAVE, WIFI_STOP, WIFI_RSSI, WIFI_PS, WIFI_PREPARE, WIFI_AWAIT, WIFI_ADOPT
BluetoothBLE_START, BLE_ADV, BLE_SCAN, BLE_QUIET, BLE_STOP, BLE_UNPAIR
The C5 itselfC5_BEGIN, C5_WRITE, C5_FINISH, C5_ABORT, C5_INFO, EXP_POWER
SleepSLEEP: start (req.on true) or stop the sentinel. Only with CONFIG_KODE_LOWPOWER

CONNECT runs these steps:

  1. Platform setup once (NVS and the default event loop).
  2. Drain a stale CP_INIT.
  3. Apply the handover rule.
  4. Give the reset line to esp-hosted.
  5. esp_hosted_init() and esp_hosted_connect_to_slave().
  6. Wait for CP_INIT.
  7. Read the slave version.
  8. Take the snapshot.
  9. Register the peer listeners.

A snapshot failure is never fatal. If CP_INIT does not arrive in 10 s the job returns KODE_TIMEOUT and logs a question about whether the C5 carries the slave firmware at all.

DISCONNECT stops Wi-Fi and BLE first, while a channel still exists to tell them. It then calls esp_hosted_deinit() with the loss report masked, and clears the local state of kdrv_c5, kdrv_wifi and kdrv_ble.

A C5 update is C5_BEGIN, then C5_WRITE, then C5_FINISH, which ends the session and activates the image. C5_WRITE sends pieces of 1500 bytes (the size of the vendor's example; esp-hosted sleeps 10 ms in each write, so this also sets the pace). After activation the C5 reboots into the new slot and cannot go back, so the driver marks the link down at once. The data pointer of C5_WRITE is borrowed from AO_Storage's loan buffer; it is the one pointer in the whole interface that is not a copy.

CP_INIT carries the reason the C5 booted. The event task of esp-hosted records it with kdrv_c5_note_reset(). The worker records the connect time with kdrv_c5_note_link_ms().

The KODE services

Four services share one pair of message ids over esp-hosted's peer data: KODE_MSG_REQ 0x4B4F4401 out, KODE_MSG_RSP 0x4B4F4481 back. Every message starts with an 8-byte header (version, service, op, seq, status, reserved, len, little-endian). The P4 drops an answer whose version is not KODE_WIRE_VERSION (3) or whose seq does not match. The largest payload is 2048 bytes.

ServiceOperationsWhat it holds
SELF (0)INFO (1), PARK (2)INFO answers a 24-byte kode_info_t: vault count, key count, identity flags, heap figures, token swaps and misses. PARK keeps the Wi-Fi station and drops the host
VAULT (1)REMEMBER, JOIN, LIST, FORGET, WIPEUp to 8 networks (KODE_VAULT_NETS_MAX), SSID up to 32 bytes, password up to 64. After a join that worked, the P4 sends the name and password once; afterwards it knows only the names
ID (2)CSR, CERT_SET, CERT_GET, SIGN_AUTH, LOCK, STATEThe unit's key lives in the C5's Key Manager. It signs its own certificate request and a cloud challenge (a nonce of 1 to 200 characters of [A-Za-z0-9._-], checked on both chips). Nothing signs bytes the P4 chose. LOCK closes CSR and CERT_SET for good. A certificate pair is at most 1536 bytes
BTKEYS (3)PUT, DEL, WIPE, HASBond keys behind 16-byte tokens (sdk/kdrv/radio explains the tokens)

Status bytes: OK 0, REFUSED 1, BAD_REQUEST 2, NOT_FOUND 3, STORAGE 4, LOCKED 5. PARK answers OK when a station is associated, REFUSED when there is nothing to keep, and BAD_REQUEST from an image that predates the park.

kdrv_c5svc has two ways to ask:

One request may be in flight per service. kdrv_c5svc_abandon() gives up on one, and a late answer is dropped. kdrv_c5svc_forget() clears everything in flight when the link goes down.

Gotchas

Example code

An app that drives a module on the expansion header powers it through the C5's IO5. This one switches the supply on for five seconds and off again. With a multimeter on pins 2 and 19 of J3, you read 3.3 V for five seconds, then 0 V.

/*
 * module_power.c - 2026-10-28 - from Kode with love
 *
 * Switches the +3.3V_EXT supply of the expansion header J3 on for five
 * seconds, then off. The supply is enabled by pin IO5 of the ESP32-C5, so
 * the request goes to that chip. With a multimeter on pins 2 and 19 of J3
 * you read 3.3 V for five seconds, then 0 V.
 */
#include "kode.h"
 
/* Bring the system up, run the supply for five seconds, then idle. */
void app_main(void)
{
    kode_init();
 
    /* Posts to the owner of the C5 and returns at once. */
    kode_exp_power(true);
    kode_delay_ms(5000);
    kode_exp_power(false);
 
    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