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

- **It starts the framework**: the event bus, the four event pools and the tick.
- **It supports it**: the 1 ms clock, the assert hook, the door an interrupt uses to post an event, the workers and a watchdog that finds an Active Object that stopped.
- **It holds the policy of the whole SDK**: the priority of every Active Object, the size of every queue and stack, and the list of every signal with the event it carries.

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

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

- A signal below it is published on the bus to every Active Object that subscribed (`QACTIVE_PUBLISH`).
- A signal at or above it is posted to exactly one Active Object (`QACTIVE_POST`), and it belongs to that owner's private protocol.

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.

| File | What it does |
| --- | --- |
| `include/kqp.h` | The 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.h` | The policy: priority map, tick rate, pool counts, queue and stack sizes, and every shared limit that sizes an event |
| `include/kqp_signals.h` | `enum KodeSignals` and every event struct |
| `include/kqp_worker.h` | The worker API: `kqp_worker_start()`, `kqp_worker_submit()`, the registry and `KqpWorkerDoneEvt` |
| `src/kqp.c` | `kqp_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.h` | The tick source, `Q_onError()` and the two empty lifecycle callbacks; the header is private |
| `src/kqp_isr.c` | `kqp_post_from_isr()` |
| `src/kqp_timer.c` | `kqp_timer_stale()` |
| `src/kqp_worker.c` | The worker over `khal_worker`, and the registry `ps` reads |
| `src/kqp_watch.c` | The Active Object supervisor: reads the port's records and panics with a name |
| `src/kqp_watch_judge.c`, `src/kqp_watch_judge.h` | The verdict, plain arithmetic the host tests run; the header is private |
| `Kconfig` | `KQP_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 critical sections use a spinlock (`taskENTER_CRITICAL`) because disabling interrupts does not protect against the other core.
- The FreeRTOS headers come through ESP-IDF's `freertos/` folder.
- The port records every dispatch for the supervisor.

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

