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:

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
A call from your code to a 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:

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
A blocking call, step by step

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:

LineReadsFrom
1<project> <version> (<date> <time>, ESP-IDF <version>), <role>; the role is the OS, from <partition> or an appThe image descriptor, kdrv_running_desc()
2sdk <version>KODE_SDK_VERSION, written by the build
3hardware <product> <revision>, <chip> v<major>.<minor>, <n> MB flash, <n> MB PSRAMkbsp for the names, khal_chip_info() for the chip and sizes
4reset <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:

  1. git describe --tags --dirty --always in the SDK's work tree (when the SDK is a submodule).
  2. The version the component manager recorded for kodediy/kode in dependencies.lock (when the SDK was fetched).
  3. 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.

FileWhat it does
include/kode.hThe facade: every function below, the constants an app sizes arrays against, and KODE_VERSION_MAJOR 3, KODE_VERSION_MINOR 0
include/kode_ao.hkode.h plus kqp.h and kaos.h. Declares no function of its own
include/kode_worker.hkode_worker_t, kode_worker_start() and kode_worker_submit(), inline over kqp_worker_*
src/kode.ckode_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.hThe four banner lines; the header is private
src/kode_banner_text.cThe closed table of reset words and the reset line, pure text the host tests compile alone
version.cmakeWrites kode_sdk_version.h on every build
README.md, CMakeLists.txt, idf_component.ymlThe 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:

  1. It runs kode_log_init(), then with CONFIG_KODE_LOWPOWER kode_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). Then khal_heap_watch(), khal_identity_init(), khal_pwm_init(), khal_exp_init() and kqp_init().
  2. It starts the Active Objects in the order of the table below, and prints the banner right after AO_Log starts.
  3. It ends with kaos_config_add(), log_start_reader(), sched_system_up() when the scheduler is built, and QF_run(), which returns at once because the scheduler already runs.

Your app_main() goes on as an ordinary task beside them.

OrderActive ObjectPriorityQueueStackGated byWhy it sits here
1AO_Log6644096alwaysEverything logs through it
2AO_Boot1284096always
3AO_LowPower24483072KODE_LOWPOWERThird, so every owner after it can join the rounds of rest in its own initial transition
4AO_Scheduler16243072KODE_SCHEDULERAfter AO_LowPower, which it joins, and before AO_Console. It posts to nobody until sched_system_up()
5AO_Storage5166144alwaysBringing the C5 link up walks the card by posting to it
6AO_Radio486144KODEOS_RADIO
7AO_Net2164096KODEOS_NETIts initial transition asks AO_Radio for the Wi-Fi state
8AO_LedMgr7163072always
9AO_Ir18163072alwaysSelf-contained
10AO_Nfc19166144alwaysSelf-contained
11AO_Rfid23163072alwaysSelf-contained
12AO_Audio20163072alwaysSelf-contained
13AO_Imu21164096alwaysAO_Haptic posts to it from its first step
14AO_Haptic22163072always
15AO_AppMgr9168192KODEOS_APPMGR
16AO_Autotest10164096KODEOS_AUTOTESTBefore the console, its first client
17AO_Console11648192KODEOS_CONSOLE
18AO_Power884096always
19AO_IoExp17164096alwaysButtons, touch and display post to it or subscribe from their initial transitions
20AO_Buttons13163072always
21AO_Touch14164096always
22AO_Display15166144alwaysLast: 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

CallFrom an AOWhat 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)noSleeps and yields the CPU
kode_time_ms()yesMilliseconds since boot; wraps after about 49 days
kode_device_id()yesThe 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.

CallFrom an AOWhat it does
kode_led_set(on)yesTurns the LED on (last colour, or white) or off
kode_led_toggle()yesInverts it
kode_led_rgb(r, g, b)yesSets 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.

CallFrom an AOWhat it does
kode_buttons_read()yesThe 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.

CallFrom an AOWhat it does
kode_ir_send_nec(addr, cmd)yesPosts 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)noSends 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)noWaits 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)noThe 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.

CallFrom an AOWhat it does
kode_audio_tone(hz, ms, amp_pct)yesPosts one tone; hz snaps to the nearest detector bin and is refused below 400 Hz; amp_pct 1 to 100
kode_audio_volume(pct)yesPosts 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)yesPosts the microphone gain, 0 to 42, rounded down to a 6 dB step; KODE_DENIED above 42
kode_audio_info(out)noWhat the path is and is set to; one round trip, safe while a sound plays
kode_audio_play(frames, n)noPlays 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)noRecords ms (up to KODE_AUDIO_BUF_SECONDS) and blocks until done
kode_audio_record_file(path, ms, bytes)noRecords 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)noOpens 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)noWrites 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()noCloses the stream and blocks until what it held has been heard

API: motion

The haptic driver and the two motion sensors.

CallFrom an AOWhat it does
kode_haptic_play(ms, gain_pct)yesPosts 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)noOne 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)noOne 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().

