SDK

kqp and qpc

How the Kode Dot's firmware runs QP/C: the event pools, the priority of every Active Object, the 1 ms tick, and the watchdog.

What it is

Every part of the Kode Dot's firmware that owns a device is an Active Object: a task with its own queue of events and a state machine that handles one event at a time. The framework that runs them is QP/C from Quantum Leaps, and the SDK holds it in two components. qpc is the framework itself, vendored, with a FreeRTOS port adapted for the two cores of the ESP32-P4. kqp is how this system uses it.

kqp does three jobs:

Only kqp includes qpc.h. Every other layer gets QP/C through kqp.h, and your own Active Objects get it by including kode_ao.h (sdk/kode). The event catalog, which says which signal carries which event and who handles it, is on sdk/events.

How it is connected

flowchart LR
init["kode_init()"] --> qf["QF_init"]
qf --> ps["QF_psInit<br/>MAX_PUB_SIG lists"]
ps --> pools["QF_poolInit x 4<br/>the event pools"]
pools --> tick["kqp_tick_start<br/>khal_timer_periodic"]
tick --> watch["kqp_watch_start<br/>task kqp.watch"]
class pools store
What kqp_init() starts, in order

kode_init() calls kqp_init() once, before any Active Object exists. The order matters. A clock with no pools would hand a timer event to an Active Object that cannot allocate one, so the tick starts after the pools. The supervisor starts last, because it reads Active Objects that exist by then.

Publish and subscribe. enum KodeSignals in kqp_signals.h has one marker, MAX_PUB_SIG, that splits it in two:

The last name in the list, MAX_SIG, is the first number your own signals may take. Two components can repeat each other's numbers above it, because a signal that stays inside one Active Object never crosses to another.

Events carry copies. An event holds the data itself, or a pointer whose owner is named where the event is declared. A listing travels as one event per entry and an end marker.

The blocking requests (infrared, NFC, RFID, audio, motion, storage) carry a khal_sem_t done and a pointer into memory the requester lends for that one request. The owner writes the answer and signals the semaphore. That is why kqp requires khal publicly: kqp_signals.h includes khal_sync.h.

The tick. khal_timer_periodic() calls back every millisecond in the esp_timer task, which runs at priority 22. The callback is task context, so it calls the task-level QTIMEEVT_TICK_X(). If the timer cannot start, Q_ASSERT_ID(100, ...) stops the board: a framework with no clock looks healthy while no timer event ever fires.

The tick can be stopped. A tick every millisecond keeps the chip out of light sleep, so AO_LowPower calls kqp_tick_rest(true) before the board sleeps and kqp_tick_rest(false) when it wakes (sdk/kaos/lowpower). While the tick is stopped no QTimeEvt fires, and the supervisor does not judge: a stopped tick is not a hung Active Object, and the supervisor forgets what it knew once the tick runs again. The call is idempotent and has one caller.

An interrupt posts one way. kqp_post_from_isr() calls the FromISR post and requests the context switch that must follow. It takes a static, immutable event and allocates nothing. The expander's INT line (GPIO6) and the top button (GPIO35) use it.

A crash is a panic with a name. Q_onError() prints the module, the id and the task that raised the assert through khal_panic_print(), then calls khal_panic("QP assert").

It does not use klog: QP raises its asserts inside a critical section, where the C library's recursive lock would turn the message into a bare abort(). The task named is the one that suffers the failure. A full queue (qf_port:130) names the task whose queue is full, never the one that posted.

Workers. An Active Object step stays short. A call that waits on a chip, a medium or the network goes to a worker, a task of the Active Object's own that runs one job and posts one KqpWorkerDoneEvt back. The Active Object keeps every decision: the order of jobs, retries, timeouts and the sequence number that tells a late answer from the one it waits for.

Specifications

The files

The files of kqp and of the vendored qpc.

