# AO_Power

The Active Object that owns the charger and the gauge: the 2 s battery reading, the boot policy, boost and charge control, ship mode and its part in the rest.

## What it is

The Kode Dot has two battery chips: the BQ25896 charger and the CW2217 fuel gauge (`sdk/kdrv/bq25896`, `sdk/kdrv/cw2217`). **AO_Power** is the only code allowed to talk to them. The drivers know their chip as the datasheet describes it. AO_Power knows what this board needs: the settings written to the charger at every boot, how to wake the gauge, how to turn two readings into one percentage, and which requests to refuse.

AO_Power does five things:

1. Every 2 s it reads both chips and publishes one battery reading.
2. At every boot it writes the board's policy into the charger, which keeps its registers across a reflash of the main chip.
3. It carries out the operations a console or an app can ask for: the 5 V boost on the expansion header, charging on or off, the charge current, and a factory reset of the charger.
4. It opens the battery switch when the console verb `ship_mode` asks. The states ASLEEP and OFF are not its: `sdk/kaos/lowpower` decides them, and AO_Power rests its ADC when that Active Object asks.
5. It pulses the RGB LED blue while flash is written, with the screen on or off. The LED never shows the battery state.

## How it is connected

```mermaid
stateDiagram-v2
  [*] --> Power_polling : claim, start the charger, write the policy, wake the gauge
  Power_polling --> Power_polling : POWER_POLL_SIG, pulse, peek, ship, field, test, charge, config
  Power_polling --> Power_waiting : POWER_CTRL_REQ_SIG for boost or pmic_reset
  Power_waiting --> Power_polling : POWER_CTRL_TICK_SIG, the check is done
  Power_waiting --> Power_waiting : another request answers KODE_NO_RESOURCES
```

*AO_Power's states*

### Claims and start-up

AO_Power runs at priority 8 with a queue of 8 events and a 4096-byte stack. It claims `KBSP_RES_PMIC` (I2C `0x6B`) and `KBSP_RES_GAUGE` (`0x64`). Its initial transition subscribes to `APP_FLASH_PROGRESS_SIG`, `APP_FLASH_DONE_SIG`, `APP_FLASH_FAIL_SIG` and `CONFIG_REQ_SIG`, and with `CONFIG_KODE_LOWPOWER` joins the rounds of rest as `power` and subscribes to `LOWPOWER_ROUND_SIG` and `LOWPOWER_STATE_SIG`. It reads the setting `led_activity`, starts the charger, writes the policy and the stored `charge` and `charge_ma` over it, and starts and wakes the gauge. If the charger does not take its policy, the flag `pmic` stays false, AO_Power logs the field the chip refused, and every operation answers `KODE_DENIED` with one shared log line. The gauge is optional: it is powered from the cell, so a board with no battery has none.

### The waiting substate

`Power_waiting` is a substate of `Power_polling`, used only while a `boost` or `pmic_reset` waits for the charger to settle. The poll, `bat_info` and everything else keep working, and leaving the substate does not restart the poll timer. A second operation that arrives meanwhile is answered `KODE_NO_RESOURCES`.

### The poll

The first reading waits 1500 ms, because the charger's measuring circuit needs about 1.2 s after it is switched on and the gauge needs about a second after it wakes. Asked sooner, both answer the lowest value of every range, which looks like an empty cell. After that the poll runs every 2000 ms and publishes a reading every time, changed or not, so a screen gets one after a repaint.

AO_Power reads the measurements in one block so voltage and current come from the same conversion, then the charge phase, the input and the fault register. Each read is checked on its own: when the bus does not work AO_Power publishes nothing and never hands out 2304 mV as a measurement.

### The reading

`boost` is true when the USB line status reads 7, the chip making its own 5 V. `vbus_mv` is 0 unless an input is good or the chip is boosting.

Until the gauge reports ready, the voltage is the charger's, in 20 mV steps. After that, when the gauge reads more than 2000 mV, AO_Power uses the gauge's finer voltage and its current. The sign of the current is already positive for charging on this board.

The percentage is computed by AO_Power and is never read from the gauge, which has no battery profile loaded. The steps are:

