~/blog/owning-the-telemetry-why-i-built-a-custom-esp32-bms-from-scratch.md

Owning the Telemetry: Why I Built a Custom ESP32 BMS from Scratch

April 29, 2026
4 min read
#Embedded Systems#FreeRTOS#Firmware Engineering#Power Electronics#C++#ESP-IDF#I2C Communication#Hardware Architecture
Owning the Telemetry: Why I Built a Custom ESP32 BMS from Scratch

Owning the Telemetry: Why I Built a Custom ESP32 BMS from Scratch

If you want to protect a 3S Lithium-Ion battery pack, the industry offers countless ready-made ICs from Texas Instruments or Analog Devices. You wire them up, and they work blindly in the background.

So why did I choose to build a custom Battery Management System (BMS) from scratch using an ESP32, INA219 sensors, and FreeRTOS?

The answer is absolute control. A ready-made IC gives you a black box of safety. A custom telemetry system gives you the power to rule every single parameter, analyze raw discharge curves, and dictate the exact state machine of your hardware. As a 2nd-year Electrical and Electronics Engineering student, I realized that relying on black boxes wouldn't teach me how the hardware actually breathes.

The RTOS Architecture: Determinism Over Polling

This project required strict timing. I bypassed the standard Arduino loop() and built the architecture around FreeRTOS.

The control logic is straightforward but mission-critical. I use I2C to communicate with the INA219 power monitors. The system constantly reads the raw voltage and current data. If a cell drops below the safe threshold, the ESP32 triggers a strict hardware cutoff by pulling the MOSFET gate pins LOW via standard GPIOs.

By utilizing FreeRTOS tasks, I separated the telemetry logging from the safety cutoff logic. The GPIO cutoff runs at a higher priority, ensuring that a slow serial print or a network delay will never cause a thermal runaway.

cpp
// Example code for mission-critical task creation. xTaskCreatePinnedToCore( bms_fault_detection_task, "Fault_Task", 4096, NULL, configMAX_PRIORITIES - 1, // Absolute highest priority &faultTaskHandle, 1 // Pinning this task to Core 1 );

The "Bare-Metal" Dilemma and Pragmatic Engineering

Initially, I wanted to write my own I2C driver from absolute scratch using bitwise operations on the ESP32 registers. I wanted the "bare-metal" bragging rights.

However, engineering is about managing risk. My custom bit-banged I2C library was fundamentally basic. In power electronics, a single dropped clock pulse could mean a missed voltage spike, leading to a battery fire. I made the pragmatic choice to use the robust ESP-IDF I2C APIs for the physical layer.

The real engineering happened afterward. I didn't use abstracted Arduino libraries for the INA219. I read the raw bytes directly from the bus and processed the calibration registers exactly as the Texas Instruments datasheet dictated. This approach bridges the gap between hardware reliability and low-level driver development.

cpp
esp_err_t INA219::readBusVoltage_V(float *out_voltage) { uint8_t rx_data[2]; esp_err_t err = i2c_master_transmit_receive(_dev_handle, &REG_BUS_VOLTAGE, 1, rx_data, 2, 100); if (err != ESP_OK) { return err; } uint16_t raw_status = (uint16_t)((rx_data[0] << 8) | rx_data[1]); /** * From the datasheet, we need to shift the 3 bits to the right so we can get the raw_voltage value. * Refer to: https://www.ti.com/lit/ds/symlink/ina219.pdf#%5B%7B%22num%22%3A87%2C%22gen%22%3A0%7D%2C%7B%22name%22%3A%22XYZ%22%7D%2C0%2C575.3%2C0%5D * The Bus Voltage register bits are not right-aligned. In order to compute the value of the Bus Voltage, Bus Voltage Register contents must be shifted right by three bits. This shift puts the BD0 bit in the LSB position so that the contents can be multiplied by the Bus Voltage LSB of 4-mV to compute the bus voltage measured by the device. */ uint16_t raw_voltage = raw_status >> 3; /** * And from the same page as the referred previously, we need to multiply 4 mV to compute the actual bus voltage read from * the sensor. */ *out_voltage = (float)raw_voltage * 0.004f; return ESP_OK; }

Refer to this repository for more implementations.

The Real Bugs: Toolchains and Datasheets

The hardest part of this project was not the C++ code. The true bottlenecks were development environments and documentation.

First, integrating the ESP-IDF toolchain into my NixOS setup was a nightmare. I initially tried using a shell.nix approach with the mirrexagon/nixpkgs-esp-dev repository. It led to endless dependency conflicts. I eventually solved it by migrating entirely to a flake.nix architecture, locking my dependencies and creating a reproducible clean-room environment for compilation.

The second massive hurdle was the datasheet itself. Reading a 30-page technical document is an acquired skill. At first, I was completely lost in the register maps and calibration formulas. But wrestling with the INA219 documentation forced me to learn how silicon manufacturers communicate with engineers. It elevated my perspective from a hobbyist pasting code to an engineer implementing hardware specifications.

Conclusion

Building this BMS taught me that embedded engineering is not about typing syntax. It is about reading datasheets, managing toolchains, and making pragmatic decisions to keep hardware from catching fire. Ready-made chips are great for production, but if you want to understand the system, you have to build the system.

Here are the source codes for this project:

  • INA219 Library

  • ESP32 Firmware Code (Still in development, will be updated.)

End of file