| Range | Delivery | Rule |
| --- | --- | --- |
| Below `MAX_PUB_SIG` | `QACTIVE_PUBLISH` to every subscriber | `QActive_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 above | `QACTIVE_POST` to one Active Object | The private protocol of one owner: requests, replies to the asker, private timers and self-posts |
| `MAX_SIG` and above | Yours | The 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.

| Tier | Blocks | As wide as | Serves |
| --- | --- | --- | --- |
| small, `KQP_SMALL_POOL_COUNT` | 32 | `TouchEvt` | LED commands, touch samples, button events, console bytes, worker done events |
| medium, `KQP_MEDIUM_POOL_COUNT` | 64 | `ConsoleLineEvt` (a 240-byte line) | Command lines, pixel flush requests, progress reports, listing entries, install requests |
| large, `KQP_LARGE_POOL_COUNT` | 48 | `MqttMsgEvt` (a 240-byte payload), or `SchedEntryEvt` when `CONFIG_KODE_SCHEDULER` is on | Log lines, the rows of a console answer, MQTT messages, the entries AO_Scheduler lists |
| jumbo, `KQP_JUMBO_POOL_COUNT` | 4 | `AppDataIconEvt` (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:

- **Medium.** The pool holds a whole page of a listing (`KODE_STORAGE_LIST_MAX + 1` = 25 events) waiting in AO_Gui's queue while the console prints its own copy.
- **Large.** The pool is sized by the console's largest synchronous answer. `help` and `ps` emit one line per row in one step, at a priority above AO_Log, so every line holds a block until the console yields. They answer in slices of 12 rows (`KAOS_WIRE_SLICE`) one tick apart so AO_Log can drain.
- **Large against AO_Log's queue.** The queue (64) is deeper than the pool (48) on purpose: the pool runs dry, a bounded drop, before the queue can fill, an assert.
- **Jumbo.** The pool covers everything one 4 KB protocol chunk can fragment into as it arrives. The transfer's flow control is the `>|ack` protocol, not this pool.

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

| Prio | Macro | Runs |
| --- | --- | --- |
| 1 | `KODE_PRIO_USER_LOW` | Free: below AO_Net, an app's own Active Object starts here |
| 2 | `KODE_PRIO_NET` | AO_Net and its two worker tasks |
| 3 | `KODE_PRIO_USER_GUI` | The renderer: AO_Gui in kodeOS, your UI in your image. Holds one Active Object |
| 4 | `KODE_PRIO_READER`, `KODE_PRIO_RADIO` | The console reader task (not an Active Object), and AO_Radio with its two workers |
| 5 | `KODE_PRIO_STORAGE` | AO_Storage and `storage_sd` |
| 6 | `KODE_PRIO_LOG` | AO_Log, the console device |
| 7 | `KODE_PRIO_LED` | AO_LedMgr |
| 8 | `KODE_PRIO_POWER` | AO_Power |
| 9 | `KODE_PRIO_APPMGR` | AO_AppMgr |
| 10 | `KODE_PRIO_AUTOTEST` | AO_Autotest |
| 11 | `KODE_PRIO_CONSOLE` | AO_Console |
| 12 | `KODE_PRIO_BOOT` | AO_Boot |
| 13 | `KODE_PRIO_BUTTONS` | AO_Buttons |
| 14 | `KODE_PRIO_TOUCH` | AO_Touch |
| 15 | `KODE_PRIO_DISPLAY` | AO_Display |
| 16 | `KODE_PRIO_USER_HIGH`, or `KODE_PRIO_SCHEDULER` | Above 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 |
| 17 | `KODE_PRIO_IOEXP` | AO_IoExp: one I2C read and one publish per edge |
| 18 | `KODE_PRIO_IR` | AO_Ir and `ir_worker` |
| 19 | `KODE_PRIO_NFC` | AO_Nfc |
| 20 | `KODE_PRIO_AUDIO`, `KODE_PRIO_AUDIO_WORKER` | AO_Audio and its codec worker: the one high level with a real deadline, the codec must be refilled at 48 kHz |
| 21 | `KODE_PRIO_IMU` | AO_Imu: a step drains 16 FIFO entries, about 3 ms at most |
| 22 | `KODE_PRIO_HAPTIC` | AO_Haptic, level with the `esp_timer` task |
| 23 | `KODE_PRIO_RFID` | AO_Rfid and `rfid_worker`: the T5577 downlink must not be preempted mid-bit |
| 24 | `KODE_PRIO_LOWPOWER` | AO_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`.