FileWhat it does
include/kqp.hThe header everything includes: kqp_init(), kqp_post_from_isr(), kqp_timer_stale(); includes qpc.h, kqp_signals.h and kqp_config.h
include/kqp_config.hThe policy: priority map, tick rate, pool counts, queue and stack sizes, and every shared limit that sizes an event
include/kqp_signals.henum KodeSignals and every event struct
include/kqp_worker.hThe worker API: kqp_worker_start(), kqp_worker_submit(), the registry and KqpWorkerDoneEvt
src/kqp.ckqp_init(), the subscriber lists, the four pools and the _Static_assert lines that keep each event in its tier
src/kqp_bsp.c, src/kqp_bsp.hThe tick source, Q_onError() and the two empty lifecycle callbacks; the header is private
src/kqp_isr.ckqp_post_from_isr()
src/kqp_timer.ckqp_timer_stale()
src/kqp_worker.cThe worker over khal_worker, and the registry ps reads
src/kqp_watch.cThe Active Object supervisor: reads the port's records and panics with a name
src/kqp_watch_judge.c, src/kqp_watch_judge.hThe verdict, plain arithmetic the host tests run; the header is private
KconfigKQP_AO_WATCH and KQP_AO_STEP_MAX_MS
qpc (component components/qpc)QP/C 8.1.4, vendored (GPL-3.0), eleven of its framework files, plus port/qf_port.c, port/qp_port.h and port/qp_config.h for FreeRTOS on ESP-IDF SMP. No built-in kernel and no tracing

The port has three changes marked kodeOS/ESP-IDF adaptation:

QF_run() returns at once, because the scheduler already runs when app_main() starts. QActive_getQueueUse(), _Free() and _Min() return 0 because FreeRTOS gives no such number.

The two ranges

Signals split into three ranges by number.

RangeDeliveryRule
Below MAX_PUB_SIGQACTIVE_PUBLISH to every subscriberQActive_publish_() asserts (qf_ps:240) on a signal at or above the marker. Holds announcements of state, listings, answers to queries, test steps and a few requests
MAX_PUB_SIG and aboveQACTIVE_POST to one Active ObjectThe private protocol of one owner: requests, replies to the asker, private timers and self-posts
MAX_SIG and aboveYoursThe first number free for the private signals of your own Active Objects

The catalog counts 87 published signals and 277 direct-post signals. GUI_DUMP_REQ_SIG is the one request in the published range: AO_Gui lives in kgui, above the SDK, so the console asks the bus instead of naming a symbol of a higher layer. A firmware with no GUI has no subscriber, and the console's own watchdog reports it.

The pools

Four pools, each as wide as its widest event.

TierBlocksAs wide asServes
small, KQP_SMALL_POOL_COUNT32TouchEvtLED commands, touch samples, button events, console bytes, worker done events
medium, KQP_MEDIUM_POOL_COUNT64ConsoleLineEvt (a 240-byte line)Command lines, pixel flush requests, progress reports, listing entries, install requests
large, KQP_LARGE_POOL_COUNT48MqttMsgEvt (a 240-byte payload), or SchedEntryEvt when CONFIG_KODE_SCHEDULER is onLog lines, the rows of a console answer, MQTT messages, the entries AO_Scheduler lists
jumbo, KQP_JUMBO_POOL_COUNT4AppDataIconEvt (4 KB)A chunk of a firmware image arriving on the console, a third of an app icon

QF requires the pools in strictly increasing block size and serves each allocation from the smallest block that fits. The size of an event alone decides its tier: a LedEvt is narrower than a TouchEvt and still takes a whole small block.

Each tier is a typedef of its widest tenant, and 165 _Static_assert lines in kqp.c stop the build when an event outgrows its tier, or when anything but the image chunk and the icon third reaches the jumbo tier. Only the firmware build holds these checks, because the host computer sizes pointers and long at 64 bits and the board at 32.

The counts have reasons:

The priorities

A higher number is a higher priority, and the QP priority is the FreeRTOS priority of the task, so ps prints this table live.

