# CW2217 fuel gauge

The chip that measures a Kode Dot's battery voltage and current, how it is woken and read, and every register and driver call over it.

## What it is

The **CW2217** from CellWise measures the battery cell. It has a voltmeter for the cell, an ammeter that reads the voltage across a 10 mΩ resistor in series with the cell, and a thermistor input. On top of those sits an algorithm that turns the measurements into a charge percentage, a health figure and a count of charge cycles. Datasheet: CW2217EAAD-DS V1.0.

The algorithm needs an 80-byte description of the exact cell, the **battery profile**, which the chip maker supplies per cell model. No profile for the Kode Dot's cell exists, so none is loaded. The chip works as a voltmeter and an ammeter, and both are exact. The percentage, health, cycle count and average current registers carry no meaning. AO_Power computes the percentage itself from the cell's voltage curve.

## How it is connected

```mermaid
flowchart LR
  cell["1000 mAh cell"] -- "VBAT" --> cw["CW2217"]
  cell -- "R75, 10 mOhm shunt" --> cw
  p4["ESP32-P4<br/>AO_Power"] -- "I2C, 0x64" --> cw
  cw -. "FG_GPOUT<br/>not routed" .-> p4
  class cell chip
  class cw chip
  class p4 ao
```

*The gauge on the Kode Dot*

The CW2217 answers at `0x64` on the shared I2C bus (`KBSP_I2C_ADDR_GAUGE`). **AO_Power** owns it and claims it as `KBSP_RES_GAUGE`; nothing else writes to it. The charger, the BQ25896 (`sdk/kdrv/bq25896`), shares the same owner, because the two chips answer one question between them: the charger knows what the power path is doing, the gauge knows the cell.

### The wiring

The wiring decides what the gauge can do:

- **The chip runs from the cell.** Its I2C side wakes above 2.4 V and stops below 2.2 V. With no battery fitted it does not answer at all, and AO_Power treats it as an optional part that is absent.
- **The shunt is R75, 10 mΩ**, in series with the cell on the `VBAT_SHUNT` net, read through a 100 Ω filter on each sense pin. The sense pins are wired so that charging reads positive, as the datasheet defines it. The current agrees with the charger's within one of its 50 mA steps and has the same sign: the gauge read +511 mA while the charger reported 500 mA of charge, measured on a Kode Dot.
- **The thermistor pin is not connected.** The temperature register reads `0x00`, which is −40 °C. The cell's temperature comes from the charger.
- **The alarm pin `FG_GPOUT` reaches neither the P4 nor the expander.** The expander's P5 is the IMU's `INT1` line. Nothing in the firmware reads a gauge interrupt, and none of the three alarm sources would give a useful signal without a profile.

### Bring-up

`kdrv_cw2217_init()` probes `0x64` for 100 ms, attaches the device and checks that `VERSION` reads `0xA0`. It writes nothing. The chip starts asleep, with `CONFIG` at `0xF0`. AO_Power then calls `kdrv_cw2217_wake()`, which writes `0x30`, waits 20 ms and writes `0x00`, and returns at once.

The algorithm needs 900 to 1100 ms to come up, and until it does the chip answers `FW_VERSION` 0 and a cell voltage of 0 mV. Both look like readings. The driver does not wait, because an Active Object never waits. AO_Power polls the readiness byte on its own timer.

### The poll and the percentage

AO_Power reads at 1.5 s after boot and then every 2 s (`KODE_POWER_FIRST_MS`, `KODE_POWER_POLL_MS`). Until `ready` reads 1 it uses the charger's voltage alone. After that the cell voltage is the gauge's, unless it reads 2000 mV or less, and the current comes from the gauge. Every poll publishes one `POWER_EVT_SIG` with the voltage, the current (into the cell positive, out negative) and the percentage.

**The percentage is AO_Power's.** It works in three steps:

1. It takes the gauge's voltage and subtracts the current times 180 mΩ (`KBSP_CELL_R_INT_MOHM`) to estimate the open-circuit voltage.
2. It smooths that with a moving average of weight 1/5.
3. It reads the percentage off a 13-point curve in `kbsp_cell.h`.

