BMS ENGINEERING · FIELD NOTES

Streaming a Battery Pack's Vitals to a Live Grafana Dashboard

Jul 2026 9 min read Ravi Sharma
Voltage, current, temperature and time-to-discharge, streamed from firmware to a live dashboard — plus how the same pipeline extends to on-demand mobile access and remote pack on/off control.
Pack Voltage
51.4V
Discharge Current
14.8A
Max Cell Temp
33.6°C
Time to Discharge
2h 14m

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.

Why this exact stack: every hop is replaceable and battle-tested independently, the transport (MQTT) tolerates flaky field connections, and Telegraf removes the need to hand-write a backend service just to get data into a time-series database.

01 · Architecture at a Glance

The pipeline has one job at each stage: publish, deliver, bridge, store, render.

ESP32 + BMS
reads V / I / T
MQTT publish
TLS, QoS 1
Mosquitto
broker
Telegraf
MQTT consumer
InfluxDB
time-series
Grafana
live dashboard

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);
}
QoS matters here: use QoS 1 (at-least-once) for telemetry, not QoS 0. A dropped voltage reading in a dashboard is a cosmetic gap; a silently dropped over-temperature event is the kind of gap you don't want during a field failure investigation.

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.

grafana.rksharma.dev/d/bms-pack-07/live-telemetry

BMS Live Telemetry — Pack 07

Last 6h · refresh 5s
Pack Voltage & Currentnominal
51.4V 14.8A
V: amber solidA: green dashedwindow: 6h
Max Cell Tempok
33.6°
warn >45°Ccrit >58°C
Time to Discharge@ 14.8A load
2h 14mSoC 61% · 8.2Ah remaining
0%61%100%
Cell Balancing & Alerts3 active
Cell 03 — voltage delta 34mVbalancing
Cell 07 — voltage delta 29mVbalancing
Cell 11 — voltage delta 18mVok
Pack temp rise ratenominal (0.4°C/min)
Fig 1 — Grafana dashboard layout used for pack-level live monitoring (illustrative mock built to the actual panel spec; live figures shown are representative, not a specific client's pack data).

06 · Making "Real Time" Actually Real Time

07 · Why This Stack Over the Alternatives

OptionTrade-off
HTTP POST direct to a REST APISimpler 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 + GrafanaFully 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:

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());
  }
}
Guardrail: 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.
Pack 07 · On-Demand
51.4V
61% SoC · 2h 14m remaining
Current14.8A
Max cell temp33.6°C
BalancingCells 3, 7
Pack switch
↻ Fetch latest
Fig 2 — on-demand mobile view: pull-to-refresh telemetry plus a guarded remote on/off switch (illustrative mockup).

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
← Back to all posts