SDK
AO_LowPower
The Active Object that decides whether a Kode Dot is awake, asleep or off: the round every owner answers, the wakes, the radio wake and the refusals.
What it is
A Kode Dot is in one of three states: AWAKE, ASLEEP or OFF. AO_LowPower decides which. It owns no device and claims no resource. It asks every owner of a device to put that device at rest, and it changes the state only when all of them have said they are ready. Then it stops the QP tick and lets go of the lock that keeps the P4 awake. From there the P4 light-sleeps by itself whenever every task is idle.
| State | The screen | The devices | The P4 | The C5 | The radios |
|---|---|---|---|---|---|
| AWAKE | As the interface draws it | Each as its owner runs it | Running | Running | As configured |
| ASLEEP | Dimmed, showing the time | Each at rest | Light sleep whenever every task is idle | Light sleep, with the P4 | Kept: BLE advertises or stays connected, Wi-Fi stays associated, the Kode channel keeps its session |
| OFF | Off | Each at rest | Deep sleep | Held in reset | Off |
Ship mode is not a state. The BQ25896 switch that cuts the battery has one door, the console verb ship_mode, and it belongs to AO_Power (sdk/kaos/power). internals/power-and-sleep puts the four levels side by side.
AO_LowPower exists only with CONFIG_KODE_LOWPOWER. The option is off in the SDK and on in kodeOS. CONFIG_KODE_LOWPOWER_IDLE (on by default with it) makes AO_LowPower count the idle time and offer sleep_after. kodeOS turns it off, because its own Rest decides when the board rests: there AO_LowPower counts no idle time, config has no sleep_after row, and ASLEEP comes from a request or the interface. With it off, kode_power_sleep() and kode_power_off() still exist, log low power not built and do nothing, so an app's source compiles either way.
How it is connected
stateDiagram-v2 [*] --> AWAKE : khal_power_init, the lock is held AWAKE --> Resting : idle time ran out, or a request Resting --> AWAKE : one owner busy or silent, or a person at the board (never an order for OFF) Resting --> ASLEEP : every owner ready, target ASLEEP Resting --> OFF : every owner ready, target OFF, or an order for OFF out of budget ASLEEP --> AWAKE : BOT, MQTT, BLE line, cable line, VBUS, request, a plain owner's time ASLEEP --> RadioWake : a radio owner's time RadioWake --> ASLEEP : the radio owners are done and rested RadioWake --> AWAKE : real use of the board, or a radio owner busy ASLEEP --> Resting : off_after reached, or a request for OFF RadioWake --> Resting : off_after reached, or a request for OFF OFF --> [*] : after 100 ms, deep sleep
Resting is the time of a round. The board is still AWAKE while the round runs. The states in the code are LowPower_awake, LowPower_resting, LowPower_asleep and LowPower_off, under LowPower_on, which takes the answers and timers that arrive late. LowPower_radio is a substate of LowPower_asleep.
Priority and start
AO_LowPower runs at priority 24 (KODE_PRIO_LOWPOWER), the highest of the Active Objects. A wake has to be decided before the owners it wakes run, and a sleep has to be able to ask them all to rest. Its steps are short: a round is a publish and a count, and the sleep itself happens in the idle task. The queue holds 48 events (KODE_LOWPOWER_QUEUE_LEN) and the stack is the small tier, 3072 bytes.
kode_init() calls kode_boot_gate() before any Active Object exists, then starts AO_Log, AO_Boot and AO_LowPower in that order. It starts third so that every owner started later can join in its own initial transition. The initial transition runs khal_power_init(), which configures power management and takes the lock. The lock starts held, which is AWAKE.
The Active Object subscribes to IOEXP_INPUT_SIG, BUTTON_EVT_SIG, TOUCH_EVT_SIG, POWER_EVT_SIG, MQTT_MSG_SIG and CONFIG_REQ_SIG.
The transitions
| From | To | What causes it |
|---|---|---|
| AWAKE | ASLEEP | The idle time with no input (sleep_after), or a request: the console verb sleep, or kode_power_sleep() from an app |
| AWAKE | OFF | A request: the console verb power_off, kode_power_off(), or the answer of the Turn off card |
| ASLEEP | OFF | The time spent asleep (off_after), or a request for OFF |
| ASLEEP | AWAKE | BOT pressed; a message from the Kode channel; a console line on BLE or on the cable; a cable seen at the minute; a request for AWAKE; the work of an owner that is not a radio owner, when it falls due |
| ASLEEP | radio wake | The work of a radio owner falls due: the renewal of the Kode channel's session, its retry, or the mqtt check. The glass stays asleep and the board returns to ASLEEP when the work is done |
| OFF | AWAKE | BOT held for about a second. The wake is a deep-sleep wake, which is a boot: kodeOS starts again and lands on the face |
Three more facts hold for every row:
- A cable keeps the board AWAKE. While VBUS is present, charging or not, a request for ASLEEP or OFF is refused and the idle time only starts again. The USB console, the dock console and the debugger stay alive for this reason.
- Nothing else wakes the board. TOP, the pad, the touch panel and motion do not wake ASLEEP or OFF. An owner's work that falls due wakes ASLEEP, never OFF.
- ASLEEP goes to OFF directly. The owners pass from their ASLEEP rest to their OFF rest. Nothing lights, powers up or wakes in between.
A round of readiness
The board changes state only through a round:
- AO_LowPower numbers the round, publishes
LOWPOWER_ROUND_SIGwith the target state and arms a one-shot ofKODE_LOWPOWER_ROUND_MS(2000 ms). - Each owner that joined puts its device at rest and answers once with
lowpower_ready(): ready, or busy with a reason of at most 23 characters. The answer carries the number of the round. AO_LowPower drops and logs at debug level an answer for another round, one from an id that never joined, and a second one from the same owner. - When every answer is ready, the state changes and AO_LowPower publishes
LOWPOWER_STATE_SIG. - One busy answer, or one owner that does not answer in 2000 ms, abandons the round. The board stays AWAKE, the log names the owner and the reason, and the refusal is published as
LOWPOWER_REFUSED_SIG.
There is no round back to AWAKE: nobody may refuse a wake, so asking would only delay it. An abandoned round publishes LOWPOWER_STATE_SIG with state AWAKE and prev AWAKE (ASLEEP for an OFF round that began from ASLEEP). This is how an owner that already rested learns to restore. If no event is left to carry a state AWAKE, AO_LowPower publishes it again every KODE_LOWPOWER_STATE_RETRY_MS (20 ms) until one goes out.
A person at the board wins over a sleep nobody asked for:
- A round that the idle time began is abandoned by any input: a button, a touch, a console line. The refusal is
input: board used. - A round that a request for ASLEEP began is abandoned only by a fresh press of BOT, because the thumb that asked must not cancel it. The refusal is
BOT: pressed. A request for AWAKE also abandons it:request: cancelled. - A request for OFF is an order, and nothing a person does at the board ends it but a cable (see below).
- A cable that appears during a round abandons it:
cable: external power.
A request that meets a busy owner
An owner is busy for a moment more often than not: the radio still stopping a job, the Kode channel in the middle of its join. So a round that a request for ASLEEP began is not given up at the first busy answer or the first silence. KODE_LOWPOWER_REQUEST_RETRY_MS (1000 ms) later the same request begins a new round, until KODE_LOWPOWER_REQUEST_RETRY_WINDOW_MS (10000 ms), counted from the request, has run out. The board is AWAKE in the wait. The requester is answered once: by the new state when a round goes through, or by LOWPOWER_REFUSED_SIG with the reason of the last round when the window is over.
Only the two answers of an owner, busy and none, are asked again. Everything else ends the request at once: a cable (the refusal is AO_LowPower's own and is made before any round), BOT, a request for AWAKE, a new request that takes the place of the waiting one, and a request that cannot be taken (no such state, power management did not start, a round is running). A round that the idle time began, or a radio wake's, has no requester and is never asked again.
A request for OFF is an order
Off is what the person asked for: the Turn off card, the console's power_off, an app's kode_power_off(). No owner can refuse it, so its round is never abandoned and never begun again from AWAKE.
- A busy owner is asked again, alone. Every
KODE_LOWPOWER_REQUEST_RETRY_MS(1000 ms) AO_LowPower posts the same round to each owner that answered busy, so it can finish what it has in hand. The owners that already rested stay at rest, and the glass stays dark. The log readsOFF waits for <owner> (<why>): asked again in 1000 ms. - The order has a budget. When
KODE_LOWPOWER_ORDER_MS(10000 ms) have run out, counted from the round, the board goes OFF whoever is still busy or silent. The log names each one and saysOFF was ordered: going OFF without them. - Only a cable ends it. BOT, a touch, a console line and a request for AWAKE do nothing to the order. A cable refuses it before the round, and ends it during the round, with
cable: external power, because on a cable the board stays on. - The requester is answered once, by
LOWPOWER_STATE_SIGwith state OFF, or by the cable's refusal.
A round for OFF that AO_LowPower began itself, when off_after was reached, has no requester and keeps the rules of the idle time.
The order on the way down
For ASLEEP, AO_LowPower publishes the state and logs at NOTICE what it enters and why. Only then does it stop the QP tick and let go of the lock. The P4 sleeps only when every task is idle, so AO_Log, which runs below, writes those lines first.
OFF has no such margin, because deep sleep is immediate. After LOWPOWER_STATE_SIG with state OFF, AO_LowPower waits KODE_LOWPOWER_OFF_GRACE_MS (100 ms) for the log and the owners to finish, and then khal_power_off_enter() runs.
The Active Object supervisor (kqp_watch, sdk/kqp) cannot read a sleep as a hang. The tick stops only after every owner answered ready, and an owner answers ready when the work in hand is finished. On the way up the tick restarts first and the lock follows, both before the state AWAKE is published, so an owner that restores its device finds timers that run.
Who takes part
An owner joins with lowpower_join() in its own initial transition. QP runs that transition in the thread that starts the Active Object, so the join needs no lock. The table holds KODE_LOWPOWER_PARTICIPANTS (24) places. A join after the first round is refused and logged, and that owner is never asked.
| Owner | Joins as | In ASLEEP | Answers busy while |
|---|---|---|---|
| AO_Display | display | Dims the glass, takes the DSI link down, keeps the panel's memory, writes the time each minute | A screenshot is lent or the mirror runs (glass lent, mirror running); the renderer handed no glyphs (renderer not parked); the link or the panel would not rest (display link, display panel, glass not restored) |
| AO_Touch | touch | The CST820 interrupt off and auto-sleep on, one second after the last touch | The touch test runs (touch test) |
| AO_Imu | imu | The IMU and the magnetometer at standby | A window, a row or a read waits (imu busy) |
| AO_Haptic | haptic | The AW86233 at standby | A buzz or the row runs (haptic busy) |
| AO_Audio | audio | The codec is closed. The amplifier is the C5's pin | A sound, a recording, a stream or the row (audio playing, audio recording, audio test, audio busy) |
| AO_Nfc | nfc | The field down, the ST25R3916B asleep | A read, an emulation, a sweep, a dump, the row or a held carrier (nfc busy) |
| AO_Rfid | rfid | The receiver's pull released | A request or a job is with the worker (rfid busy) |
| AO_LedMgr | led | The LED off, the heartbeat stopped | The led row runs (led test) |
| AO_Storage | storage | Files closed, the card idle | A write or another transfer is open (file write, storage busy) |
| AO_Power | power | The PMIC's ADC back to one-shot. The gauge stays readable | A flash write, the plug row or a ship countdown (flash write, power plug test, ship countdown) |
| AO_IoExp | ioexp | Arms the expander's INT as the light-sleep wake | A failed read waits for its retry (expander retry) or the wake line would not arm (no wake line) |
| AO_Radio | radio | Starts the sleep sentinel: the C5 sleeps with the P4 | A job holds the channel, a C5 update, a park for a jump, the production row, a link that starts or stops, or the sentinel refused |
| AO_Net | net | Keeps the sessions and starts nothing new | A job, a transfer, a screen upload, a leave for a jump, or the dwell of the mqtt check (check dwell) |
| AO_Gui (kgui) | gui | Stops every timer and draws the face for the glass to hold | A flash write is in flight (write in flight) or the screen is not up yet (starting) |
| AO_Console | console | Nothing to rest | A payload crosses the wire (console transfer) |
| AO_Scheduler | scheduler | Gives the time of its earliest entry with lowpower_wake_at() | A line waits for the console, or an event of code is within its bound (sched work); no event was left for the time (sched wake) |
| AO_Boot, AO_AppMgr, AO_Autotest | boot, appmgr, autotest | Nothing to rest | A restart is on its way (home pending, restart pending), the factory reset runs (factory reset), an app is being written (app write), or a self-test run is in flight (test row) |
AO_Ir has no device of its own and does not join, since the C5 serves it. AO_Buttons does not join either: it listens to the events of AO_IoExp. Each owner's part is on its own page.
The contract of a participant
An owner that joins keeps four rules, and a fifth when it is a radio owner:
- It rests on the round and answers once. The owner subscribes to
LOWPOWER_ROUND_SIGandLOWPOWER_STATE_SIGitself, puts its device at rest or refuses, and callslowpower_ready()exactly once. - It restores on state AWAKE. An owner whose device wakes for whoever asks next has nothing to restore and listens to the rounds only.
- The restore is idempotent. A state AWAKE can arrive twice, because an abandoned round publishes it with
prevAWAKE. - It arms no
QTimeEvtin response to state ASLEEP. The tick is stopped by then and the timer would never fire. Work with a deadline that can fall due asleep is measured in real time (khal_time_us()) and handed to AO_LowPower withlowpower_wake_at()before the owner answers a round for ASLEEP. - A radio owner restores on state RADIO and ends its work with
lowpower_radio_done(). Every other owner tests for state AWAKE and never sees the radio wake.
An Active Object of your own follows the same rules. kode_ao.h brings the registration calls and the signals.
ASLEEP: the tick, the lock and the minute
ASLEEP has no QP tick, because a tick every millisecond would keep the P4 out of light sleep. kqp_tick_rest(true) stops it, and the supervisor does not judge while it is stopped. So AO_LowPower counts the time asleep on khal_timer_once(), a one-shot timer whose callback posts LOWPOWER_MINUTE_SIG. At most KHAL_TIMER_ONCE_SLOTS (4) can be pending, and a one-shot cannot be cancelled, so the Active Object keeps the due time and the number of timers outstanding and obeys only a due time that has come.
One timer serves every count. It is armed for the next minute of the wall clock, the off_after deadline or an owner's time, whichever comes first. When the clock is not set, the minute is a whole 60 s. At each minute AO_LowPower does three things:
- It checks
off_after. When the time asleep has reached it, the minute becomes a round for OFF. The count starts at the ASLEEP that follows a wake somebody used, and a wake for an owner's time that nobody used does not start it again. - It asks AO_Power for a quiet reading with
power_peek_request(). The charger's interrupt reaches no pin the P4 can sleep on, so this is how a cable that appeared is found, within 60 s. A cable wakes the board. Without a cable, the reading is the minute's sample of the summary of the last rest. - It writes the previous minute's sample into the store, then posts
DISPLAY_MINUTE_REQ_SIGto AO_Display, which writes the time on the glass. The store comes first so the two writes never overlap.
The lock is one esp_pm lock of type "no light sleep", taken at boot. AWAKE is "the lock is held". There is no forced esp_light_sleep_start(): power management sleeps by itself once the lock is let go and nothing else holds one. The P4 runs at a fixed 400 MHz, so light sleep is the saving and scaling is not used. The P4 also wakes briefly for its own housekeeping, such as the MQTT keepalive or a TCP acknowledgement. These wakes are not transitions: the state stays ASLEEP and the screen does not change.
ASLEEP: what wakes the board
Cause (the word in lowpower_info) | What it is |
|---|---|
bot | A fresh press of BOT, read from the expander's input event |
mqtt | MQTT_MSG_SIG: a message from the Kode channel or from an MQTT client of yours |
ble | A console line that arrives on BLE |
cable | A console line on the serial port or the dock, or a POWER_EVT_SIG that says VBUS is present |
timer | An owner's time: LOWPOWER_WAKE_AT_SIG named a time and it has come. A radio wake for a radio owner, a whole wake for AO_Scheduler |
request | A request for AWAKE |
Every console line from any source calls lowpower_input_note() before the busy check. For the cable, BLE and the Kode channel it starts the idle time over, and in ASLEEP it wakes the board: a refused line is a person at the other end as much as an accepted one. A line with the source SCHED, which AO_Scheduler runs on its own account, is not input.
ASLEEP: work that falls due
A QTimeEvt does not count while the tick is stopped. An owner whose work has a deadline in hours keeps it in real time, khal_time_us(), which runs through light sleep. The Kode channel does this for the renewal of its session and its back-off, and AO_Scheduler for the minute of its earliest entry.
Before an owner answers a round for ASLEEP, it calls lowpower_wake_at(who, due_us), which posts LOWPOWER_WAKE_AT_SIG. AO_LowPower keeps two times: the earliest a radio owner gave, and the earliest any other owner gave. They are kept apart because a radio wake asks the radio owners alone to say their times again. The time folds into the one timer of the minute. A time that has passed is due at once, with the timer armed for 1 µs.
When the timer comes and the time has come, the board wakes with the cause timer. off_after is judged first, so a time that falls due together with it goes to OFF. After that:
- A radio owner's time makes a radio wake. The log says
<owner> has work due: a radio wake, the glass stays asleep. - Any other owner's time is a whole AWAKE with the cause
timer. The log says<owner> has work due: waking for it. AO_Scheduler is such an owner, and its entries wake the whole board, glass included.
The times are forgotten when a round begins and when the board wakes, and an owner says its time again in every round. A time that no longer matters costs one wake that finds nothing to do.
The radio wake
A renewal of the Kode channel's session needs the network and not the glass, so the wake for it does not light the board. A radio wake is a phase of ASLEEP, and lowpower_info still says state=asleep.
| Step | What happens |
|---|---|
| Begin | A radio owner's time comes. AO_LowPower forgets that time, arms the minute again, takes the tick and the lock, counts the wake in wakes_timer, publishes LOWPOWER_STATE_SIG with state KODE_LP_RADIO (cause timer, prev ASLEEP) and arms the bound KODE_LOWPOWER_RADIO_MAX_MS (60000 ms) |
| Work | The radio owners restore on state RADIO. AO_Radio stops the sleep sentinel so the bus is awake, and AO_Net does what the channel has due. The display, the touch panel, the interface and every other owner test for AWAKE, stay at rest, and the minute keeps being written on the glass |
| Done | Each radio owner calls lowpower_radio_done() when its work is finished |
| Rest | When every radio owner is done, or when the bound runs out, AO_LowPower posts a round for ASLEEP to the radio owners alone and arms the round's one-shot. A published round would ask every owner to rest again, and the others never woke |
| Back | When every answer is ready, AO_LowPower publishes LOWPOWER_STATE_SIG with state ASLEEP (cause timer, prev RADIO), stops the tick and lets go of the lock. No glass was lit |
Entering and leaving the radio wake never enters or exits LowPower_asleep itself, because QP does not exit or enter a parent for a transition between it and its own child. So the time asleep, the off_after count, the summary of the rest and the one-shots of the minute run on through it.
A radio wake becomes a whole AWAKE when something real uses the board: BOT, a message from the broker, a console line on BLE or the cable, a cable, or a request for AWAKE. A radio owner that answers the closing round busy, or does not answer in 2000 ms, does the same, and the refusal is published. A radio wake that reaches off_after goes to OFF as the ASLEEP rest would. Another owner's time survives a radio wake in its own slot, so a radio time that is earlier never hides it.
The rest after the work of a timer wake
A whole wake for an owner's time should end when the owner's work does, and not after the idle time. The owner cannot judge that alone, because it does not know who else is at the board, so it calls lowpower_request_rest(src) when its work is done. AO_Scheduler is the caller, with src equal to KODE_LOWPOWER_SRC_SCHED (0xFD).
AO_LowPower keeps one flag, used. It is false after a wake whose cause is timer, and true after every other wake, the boot included. It turns true at the first real input since the wake: a button, a touch, a console line from the cable, BLE or the Kode channel, a fresh press of BOT, or a cable that appears.
- The board was not used. The request is taken like any request for ASLEEP, retries included, without waiting for the idle time.
- The board was used, or is not AWAKE, or a round is running, or another request waits to begin again. The request is refused and nothing else changes. The board sleeps by the ordinary idle time.
- A person comes while the rest's round or its retry runs. The rest is given up and the requester hears
input: board used. - The requester is answered once, by
LOWPOWER_STATE_SIGor byLOWPOWER_REFUSED_SIGwith itssrc. The one case with no answer is afalsereturn, when no event was left to post the request.
OFF
OFF closes the sessions first. AO_Net closes the Kode channel and any MQTT session, then tells AO_Radio with radio_net_left(). AO_Radio waits for that, stops the sleep sentinel if it runs and answers. The C5 link and the BLE host stay up and nothing is taken down: deep sleep stops the P4 whole, khal_power_off_enter() holds the C5 in reset, and tearing the link down while the BLE host still runs would panic that host.
khal_power_off_enter() holds five pins at their rest levels through deep sleep: the card's supply (off), the NFC chip's select (high), the pull of the LF front end, the reset of the panel and touch, and the C5's enable (low), which is written last. It arms the expander's interrupt line, GPIO6, as the one wake source: ext1 on a low level.
The boot from OFF
The expander's interrupt line is low when any of the five buttons on the expander is down, so the wake alone does not say that BOT was pressed. kode_init() calls kode_boot_gate() right after kode_log_init(), before any Active Object starts:
- On a cold boot, or any boot that is not a wake from OFF, the gate lets go of the pin holds and returns.
- On a wake from OFF with BOT down, it lets go of the holds and the boot goes on.
kaos.bootlogswoke from OFF (BOT). - On a wake from OFF with any other button, it goes back to deep sleep without releasing the holds. The C5 stays in reset, the card unpowered and the screen dark.
- If the expander does not answer, the gate cannot say what woke the board. The boot goes on, with a warning.
The gate reads BOT's live level about one second after the press, because the boot takes that long to reach kode_init(). The expander keeps no latch, so a tap that is released before the gate reads sends the board back to OFF. To wake the board from OFF, hold BOT for about a second.
This gate is a documented exception to design rules 1 and 3 (sdk/design-rules). It calls kdrv_tca6408a_init() and kdrv_tca6408a_get() itself, although AO_IoExp owns the expander, and it reads the chip with no event. No Active Object runs yet at that point, and the pad button has to decide the boot before the first of them starts.
The screen in ASLEEP
The CO5300 keeps the picture in a memory of its own, the GRAM, and shows it with nobody driving it. So the DSI link can go while the screen stays lit. AO_Display takes the link down, and with it the video scan-out, both framebuffers and the rail of the DPHY. Each minute it brings a small link up, writes the rectangle of the time into the GRAM and takes the link down again (sdk/kdrv/co5300).
The SDK cannot draw, because lv_* lives only in kgui, kui and kscreen. So the pixels come from the owner of the screen. On the round, the renderer draws the page the glass holds while it sleeps and renders the ten digits and the colon once into a strip in PSRAM. It hands the strip to AO_Display with display_glyphs_hand(), in a DisplayGlyphsEvt that also carries the position of the time, the dimming level and whether the face shows a 12 h dial. AO_Display composes hh:mm from the strip in the local time of the face's zone and writes it. LVGL does not run. A renderer with nothing to hand over passes a NULL strip. The glass is then dimmed to KODE_LOWPOWER_DIM_PCT (20 %) and stays dark, and the minute writes nothing.
The strip is a pointer, the one exception to design rule 7 that this event makes. Eleven glyphs of a large font are far more than an event can carry. The strip stays the renderer's, valid from the hand-over until the board is AWAKE again, and AO_Display never frees or writes it.
The link going down frees both framebuffers, and the link coming up allocates a new pair at addresses that can differ. The contract with a renderer is:
- On the round, the renderer stops drawing and flushes its page. AO_Display lets flushes through while it waits for the glyphs and drops them afterwards.
- The glyphs are the renderer's word that it has stopped. AO_Display takes the link down only after
DISPLAY_GLYPHS_SIGarrives. If none arrives inKODE_LOWPOWER_GLYPHS_WAIT_MS(1500 ms, under the 2000 ms of the round), it answers busy withrenderer not parkedand the round is abandoned safely. - On AWAKE, AO_Display brings the link up and publishes
DISPLAY_REPAINT_SIGon every exit from a rest it began, a busy refusal included. The renderer fetches both buffers again fromkdrv_display_back_buffer()andkdrv_display_front_buffer()and paints the whole glass.kdrv_display_buffers_epoch()changes with every new pair.
The radios while the P4 sleeps
ASLEEP keeps the radios, so the P4 and the C5 sleep together, with work on both sides of the SDIO link.
On the P4, the sleep sentinel of sdk/kdrv/c5 keeps the esp-hosted link alive. AO_Radio starts it on the round with a job of its radio worker, and rule 9 holds: the sentinel runs the handshake and reports what happened, and decides no state. Going down, it marks the bus silent, arms the wake line KBSP_PIN_C5_WAKE_P4 (GPIO2), announces power save to the C5, waits 20 ms and suspends esp-hosted's three SDIO tasks, which would otherwise poll every 10 ms and wake the P4 a hundred times a second. If the C5 refuses, the line does not arm or the tasks never settle, the bus stays up and the round is answered busy with the reason.
Coming up starts on the C5's line or on a frame the P4 queued. A hook on esp_hosted_tx() catches every outgoing frame, and the sentinel knocks the C5 awake before the frame is sent. After a wake the P4 stays up for 100 ms for the traffic to land, and for another 3 s each time the BLE GAP count has moved. A held BLE connection does not keep the P4 awake: the controller in the C5 keeps the connection and the P4 sleeps between its events.
On the C5, the host-sleep code acts only while the P4 has announced power save:
- The C5 enters light sleep and wakes on its SDIO CMD line (IO10, low level), so the P4 can knock. The first command of a knock is lost and the host retries it.
- A frame filter drops group-addressed Wi-Fi frames (broadcast, multicast, IPv6) while the host sleeps. It keeps unicast, ARP requests for the host's own IPv4 address and DHCP to the client port, since each dropped frame would have woken the P4.
- The C5 raises
C5_WU_P4(C5 IO3 to P4 GPIO2) to wake the P4. - The C5 drives the amplifier's enable,
PA_CTRL(IO4), low when the host announces power save, and high again, before anything else, when the host announces that it is awake.
IPv6 neighbour solicitations are dropped while the P4 sleeps, so the P4 is unreachable over IPv6 until it wakes.
The summary of the last rest
A rest on the battery cannot be watched on the cable, because the cable keeps the board awake and opening the serial port resets it. So AO_LowPower keeps one record of the last rest in the blob lp_last of the kset namespace, written only by AO_LowPower. It is a lowpower_last_t: 32 bytes of totals and a ring of KODE_LOWPOWER_LAST_SAMPLES (60) samples of 8 bytes, 512 bytes in all, with a _Static_assert on the size. A record of another version or size reads as none.
| Field | What it holds |
|---|---|
start_s | The wall clock when the rest began, in Unix seconds. 0 when the clock was not set |
duration_s | How long the rest had lasted at the last write |
slept_pct, sleeps | The share of the rest spent in light sleep, and the light sleeps in it |
wakes_c5, wakes_tx, deaf | The wakes of the P4 by the C5's line and by its own traffic for the C5, and the wakes the C5 never answered |
end_cause | The cause that ended the rest, or KODE_LOWPOWER_LAST_OPEN while no end was written |
samples | One sample for each minute: seconds from the start, the cell's current in mA (into the cell positive) and its voltage in mV |
The totals are those of the rest and not of the boot. The sample is the answer to the minute's peek of AO_Power. The P4 is awake for the minute anyway, so the gauge is read for a few milliseconds at about 75 mA, and the sample carries that bias. A reading with a cable in it is no sample, because the cable wakes the board.
The working copy is 512 bytes in PSRAM, taken when the rest begins and given back when it ends. Each sample is kept in it when it arrives and the whole record is written as one blob at the start of the next minute, before AO_Display is asked for the time. Erasing a page of the store stops the flash cache on both cores for milliseconds, and a GRAM write that runs then fails, so the order matters. One more write follows when the rest ends. The record a reset finds is at most one minute behind, and a rest that ends before its first minute leaves the earlier summary alone. A board that sleeps all day writes 1,440 times a day. If PSRAM has no room, the rest goes on without a summary and the log says so.
Specifications
Settings
AO_LowPower owns two stored settings of config, each a 4-byte blob of seconds in the kset namespace. A stored value that is missing, of the wrong size or not one of the listed words reads as the default. Each set logs <key> <word> (stored) at NOTICE.
| Key | Values | Default | Meaning |
|---|---|---|---|
sleep_after | 30s, 1m, 2m, 5m, 10m, never | 2m | The idle time in AWAKE. A button, a touch or a console line starts it over. It does not depend on the auto-lock of the face or on Kody's sleep |
off_after | 1h, 6h, 24h, 3d, 7d, never | 1h | The time spent in ASLEEP before OFF |
A change of sleep_after starts the idle time over at once. never counts nothing. Without CONFIG_KODE_LOWPOWER_IDLE the row does not exist.
Refusals
A refused request answers the verb with >err code=4 msg="<who>: <why>". Code 4 is "busy". lowpower_last_refusal() keeps the last one and lowpower_info prints it as last_busy.
| Text | Meaning |
|---|---|
cable: external power | VBUS is present. The board neither sleeps nor powers off |
<owner>: <why> | A participant answered busy, for example storage: file write |
<owner>: no answer | A participant did not answer in 2000 ms |
request: a round is running | A second request while a round is open |
request: request pending | A rest was asked while another request waits to begin again |
request: cancelled | A request for AWAKE ended the round |
request: board not AWAKE | A rest was asked while the board was not AWAKE |
request: no such state | The target is not one of the three states |
request: power management did not start | khal_power_init() failed. The board stays AWAKE |
BOT: pressed | The thumb of a person at the board ended a round that was requested |
input: board used | An input ended a round that the idle time began, or the board was used since its last wake |
With the option off, sleep and power_off answer >err code=4 msg=not_built and lowpower_info does not exist.
Console verbs and calls
The syntax of the verbs is in internals/command-set.
| Verb or call | What it does |
|---|---|
sleep | Takes the board to ASLEEP. >ok when the state changed |
power_off | Takes the board to OFF |
ship_mode | Opens the battery switch. It is AO_Power's, the only door to ship mode |
lowpower_info | Two records, >|lowpower (state, seconds in it, cause, the two times, wakes by bot, ble, mqtt and timer, last_busy) and >|lowpower_stats (light-sleep share, sleeps, the C5's wakes and its frames, deaf), then >ok |
lowpower_info last=on | The summary of the last rest: a >|lowpower_last header and one >|lp_sample per minute, oldest first, in slices of KAOS_WIRE_SLICE (12). end=asleep means no end was written because a reset ended the rest. A board with no summary prints >|lowpower_last present=no |
kode_power_sleep(), kode_power_off() | The facade calls. Both return at once. The answer is LOWPOWER_STATE_SIG, or LOWPOWER_REFUSED_SIG when the board will not (sdk/kode) |
lowpower_request(target, src) | Asks for a state. src is the console source, or KODE_LOWPOWER_SRC_APP (0xFE) for an app |
lowpower_request_rest(src) | Asks for ASLEEP once an owner's work is done. Returns false when no event was left to post it |
lowpower_input_note(src) | Says a console line came in. Safe from any task |
lowpower_join(ao, name), lowpower_join_radio(ao, name) | Join the rounds. They return KODE_LOWPOWER_WHO_NONE when the table is full |
lowpower_ready(who, round, busy, why) | Answers a round |
lowpower_wake_at(who, due_us) | Gives the real time at which the owner's work falls due. False when no event was left |
lowpower_radio_done(who) | A radio owner's work in a radio wake is finished. Dropped outside a radio wake |
lowpower_last_read(out), lowpower_last_sample(l, i) | Read the summary and walk its samples. Safe from any task |
The cause word in lowpower_info is request, idle, off_after, bot, ble, mqtt, cable, timer or boot. The wakes_ fields count wakes from ASLEEP by the cause that began them. A radio wake that BOT then turns into a whole AWAKE counts in wakes_timer and in wakes_bot.
Signals
AO_LowPower publishes LOWPOWER_ROUND_SIG (LowPowerRoundEvt: target, round), LOWPOWER_STATE_SIG (LowPowerStateEvt: state, cause, prev) and LOWPOWER_REFUSED_SIG (LowPowerRefusedEvt: the source and the text). It posts DISPLAY_MINUTE_REQ_SIG to AO_Display and POWER_PEEK_REQ_SIG to AO_Power. It takes the posted signals LOWPOWER_REQ_SIG, LOWPOWER_READY_SIG, LOWPOWER_REST_SIG, LOWPOWER_INPUT_SIG, LOWPOWER_WAKE_AT_SIG, LOWPOWER_RADIO_DONE_SIG and LOWPOWER_MINUTE_SIG, and five private one-shots. sdk/events lists each one.
Timers
| Timer | What it times |
|---|---|
LOWPOWER_ROUND_TMO_SIG | A round, KODE_LOWPOWER_ROUND_MS (2000 ms). After state OFF, the grace before deep sleep, KODE_LOWPOWER_OFF_GRACE_MS (100 ms) |
LOWPOWER_IDLE_TMO_SIG | The idle time in AWAKE, the value of sleep_after |
LOWPOWER_RETRY_TMO_SIG | The wait before a requested round that met a busy or silent owner begins again, 1000 ms |
LOWPOWER_RADIO_TMO_SIG | The bound of a radio wake, 60000 ms |
LOWPOWER_STATE_RETRY_SIG | The wait before a state change that found no event is published again, 20 ms |
khal_timer_once() | The minute, the off_after deadline and an owner's time in ASLEEP, where the tick is stopped |
Gotchas
Example code
An app that asks for ASLEEP ten seconds after it starts. On battery the log shows entering ASLEEP (request) and the Kode Dot sleeps until BOT, a message or a cable wakes it. With a cable in, the log shows request for ASLEEP (source 254) refused: cable: external power and the app keeps running.
/*
* sleep_later.c - 2026-11-03 - from Kode with love
*
* Starts the system, waits ten seconds and asks AO_LowPower for ASLEEP. On
* battery the log shows "entering ASLEEP (request)", the screen dims to the
* time and the Kode Dot sleeps until BOT, a Kode channel message or a cable
* wakes it. With USB plugged in, the log shows that the request was refused
* because of the cable, and the app goes on running.
*/
#include "kode.h"
#define TAG "sleep_later"
#define BEFORE_SLEEP_MS 10000U
/* Wait, then ask for the rest. The call returns at once whatever the answer. */
void app_main(void)
{
kode_init();
KLOG_I(TAG, "asking for ASLEEP in %u ms", (unsigned)BEFORE_SLEEP_MS);
kode_delay_ms(BEFORE_SLEEP_MS);
kode_power_sleep();
for (;;) {
kode_delay_ms(1000U);
}
}Not compiled against the SDK. TBD: a build and a run on a Kode Dot on battery.
In the SDK
components/kaos/lowpower:lowpower_ao.c, the summary inlowpower_last.c, andlowpower_none.cfor a build without the option.components/khal/power: the lock, the wake lines and deep sleep.components/kode/src/kode_gate.c: the boot gate.kaos.h: the calls and types of this page.