PrioMacroRuns
1KODE_PRIO_USER_LOWFree: below AO_Net, an app's own Active Object starts here
2KODE_PRIO_NETAO_Net and its two worker tasks
3KODE_PRIO_USER_GUIThe renderer: AO_Gui in kodeOS, your UI in your image. Holds one Active Object
4KODE_PRIO_READER, KODE_PRIO_RADIOThe console reader task (not an Active Object), and AO_Radio with its two workers
5KODE_PRIO_STORAGEAO_Storage and storage_sd
6KODE_PRIO_LOGAO_Log, the console device
7KODE_PRIO_LEDAO_LedMgr
8KODE_PRIO_POWERAO_Power
9KODE_PRIO_APPMGRAO_AppMgr
10KODE_PRIO_AUTOTESTAO_Autotest
11KODE_PRIO_CONSOLEAO_Console
12KODE_PRIO_BOOTAO_Boot
13KODE_PRIO_BUTTONSAO_Buttons
14KODE_PRIO_TOUCHAO_Touch
15KODE_PRIO_DISPLAYAO_Display
16KODE_PRIO_USER_HIGH, or KODE_PRIO_SCHEDULERAbove the pixel path. AO_Scheduler takes the level when CONFIG_KODE_SCHEDULER is on, and the name KODE_PRIO_USER_HIGH is not defined then. Without the scheduler the level is free: use it only with a reason you can write down
17KODE_PRIO_IOEXPAO_IoExp: one I2C read and one publish per edge
18KODE_PRIO_IRAO_Ir and ir_worker
19KODE_PRIO_NFCAO_Nfc
20KODE_PRIO_AUDIO, KODE_PRIO_AUDIO_WORKERAO_Audio and its codec worker: the one high level with a real deadline, the codec must be refilled at 48 kHz
21KODE_PRIO_IMUAO_Imu: a step drains 16 FIFO entries, about 3 ms at most
22KODE_PRIO_HAPTICAO_Haptic, level with the esp_timer task
23KODE_PRIO_RFIDAO_Rfid and rfid_worker: the T5577 downlink must not be preempted mid-bit
24KODE_PRIO_LOWPOWERAO_LowPower: a wake is decided before the owners it wakes run, and a round of rest is a publish and a count

The order puts the slowest input and output at the bottom, where it can delay only itself. AO_Net is the lowest Active Object kodeOS starts. Every subscriber to MQTT_MSG_SIG outranks it, which keeps a chatty broker from filling anyone's queue, though your own Active Object at level 1 may sit below it. AO_Gui follows, so a slow repaint never delays the console.

Level 18 is the one place the order breaks: QF_MAX_ACTIVE gave room for another level only at the top, so AO_Ir took 18. Its step queues a message to the C5 and returns, so it cannot hold anyone for long at that level. An owner whose step is long needs a renumbered map and must not copy it.

QF_MAX_ACTIVE is 25, the most FreeRTOS leaves in this ESP-IDF, and 24 is the highest level an Active Object may actually ask for. AO_LowPower holds 24, so no level is left above the map. Level 25 has no name on purpose and cannot be reached: a task asked for 25 is clamped to 24. A second QACTIVE_START at a taken priority is a QP assertion and a reset.

Stacks and queues

The queue holds events that wait for the Active Object; the stack is its task's memory. KQP_AO_QUEUE_LEN is 16 events. A stack overflow does not assert: the Active Object hangs mid-work with no message. Raise a tier freely and lower one only after measuring the heaviest path with tasks.

TierBytesTaken by
KQP_AO_STACK_BYTES8192AO_AppMgr (two directory copies of about 1 KB on its stack), AO_Console. The default for an Active Object an app starts
KQP_AO_STACK_LARGE6144AO_Storage, AO_Display, AO_Radio, AO_Nfc
KQP_AO_STACK_MEDIUM4096AO_Log, AO_Boot, AO_Power, AO_Touch, AO_IoExp, AO_Imu, AO_Net, AO_Autotest
KQP_AO_STACK_SMALL3072AO_LedMgr, AO_Ir, AO_Rfid, AO_Audio, AO_Haptic, AO_Buttons, AO_LowPower, AO_Scheduler

Queue depth is 16 for every Active Object except AO_Log and AO_Console (64), AO_LowPower (48, KODE_LOWPOWER_QUEUE_LEN), AO_Scheduler (24, KODE_SCHED_QUEUE_LEN) and AO_Boot, AO_Power and AO_Radio (8). The facade sets them in kode.c (sdk/kode).

The calls

The calls of kqp, with the worker API at the end.

