Industrial IoT Monitoring Platform
A real-time MQTT pipeline serving live battery telemetry from globally distributed plants. Dynamically subscribes to 100,000+ topics with zero hardcoded schema, delivered sub-second to operators at batterywali.com.
Open live dashboardThe problem
Industrial battery fleets generate cell-level telemetry — voltage, temperature, and state-of-charge for every cell in every pack — from plants distributed across continents. The data has to land on an operator screen fast enough to act on, and the system has to keep working as new plants, racks, and packs come online without redeploying the backend every time the topic tree grows.
Hardcoding 100,000+ MQTT topics is not a solution. Neither is exposing the broker directly to the browser. The pipeline needs to discover the schema, isolate tenants, and push updates over a stable contract.
Architecture
Each plant runs a Raspberry Pi acting as the edge gateway — it bridges the on-site PLCs and battery management buses to MQTT, publishing per-cell telemetry over QoS 1 to a central Mosquitto broker. A topic resolver on the backend discovers the topic tree at runtime, normalizes it, and fans out updates to subscribed clients over a thin WebSocket layer.
What it does
- Dynamic topic discoveryBackend subscribes with wildcards and infers the (plant/rack/pack/cell) hierarchy from incoming messages. New packs onboard with zero config changes.
- Sub-second push to UIWebSocket fan-out keeps the operator dashboard live; p95 from device publish to pixel update sits under 200ms.
- Tenant isolationEach plant lives in its own topic namespace, ACLs are enforced at the broker, and the API only ever returns rows scoped to the caller. Zero cross-tenant leak.
- Edge resiliencePi gateways buffer locally when the link drops and resume at the broker without duplicates. Operator dashboards reconnect transparently.
- Schema as data, not codeAdding a new metric (e.g. internal resistance) is a publish, not a deploy. The frontend renders whatever the resolver maps in.
- Observable end-to-endEvery hop has structured logs and counters — broker connections, resolver hits, websocket subscribers, latency histograms.
Per-pack live view
Drilling into a single pack shows per-cell voltage, temperature, and SoC updating in real time. The resolver maps every cell from the raw topic namespace into a stable JSON contract the dashboard renders against.
Plants, globally
A dozen plants spread across continents publish into the same topic tree. Plant identity stays anonymised on the public page; in production each pin maps to a customer-specific tenant.
One broker, every site
Every Pi at every plant talks to the same Mosquitto cluster. Topic namespaces keep tenants isolated; ACLs make it enforceable.
History, on demand
Live telemetry is durable — every sample is persisted so operators can replay yesterday at the same resolution they get today. SoC and pack voltage trends are exposed over the same REST surface the dashboard uses.
Dynamic topic resolution
The resolver is the load-bearing piece. It subscribes once with a wildcard, derives the hierarchy from each incoming topic, and keeps an in-memory schema that the WebSocket layer reads against. No topic ever appears as a string literal in application code.
# topics arrive as: tenants/<tenant>/plants/<plant>/racks/<rack>/packs/<pack>/cells/<n>/<metric>
PATTERN = re.compile(
r"^tenants/(?P<tenant>[^/]+)"
r"/plants/(?P<plant>[^/]+)"
r"/racks/(?P<rack>[^/]+)"
r"/packs/(?P<pack>[^/]+)"
r"/cells/(?P<cell>\d+)"
r"/(?P<metric>[\w_]+)$"
)
def on_message(client, _userdata, msg):
m = PATTERN.match(msg.topic)
if not m:
return
key = SchemaKey(**m.groupdict())
schema.upsert(key) # grow the live topic tree
fanout.publish(key, msg.payload) # push to subscribed websocketsStack, by layer
Every layer has one job and a tight contract with the one above it. Swap Mosquitto for HiveMQ or SQLite for Postgres — nothing else moves.
- 05layerOperatorBrowser dashboard, live updates, drill-down per cell.
- React
- WebSockets
- D3
- 04layerServingREST snapshots + WebSocket deltas on the same contract.
- FastAPI
- Pydantic
- Uvicorn
- 03layerBackendDynamic topic resolver, schema discovery, fan-out.
- Python 3.12
- paho-mqtt
- PostgreSQL
- Redis
- 02layerTransportCentral pub/sub with per-tenant ACLs.
- Mosquitto
- MQTT 3.1.1
- QoS 1
- 01layerEdgeRaspberry Pi bridges PLCs/BMS to MQTT at each plant.
- Raspberry Pi OS
- systemd
- modbus
- C/C++ daemon
How a sample travels
- 01 · edge
Pi gateway at the plant
Reads the BMS bus, publishes per-cell V/°C/SoC to MQTT at QoS 1. Buffers locally if the uplink drops.
- 02 · transport
Mosquitto broker
Central pub/sub. ACLs scope each tenant to their own namespace; no cross-tenant subscription is possible.
- 03 · resolver
Dynamic topic resolver
Subscribes with a wildcard, derives the schema from incoming topics, persists samples, and fans out to websockets.
- 04 · serving
REST + WebSocket API
WebSocket pushes deltas live, REST serves snapshots and historical windows. Same contract, no broker exposure.
- 05 · operator
Live dashboard
Renders plant / rack / pack / cell views with sub-second updates. Public read at batterywali.com:9000.
Outcome
The platform is live and serving operators across all 12 plants. Onboarding a new pack or even a new metric no longer requires a backend deploy — the resolver picks it up the moment a Pi starts publishing. Zero data leaks, sub-second latency, and a topic surface that has grown more than 5× since launch without a single line of new schema code.