SDK
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:
- Every 2 s it reads both chips and publishes one battery reading.
- At every boot it writes the board's policy into the charger, which keeps its registers across a reflash of the main chip.
- 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.
- It opens the battery switch when the console verb
ship_modeasks. The states ASLEEP and OFF are not its:sdk/kaos/lowpowerdecides them, and AO_Power rests its ADC when that Active Object asks. - 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
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
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:
- Estimate the resting voltage: the measured voltage minus the current times the cell's internal resistance,
KBSP_CELL_R_INT_MOHM(180 mΩ). - 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.
- Look it up on the 13-point curve of
kbsp_cell.h, interpolating in a straight line. - 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. BelowKBSP_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:
- 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. - It logs
ship mode: opening the BATFETand the wake instructions, because on battery the board is dead the instant the write lands. - 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
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.
/*
* 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:power_ao.c, the poll, the policy, the guards and the operations.components/kbsp/board/include/kbsp_cell.h: the pack's limits and the voltage curve.kode.h:kode_power_boost(),kode_power_charge(),kode_power_charge_limit(). The callskode_power_sleep()andkode_power_off()go to AO_LowPower (sdk/kaos/lowpower).