Firmware that watches all 144 cells of a Formula Student tractive battery, decides in milliseconds whether the pack is safe, and pulls the car's shutdown circuit open when it isn't. Built on a Teensy 4.1 under FreeRTOS for Virginia Motorsports.
The tractive battery on an electric Formula Student car stores enough energy to be lethal. The competition rules treat the BMS as a safety device rather than a convenience: it must measure every cell's voltage and the temperature of critical points in the pack whenever the tractive system is live or a charger is attached, and the moment anything reads out of bounds it must open the shutdown circuit and light an indicator that stays lit until a human resets it.
That framing drives the whole design. There is no "retry and hope" path and no state where a detected fault quietly goes away — a fault, once seen, is latched. The interesting engineering is in getting 144 trustworthy measurements across a high-voltage isolation boundary fast enough that the safety decision is made on fresh data.
Each segment has its own Analog Devices ADBMS6830B cell monitor sitting at pack
potential, measuring its 18 cell taps and 6 NTC thermistors locally. The monitors are
daisy-chained to each other over isoSPI — a two-wire differential link that
carries the four SPI signals as pulse pairs through transformers. That transformer coupling is
what puts a galvanic isolation boundary between the high-voltage stack and the control
electronics, which the rules require at every module-to-module boundary.
On the low-voltage side an ADBMS6822 bridge translates isoSPI back into ordinary
4-wire SPI for the Teensy. The MCU never touches pack potential; it only ever talks to the
bridge.
A daisy chain of transformers through a noisy vehicle is not a friendly channel, so nothing is taken at face value. Every command the Teensy sends carries a PEC15 checksum and every data group that comes back carries a PEC10; a mismatch marks the snapshot invalid rather than letting a corrupted voltage reach the fault logic. A missing or stale measurement is itself a fault condition under the rules, so silence is treated as failure, not as "no news."
setup() initialises the peripherals, seeds the shared state, spawns the tasks and
hands control to the FreeRTOS scheduler — it never returns, and loop() is unused.
From there every task operates on the same few global structs: BatteryStatus
holds the cell and thermistor arrays plus the pack summary, ChargingStatus holds
the charger link, and BMSStatus wraps both with the active faults, the latched
faults, and the current state.
Priorities are assigned by consequence, not by convenience. The tasks that can open the shutdown circuit or report a fault to the rest of the car run at the highest priority and preempt everything else; charger housekeeping runs at the lowest. A charger handshake can never delay a fault from reaching the shutdown circuit.
data_aq_task is the only writer of the
pack picture, and fault_handler_task — at the highest priority — is the only task
that can open the shutdown circuit.
| Module | Responsibility |
|---|---|
shared/ | Header-only data model: pack limits, fault bits, state enum. Every other module depends on it and it depends on nothing. |
data_aq/ | Drives the monitor stack over SPI, validates PEC, builds the pack snapshot, computes the summary and state of charge, evaluates faults. |
fault_handler/ | Watches the BMS state and asserts the shutdown pin while it is ERROR. |
charging/ | Charger connect/heartbeat state machine, pack limits in the control frame, and the conditions that stop a charge. |
can_handler/ | FlexCAN_T4 on CAN1 at 500 kbit/s. RX interrupt enqueues frames; TX encoders publish faults, SOC and temperatures per the vehicle DBC. |
gui/ | Human-readable telemetry over USB serial — the thing you actually use when the pack is on the bench. |
Every acquisition cycle ends by evaluating the fresh snapshot against the five conditions the
rules enumerate. If any of them trips, the corresponding bit is set in
active_faults and — this is the important part — OR'd into
latched_faults, which nothing in the firmware ever clears. The state machine moves
to ERROR and does not move back.
ERROR, and it is a human with a physical reset — the firmware has no path that
clears a latched fault. That asymmetry is deliberate: a pack fault that clears itself is a
pack fault you get to have twice.
Charging runs over the same CAN bus using 29-bit extended frames. The BMS sends a control frame to the charger once a second carrying the pack's voltage and current limits; the charger answers with its actual output and its own fault bits. If the heartbeat stops, the charger stops — the protocol's failure mode is "no charge," which is the one you want.
The firmware stops a charge on any of three conditions:
Cell balancing is not permitted while the shutdown circuit is open, which means balancing can never be a background activity — it has to be explicitly tied to the same state the safety path owns.
The architecture, the shared data model, the CAN layer and the charging state machine are in place, and the driver is up far enough to configure the monitor stack, run the wake sequence and read back configuration and device IDs over the real hardware. The current work is bringing up the full voltage and thermistor read path against a live segment and validating the PEC implementation end to end — the point where measurements stop coming from a test harness and start coming from actual cells.
Build and flash is PlatformIO (pio run -t upload) against a Teensy 4.1, with a
serial GUI task for bench diagnostics.