A self-hosted monitoring and control dashboard for a 2023 Mercedes Sprinter VS30 AWD 144" High Roof conversion.
A dashboard running on a Raspberry Pi 4B in the van's electrical cabinet. It reads the 12V LiFePO4 system over Bluetooth, controls smart relays over WiFi, logs everything to SQLite, and is reachable from a phone anywhere via Tailscale.
Everything runs locally. No cloud services, no vendor accounts, no subscriptions. The only outbound dependency is Tailscale for remote access.
A deployed version with physically modelled fake data runs on Vercel. Solar follows a bell curve, SOC integrates against a baseline load, and the history charts come from the same simulation as the live values, so they agree with each other rather than being random noise.
Run it locally:
cd frontend && VITE_DEMO=true npm run dev| Feature | Detail |
|---|---|
| Battery — Power Queen 100Ah LiFePO4 BMS | SOC, voltage, current, temperature, per-cell voltages over BLE |
| Solar — Victron SmartSolar MPPT 75/15 | Panel watts, charge state, daily yield from BLE advertisements |
| Fridge — Dometic CFX5 35 | Temp, set point, compressor, door, power source via an ESP32 BLE bridge |
| Starlink Mini | Latency, throughput, obstruction, and measured power draw over local gRPC |
| EcoFlow River 2 Max | Battery % decoded from the raw BLE advertisement — no auth, no cloud |
| Smart relays — 2× Shelly 1 Gen4 | USB outlets and garage circuit, local HTTP, no cloud |
| History — SQLite, four tiers | Raw → hourly → daily → monthly, automatic rollup and pruning |
| Operating modes | Storage / Camp / Trail / In Town, persisted across restarts |
| Pi health | CPU temp, load, memory, disk, uptime, undervoltage flags |
| Networking | Dual WiFi with automatic failover, switchable from the dashboard |
| Event log | Every state change recorded — toggles, mode changes, reboots |
| Backups | Nightly snapshot over Tailscale, plus on-demand download |
| Remote access | Tailscale — works from any network |
Stated explicitly, because a README claiming hardware that isn't there is worse than one admitting the gaps.
| Feature | Why not |
|---|---|
| Cameras | Router and UI scaffolded, hardware not yet installed |
| Shore power | Inferred from the BMS/MPPT current delta — no charger telemetry |
| DC-DC charger | Orion-Tr 12/12-18 is non-smart. Static config only |
| Fridge control | Reading works. Writing (set temp, on/off) is supported by the component but not wired up |
| Garmin PowerSwitch | BlueZ can't connect. Protocol is undocumented — see 2026-08-27 review |
| Maxxfan relay | Tested and rejected — it defaults open on power loss |
| Applying modes | The selection persists, but nothing is driven by it yet |
Frontend — Vite, React 18, TypeScript, Tailwind, Zustand, Recharts, React Router. Served in production by a small Express server that also proxies the API and handles auth.
Design work happens in Figma first: Van Control Panel — Dashboard UI. The file currently holds a redesign — lighter surfaces, bolder state colour, per-concern drawers instead of one settings panel — that is deliberate direction rather than anything merged. The screenshots above are the live build.
Backend — FastAPI on uvicorn. bleak for BLE, victron-ble for the MPPT,
a vendored fork of pq_bms_bluetooth for the BMS, httpx for the Shellys,
starlink-grpc-core for the dish, SQLite for storage.
Firmware — an ESP32-S3 running ESPHome bridges the Dometic fridge, which BlueZ cannot talk to at all. It exposes a local JSON API the Pi polls like any other WiFi device.
Infrastructure — Raspberry Pi 4B, nginx on :80 in front of the Express server, Starlink Mini with a home network as fallback, Tailscale, and a self-hosted GitHub Actions runner that deploys on push.
- Victron data comes from BLE advertisements, not a VE.Direct cable — the MPPT broadcasts encrypted manufacturer data that decrypts with a key from the app.
- The BMS needs a persistent connection. Rapid reconnects trigger a firmware lockout that requires physically pulling the 50A disconnect, so there is a 5-minute cooldown guard.
- The fridge took an ESP32 to reach at all. Two independent blockers: the
CFX5 speaks a different protocol generation than the widely-forked CFX3
component (
537a04xx, not537a03xx), and it requires real BLE bonding that ESPHome's default client never initiates. BlueZ on the Pi can't do either. - EcoFlow battery % is a single unencrypted byte in the BLE advertisement, at a fixed offset after the serial number. Verified against the unit's own screen. Everything richer than that lives behind their encrypted protocol.
- History is downsampled server-side. Raw endpoints bucket-average to ~300 points rather than shipping 2,880 rows a chart can't display.
- Polling pauses when the tab is hidden, which cut idle request volume by roughly 85%.
- NetworkManager won't roam back to a preferred network on its own — it only evaluates priority at boot or after a disconnect. A dispatcher script handles the return to Starlink, and the ESP32 needed its own equivalent.
Open in VS Code and press Cmd+Shift+B, or:
# frontend — proxies to the Pi over Tailscale
cd frontend && npm install --include=dev && npm run dev
# backend
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/uvicorn app.main:app --reload --port 8000--include=dev matters if NODE_ENV=production is set in your shell — npm will
otherwise silently skip devDependencies, including TypeScript.
Rebuilding the Pi from a blank SD card: docs/SETUP.md. Most
of it is automated by scripts/pi-setup.sh, which is idempotent and safe to
re-run after a failure:
scp scripts/pi-setup.sh todd@van-pi.local:~/
ssh -t todd@van-pi.local 'chmod +x pi-setup.sh && ./pi-setup.sh'| Mode | Camera interval | Intent |
|---|---|---|
storage |
6 hr | Long-term parking, battery preservation |
camp |
30 min | Default active mode |
trail |
15 min | Parked and unattended |
in_town |
30 min | Full connectivity |
The selection persists across reboots. Driving behaviour from it is not implemented.
| Scenario | Method |
|---|---|
| Same network as the Pi | http://van-pi.local |
| Anywhere else | Tailscale address |
Two auth layers: a signed cookie on the Express frontend, and an API key on the FastAPI backend for anything not arriving over loopback.
- Setup — rebuild from a blank SD card
- Troubleshooting — symptom-first fixes
- Architecture
- API Reference
- Hardware
- Operating Modes
- Design file — Figma, redesign in progress
- Future Features — prioritized roadmap
- Claude Code Context — conventions, gotchas, full system notes
- Rubber Duck Review — bugs found, and the reasoning errors behind them
- Rubber Duck Review, 2026-08-27 — the CFX5 protocol investigation
MIT