Most BMS boards already know exactly how healthy the pack is — cell voltages, current draw, thermal margins, state of charge. The problem is almost never the sensing. It's that this information is trapped on the board, visible only if you tether a laptop and open a serial monitor. This post walks through the pipeline I use to get that telemetry off the board and into a dashboard a client (or an ops team) can glance at from anywhere: ESP32 → MQTT → Mosquitto → Telegraf → InfluxDB → Grafana.
01 · Architecture at a Glance
The pipeline has one job at each stage: publish, deliver, bridge, store, render.
Nothing in the middle three stages needs custom backend code — Telegraf's MQTT consumer plugin reads the topic directly and writes points to InfluxDB using a config file, not a server you have to maintain.
02 · Firmware: Reading the Pack and Publishing Telemetry
The ESP32 polls the BMS front-end (via UART/I2C depending on the AFE — bq76952, ISL94212, etc.), computes a rolling time-to-discharge estimate from present current draw and remaining capacity, and publishes a single JSON payload per interval.
// Called every 2s from the main loop
void publishTelemetry(BmsReading r) {
float ttdHours = (r.remaining_mAh / 1000.0) / r.current_A;
StaticJsonDocument<256> doc;
doc["pack_id"] = "bms-pack-07";
doc["voltage_v"] = r.voltage_V; // 51.42
doc["current_a"] = r.current_A; // 14.80 (+charge / -discharge)
doc["temp_c"] = r.max_temp_C; // 33.6
doc["soc_pct"] = r.soc_percent; // 61
doc["ttd_min"] = int(ttdHours * 60);
doc["balancing"] = r.balancing_mask; // bitmask of active cells
doc["ts"] = getEpochMillis();
char payload[256];
serializeJson(doc, payload);
mqttClient.publish("bms/pack-07/telemetry", payload, false);
}
03 · Mosquitto: The Broker
A single Mosquitto instance on a small VPS is enough for dozens of packs. The config that matters for a client-facing deployment is TLS + per-device credentials, not throughput:
listener 8883
cafile /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
allow_anonymous false
password_file /etc/mosquitto/passwd
# one ACL line per pack keeps devices from reading each other's topics
acl_file /etc/mosquitto/acl.conf
04 · Telegraf: The Bridge, Not a Backend
Telegraf subscribes to the MQTT topic, parses the JSON, and writes each field as an InfluxDB point — no glue code required.
[[inputs.mqtt_consumer]]
servers = ["tls://broker.rksharma.dev:8883"]
topics = ["bms/+/telemetry"]
data_format = "json"
tag_keys = ["pack_id"]
json_time_key = "ts"
json_time_format = "unix_ms"
[[outputs.influxdb_v2]]
urls = ["http://localhost:8086"]
token = "$INFLUX_TOKEN"
organization = "rksharma"
bucket = "bms_telemetry"
What lands in InfluxDB
bms_telemetry,pack_id=pack-07 voltage_v=51.42,current_a=14.8,temp_c=33.6,soc_pct=61,ttd_min=134 1751702400000
05 · Grafana: The Dashboard
With InfluxDB as the data source, the dashboard is four panel types: a time-series panel for voltage and current, a gauge for temperature against thermal thresholds, a stat panel for time-to-discharge, and a table for balancing/alert state. Below is the live view a client sees — this is a mockup built to spec, not a captured screenshot, since actual pack data is client-confidential.
| Cell 03 — voltage delta 34mV | balancing |
| Cell 07 — voltage delta 29mV | balancing |
| Cell 11 — voltage delta 18mV | ok |
| Pack temp rise rate | nominal (0.4°C/min) |
06 · Making "Real Time" Actually Real Time
- Publish interval vs. dashboard refresh: firmware publishes every 1–2s for current/voltage (fast-changing), 5–10s for temperature (slow-changing) — no reason to spend radio time and broker bandwidth on a value that hasn't moved.
- Offline buffering: the ESP32 keeps a small ring buffer in RAM and republishes on reconnect with the original timestamp, so a dropped Wi-Fi link doesn't leave a permanent gap in the history.
- Retained last-value: publish the most recent reading as a retained MQTT message so a freshly opened dashboard shows current state instantly, not "no data" until the next interval.
- TLS everywhere: pack telemetry indicates SoC and discharge behavior — treat it as sensitive operational data, not just a nice-to-have signal, especially for fleet/commercial deployments.
07 · Why This Stack Over the Alternatives
| Option | Trade-off |
|---|---|
| HTTP POST direct to a REST API | Simpler backend, but no pub/sub fan-out and worse behavior on flaky connections |
| Cloud IoT platform (AWS IoT / Azure IoT Hub) | Less to self-host, but higher recurring cost and vendor lock-in for a client-owned deployment |
| Mosquitto + Telegraf + InfluxDB + Grafana | Fully self-hostable, open-source, and each piece can be swapped independently — the choice here |
08 · Extending It: On-Demand Mobile Access + Remote On/Off
Grafana is great for someone watching a screen. A field technician or fleet operator usually wants something lighter: open an app, check the pack's state in one glance, and — when needed — switch the pack on or off from their phone. That needs two additions to the pipeline above: a pull-style read path for the app, and a command channel back down to the ESP32.
Read path: a lightweight API, not a raw MQTT connection
A mobile app shouldn't hold a persistent MQTT session open just to check status occasionally — it's heavier on battery and reconnect logic than it needs to be. Instead, a small REST endpoint sits in front of InfluxDB and returns the latest point on demand:
GET /api/pack-07/latest
{
"voltage_v": 51.4,
"current_a": 14.8,
"temp_c": 33.6,
"soc_pct": 61,
"ttd_min": 134,
"switch_state": "ON",
"ts": 1751702400000
}
The app calls this whenever it's opened or pulled-to-refresh — cheap, stateless, and it works fine even on a spotty cellular connection. If you need push updates instead of polling, the same endpoint can be backed by a WebSocket or Server-Sent Events channel without changing anything upstream.
Command path: switching the pack on/off remotely
This is the direction that needs the most care, since a command channel to a battery pack is a safety-relevant control path, not just telemetry. The pattern I use:
- App sends an authenticated request to the backend:
POST /api/pack-07/switch {"state":"OFF"} - Backend publishes a signed command to a dedicated MQTT topic the pack subscribes to — never lets the app talk to the broker directly
- ESP32 verifies the command signature and current pack state before acting, and only actuates the contactor if the pack isn't mid-balancing or in a fault state
- ESP32 publishes a confirmation back on a separate
acktopic, which the backend relays to the app so the toggle reflects reality, not optimism
mqttClient.subscribe("bms/pack-07/cmd");
void onMqttMessage(char* topic, byte* payload, unsigned int len) {
if (!verifySignature(payload, len)) return; // reject unsigned/tampered commands
StaticJsonDocument<128> cmd;
deserializeJson(cmd, payload, len);
if (cmd["action"] == "switch" && bms.isSafeToSwitch()) {
contactor.set(cmd["state"] == "ON");
publishAck(cmd["request_id"], contactor.state());
}
}
isSafeToSwitch() should refuse the command during active balancing, an unresolved fault, or a thermal excursion — the app can show why it was refused rather than silently ignoring the request.Building telemetry for your own BMS or EV platform?
I design and implement embedded firmware and the end-to-end telemetry pipeline behind it — from AFE integration through to the dashboard your team actually watches.
Get in touch