Kookabus-01 code

The host sees one I²C device: the INA226 at 0x40. The LEDs and the hot swap run by themselves and are not readable over I²C.

Getting started

  1. Fit a 10 A ATO fuse in F1 and F2. They ship separately.
  2. Connect the robot's power input to LOAD with a male XT60 lead.
  3. 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.
  4. Plug the first pack into BATT A. Its LED lights once the host's 3.3 V is present.
  5. Plug the second pack into BATT B. To swap later, unplug the pack whose LED is off, replace it, then repeat for the other.
  6. 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, &reg, 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
done

Set 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

Register-level detail, including the full INA226 map with this board's scale factors, is on the specifications page.