Every lithium-ion battery you use, from a phone to an EV, has a small piece of software and hardware babysitting it. That is the Battery Management System (BMS). Without one, a pack can overcharge, overheat, or drift out of balance, and the failure modes range from a dead pack to a fire.
In this post I'll cover what a BMS does and then build a minimal BMS core in Rust.
What does a BMS actually do?
A BMS has four main jobs:
- Monitoring: it measures the voltage of each cell, the pack current, and temperatures.
- State estimation: it estimates State of Charge (SoC, "how full is it?") and State of Health (SoH, "how worn out is it?").
- Protection: it opens the contactor or MOSFET when something goes out of range (over-voltage, under-voltage, over-temperature, over-current).
- Balancing: cells in series drift apart over time, so it bleeds off the stronger cells to keep the pack even.
The flow in the diagram above is the whole loop: sensors feed the controller, the controller decides, and it acts on the pack through the contactor and balancing circuits.
Why Rust for a BMS?
A BMS is safety-critical and often runs on constrained hardware. Rust fits well because:
- Memory safety without a GC: no use-after-free or data races in the protection logic.
- Enums and pattern matching: faults and states become types the compiler forces you to handle.
-
no_stdsupport: the same logic can run on a microcontroller (STM32, nRF, ESP32). - Zero-cost abstractions: predictable timing for control loops.
The Rust implementation
The example below is deliberately simple and uses only the standard library, so you can run it with cargo run. It covers:
- Per-cell readings
- SoC tracking with coulomb counting
- Fault detection with a latched fault state
- A cell balancing check
use std::time::Duration;
#[derive(Debug, Clone, Copy)]
struct CellReading {
voltage_v: f32,
temp_c: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Fault {
OverVoltage(usize),
UnderVoltage(usize),
OverTemp(usize),
OverCurrent(f32),
}
#[derive(Debug, PartialEq)]
enum State {
Ok,
Faulted(Fault),
}
struct Limits {
v_max: f32,
v_min: f32,
temp_max_c: f32,
current_max_a: f32,
}
struct Bms {
limits: Limits,
capacity_ah: f32,
soc: f32, // 0.0 ..= 1.0
state: State,
}
impl Bms {
fn new(limits: Limits, capacity_ah: f32, initial_soc: f32) -> Self {
Self { limits, capacity_ah, soc: initial_soc, state: State::Ok }
}
/// Returns the first fault found, if any.
fn check(&self, cells: &[CellReading], current_a: f32) -> Option<Fault> {
if current_a.abs() > self.limits.current_max_a {
return Some(Fault::OverCurrent(current_a));
}
for (i, c) in cells.iter().enumerate() {
if c.voltage_v > self.limits.v_max {
return Some(Fault::OverVoltage(i));
}
if c.voltage_v < self.limits.v_min {
return Some(Fault::UnderVoltage(i));
}
if c.temp_c > self.limits.temp_max_c {
return Some(Fault::OverTemp(i));
}
}
None
}
/// One control-loop tick. Positive current = discharging.
fn update(&mut self, cells: &[CellReading], current_a: f32, dt: Duration) {
// A fault is latched: once tripped, stay tripped until reset.
if self.state != State::Ok {
return;
}
if let Some(fault) = self.check(cells, current_a) {
self.state = State::Faulted(fault);
// In real hardware: open the contactor here.
return;
}
// Coulomb counting: SoC -= (I * dt) / capacity
let dt_h = dt.as_secs_f32() / 3600.0;
self.soc = (self.soc - current_a * dt_h / self.capacity_ah).clamp(0.0, 1.0);
}
}
/// Cells noticeably above the weakest cell get bled down.
fn cells_to_balance(cells: &[CellReading], threshold_v: f32) -> Vec<usize> {
let min = cells.iter().map(|c| c.voltage_v).fold(f32::MAX, f32::min);
cells
.iter()
.enumerate()
.filter(|(_, c)| c.voltage_v - min > threshold_v)
.map(|(i, _)| i)
.collect()
}
fn main() {
let limits = Limits {
v_max: 4.2,
v_min: 3.0,
temp_max_c: 60.0,
current_max_a: 100.0,
};
// 50 Ah pack, starting at 90% charge
let mut bms = Bms::new(limits, 50.0, 0.9);
let mut cells = vec![
CellReading { voltage_v: 4.10, temp_c: 30.0 },
CellReading { voltage_v: 4.12, temp_c: 31.0 },
CellReading { voltage_v: 4.09, temp_c: 30.5 },
CellReading { voltage_v: 4.15, temp_c: 31.5 },
];
println!("Cells to balance: {:?}", cells_to_balance(&cells, 0.02));
// Discharge at 25 A for 30 minutes, in 60 s ticks
for _ in 0..30 {
bms.update(&cells, 25.0, Duration::from_secs(60));
}
println!("SoC after 30 min: {:.1}%", bms.soc * 100.0);
// Simulate a thermal event on cell 2
cells[2].temp_c = 65.0;
bms.update(&cells, 25.0, Duration::from_secs(60));
println!("State: {:?}", bms.state);
}
Expected output:
Cells to balance: [1, 3]
SoC after 30 min: 65.0%
State: Faulted(OverTemp(2))
Walking through the code
Faults are a type, not a string. Fault is an enum that carries the index of the offending cell. Anywhere you match on it, the compiler makes you handle every case, so adding a new fault type can't silently slip past your protection logic.
The fault is latched. Once state becomes Faulted, update returns early and stops accumulating SoC. Real BMS designs do this on purpose: a pack that tripped on over-temperature should not auto-resume the moment the reading dips back under the limit.
SoC uses coulomb counting. It integrates current over time: SoC -= I * dt / capacity. It's simple and cheap, but errors accumulate (sensor offset, temperature effects), so production systems correct it using open-circuit voltage lookups or an Extended Kalman Filter.
Balancing is a simple comparison. We find the lowest cell voltage and flag any cell more than 20 mV above it. Hardware then bleeds those cells through a resistor (passive balancing).
Where to take it next
This is a toy, but it's a solid skeleton. Ways to extend it:
-
Go
no_std: replaceVecwithheapless::Vecand run it on an STM32 or ESP32 withembedded-hal. - Add hysteresis and debounce: require a fault to persist for N ticks before tripping, to avoid false positives from noisy sensors.
- Better SoC: add an EKF, or a voltage-vs-SoC lookup table to correct drift.
- Add SoH tracking: estimate capacity fade and internal resistance growth over cycles.
-
Talk CAN bus: publish pack state to the rest of the vehicle with the
socketcancrate. -
Test it properly: Rust's built-in test framework and property-based testing (
proptest) are great for hammering protection logic with random inputs.
Wrapping up
A BMS is a great example of where Rust's strengths line up with the problem: strict correctness, predictable performance, and a type system that encodes safety rules. If you're building anything with batteries, from e-bikes to grid storage, this is a fun area to dig into.
If you build on this, I'd love to hear about it in the comments.
Top comments (0)