SDK

AO_Scheduler

AO_Scheduler runs cron entries on a Kode Dot: the time expression, how an entry fires, the wake for it and the mqtt check behind the Kode channel.

What it is

AO_Scheduler holds a table of entries. An entry is a name, a time in the five fields of cron, and some work. When the minute of an entry comes, the work runs. The work is a console line, as if somebody had typed it, or an event for an Active Object of an app. The time is read on the epoch clock as the UTC calendar that Unix seconds give, and the finest step is one minute.

The Kode Dot spends most of its life asleep (sdk/kaos/lowpower). AO_Scheduler is a participant of the low-power rounds. Before the board sleeps, it tells AO_LowPower the real time of its earliest entry, and the board wakes for it. The wake lights the screen and restores every owner, as a button does. When the work of that wake ends and nobody used the board, AO_Scheduler asks AO_LowPower to put it back to sleep without waiting for the idle time.

One entry is always there: mqtt, the safety net of the Kode channel. Its work is not a line. Every 15 minutes by default, AO_Scheduler hands the time of the next check to AO_Net, which looks at its sessions in a radio wake that leaves the screen dark. If the interrupt that wakes the P4 for a message is ever lost, the message still arrives at the next check. You can change the time of mqtt or switch it off like any other entry.

AO_Scheduler exists only with CONFIG_KODE_SCHEDULER. The option is off in the SDK and on in kodeOS. With it off, the calls in kaos.h refuse and log the scheduler is not built, and the console verb cron answers >err code=4 msg=not_built.

How it is connected

flowchart LR
cron["console: cron"] --> sched["AO_Scheduler"]
code["app AO: sched_request_set_ao"] --> sched
sched -- "ConsoleLineEvt, source SCHED" --> con["AO_Console"]
sched -- "SchedFireEvt" --> app["app AO"]
sched -- "net_check_at" --> net["AO_Net"]
sched -- "lowpower_wake_at, lowpower_request_rest" --> lp["AO_LowPower"]
con -- "sched_line_done<br/>sched_line_busy" --> sched
net -- "sched_check_done" --> sched
From an entry to its work

AO_Scheduler owns the table, the minute timer and the order of the lines it hands to the console. It drives no device and claims no resource. It owns the NVS namespace kcron.

Priority and start

AO_Scheduler runs at priority 16 (KODE_PRIO_SCHEDULER), the level of KODE_PRIO_USER_HIGH, above AO_Display. A step is a pass over sixteen entries and a post, and nothing it does touches a device, but the minute has to arrive on time while AO_Gui draws all the face's time. It is not AO_LowPower's level (24) and it does not sit under AO_Gui.

The level is behind CONFIG_KODE_SCHEDULER. An app that turns the option on gives KODE_PRIO_USER_HIGH up. The macro is not defined for it, so an Active Object that still starts at it fails to compile, where QP would answer a second start at an occupied level with an assert at boot. With the option off the level is free.

The queue holds KODE_SCHED_QUEUE_LEN (24) events. A request leaves KODE_SCHED_REQ_MARGIN (4) places free for the answer of a line and the timer. The stack is the small tier, 3072 bytes.

kode_init() starts AO_Scheduler right after AO_LowPower, or after AO_Boot when CONFIG_KODE_LOWPOWER is off, and before AO_Storage and AO_Console, so that the first cron line finds a queue to post to. At the end of kode_init(), once every Active Object has its queue, sched_system_up() posts it SCHED_UP_SIG. Until then the minute timer is not armed and nothing is posted to another Active Object, because a post to a queue that does not exist yet is an assert.

The Active Object subscribes to LOWPOWER_ROUND_SIG, LOWPOWER_STATE_SIG and LOWPOWER_REFUSED_SIG, only with CONFIG_KODE_LOWPOWER. Without it the scheduler still runs and the entries fire while the board is awake, since there is no sleep to wake from. The states are Scheduler_on, and Scheduler_waiting while a line is out and the console owes its answer.

What an entry is

