SDK
kode facade
kode_init() and every call an app makes: the headers, the start order, the API grouped by area, the boot banner and the build options of the SDK.
What it is
kode is the top of the SDK and the only component an app has to name. You add it to a project, include kode.h, call kode_init() from app_main(), and the whole system runs inside your firmware: the same Active Objects, the same signals and the same rule that every device has exactly one owner. kodeOS starts itself with this call, and so does your app.
An app is a whole firmware. It carries its own copy of the runtime, so your Active Objects and kodeOS's are the same objects, in your image, scheduled by your own QF. Nothing is shared with a running kodeOS, and any reset of your firmware comes back to kodeOS, because the bootloader marks the app's boot request as tried before it jumps and the app never renews it.
Almost every call posts a request to the Active Object that owns the device, so the owner cannot tell whether a request came from kodeOS or from you. A few calls wait for the answer. Which ones is the first thing to learn here: the Specifications table gives it for every function.
Three headers cover the facade:
kode.his the small one: start the system, ask an owner for something, go home. It adds no QP/C names to your code.kode_ao.hiskode.hpluskqp.handkaos.h, for an app that writes Active Objects of its own (sdk/kqp).kode_worker.hgives an Active Object a worker for the calls that wait.
How it is connected
flowchart LR app["Your code"] subgraph sdk["The SDK in your image"] facade["kode.h"] -- "LED_RGB_SIG" --> ao["AO_LedMgr"] ao -- "function call" --> drv["kdrv<br/>KTD2026 driver"] end app -- "kode_led_rgb()" --> facade drv --> chip["KTD2026"] class app app class ao ao class chip chip
The facade depends on kaos and kqp, and that is the whole design. Everything an app asks for goes through the Active Object that owns the device, exactly as kodeOS's own code does. kode requires kaos, kqp, kdefs, klog, kdrv and khal publicly, because its headers include theirs (kode_status_t, the expansion header's types, KODE_APP_MANIFEST), and kbsp privately: the facade claims the expansion header's resource and an app never names one.
Two kinds of call
Every call is one of two kinds:
- A posting call builds an event, hands it to the owner and returns. It tells you the request was accepted, and never that the work happened.
- A blocking call posts a request that points at a buffer of the facade, then sleeps on a semaphore the facade created once in
kode_init(). The owner writes the answer in the buffer and signals the semaphore. The facade wakes and copies the answer out.
sequenceDiagram autonumber participant A as Your task box "The SDK in your image" participant F as The facade participant O as The owner AO end A->>F: kode_imu_read(&sample) F->>O: IMU_READ_REQ_SIG, pointing at the facade's buffer Note over F: sleeps on its semaphore O->>F: writes the answer, signals F->>A: copies it into sample, returns
Nothing of yours is lent to the owner, so a call that gives up first (a timeout) leaves nothing dangling, and out can be an ordinary local.
Each blocking family has one claim: one caller at a time, and a second one gets KODE_DENIED with nothing posted. The semaphore is one instance, because khal_sync.h has no destructor for one and a call meant for a loop must not leak one per pass.
A call that timed out leaves its answer owed, and the next call waits for the abandoned answer to land before it starts.
Announcements at startup
An announcement made during startup has already been made. Every Active Object that kodeOS starts is running when kode_init() returns. Ask for a state instead of waiting for it: subscribe to DISPLAY_STATE_SIG, then call display_state_request() (sdk/kscreen).
The start order
The start order lives in one place, kode_init() in kode.c. Posting to an Active Object that has no queue yet is assert failed: xQueueGenericSend queue.c:936 (pxQueue) and a boot loop, so the order is the constraint set below.
Device owners always start. Policy (the console, the app manager, the radio, the network) starts only when the build asks for it (see the Kconfig tables).
Coming home
Coming home is not your job. kode_exit_to_os() is the polite way, but any reset does it: the button, a crash, a watchdog.
The bootloader marks the boot request TRIED before it jumps to your app, and AO_Boot in an app never writes otadata or the request. The next reset therefore boots kodeOS, even for an app that dies on its first line (sdk/kaos/boot).
The boot banner
kode_init() prints four lines at the top of every log, through AO_Log, at level NOTICE under the tag kode. They pass every level filter except a global off.
The four lines:
| Line | Reads | From |
|---|---|---|
| 1 | <project> <version> (<date> <time>, ESP-IDF <version>), <role>; the role is the OS, from <partition> or an app | The image descriptor, kdrv_running_desc() |
| 2 | sdk <version> | KODE_SDK_VERSION, written by the build |
| 3 | hardware <product> <revision>, <chip> v<major>.<minor>, <n> MB flash, <n> MB PSRAM | kbsp for the names, khal_chip_info() for the chip and sizes |
| 4 | reset <reason> | kdrv_reset_reason() and a closed table of sixteen |
A fact that cannot be read prints ?.
For a panic, the interrupt watchdog and the task watchdog, line 4 gives the core dump's panic reason instead of the words, read only when the dump belongs to this image (reset AO stuck: Net, 30 s in one event). When kodeOS starts after an app, it reads the app's name from the TRIED boot request before AO_Boot clears it and prints reset back from app <name>: <reason>, without reading the dump, because an app is built without one. AO_Boot logs no reset reason of its own: the banner is the one place.
The SDK version is the first of these that exists:
git describe --tags --dirty --alwaysin the SDK's work tree (when the SDK is a submodule).- The version the component manager recorded for
kodediy/kodeindependencies.lock(when the SDK was fetched). - The word
unknown.
version.cmake runs on every build and rewrites the header only when the string changed.
Specifications
The files
The files of the component.
| File | What it does |
|---|---|
include/kode.h | The facade: every function below, the constants an app sizes arrays against, and KODE_VERSION_MAJOR 3, KODE_VERSION_MINOR 0 |
include/kode_ao.h | kode.h plus kqp.h and kaos.h. Declares no function of its own |
include/kode_worker.h | kode_worker_t, kode_worker_start() and kode_worker_submit(), inline over kqp_worker_* |
src/kode.c | kode_init(), the Active Objects' queue and stack storage, and the body of every call: the semaphores, the claims, the copy-out |
src/kode_banner.c, src/kode_banner.h | The four banner lines; the header is private |
src/kode_banner_text.c | The closed table of reset words and the reset line, pure text the host tests compile alone |
version.cmake | Writes kode_sdk_version.h on every build |
README.md, CMakeLists.txt, idf_component.yml | The component's pointer, build and manifest |
kode.h also includes kode_defs.h, kode_log.h, kapp_desc.h (for KODE_APP_MANIFEST, what the launcher shows about your app), kode_variables.h, kode_functions.h, kode_config.h, kapp_cat.h, kdrv_buttons.h, kdrv_ir.h, kdrv_es8311.h, kes_fields.h, kdrv_qmi8658.h, knfc_tag.h, knfc_field.h, knfc_emulate.h, krfid_wave.h and khal_exp.h, so the types the calls speak arrive with it.
The start order
kode_init() does this, in order:
- It runs
kode_log_init(), then withCONFIG_KODE_LOWPOWERkode_boot_gate(), which sends a Kode Dot woken from OFF by any button but BOT back to deep sleep before any Active Object exists (sdk/kaos/lowpower). Thenkhal_heap_watch(),khal_identity_init(),khal_pwm_init(),khal_exp_init()andkqp_init(). - It starts the Active Objects in the order of the table below, and prints the banner right after AO_Log starts.
- It ends with
kaos_config_add(),log_start_reader(),sched_system_up()when the scheduler is built, andQF_run(), which returns at once because the scheduler already runs.
Your app_main() goes on as an ordinary task beside them.
| Order | Active Object | Priority | Queue | Stack | Gated by | Why it sits here |
|---|---|---|---|---|---|---|
| 1 | AO_Log | 6 | 64 | 4096 | always | Everything logs through it |
| 2 | AO_Boot | 12 | 8 | 4096 | always | |
| 3 | AO_LowPower | 24 | 48 | 3072 | KODE_LOWPOWER | Third, so every owner after it can join the rounds of rest in its own initial transition |
| 4 | AO_Scheduler | 16 | 24 | 3072 | KODE_SCHEDULER | After AO_LowPower, which it joins, and before AO_Console. It posts to nobody until sched_system_up() |
| 5 | AO_Storage | 5 | 16 | 6144 | always | Bringing the C5 link up walks the card by posting to it |
| 6 | AO_Radio | 4 | 8 | 6144 | KODEOS_RADIO | |
| 7 | AO_Net | 2 | 16 | 4096 | KODEOS_NET | Its initial transition asks AO_Radio for the Wi-Fi state |
| 8 | AO_LedMgr | 7 | 16 | 3072 | always | |
| 9 | AO_Ir | 18 | 16 | 3072 | always | Self-contained |
| 10 | AO_Nfc | 19 | 16 | 6144 | always | Self-contained |
| 11 | AO_Rfid | 23 | 16 | 3072 | always | Self-contained |
| 12 | AO_Audio | 20 | 16 | 3072 | always | Self-contained |
| 13 | AO_Imu | 21 | 16 | 4096 | always | AO_Haptic posts to it from its first step |
| 14 | AO_Haptic | 22 | 16 | 3072 | always | |
| 15 | AO_AppMgr | 9 | 16 | 8192 | KODEOS_APPMGR | |
| 16 | AO_Autotest | 10 | 16 | 4096 | KODEOS_AUTOTEST | Before the console, its first client |
| 17 | AO_Console | 11 | 64 | 8192 | KODEOS_CONSOLE | |
| 18 | AO_Power | 8 | 8 | 4096 | always | |
| 19 | AO_IoExp | 17 | 16 | 4096 | always | Buttons, touch and display post to it or subscribe from their initial transitions |
| 20 | AO_Buttons | 13 | 16 | 3072 | always | |
| 21 | AO_Touch | 14 | 16 | 4096 | always | |
| 22 | AO_Display | 15 | 16 | 6144 | always | Last: starting it brings the panel up and announces it |
Each Active Object's task is named after it before it starts, so a backtrace and ps read log, boot, storage and so on.
API: how to read the tables
Column From an AO says whether an Active Object may call the function. Where it says no, the call blocks, and a call parked inside a state handler stalls every priority beneath it.
Use a worker (kode_worker_start()) and handle the result as an event. Status values are kode_status_t (sdk/kdefs).
API: start and time
| Call | From an AO | What it does |
|---|---|---|
kode_init() | once, from app_main() | Brings up logging, QF, the pools and every Active Object the build was configured with, then returns |
kode_delay_ms(ms) | no | Sleeps and yields the CPU |
kode_time_ms() | yes | Milliseconds since boot; wraps after about 49 days |
kode_device_id() | yes | The board's serial: no separators, safe in a topic, a file name or a URL. Never NULL; empty when the factory MAC could not be read. Valid for the life of the firmware |
API: the status LED
These publish to AO_LedMgr. They return KODE_OK, or KODE_NO_RESOURCES when the event pool is dry.
| Call | From an AO | What it does |
|---|---|---|
kode_led_set(on) | yes | Turns the LED on (last colour, or white) or off |
kode_led_toggle() | yes | Inverts it |
kode_led_rgb(r, g, b) | yes | Sets the colour; (0, 0, 0) is off |
API: the buttons
Six of them, one owner: AO_Buttons debounces and publishes BUTTON_EVT_SIG. An app whose main loop is a task polls instead.
| Call | From an AO | What it does |
|---|---|---|
kode_buttons_read() | yes | The mask of buttons down: bit 1U << kdrv_button_t set means pressed. KDRV_BTN_UP, _DOWN, _LEFT, _RIGHT, _TOP, _BOT are bits 0 to 5. Never blocks, touches no bus, 0 before kode_init(). A press shows after KODE_BUTTONS_SETTLE_MS (30 ms), so find an edge by comparing with the last call. An injected btn press reads as down until the next word about that button |
API: infrared
Two emitters and a receiver wired to the ESP32-C5; AO_Ir owns the block. A firmware built without KODEOS_RADIO has no infrared and the calls refuse. Only NEC is encoded here; anything else goes out as raw symbols.
| Call | From an AO | What it does |
|---|---|---|
kode_ir_send_nec(addr, cmd) | yes | Posts one NEC frame and returns once it is queued; the frame takes 52 to 86 ms on the wire. A dry pool drops it inside AO_Ir with a log line |
kode_ir_send_raw(sym, n, carrier_hz) | no | Sends n {mark_us, space_us} symbols, mark first, n in 1 to KODE_IR_RAW_MAX_SYMBOLS; 38000 Hz for most remotes. Copies the frame, so sym is yours at once. One call at a time. KODE_TIMEOUT also when the frame waited too long behind others, and it is then never sent |
kode_ir_capture(out, cap, n, ms) | no | Waits up to ms for one frame and copies its symbols; at most KDRV_IR_RX_MAX_SYMBOLS. out and n are written only on KODE_OK. One call at a time (KODE_DENIED). KODE_TIMEOUT means the system, not the receiver, is at fault |
kode_ir_last(out) | no | The last frame AO_Ir heard, whoever was listening. One round trip. KODE_DENIED when nothing was heard yet |
API: audio
One ES8311 does both directions, so one owner, AO_Audio. Everything is 48 kHz, 16-bit, mono (KODE_AUDIO_RATE_HZ): the codec's capture and playback share one I2S clock, and the SDK ships no resampler.
The speaker amplifier is enabled by the C5's own firmware, so sound needs no radio link. A C5 running another image leaves it off and every call here still returns KODE_OK. The calls that block share one claim and one mutex.
| Call | From an AO | What it does |
|---|---|---|
kode_audio_tone(hz, ms, amp_pct) | yes | Posts one tone; hz snaps to the nearest detector bin and is refused below 400 Hz; amp_pct 1 to 100 |
kode_audio_volume(pct) | yes | Posts the output volume, 0 to 100 (100 is 11 dB under the codec's full scale); KODE_DENIED above 100 |
kode_audio_mic_gain(db) | yes | Posts the microphone gain, 0 to 42, rounded down to a 6 dB step; KODE_DENIED above 42 |
kode_audio_info(out) | no | What the path is and is set to; one round trip, safe while a sound plays |
kode_audio_play(frames, n) | no | Plays a whole sound and blocks until it ends; n up to KODE_AUDIO_MAX_FRAMES (KODE_AUDIO_BUF_SECONDS of audio). The frames are copied first |
kode_audio_record(out, cap, n, ms) | no | Records ms (up to KODE_AUDIO_BUF_SECONDS) and blocks until done |
kode_audio_record_file(path, ms, bytes) | no | Records straight into a 48 kHz mono WAV file; the parent directory must exist. The frames never pass through you |
kode_audio_stream_open(rate_hz, channels) | no | Opens a stream; only KODE_AUDIO_RATE_HZ, 1 channel or 2 (played as their mean). Holds the codec until closed, so every other sound and a second stream are refused |
kode_audio_stream_write(frames, n, ms, taken) | no | Writes frames; blocks only while the ring (KODE_AUDIO_STREAM_FRAMES, 200 ms) is full, up to ms; 0 takes what fits. A writer that falls behind is covered with silence. The opening task writes and closes |
kode_audio_stream_close() | no | Closes the stream and blocks until what it held has been heard |
API: motion
The haptic driver and the two motion sensors.
| Call | From an AO | What it does |
|---|---|---|
kode_haptic_play(ms, gain_pct) | yes | Posts one buzz of the LRA; ms 1 to KODE_HAPTIC_MAX_MS (2000), gain_pct 0 to 100, clamped by the driver at the LRA's rating (80 %). KODE_DENIED for a bad argument or while a buzz or the haptic row plays |
kode_imu_read(out) | no | One IMU sample; blocks up to KODE_IMU_READ_WAIT_MS (600). The IMU rests until asked, so a read that wakes it is answered about 200 ms later; reads inside KODE_IMU_REST_AFTER_MS (2000) answer at once. KODE_IO_ERROR when the part is absent |
kode_mag_read(mg) | no | One raw magnetometer sample in milligauss on the QMC5883P's own axes, no calibration; blocks up to KODE_MAG_READ_WAIT_MS (600). KODE_ERROR when no conversion was ready (the part converts at 50 Hz: ask again). Shares one claim with kode_imu_read() |
A board without the Antenna PCB has neither chip. Both owners still start and answer.
API: NFC
The ST25R3916B on the Antenna PCB; the field is up only while a call waits. Every kode_nfc_* function blocks, never from an Active Object, shares one buffer and one claim, and gives KODE_DENIED to a second caller.
A board with no reader answers KODE_DENIED for the read and NDEF calls, KODE_ERROR for kode_nfc_carrier() and KODE_IO_ERROR for kode_nfc_field_read().
| Call | From an AO | What it does |
|---|---|---|
kode_nfc_read(out, ms) | no | Waits up to ms for a tag and copies its identity. KODE_TIMEOUT when nothing was on the antenna |
kode_nfc_ndef_read(out, cap, n, ms) | no | Waits for a tag and copies its NDEF message as text. KODE_DENIED for a tag type it cannot read (Mifare Classic); KODE_NO_RESOURCES when it does not fit cap |
kode_nfc_ndef_write_uri(uri, ms) | no | Writes an NDEF URI record, at most KODE_NFC_TEXT_MAX (96) bytes. The size is checked against the tag before the first byte, so KODE_NO_RESOURCES means the tag is untouched |
kode_nfc_ndef_write_text(text, ms) | no | The same with a Text record |
kode_nfc_field_read(out, ms) | no | Listens with the reader's own field off and describes what reached the antenna, ms 1 to 200 |
kode_nfc_emulate_start(card) | no | Presents the Kode Dot as a passive NFC-A card until stopped |
kode_nfc_emulate_stop(reads) | no | Stops it; reads counts the Type-2 READs served. KODE_DENIED when nothing was emulated |
kode_nfc_carrier(mode) | no | Holds or drops a maximum-drive 13.56 MHz field (0 off, 1 bare, 2 ASK) for the FCC bench. Real only in a build with KODEOS_NFC_CERT; otherwise a stub that refuses |
API: RFID
The 125 kHz front end on the Antenna PCB. The same contract as NFC: blocking, never from an Active Object, one caller at a time. A board with no front end is not detected: a read times out like an empty window.
| Call | From an AO | What it does |
|---|---|---|
kode_rfid_read(id, rf_div, ms) | no | Energises for up to ms and copies the first EM4100 ID decoded. KODE_TIMEOUT also when AO_Rfid turned the read away because its front end was busy |
kode_rfid_clone(id, ms) | no | Clones an ID onto a T5577 presented within ms, verified by read-back, so KODE_OK is the whole round trip |
kode_rfid_field_read(out, wave, cap, n, ms) | no | Reads the demod line with the carrier off for ms (1 to 150) and describes it; wave may be NULL |
kode_rfid_carrier(mode) | no | Holds or drops a maximum-drive 125 kHz carrier (0 off, 1 bare, 2 OOK) for the FCC bench; needs KODEOS_NFC_CERT. KODE_OK means commanded and configured, not that RF radiated |
API: the store and files
The store is a handful of strings the board remembers under keys you choose, which survive a reset and the switch to kodeOS. An operator provisions them with nvs_set key="pat" value="..." at the console.
Files are the door to AO_Storage: the seven calls share one claim and one semaphore, so only one may be in flight, and each waits 5 s for one answer from AO_Storage. A path is absolute and virtual: /sdcard/... or /storage/....
| Call | From an AO | What it does |
|---|---|---|
kode_nvs_set(key, value) | yes, not from a worker job | Stores value under key (at most KODE_NVS_KEY_MAX 16 and KODE_NVS_VALUE_MAX 192). Refused whole, never shortened |
kode_nvs_get(key, out, cap) | yes, not from a worker job | Reads it back. KODE_DENIED means nothing was ever stored: the board is not provisioned, not a fault |
kode_nvs_del(key) | yes, not from a worker job | Forgets key; a missing key is success |
kode_fs_stat(path, out) | no | What the path is. KODE_OK with exists false is an answer; read rc for a refusal |
kode_fs_list(path, cb, ctx) | no | One directory, one level, at most KODE_FS_LIST_MAX (24) entries offered to cb. KODE_NO_RESOURCES after the callbacks means there were more |
kode_fs_mkdir(path) | no | One directory; the parent must exist (KODE_EXISTS, KODE_NOT_DIR) |
kode_fs_remove(path) | no | A file, or an empty directory |
kode_fs_rename(from, to) | no | Rename or move, same volume only |
kode_fs_read(path, buf, cap, got) | no | The whole file, at most KODE_FS_READ_MAX (4096) bytes. KODE_NO_RESOURCES when it is larger |
kode_fs_write(path, data, len, append) | no | Puts data in the file, at most KODE_FS_READ_MAX. What the write accepts is what the read hands back |
kode_sd_lend(sectors) | no | Takes the microSD from AO_Storage, its volume unmounted, and reports the sector count |
kode_sd_return() | no | Gives it back and mounts the volume again; also mounts a card never lent |
kode_sd_read_sectors(first, count, dst) | no | Reads whole 512-byte sectors of the lent card; call from one task |
kode_sd_write_sectors(first, count, src) | no | Writes them; KODE_ERROR includes writing past the end |
kode_flash(src, dst, run) | yes | Posts a write of an image: src is fs:<file>, sd:<file>, a bare name or an http:// or https:// URL of up to 256 characters; dst is os or app; run boots it once verified (ignored for os). KODE_DENIED when the firmware has no AO_AppMgr, KODE_ERROR when src is longer |
API: the network
Needs a joined Wi-Fi network (wifi_join on the console, or one the board remembers). lwIP runs on the P4, so none of this queues behind the C5's one sequential channel. Without KODEOS_NET every call still links and answers KODE_DENIED.
The first four block for as long as the network takes, so call them from app_main() or a task of your own. MQTT works the other way: those calls post to AO_Net and return at once.
| Call | From an AO | What it does |
|---|---|---|
kode_net_lookup(host, ip4) | no | Resolves host to an IPv4 address in network byte order. KODE_ERROR when it does not resolve |
kode_net_ping(host, count, stats) | no | One echo per second; KODE_OK when the session ran, even if nothing answered (stats->rx says) |
kode_http_get(url, buf, cap, out) | no | GET into buf. https:// verifies against the Mozilla certificate bundle. A 404 is KODE_OK: out->status carries what the server said, out->truncated says the body did not fit |
kode_udp_send(host, port, data, len) | no | Sends one datagram; KODE_OK means it is on the wire, not that it arrived |
kode_mqtt_connect(uri, user, pass) | yes | Asks AO_Net to connect; the connection is asynchronous. KODE_DENIED without KODEOS_NET |
kode_mqtt_disconnect() | yes | Asks AO_Net to stop the client |
kode_mqtt_publish(topic, data, len) | yes | Publishes data to topic; a payload over KODE_MQTT_PAYLOAD_MAX (240) bytes is cut. Accepted, not delivered |
kode_mqtt_subscribe(topic) | yes | Subscribes at QoS 0. Messages arrive as MQTT_MSG_SIG, so receiving takes an Active Object of your own; there is no polling accessor |
kode_mqtt_unsubscribe(topic) | yes | Stops receiving topic |
kode_mqtt_connected() | yes | Whether the client is connected right now |
The Kode channel holds the one MQTT client in a build with KODEOS_CHANNEL, so an app that uses kode_mqtt_* sets CONFIG_KODEOS_CHANNEL=n.
API: power and going home
These post to AO_LowPower, AO_Power and AO_Boot and return void, because the outcome is asynchronous. Subscribe to POWER_CTRL_SIG for the answer of the charger calls and POWER_EVT_SIG for what the chip does afterwards. Neither kode_power_sleep() nor kode_power_off() reaches the battery's ship mode: that is the console verb ship_mode, and the facade has no call for it (internals/power-and-sleep).
| Call | From an AO | What it does |
|---|---|---|
kode_power_sleep() | yes | Asks AO_LowPower for ASLEEP: the screen dimmed and showing the time, every owner at rest, the P4 and the C5 in light sleep, the radios kept up. Returns at once. The answer is LOWPOWER_STATE_SIG, or LOWPOWER_REFUSED_SIG when the Kode Dot will not (kode_ao.h brings the signals). Refused while a cable is in (cable: external power) and while an owner is busy; the log says who and why. BOT, a message on BLE or the Kode channel, or a cable wakes it, and after off_after it goes on to OFF by itself. Without CONFIG_KODE_LOWPOWER it logs low power not built and does nothing |
kode_power_off() | yes | Asks AO_LowPower for OFF: the sessions close, the C5 is held in reset and the P4 enters deep sleep. Only BOT held for about a second wakes it, and the boot that follows is a normal one. It is what the Turn off card of kodeOS does. It is an order, not a request: an owner that is busy is given up to KODE_LOWPOWER_ORDER_MS (10 s) to finish, and then the board goes OFF all the same (sdk/kaos/lowpower). Only a cable refuses it, and it logs low power not built in a build without the option |
kode_power_boost(on) | yes | 5 V from the battery on the expansion header, 0.5 A. Refused while USB is present and while the cell is under the pack's floor; stopped on a low cell or a fault. A reboot is off |
kode_power_charge(on) | yes | Allows or forbids charging; AO_Power also holds it outside the pack's 10 to 45 C window |
kode_power_charge_limit(ma) | yes | The fast-charge current, 64 mA up to the pack's ceiling, rounded to a 64 mA step; above the ceiling nothing is written |
kode_exit_to_os() | no | Posts BOOT_EXIT_REQ_SIG and loops in a delay; the reset usually comes before the first pass |
kode_home_defer() | yes | Asks AO_Boot for a moment before a held bottom button restarts into kodeOS. Only inside the handling of that hold, within KODE_HOME_GRACE_MS (200). KODE_DENIED when no hold is pending |
kode_home_now() | yes | Goes home now and returns. The deferral is capped at KODE_HOME_DEFER_MAX_MS (3000) from the hold |
With CONFIG_KODEOS_HOME_HOLD (on by default in an app), holding the bottom button publishes HOME_SIG and restarts into kodeOS after the grace, whatever the firmware is doing. An app with something to save defers from its HOME_SIG or BUTTON_HOLD_SIG handler, saves, then calls kode_home_now().
API: the expansion header
J3 is yours: 14 positions, numbered 1 to 14 in the connector's order (kbsp_exp.h says what each can be). The first call claims the header for the app and every call after it acts on it.
KODE_EXISTS means something in this image already owns it (the console, after one exp pos=... mode=... typed on the wire) and no later call will succeed in this boot.
The header carries no identification, so the firmware that drives a module takes the module's positions with kode_exp_lend() and every other call on them is refused. Nothing here blocks, and every call is khal_exp's of the same name.
| Call | From an AO | What it does |
|---|---|---|
kode_exp_input(pos, pull) | yes | A digital input, with or without a pull |
kode_exp_output(pos, level) | yes | An output driven to level |
kode_exp_write(pos, level) | yes | Drives a position that is already an output |
kode_exp_level(pos) | yes | The level of an input or output; false for any other |
kode_exp_adc(pos, raw, mv, calibrated) | yes | Reads an analog position, configuring it on the first call |
kode_exp_pwm(pos, hz, duty_x10) | yes | A PWM output, or retunes one |
kode_exp_irq(pos, kind, cb, arg) | yes | Attaches an interrupt to an input. The callback may post a static event with kqp_post_from_isr() or give a semaphore, nothing else |
kode_exp_irq_enable(pos) | yes | Re-enables the interrupt once served |
kode_exp_release(pos) | yes | Back to free and high-Z |
kode_exp_lend(pos, n, tag, gpio) | yes | Takes positions for a peripheral you drive with ESP-IDF yourself, under a tag |
kode_exp_return(tag) | yes | Gives back every position and controller lent under the tag |
kode_exp_ctrl(bus, tag, id) | yes | Takes an I2C, SPI or UART controller for a module and returns IDF's number. An app may take I2C1, SPI3 (SPI3_HOST, 2) and UART0 to 4 |
kode_exp_power(on) | yes | Switches J3's +3.3V_EXT, off until you switch it on, from the C5's IO5. Needs KODEOS_RADIO; a Wi-Fi scan can delay it by about 40 s. Claims nothing |
A UART on lent pins loses bytes while flash is written unless the image sets CONFIG_UART_ISR_IN_IRAM: a sector erase takes tens of milliseconds and the 128-byte FIFO fills in about 11 ms at 115200 baud.
API: workers and the console's tables
The worker calls, and the three calls that add rows to the console's tables.
| Call | From an AO | What it does |
|---|---|---|
kode_worker_start(me, ao, name, stack_bytes, prio, depth) | yes | Brings up a worker for ao, stack in PSRAM, once. KODE_DENIED for a NULL argument; KODE_NO_RESOURCES when the task cannot be had |
kode_worker_submit(me, fn, arg, done_sig, seq_out) | yes | Queues a job and never blocks; the result returns as KqpWorkerDoneEvt (sdk/kqp). arg stays valid until the done event |
kode_variables_add(table, n) and KODE_VARIABLES_ADD(t) | after kode_init() | Registers read-only state for variable name="<row>" [watch=<ms>] on the console. Up to 32 rows in 4 tables, watch 100 to 60000 ms |
kode_functions_add(table, n) and KODE_FUNCTIONS_ADD(t) | after kode_init() | Registers what the app does for function name="<row>" [arg=]. Up to 64 rows in 4 tables |
kode_config_add(table, n) and KODE_CONFIG_ADD(t) | after kode_init() | Registers stored settings for config. Up to 32 in 4 tables; the owner answers with kode_config_answer() |
A job's stack lives in PSRAM, so a job calls nothing that reaches the internal flash: ESP-IDF's flash, partition and NVS functions, and kode_nvs_*, which refuse there with KODE_DENIED and one error line.
A registration is refused whole, with an error line that names the entry:
KODE_EXISTSfor a name already taken.KODE_NO_RESOURCESpast the limits.KODE_DENIEDfor any other rule, and always in a build without the console.
Every callback runs in AO_Console's task: return promptly and never block.
The reset words
Line 4 of the banner takes its words from a closed table. A number not in it reads unknown.
| Reason | Words |
|---|---|
KDRV_RESET_UNKNOWN | unknown |
KDRV_RESET_POWERON | power-on or BOT+LEFT |
KDRV_RESET_EXTERNAL | external reset |
KDRV_RESET_SOFTWARE | software restart |
KDRV_RESET_PANIC | panic |
KDRV_RESET_INT_WDT | interrupt watchdog |
KDRV_RESET_TASK_WDT | task watchdog |
KDRV_RESET_WDT | other watchdog |
KDRV_RESET_DEEPSLEEP | deep-sleep wake |
KDRV_RESET_BROWNOUT | brownout |
KDRV_RESET_SDIO | SDIO reset |
KDRV_RESET_USB | USB port opened |
KDRV_RESET_JTAG | JTAG reset |
KDRV_RESET_EFUSE | eFuse error |
KDRV_RESET_PWR_GLITCH | power glitch |
KDRV_RESET_CPU_LOCKUP | CPU lockup |
Kconfig options
Write CONFIG_<NAME>=y or =n in your project's sdkconfig.defaults. ESP-IDF links what it compiles, so without these options a firmware that uses kaos would get the console, the app manager and the radio whether it wanted them or not.
One rule runs through them: a firmware always has the owners of its devices and can leave out every policy. Device owners are never optional, because an unowned device is the failure the one-owner rule prevents.
A new option is not picked up by idf.py build: the configuration only regenerates on a CMake configure, so a default y compiles as unset and its code vanishes behind its #if. Run idf.py reconfigure and check with grep CONFIG_<NAME> sdkconfig.
A cached value in the generated sdkconfig beats a new default, so a changed board line needs that file deleted. Code reads these with #if, never a ternary: an unset Kconfig bool is undefined, not 0.
The kaos options live under the menu "kodeOS active objects" (components/kaos/Kconfig). They come in five groups, one table each. The first group holds the boot role and the policy owners.
| Option | Default | Depends on or selects | What it gates |
|---|---|---|---|
KODEOS_BOOT_ROLE_APP | the default of the choice | AO_Boot never writes otadata or the boot request, so the next reset returns to kodeOS, a crash on the first line included | |
KODEOS_BOOT_ROLE_OS | AO_Boot clears otadata at startup, reports why the boot happened and can hand the machine to a slot. A firmware that forgets to say what it is behaves like an app | ||
KODEOS_HOME_HOLD | y | depends on the app role | Holding BOT for 600 ms returns to kodeOS, so no app can trap the user. The grace is KODE_HOME_GRACE_MS |
KODEOS_AUTOTEST | n | selected by KODEOS_CONSOLE | AO_Autotest, the self-test rows and the walk over them. It owns no device |
KODEOS_CONSOLE | y | selects KODEOS_APPMGR and KODEOS_AUTOTEST | AO_Console, the command table. AO_Log still owns the console device without it, so the log works and nothing parses what you type |
KODEOS_APPMGR | y | AO_AppMgr, which writes images into the apps region and the OTA slots. An app almost never needs it | |
KODEOS_SCREENSHOT | y | screenshot: a copy of the panel's front framebuffer as a 402 KB BMP in PSRAM, held only while it is sent. Exact for a firmware that draws by hand as for LVGL. Off, the verb says it is unavailable | |
KODEOS_MIRROR | y | depends on KODEOS_SCREENSHOT | The JPEG mirror of the panel and its four verbs mirror_start, mirror_stop, mirror_ack, mirror_info; about 530 KB of PSRAM that the board no longer holds from boot when it is off. It does not gate touch, tap, swipe and btn injection |
KODEOS_NFC | y | AO_Nfc, the owner of the ST25R3916B. Off, the chip, SPI2 and KBSP_RES_NFC are the app's: nothing starts or parks the reader, and a call to kode_nfc_*() fails to link | |
KODEOS_NFC_CERT | n | depends on KODEOS_NFC | The held maximum-drive carriers of the NFC (13.56 MHz) and RFID (125 kHz) owners, for the FCC bench. Off, kode_nfc_carrier() and kode_rfid_carrier() link stubs that refuse |
KODEOS_GUI | declared by kgui, not the SDK | Read once by the console's ps row Gui. An app built on the SDK alone has no such option |
Kconfig: radio, Wi-Fi and Bluetooth
The ESP32-C5 link and what runs over it.
| Option | Default | Depends on or selects | What it gates |
|---|---|---|---|
KODEOS_RADIO | y | selects ESP_HOSTED_ENABLED | AO_Radio, the ESP32-C5 link over SDIO. Without it the C5 still boots and runs, but there is no IR, and the speaker amplifier works only if the C5's own firmware enabled it. The microSD does not need it |
KODEOS_WIFI | y | depends on KODEOS_RADIO | The remote Wi-Fi stack and the wifi_* verbs |
KODEOS_WIFI_DEFAULT_ON | n | depends on KODEOS_WIFI | What the stored wifi flag is on a board whose store was never written |
KODEOS_WIFI_AUTOJOIN | y | depends on KODEOS_WIFI | Joins the strongest remembered network when the link comes up. It is the default of the stored wifi_join, and it does not turn Wi-Fi on |
KODEOS_BLE | y | depends on KODEOS_RADIO and BT_NIMBLE_ENABLED | BLE: NimBLE on the P4, the controller on the C5, HCI over the same SDIO link. The board file sets BT_NIMBLE_ENABLED; the option is absent rather than broken in a firmware that did not |
KODEOS_BLE_DEFAULT_ON | y | depends on KODEOS_BLE | The same for the stored ble flag: BLE advertises from boot until the user switches it off |
KODEOS_BLE_AT_BOOT | n | depends on KODEOS_BLE | BLE on from every boot, ignoring the store. For a bench or lab build, not a shipped kodeOS |
KODEOS_C5_HOLD | n | depends on KODEOS_RADIO | Holds the C5 in reset while neither Wi-Fi nor BLE is wanted. A C5 in reset draws nothing and the amplifier shuts down with it; the first sound of a boot waits KODE_C5_BOOT_MS (600) |
KODEOS_BLE_CONSOLE | y | depends on KODEOS_BLE and KODEOS_CONSOLE | The console GATT service: the same verbs and grammar as the USB-C cable |
KODEOS_BLE_CONSOLE_PAIRING | y | depends on KODEOS_BLE_CONSOLE | LE Secure Connections, bonded. Off, the service is open to anyone in range. Needs CONFIG_BT_NIMBLE_SM_SC_ONLY=0, which is an int and not a bool: written as =y it fails the type check so quietly that it looks as if it worked |
KODEOS_BLE_PAIRING_PASSKEY | y | depends on KODEOS_BLE_CONSOLE_PAIRING | Passkey entry with the board showing six digits on the glass in kodeOS; in an app a new pairing is refused. The digits never reach a log or a console verb |
Kconfig: network and the Kode channel
AO_Net and what it keeps up.
| Option | Default | Depends on or selects | What it gates |
|---|---|---|---|
KODEOS_NET | y | depends on KODEOS_WIFI | AO_Net: ping, HTTP(S), UDP and MQTT on the P4's own lwIP |
KODEOS_MQTT | y | depends on KODEOS_NET | The MQTT client, owned by AO_Net. The component downloads either way; this gates compiling and linking it |
KODEOS_CHANNEL | y | depends on KODEOS_MQTT and KODEOS_CONSOLE | The Kode channel: AO_Net signs in through the C5 and keeps one MQTT session up. The stored chan setting (default on) switches it. It holds the one MQTT client |
KODEOS_CHANNEL_RENEW_S | 82800 (60 to 82800) | depends on KODEOS_CHANNEL | Seconds from a connect to the renewal; the token lives 24 h. Shorten only on a bench |
KODEOS_SHOT_HTTP | y | depends on KODEOS_CHANNEL and KODEOS_MIRROR | Uploads the glass as one JPEG to the channel's shots link |
Kconfig: rest and schedule
The two Active Objects that decide and own no device. Both are off in the SDK and on in kodeOS, and the two are independent.
| Option | Default | Depends on or selects | What it gates |
|---|---|---|---|
KODE_LOWPOWER | n in the SDK, y in kodeOS | depends on KODEOS_RADIO; selects PM_ENABLE, FREERTOS_USE_TICKLESS_IDLE and PM_LIGHT_SLEEP_CALLBACKS | AO_LowPower and the states AWAKE, ASLEEP and OFF (sdk/kaos/lowpower), the sleep sentinel of the radio, the rest of every owner, kode_boot_gate(), the settings sleep_after and off_after. Off, kode_power_sleep() and kode_power_off() log low power not built, sleep and power_off answer not_built, and lowpower_info does not exist |
KODE_SCHEDULER | n in the SDK, y in kodeOS | AO_Scheduler, the table of cron entries and the verb cron (sdk/kaos/scheduler). It takes the level of KODE_PRIO_USER_HIGH, which is then not defined. Off, the calls of kaos.h refuse and cron answers not_built |
A new Kconfig option is not picked up by a plain idf.py build. After adding or changing either one, run idf.py reconfigure and read grep CONFIG_KODE_LOWPOWER sdkconfig, because a build that is suspiciously clean may have compiled the option as unset. Light sleep also needs three lines in sdkconfig.defaults, which kodeOS carries: CONFIG_PM_POWER_DOWN_CPU_IN_LIGHT_SLEEP=n, CONFIG_ESP_SLEEP_GPIO_RESET_WORKAROUND=n and CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION=y.
Kconfig: the other components
The other components have menus of their own.
| Option | Default | Depends on | What it gates |
|---|---|---|---|
KLOG_MAXIMUM_LEVEL_* | debug | a choice | The most verbose klog level compiled in: debug, info, notice, warn, error or none. A line below it is not in the firmware, and log_level cannot bring it back. kodeOS keeps notice or more, because the factory reads the banner |
KLOG_DEFAULT_LEVEL_* | info | a choice, never more verbose than the ceiling | The level the console starts at, until log_level stores another: debug, info, notice, warn, error or off |
KQP_AO_WATCH | y | The Active Object watchdog (sdk/kqp) | |
KQP_AO_STEP_MAX_MS | 5000 (100 to 10000) | KQP_AO_WATCH | The longest step before the warning |
KHAL_CONSOLE_DOCK | n | the ESP32-P4 target | A second console on the magnetic dock, USB CDC on GPIO26 and 27. Off in the SDK, because it takes the OTG controller an app may want. Pulls in esp_tinyusb 2.2.0, so the first configure of a clean tree reports the TinyUSB symbols as unknown |
KHAL_CONSOLE_DOCK_TX_WAIT_MS | 20 (0 to 200) | KHAL_CONSOLE_DOCK | How long a write waits for the dock host to drain before it drops the rest |
KSCREEN_ROTATE_PAD | y | When an app turns the glass, the four pad buttons publish the direction they point on the turned glass (sdk/kscreen) |
The settings the board file fixes (flash size, the partition table at 0x8000, HEX PSRAM at 250 MHz, the 128 KB L2 cache, the SDIO wiring, the NimBLE lines, the LVGL block) live in components/kbsp/sdkconfig.board (sdk/kbsp) and every firmware reads it first.
Two lines an app spells out itself: CONFIG_PARTITION_TABLE_CUSTOM_FILENAME, because ESP-IDF resolves it against the project, and set(IDF_TARGET esp32p4) before project(). This is what most examples write:
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="managed_components/kodediy__kbsp/partitions.csv"
CONFIG_KODEOS_BOOT_ROLE_APP=y
CONFIG_KODEOS_CONSOLE=y
CONFIG_KODEOS_SCREENSHOT=n
CONFIG_KODEOS_RADIO=n
CONFIG_ESP_HOSTED_ENABLED=n
CONFIG_BT_ENABLED=nTo have no radio, set both KODEOS_RADIO=n and ESP_HOSTED_ENABLED=n. KODEOS_RADIO selects the second, and kdrv depends on esp_wifi_remote, whose presence makes ESP_HOSTED_ENABLED default to y: the component then installs a constructor that claims the SDMMC controller before app_main.
Kconfig cannot express "deselect", so a firmware that wants no radio says the opposite out loud. The BT_ENABLED=n line drops the NimBLE host the board file switches on.
Gotchas
Example code
Poll the buttons from a loop. The LED shows green while TOP is held and red while the pad is held, and the bottom button hands the Kode Dot back to kodeOS.
/*
* led_follow.c - 2026-10-28 - from Kode with love
*
* Polls the buttons from the main task. The LED turns green while TOP is
* held, red while any pad button is held, and goes dark otherwise. BOT hands
* the Kode Dot back to kodeOS.
*/
#include "kode.h"
/* The bit one button has in the mask kode_buttons_read() returns. */
#define BIT_OF(button) (1U << (button))
#define PAD_MASK (BIT_OF(KDRV_BTN_UP) | BIT_OF(KDRV_BTN_DOWN) | \
BIT_OF(KDRV_BTN_LEFT) | BIT_OF(KDRV_BTN_RIGHT))
/* Bring the system up, then follow the buttons forever. */
void app_main(void)
{
kode_init();
while (1) {
uint32_t const down = kode_buttons_read();
if (down & BIT_OF(KDRV_BTN_BOT)) {
kode_exit_to_os();
}
if (down & BIT_OF(KDRV_BTN_TOP)) {
kode_led_rgb(0, 255, 0);
} else if (down & PAD_MASK) {
kode_led_rgb(255, 0, 0);
} else {
kode_led_rgb(0, 0, 0);
}
kode_delay_ms(20);
}
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/kode: the facade, withsrc/kode.choldingkode_init()and every call.kode.h: the contract of every function, status by status.components/kaos/Kconfig: the build options, with their reasons.