The displayed value moves at most 2 points per reading. When the charger reports the charge as done, the value is 100.

### The driver is one table

`kdrv_cw2217.c` describes every field as a row: register, kind, shift and width. `kdrv_cw2217_get()` and `kdrv_cw2217_set()` take a field and a value in its unit. A `set` refuses read-only rows and values out of range, does a masked read-modify-write so neighbouring bits keep their values, and reads the row back.

Four sequences are typed calls: wake, sleep, restart and loading a profile. The driver logs when the chip stops answering and when it returns, once each.

## Specifications

### The board

| Thing | Value |
| --- | --- |
| Address | `0x64`. `VERSION` reads `0xA0` asleep or awake; `kdrv_cw2217_init()` refuses anything else |
| Firmware byte | `FW_VERSION` reads `0x00` asleep. Awake, bits 7:6 are `01` for a CW2217; a Kode Dot reads `0x4A` |
| Supply | From the cell. Wakes above 2.4 V, stops below 2.2 V. 0.5 µA in shutdown, 12 to 16 µA active |
| Cell voltage | 14 bits, 312.5 µV per step, 2.5 to 5.0 V. A conversion takes 5 ms. Accuracy ±7.5 mV at 25 °C and 4 V |
| Current | Signed 16 bits, 52.4 mV full scale across the shunt. A conversion takes 10 ms. At 10 mΩ: 160 µA per step, ±5.24 A |
| Sign | Charging positive, as the datasheet defines it. AO_Power uses the value unchanged |
| Start-up | 900 to 1100 ms after the second wake write before `ready` reads 1 |
| Temperature | Pin not connected: `temp_c2` reads −80, which is −40 °C. The profile on the chip would have to select a host-reported temperature to use one |
| Bus | Up to 400 kHz. Reads of one or two bytes both work |
| Profile | 80 bytes at `0x10` to `0x5F`. None is loaded; the block reads all zero. A generic 4.2 V lithium-ion profile starts the algorithm and verifies byte for byte, but its percentage means nothing for this cell. The block lives in RAM that the cell powers |
| Percentage without a profile | `SOC` reads `0xFEB3` and the health, cycle and average-current registers read 0 |

### The measurement and control registers

The registers `0x00` to `0x0F`: the readings, the configuration and the alarms. `[7:6]` is bits 7 and 6. A range such as `0x02-0x03` is two registers holding one number, high byte first.

| Reg | Reset | Name and meaning |
| --- | --- | --- |
| `0x00` | `0xA0` | `VERSION`: the chip family, the same asleep and awake |
| `0x02-0x03` | 0 | `VCELL`: 14 bits, 312.5 µV per step |
| `0x04-0x05` | 0 | `SOC`: whole percent in the high byte, 1/256 % in the low byte. A high byte above 100 means no profile |
| `0x06` | 0 | `TEMP`: −40 °C + code / 2, from the thermistor pin |
| `0x08` | `0xF0` | `CONFIG`: `SLEEP` [7:6], `RESTART` [5:4], [3:0] reserved and kept 0. `0x30` then `0x00` wakes the chip. `0xF0`, `0x30`, `0x00` restarts it |
| `0x0A` | `0x40` | `INT_CONF`: alarm enables [6:4] (percentage, hot, cold) and their status bits [2:0], cleared by writing 0 |
| `0x0B` | `0x14` | `UPDATE_FLAG` [7] (a profile is loaded), percentage alarm threshold [6:0]. The alarm fires when the whole percent crosses the threshold; 101 to 127 fires on every change |
| `0x0C` | `0xAA` | `TEMP_MAX`: −40 °C + code / 2, default 45 °C |
| `0x0D` | `0x50` | `TEMP_MIN`: −40 °C + code / 2, default 0 °C |
| `0x0E-0x0F` | 0 | `CURRENT`: signed, 52.4 mV × code / 32768 across the shunt |

### The profile and extended registers

The registers `0x10` to `0xAB`: the battery profile, the host temperature and the figures that need a profile to mean anything.