FieldWhat it is
Name1 to KODE_SCHED_NAME_MAX (15) characters from A-Z a-z 0-9 _ - .. Unique, and the way an entry is shown, changed or removed. mqtt is reserved
TimeFive cron fields, at most KODE_SCHED_WHEN_MAX (63) characters
WorkA console line of at most 239 characters (KODE_SCHED_CMD_MAX is 240 bytes, NUL included), an event posted to an Active Object, or nothing (mqtt)
Ownerconsole, code or builtin
StoredWhether the entry survives a reset. Only the entries of the console and the time of mqtt are stored

The table has KODE_SCHED_MAX (16) places. The slot of mqtt is always taken, even when mqtt is off, so mqtt and 15 other entries fill it. A text that does not fit its buffer is refused with the name of its field and never cut.

An entry can be changed or removed only through the door that made it. There is one name space for all the doors.

OwnerMade byStoredChanged by
consoleThe cron verb, from any portYes, in kcronThe cron verb
codesched_request_set_ao(), from an Active Object of an appNo: the app makes it again at each bootsched_request_set_ao() and sched_request_remove()
builtinThe scheduler: mqttIts time, or its being offThe cron verb: a new time, or when=off

A request on a name that another owner holds is refused with the reason owner. The code door cannot take mqtt. The console can change or switch off mqtt and cannot give it a line.

The time expression

An expression has five fields separated by spaces or tabs: minute (0 to 59), hour (0 to 23), day of month (1 to 31), month (1 to 12) and day of week (0 to 6). Sunday is 0, and 7 means Sunday too. Each field is a list of items separated by commas, and an item is *, a number or a range a-b. A * or a range can carry a step: */5, 9-17/2. A step must follow * or a range, so 5/2 is a syntax error. A step cannot be wider than the field: */61 on the minutes is refused and */60 is not.

The expression is matched against the UTC calendar. No zone is applied and daylight saving does not exist for it. Names (JAN, MON), shorthands such as @hourly, and the characters L, W and # are refused as syntax. A sixth field, for seconds, is refused as fields.

Day of month and day of week combine as classic cron does. When both are restricted, a day matches if either of them matches. When the text of either one begins with *, a day must match both. A star with a step, */2, counts as a star.

The calendar is worked out in the code with int64_t, so leap years, the ends of months and the year 2038 behave the same on the host and on the board. The search for the next minute looks at most SCHED_CRON_HORIZON_DAYS (1462) days ahead. An expression that parses but matches no minute in that time, such as 0 0 31 2 *, is refused with the reason never. The other reasons of a refused expression are fields (not five), range (a value outside its field), step and syntax.

When an entry fires

An entry fires at most once for each matching minute, at the first second of that minute or as close to it as the wake allows.

The lines

The work of a console entry is a line that runs with the console source SCHED (KODE_CONSOLE_SRC_SCHED), a fourth source beside the cable, BLE and the Kode channel.

An entry whose line does not answer. AO_Scheduler waits for the answer of a line for at most KODE_SCHED_WORK_BOUND_MS (10000 ms). When the bound runs out, a WARN says a line did not answer in 10000 ms; the work of its minute is given up, the lines of the minute that did not go out yet are dropped (one WARN each), the answer that was owed is forgotten, and the board is asked to rest if the wake was the timer's. The entry whose line did not answer skips its next minute, with one WARN, and runs again from the minute after. An entry whose next minute is much later, such as an hourly one, is not skipped.

An entry of code. sched_request_set_ao(who, name, when, ao, sig) makes an entry whose work is an event. At each match AO_Scheduler posts a SchedFireEvt with the entry's name (a copy) to ao with the signal sig, leaving KODE_SCHED_POST_MARGIN (2) places free in the queue of ao. The entry is done KODE_SCHED_WORK_BOUND_MS after the post and is skipped while its previous event is within the bound. A post that the queue would not take is a WARN, and the entry did not fire.

Asleep: the wake for an entry

AO_Scheduler joins the rounds of AO_LowPower as scheduler.

The mqtt entry

The mqtt entry is the net under the interrupt. The C5 wakes the P4 for a message on MQTT or BLE and the answer is immediate. The entry does not replace that. It covers the case in which the interrupt is lost.

Its time is */15 * * * * on a board with nothing stored (KODE_SCHED_MQTT_DEFAULT). The cron verb changes it to any expression, with a minimum period of one minute, or switches it off with when=off. A switched-off mqtt shows as when="off" with next=-, and giving it a time switches it on again. It takes no line.

