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
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
| Field | What it is |
|---|---|
| Name | 1 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 |
| Time | Five cron fields, at most KODE_SCHED_WHEN_MAX (63) characters |
| Work | A 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) |
| Owner | console, code or builtin |
| Stored | Whether 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.
| Owner | Made by | Stored | Changed by |
|---|---|---|---|
console | The cron verb, from any port | Yes, in kcron | The cron verb |
code | sched_request_set_ao(), from an Active Object of an app | No: the app makes it again at each boot | sched_request_set_ao() and sched_request_remove() |
builtin | The scheduler: mqtt | Its time, or its being off | The 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.
- One timer. AO_Scheduler arms one
QTimeEvtfor the earliest next minute of the entries that have work. The wait is at mostKODE_SCHED_ARM_MAX_MS(60000 ms), so a jump of the clock is noticed within a minute. At the expiry the clock is read again, and the pass runs for the minute the clock says, only if that minute is not before the one the timer was armed for. - Nothing fires while the clock is unknown. The clock kept across a power cut counts as set:
timesaysstate=approxand entries run against it. While no clock is known, AO_Scheduler asks for it everyKODE_SCHED_CLOCK_POLL_MS(1000 ms), only while an entry has work ormqttis on. The first minute that fires is the one after the clock becomes known. - A minute is skipped, not made up. An entry does not run late when its minute passes while the board is OFF, the clock is unknown, or the board is busy past that minute. This is what cron does. A wake that comes late inside the same minute still runs it.
- A jump forward fires once, for the minute it lands on, and skips the minutes it jumped over.
- A set of the clock re-arms the timer. Every set of the wall clock (
time set, a time server's answer, the Time page) calls the hook kdrv_clocks keeps (kdrv_clocks_wall_on_set()), which posts AO_Scheduler the staticSCHED_WALL_SET_SIGwith a margin of 0. AO_Scheduler arms its timer again from the new time, so the minute after a set runs andnext=is what fires. - A clock set back never repeats a minute that already ran. A clock set back by
KODE_SCHED_BACK_MAX_MIN(60) minutes or more is not waited for: the last minute is forgotten and the entries run from the new time, with a NOTICE that says so. - Nothing fires in the first
KODE_SCHED_SETTLE_MS(5000 ms) after boot, and nothing fires beforesched_system_up(). A minute that falls inside either runs late, when both are over, if the clock is still inside that minute.
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.
- One at a time. The entries that fire in the same minute are handed to AO_Console one by one, in the order of the table. The next goes out after the console has answered the one before, so at most one line is out and an answer is the answer to it. The console calls
sched_line_done()for every line of this source, on its terminator,>okor>err, whichever way the line ended. - A busy console is waited for. A line that arrives while the console runs another command, a
screenshotfrom the cable for example, is not answeredbusy. The console callssched_line_busy()instead, the entry keeps its place, and AO_Scheduler gives the line again everyKODE_SCHED_RETRY_MS(1000 ms) until the console takes it. The tries end with the bound of the minute's work,KODE_SCHED_WORK_BOUND_MS: a line the console never took is dropped with a WARN,the entry '<name>' did not run: the work of its minute ran out of time, and the entry runs again at its next minute. - The answers go to the log. A line from SCHED has no reader. Each reply line is written as a NOTICE of the tag
kcli, for examplesched: >|pongandsched: >ok. Because they are NOTICE lines,config key="log" value=w,eoroffhides them. - Which verbs it may run.
cronchecks the first word ofcmdwhen the entry is made: a word that is no verb of this build, orcronitself, is refused at once with>err code=3 msg=cmd, and nothing is stored. At run time a SCHED line obeys the exclusions of a line from the Kode channel: no payload verbs (install src="terminal",file_put,file_get,screenshot dst=console,mirror_start) and noinstall dst=c5. These are accepted when the entry is made, because the verb alone does not say whether a line is a payload one, and they answer>err code=5 msg=schedeach time the entry runs. It cannot runcroneither: an entry does not edit the table it runs from. Every other verb runs,app_run,sleepandpower_offincluded. - It is not use of the board. A SCHED line does not start the idle time over, does not count as input and does not wake ASLEEP (
sdk/kaos/lowpower).
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.
- On a round for ASLEEP, it reads the clock again, gives AO_LowPower the real time of its earliest entry that has work with
lowpower_wake_at(), lets the minute timer go because the QP tick stops, and answers ready. It answers busy only for work it still holds itself: a line that waits for the console, or an event of code within its bound (sched work), or no event was left to carry the time (sched wake). A line already handed to the console is the console's. A line that waits behind asleepor apower_offdoes not hold the round, because that line asks for the round. When the line answers with the board at rest, the lines behind it are dropped with one WARN each. With onlymqttin the table it gives no time at all, because the time ofmqttgoes to AO_Net. - The wake is a whole AWAKE, with the cause
timer. It is not a radio wake. The screen lights and every owner restores. The log saysscheduler has work due: waking for it. On the AWAKE the minute timer is armed again, and the minute the board woke for is not lost. - The rest after the work. When the wake was the timer's, AO_Scheduler calls
lowpower_request_rest(KODE_LOWPOWER_SRC_SCHED)after a pass of a minute whose work is done, a pass that fired nothing included. It asks once and does not wait for the answer. An entry is done when the console has answered its line, or, for an entry of code, when the bound has passed. For a refusal the log says at INFOthe board stays awake: <why>. Two refusals pass by themselves soon and the next pass asks again after them:request: a round is runningandrequest: request pending. Any other refusal stands for the rest of the wake, so an entry every minute does not start a round a minute that meets the same answer.input: board usedmeans a person has the board and the wake is theirs. A wake that a button, a cable or a message caused is not asked to rest. - A round for OFF is answered ready with no time, or busy
sched workwhen the scheduler holds work that has not gone out. The board boots when it wakes from OFF, and the minutes that passed are skipped.
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 time is sent when the system is up, sent again when
mqttor the clock changes, and sent after every check that AO_Net reports withsched_check_done(). It is-1while the clock is unknown or the entry is off. A time that has come stays AO_Net's until AO_Net reports the check made, for at mostSCHED_CHECK_HOLD_S(63 s): no later time is sent over it, so a wake in that minute dwells on the check instead of losing it. While the board sleeps nothing is sent to AO_Net except the next time, as the answer to a check that was made. - AO_Net gives AO_LowPower the earlier of this time and the deadline of the Kode channel. At that time the board makes a radio wake: the screen stays asleep and only the radio owners wake.
- The check wakes the radio only when something listens: the C5 link is up, Wi-Fi has an address, and the Kode channel is in use or a client of yours holds the MQTT session. Otherwise a check that falls due is passed at once.
- The radio wake stays for
KODE_NET_CHECK_DWELL_MS(3000 ms), counted from the beginning of the wake, so what the C5 or the broker holds for the P4 flows in. A check that falls due in a whole wake dwells the same 3000 ms, and while it dwells AO_Net answers a round for ASLEEP busy,check dwell, so the rest that AO_Scheduler asks for begins again until the dwell ends. The dwell is shorter than the retry window, andkqp_config.hasserts it at compile time. A round for OFF is not refused for the dwell, because it closes the sessions the dwell listens to.
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
| Form | What it does |
|---|---|
cron | Lists 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=off | Removes 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
| Signal | How | From |
|---|---|---|
SCHED_SET_REQ_SIG | sched_request_set() | The console: add or change an entry that runs a line |
SCHED_SET_AO_REQ_SIG | sched_request_set_ao() | An Active Object of an app: an entry that posts an event |
SCHED_REMOVE_REQ_SIG | sched_request_remove() | An app: remove an entry of code |
SCHED_LIST_REQ_SIG | sched_request_list() | The console: one entry or all |
SCHED_LINE_DONE_SIG | sched_line_done(), a static event | AO_Console: the line is answered |
SCHED_LINE_BUSY_SIG | sched_line_busy(), a static event | AO_Console: the line arrived while another command ran, and was not taken |
SCHED_CHECK_DONE_SIG | sched_check_done() | AO_Net: the mqtt check was made |
SCHED_WALL_SET_SIG | The hook kdrv_clocks calls (kdrv_clocks_wall_on_set()), a static event, margin 0 | kdrv_clocks: the wall clock was set |
SCHED_UP_SIG | sched_system_up(), a static event | kode_init(): every Active Object has its queue |
SCHED_DUE_SIG, SCHED_WORK_TMO_SIG, SCHED_RETRY_SIG | Its own one-shot QTimeEvt | Itself: 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_SIG | Subscribed | AO_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
| Call | What 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
| Name | Value |
|---|---|
KODE_SCHED_MAX, KODE_SCHED_NAME_MAX, KODE_SCHED_WHEN_MAX | 16, 15, 63 |
KODE_SCHED_QUEUE_LEN, KODE_SCHED_REQ_MARGIN, KODE_SCHED_POST_MARGIN, KODE_SCHED_END_MARGIN | 24, 4, 2, 1 |
KODE_SCHED_ARM_MAX_MS, KODE_SCHED_CLOCK_POLL_MS, KODE_SCHED_SETTLE_MS | 60000 ms, 1000 ms, 5000 ms |
KODE_SCHED_WORK_BOUND_MS, KODE_SCHED_RETRY_MS, KODE_SCHED_BACK_MAX_MIN | 10000 ms, 1000 ms, 60 min |
KODE_NET_CHECK_DWELL_MS, SCHED_CHECK_HOLD_S | 3000 ms, 63 s |
SCHED_CRON_HORIZON_DAYS | 1462 |
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
components/kaos/scheduler:scheduler_ao.c,sched_cron.c(plain C with no RTOS, which the host tests run as it is) andscheduler_none.cfor a build without the option.kaos.h: the calls, the owner type and the numbers of the entries.kqp_config.h: the constantsKODE_SCHED_*and the priority.