CallWhat it does
kqp_init()Once from app_main(), before any Active Object: QF_init, QF_psInit, four QF_poolInit, kqp_tick_start(), then kqp_watch_start() when CONFIG_KQP_AO_WATCH is on
kqp_post_from_isr(ao, e)Post a static, immutable event from an interrupt and request the context switch. The only door an interrupt has into an Active Object
kqp_timer_stale(te, live)Disarm a one-shot and say whether its expiry is already queued. QTimeEvt_disarm() returns false both for a timer never armed and for one that expired and posted; live says which. True means the next event for te is a generation behind and the handler drops it
KQP_TICKS_PER_MS(ms)Milliseconds to ticks, truncated. KQP_TICKS_PER_SEC is 1000, so one tick is 1 ms
kqp_worker_start(me, ao, name, stack_bytes, prio, depth, psram_stack)Bring a worker up for one Active Object, once, from its initial transition. KODE_DENIED for a NULL argument
kqp_worker_submit(me, fn, arg, done_sig, &seq)Queue a job kode_status_t fn(void *arg, uint32_t *value) and never block. KODE_NO_RESOURCES when the queue is full; nothing is queued then
kqp_worker_is_self(me)Whether the caller is the worker's task
kqp_worker_count(), kqp_worker_info(idx, &info)The registry: name, stack given and stack never used, read now

The workers

A job is a function and its argument. When it returns, the worker posts one KqpWorkerDoneEvt to the Active Object: the signal chosen at submit, the sequence number, the kode_status_t and one 32-bit word.

The worker never retries a job, drops a stale one or judges a result. One Active Object submits to a worker, the one that owns it, because sequence numbers count per worker. Jobs run in order, one at a time.

The done event is posted from the worker's own task, which waits in a loop, 1 ms apart, for a pool block and for room in the queue. An Active Object that stops reading its queue stalls its worker. The event is small-pool sized.

A stack in PSRAM costs no internal RAM besides the control block, and a job on such a stack must never touch the internal flash, because the cache is off while flash is written.

KQP_WORKERS_MAX is 16 and the name is clipped to KQP_WORKER_NAME_MAX (16). A seventeenth worker runs, is not listed and logs a warning. ps prints one >|worker record each.

The workers kodeOS starts:

WorkerOwnerStackRuns
net_workerAO_Net12288 B, PSRAMPing, GET, POST, UDP, the start and stop of MQTT
net_streamAO_Net12288 B, PSRAMThe open, reads and close of a download
radio_workerAO_Radio6144 B, internalThe jobs of the C5 link
radio_svcAO_Radio8192 B, PSRAMThe sends to the KODE services of the C5
audio_workerAO_Audio8192 B, PSRAMThe jobs of the codec
rfid_workerAO_Rfid8192 B, PSRAMRead, clone and capture
ir_workerAO_Ir8192 B, PSRAMThe calls to the infrared service on the C5
storage_sdAO_Storage8192 B, PSRAMThe calls to the card

An app gets the same mechanism as kode_worker_start() and kode_worker_submit() (sdk/kode). Four driver tasks are modes and not jobs, and stay driver tasks: nfc_irq, nfc_emu, nfc_cert and rfid_cert.

The watchdog

A supervisor task named kqp.watch looks at every Active Object once a second. It runs at the highest application priority on a 3072-byte internal stack, because it runs on the panic path, and it is subscribed to the ESP-IDF task watchdog, which it feeds at the end of each pass. khal_watch_start() also makes the task watchdog panic.

The watchdog covers the Active Objects of kodeOS, the system ones inside an app and the ones you write, with no code in them. An initial transition is not watched, because it runs before the task exists.

The port keeps one record per Active Object, written only by its own task:

The supervisor measures time with the FreeRTOS tick count. It reports three kinds of hang:

HangThe supervisor seesReport
stuckCounter odd, event began more than 30 s agokodeOS: AO 'Net' (prio 2) stuck 30.0 s in signal 0x2a
starvedCounter even, queue holds events, counter unmoved for more than 30 skodeOS: AO 'Net' (prio 2) starved 30.0 s, 3 events waiting
slow stepThe port timed a step over CONFIG_KQP_AO_STEP_MAX_MSOne warning per pass, at every log level except off: watch: Storage step 4092 ms (signal 0x85)

The deadline (KQP_AO_WATCH_TIMEOUT_MS, 30000) and the pass (KQP_AO_WATCH_PERIOD_MS, 1000) are the same for every Active Object and no call changes them.