AO_Scheduler does not wake the board for mqtt. It hands the time of the next check to AO_Net with net_check_at(), and AO_Net does the rest (sdk/kaos/net):

The broker must keep a persistent session for the safety net to be worth anything. A message that the broker does not hold for a Kode Dot that is not connected is lost before it looks. Holding it is the broker's side: a session that survives the disconnect and QoS 1 on the topics that matter. AO_Scheduler does not provide it.

cron from the Kode channel

The verb cron is accepted from the Kode channel like every other verb. The risk is that a line from the channel can leave a stored entry. The entry runs later, with no session open and nobody at the board, and it runs every verb that a SCHED line may run. Anyone who can publish on cmd/in can leave work for the future. List the entries with cron and remove the one that should not run with cron name="<name>" when=off. The replies go out on cmd/out, as for any verb (internals/kode-channel).

Storage

The console entries and the time of mqtt are stored in the NVS namespace kcron, which only AO_Scheduler writes. Each console entry has one record under the key e<slot> and mqtt has one under the key mqtt. A record is a SchedStored blob of 321 bytes: a version byte, the name, the time and the line, with a _Static_assert on the size.

AO_Scheduler skips a record that cannot be read, has a wrong length or version, an unterminated text, a bad name or an expression that does not parse, with a WARN that says the stored record <key> is skipped: <why>. A board with nothing usable stored has mqtt at its default. A write that fails keeps the entry in RAM with stored=no, and a WARN.

An app cannot write the store, because the apps region has a flash-write guard. In a build without CONFIG_KODEOS_BOOT_ROLE_OS nothing is read or written and every entry lives in RAM, mqtt at its default.

The table is KODE_SCHED_MAX entries of about 350 bytes. It is taken from PSRAM once, when the Active Object is constructed (design rule 8). If PSRAM has no room, the log says no memory for the table; the scheduler runs nothing, every request is refused memory and nothing fires.

Specifications

The verb

FormWhat it does
cronLists the entries, mqtt first. It is always listed
cron name="x"Shows one entry
cron name="x" when="0 7 * * *" cmd="ping"Makes or changes a console entry. cmd must start with a verb of this build other than cron. mqtt takes no cmd
cron name="x" when=offRemoves a console entry, or switches mqtt off. off is the one bare word when takes

A record reads >|cron name="mqtt" when="*/15 * * * *" cmd=- owner=builtin next=<epoch> stored=no. The syntax of the verb is in internals/command-set.

Signals

SignalHowFrom
SCHED_SET_REQ_SIGsched_request_set()The console: add or change an entry that runs a line
SCHED_SET_AO_REQ_SIGsched_request_set_ao()An Active Object of an app: an entry that posts an event
SCHED_REMOVE_REQ_SIGsched_request_remove()An app: remove an entry of code
SCHED_LIST_REQ_SIGsched_request_list()The console: one entry or all
SCHED_LINE_DONE_SIGsched_line_done(), a static eventAO_Console: the line is answered
SCHED_LINE_BUSY_SIGsched_line_busy(), a static eventAO_Console: the line arrived while another command ran, and was not taken
SCHED_CHECK_DONE_SIGsched_check_done()AO_Net: the mqtt check was made
SCHED_WALL_SET_SIGThe hook kdrv_clocks calls (kdrv_clocks_wall_on_set()), a static event, margin 0kdrv_clocks: the wall clock was set
SCHED_UP_SIGsched_system_up(), a static eventkode_init(): every Active Object has its queue
SCHED_DUE_SIG, SCHED_WORK_TMO_SIG, SCHED_RETRY_SIGIts own one-shot QTimeEvtItself: the minute, the bound of the work, and the wait before a line the console was busy for goes again
LOWPOWER_ROUND_SIG, LOWPOWER_STATE_SIG, LOWPOWER_REFUSED_SIGSubscribedAO_LowPower