| Tier | Bytes | Taken by |
| --- | --- | --- |
| `KQP_AO_STACK_BYTES` | 8192 | AO_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_LARGE` | 6144 | AO_Storage, AO_Display, AO_Radio, AO_Nfc |
| `KQP_AO_STACK_MEDIUM` | 4096 | AO_Log, AO_Boot, AO_Power, AO_Touch, AO_IoExp, AO_Imu, AO_Net, AO_Autotest |
| `KQP_AO_STACK_SMALL` | 3072 | AO_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.

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

| Worker | Owner | Stack | Runs |
| --- | --- | --- | --- |
| `net_worker` | AO_Net | 12288 B, PSRAM | Ping, GET, POST, UDP, the start and stop of MQTT |
| `net_stream` | AO_Net | 12288 B, PSRAM | The open, reads and close of a download |
| `radio_worker` | AO_Radio | 6144 B, internal | The jobs of the C5 link |
| `radio_svc` | AO_Radio | 8192 B, PSRAM | The sends to the KODE services of the C5 |
| `audio_worker` | AO_Audio | 8192 B, PSRAM | The jobs of the codec |
| `rfid_worker` | AO_Rfid | 8192 B, PSRAM | Read, clone and capture |
| `ir_worker` | AO_Ir | 8192 B, PSRAM | The calls to the infrared service on the C5 |
| `storage_sd` | AO_Storage | 8192 B, PSRAM | The 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:

- a dispatch counter (odd inside an event, even between events)
- the tick the current event began, and its signal
- the length and signal of the latest step that ended over the step limit

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

| Hang | The supervisor sees | Report |
| --- | --- | --- |
| stuck | Counter odd, event began more than 30 s ago | `kodeOS: AO 'Net' (prio 2) stuck 30.0 s in signal 0x2a` |
| starved | Counter even, queue holds events, counter unmoved for more than 30 s | `kodeOS: AO 'Net' (prio 2) starved 30.0 s, 3 events waiting` |
| slow step | The port timed a step over `CONFIG_KQP_AO_STEP_MAX_MS` | One 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:

- an Active Object idle in a state
- a task that is not an Active Object
- a step slower than the step limit and shorter than 30 s (it is logged and nothing more)
- an Arduino sketch, which does not run this port

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:

| Option | Default | What it does |
| --- | --- | --- |
| `KQP_AO_WATCH` | y | The 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_MS` | 5000 (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.

| Limit | Value | Meaning |
| --- | --- | --- |
| `KODE_CONSOLE_LINE_MAX` | 240 | A console line, also an MQTT payload (`KODE_MQTT_PAYLOAD_MAX`) |
| `KODE_LOG_LINE_MAX` | 256 | A log line |
| `KODE_RECV_CHUNK` | 4096 | One chunk of the receive protocol |
| `KODE_RECV_IDLE_MS` | 5000 | No chunk for this long and the host is gone |
| `KODE_STORAGE_LIST_MAX` | 24 | The page of a listing |
| `KODE_APP_LIST_MAX` | 64 | Rows a reader keeps of each list |
| `KODE_FLASH_SRC_MAX` | 128 | A source name; `FlashReqEvt` has the least room left of the group |
| `KODE_BUTTON_HOLD_MS` | 600 | BOT or TOP held this long publishes `BUTTON_HOLD_SIG` |
| `KODE_HOME_GRACE_MS`, `KODE_HOME_DEFER_MAX_MS` | 200, 3000 | The home gesture's grace and the longest deferral |
| `KODE_C5_BOOT_MS` | 600 | The 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

> **WARNING**
>
> **A timer set to 0 ticks resets the board.** `KQP_TICKS_PER_MS(ms)` truncates, so with a 1 ms tick only `KQP_TICKS_PER_MS(0)` gives 0. QP answers a `QTimeEvt` armed or re-armed with 0 ticks by asserting (`Q_REQUIRE` 440 in `qf_time.c` for an arm, 610 for a re-arm) and the board restarts. Check the result before arming whenever the period comes from a value typed or received while the board runs.

> **WARNING**
>
> **A full queue is an assert, not a dropped event.** A post without a margin that finds no room stops the board at `qf_port:130` and names the task whose queue is full, which is the victim and not the poster. Give an Active Object that subscribes to a bursty signal a queue as deep as the burst, or post with a margin. This is why every subscriber to `MQTT_MSG_SIG` must outrank AO_Net: it drains each message before the next is published.

> **WARNING**
>
> **A step that waits counts as a hang.** An Active Object that blocks on a socket, a semaphore or the I2C lock for 30 s panics the board with a core dump that names it, and a step over 5 s logs a warning first. Hand the wait to a worker with `kqp_worker_submit()` and handle the result as an event. Never call a blocking facade function from an Active Object (`sdk/kode`).

> **NOTE**
>
> **`kqp_timer_stale()` is for one-shots you disarm.** `QTimeEvt_disarm()` returns false for a timer that never ran and for one that already expired into your queue. Reading that false as "nothing to worry about" lets the late expiry end a window that belongs to the arming that replaced it. Keep a `live` flag, set when you arm and cleared when you see the expiry, and pass it in.

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

```c
/*
 * 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

- [`components/kqp`](https://github.com/kodediy/kode-sdk/tree/main/components/kqp): the component. `include/kqp_config.h` is the policy and `include/kqp_signals.h` is the enum and every event struct.
- [`components/qpc`](https://github.com/kodediy/kode-sdk/tree/main/components/qpc): QP/C 8.1.4 and the FreeRTOS port.
- [`examples/`](https://github.com/kodediy/kode-sdk/tree/main/examples): `display_lvgl` is an Active Object of your own with a state machine, a queue and a stack.