| Reg | Reset | Name and meaning |
| --- | --- | --- |
| `0x10-0x5F` | 0 | The battery profile, 80 bytes. From CellWise's reference driver; the datasheet does not describe it |
| `0xA0-0xA1` | 0 | `T_HOST`: host-reported temperature, half degrees in the high byte. The low byte must change on every write or the chip ignores it |
| `0xA2` | 0 | `USER_CONF`: a free byte |
| `0xA4-0xA5` | 0 | `CYCLECNT`: charge cycles in sixteenths; clears on shutdown |
| `0xA6` | 0 | `SOH`: health, 0 to 100 %; 100 on entering active mode; clears on shutdown |
| `0xA7` | none | Readiness: bits 2 and 3 both set once the algorithm runs. From the reference driver, not in the datasheet |
| `0xA8-0xA9` | 0 | `AVE_CUR`: signed 16 s average, same scale and sign as `CURRENT` |
| `0xAB` | 0 | `FW_VERSION`: `0x00` asleep; top two bits `01` for a CW2217, the rest the revision |

### The fields

The driver's table. The first column is the name the console's `gauge` command takes. **RO** is read-only.

| Field | Reg | Unit and range | Access |
| --- | --- | --- | --- |
| `version` | `0x00` | code, 0 to 255 | RO |
| `vcell_mv` | `0x02-0x03` | mV, 0 to 5120 | RO |
| `soc_x256` | `0x04-0x05` | 1/256 %, 0 to 65535 | RO |
| `temp_c2` | `0x06` | half degrees, −80 to 175 | RO |
| `en_soc_int`, `en_tmx_int`, `en_tmn_int` | `0x0A` bits 6, 5, 4 | bool | RW |
| `int_flags` | `0x0A` bits 2:0 | code, 0 to 7; write 0 to clear | RW |
| `update_flag` | `0x0B` bit 7 | bool | RO |
| `soc_alert_pct` | `0x0B` bits 6:0 | %, 0 to 127 | RW |
| `temp_max_c2`, `temp_min_c2` | `0x0C`, `0x0D` | half degrees, −80 to 175 | RW |
| `current_ma` | `0x0E-0x0F` | mA, ±5240 at 10 mΩ, charging positive | RO |
| `t_host_c2` | `0xA0-0xA1` | half degrees, −80 to 175 | RW |
| `user_conf` | `0xA2` | code, 0 to 255 | RW |
| `cycles_x16` | `0xA4-0xA5` | 1/16 cycle, 0 to 65535 | RO |
| `soh_pct` | `0xA6` | %, 0 to 100 | RO |
| `ready` | `0xA7` | bool, bits 2 and 3 both set | RO |
| `ave_current_ma` | `0xA8-0xA9` | mA, same range as `current_ma` | RO |
| `fw_version` | `0xAB` | code, 0 to 255 | RO |

The driver computes the current in 64-bit microamps as `code × 52.4 mV / (32768 × KBSP_CELL_RSENSE_MOHM)` and rounds to the milliamp, away from zero. The table's range comes from the same constant. A write to `t_host_c2` sets the high byte, then a low byte that alternates between 0 and 1, and reads nothing back.

### The driver

| Call | What it does |
| --- | --- |
| `kdrv_cw2217_init()` | Probes `0x64`, attaches the device, checks `VERSION`. Writes nothing. `KODE_ERROR` when nothing answers, logged as a warning |
| `kdrv_cw2217_present()` | True once `init` succeeded |
| `kdrv_cw2217_wake()` | `0x30`, 20 ms, `0x00`. Returns at once; poll `ready` |
| `kdrv_cw2217_sleep()` | `0x30`, 20 ms, `0xF0`. Clears health and cycles; the profile and its flag stay |
| `kdrv_cw2217_reset()` | The datasheet's restart, `0xF0`, `0x30`, `0x00`, then waits up to 3 s for `ready`. `KODE_TIMEOUT` if it never comes |
| `kdrv_cw2217_get(f, &v)` | One field in its unit. `v` is untouched on a failed read |
| `kdrv_cw2217_set(f, v)` | One field, masked, read back. `KODE_DENIED` for a read-only row or a value out of range, `KODE_ERROR` when the bus fails or the chip did not keep it |
| `kdrv_cw2217_field_info(f)`, `_field_by_name()` | Name, unit and range of a field; what the console's `gauge` prints |
| `kdrv_cw2217_profile_load(profile, pct)` | Sleep, write the 80 bytes, set `UPDATE_FLAG` together with the threshold `pct` in the same write, disable the interrupts, wake, wait up to 5 s for `ready`, read the 80 bytes back. Blocks 1 to 2 s. `KODE_ERROR` when a byte differs, `KODE_TIMEOUT` when the algorithm never starts |
| `kdrv_cw2217_profile_read(out)` | The 80 bytes as the chip holds them |
| `kdrv_cw2217_dump()` | Logs every field, the raw `CONFIG` byte and the profile as five lines of 16 bytes |