On a hang the supervisor does this, in order:

  1. It prints the line through khal_panic_print() (AO_Log may be the one stuck).
  2. It calls khal_panic() with a reason that starts AO stuck: or AO starved: .
  3. The panic handler writes a core dump.

With several over the deadline it names the longest. The next boot banner shows the reason on its last line (sdk/kode). An app is built without a core dump, so after a hang in an app kodeOS shows back from app <name>: panic.

The supervisor does not catch:

With CONFIG_ESP_TASK_WDT_EN off there is no supervisor and a warning says so. The slow-step time is wall-clock: a low-priority Active Object preempted mid-step can pass the limit without working, so a line is a reason to look and no proof.

A hang in an app that the mailbox adopted does not return to kodeOS. Its boot request is sticky, so the app starts, hangs and panics every 30 s until a new upload or BOT+LEFT.

The two Kconfig options of the watchdog:

OptionDefaultWhat it does
KQP_AO_WATCHyThe port records every dispatch and the supervisor runs. Off removes all of it: no record, no task, and the task watchdog keeps what the project set
KQP_AO_STEP_MAX_MS5000 (100 to 10000)The longest step before the warning. Needs KQP_AO_WATCH

The limits events are sized by

The limits in kqp_config.h that fix how big an event can be.

LimitValueMeaning
KODE_CONSOLE_LINE_MAX240A console line, also an MQTT payload (KODE_MQTT_PAYLOAD_MAX)
KODE_LOG_LINE_MAX256A log line
KODE_RECV_CHUNK4096One chunk of the receive protocol
KODE_RECV_IDLE_MS5000No chunk for this long and the host is gone
KODE_STORAGE_LIST_MAX24The page of a listing
KODE_APP_LIST_MAX64Rows a reader keeps of each list
KODE_FLASH_SRC_MAX128A source name; FlashReqEvt has the least room left of the group
KODE_BUTTON_HOLD_MS600BOT or TOP held this long publishes BUTTON_HOLD_SIG
KODE_HOME_GRACE_MS, KODE_HOME_DEFER_MAX_MS200, 3000The home gesture's grace and the longest deferral
KODE_C5_BOOT_MS600The C5's boot time after it leaves reset

Some limits exist twice, in kqp_config.h and in a driver header (kdrv_net, kdrv_apps), because a driver cannot include the scheduler's configuration. Change both.

The signals

Every signal, the event it carries and who sends it are on their own page, sdk/events.

Gotchas

Example code

An Active Object of your own at the free level 1. It subscribes to BUTTON_EVT_SIG, so AO_Buttons publishes every press to it like to any other subscriber. Press a button on the Kode Dot and the console prints one button <n> pressed line.

/*
 * button_log.c - 2026-10-28 - from Kode with love
 *
 * An Active Object of your own at KODE_PRIO_USER_LOW. It subscribes to the
 * button events and logs each press: the console prints one line per press.
 */
#include "kode_ao.h"
 
static QActive     s_ao;
static QEvtPtr     s_queue[KQP_AO_QUEUE_LEN];
static StackType_t s_stack[KQP_AO_STACK_MEDIUM / sizeof(StackType_t)];
 
/* The only state: log every press. */
static QState ButtonLog_running(QActive * const me, QEvt const * const e)
{
    (void)me;
    if ((e->sig == BUTTON_EVT_SIG) && ((ButtonEvt const *)e)->pressed) {
        KLOG_I("button_log", "button %u pressed",
               (unsigned)((ButtonEvt const *)e)->id);
    }
    return Q_HANDLED();
}
 
/* The initial transition: subscribe, then run. */
static QState ButtonLog_initial(QActive * const me, void const * const par)
{
    (void)par;
    QActive_subscribe(me, BUTTON_EVT_SIG);
    return Q_TRAN(&ButtonLog_running);
}
 
/* Bring the system up, then start the Active Object. */
void app_main(void)
{
    kode_init();
    QActive_ctor(&s_ao, Q_STATE_CAST(&ButtonLog_initial));
    QACTIVE_START(&s_ao, KODE_PRIO_USER_LOW, s_queue, Q_DIM(s_queue),
                  s_stack, sizeof(s_stack), (void *)0);
}

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.

In the SDK