1. Estimate the resting voltage: the measured voltage minus the current times the cell's internal resistance, `KBSP_CELL_R_INT_MOHM` (180 mΩ).
2. Smooth it with a running average of weight 1/5, kept in sixteenths of a millivolt so a small step does not round to zero.
3. Look it up on the 13-point curve of `kbsp_cell.h`, interpolating in a straight line.
4. Let the result move by at most 2 % per reading.

All of it is integer arithmetic. The charger decides when the cell is full, and the curve does not: the charge voltage is 8 mV under the pack's nominal on purpose, so when the charger reports the done phase the target becomes 100 at once and the percentage climbs to it at 2 % per reading.

### The guards

Two checks run after each reading is published, and both change what they remember only after the chip accepted the write. A failed write is retried at the next poll.

- `boost_guard()` runs while a boost is wanted. Below `KBSP_CELL_BOOST_STOP_MV` (3300 mV), or on a latched boost fault, it switches the boost off.
- `charge_guard()` holds charging when the charger's temperature band is anything but normal, a narrower window than the chip's, 10 to 45 °C for this pack. It resumes when the band returns to normal. With no pack the sensor pin floats and reads cold, so charging is held the same way.

The reading the guard's pass publishes still shows the state as read. A boost the guard stopped appears in the next reading.

### The wall clock

The supply AO_Power can switch off takes the RTC with it, so every poll calls `kdrv_clocks_wall_keep()`, which writes the time to NVS when one is due (every 60 s, on a jump of 60 s, or forced before ship mode).

### The LED pulse

From the first `APP_FLASH_PROGRESS_SIG` until the write ends, AO_Power publishes `LED_RGB_SIG` alternately blue (`0x02 0x8A 0xC2`) and dark every `KODE_LED_PULSE_MS` (500 ms). It publishes only changes, and it never calls the LED driver, since AO_LedMgr owns the chip (`sdk/kaos/led`). `APP_FLASH_DONE_SIG` or `APP_FLASH_FAIL_SIG` end the pulse. A write that sends no progress for `POWER_LED_WRITE_MS` (3 s) ends it too, because a host that died mid-transfer sends neither.

AO_AppMgr repeats the percent once a second while the card writes a copy, to keep the pulse going. The pulse applies to the next write when `led_activity` is set to `off`, and darkens one that is running.

### Ship mode

`POWER_SHIP_REQ_SIG` opens the battery switch. The only way to post it is the console verb `ship_mode`, through `power_ship_request()`. The facade has no call for it and no app reaches it. It does these things in this order:

1. It publishes `POWER_SHIP_SIG`, so an AO of higher priority gets one whole step to put its device away (AO_Audio is the one subscriber), and it forces the wall clock to NVS.
2. It logs `ship mode: opening the BATFET` and the wake instructions, because on battery the board is dead the instant the write lands.
3. It calls `kdrv_bq25896_ship_mode(false)`.

If AO_Power is still running, USB is holding the board up (`battery path open; VBUS is keeping the board up`) or the call failed (`ship mode FAILED`). Ship mode is not a state of the board and not a round: the owners of AO_LowPower are not asked to rest first. The Turn off card of kodeOS asks AO_LowPower for OFF with `kode_power_off()`, and does not reach AO_Power.

**Ship after the cable leaves.** `power_ship_on_unplug_request(who)` posts `POWER_SHIP_ARM_SIG`. AO_Power counts `KODE_SHIP_UNPLUG_MS` (15000 ms) from the poll that sees VBUS gone, then posts one `POWER_SHIP_DUE_SIG` to `who`. A poll that sees the cable back cancels the count and AO_Power stays armed, so the next unplug counts the 15 s again. AO_Power never ships by itself: the asker writes what must survive and then calls `power_ship_request()`. The factory reset's ship end in AO_Boot is the caller. While it is armed, a round of rest is answered busy `ship countdown`, because resting stops the poll that watches the cable leave.

### Resting with the board

AO_Power joins the rounds of AO_LowPower in its initial transition. The PMIC's ADC runs one conversion at a time, started at the end of a poll, and idles when it is done. On a round, AO_Power writes the conversion rate back to one-shot, in case a bench tool left it continuous, and answers ready. The polls stop with the QP tick. The gauge is never touched: it stays readable.

