This is the data tool for NFR 26. It records CAN data off the car, stores it, and shows it to you in a dashboard. You can run it live or open old sessions later.
The repo is split into a few folders. Here is what each one does.
This is the frontend. It is a React app made with Vite. Everything you see in the window (graphs, gauges, settings page, the storage setup screen, the import button, the broadcast toggle) lives here. It does not talk to the car or the database directly. It just makes HTTP and WebSocket calls to the local server.
When you run the desktop app, the frontend gets built into static HTML/CSS/JS files and served by the server inside the Electron window.
This is the Electron shell plus the local server. It is the part that actually runs on your computer and makes everything work together.
Inside it:
desktop/main/is the Node server. It boots an embedded Postgres database, runs migrations, starts the parser, and serves a Fastify API on port 4444. Routes live indesktop/main/src/server/routes/.desktop/preload/is a small bridge that lets the frontend ask Electron for things it cannot do itself, like opening a folder picker.desktop/build/has the build script and the vendored Postgres 17 binaries that ship inside the app. This means users do not need to install Postgres themselves.
When you build the .dmg, electron-builder packages all of this into one app and copies the frontend, the parser binary, the Postgres binaries, the migrations, and the DBC file into the app bundle.
This is the Python program that reads the actual data. It does two main things:
- Live mode. It opens a serial port, reads CAN frames coming off the car, decodes them using the DBC file, and writes them into Postgres in real time. The frontend gets the same frames over a WebSocket so you see them as they happen.
- Batch mode. It reads a
.nfrfile (a binary log from the SD card on the car), decodes every frame, and inserts everything into Postgres as one session you can scroll through later.
The parser gets shipped as a single binary made with PyInstaller. The desktop app spawns it as a subprocess and tells it which mode to run in.
Notes, design files, and references for the project. Nothing here runs.
The DBC file. It is the lookup table that tells the parser how to turn raw CAN bytes into named signals like BMS_SOC or Battery_Voltage. If the firmware on the car changes its message layout, this file has to change too.
This section is for someone who just installed the .dmg and wants to know what every screen does. If you've never opened the app before, start here.
This is what you see when the app first opens. The dock is your dashboard — graphs, numbers, gauges, gg-plot, cell-voltage strip, etc. — fed by the basestation receiver over USB.
One-time setup. Plug the USB receiver in, then go to Settings → Live serial port. Click Rescan, pick the device that shows up (something like cu.usbmodem326D... on macOS or COM5 on Windows), click Save. The parser restarts and the dock starts receiving frames whenever the basestation has signal. The dropdown only shows USB-serial devices — the storage USB is filtered out.
Top-bar pill (RSSI + SNR). Bottom-right of the top bar there's a pill showing RSSI ... dBm · SNR ... dB. Colour-codes by RSSI (green ≥ -70, amber ≥ -90, red below, grey when the receiver is unplugged). The pill is also a button — click it to open the Live Tools modal:
- Reset live data wipes the daily
live_todaybuffer and clears the dock's in-memory store. Useful when you want to start from a clean slate. - Test with synthetic data lets you pick signals from the DBC catalog (only signals with a usable min/max range are listed) and click ▶ START TEST. The desktop emits a 10 Hz sine wave per selected signal — each one getting its own frequency and phase so they don't all peak together — directly into the same WebSocket fan-out real frames use. Graphs immediately oscillate between each signal's catalog min and max. While running, the pill border + dot turn green and the badge reads
SIM. Click the pill again, hit ■ STOP TEST.
Pause the view. Bottom-left of the dock there's a small green LIVE indicator next to the elapsed-time counter. Click it → label turns to PAUSED, the dot dims, the time axis freezes so you can read values without dragging the slider. Data ingestion keeps running — the store still receives frames, they just stay invisible. Click again to resume; the right edge snaps back to "now".
Zoom on a graph. Click-drag horizontally on any graph to zoom into a range. The corner reset-zoom button appears. In live mode, zoom freezes the visible window (same effect as pausing) without stopping ingestion — reset to resume following live data.
Time window per graph. Each graph has a gear icon → opens an inspector panel. In there, the TIME WINDOW section sets how much of the past the graph shows: 1 MIN (default in live mode), 5 MIN, or ALL. In live mode this caps how far back the line goes so a long buffer doesn't squeeze the most recent data into a sliver. In replay mode the default is ALL (full session) — flip to 1 MIN / 5 MIN if you want a tight window that follows the scrubber.
G-G plot. Defaults to the raw IMU acceleration pair (X_Axis_Acceleration / Y_Axis_Acceleration, with gravity). Open the widget's gear icon → SOURCE section → flip to NO-G to use the gravity-compensated pair (No_G_X_Axis_Acceleration / No_G_Y_Axis_Acceleration). X plots vertically (throttle/brake = up/down), Y plots horizontally (cornering = left/right).
Cell voltages. Add the Cell-V widget and it auto-discovers all cell_v_<n> (or Cell_V_<n>) signals from the DBC and plots them as a single multi-line graph.
Live data retention. The live_today table is cleared every day at midnight America/Chicago. The desktop runs a cleanup query every 15 min that deletes anything from before today; you don't have to do anything.
For watching a session that's already been recorded, either pulled from the cloud or imported from a .nfr file off the SD card.
Open a session. Click the session picker dropdown in the top bar. You get a calendar showing which days have sessions; click a day → list of sessions for that date with duration + driver/track/car. Most recent live session also appears at the very top if you have one. Click a session to load it.
Scrubbing. Drag the slider at the bottom to move the cursor through the session. Graphs show data around the cursor's position.
Zoom. Click-drag horizontally on a graph to zoom into a range. The visible-window narrowing applies across all graphs since they share a window. Double-click or hit reset-zoom to restore.
Time window per graph. Same 1 MIN / 5 MIN / ALL toggle as live mode (gear icon → TIME WINDOW). Default in replay is ALL. Switch to 1 MIN if you want a narrow window that follows the scrubber as you drag through a long session.
The dock has an ↑ IMPORT NFR button. Click it → pick SINGLE FILE or FOLDER. Folder mode imports every .nfr file inside in one shot with a progress overlay. If a file's content hash matches one already in your DB, the import is skipped (so re-importing a folder is safe).
Re-decode with current DBC. The import modal has a "Re-decode with current DBC (overwrites existing rows)" checkbox. Tick it if you've fixed the DBC since the original import and want to reprocess existing sessions. Without the box, dedup skips matching files.
Cancel. The progress overlay has a red ■ CANCEL button. Click it → the current parse is killed mid-flight (DB transaction rolls back, no rows land for that file), and queued files are skipped.
Skipped vs failed vs cancelled. The overlay shows three counters and a yellow list of skipped files so you can tell at a glance what got dedup'd vs what actually parsed.
The dock has a DBC ▾ menu in the top bar.
- Import new DBC… uploads a CSV that becomes the active DBC (parser restarts). Cross-platform: handles Excel's UTF-8 BOM, CRLF line endings, etc.
- VIEW CURRENT DBC opens a filterable table showing every signal in the loaded DBC with its frame ID, message, sender, start_bit, length, factor, offset, min/max, unit, cycle, and type.
Click the gear icon top-right to open Settings. Worth knowing:
- Live serial port — covered above. Save button greys out when the selected port matches what's already saved.
- Cloud sync — collapsible section (▸ to expand). Lists every local session grouped by day; expand a day to see individual sessions with date, hash, sync status. Days with an active upload error auto-expand. Each row shows
date · short-id · sync-state · status/Retry. Bulk-upload, retry, delete-local, etc. are here. Live sessions are filtered out of this list (they don't have a Spaces upload path). - Cloud config — Supabase + Spaces credentials. The .app ships with read-only defaults baked in so reading from the cloud "just works". The override sections (
▸ OVERRIDE SUPABASE (ADVANCED)and▸ WRITE CREDENTIALS (FOR UPLOADING)) collapse by default; expand to paste your own. SAVE is disabled until you've actually changed something. - Live cloud sync (under Cloud config) — toggle to push live frames to Supabase for cross-machine viewing. Disabled until you've supplied your own Supabase write creds (the bundled defaults are read-only).
- Broadcast — makes the dock reachable on the local network at
http://<your-ip>:4444. Useful for putting the dashboard on a phone or tablet during testing. - Database tools — stats, clear-by-date, CSV / SQL export, SQL import. Same data flow as the cloud sync but local-only.
- Activity heatmap — calendar showing which days you have data for.
Top bar has a SESSIONS button → page listing every session in the local DB, newest first, with id / date / source / row count. Click one to open it in replay.
Cloud sync works like git. There are two directions, and they each have a clear winner:
- Push (upload to cloud). You select sessions in Settings → Storage → Local and click Upload selected. Your local copy gets sent to the cloud and becomes the canonical version there. Whatever was on the cloud for that session before is replaced. Local wins on push.
- Pull (sync from cloud). You go to Settings → Storage → Cloud, pick the sessions or days you want, and click Pull selected. The cloud version is downloaded and overwrites your local copy of those sessions. If you had been editing or re-parsing locally, those local changes are gone. Cloud wins on pull.
In other words: pushing is "make the cloud match my local" and pulling is "make my local match the cloud." Neither direction tries to merge — there is one canonical side per operation and the other side gets overwritten.
A few practical consequences:
- If you re-parse a day on your machine with a corrected DBC, the new parse is now the local version. To share it with everyone else, push it. To go back to what was on the cloud, pull it.
- Two teammates uploading the same drive day from different machines: the second person's upload is rejected with a "this session was already synced" message so they don't silently overwrite the first person's work. They can then pull to get the version that's there, or click "Re-upload anyway" if they're sure their copy is the right one.
- Deleting a session locally (DELETE LOCAL button) does not touch the cloud copy. You can always pull it back.
- The cloud holds the long-term archive. Local storage is just a fast working copy on your machine.
- First time only — bulk migration. A new install will show an Upload all button on the Local tab listing every unsynced session. Click it once to push all your historical drives to the cloud. Subsequent runs of the button only upload anything new since.
- First-time install: the .app ships with read-only cloud defaults baked in (Supabase project URL + anon key + Spaces public URL). Just open the app, go to Settings → Storage → Cloud, and pull whatever days you need. No keys to paste. To upload (push), you do still need to paste Spaces access + secret keys under Settings → Cloud config → "Write credentials."
Where the bytes actually live:
| What | Where |
|---|---|
| Drive listing (date, driver, car, notes) | Supabase Postgres — small metadata only |
| Per-signal sample rows (the bulk) | DigitalOcean Spaces — one Parquet file per CAN message source, per session |
| Live frames (while you're recording) | Local embedded Postgres, plus optionally streamed to Supabase rt_readings for the "cool factor" cloud live view (cleared nightly) |
You open the app. Electron starts the local server. The server boots embedded Postgres and runs migrations. The server spawns the parser, which connects to the car (or replays a file). The frontend opens in the Electron window and pulls live frames over a WebSocket and historical sessions over the HTTP API. You can also turn on broadcast mode in settings, which makes the dashboard reachable from any browser on the same WiFi.
cd app && npm install && npm run dev # frontend dev server on :5173
cd desktop && npm install # install electron deps
cd parser && python -m venv .venv && source .venv/bin/activate && pip install -e .
Then in the desktop folder run the orchestrator pointed at a Postgres you already have, or build the dmg with npm run package to get the standalone app.
cd app && npm run build
cd parser && ./build.sh
cd desktop && npm run package
The output ends up in desktop/release/.
- Live edge advances at display refresh, not 4 Hz.
Live.tsxwas drivingvisEndwithsetInterval(..., 250), so the visible-window right edge stairstepped at 4 Hz no matter how fast frames actually arrived — a sample landing at +100 ms wouldn't appear until the next tick. Switched torequestAnimationFrame: ~60 Hz when the tab is visible, browser-throttled when it isn't. Data now shows as fast as the WebSocket can deliver it.
- Bundled DBC updated.
NFR26DBC.csvrefreshed with the latest signal definitions (+44 / −39 rows vs v0.7.7). Affects the default shipped with the .app and the parser's fallback when the user hasn't uploaded one; existing users with a custom uploaded DBC are unaffected (theiractive-dbc.csvkeeps taking precedence).
- Live mode renderer no longer grows unbounded in RAM. The frame
store now caps each signal at 50 000 in-memory rows (~28 min at
30 Hz). When a buffer crosses the cap it gets trimmed back to 37 500
in a single splice, so the per-push cost stays amortized O(1). Rows
trimmed from memory still exist in
live_todayon disk;ensureWindowpages them back in if the user scrolls past the in-memory window. No on-screen change at typical use. - Live mode push path is no longer O(n log n). The store used to
re-sort every signal's entire buffer on every WS batch — at hour 1
this was sorting millions of already-sorted rows. Now: append-only,
with a per-row ordering check that triggers a sort just for buffers
where an out-of-order row actually arrived (the
ensureWindow× WS-edge race). Hot path is O(rows-in-batch).
- Replay-open: ANALYZE the rollup so the planner uses its index.
v0.7.4's backfill populated
sd_rollup_1sbut never ranANALYZE, so Postgres' stats for that table still reflected the empty post-CREATE state. With no stats it defaulted to a seq scan over the entire rollup on every call — which is exactly why the v0.7.5 explicit-branch rewrite didn't move the needle. Migration0018runsANALYZEonce on existing installs;0019folds it intopopulate_sd_rollupso new imports stay analyzed. /api/sessions/:id/signals/window/explaindiagnostic route. Returns theEXPLAIN (ANALYZE, BUFFERS)plan for the same call shape as the data route. Useful for confirming the index is being used, or for catching the next time a query falls off the fast path.
- Replay-open: explicit-branch
get_signals_window. The v0.7.4 UNION-ALL form relied on the planner constant-foldingp_bucket_secs >= 1.0to prune the raw-sd_readingsbranch, but for parameterized SQL functions Postgres often plans both branches anyway — the raw branch then does its index seek even though it returns no rows, costing ~1 s on USB-backed disks. Migration0017rewrites the function in PL/pgSQL with an explicitIF: only the chosen branch is planned per call. Observed query time on the example session: ~1100 ms → expected sub-100 ms.
- Replay open is ~100x faster on long sessions. Migration
0016addssd_rollup_1s, a 1-second pre-aggregated rollup ofsd_readings(min,max,sum,nper(session_id, signal_id, second)).get_signals_windownow reads from the rollup whenever the graph bucket is>= 1 s(essentially every replay view), and falls back to rawsd_readingsonly for sub-second zoom-ins. The numbers are identical —minof mins,maxof maxes, andsum / sample_nfor the average are exact relative to the raw samples. The parser populates the rollup at the end of each batch import (~1% added cost); sessions imported before this version are lazily backfilled on first open (one slow open, fast forever after). The[signals-window] lazy-backfilllog line marks when that happens.
- Replay-open timing logs. Three boundaries instrumented so a slow open
can be attributed:
query(embedded Postgres),map(Node row materialization),route total(Fastify request) on the server console, andfetch+ingeston the browser DevTools console. Output prefix is[signals-window](server) and[replay-frames](client). Used to diagnose whether USB-disk random I/O, JSON serialization, or client-side sort is the dominant cost when opening long replays.
Bug fixes shipped on top of the v0.7.0 daily-live-mode redesign:
- Live: DBC upload now actually re-decodes the stream. Uploading a new
DBC restarted the parser subprocess but replaced the
ParserManagerinstance, so the WebSocket fan-out (and/api/live/status, simulate route) kept listening to the dead old instance. The dock froze on stale values from before the upload — which read as "still parsing with the old DBC".ParserManagernow exposes arestart()that respawns the child in place, preserving listener identity. (#20) - Live: RSSI/SNR badge no longer stays grayed when the link is up.
Two stacked bugs caused this. (1)
GET /api/live/statusnever tracked link metrics, so a page that mounted mid-stream gotnulluntil the nextsignal_qualitypacket happened to arrive — packets that fired before the WebSocket opened were lost. The route now persists the latest RSSI/SNR and clears them on basestation disconnect. (2)useLiveStatusreplaced the whole state when the initial GET resolved, clobbering any RSSI/SNR the WebSocket had already set in the gap between mount and fetch. It now merges. (#17) - Parser: 21-bit IMU "float"/"double" signals decode signed.
Already-resolved bugs (#15 GG plot only Q1, #16 IMU values
out-of-range with no negatives) traced to the same root cause: 21-bit
IMU fields marked
floatordoublein the DBC were being decoded as unsigned, wrapping the negative half of the range to large positives.decode.pygates the IEEE-754 unpack path onlength in (32, 64)so sub-word "float"/"double" entries fall through to the signed-int sign-extension branch;compile.pymarks bothfloatanddoubledata types asis_float=Truewithsigned=True. Re-parse pre-fix sessions to get correct values.