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