CallFrom an AOWhat it does
kode_nfc_read(out, ms)noWaits 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)noWaits 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)noWrites 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)noThe same with a Text record
kode_nfc_field_read(out, ms)noListens with the reader's own field off and describes what reached the antenna, ms 1 to 200
kode_nfc_emulate_start(card)noPresents the Kode Dot as a passive NFC-A card until stopped
kode_nfc_emulate_stop(reads)noStops it; reads counts the Type-2 READs served. KODE_DENIED when nothing was emulated
kode_nfc_carrier(mode)noHolds 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.

CallFrom an AOWhat it does
kode_rfid_read(id, rf_div, ms)noEnergises 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)noClones 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)noReads the demod line with the carrier off for ms (1 to 150) and describes it; wave may be NULL
kode_rfid_carrier(mode)noHolds 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/....

CallFrom an AOWhat it does
kode_nvs_set(key, value)yes, not from a worker jobStores 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 jobReads 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 jobForgets key; a missing key is success
kode_fs_stat(path, out)noWhat the path is. KODE_OK with exists false is an answer; read rc for a refusal
kode_fs_list(path, cb, ctx)noOne 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)noOne directory; the parent must exist (KODE_EXISTS, KODE_NOT_DIR)
kode_fs_remove(path)noA file, or an empty directory
kode_fs_rename(from, to)noRename or move, same volume only
kode_fs_read(path, buf, cap, got)noThe whole file, at most KODE_FS_READ_MAX (4096) bytes. KODE_NO_RESOURCES when it is larger
kode_fs_write(path, data, len, append)noPuts data in the file, at most KODE_FS_READ_MAX. What the write accepts is what the read hands back
kode_sd_lend(sectors)noTakes the microSD from AO_Storage, its volume unmounted, and reports the sector count
kode_sd_return()noGives it back and mounts the volume again; also mounts a card never lent
kode_sd_read_sectors(first, count, dst)noReads whole 512-byte sectors of the lent card; call from one task
kode_sd_write_sectors(first, count, src)noWrites them; KODE_ERROR includes writing past the end
kode_flash(src, dst, run)yesPosts 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.

CallFrom an AOWhat it does
kode_net_lookup(host, ip4)noResolves host to an IPv4 address in network byte order. KODE_ERROR when it does not resolve
kode_net_ping(host, count, stats)noOne echo per second; KODE_OK when the session ran, even if nothing answered (stats->rx says)
kode_http_get(url, buf, cap, out)noGET 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)noSends one datagram; KODE_OK means it is on the wire, not that it arrived
kode_mqtt_connect(uri, user, pass)yesAsks AO_Net to connect; the connection is asynchronous. KODE_DENIED without KODEOS_NET
kode_mqtt_disconnect()yesAsks AO_Net to stop the client
kode_mqtt_publish(topic, data, len)yesPublishes data to topic; a payload over KODE_MQTT_PAYLOAD_MAX (240) bytes is cut. Accepted, not delivered
kode_mqtt_subscribe(topic)yesSubscribes 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)yesStops receiving topic
kode_mqtt_connected()yesWhether 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).

CallFrom an AOWhat it does
kode_power_sleep()yesAsks 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()yesAsks 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)yes5 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)yesAllows or forbids charging; AO_Power also holds it outside the pack's 10 to 45 C window
kode_power_charge_limit(ma)yesThe 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()noPosts BOOT_EXIT_REQ_SIG and loops in a delay; the reset usually comes before the first pass
kode_home_defer()yesAsks 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()yesGoes 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.

CallFrom an AOWhat it does
kode_exp_input(pos, pull)yesA digital input, with or without a pull
kode_exp_output(pos, level)yesAn output driven to level
kode_exp_write(pos, level)yesDrives a position that is already an output
kode_exp_level(pos)yesThe level of an input or output; false for any other
kode_exp_adc(pos, raw, mv, calibrated)yesReads an analog position, configuring it on the first call
kode_exp_pwm(pos, hz, duty_x10)yesA PWM output, or retunes one
kode_exp_irq(pos, kind, cb, arg)yesAttaches 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)yesRe-enables the interrupt once served
kode_exp_release(pos)yesBack to free and high-Z
kode_exp_lend(pos, n, tag, gpio)yesTakes positions for a peripheral you drive with ESP-IDF yourself, under a tag
kode_exp_return(tag)yesGives back every position and controller lent under the tag
kode_exp_ctrl(bus, tag, id)yesTakes 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)yesSwitches 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.

CallFrom an AOWhat it does
kode_worker_start(me, ao, name, stack_bytes, prio, depth)yesBrings 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)yesQueues 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:

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.

