# 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

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

*The states of AO_LowPower*

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

1. AO_LowPower numbers the round, publishes `LOWPOWER_ROUND_SIG` with the target state and arms a one-shot of `KODE_LOWPOWER_ROUND_MS` (2000 ms).
2. 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.
3. When every answer is ready, the state changes and AO_LowPower publishes `LOWPOWER_STATE_SIG`.
4. 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 reads `OFF 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 says `OFF 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_SIG` with 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:

1. **It rests on the round and answers once.** The owner subscribes to `LOWPOWER_ROUND_SIG` and `LOWPOWER_STATE_SIG` itself, puts its device at rest or refuses, and calls `lowpower_ready()` exactly once.
2. **It restores on state AWAKE.** An owner whose device wakes for whoever asks next has nothing to restore and listens to the rounds only.
3. **The restore is idempotent.** A state AWAKE can arrive twice, because an abandoned round publishes it with `prev` AWAKE.
4. **It arms no `QTimeEvt` in 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 with `lowpower_wake_at()` before the owner answers a round for ASLEEP.
5. **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:

1. 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.
2. 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.
3. It writes the previous minute's sample into the store, then posts `DISPLAY_MINUTE_REQ_SIG` to 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_SIG` or by `LOWPOWER_REFUSED_SIG` with its `src`. The one case with no answer is a `false` return, 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.boot` logs `woke 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:

1. **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.
2. **The glyphs are the renderer's word that it has stopped.** AO_Display takes the link down only after `DISPLAY_GLYPHS_SIG` arrives. If none arrives in `KODE_LOWPOWER_GLYPHS_WAIT_MS` (1500 ms, under the 2000 ms of the round), it answers busy with `renderer not parked` and the round is abandoned safely.
3. **On AWAKE**, AO_Display brings the link up and publishes `DISPLAY_REPAINT_SIG` on every exit from a rest it began, a busy refusal included. The renderer fetches both buffers again from `kdrv_display_back_buffer()` and `kdrv_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

> **WARNING**
>
> **`sleep` and `power_off` answer `cable: external power`.** A USB cable is in. VBUS keeps the Kode Dot awake by design, so the console and the debugger stay alive, and a request is refused before any round. Pull the cable and drive the states over BLE or the Kode channel, then read `lowpower_info` after a wake.

> **WARNING**
>
> **A tap of BOT does not wake the Kode Dot from OFF.** The boot gate reads the button about a second after the press, and the expander keeps no latch. A tap that is released first sends the board back to deep sleep. Hold BOT for about a second.

> **NOTE**
>
> **Any console line wakes ASLEEP.** A line is a person at the other end, whatever the port, and the cause is the word of its port: `ble`, `mqtt` or `cable`. A line from AO_Scheduler is the one that is not.

> **NOTE**
>
> **A timer started in response to ASLEEP never fires.** The tick is stopped by then. Arm nothing on state ASLEEP, and give a deadline that must survive a sleep to AO_LowPower with `lowpower_wake_at()`.

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

```c
/*
 * 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`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/lowpower): `lowpower_ao.c`, the summary in `lowpower_last.c`, and `lowpower_none.c` for a build without the option.
- [`components/khal/power`](https://github.com/kodediy/kode-sdk/tree/main/components/khal/power): the lock, the wake lines and deep sleep.
- [`components/kode/src/kode_gate.c`](https://github.com/kodediy/kode-sdk/blob/main/components/kode/src/kode_gate.c): the boot gate.
- [`kaos.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kaos/core/include/kaos.h): the calls and types of this page.
