SolarRally: The IoT Stack Behind an EV Charger Dashboard
How I built a real-time EV solar charging monitor using FastAPI, WebSockets, MQTT, and a React dashboard — and what I'd do differently.
SolarRally started as a question: what does it actually take to surface live telemetry from a solar-powered EV charger into a browser dashboard? The answer turned out to be a four-layer stack where every layer has its own failure modes.
Architecture Overview
[EV Charger / Sensor Node]
│ MQTT publish
▼
[MQTT Broker — Mosquitto]
│ subscribe
▼
[FastAPI Backend — Python]
│ WebSocket broadcast
▼
[React Dashboard — Browser]
Simple on paper. Each arrow hides complexity.
MQTT: The Reliable Piece
Mosquitto was the one part that never gave me trouble. Charger state, solar input wattage, and battery SOC published to topics like charger/state, solar/power, and battery/soc at 1-second intervals.
The FastAPI backend subscribed using paho-mqtt:
import paho.mqtt.client as mqtt
def on_message(client, userdata, msg):
topic = msg.topic
payload = json.loads(msg.payload)
asyncio.run_coroutine_threadsafe(
broadcast(topic, payload), loop
)
The asyncio.run_coroutine_threadsafe pattern is the key — paho runs its network loop in a thread, but the WebSocket manager lives in the asyncio event loop. Bridging them without deadlocks took a few iterations.
WebSockets: Where Things Got Interesting
FastAPI’s WebSocket support is clean, but managing multiple clients requires a connection manager. Mine handled connect, disconnect, and broadcast:
class ConnectionManager:
def __init__(self):
self.active: list[WebSocket] = []
async def connect(self, ws: WebSocket):
await ws.accept()
self.active.append(ws)
async def disconnect(self, ws: WebSocket):
self.active.remove(ws)
async def broadcast(self, data: dict):
dead = []
for ws in self.active:
try:
await ws.send_json(data)
except:
dead.append(ws)
for ws in dead:
self.active.remove(ws)
The stale connection cleanup in broadcast was an afterthought that became essential — browser tabs close without clean WebSocket termination constantly.
React Dashboard
The frontend consumed the WebSocket stream and updated a live chart using recharts. The pattern was straightforward: useEffect opens the socket, useState holds the sliding window of data points, and LineChart re-renders on each update.
One gotcha: recharts re-renders on every data push, and at 1Hz with a 60-point window, this was fine. At 10Hz it would destroy performance. The fix is useMemo on the data slice and shouldComponentUpdate equivalents — worth knowing before you scale.
What I’d Do Differently
1. TimescaleDB instead of PostgreSQL. Raw power data is time-series by nature. TimescaleDB’s automatic partitioning and time_bucket aggregation would have saved me from writing a lot of manual windowing SQL.
2. Server-Sent Events for the dashboard. WebSockets felt like overkill for a unidirectional data stream. SSE is simpler, survives load balancers without sticky sessions, and browsers handle reconnection automatically.
3. Schema validation at the MQTT boundary. Late in the project I added Pydantic models to validate MQTT payloads. It caught two cases where the charger firmware sent malformed JSON during a firmware update. Should have been there from day one.
SolarRally is on GitHub — the README has setup instructions for running it locally with a simulated MQTT publisher so you don’t need actual hardware.