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:

  1. It charges the cell from USB-C.
  2. It feeds the board from the cable, the battery, or both, and keeps that supply, SYS, at a usable voltage.
  3. 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.
  4. 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 charger on the Kode Dot

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

FieldWrittenWhy
watchdog0, offThe chip's watchdog otherwise resets the charge current after 40 s
conv_rate0The ADC runs one conversion per poll, not continuously (see Gotchas)
en_hiz0The input is used
chg_config1Charging is allowed
otg_config0The boost is off until an app asks for it
boost_limcode 0, 0.5 A0.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
bhotcode 1, about 55 °CThe pack's own limit, below the chip's 60 °C default
vreg_mv4192Charge voltage, 8 mV under the pack's nominal
ichg_ma512Fast charge at 0.5 C for the 1000 mAh pack
iprechg_ma, iterm_ma64The 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:

  1. It rounds to the field's step and refuses a value out of range.
  2. It does a masked read-modify-write, so the other fields of the register keep their bits.
  3. 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

ThingValue
Address0x6B. REG14 bits 5:3 read 000, the BQ25896's part number; kdrv_bq25896_init() refuses any other
Input current ceiling850 mA measured with the input limit in force. R58 should give about 755 mA; TBD: why the two differ
Boost output5.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 profile512 mA fast, 64 mA pre-charge, 4.192 V, ends below 64 mA
Charge current an app may ask for64 to 896 mA (KBSP_CELL_ICHG_MAX_MA)
TS at room temperature56 to 59 % of REGN with a pack fitted, about 28 °C. About 85 % with no pack
Ship mode12 µ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.

RegResetFields
0x000x08EN_HIZ [7], EN_ILIM [6], IINLIM [5:0] = 100 mA + 50 mA per code
0x010x06BHOT [7:6], BCOLD [5], VINDPM_OS [4:0] = 100 mV per code
0x020x11CONV_START [7], CONV_RATE [6], BOOST_FREQ [5], ICO_EN [4], FORCE_DPDM [1], AUTO_DPDM_EN [0]
0x030x1ABAT_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]
0x040x20EN_PUMPX [7], ICHG [6:0] = 64 mA per code, 3008 mA at most
0x050x13IPRECHG [7:4], ITERM [3:0], each 64 mA + 64 mA per code
0x060x5EVREG [7:2] = 3.840 V + 16 mV per code, 4.608 V at most; BATLOWV [1]; VRECHG [0]
0x070x9DEN_TERM [7], STAT_DIS [6], WATCHDOG [5:4], EN_TIMER [3], CHG_TIMER [2:1], JEITA_ISET [0]
0x080x03BAT_COMP [7:5] = 20 mΩ per code, VCLAMP [4:2] = 32 mV per code, TREG [1:0] = 60 °C + 20 °C per code
0x090x44FORCE_ICO [7], TMR2X_EN [6], BATFET_DIS [5], JEITA_VSET [4], BATFET_DLY [3], BATFET_RST_EN [2], PUMPX_UP [1], PUMPX_DN [0]
0x0A0x73BOOSTV [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.

RegResetFields
0x0Bread onlyVBUS_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]
0x0Cread only, latchingWATCHDOG_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)
0x0D0x12FORCE_VINDPM [7], VINDPM [6:0] = 2.6 V + 100 mV per code, 3.9 V at least
0x0EmeasuredTHERM_STAT [7], BATV [6:0] = 2.304 V + 20 mV per code
0x0FmeasuredSYSV [6:0], same scale
0x10measuredTSPCT [6:0] = 21 % + 0.465 % of REGN per code
0x11measuredVBUS_GD [7], VBUSV [6:0] = 2.6 V + 100 mV per code
0x12measuredICHGR [6:0] = 50 mA per code
0x13read onlyVDPM_STAT [7], IDPM_STAT [6], IDPM_LIM [5:0] = 100 mA + 50 mA per code
0x140x02REG_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.

FieldCodes
BHOT0 = about 60 °C, 1 = 55 °C, 2 = 65 °C, 3 = off
BCOLD0 = about −10 °C, 1 = −20 °C
WATCHDOG0 off, 1 = 40 s, 2 = 80 s, 3 = 160 s
CHG_TIMER0 = 5 h, 1 = 8 h, 2 = 12 h, 3 = 20 h
BOOST_LIM0 to 6 = 0.5, 0.75, 1.2, 1.4, 1.65, 1.875, 2.15 A
MIN_VBAT_SEL0 = 2.9 V, 1 = 2.5 V
BATLOWV0 = 2.8 V, 1 = 3.0 V
VRECHG0 = 100 mV, 1 = 200 mV

The driver

CallWhat 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