A flash write, the plug row or a ship countdown makes AO_Power answer busy (`flash write`, `power plug test`, `ship countdown`). On `LOWPOWER_STATE_SIG` with state AWAKE, an AO_Power that rested starts a conversion, so that the first poll reads something taken after the sleep. While it is rested it ignores `POWER_POLL_SIG`: a radio wake runs the tick, but the ADC is idle and its registers are stale, so a poll would act on old readings.

While the board is ASLEEP, AO_LowPower asks for a reading once a minute with `power_peek_request()`. AO_Power keeps the wall clock if a keep is due, takes the reading and publishes it, and logs nothing. The reading tells AO_LowPower whether a cable is present. The status registers of the charger and the gauge need no conversion, so the cable is seen fresh while the board sleeps.

### The operations

`POWER_CTRL_REQ_SIG` carries an op and a value, and `POWER_CTRL_SIG` always answers, a refusal included.

| Op | Facade call | What AO_Power adds to the register write |
| --- | --- | --- |
| `POWER_CTRL_BOOST` | `kode_power_boost(on)` | Refuses `on` in three cases, then writes the bit, waits 50 ms in `Power_waiting` and reads the USB line status back. See Boost below |
| `POWER_CTRL_CHARGE` | `kode_power_charge(on)` | Writes "wanted and not held by temperature" and answers with the bit read back. A request to charge while the guard holds answers `KODE_OK` with 0 |
| `POWER_CTRL_ICHG` | `kode_power_charge_limit(ma)` | Refuses a value outside 64 to `KBSP_CELL_ICHG_MAX_MA` (896 mA) before it uses the bus. Answers what the chip kept on its 64 mA grid, so 500 reads back 512 |
| `POWER_CTRL_RESET` | none | Writes the reset bit, polls it every 10 ms up to ten times, then applies the policy again and the stored settings. `KODE_TIMEOUT` if the bit never clears |

Boost refuses `on` when USB is present, before any battery reading exists, or under `KBSP_CELL_BOOST_MIN_MV` (3600 mV). A boost that did not start is named (USB arrived, temperature outside the boost window, a boost fault, otherwise the OTG pin is low) and the bit is cleared. A boost that will not stop keeps its wish, and the guard retries.

The facade calls return `void`: the outcome is asynchronous, and an AO of yours subscribes to `POWER_CTRL_SIG`. The three wishes live in RAM. After a reboot the board starts with the boost off, the pack's charge profile, and the charging that the settings `charge` and `charge_ma` say.

### Register access

`POWER_FIELD_REQ_SIG` names a chip, an op (get, set or dump) and a field. The answer is published as `POWER_FIELD_SIG`, and a set answers with the readback. AO_Power refuses two fields: `batfet_dis`, because that write is ship mode without the log lines, and `reg_rst`, because a hand write cannot wait for the bit to clear before the policy is applied again. Their answers point at `ship_mode` and `pmic_reset`. A write to a field the policy sets is allowed, and logs that the policy is off until the next boot.

### The self-tests

`test component=pmic` and `test component=gauge` judge the chips with a battery and a USB adapter attached. The charger passes when it answers with the right part number, the policy reads back as written (except `chg_config` and `ichg_ma`, held to the last value written), the input is 4400 to 5500 mV from a host or an adapter, the cell is between `KBSP_CELL_VCUT_MV` and `KBSP_CELL_VTRIP_MV` (3000 to 4280 mV), SYS is at or above `sys_min`, the thermistor reads normal, no fault is live and the charge phase is pre, fast or done.

The gauge passes when it answers, is ready, agrees with the charger's voltage within 100 mV, the charger delivers at least 200 mA, and its current agrees within 50 mA plus a quarter. The current check proves the shunt, so a full cell fails it. Every reading is logged before it is judged.

### The `power_plug` row

The user profile of AO_Autotest asks AO_Power for a guided row (`sdk/kaos/autotest`). It publishes `POWER_TEST_SIG` for each phase: `POWER_TEST_ASK_PLUG`, `POWER_TEST_PLUGGED`, `POWER_TEST_ASK_UNPLUG`, then `_PASS`, `_FAIL` or `_TIMEOUT`.

