Code Battery Monitoring Scripts for Mobile Robots in 20 Minutes

Build reliable Python battery monitoring for mobile robots: read voltage, trigger alerts, and prevent damage from over-discharge.

Problem: Your Robot Dies Mid-Mission With No Warning

Your mobile robot shuts down unexpectedly in the field, corrupting logs and leaving it stranded. You need a monitoring script that reads battery state, triggers safe shutdown before damage occurs, and logs voltage over time.

You'll learn:

  • How to read battery voltage via ADC (I2C/SPI) in Python
  • How to calculate state-of-charge (SoC) from voltage curves
  • How to trigger tiered alerts and a graceful shutdown

Time: 20 min | Level: Intermediate


Why This Happens

Li-ion and LiPo cells drop from ~4.2V (full) to ~3.0V (cutoff) in a non-linear curve. Most robot controllers don't monitor this at the software level — they rely on hardware protection circuits that trip hard, without saving state or notifying your system.

Common symptoms:

  • Robot powers off mid-task with no log entry
  • Motors brown out at "30%" battery because voltage sag isn't accounted for
  • Battery degrades faster than expected from repeated deep discharge

Solution

This guide uses a Raspberry Pi with an INA219 current/voltage sensor over I2C. The same pattern applies to any ADC; swap the read_voltage() function for your hardware.

Step 1: Install Dependencies

pip install smbus2 RPi.GPIO

Verify the INA219 is visible on the I2C bus:

i2cdetect -y 1

Expected: You should see address 0x40 (INA219 default) in the output grid.

If it fails:

  • No address shown: Check wiring — SDA to GPIO2, SCL to GPIO3, confirm 3.3V supply
  • Permission denied: Add your user to the i2c group: sudo usermod -aG i2c $USER

Step 2: Define the Voltage-to-SoC Curve

Li-ion cells don't discharge linearly. A lookup table gives you accurate state-of-charge without complex math.

# battery_monitor.py

import smbus2
import time
import logging
import subprocess
from dataclasses import dataclass

logging.basicConfig(
    filename="/var/log/robot_battery.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s"
)

# Voltage → SoC lookup for a 4S Li-ion pack (16.8V full, 12.0V cutoff)
# Tune these values to your specific cell chemistry and pack configuration
VOLTAGE_SOC_TABLE = [
    (16.8, 100),
    (16.4, 90),
    (16.0, 80),
    (15.6, 70),
    (15.2, 60),
    (14.8, 50),
    (14.2, 40),
    (13.6, 30),
    (13.0, 20),
    (12.4, 10),
    (12.0, 0),
]

WARN_THRESHOLD  = 20  # % — alert pilot/operator
CRITICAL_THRESHOLD = 10  # % — begin graceful shutdown

@dataclass
class BatteryState:
    voltage: float
    soc: int         # state of charge, 0–100%
    status: str      # "OK", "WARN", "CRITICAL"

Step 3: Read Voltage from INA219

INA219_ADDRESS = 0x40
INA219_BUS_VOLTAGE_REG = 0x02

def read_voltage(bus: smbus2.SMBus) -> float:
    """Read bus voltage from INA219 and return value in volts."""
    raw = bus.read_word_data(INA219_ADDRESS, INA219_BUS_VOLTAGE_REG)
    # INA219 returns big-endian; swap bytes
    raw = ((raw & 0xFF) << 8) | ((raw >> 8) & 0xFF)
    # Bits 15:3 are the voltage data; LSB = 4mV
    voltage = (raw >> 3) * 0.004
    return round(voltage, 3)

def voltage_to_soc(voltage: float) -> int:
    """Interpolate SoC from the lookup table."""
    if voltage >= VOLTAGE_SOC_TABLE[0][0]:
        return 100
    if voltage <= VOLTAGE_SOC_TABLE[-1][0]:
        return 0
    for i in range(len(VOLTAGE_SOC_TABLE) - 1):
        v_high, soc_high = VOLTAGE_SOC_TABLE[i]
        v_low,  soc_low  = VOLTAGE_SOC_TABLE[i + 1]
        if v_low <= voltage <= v_high:
            ratio = (voltage - v_low) / (v_high - v_low)
            return int(soc_low + ratio * (soc_high - soc_low))
    return 0

Step 4: Build the Monitor Loop

def get_battery_state(bus: smbus2.SMBus) -> BatteryState:
    voltage = read_voltage(bus)
    soc = voltage_to_soc(voltage)

    if soc <= CRITICAL_THRESHOLD:
        status = "CRITICAL"
    elif soc <= WARN_THRESHOLD:
        status = "WARN"
    else:
        status = "OK"

    return BatteryState(voltage=voltage, soc=soc, status=status)

def graceful_shutdown():
    """Save state and power down safely."""
    logging.critical("Battery critical — initiating shutdown")
    # Add your mission-abort hooks here (e.g., park robot, flush logs)
    subprocess.run(["sudo", "shutdown", "-h", "now"])

def main():
    bus = smbus2.SMBus(1)  # /dev/i2c-1 on Raspberry Pi
    poll_interval = 30     # seconds between readings

    logging.info("Battery monitor started")

    while True:
        try:
            state = get_battery_state(bus)
            logging.info(f"{state.voltage}V | {state.soc}% | {state.status}")

            if state.status == "CRITICAL":
                graceful_shutdown()
                break

            if state.status == "WARN":
                # Publish to your robot's message bus, e.g. ROS topic or MQTT
                logging.warning(f"Low battery: {state.soc}% remaining")

        except OSError as e:
            # I2C read failure — sensor disconnected or power issue
            logging.error(f"Sensor read failed: {e}")

        time.sleep(poll_interval)

if __name__ == "__main__":
    main()

Step 5: Run as a systemd Service

Running as a service ensures monitoring survives SSH disconnects and starts on boot.

# /etc/systemd/system/battery-monitor.service
[Unit]
Description=Robot Battery Monitor
After=network.target

[Service]
ExecStart=/usr/bin/python3 /opt/robot/battery_monitor.py
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

Enable it:

sudo systemctl daemon-reload
sudo systemctl enable battery-monitor
sudo systemctl start battery-monitor

Verification

Check that readings are appearing in the log:

tail -f /var/log/robot_battery.log

You should see timestamped entries every 30 seconds:

2026-02-17 10:14:02 INFO 15.62V | 68% | OK
2026-02-17 10:14:32 INFO 15.59V | 67% | OK

Check service health:

sudo systemctl status battery-monitor

Expected: Active: active (running)

If voltage reads 0.000V: The INA219 shunt resistor value in your register config may need adjustment. Read CONFIG_REG (0x00) and verify the gain setting matches your shunt.


What You Learned

  • Li-ion SoC requires curve interpolation, not a simple percentage from voltage
  • Tiered thresholds (WARN → CRITICAL) give you time to abort missions gracefully before hardware protection trips
  • Wrapping I2C reads in try/except prevents a flaky sensor from crashing the monitor itself
  • Limitation: This script samples at fixed intervals and misses instantaneous voltage sag under high motor load — consider a shorter poll interval (5s) during high-current maneuvers, or add current sensing to calculate a load-compensated voltage

When NOT to use this approach: If your robot uses a smart battery (e.g., DJI, Maxell BMS with UART output), read the BMS protocol directly — it already handles SoC calculation and will be more accurate than voltage-based estimates.


Tested on Raspberry Pi 4B, Python 3.11, INA219 breakout board — pattern applies to Jetson Nano, Orange Pi, and any Linux SBC with I2C.