# 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

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

*From a finished line to the owner and back*

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:

1. Busy. From `cmd_begin()` to the terminator every new line answers `>err code=4 msg=busy` without being parsed. A line of source NET is the exception: the console hands it back to AO_Net, which offers it again.
2. The token count.
3. The verb, found in the table.
4. `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: `sleep` and `power_off` 17 s (`KCLI_LOWPOWER_TIMEOUT_MS`), `screenshot dst=file` 20 s, `cat` 30 s, `cp` 60 s, the rows of `test` their budget plus 5 s.
- Long commands disarm the watchdog and trust the owner to always close: `install src="terminal"` and `file_put` after `RECV_READY_SIG`, the Wi-Fi and Bluetooth bring-up and scan verbs, `net_ping`, `http_get`, `udp_send`, `mqtt_up` and `mqtt_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

> **WARNING**
>
> **Every command after a long one answers `busy`.** A command that waits on an owner holds the line until its terminator. A script sends the next line after it reads `>ok` or `>err`. A busy answer to a line that is malformed too is normal: the busy check comes before validation. A refused line still counts as input for AO_LowPower: the console calls `lowpower_input_note()` for every line before the busy check, so a line restarts the idle time and wakes a board that sleeps. A SCHED line does not count, and neither does a line of the channel that the console hands back.

> **WARNING**
>
> **`>ok` can mean accepted and no more.** The verbs that answer at once, such as `app_run` and `mqtt_pub`, report their outcome in `!` log lines, which never reach the Kode channel. A remote host needs a verb that answers in `>|` records.

> **NOTE**
>
> **A getter or a `run` that blocks freezes the console.** `variable` and `function` call your code on AO_Console's task. Return at once, and move waiting work to an AO or a worker of your own.

> **NOTE**
>
> **`hcf` sends no terminator.** It panics on purpose through `Q_onError()` and leaves a core dump that the next `info` reports. The reset is the answer.

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

```c
/*
 * 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`](https://github.com/kodediy/kode-sdk/tree/main/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`](https://github.com/kodediy/kode-sdk/blob/main/components/kaos/core/include/kcli_proto.h): the six error codes.
- [`kode_variables.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kaos/core/include/kode_variables.h): the tables an app registers.
