Getting started
- Fit a 10 A ATO fuse in F1 and F2. They ship separately.
- Connect the robot's power input to LOAD with a male XT60 lead.
- Connect the host's Qwiic or PiicoDev port to J4 with a 4-way JST SH cable. Use J5 to chain further I²C boards. The board ships with pull-ups on; cut JP3 if the host or another board already has them.
- Plug the first pack into BATT A. Its LED lights once the host's 3.3 V is present.
- Plug the second pack into BATT B. To swap later, unplug the pack whose LED is off, replace it, then repeat for the other.
- Run one of the examples below. Each prints volts, amps and watts once a second and warns below the pack cut-off.
Examples
Every example does the same three things: checks the INA226 manufacturer ID, writes CONFIG = 0x4527 for 16-sample averaging and CALIBRATION = 2048 for 0.5 mA per bit with the 5 mΩ shunt, then reads bus voltage at 1.25 mV per bit, current and power. Download all five as a zip.
Pico, ESP32 or any MicroPython port. Copy to the board as main.py, or import Kookabus from it. Download kookabus.py
# Kookabus-01 driver and demo for MicroPython (Raspberry Pi Pico, ESP32, any port with machine.I2C).
# Copy to the board as main.py, or import Kookabus from it.
#
# The board's INA226 runs from the host's 3.3 V, so it resets whenever the host does:
# begin() must run after every host power-up (the constructor does it).
from machine import I2C, Pin
import time
R_SHUNT = 0.005 # ohm; trim this against a multimeter if the current reads off
I_LSB = 0.0005 # A per CURRENT bit
CAL = round(0.00512 / (I_LSB * R_SHUNT)) # 2048
CONFIG = 0x4527 # 16-sample average, 1.1 ms conversions, continuous
CELLS = 5 # 5S tool packs
R_PATH = 0.03 + 0.10 # ohm: board (fuse, FET, shunt, copper) + an assumed pack resistance
V_CUTOFF = 3.0 * CELLS
# Resting Li-ion cell voltage -> % (typical NMC; tune for your packs)
OCV = ((3.00, 0), (3.30, 5), (3.45, 10), (3.60, 20), (3.70, 35),
(3.80, 50), (3.90, 65), (4.00, 78), (4.10, 90), (4.20, 100))
def soc(pack_v, cells=CELLS):
v = pack_v / cells
if v <= OCV[0][0]:
return 0
for (v0, p0), (v1, p1) in zip(OCV, OCV[1:]):
if v <= v1:
return p0 + (p1 - p0) * (v - v0) / (v1 - v0)
return 100
class Kookabus:
def __init__(self, i2c, addr=0x40):
self.i2c, self.addr = i2c, addr
self.begin()
def _w(self, reg, val):
self.i2c.writeto_mem(self.addr, reg, bytes((val >> 8, val & 0xFF)))
def _r(self, reg, signed=False):
b = self.i2c.readfrom_mem(self.addr, reg, 2)
v = (b[0] << 8) | b[1]
return v - 0x10000 if signed and v & 0x8000 else v
def begin(self):
if self._r(0xFE) != 0x5449:
raise OSError("no INA226 at 0x%02x" % self.addr)
self._w(0x00, CONFIG)
self._w(0x05, CAL)
def voltage(self): # V at the LOAD connector
return self._r(0x02) * 0.00125
def current(self): # A into the load; negative only if something back-feeds
return self._r(0x04, True) * I_LSB
def power(self): # W
return self._r(0x03) * 25 * I_LSB
def pack_voltage(self): # V, estimated resting voltage with the sag added back
return self.voltage() + self.current() * R_PATH
if __name__ == "__main__":
# PiicoDev / Qwiic on a Pico: I2C0 on GP8 (SDA) / GP9 (SCL). ESP32: I2C(0, sda=Pin(21), scl=Pin(22)).
bus = Kookabus(I2C(0, sda=Pin(8), scl=Pin(9), freq=400_000))
while True:
v, i, p = bus.voltage(), bus.current(), bus.power()
pv = bus.pack_voltage()
print("%6.2f V %6.3f A %6.1f W ~%3.0f %%%s" %
(v, i, p, soc(pv), " LOW - stop the load" if v < V_CUTOFF else ""))
time.sleep(1)Any Arduino-core board with a 3.3 V Qwiic port. No library needed. Upload and open the serial monitor at 115200 baud. Download kookabus.ino
// Kookabus-01 demo for Arduino (any board with Wire and a 3.3 V Qwiic port:
// SparkFun RedBoard, ESP32, RP2040, Teensy...). Needs no library.
//
// The board's INA226 runs from the host's 3.3 V, so it resets with the host:
// kbBegin() must run after every power-up.
#include <Wire.h>
const uint8_t KB_ADDR = 0x40;
const float R_SHUNT = 0.005f; // ohm; trim against a multimeter if current reads off
const float I_LSB = 0.0005f; // A per CURRENT bit
const uint16_t CAL = (uint16_t)(0.00512f / (I_LSB * R_SHUNT) + 0.5f); // 2048
const uint16_t CONFIG = 0x4527; // 16-sample average, 1.1 ms conversions, continuous
const float V_CUTOFF = 5 * 3.0f; // 5S pack empty
static void kbWrite(uint8_t reg, uint16_t val) {
Wire.beginTransmission(KB_ADDR);
Wire.write(reg);
Wire.write(val >> 8);
Wire.write(val & 0xFF);
Wire.endTransmission();
}
static uint16_t kbRead(uint8_t reg) {
Wire.beginTransmission(KB_ADDR);
Wire.write(reg);
Wire.endTransmission(false);
Wire.requestFrom(KB_ADDR, (uint8_t)2);
uint16_t hi = Wire.read();
return (hi << 8) | Wire.read();
}
static bool kbBegin() {
if (kbRead(0xFE) != 0x5449) return false; // TI manufacturer ID
kbWrite(0x00, CONFIG);
kbWrite(0x05, CAL);
return true;
}
float kbVolts() { return kbRead(0x02) * 0.00125f; } // at the LOAD connector
float kbAmps() { return (int16_t)kbRead(0x04) * I_LSB; } // into the load
float kbWatts() { return kbRead(0x03) * 25 * I_LSB; }
void setup() {
Serial.begin(115200);
Wire.begin();
Wire.setClock(400000);
while (!kbBegin()) {
Serial.println("Kookabus-01 not found at 0x40, check the Qwiic cable");
delay(1000);
}
}
void loop() {
float v = kbVolts();
Serial.print(v, 2); Serial.print(" V ");
Serial.print(kbAmps(), 3); Serial.print(" A ");
Serial.print(kbWatts(), 1); Serial.print(" W");
if (v < V_CUTOFF) Serial.print(" LOW - stop the load");
Serial.println();
delay(1000);
}Raspberry Pi, Jetson or any board with /dev/i2c-N. Run: pip install smbus2 && python3 kookabus.py 1 0x40 Download kookabus.py
#!/usr/bin/env python3
"""Kookabus-01 reader for Linux Python (Raspberry Pi, Jetson...).
pip install smbus2 && python3 kookabus.py [bus] [addr]
"""
import sys
import time
R_SHUNT = 0.005 # ohm; trim against a multimeter if current reads off
I_LSB = 0.0005 # A per CURRENT bit
CAL = round(0.00512 / (I_LSB * R_SHUNT)) # 2048
CONFIG = 0x4527 # 16-sample average, 1.1 ms conversions, continuous
CELLS = 5
R_PATH = 0.03 + 0.10 # ohm: board + an assumed pack resistance, for sag correction
V_CUTOFF = 3.0 * CELLS
# Resting Li-ion cell voltage -> % (typical NMC; tune for your packs)
OCV = ((3.00, 0), (3.30, 5), (3.45, 10), (3.60, 20), (3.70, 35),
(3.80, 50), (3.90, 65), (4.00, 78), (4.10, 90), (4.20, 100))
def soc(pack_v, cells=CELLS):
v = pack_v / cells
if v <= OCV[0][0]:
return 0
for (v0, p0), (v1, p1) in zip(OCV, OCV[1:]):
if v <= v1:
return p0 + (p1 - p0) * (v - v0) / (v1 - v0)
return 100
class Kookabus:
def __init__(self, bus=1, addr=0x40):
from smbus2 import SMBus
self.bus, self.addr = SMBus(bus), addr
if self._r(0xFE) != 0x5449:
raise OSError(f"no INA226 at 0x{addr:02x}")
self._w(0x00, CONFIG)
self._w(0x05, CAL)
# The INA226 is big-endian; SMBus word transfers are little-endian.
def _w(self, reg, val):
self.bus.write_i2c_block_data(self.addr, reg, [val >> 8, val & 0xFF])
def _r(self, reg):
hi, lo = self.bus.read_i2c_block_data(self.addr, reg, 2)
return hi << 8 | lo
def voltage(self):
return self._r(0x02) * 0.00125
def current(self):
raw = self._r(0x04)
return (raw - 0x10000 if raw & 0x8000 else raw) * I_LSB
def power(self):
return self._r(0x03) * 25 * I_LSB
if __name__ == "__main__":
kb = Kookabus(int(sys.argv[1]) if len(sys.argv) > 1 else 1,
int(sys.argv[2], 0) if len(sys.argv) > 2 else 0x40)
while True:
v, i, p = kb.voltage(), kb.current(), kb.power()
low = " LOW - stop the load" if v < V_CUTOFF else ""
print(f"{v:6.2f} V {i:6.3f} A {p:6.1f} W ~{soc(v + i * R_PATH):3.0f} %{low}", flush=True)
time.sleep(1)Same hosts, no Python. Build and run: gcc -O2 -o kookabus kookabus.c && ./kookabus /dev/i2c-1 0x40. Exits non-zero below the cut-off so a service can stop the load. Download kookabus.c
/* Kookabus-01 reader for Linux (Raspberry Pi, Jetson, any board with /dev/i2c-N).
*
* gcc -O2 -o kookabus kookabus.c && ./kookabus /dev/i2c-1 0x40
*
* Prints one line a second. Exits non-zero when the pack falls below the cut-off,
* so a shell loop or systemd unit can stop the load.
*/
#include <fcntl.h>
#include <linux/i2c-dev.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <unistd.h>
#define R_SHUNT 0.005 /* ohm; trim against a multimeter if current reads off */
#define I_LSB 0.0005 /* A per CURRENT bit */
#define CONFIG 0x4527 /* 16-sample average, 1.1 ms conversions, continuous */
#define V_CUTOFF (5 * 3.0)
static int fd;
static void wr(uint8_t reg, uint16_t val)
{
uint8_t b[3] = { reg, val >> 8, val & 0xFF };
if (write(fd, b, 3) != 3) { perror("i2c write"); exit(2); }
}
static uint16_t rd(uint8_t reg)
{
uint8_t b[2];
if (write(fd, ®, 1) != 1 || read(fd, b, 2) != 2) { perror("i2c read"); exit(2); }
return (b[0] << 8) | b[1];
}
int main(int argc, char **argv)
{
const char *dev = argc > 1 ? argv[1] : "/dev/i2c-1";
int addr = argc > 2 ? (int)strtol(argv[2], NULL, 0) : 0x40;
if ((fd = open(dev, O_RDWR)) < 0 || ioctl(fd, I2C_SLAVE, addr) < 0) { perror(dev); return 2; }
if (rd(0xFE) != 0x5449) { fprintf(stderr, "no INA226 at 0x%02x\n", addr); return 2; }
wr(0x00, CONFIG);
wr(0x05, (uint16_t)(0.00512 / (I_LSB * R_SHUNT) + 0.5)); /* 2048 */
for (;;) {
double v = rd(0x02) * 0.00125;
double i = (int16_t)rd(0x04) * I_LSB;
double p = rd(0x03) * 25 * I_LSB;
printf("%6.2f V %6.3f A %6.1f W\n", v, i, p);
fflush(stdout);
if (v < V_CUTOFF) { fprintf(stderr, "pack below %.1f V, stop the load\n", V_CUTOFF); return 1; }
sleep(1);
}
}Uses the kernel ina2xx driver, no user-space I²C code. Run once per boot as root: sudo ./kookabus-hwmon.sh 1 0x40. Afterwards lm-sensors shows the values too. Download kookabus-hwmon.sh
#!/bin/sh
# Kookabus-01 through the kernel's ina2xx hwmon driver: no user-space I2C code.
# Run as root once per boot (or put the first two steps in rc.local / a systemd unit):
# sudo ./kookabus-hwmon.sh [i2c bus number] [address]
# Afterwards any user can read the values; `sensors` (lm-sensors) shows them too.
BUS=${1:-1}
ADDR=${2:-0x40}
DEV=/sys/bus/i2c/devices/$BUS-$(printf %04x "$ADDR")
[ -d "$DEV" ] || echo ina226 "$ADDR" > /sys/bus/i2c/devices/i2c-"$BUS"/new_device
HW=$(echo "$DEV"/hwmon/hwmon*)
echo 5000 > "$HW"/shunt_resistor # micro-ohm
while :; do
# in1 = bus voltage (mV), curr1 = mA, power1 = uW
awk -v v="$(cat "$HW"/in1_input)" -v i="$(cat "$HW"/curr1_input)" -v p="$(cat "$HW"/power1_input)" \
'BEGIN { printf "%6.2f V %6.3f A %6.1f W\n", v/1000, i/1000, p/1e6 }'
sleep 1
doneSet it up on every start. The INA226 runs from the host's 3.3 V, so it loses CONFIG and CALIBRATION whenever the host powers off. Every example writes them in its start-up path; keep that in yours.
Knobs to tune
- R_SHUNT. Nominally 0.005 Ω. Trim it if the current reading disagrees with a meter.
- R_PATH. Board plus an assumed pack resistance, used to add the sag back for a resting-voltage estimate. The Python and MicroPython examples default to 0.13 Ω.
- OCV table. Resting cell voltage to percent, typical NMC. Adjust for your packs. The state-of-charge helper is in the Python and MicroPython examples, and
test_kookabus.pychecks the register maths and the table without hardware. - V_CUTOFF. 3.0 V per cell, 15 V for 5S. The board cannot disconnect a flat pack: the host must stop the load below this.
Register-level detail, including the full INA226 map with this board's scale factors, is on the specifications page.