SDK
AO_Console
The Active Object that reads console commands: the command table, one command in flight, the watchdog, paced answers and the error codes.
What it is
AO_Console turns a line of text into an action. AO_Log owns the console port, builds each line from the bytes that arrive and publishes it (sdk/kaos/log). AO_Console receives that line, cuts it into words, checks the values and runs the command. To run it, AO_Console sends an event to the Active Object that owns the device and prints the answer when it comes back. The text goes out through AO_Log, so AO_Log stays the only writer of the console.
A few verbs have no owner to ask, and AO_Console runs them itself: help, ps, tasks, i2c_scan, clocks, exp, time, the nvs_* family, variable, function and config. One table, CMDS, drives the validator, the dispatcher and help, so the grammar, the help text and the error messages cannot disagree. The verbs themselves are listed on internals/command-set, and the text rules on internals/console-protocol. This page describes the code that runs them.
How it is connected
flowchart LR log["AO_Log"] -- "CONSOLE_LINE_SIG" --> con["AO_Console"] net["AO_Net"] -- "CONSOLE_LINE_SIG<br/>Kode channel" --> con con -- "net_line_done()<br/>net_line_busy()" --> net sched["AO_Scheduler"] -- "CONSOLE_LINE_SIG<br/>cron entry" --> con con -- "sched_line_done()<br/>sched_line_busy()" --> sched con -- "request event" --> owner["The owner AO"] owner -- "answer event" --> con con -- "kode_log_reply()" --> log con -- "CONFIG_REQ_SIG<br/>GUI_DUMP_REQ_SIG" --> bus["Published"] class log,net,sched,con,owner ao
AO_Console in numbers:
| Thing | Value |
|---|---|
| Priority | 11 |
| Queue | 64 events |
| Stack | 8192 bytes, the largest tier |
| Claims | Nothing at boot |
| Compiled under | KODEOS_CONSOLE, which selects KODEOS_APPMGR and KODEOS_AUTOTEST |
| States | One, Console_active |
The stack is the largest tier because the AO formats every reply in its own frames and carries the payload verbs.
What is in flight is data, and the timers decide what happens next. The initial transition subscribes to every answer signal a command can wait for, CONSOLE_LINE_SIG first. Whole groups are compiled out when the radio, Wi-Fi, Bluetooth, the network or the mirror is not in the build.
Which line, from which port
ConsoleLineEvt.src says whether the line came from the cable, Bluetooth, the Kode channel or the scheduler (KODE_CONSOLE_SRC_SERIAL, _BLE, _NET, _SCHED). AO_Console copies it into src before it dispatches. cmd_src holds the port of the command in flight, and every reply goes out tagged with it, so AO_Log can copy a channel command's replies to the channel ring. A busy refusal goes to the port of the new line, not to the port of the command in flight.
A SCHED line has no port. AO_Scheduler runs it on its own account at a cron time, so nobody typed it and no wire carries it. reply() is the one function every reply leaves through, and for source SCHED it writes the reply as a notice, sched: <reply>, and sends nothing to a port. When the reply is a terminator, sched_line_done() follows and AO_Scheduler sends its next line. This covers every way a command ends: the handler, a refusal, an unknown verb, the watchdog and the low-power events. A blank line from SCHED has no reply, and console_exec() calls sched_line_done() for it, so the scheduler is told once per line. A SCHED line that arrives while a command is in flight is not refused either: console_exec() calls sched_line_busy(), answers nothing, and AO_Scheduler gives the line again a second later (sdk/kaos/scheduler).
A NET line tells AO_Net when it ends. AO_Net holds the lines that arrive on the Kode channel and gives the console one at a time, each with a sequence number in ConsoleLineEvt.seq. The console keeps the number and reply() returns it with net_line_done() after the reply that ends the command (>ok or >err), so the answers stay in order. AO_Net ignores an end with another number, so a command that ends after AO_Net gave up on it does not end the line that took its place. A line of source NET that arrives while a command is in flight is not refused: console_exec() calls net_line_busy() and returns, and AO_Net offers the line again after 100 ms. Both calls exist in a build with CONFIG_KODEOS_CHANNEL.
Which source may run what
Nearly every verb takes a line and gives lines, from any port. Three kinds ask more of their port, and src_allows() is the one place that decides. A refusal comes before anything is posted or begun.
| Kind | Verbs | Refused for |
|---|---|---|
| Not Bluetooth | ident_csr, ident_cert, ident_lock, c5_wipe | Bluetooth: >err code=5 msg=cable |
| Wire | install with src="terminal", file_put, file_get, screenshot dst=console, mirror_start | The Kode channel: >err code=5 msg=net |
| Cable | install with dst=c5 | Bluetooth: msg=cable. The channel: msg=net |
| Not the scheduler | cron | A SCHED line: >err code=5 msg=sched |
A SCHED line is held to the same policy as the channel: the Wire and Cable kinds answer it >err code=5 msg=sched, and an entry cannot edit the table it runs from, which is why cron is refused to it. The identity verbs of the first row run from SCHED as they run from the channel.
The channel carries lines and no raw bytes, so a verb that switches the port to raw mode or answers with >|d= payload records needs a wire (internals/kode-channel).
One command in flight
console_exec() splits the line into tokens, then runs these checks in order:
- Busy. From
cmd_begin()to the terminator every new line answers>err code=4 msg=busywithout being parsed. A line of source NET is the exception: the console hands it back to AO_Net, which offers it again. - The token count.
- The verb, found in the table.
cmd_validate().
Handlers receive values that are already checked. Many verbs answer >ok at once and never set the in-flight kind: led_*, sd_mount, app_run, radio_up, mqtt_pub. Their >ok means accepted, and the owner tells the outcome in ! log lines.
The watchdog
cmd_begin() arms the tmo timer for 5000 ms. cmd_end() disarms it and returns to idle. If the owner never answers, the watchdog closes the command with >err code=6, so a silent owner cannot leave the console refusing everything. Some commands change that deadline:
cmd_extend()gives a command a bound of its own:sleepandpower_off17 s (KCLI_LOWPOWER_TIMEOUT_MS),screenshot dst=file20 s,cat30 s,cp60 s, the rows oftesttheir budget plus 5 s.- Long commands disarm the watchdog and trust the owner to always close:
install src="terminal"andfile_putafterRECV_READY_SIG, the Wi-Fi and Bluetooth bring-up and scan verbs,net_ping,http_get,udp_send,mqtt_upandmqtt_down. - The payload producer re-arms the watchdog on every pass that moved bytes, so it measures progress and not total time.
Paced answers
Every reply line is a LogEvt from the large pool of 48 blocks, and AO_Console outranks AO_Log. A listing emitted in one step would take the whole pool before one line reached the port. So multi-record output leaves in slices of KCLI_SLICE = 12 records, and the pacer timer spaces the slices 10 ms apart. The gap is a timer and not a self-post: a self-post keeps the queue full, and AO_Log would never run.
screenshot dst=console and file_get use the same pacer through one producer. It sends base64 records of 216 characters, which carry 162 source bytes, a whole number of base64 groups. It checks console_congested() before each record.
Variables, functions and settings
An app registers rows with kode_variables_add(), kode_functions_add() and kode_config_add() after kode_init(). The registry (kregistry.c, no QP code) holds the rows, and AO_Console does the console side. variable reads a row and function runs one, both on AO_Console's task, so a getter or a run must return at once.
variable name="x" watch=<ms> prints the value on a periodic timer; four watches can run at once, 100 to 60000 ms apart, and a watch skips a sample while the port is congested. A function with answer_ms holds the line open and prints the records its owner posts as FUNCTION_ANS_SIG. config lists, reads and sets the stored settings, one CONFIG_REQ_SIG at a time, with a 1 s bound per owner (KODE_CONFIG_ANSWER_MS). The first request for a key is published. Its answer names the owner (KodeConfigAnsEvt.owner), and from then on the console posts that key's requests straight to the owner, leaving 4 slots of its queue free (KODE_CONFIG_POST_MARGIN, the margin AO_Radio and AO_Power keep for their own doors). A queue that has no such room answers >err code=4 msg=busy, so a burst of config lines can never fill an owner's queue.
Self-tests
test asks AO_Autotest for a run with the client id AUTOTEST_CLIENT_CONSOLE and prints only the events that carry that id (sdk/kaos/autotest). test_report prints the report AO_Autotest kept.
Specifications
The error codes
Every failure is one line, >err code=<n> msg=<token>. The codes are kcli_err_t in kcli_proto.h.
| Code | Name | Raised when |
|---|---|---|
| 1 | KCLI_ERR_VERB | No table entry matches the first token. msg is the verb |
| 2 | KCLI_ERR_MISSING | A token has no =, or a required key is absent. msg is the key |
| 3 | KCLI_ERR_VALUE | An unknown key, or a value that fails its type, range or list. Also a ninth token, an unclosed quote and bytes after a closing quote. msg is the key |
| 4 | KCLI_ERR_BUSY | A command is in flight (a line of source NET is handed back to AO_Net instead), or the owner is busy with another request. sleep and power_off raise it with msg="<who>: <why>" when an owner will not rest, and every form of sleep, power_off and cron raises it with msg=not_built in a build without the option |
| 5 | KCLI_ERR_REJECT | The owner said no, or the request never reached it (an empty event pool included) |
| 6 | KCLI_ERR_TIMEOUT | The watchdog expired, or the receiver of a payload sent RECV_STALL_SIG |
The grammar
| Rule | Value |
|---|---|
| Tokens per line, verb included | 8 (KCLI_MAX_ARGS) |
| Keys per command | 6 (KCLI_MAX_KEYS). console_specs_ok() checks the table and Console_ctor() asserts it |
| Bare values | KARG_UINT (bounded by the spec), KARG_BOOL (on, off), KARG_ENUM, KARG_RGB (r,g,b) |
| Quoted values | KARG_STRING (not empty), KARG_TEXT (may be empty). Escapes \", \\, \n, and no other: console_token() returns CONSOLE_TOK_BAD for any other backslash pair, answered code 3 with the key |
| Repeated keys | Refused: cmd_validate() answers code 3 with the key given twice |
| Either form | KARG_ROW: the arg= of function and the value= of config, typed by the registry row the line names |
Commands in CMDS | 156, with every option built in |
A string value is always quoted and every other value bare. The wrong form is code=3.
Signals in
AO_Console subscribes to the answers its commands wait for. Each case checks the in-flight kind first, because other AOs publish entries and states for their own askers.
| Signals | Events | From | Used by |
|---|---|---|---|
CONSOLE_LINE_SIG | ConsoleLineEvt | AO_Log (publish), AO_Net and AO_Scheduler (post) | Every command |
SCHED_ENTRY_SIG, SCHED_END_SIG | SchedEntryEvt, SchedEndEvt | AO_Scheduler, posted | cron: one cron record per entry, then >ok or the refusal that matches the reason. Read only while a cron is in flight |
LOWPOWER_STATE_SIG, LOWPOWER_REFUSED_SIG | LowPowerStateEvt, LowPowerRefusedEvt | AO_LowPower | sleep, power_off |
LOWPOWER_ROUND_SIG | LowPowerRoundEvt | AO_LowPower | Not a command. The console has no device to rest, so it answers the round busy (console transfer) while a payload crosses the wire and ready otherwise |
STORAGE_ENTRY_SIG, STORAGE_LIST_END_SIG, STORAGE_INFO_SIG, STORAGE_TEST_SIG, TEST_RESULT_SIG | StorageEntryEvt, ListEndEvt, StorageInfoEvt, StorageTestEvt, TestResultEvt | AO_Storage | sd_ls, fs_ls, sd_info, fs_info, sd_verify |
STORAGE_PATH_RSP_SIG, STORAGE_LINE_SIG, STORAGE_OPEN_RSP_SIG, STORAGE_DATA_SIG, STORAGE_WRITE_RSP_SIG, STORAGE_DESC_RSP_SIG, STORAGE_FIELD_RSP_SIG | The matching events | AO_Storage, posted | The file tree, file_get, screenshot dst=file, file_desc, sd |
APPDATA_FIELDS_SIG, APPDATA_ICON_SIG, APPDATA_END_SIG, APPDATA_RM_RSP_SIG | App-data events | AO_Storage, posted | appdata_ls, appdata_info, appdata_rm |
APP_ENTRY_SIG, APP_LIST_END_SIG, APP_CAT_RSP_SIG | AppEntryEvt, ListEndEvt, AppCatRspEvt | AO_AppMgr | app_list, app_cat |
RECV_READY_SIG, RECV_ACK_SIG, RECV_DONE_SIG, RECV_FAIL_SIG, RECV_STALL_SIG | none | AO_AppMgr, AO_Storage, AO_Radio | install src="terminal", file_put. Become >|ready chunk=4096, >|ack and the terminator |
AUTOTEST_BEGIN_SIG, AUTOTEST_ROW_SIG, AUTOTEST_HOLD_SIG, AUTOTEST_END_SIG, AUTOTEST_REPORT_SIG | Autotest*Evt | AO_Autotest | test, test_report |
BTN_TEST_SIG, TOUCH_TEST_SIG, AUDIO_TEST_SIG, IMU_TEST_SIG, MAG_TEST_SIG, HAPTIC_TEST_SIG, DISPLAY_TEST_SIG, LED_TEST_SIG, POWER_TEST_SIG, PSRAM_TEST_SIG | Step events | The owners | The steps of the guided rows, printed as they come |
POWER_EVT_SIG, POWER_FIELD_SIG, POWER_CTRL_SIG | PowerEvt, PowerFieldEvt, PowerCtrlEvt | AO_Power | bat_info, pmic, gauge, boost, pmic_reset |
BTN_STATE_SIG, BOOT_STATE_SIG, GUI_STATE_SIG, IR_RX_SIG, DISPLAY_MIRROR_INFO_SIG | State events | AO_Buttons, AO_Boot, AO_Gui, AO_Ir, AO_Display | btn_read, boot_info, gui_info, ir_rx, mirror_info |
AUDIO_FIELD_SIG, AUDIO_FILE_RSP_SIG, HAPTIC_FIELD_SIG | Field and file events | AO_Audio, AO_Haptic | audio, audio_rec, haptic |
CONFIG_ANS_SIG | KodeConfigAnsEvt | The owner of the key | config |
FUNCTION_ANS_SIG | FunctionAnsEvt | The owner of a function that answers, posted | function |
RADIO_STATE_SIG, WIFI_*_SIG, BLE_*_SIG | Radio events | AO_Radio | The radio_*, wifi_*, ble_* verbs |
NET_PING_*_SIG, NET_HTTP_*_SIG, NET_UDP_DONE_SIG, NET_INFO_SIG, MQTT_STATE_SIG, CHANNEL_STATE_SIG | Network events | AO_Net | The net_*, mqtt_* verbs, channel_info |
The posted answers (*_RSP_SIG to a named asker) reach AO_Console only because it is the asker. It does not subscribe to MQTT_MSG_SIG on purpose: a message nobody asked for belongs in a ! line, never in the middle of a command's answer. The IOEXP_FIELD_RSP_SIG, TOUCH_FIELD_RSP_SIG, RADIO_FIELD_RSP_SIG, NFC_*_RSP_SIG, RFID_*_RSP_SIG and IR_INFO_RSP_SIG answers come back the same way.
Signals out
| Signal | Event | Range | Meaning |
|---|---|---|---|
CONFIG_REQ_SIG | KodeConfigReqEvt | publish, then post | Read or set one key. The owner answers with kode_config_answer(). Published until the key's owner has answered once, then posted to it with KODE_CONFIG_POST_MARGIN slots left; a refused post answers >err code=4 msg=busy |
GUI_DUMP_REQ_SIG | none | publish | gui_info. Published because kgui is above kaos and kaos cannot name AO_Gui |
| Requests to owners | Per owner | post | LED_* to AO_LedMgr, STORAGE_* to AO_Storage, appmgr_*_request() and FLASH_REQ_SIG to AO_AppMgr, power_*_request() to AO_Power (ship_mode is power_ship_request()), lowpower_request() to AO_LowPower (sleep, power_off), sched_request_list() and sched_request_set() to AO_Scheduler (cron), and the matching helpers for AO_Radio, AO_Net, AO_Nfc, AO_Rfid, AO_Ir, AO_Audio, AO_Haptic, AO_IoExp, AO_Touch, AO_Display and AO_Boot |
| Buttons and touch injection | ButtonEvt, TouchEvt | publish | btn, tap, touch and swipe publish what AO_Buttons and AO_Touch publish |
Replies go through kode_log_reply(), on the same path as log lines.
Timers
| Timer | Signal | Kind | Value |
|---|---|---|---|
tmo | CONSOLE_TIMEOUT_SIG | Watchdog of the command in flight | 5000 ms (KCLI_CMD_TIMEOUT_MS), extended per command |
pacer | CONSOLE_SLICE_SIG | One-shot between slices | 10 ms |
inj_tmo | KCLI_INJ_TMO_SIG | An injected tap or swipe has a sample due | Tap hold 60 ms (KCLI_TAP_HOLD_MS), retry 60 ms |
| four watch timers | CONSOLE_WATCH_SIG | Periodic, one per followed variable | The watch= period |
Settings
AO_Console owns no stored setting. It only mediates config between the registry and the owners (internals/settings).
Gotchas
Example code
Register one variable and one function so the console can read and drive your app. With the app running, type variable name="uptime_s" to read the seconds since boot, and function name="green" to turn the LED green.
/*
* console_hooks.c - 2026-10-28 - from Kode with love
*
* Gives the console one variable and one function. Type variable
* name="uptime_s" to read the uptime, and function name="green" to light
* the LED green. Both run on AO_Console's task and return at once.
*/
#include <stdio.h>
#include "kode.h"
/* Spell the uptime in seconds as one word. */
static kode_status_t get_uptime(char *buf, size_t cap)
{
(void)snprintf(buf, cap, "%lu", (unsigned long)(kode_time_ms() / 1000U));
return KODE_OK;
}
/* Publish the colour and return: AO_LedMgr does the work. */
static kode_status_t run_green(const kode_value_t *arg)
{
(void)arg;
return kode_led_rgb(0, 64, 0);
}
static const kode_variable_t VARIABLES[] = {
{ .name = "uptime_s", .desc = "Seconds since boot", .get = get_uptime },
};
static const kode_function_t FUNCTIONS[] = {
{ .name = "green", .desc = "LED green", .run = run_green },
};
/* Bring the system up, register the rows, then idle. */
void app_main(void)
{
kode_init();
(void)KODE_VARIABLES_ADD(VARIABLES);
(void)KODE_FUNCTIONS_ADD(FUNCTIONS);
for (;;) { kode_delay_ms(1000); }
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/kaos/console:console_ao.c(the command table and the handlers),console_fs.c(the file verbs),console_grammar.c(the tokenizer and the quoting).kcli_proto.h: the six error codes.kode_variables.h: the tables an app registers.