### The owner

| Thing | Value |
| --- | --- |
| Owner | AO_Power, `components/kaos/power/src/power_ao.c`; claims `KBSP_RES_GAUGE` |
| Poll | First at 1500 ms, then every 2000 ms |
| Gauge voltage accepted | Above 2000 mV; otherwise the charger's reading stays |
| Percentage | Open-circuit voltage = cell voltage − current × 180 mΩ, moving average of weight 1/5, 13-point curve, at most 2 points per reading, 100 when the charge is done |
| Console | `gauge field= value=` reads or writes a field, `gauge` alone dumps, `bat_info` dumps both power chips |
| Self-test | `test component=gauge`: identity, `ready`, firmware bits `01`, cell voltage between 3000 and 4280 mV, within 100 mV of the charger's. When the charger delivers at least 200 mA, the current must agree with it to within 50 mA plus a quarter of the charger's figure |
| Never called by kodeOS | `sleep()`, `reset()` and `profile_load()`: they exist to load a profile, and none exists for this cell |

## Gotchas

> **WARNING**
>
> **The gauge answers zeros for about a second after it wakes.** `FW_VERSION` reads `0x00` and the cell reads 0 mV, and both look like data. The algorithm is not up yet. Poll `ready` (`0xA7`, bits 2 and 3) and trust nothing else until it reads 1, as AO_Power does.

> **WARNING**
>
> **A percentage above 100 is not a percentage.** Without a profile, `soc_x256` reads `0xFEB3`, and its high byte is 254. The driver passes the value on unchanged so that a caller can recognise it. Take the percentage from `POWER_EVT_SIG`, where AO_Power has computed it from the voltage.

> **NOTE**
>
> **With no battery there is no gauge.** The chip runs from the cell and does not answer on the bus when the cell is absent. `kdrv_cw2217_init()` returns `KODE_ERROR` with a warning, and `test component=gauge` fails. Fit a pack before you judge a board.

> **NOTE**
>
> **The temperature always reads −40 °C.** The thermistor pin is not connected, so `temp_c2` sits at its floor of −80. Read the cell's temperature from the charger (`sdk/kdrv/bq25896`).

## Example code

Ask AO_Power to dump the gauge. Open the serial log and look for the `kdrv.cw2217` lines: `ready=1`, the cell voltage in `vcell_mv`, and a profile of 80 zero bytes.

```c
/*
 * gauge_dump.c - 2026-10-28 - from Kode with love
 *
 * Waits for the CW2217 fuel gauge on the Kode Dot to come up, then asks
 * AO_Power, the chip's owner, to log every field. With a battery fitted, the
 * serial log shows one line of fields (vcell_mv, current_ma, ready and the
 * rest) and the five lines of the 80-byte battery profile.
 */
#include "kode_ao.h"

/* Bring the system up, give the gauge time to start, then ask for the dump. */
void app_main(void)
{
    kode_init();

    /* The gauge needs about a second after its wake before it means anything. */
    kode_delay_ms(3000);
    power_field_request(POWER_CHIP_GAUGE, POWER_OP_DUMP, 0U, 0);

    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`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/power): the driver, `kdrv_cw2217.c`, beside the charger's.
- [`kdrv_cw2217.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kdrv/power/include/kdrv_cw2217.h): the fields, the sequences and the profile calls.
- [`components/kaos/power`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/power): AO_Power, the owner, with the poll and the percentage.