A request is answered to the asker with zero or more SCHED_ENTRY_SIG (SchedEntryEvt: owner, stored, next_s, name, when, cmd) and then one SCHED_END_SIG (SchedEndEvt: st, why, count). An entry is posted with KODE_SCHED_END_MARGIN (1) place left in the asker's queue so the end always has room. If an entry did not fit, the end says KODE_ERROR with the reason partial and, in count, how many entries did arrive. The reasons of a refusal are args name when cmd fields range step syntax never full owner unknown memory. A reply that has no room is logged and dropped and never asserts, because the console can be driven from the Kode channel.

AO_Scheduler posts a ConsoleLineEvt with the source SCHED to AO_Console for each line, a SchedFireEvt to the Active Object of an entry of code, NET_CHECK_AT_SIG to AO_Net through net_check_at(), and LOWPOWER_REST_SIG and LOWPOWER_WAKE_AT_SIG to AO_LowPower through lowpower_request_rest() and lowpower_wake_at().

Calls

CallWhat it does
sched_request_set(who, name, when, cmd)Makes or changes a console entry. when of "off" removes it, or switches mqtt off
sched_request_set_ao(who, name, when, target, sig)Makes or changes an entry of code
sched_request_remove(who, name)Removes an entry of code. A console entry or mqtt answers owner
sched_request_list(who, name)Lists one entry, or all of them when name is NULL
sched_line_done()Says the console answered the line it was given. Nothing happens when no line is out
sched_line_busy()Says the console was busy and did not take the line. AO_Scheduler gives it again after KODE_SCHED_RETRY_MS. Safe from any task
sched_check_done(passed_s)AO_Net's report that the check of that minute was made
sched_system_up()kode_init()'s call, once, after the last Active Object started. Exists only with the option

Each request returns false when it could not be posted: no event was left, or AO_Scheduler's queue had fewer than KODE_SCHED_REQ_MARGIN places free. Then no answer follows. With the option off every call refuses and logs.

Constants

NameValue
KODE_SCHED_MAX, KODE_SCHED_NAME_MAX, KODE_SCHED_WHEN_MAX16, 15, 63
KODE_SCHED_QUEUE_LEN, KODE_SCHED_REQ_MARGIN, KODE_SCHED_POST_MARGIN, KODE_SCHED_END_MARGIN24, 4, 2, 1
KODE_SCHED_ARM_MAX_MS, KODE_SCHED_CLOCK_POLL_MS, KODE_SCHED_SETTLE_MS60000 ms, 1000 ms, 5000 ms
KODE_SCHED_WORK_BOUND_MS, KODE_SCHED_RETRY_MS, KODE_SCHED_BACK_MAX_MIN10000 ms, 1000 ms, 60 min
KODE_NET_CHECK_DWELL_MS, SCHED_CHECK_HOLD_S3000 ms, 63 s
SCHED_CRON_HORIZON_DAYS1462

Gotchas

Example code

An app that asks for a console entry named tick that runs ping every minute. Once the wall clock is set, the log shows sched: >|pong and sched: >ok at the start of each minute. The entry lives in RAM, since an app cannot write the store, so the app makes it again at every start.

/*
 * every_minute.c - 2026-11-03 - from Kode with love
 *
 * Makes a console entry named "tick" that runs the line "ping" every minute,
 * UTC. Needs CONFIG_KODE_SCHEDULER=y, the console, and a wall clock that is
 * set (join Wi-Fi, or run "time set" from the console). Once it is, the log
 * shows "sched: >|pong" and "sched: >ok" at the start of each minute. Nobody
 * waits for the answer of the request, so the asker is NULL.
 */
#include "kode_ao.h"
 
#define TAG        "every_minute"
#define TICK_NAME  "tick"
#define TICK_WHEN  "* * * * *"
#define TICK_LINE  "ping"
 
/* Start the system, then make the entry. */
void app_main(void)
{
    kode_init();
 
    if (!sched_request_set(NULL, TICK_NAME, TICK_WHEN, TICK_LINE)) {
        KLOG_E(TAG, "the scheduler did not take the request");
    }
 
    for (;;) {
        kode_delay_ms(1000U);
    }
}

Not compiled against the SDK. TBD: a build and a run on a Kode Dot. From the console, cron name="tick" when="* * * * *" cmd="ping" makes the same entry stored, and cron name="tick" when=off removes it.

In the SDK