Cool Eva
Telemetry for a watercooled 2021 Energica Eva Ribelle. A Raspberry Pi inside the bike logs the temperatures of a custom watercooling loop on the battery pack, plus the bike's own battery / charge / cell / drive telemetry read straight off the CAN bus — all into an encrypted, write-only ride log, surfaced as a live phone dashboard and (after decryption on the laptop) a Grafana dashboard for post-ride analysis.
Hardware & setup
- Raspberry Pi Zero 2 W running the app as a
systemdservice (Node.js, runs as root). - 2× MAX31865 + PT100 probes over SPI — coolant in and out of the custom battery watercooling loop.
- 8devices Korlan USB2CAN plugged into the bike's OBD port →
can0(in-kernelusb_8dev, no driver install). 500 kbit, 11-bit. The app reads broadcast frames and actively polls standard OBD-II PIDs (read-only — no diagnostic writes). - Networking: the Pi joins my phone's hotspot, so it's reachable at
http://cool-eva.localfrom the phone's browser. It's a bit janky (have to open hotspot page in phone settings for ~20s at the start of every ride), but it works for an at-a-glance dash while riding/charging.
What it logs
Everything is logged on change (so steady values don't spam the log) into the encrypted ride log — see Encrypted ride log.
| Group | Signals | Source |
|---|---|---|
| Coolant (custom loop) | coolant_in, coolant_out (°C) | MAX31865 PT100 |
| Battery / BMS | batt_temp_lo, batt_temp_hi (°C, always the true pack temperature), batt_temp_lo_vcu, batt_temp_hi_vcu (what the VCU and dash read — lowered once a DC-derate config is flashed, by an amount that depends on the config; under the current clamp they pin at 35 °C while the pack is hotter), soc (%), soh (%), pack_v (V), pack_a (A), pack_kw (kW), allowed_discharge_a, allowed_regen_a (A), pack_resistance_mohm (mΩ) | CAN 0x200/0x202/0x206/0x660 |
| Cells | cell_min_mv, cell_avg_mv, cell_max_mv, cell_spread_mv, cell_deviation_mv (the BMS's own ΔV), cell_lowest_v_idx, cell_highest_v_idx (which cell is at each extreme right now — at a few mV of spread that ranking is noise, not a health verdict), cells_connected, cell_voltage_sum_v | CAN 0x203/0x205/0x207 |
| BMS state & faults | charge_state (raw System State bitfield) + decoded bms_state_* (discharge / charge / balancing / trickle / idle / charge complete / maintenance), bms_error_flags, bms_warning_flags (raw words) + booleans for the ones worth acting on: cell over/under voltage, over temperature, leak detected, leak detection failed, contactor faults, low SOC, balancing required | CAN 0x201 |
| Isolation | iso_test_1, iso_test_2, iso_test_total (10-bit ADC, 512 = ideal), bms_io_state, lmu_comm_warnings | CAN 0x207/0x206 |
| Charge | charge_state, dc_v, dc_a, mains_v, mains_a, charge_limit_a, charger_enabled, charger_max_dc_v, charger_max_dc_a | CAN 0x201/0x305/0x306/0x10a/0x300 |
| Energy | inst_consumption_wh, residual_energy_wh (available energy), bms_remaining_energy_raw, remaining_ah | CAN 0x025/0x10A/0x205 |
| Drive | throttle_pct, speed_kmh, motor_rpm, motor_load_pct, dist_since_clear_km | CAN 0x109 + OBD-II 0D/0C/04/31 |
| OBD-II (1 Hz) | bike_coolant_temp (motor/coolant °C), oil_temp (°C), ambient_temp (°C), aux_12v (V), soh_pid (%) | OBD-II 05/5C/46/42/5B |
| Trouble codes | mil_on, dtc_count (stored, per PID 01), dtc_stored_count (the mode-03 list's own length — the same number down a second path), dtc_list_count (the bike's active list, a different thing), freeze_frame_dtc (the code that lit the lamp), plus one 1/0 signal per code Energica documents. See Trouble codes | OBD-II 01/02 + mode 03 · CAN 0x410 |
Signals that need the custom BMS config
Everything in the table above works on a stock, unmodified Energica — including the isolation readings on 0x207 and the allowed-current limits on 0x202, which the BMS broadcasts as shipped.
The frames below only exist once the pack's LiBAL BMS has been reflashed with the custom config. On a stock bike they simply never arrive, so these signals being absent is normal, not a fault:
| Group | Signals | Source |
|---|---|---|
| Per-cell voltages | lmu1_cell1_mv … lmu11_cell7_mv — the individual cells, multiplexed by module at 20 Hz. Known gap: cells 4-8 of LMU 1 and 2 never get sampled, because the CAN transmit order is phase-locked to the BMS's module poll (see obd-garage/CAN_MAP.md) | CAN 0x662–0x664 |
| Per-module temps | lmu1_bat1_c, lmu1_pcb1_c, lmu1_pcb2_c … — each module's battery and board sensors, keyed off the same module number as its cells | CAN 0x664 |
| Pack temps | pack_temp_avg (°C), lmu_temp_high_idx, lmu_temp_low_idx; in the clamp config also the true batt_temp_lo/batt_temp_hi plus the clamp's own arithmetic (clamp_diff, clamp_amount, batt_temp_hi_vcu_echo) | CAN 0x660 |
| Energy / hours | bms_remaining_energy_wh (1 Wh resolution), bms_uptime_min (BMCU hour meter) | CAN 0x661 |
| Cell limits | cell_cutoff_mv, cell_end_of_life_mv, cell_overvoltage_mv, cell_target_mv — the thresholds the BMS is actually configured with, so nothing downstream has to hardcode them | CAN 0x665 |
VIN and BMS writes are still not reachable from the OBD port (on the standard pins, haven't tried the other pins yet). Per-cell voltages are — they just have to be enabled in the BMS's own configuration first; see
obd-garage/CAN_MAP.md.
CUSTOM_BMS_CONFIG — set this only if you flashed the custom config
The custom config lowers the pack temperatures the VCU reads, so that its DC-charge derate knee — which starts at a reported 36 °C, far too early for a watercooled pack — is pushed later. How much lower depends on which config is flashed: the current one (14-signbit-clamp) pins the reported value at 35 °C whenever the pack is hotter than that, and passes the true temperature through untouched when it is colder, so the difference is 0 below the threshold and (true − 35) above it. An earlier config used a flat −15 °C instead, but it broke charging and is retired. Either way this changes what 0x200's temperature bytes mean, so the app has to be told:
CUSTOM_BMS_CONFIG=1 # only with the custom BMS config flashed. Default: unset = stock.
Leave it unset on a stock bike — batt_temp_lo / batt_temp_hi then come straight off 0x200, as they always have, from about five seconds after the BMS starts talking (see below). With it set, those keys come from 0x660 instead and 0x200's (shifted) view is logged separately as batt_temp_lo_vcu / batt_temp_hi_vcu. Either way batt_temp_lo / batt_temp_hi always mean the true pack temperature, so the history stays one continuous series.
The flag is only a hint about what to expect — the frames on the bus win. Which config is live can only be read off what arrives (a long 0x660 means the offset config, a short one means the extended config without it, and no 0x660 at all means stock), so the true-temperature keys are not written at all until that is settled, which takes up to 5 s from the first 0x200. A gap is deliberate: batt_temp_lo / batt_temp_hi are never allowed to carry the shifted view, and everything is logged on change, so a few seconds of silence costs nothing while a wrong value would be sealed into the ride log for good. batt_temp_lo_vcu / batt_temp_hi_vcu are unaffected and log from the first frame.
Get the flag wrong in either direction and the app says so:
- flag unset but the custom frames turn up → loud error, and the keys take
0x660's true values; - flag set but no
0x660ever arrives → warning, andbatt_temp_lo/batt_temp_histay unlogged rather than falling back to0x200, whose bytes are the shifted view under that config. Unset the flag if the pack really is stock.
Nothing else in the app is affected by this flag; every other decode is correct on both configs.
How it works
MAX31865 probes ─┐
├─► signals (log-on-change) ─► sealed ride log ─► /dl ─► laptop ─► SQLite ─► Grafana
Korlan can0 ─────┤ └─► live state ─► WebSocket ─► phone dashboard
· broadcast decode (0x200, 0x203, …) (the bike holds only a public key:
· OBD-II poll @1 Hz (0D, 05, 42, …) it can seal history, never read it)
· GPS on 0x410
Energica BT hub ─┘
· torque/power, odometer, vehicle state
src/can/—socket(can0 bring-up + raw channel),decode/decode-bms(broadcast frame decoders, pure),pack-temperature(picks which frame owns the true pack temperature, since that depends on which BMS config is flashed),obd(OBD-II poll loop),signals/registry(log-on-change core).src/gps/—decode(the hub's GPS message, pure; shared by CAN0x410and the BLE link, which send byte-identical frames),clock(steps the Pi's clock from satellite UTC — it has no RTC).src/ble/— the Bluetooth link to the Connectivity Hub:protocol(framing + handshake, pure),client(D-Bus session),adapter(bring-up).src/sensors/max31865.ts— the coolant probes.src/storage/encrypted-log.ts— the only persistence on the bike: sealed, append-only, write-only.src/db.ts— SQLite schema (long/EAV:signal+reading). Now used only on the laptop, byscripts/decrypt-log.ts, to rebuild a plaintext DB from decrypted segments.src/http/—static(servespublic/from memory),download(/dl),waypoint(/waypoint),status(/status).src/ws.ts+public/— the live phone riding dashboard (see below).src/index.ts— wires it all together.
The dashboard
Four screens, switched from the tab bar — or by flashing the high beam three times, which is the only control that works with both hands on the bars.
| Screen | For |
|---|---|
| Ride | GPS speed, power, pack and coolant temperatures, coolant ΔT with the I²R watts going in beside it, charge |
| Hypermile | Weakest cell's headroom above cut-off (instantaneous and sag-compensated), the BMS's 60 s cut-off timer, allowed current, rolling Wh/km, resistive loss, all 81 cells as a strip |
| Charge | A / V / kW, what the BMS is granting the charger, balancing state, cell spread trend, pack temperature, isolation |
| All | Every signal the bike is producing, grouped and filterable |
Ride and Charge are what you leave it on; the other two the bike picks for you. It switches to Charge when the BMS reports a charging state, and to Hypermile below 5 % SOC or when the weakest cell drops within 150 mV of cut-off — SOC near empty comes from an OCV table that was never re-characterised for these cells, so it can't be the only trigger. The switch is edge-triggered: once it has moved you, you can move back and it stays put.
Design notes for the hypermiling numbers are in HYPERMILING.md; the derivations all live in public/lib/derive.js and nothing derived is ever logged.
No build step
public/ is plain ES modules that the browser runs exactly as committed — deploy stays git pull + restart, with nothing to rebuild and no dist to go stale, and you can edit a file over ssh in the garage. The one dependency is VanJS (5 kB, vendored in public/vendor/), which is signals over real DOM with no virtual DOM anywhere.
It is still type-checked: tsconfig.json sets checkJs, and the modules carry JSDoc types that import the server's own DashboardMessage from src/ws.ts. Change the wire shape and npm run typecheck fails — which is what stops the two drifting apart.
Bad readings are filtered rather than drawn. Across 7.6 M logged rows the bike has produced coolant_in at −242 °C (59 450 rows), coolant_out at 988 °C (40 351), 0xFFFF cell voltages and high_beam reading 193 — so public/lib/bounds.js gates every signal against a physical range, and a rejected value shows as sensor fault instead of being clamped into something plausible.
Developing it without the bike
node --experimental-strip-types scripts/replay-capture.ts <capture.log> --speed 4 --skip 60
# → dashboard on http://localhost:8080, fed by the real decoders
Replays a candump -tA capture through the actual decode path, so what's on screen has taken the same route it does on the Pi. Captures and what's in each are listed in cool-eva-archive/CAPTURES.md; capture-20260802-185513-563dd217.log is the only one containing a real charging session.
Saving a waypoint from Siri
GET /waypoint stamps the current fix into the ride log and replies with one line of text, which Siri reads back. To set it up: Shortcuts → new shortcut → Get Contents of URL → http://cool-eva.local/waypoint, then add Speak Text with the result. Name it something like "Mark this spot" and it works from the handlebars.
A waypoint is stored as three ordinary signals (waypoint_seq, waypoint_lat, waypoint_lon), so it travels the normal path into the encrypted log and needs no change to the log format. It refuses to save on a fix older than 30 seconds, and says so rather than silently recording the wrong place.
Running it
The app is a systemd service on the Pi (Node 24, TypeScript run directly via --experimental-strip-types), serving http://<pi>/ on port 80.
npm install # builds better-sqlite3 + socketcan (Linux only)
sudo node scripts/setup-service.ts # install + enable + start the systemd service
# deploy an update
git pull && npm ci && sudo systemctl restart thermometer
sudo journalctl -u thermometer -f # follow logs
The sealed ride log can be downloaded from http://<pi>/dl — short enough to type on a phone, ~10x smaller than the old SQLite download, and safe to fetch over any network because it's ciphertext. Decrypt it on the laptop (see below) to get a .db for Grafana.
Grafana
docker compose up -d # Grafana at http://localhost:3000, reads temperatures.db
# (build that file from a /dl download: see "Encrypted ride log")
Six dashboards are provisioned from grafana/dashboards/, one file each:
- Cooling (
cooling.json) — ΔT across the pack, heat removed against an assumed coolant flow, inlet/outlet/ambient, per-module temperatures, powertrain temps. - Battery & cell balancing (
battery-cells.json) — per-cell voltage and per-module temperature heatmaps, spread over time, the cell limits the BMS is actually configured with. - Ride summary (
ride-summary.json) — speed, power, torque, energy, peak temperatures, position, bike state. - Charging (
charging.json) — charge sessions, charger mains and DC side, the BMS system-state lanes. - Isolation & faults (
isolation-faults.json) — the BMS isolation test in raw ADC counts, the error and warning flags, the stored diagnostic code counts, the BMS IO lines. - Explore & data health (
explore.json) — every logged signal, a browser over the whole registry, and how long each signal has been quiet.
grafana/README.md collects the datasource and panel traps that querying log-on-change data in this plugin keeps producing — read it before writing a new dashboard.
Encrypted ride log
A stolen bike is a stolen SD card, and the log holds every route you've ridden — including the one that ends at your front door — plus the ID of the key fob that starts it. So the Pi can be given a public key only: it seals every reading it writes and cannot read any of it back.
Set it up once, on the laptop:
node --experimental-strip-types scripts/generate-log-key.ts # writes both keys
# back the PRIVATE key up (password manager) — it is the only thing that can ever read the logs
scp ride-log-key.public.pem pi@cool-eva.local:/home/pi/thermometer/
Restart the service; it logs ride-log: encrypting to … once it finds the key. Sealed segments land in ride-logs/*.celog. To read them back:
# a /dl download is a single file; ride-logs/ off the Pi is a directory — both work
node --experimental-strip-types scripts/decrypt-log.ts cool-eva-2026-08-01.celog
node --experimental-strip-types scripts/decrypt-log.ts ride-logs/ --out rides.db
# the key is looked up relative to the CWD, so from anywhere else:
RIDE_LOG_PRIVATE_KEY=~/Documents/cool-eva/ride-log-key.private.pem \
node --experimental-strip-types scripts/decrypt-log.ts ~/Downloads/cool-eva-2026-08-01.celog
That rebuilds an ordinary SQLite file, so Grafana and the dashboards work against it unchanged.
⚠️ The Grafana datasource points at
/repo/temperatures.db(grafana/provisioning/datasources/sqlite.yml), so either decrypt with--out temperatures.dbin a directory that doesn't already hold your pre-encryption archive — the tool would append into it — or repoint the datasource atrides.db. The sealed log is also ~10x smaller than the equivalent SQLite (gzip before encryption, and crypto overhead is per 30-second segment rather than per row).
Each segment uses a fresh ephemeral X25519 key (ECDH → HKDF-SHA256 → AES-256-GCM), so compromising the Pi cannot retroactively decrypt anything already written. Segments are independently sealed, so damage is contained: the reader resyncs on the next segment and reports what it couldn't read rather than stopping. There is deliberately no recovery path: lose the private key and every logged ride is gone forever. That is exactly what makes the SD card worthless to a thief.
What this does and doesn't hide
Holds up: the readings themselves — routes, coordinates, the key-fob ID, everything in the tables above. Whoever takes the SD card, or intercepts a /dl download, gets ciphertext.
Does not:
- Timing metadata leaks. Filenames are
rides-YYYY-MM-DD.celog, and sizes scale with how much was logged. That reveals which days the bike moved and roughly for how long, without revealing where. Since the motivating worry is someone learning your habits, that's worth knowing. - No cross-segment integrity. Each segment is authenticated on its own, so tampering within one is detected — but segments can be deleted or reordered without the reader noticing. It protects confidentiality, not completeness.
/dlis unauthenticated on port 80, like the rest of the server. The payload is sealed, so the exposure is the metadata above rather than the data — but it's a wider audience than "whoever holds the SD card".
Trouble codes
The bike keeps two completely different fault lists, and the Faults tab shows them apart because merging them would be wrong:
| What it is | How many, right now | Where from | |
|---|---|---|---|
| Active | What the bike says is wrong at this moment. It flickers — one code was present on 2 of 8 consecutive polls at a standstill | 0-1 | Connectivity Hub message type 25, over Bluetooth and mirrored onto CAN 0x410 |
| Stored | Everything that has ever been wrong and not been cleared. It only climbs | 39 | OBD-II mode 03, over ISO-TP |
| Pending / permanent | Would be OBD-II modes 07 and 0A | — | no response. See below |
Mode 03's reply is 80 bytes, so it needs ISO-TP: a First Frame, a flow-control frame back from us, then eleven Consecutive Frames. src/can/iso-tp.ts reassembles it and src/diagnostics/obd-dtc.ts decodes it — both pure, bytes in and codes out, so a captured transfer replays on a laptop:
node --experimental-strip-types scripts/decode-dtc-response.ts
# → replays a real 2026-08-04 transfer and checks it still decodes to the same 39 codes
Codes are named from Energica's own type-approval table (src/diagnostics/dtc-table.ts, 148 codes). All 39 of this bike's are in it. Mode 01 PID 02 — the freeze-frame code, i.e. the one the bike captured when it lit the lamp — reads P0514, "Error reading temperature", which is why the warning light is on.
Modes 07 and 0A return nothing at all — silence, not a refusal, across six attempts. That means "not implemented" and "implemented but withheld" cannot be told apart from here, so the dashboard says "no response" rather than "none pending". Those are different claims and only one of them is true.
The transfer is not reliable — the First Frame arrives every time and the Consecutive Frames sometimes never do, at somewhere between 25 % and 70 % per attempt. It is retried, and it is read once a minute from inside the sequential OBD poll loop so nothing else of ours is on the bus while it runs.
Notes
- The CAN bus is read-only: passive broadcast decode + standard OBD-II read requests only. No KWP/UDS writes. Nothing here can clear a trouble code: OBD-II mode 04 is not implemented and must not be — it would erase the history above, on a bike that has been accumulating it since before anyone was reading.
- Coolant history predating the CAN integration is preserved (migrated into the current schema; the original table is kept as a backup).
- Any
temperatures.dbleft on the Pi from before the encrypted log is plaintext history — copy it off and delete it from the bike, or the SD card still gives up every route you rode before the switch.