Every `KODE_POWER_PLUG_LOOK_MS` (250 ms) it reads the input-good bit. The charger has `KODE_POWER_PLUG_STEP_MS` (10 s) to appear, and if it never does the row publishes `TEST_SKIP_SIG`. Once it appears the row waits `KODE_POWER_PLUG_SETTLE_MS` (2 s), runs both chip tests, then asks for the charger to be taken out for another 10 s. The budget is 27 s. Here the ammeter is judged only when the charger delivers 200 mA or more, because most units sit on a full cell.

## Specifications

### Signals in

| Signal | Event | From |
| --- | --- | --- |
| `POWER_POLL_SIG`, `POWER_PULSE_SIG`, `POWER_CTRL_TICK_SIG`, `POWER_TEST_STEP_SIG`, `POWER_SHIP_TMO_SIG` | none (timers) | Itself |
| `APP_FLASH_PROGRESS_SIG`, `APP_FLASH_DONE_SIG`, `APP_FLASH_FAIL_SIG` | `AppProgressEvt`, `AppFlashDoneEvt`, `AppFlashFailEvt` | AO_AppMgr and AO_Radio, subscribed |
| `POWER_DUMP_REQ_SIG` | none | `power_dump_request()`, for `bat_info`. Takes a fresh reading and publishes it at once |
| `POWER_PEEK_REQ_SIG` | none | `power_peek_request()`: AO_LowPower, once a minute while the board is ASLEEP. One reading published, the wall clock kept, nothing logged |
| `POWER_SHIP_REQ_SIG` | none | `power_ship_request()`: the console verb `ship_mode`, its only caller |
| `POWER_SHIP_ARM_SIG` | `PowerShipArmEvt` | `power_ship_on_unplug_request()`: the factory reset's ship end |
| `LOWPOWER_ROUND_SIG`, `LOWPOWER_STATE_SIG` | `LowPowerRoundEvt`, `LowPowerStateEvt` | AO_LowPower, subscribed with `CONFIG_KODE_LOWPOWER`: a round of rest, and the restore on state AWAKE |
| `POWER_FIELD_REQ_SIG` | `PowerFieldReqEvt` (`chip`, `op`, `field`, `value`) | `power_field_request()`, for `pmic` and `gauge` |
| `POWER_TEST_REQ_SIG` | `PowerTestReqEvt` (`chip`) | `power_test_request()`. `POWER_CHIP_PMIC`, `_GAUGE` or `_PLUG` |
| `POWER_CTRL_REQ_SIG` | `PowerCtrlReqEvt` (`op`, `value`) | `power_ctrl_request()`, the three facade calls, `boost` and `pmic_reset` |
| `CONFIG_REQ_SIG` | `KodeConfigReqEvt` | AO_Console. AO_Power answers the keys `led_activity`, `charge` and `charge_ma` |

### Signals out

| Signal | Event | Range | Meaning |
| --- | --- | --- | --- |
| `POWER_EVT_SIG` | `PowerEvt` (`vbat_mv`, `vbus_mv`, `current_ma`, `soc_pct`, `charging`, `fault`, `vbus`, `boost`) | publish | The reading, every poll. A dry pool skips one reading |
| `POWER_FIELD_SIG` | `PowerFieldEvt` | publish | Closes `pmic` and `gauge` |
| `POWER_CTRL_SIG` | `PowerCtrlEvt` (`op`, `value`, result) | publish | Closes `boost` and `pmic_reset`, a refusal included |
| `POWER_SHIP_SIG` | none | publish | The BATFET is about to open (ship mode) |
| `POWER_SHIP_DUE_SIG` | none | post | To the asker of `POWER_SHIP_ARM_SIG`: the cable has been out for `KODE_SHIP_UNPLUG_MS` |
| `POWER_TEST_SIG` | `PowerTestEvt` (`step`, `total`, `phase`) | publish | One step of `power_plug` |
| `TEST_RESULT_SIG`, `TEST_SKIP_SIG` | `TestResultEvt` | publish | The verdicts, named `pmic`, `gauge` and `power` |
| `CONFIG_ANS_SIG` | `KodeConfigAnsEvt` | publish | The value of a key |
| `LED_RGB_SIG` | `LedRgbEvt` | publish | The write pulse |