ReasonWords
KDRV_RESET_UNKNOWNunknown
KDRV_RESET_POWERONpower-on or BOT+LEFT
KDRV_RESET_EXTERNALexternal reset
KDRV_RESET_SOFTWAREsoftware restart
KDRV_RESET_PANICpanic
KDRV_RESET_INT_WDTinterrupt watchdog
KDRV_RESET_TASK_WDTtask watchdog
KDRV_RESET_WDTother watchdog
KDRV_RESET_DEEPSLEEPdeep-sleep wake
KDRV_RESET_BROWNOUTbrownout
KDRV_RESET_SDIOSDIO reset
KDRV_RESET_USBUSB port opened
KDRV_RESET_JTAGJTAG reset
KDRV_RESET_EFUSEeFuse error
KDRV_RESET_PWR_GLITCHpower glitch
KDRV_RESET_CPU_LOCKUPCPU 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.

OptionDefaultDepends on or selectsWhat it gates
KODEOS_BOOT_ROLE_APPthe default of the choiceAO_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_OSAO_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_HOLDydepends on the app roleHolding BOT for 600 ms returns to kodeOS, so no app can trap the user. The grace is KODE_HOME_GRACE_MS
KODEOS_AUTOTESTnselected by KODEOS_CONSOLEAO_Autotest, the self-test rows and the walk over them. It owns no device
KODEOS_CONSOLEyselects KODEOS_APPMGR and KODEOS_AUTOTESTAO_Console, the command table. AO_Log still owns the console device without it, so the log works and nothing parses what you type
KODEOS_APPMGRyAO_AppMgr, which writes images into the apps region and the OTA slots. An app almost never needs it
KODEOS_SCREENSHOTyscreenshot: 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_MIRRORydepends on KODEOS_SCREENSHOTThe 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_NFCyAO_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_CERTndepends on KODEOS_NFCThe 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_GUIdeclared by kgui, not the SDKRead 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.

OptionDefaultDepends on or selectsWhat it gates
KODEOS_RADIOyselects ESP_HOSTED_ENABLEDAO_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_WIFIydepends on KODEOS_RADIOThe remote Wi-Fi stack and the wifi_* verbs
KODEOS_WIFI_DEFAULT_ONndepends on KODEOS_WIFIWhat the stored wifi flag is on a board whose store was never written
KODEOS_WIFI_AUTOJOINydepends on KODEOS_WIFIJoins 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_BLEydepends on KODEOS_RADIO and BT_NIMBLE_ENABLEDBLE: 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_ONydepends on KODEOS_BLEThe same for the stored ble flag: BLE advertises from boot until the user switches it off
KODEOS_BLE_AT_BOOTndepends on KODEOS_BLEBLE on from every boot, ignoring the store. For a bench or lab build, not a shipped kodeOS
KODEOS_C5_HOLDndepends on KODEOS_RADIOHolds 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_CONSOLEydepends on KODEOS_BLE and KODEOS_CONSOLEThe console GATT service: the same verbs and grammar as the USB-C cable
KODEOS_BLE_CONSOLE_PAIRINGydepends on KODEOS_BLE_CONSOLELE 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_PASSKEYydepends on KODEOS_BLE_CONSOLE_PAIRINGPasskey 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.

OptionDefaultDepends on or selectsWhat it gates
KODEOS_NETydepends on KODEOS_WIFIAO_Net: ping, HTTP(S), UDP and MQTT on the P4's own lwIP
KODEOS_MQTTydepends on KODEOS_NETThe MQTT client, owned by AO_Net. The component downloads either way; this gates compiling and linking it
KODEOS_CHANNELydepends on KODEOS_MQTT and KODEOS_CONSOLEThe 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_S82800 (60 to 82800)depends on KODEOS_CHANNELSeconds from a connect to the renewal; the token lives 24 h. Shorten only on a bench
KODEOS_SHOT_HTTPydepends on KODEOS_CHANNEL and KODEOS_MIRRORUploads 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.

OptionDefaultDepends on or selectsWhat it gates
KODE_LOWPOWERn in the SDK, y in kodeOSdepends on KODEOS_RADIO; selects PM_ENABLE, FREERTOS_USE_TICKLESS_IDLE and PM_LIGHT_SLEEP_CALLBACKSAO_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_SCHEDULERn in the SDK, y in kodeOSAO_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.

OptionDefaultDepends onWhat it gates
KLOG_MAXIMUM_LEVEL_*debuga choiceThe 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_*infoa choice, never more verbose than the ceilingThe level the console starts at, until log_level stores another: debug, info, notice, warn, error or off
KQP_AO_WATCHyThe Active Object watchdog (sdk/kqp)
KQP_AO_STEP_MAX_MS5000 (100 to 10000)KQP_AO_WATCHThe longest step before the warning
KHAL_CONSOLE_DOCKnthe ESP32-P4 targetA 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_MS20 (0 to 200)KHAL_CONSOLE_DOCKHow long a write waits for the dock host to drain before it drops the rest
KSCREEN_ROTATE_PADyWhen 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=n

To 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