# 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.h` is the small one: start the system, ask an owner for something, go home. It adds no QP/C names to your code.
- `kode_ao.h` is `kode.h` plus `kqp.h` and `kaos.h`, for an app that writes Active Objects of its own (`sdk/kqp`).
- `kode_worker.h` gives an Active Object a worker for the calls that wait.

## How it is connected

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

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

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

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

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.

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

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.

| 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_EXISTS` for a name already taken.
- `KODE_NO_RESOURCES` past the limits.
- `KODE_DENIED` for 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:

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

> **WARNING**
>
> **A blocking call inside an Active Object stalls everything below it.** `kode_net_ping()`, `kode_http_get()`, `kode_imu_read()`, the NFC and RFID reads, the file calls and `kode_delay_ms()` all sleep in the task that calls them. Inside a state handler that parks the whole Active Object, and after 30 s the watchdog panics the board (`sdk/kqp`). Call them from `app_main()` or a task of your own, or run them as a job on a worker and take the result as an event.

> **WARNING**
>
> **`KODE_DENIED` is a refusal to try, not a verdict.** A second caller of a blocking family, a call that follows one that timed out and is still owed an answer, a malformed path, or AO_Storage busy with a `cat`, a `cp` or an install all say it. Wait a moment and ask again. A task blocked in one of these calls must never be deleted: its claim is never released and every later call answers `KODE_DENIED` for the rest of the boot.

> **NOTE**
>
> **A posting call returns before anything happens.** `KODE_OK` from `kode_led_rgb()`, `kode_audio_tone()`, `kode_ir_send_nec()` or `kode_mqtt_publish()` means the request is queued. A codec in use, a dry pool inside the owner or a broker that never answers shows up in the log or as a later event. For an answer, subscribe: `POWER_CTRL_SIG`, `MQTT_STATE_SIG`, `AUDIO_DONE_SIG` and the others in `sdk/kqp`.

> **NOTE**
>
> **One touch of J3 claims all fourteen positions until the next boot.** Nothing releases the expansion header's resource, and `KODE_EXISTS` is permanent for that boot, so do not retry it. J3's `+3.3V_EXT` supply is off at the start of every app: call `kode_exp_power(true)` before you talk to a module, and wait for the module to answer.

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

```c
/*
 * 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`](https://github.com/kodediy/kode-sdk/tree/main/components/kode): the facade, with `src/kode.c` holding `kode_init()` and every call.
- [`kode.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kode/include/kode.h): the contract of every function, status by status.
- [`components/kaos/Kconfig`](https://github.com/kodediy/kode-sdk/blob/main/components/kaos/Kconfig): the build options, with their reasons.