### Stored settings

All three live in the NVS namespace `kset` and are changed with `config`.

| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `led_activity` | bool | on | The blue pulse during a flash write |
| `charge` | bool | on | Charging allowed. Applied at every boot and after `pmic_reset` |
| `charge_ma` | `uint16_t`, mA | 512 | Fast-charge current, 64 to 896. The stored value is what the chip kept: 300 reads back 320 |

### Constants

| Name | Value |
| --- | --- |
| `KODE_POWER_POLL_MS`, `KODE_POWER_FIRST_MS` | 2000 ms, 1500 ms |
| `KODE_LED_PULSE_MS`, `POWER_LED_WRITE_MS` | 500 ms, 3000 ms |
| Boost wait, reset poll | 50 ms; 10 ms times 10 |
| `KDRV_CLOCKS_WALL_KEEP_S` | 60 s |
| `KBSP_CELL_BOOST_MIN_MV`, `KBSP_CELL_BOOST_STOP_MV` | 3600 mV, 3300 mV |
| `KBSP_CELL_VREG_MV`, `KBSP_CELL_ICHG_MA`, `KBSP_CELL_ICHG_MAX_MA` | 4192 mV, 512 mA, 896 mA |

The console verbs are `bat_info`, `ship_mode`, `pmic`, `gauge`, `boost`, `pmic_reset` and `test component=pmic|gauge`. The battery constants are `kbsp_cell.h` (`sdk/kbsp`).

## Gotchas

> **WARNING**
>
> **The board does not switch off while USB is plugged in.** VBUS feeds the system rail, so with the battery path open (`ship_mode`) the board stays on and AO_Power logs `battery path open`. It powers off when the cable is pulled. A real check of ship mode needs a person, a battery and no cable. The same cable makes AO_LowPower refuse `sleep` and `power_off` with `cable: external power`.

> **WARNING**
>
> **`boost` is refused with a cable in, and in the first 1.5 s.** With USB present the hardware holds the OTG pin low, and setting the bit would start the boost the moment the cable leaves. Until the first reading arrives the cell voltage is 0, below the 3600 mV minimum.

> **NOTE**
>
> **A facade call is not stored.** `kode_power_charge_limit()` and `kode_power_charge()` change the chip and nothing else. A reboot writes the policy and the stored settings over them. Use `config key="charge_ma"` for a value that must persist.

> **NOTE**
>
> **There is no battery reading in the first 1.5 s of a boot.** A screen that asks earlier gets nothing and not a wrong value. The gauge's finer voltage arrives a poll or two later.

## Example code

Stop charging for ten seconds and start again. With the app running, `pmic field="chg_config"` on the console reads 0 for ten seconds, then 1.

```c
/*
 * charge_pause.c - 2026-10-28 - from Kode with love
 *
 * Asks AO_Power to forbid charging, waits ten seconds, then allows it
 * again. AO_Power writes the charger and reads the bit back. On a Kode Dot
 * with the console open, pmic field="chg_config" reads 0, then 1.
 */
#include "kode.h"

/* Pause charging for ten seconds, then idle. */
void app_main(void)
{
    kode_init();

    kode_power_charge(false);
    kode_delay_ms(10000);
    kode_power_charge(true);

    for (;;) {
        kode_delay_ms(1000);
    }
}
```

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. **TBD**: a run on a Kode Dot.

## In the SDK

- [`components/kaos/power`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/power): `power_ao.c`, the poll, the policy, the guards and the operations.
- [`components/kbsp/board/include/kbsp_cell.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kbsp/board/include/kbsp_cell.h): the pack's limits and the voltage curve.
- [`kode.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kode/include/kode.h): `kode_power_boost()`, `kode_power_charge()`, `kode_power_charge_limit()`. The calls `kode_power_sleep()` and `kode_power_off()` go to AO_LowPower (`sdk/kaos/lowpower`).
