SDK
BQ25896 charger
The chip that charges a Kode Dot, picks its power source, boosts 5 V for the header and switches it off: every register, and the driver over them.
What it is
The BQ25896 from Texas Instruments decides where a Kode Dot's power comes from and what happens to the battery. It does four jobs:
- It charges the cell from USB-C.
- It feeds the board from the cable, the battery, or both, and keeps that
supply,
SYS, at a usable voltage. - It runs backwards: from the cell it makes 5 V on its input line,
VBUS, which reaches the expansion header. This is the boost, also called OTG. - It holds the switch between the battery and the board, the BATFET. Opening it is the deepest off the hardware has, ship mode: the chip then draws 12 µA from the cell and nothing else runs.
It accepts 3.9 to 14 V in and charges at up to 3 A. Datasheet: TI SLUSC76C.
How it is connected
flowchart LR subgraph dot["On the Kode Dot"] ao["AO_Power<br/>on the ESP32-P4"] -- "I2C, 0x6B" --> bq["BQ25896"] bot["bottom button"] -- "QON" --> bq bq -- "SYS" --> board["the board"] bq -- "BAT, through the BATFET" --> cell["1000 mAh cell"] bq -- "VBUS, boost" --> hdr["expansion header"] end usb["USB-C cable"] -- "VBUS" --> bq class usb ext class ao ao class bq chip class cell chip
The chip answers at 0x6B on the shared I2C bus (KBSP_I2C_ADDR_CHARGER).
AO_Power owns it: nothing else in the system writes to it. Software
reaches it through AO_Power's requests, kode_power_charge(),
kode_power_charge_limit() and kode_power_boost(), and AO_Power publishes
a reading every 2 s. Ship mode has one door, the console verb ship_mode;
the facade has no call for it. The calls kode_power_sleep() and
kode_power_off() rest the Kode Dot through AO_LowPower and never reach the
charger (sdk/kaos/lowpower).
The wiring
The wiring decides more than the firmware does:
- The cable decides charging and boosting. A circuit that senses USB
power drives the charge-enable pin
CEand, through transistor Q5, the boost pinOTG. With a cable in, charging is allowed and the boost is physically impossible. On battery alone, the reverse. The P4 drives neither pin. - The input limit is a resistor. R58, 470 Ω on the
ILIMpin, sets the input current ceiling. A USB chip on the board drivesPSEL, which tells the BQ25896 whether a computer or a wall adapter is plugged in. - The temperature comes from the pack. R65 (5.23 kΩ) and R74 (30.1 kΩ)
form the divider on the
TSpin, with the pack's 10 kΩ thermistor on pin 2 of J7, the battery connector. This is the only temperature the charger sees. - The bottom button is
QON. Pressing it wakes a Kode Dot in ship mode; holding it 16 to 23 s with no cable power-cycles the whole board. PG,STATandINTgo nowhere the firmware reads. AO_Power polls the status over I2C instead.
The boot policy
At every boot AO_Power writes one policy, each line read back, from the
cell's numbers in kbsp_cell.h. The chip keeps its registers when the P4 is
reflashed, so the policy is written every time, never assumed:
| Field | Written | Why |
|---|---|---|
watchdog | 0, off | The chip's watchdog otherwise resets the charge current after 40 s |
conv_rate | 0 | The ADC runs one conversion per poll, not continuously (see Gotchas) |
en_hiz | 0 | The input is used |
chg_config | 1 | Charging is allowed |
otg_config | 0 | The boost is off until an app asks for it |
boost_lim | code 0, 0.5 A | 0.5 A out draws about 0.9 A from a cell at 3.0 V; the chip's 1.4 A default would exceed the pack's limit |
bhot | code 1, about 55 °C | The pack's own limit, below the chip's 60 °C default |
vreg_mv | 4192 | Charge voltage, 8 mV under the pack's nominal |
ichg_ma | 512 | Fast charge at 0.5 C for the 1000 mAh pack |
iprechg_ma, iterm_ma | 64 | The chip's floor |
When the Kode Dot rests, AO_Power writes conv_rate back to 0 on every round, in
case a bench tool left the ADC continuous. While it sleeps, the minute's
reading needs no conversion: the status registers and the gauge are read as
they are, so a cable that appeared is seen fresh (sdk/kaos/power).
The pack takes charge between 10 and 45 °C, a narrower window than the
chip's. AO_Power reads the temperature fault at every poll and holds charging
off while it reads anything but normal. A boost request is refused below
3600 mV (KBSP_CELL_BOOST_MIN_MV), and a running boost is stopped below
3300 mV (KBSP_CELL_BOOST_STOP_MV) or on a boost fault.
The driver is one table
kdrv_bq25896.c describes every field of REG00 to REG14 as a row: register,
shift, width, offset, step, the codes it accepts, and whether it is
read-write, read-only or self-clearing. kdrv_bq25896_get() and
kdrv_bq25896_set() take a field and a value in its unit (mV, mA, °C, a
code).
A set does three things:
- It rounds to the field's step and refuses a value out of range.
- It does a masked read-modify-write, so the other fields of the register keep their bits.
- It reads the register back.
A self-clearing bit is written and not checked, since it returns to 0 by itself. The driver holds no policy at all: what to write is AO_Power's decision. Three things are typed calls because a table cannot express them: the ADC block, the fault register and ship mode.
Specifications
The board
| Thing | Value |
|---|---|
| Address | 0x6B. REG14 bits 5:3 read 000, the BQ25896's part number; kdrv_bq25896_init() refuses any other |
| Input current ceiling | 850 mA measured with the input limit in force. R58 should give about 755 mA; TBD: why the two differ |
| Boost output | 5.0038 V measured from the battery, 4.998 V programmed. Starts in 28 ms. 267 mV of ripple at no load in PFM, gone with pfm_otg_dis |
| Charge profile | 512 mA fast, 64 mA pre-charge, 4.192 V, ends below 64 mA |
| Charge current an app may ask for | 64 to 896 mA (KBSP_CELL_ICHG_MAX_MA) |
TS at room temperature | 56 to 59 % of REGN with a pack fitted, about 28 °C. About 85 % with no pack |
| Ship mode | 12 µA from the cell. Leaves on USB power, the bottom button held 0.9 to 1.3 s, or a register reset |
The settings registers
The registers 0x00 to 0x0A: settings. [7] is
bit 7, [5:0] bits 5 to 0. Reset values are the datasheet's, after a real
power-on; a reflash of the P4 does not reset them.
| Reg | Reset | Fields |
|---|---|---|
0x00 | 0x08 | EN_HIZ [7], EN_ILIM [6], IINLIM [5:0] = 100 mA + 50 mA per code |
0x01 | 0x06 | BHOT [7:6], BCOLD [5], VINDPM_OS [4:0] = 100 mV per code |
0x02 | 0x11 | CONV_START [7], CONV_RATE [6], BOOST_FREQ [5], ICO_EN [4], FORCE_DPDM [1], AUTO_DPDM_EN [0] |
0x03 | 0x1A | BAT_LOADEN [7], WD_RST [6], OTG_CONFIG [5], CHG_CONFIG [4], SYS_MIN [3:1] = 3.0 V + 100 mV per code, MIN_VBAT_SEL [0] |
0x04 | 0x20 | EN_PUMPX [7], ICHG [6:0] = 64 mA per code, 3008 mA at most |
0x05 | 0x13 | IPRECHG [7:4], ITERM [3:0], each 64 mA + 64 mA per code |
0x06 | 0x5E | VREG [7:2] = 3.840 V + 16 mV per code, 4.608 V at most; BATLOWV [1]; VRECHG [0] |
0x07 | 0x9D | EN_TERM [7], STAT_DIS [6], WATCHDOG [5:4], EN_TIMER [3], CHG_TIMER [2:1], JEITA_ISET [0] |
0x08 | 0x03 | BAT_COMP [7:5] = 20 mΩ per code, VCLAMP [4:2] = 32 mV per code, TREG [1:0] = 60 °C + 20 °C per code |
0x09 | 0x44 | FORCE_ICO [7], TMR2X_EN [6], BATFET_DIS [5], JEITA_VSET [4], BATFET_DLY [3], BATFET_RST_EN [2], PUMPX_UP [1], PUMPX_DN [0] |
0x0A | 0x73 | BOOSTV [7:4] = 4.55 V + 64 mV per code, PFM_OTG_DIS [3], BOOST_LIM [2:0] |
The status and reading registers
The registers 0x0B to 0x14: status, faults, thresholds, the ADC results and the part number.
| Reg | Reset | Fields |
|---|---|---|
0x0B | read only | VBUS_STAT [7:5]: 0 none, 1 USB host, 2 adapter, 7 boost. CHRG_STAT [4:3]: 0 off, 1 pre-charge, 2 fast, 3 done. PG_STAT [2], VSYS_STAT [0] |
0x0C | read only, latching | WATCHDOG_FAULT [7], BOOST_FAULT [6], CHRG_FAULT [5:4] (1 input, 2 thermal shutdown, 3 timer), BAT_FAULT [3], NTC_FAULT [2:0] (2 warm, 3 cool, 5 cold, 6 hot) |
0x0D | 0x12 | FORCE_VINDPM [7], VINDPM [6:0] = 2.6 V + 100 mV per code, 3.9 V at least |
0x0E | measured | THERM_STAT [7], BATV [6:0] = 2.304 V + 20 mV per code |
0x0F | measured | SYSV [6:0], same scale |
0x10 | measured | TSPCT [6:0] = 21 % + 0.465 % of REGN per code |
0x11 | measured | VBUS_GD [7], VBUSV [6:0] = 2.6 V + 100 mV per code |
0x12 | measured | ICHGR [6:0] = 50 mA per code |
0x13 | read only | VDPM_STAT [7], IDPM_STAT [6], IDPM_LIM [5:0] = 100 mA + 50 mA per code |
0x14 | 0x02 | REG_RST [7], ICO_OPTIMIZED [6], PN [5:3], TS_PROFILE [2], DEV_REV [1:0] |
The codes
What the code of each coded field means.
| Field | Codes |
|---|---|
BHOT | 0 = about 60 °C, 1 = 55 °C, 2 = 65 °C, 3 = off |
BCOLD | 0 = about −10 °C, 1 = −20 °C |
WATCHDOG | 0 off, 1 = 40 s, 2 = 80 s, 3 = 160 s |
CHG_TIMER | 0 = 5 h, 1 = 8 h, 2 = 12 h, 3 = 20 h |
BOOST_LIM | 0 to 6 = 0.5, 0.75, 1.2, 1.4, 1.65, 1.875, 2.15 A |
MIN_VBAT_SEL | 0 = 2.9 V, 1 = 2.5 V |
BATLOWV | 0 = 2.8 V, 1 = 3.0 V |
VRECHG | 0 = 100 mV, 1 = 200 mV |
The driver
| Call | What it does |
|---|---|
kdrv_bq25896_init() | Probes 0x6B, attaches the device to the bus, checks the part number. Writes nothing |
kdrv_bq25896_get(f, &v) | One field in its unit. v is untouched on a failed read, so a bus error never becomes a number |
kdrv_bq25896_set(f, v) | One field, masked, read back. KODE_DENIED for a read-only field or a value out of range, KODE_ERROR when the bus fails or the chip did not keep it |
kdrv_bq25896_field_info(f), _field_by_name() | Name, unit, range and step of a field; what the console's pmic prints |
kdrv_bq25896_adc_read() | REG0E to REG12 in one transaction: one conversion, all five numbers |
kdrv_bq25896_faults(&latched, &live) | REG0C read twice: the first read is the history since the last read, the second is now |
kdrv_bq25896_ship_mode(delayed) | One write of REG09 with BATFET_DIS set and BATFET_RST_EN kept. On battery it never returns |
kdrv_bq25896_dump() | Logs every register in hex with each field decoded |
Gotchas
Example code
Ask AO_Power to charge the cell at 256 mA instead of the 512 mA it writes at
boot. With the app running, type pmic field="ichg_ma" on the console: the
answer reads 256.
/*
* charge_limit.c - 2026-10-28 - from Kode with love
*
* Asks AO_Power, the owner of the Kode Dot's BQ25896, to charge the cell at
* 256 mA, then keeps the app alive so the console stays up. Type
* pmic field="ichg_ma" on the console and the value reads 256.
*/
#include "kode.h"
/* Bring the system up, ask for the new current, then idle. */
void app_main(void)
{
kode_init();
kode_power_charge_limit(256);
while (1) {
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/kdrv/power: the driver,kdrv_bq25896.c, beside the gauge's.kdrv_bq25896.h: every field, the ADC and fault structures, the calls.components/kaos/power: AO_Power, the owner, with the boot policy.