A CAN-bus analyzer. Phase 1 (alpha0) ships a single-process GUI that
opens a Vector BLF log, decodes it against a DBC, and streams the
result into a virtualized trace view. Phase 2 splits the data source
out behind a network protocol; Phase 3 fills in a multi-panel docking
layout (dockable trace and project panels in arbitrary arrangements)
and JSON project files; Phase 4 adds a signal-plotting view; Phase 5
adds transmit, the --loopback server, and DBC value-table rendering
across views; Phase 6 introduces logical buses, per-bus DBC scoping,
a structured filter element, and a project graph panel showing the
project's wiring; Phase 8 adds per-vendor hardware adapters; Phase 9
makes captures persistable through Save Capture (notes ride inside
the BLF as GLOBAL_MARKER records — no sidecar files per ADR 0010),
and offers a Recent BLFs list in the toolbar. See
plans/ for the detailed roadmap.
Prebuilt alpha bundles are published to GitHub Releases:
| Platform | Artifact |
|---|---|
| macOS (Apple Silicon / arm64) | .dmg — drag to Applications |
| Windows (x64) | .msi or NSIS -setup.exe |
These bundles are unsigned. On first launch:
- macOS: Gatekeeper blocks an unsigned, un-notarized app — right-click
the app → Open, then confirm. (Or
xattr -dr com.apple.quarantine /Applications/cannet.app.) - Windows: SmartScreen shows a warning — click More info → Run anyway.
Signing/notarization is a planned follow-up. Building from source (below)
avoids the warnings entirely. Live hardware / virtual-bus capture works
out of the box — these bundles ship a frozen, self-contained sidecar, so
no uv or Python is needed at runtime (uv is developer-only; see
§ uv resolution).
crates/
cannet-core/ CanFrame model + CanFrameSource / CanFrameSink traits,
plus the `SharedBus` virtual-bus primitive (ADR 0021)
used in-process by the GUI host and over the wire by
`cannet-server --virtual-bus`. Every other crate
either produces or consumes through these — the seam
where network transports and hardware adapters slot
in. See its rustdoc for the contract.
cannet-blf/ `BlfCanFrameSource`: Vector BLF files as a CanFrameSource.
Wraps `blf-asc` and translates each object into a
`cannet_core::CanFrame` (classic / FD / remote / error).
Phase 9 added `BlfCaptureWriter` — the inverse direction
for Save Capture, with an atomic temp-file + rename.
cannet-dbc/ `Database::parse(text)` + `decode(frame)` + `signals()`
(the message/signal list a plot panel picks from).
Hand-rolled bit extraction (LE / Motorola sequential
BE), sign extension, multiplexed-signal filtering.
cannet-wire/ Phase-2 wire protocol: tonic / gRPC service definition
(`proto/cannet.proto`), generated client + server
stubs, conversion helpers between `cannet_core`
frames and the wire types, and a batching adapter
layer so application code stays in `Stream<CanFrame>`.
cannet-server/ BLF replay server + `--virtual-bus` server. The
replay mode loads a BLF into memory and streams its
channels on a loop while a client is subscribed
(transmits rejected, read-only; single-client).
The `--virtual-bus` mode (ADR 0021) hosts a multi-
client virtual CAN bus: one factory interface, fan-
out with sender attribution, `NoAcknowledger` on
zero-recipient transmits, runtime `ConfigureBus`.
Ships a `cannet-server` binary; lib is reusable.
cannet-client/ Phase-2 gRPC client. `list_interfaces` is a one-shot
async RPC for the connection panel. `connect_and_
subscribe` returns a `RemoteCanFrameSource` (sync
`cannet_core::CanFrameSource`) backed by a worker
thread that owns its own tokio runtime, opens a
`Session`, and pumps incoming frames into a sync
mpsc queue. `into_parts()` exposes the receive +
shutdown halves alongside a Phase-5
`SessionTransmitter` for client TX. Drops cleanly on
`Drop`.
cannet-perf-measurement/
Agent-runnable performance / integration harness. Runs a
rest-of-bus simulation of the `examples/ev-demo` workload
through the real host model and emits machine-readable
metrics diffed against a dated baseline
(`cargo run -p cannet-perf-measurement -- check`). Three
modes: `tracebuffer` (in-process `TraceStore`), `grpc`
(virtual bus over real gRPC), and `hardware-peak` (full
stack over PEAK hardware via the sidecar). See its README.
apps/
gui/ Tauri 2 + React 18 + Vite trace viewer.
src/ React frontend. `TracePanel.tsx` is a trace-style
panel with a chronological / by-ID mode toggle:
chronological is `TraceView.tsx`, a hand-rolled
scaled virtualizer (the scroll container caps at
16M px and maps scrollTop to an absolute row index,
so the scrollbar represents the whole trace
regardless of size; the wheel scrolls natively but
falls back to row-stepped scrolling when a notch
would skip a screenful in the compressed regime);
by-ID is `ByIdTable.tsx` (the same scaled
virtualizer, over the host-sorted by-ID snapshot).
Both views — chronological, filtered, and by-ID —
page through one windowed-source primitive
(`useWindowedQuery.ts`, ADR 0025); the by-ID and
filtered adapters are `useByIdView.ts` /
`useFilteredTrace.ts`. Shared table bits — the
header (drag-resize, right-click show/hide,
click-to-sort) and the cell renderer — are in
`traceTable.tsx`; the column model in
`traceColumns.ts` (the by-ID sort runs host-side).
`ProjectPanel.tsx` is the
project / elements / bus / DBC panel; `PlotPanel.tsx`
the Phase-4 signal plot (uPlot), with `plotData.ts`
merging independently-sampled series onto one
timeline; `SignalsPanel.tsx` the signal view — a
latest-per-signal snapshot table (mux-aware) whose
selection is manual picks + regex patterns over the
canonical `bus/ecu/message/signal` path (ADR 0038,
`signalSelection.ts`), evaluated host-side
(`fetch_signal_page`). Each trace-style panel shows one *trace
element* — a window over the host-side session
buffer with pause / stop / clear; the elements live
in an in-memory registry (`projectElements.ts`),
persisted in the project, so closing a panel doesn't
destroy its element (`trace.ts`, `TraceControls.tsx`).
The scroll/stacking, column, trace-window, and
plot-data arithmetic live in `traceViewport.ts` /
`traceColumns.ts` / `trace.ts` / `plotData.ts`
(unit-tested alongside). A command / hotkey
framework (`commands.ts`, `keybindings.ts`,
`PaletteModal.tsx` — ADR 0018) drives every toolbar
button, the `Ctrl/Cmd+Shift+P` command palette
(recently used commands float to the top),
`Ctrl/Cmd+P` go-to-view (open panels by display
name), and go-to-event (jump the trace/plot to a
timeline event) off one command registry — toolbar,
palette, and keyboard all dispatch the same
commands. Key bindings are user-editable and
persisted to `settings.json` (ADR 0034): the
shortcuts panel (`ShortcutsPanel.tsx`)
lists every command and lets you rebind, remove, or
add a chord (reused chords allowed only where they
can't both fire; conflicts refused), with reset to
the built-in defaults. It is the living reference for
what's bound. Element display names are model-owned
and resolved everywhere by `elementLabel.ts`
(ADR 0019), editable inline in the project panel.
src-tauri/ Rust host (`cannet-gui` crate). Owns the trace
model (`trace_store.rs` — the session buffer, plus
an O(1)-maintained latest-frame-per-id index and a
per-id message-rate estimate); the BLF and remote
pumps append frames, and the frontend pulls slices
via the `fetch_trace_range` command and the
host-sorted, paged latest-by-id snapshot (each id's
latest in-window frame + its rate) via
`fetch_by_id_page` (both decoded against the
loaded DBCs — first match wins — at fetch time, both
off the main thread), plus a `trace-grew` IPC tick (~10 Hz:
count, rate, and a decoded tail of the newest rows
for flicker-free auto-scroll). `signal_sampler.rs`
walks the trace store for a chosen DBC signal and
yields a `(t, v)` series for the plot panel
(`list_signals` / `sample_signals` commands).
`src/ipc.rs` holds the IPC payload shapes;
`src/project.rs` the project-file model +
`open_project` / `save_project`. `src/system_log.rs`
(Phase 7) is the host-side structured log bus —
a bounded ring + rate limiter that the
`sys_info!` / `sys_warn!` / `sys_error!` macros
fan into alongside `tracing-subscriber`; the
System Messages panel renders it.
plans/ Living planning docs (see CLAUDE.md).
All platforms need:
- Rust, installed via rustup. The exact
toolchain is pinned in
rust-toolchain.toml; rustup reads it and auto-installs that version (and theclippy/rustfmtcomponents) the first time you runcargoin the repo, so local builds and CI use the same compiler. - Node.js, version pinned in
.node-version(currently 24.x; pnpm 11 requires ≥ 22.13). Version managers likefnm/nvm/asdfread that file; otherwise install the matching Node.js release yourself. CI reads the same file, so local and CI match. - pnpm, pinned via the
packageManagerfield inapps/gui/package.json. The simplest way to get the matching version is Corepack (corepack enable), which reads that field. Otherwise install pnpm 10+ manually via the standalone pnpm installers (curl -fsSL https://get.pnpm.io/install.sh | sh -on macOS/Linux,iwr https://get.pnpm.io/install.ps1 -useb | iexon Windows PowerShell),npm install -g pnpm, or your OS package manager (brew install pnpm,winget install pnpm, etc.). Verify withpnpm --version.
Required for tauri build (sidecar freeze) and Phase 8 vendor
drivers (Vector / Kvaser / PEAK):
uv— manages thecannet-python-cansidecar's Python environment and installs Python on the fly. We do not commituvbinaries or pack them into the installer artefact; in a development build the host expects auvto be available, either next to the GUI binary attools/uv/uv[.exe]or onPATH. For local development, runscripts/fetch-uv.shto drop the pinned binary intotools/uv/, or installuvper the upstream instructions.uvis developer-only: shipped builds run a frozen self-contained sidecar and fetch nothing (seedocs/adr/0036-frozen-python-can-sidecar.md).- A vendor SDK (only if you have the matching hardware): Vector XL Driver Library, Kvaser CANlib, or PEAK PCAN-Basic. None of these are bundled; see the Phase-8 section below for links.
Plus platform-specific build tooling for Tauri's WebView host:
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libxdo-dev \
libssl-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-devOther distros: install equivalents of the above (webkit2gtk-4.1, libxdo, openssl-dev, libsoup3, javascriptcoregtk-4.1).
xcode-select --installThe Xcode Command Line Tools provide the C/C++ toolchain and the WebKit framework Tauri uses on macOS. Nothing else is needed.
- Microsoft Visual C++ Build Tools. Install the
Visual Studio 2026 Build Tools
(free) and select the "Desktop development with C++" workload.
This provides
link.exe, the Windows SDK, and the MSVC headers that the Rust MSVC toolchain (the default on Windows) links against. Without itcargo buildfails with linker errors. - Microsoft Edge WebView2 Runtime. Preinstalled on Windows 11 and current Windows 10. If missing, grab the Evergreen Bootstrapper from the WebView2 page.
- Rust MSVC toolchain.
rustupdefaults to this on Windows; if you previously selected GNU, switch withrustup default stable-x86_64-pc-windows-msvc.
From the repo root:
pnpm --dir apps/gui install # once, to fetch frontend deps
pnpm --dir apps/gui tauri dev # development build with hot reload
pnpm --dir apps/gui tauri build # release bundletauri build first freezes the cannet-python-can sidecar
(scripts/build-sidecar.py, run by the
beforeBuildCommand hook), so it needs uv on PATH. Rebuilds are
incremental — an unchanged sidecar refreezes in seconds.
pnpm tauri dev boots Vite, compiles the Rust host, and launches the
cannet window. Use Open BLF… to pick a log; Add DBC… loads a
database for live decoding — load more than one and frames decode
against each in order, first match wins (every loaded DBC applies to
the one interface for now).
Every launch opens on a splash carrying the safety disclaimer — cannet transmits, and the system on the other end of the cable should be in a state where disrupted CAN traffic is safe. It is a notice, not a prompt: there is nothing to acknowledge and nothing is remembered. It doubles as the loading screen, so it stays up for the longer of five seconds and however long the reopened project takes to apply (its last step restores the previous session's capture, which scales with the capture's size) — when the splash lifts, the app is up with its data.
The shipping GUI can drive itself for a render-tier performance measurement, no operator and no external automation client — it covers every platform the app ships on (incl. macOS, which has no WebDriver).
Build a release binary to measure. tauri dev runs a debug Rust
host behind React's development build; the numbers it produces are its
own, not the app's, and are not comparable with a release capture. Build
the app, then run the binary directly:
pnpm --dir apps/gui tauri build --no-bundle # release host + production bundle
./target/release/cannet-gui \
--project /abs/path/to/examples/ev-zonal/ev-zonal.cannet_prj \
--connect-on-start \
--perf-capture-secs 60 \
--perf-interact scrub \
--perf-out /abs/path/to/docs/performance-measurements/frontend/<date>-<hash>.jsoncargo build --release -p cannet-gui on its own is not a substitute:
without the custom-protocol feature the tauri CLI passes, the binary
still points at the Vite dev server, so it comes up with no frontend at
all (and the run captures nothing).
Use absolute paths for --project and --perf-out. They are
resolved against the process's working directory, which is the launch
directory here but is apps/gui/src-tauri under tauri dev — a
repo-relative --project there fails to open, the app boots idle, and no
report is written.
--project <path>opens a project deterministically (ahead of the last-opened pointer). Usable on its own to just open a project.--connect-on-startfires the same connect a user clicks, once the project's bindings (and, for a local binding, the sidecar) are ready.--perf-capture-secs <n>captures the frontend diagnostics fornseconds after the session settles, then writes the report and exits.--perf-out <path>is where theRenderReportJSON lands;--perf-label <text>names the scenario in it.--perf-interact <script>drives synthetic gestures at the heavy views for the length of the run, so the capture measures interaction cost and not only resting cost.scrubzooms the plot to a working window and then cycles trace scrolls, plot pans and zooms;followdoes the zoom and then leaves the view alone, which is the scenario the scroll-smoothness gauges are meaningful in (a pan moves the window further in one step than a stall would). Omit it for a resting run.
Everything else the run needs is already in the saved project: the panel layout (the views under test), the bus bindings (the frame source), and the rest-of-bus simulation's run flag (the load, which resumes on connect). For a hardware-free run the project should bind to a virtual bus. See ADR 0031.
The render report this writes is the frontend counterpart to the host
harness's baseline. The harness can't generate it (only the webview sees
the render tier), so it's fed in: cannet-perf-measurement baseline --frontend-report <report> stores its gated UX-health metrics
(long-task time, lag, jank fraction) alongside the host modes, and
cannet-perf-measurement check --frontend-report <fresh-report> gates a
fresh run against them. baseline writes a dated
<date>-<hash>[-dirty].json snapshot; check compares against the
canonical docs/performance-measurements/baseline.json — promote a
snapshot to the reference by copying it there. Frontend render reports
live under docs/performance-measurements/frontend/, kept apart from the
host baseline they feed.
The window below the toolbar is a dockable panel area. The default
layout has a trace panel and a project panel (the project's
elements, the configured bus(es), the loaded DBCs). A trace panel
has a trace / by ID mode toggle: by ID (the default) shows one
row per arbitration id with its latest frame and its current message
rate (the msg/s column, by-id only) — click a column header to
sort by it (click again to reverse, again to clear — ▲ / ▼ marks the
sorted column); trace is the chronological view (one row per frame,
follows the live edge). Add trace creates a new trace element and a
panel for it (in by-ID mode — toggle it anytime); the new trace starts
empty and stopped (hit Start to begin capturing), regardless of
what's already in the session buffer. The project panel
lists the elements — closing a panel doesn't destroy its element,
reopen or remove it from there. Project panel toggles the project
panel itself (it's a show/hide singleton). DBC panel opens (or
focuses, if it's already open) the discovery panel — a
tree-with-fuzzy-search over every loaded DBC, grouped by bus
(bus → DBC → ECU → message → signal, per-transmitter grouping
like the RBS panel; messages with no BO_ transmitter fall under
"(no transmitter)"; unscoped DBCs appear under each bus group
labelled "applies to all buses"). Type any fragment of a signal
name, ECU, comment, value-table label, message id (hex or decimal),
or attribute, and the tree filters to the matches: ancestors of a
match auto-expand and everything else is hidden, so a filtered
render stays bounded by the match set however large the database.
The whole tree is keyboard-navigable — arrow up/down move, right
expands, left collapses (or walks to the parent), Enter selects.
The toolbar's details
toggle reveals the full per-signal detail (bit positions, scale,
range, mux indicator, float kind, attributes, value table) and
per-message detail (length, FD/BRS, mux flag, attributes). Drag a
signal or message row onto a plot panel to add it as a series, or onto
the transmit panel to create a new TX frame for that message;
multi-select (click / Shift-click / Cmd-Ctrl-click) drags the whole
selection at once. The host watches every loaded DBC file and
auto-reloads the in-memory copy when the file changes on disk — no
need to click Reload after editing a DBC in another tool. Turn
dbc_auto_reload off if you would rather choose the moment the
decoding changes while you are editing a DBC mid-analysis; a file that
disappears is still reported either way, and Reload DBC still works.
Singleton
like the project panel, and read-only (DBCs are added / removed from
the project panel, not from here). New panels arrive as a tab
in the active group — drag a
panel by its tab and drop it against an edge of the area to split it
side-by-side, or onto another panel to tab them together. Each trace
panel keeps its own scroll position, auto-scroll toggle (trace mode),
and column layout — drag the divider at a column header's right edge to
resize, and right-click the header to show / hide columns. The
time (s) column shows elapsed time since the session start (ADR
0024); hover a row's time to read the same instant as that message's
local date and time. A capture with no wall clock behind it — a BLF
carrying no measurement start time, whose session is anchored on the
file's own zero — shows no tooltip, because there is no absolute instant
to name. Trace
panels carry the trace controls: the data lives in a session buffer
that fills while connected (reset when you disconnect / reconnect;
a quit or crash instead persists it to disk and reloads it on the
next launch — see Indefinite-length capture below), and a trace is
each panel's own window over it — Pause
freezes the view (Resume continues, including frames received while
paused), Stop freezes it (Start then begins a fresh, growing
trace), and Clear empties the window keeping whatever state it's in
(Clear doesn't imply Stop or Pause — a running trace stays running).
The session buffer keeps filling underneath regardless.
(Tearing a panel out into a separate OS window isn't supported yet —
docking is within the one window; the tear-out item is in
plans/backlog.md.)
The session buffer is not held in RAM — it is a memory-mapped store
on disk, so a capture can run indefinitely (hours to days, well past
physical memory) with every historical frame still addressable for
scrolling, filtering, and plotting. Each frame is written straight
through to memory-mapped segment files in the open project's own cache
(.cannet/cache/ inside the project directory, which links to
cannet-managed local storage under an OS cache directory —
$XDG_CACHE_HOME/dev.cannet.app on Linux, the platform equivalent
elsewhere); the kernel page cache keeps the hot part resident and pages
cold history out under pressure, so RAM stays roughly flat while
the on-disk cache grows (the status line shows both — … RAM, the
whole application's resident memory, and … cache, the scratch
footprint on disk). Decoded-signal plot data and the search indexes are built on
demand and memory-mapped the same way. See
docs/adr/0001-indefinite-length-capture.md
and docs/adr/0002-disk-spill-store.md.
The capture belongs to the project. Each project gets its own cache, so opening a second project doesn't destroy the first one's capture — come back and it's still there. If you haven't given a project a directory of its own (below), cannet keeps one for it in its own cache space; nothing about that is a different mode.
Because it lives outside the process, the capture survives a quit or
crash: on the next launch the prior session reloads as a stopped
historical trace so nothing is lost. It is wiped when you replace it —
Start a new capture or Clear — or, opt-in, on a clean exit
(Settings → clear scratch cache on exit, off by default). This
scratch is ephemeral working storage, not an archive: to keep a capture
permanently, use Save Capture to write a durable .blf.
By default the scratch is unbounded (limited only by free disk). To cap it, set a byte limit in Settings; over the cap the store drops the oldest frames first (a windowed ring), keeping the most recent history within budget. A reloaded capture that was truncated this way shows a marker at the point where older history was dropped.
A .json project file holds the panel layout (including each trace
panel's column layout and auto-scroll toggle), the project's elements
(traces — and later plots, transmit messages, …), the loaded DBC
paths, the project's logical buses, and the interface bindings (each
of which names its own server address). The project panel (or the
toolbar's Open project… / Save project) drives it: Save /
Save As… write one, Open… restores it (re-loads the DBCs and
restores the bus / binding configuration — hit Connect to switch),
New starts a fresh unsaved project (default layout, no DBCs, disconnected,
buffer cleared). The panel also lists the configured server(s) with
Connect all / Disconnect all and the loaded DBCs with Add…
/ Remove / Reload all from disk. The
last opened/saved project is reopened on launch, unless you turn
reopen_last_project off — then a launch starts with nothing open,
in the auto-located project directory, and the pointer to your last
project is kept so turning it back on resumes there. With no project,
the layout is restored from that directory's own view state. Unsaved
changes show a ● in
the project panel, and closing the window with unsaved changes always
prompts you (Save & close / Discard & close / Cancel). Not carried in
the project: a trace's window
position (it re-anchors to the session buffer on each launch anyway),
and the BLF replay path.
The project directory. To keep a project's data with the project
rather than in cannet's cache, make a .cannet/ folder next to the
.cannet_prj file. That pair — a project file beside a .cannet/ —
is what makes a folder a project directory. There is one other way one
comes into being — Save project as…, which makes the folder you
pick into a complete project directory and moves the open project's
data there with it. Otherwise cannet never creates a .cannet/ in your
folders because you opened a file there. Inside it, cannet keeps that project's
settings overrides, its view state, and cache/ — a link to the
machine-local storage the capture actually lands in, kept out of the
project directory itself so a memory-mapped multi-gigabyte scratch
never ends up on a network share. A .gitignore covering cache/ is
written with it, since a project directory is plausibly a repository.
Settings resolve user-first, project-second: a value in
.cannet/settings.json overrides your own for that project, and every
other setting stays as you set it. See
docs/adr/0042-project-directory-and-scopes.md.
Settings. The settings panel is a view over settings.json, not a
replacement for it — the file is the durable contract and editing it by
hand is a supported path (ADR 0034). Every row shows the field name it
writes, so the panel teaches the file.
- Theme. General → Theme switches between
dark(the default) andlight. It applies immediately — no restart — and the whole window follows, plot canvases included. Colors you picked yourself (a bus color, a per-signal color, a color-map rule) are stored with the project and render exactly as chosen under either theme; only the colors the app derives follow the theme. Clear project colors in the command palette discards the picked bus and signal colors so they fall back to the current theme's defaults — it asks first, and it leaves color-map rules alone. - Normal mode. General → Normal mode (a developer setting, default off): when enabled, the light theme renders normally. It applies immediately, and the dark theme is unaffected.
- Finding one. Type in the search box. It matches a setting's name,
its
settings.jsonkey, its help text, and its tags, so you can find a setting you can describe but can't name — searching what it does works. The list beside it groups settings by the part of the app they govern; picking a group narrows the list. - What you changed. A setting that differs from its default is
marked and offers Reset to default. One the open project
overrides is marked as the project's, so a value that came from
.cannet/settings.jsonnever looks like a personal preference. - A value the app can't honour is refused, not repaired. Every
bounded field states its limit once, host-side; a hand-edit outside
it is rejected on the way in, reported on the System Messages
panel with the field named, and the field falls back to its default.
Your file is left as you wrote it — nothing runs at a number the file
doesn't show. A few fields treat
0as "off" rather than as a minimum (system_log_rate_limit,health_sample_interval_ms,sidecar_restart_budget, the two recents depths); their help text says so. A value of the wrong shape — text where a number belongs — is refused the same way, and costs that field only: the rest of your file is untouched. - Environment variables win, and say so.
CANNET_SIDECAR_DIRandCANNET_DRIVER_MODULEhave settings equivalents (Sidecar directory, Driver module), but the variable takes precedence for the run it is set in — it is the escape hatch, and a file shouldn't be able to disarm it. When one shadows a setting you have filled in, the System Messages panel says which variable overrode which key, with both values, so the file never quietly shows something the app isn't using. The three settings the sidecar is launched with — its directory, its driver module, its log level — are re-read on every spawn, so the project panel's Local interfaces → Restart applies a change without relaunching cannet. - Defaults you can still change per view. Some settings only decide what a new view starts as — the trace panel's Default trace view, Default auto-scroll, and Default events overlay, the plot's Default y-axis layout, Default trace columns / Default signal columns (which columns a new table shows, in what order, how wide), the Default server address an Add server form opens filled with, and the Default bus bitrate a bus you add starts at (blank — what ships — adds it with no bitrate at all, so the adapter's own applies, exactly as Add bus has always done). They are read once, when the view is created, and the view's own controls win from then on: changing one of these never reaches back into a panel you already have open, and a panel restored from a project keeps what you set in it. The column rows are the one setting with a purpose-built editor rather than a plain control — a table header adjusts the panel in front of you, so the default needs a home of its own; Use the built-in layout puts it back.
- CAN-ID format. The trace and by-ID tables spell an arbitration id
in zero-padded hex by default;
can_id_formatswitches them to decimal. Thes:/x:prefix stays either way — 11-bit and 29-bit ids overlap as numbers, so it is the only thing saying which frame a row is. Display columns only: the transmit and filter editors still take hex. developersettings. Machine-load and internal-cadence knobs — the plot's fetch interval, the view refresh interval, the live-update rate, the reconnect backoff, the health-sample cadence, the status notice dwell. They exist so that every knob the app has lives insettings.json, not because tuning them is expected, and they are hidden until you turn onshow_developer_settings. Revealed, they appear in their own Developer group rather than mixed into the others. Nothing is hidden from the file: they are all insettings.jsonwhether the panel shows them or not.- Reclaiming disk — Storage › Project caches. Every project keeps
its own capture, so the panel lists every project directory cannet
holds cached data for and what each one is currently using (measured
when you look, not on a timer — the walk is not cheap). Two actions,
and they differ: Clear data cache empties one project's cached
data and keeps both the cache directory and the entry; Delete
removes the cache directory and forgets the project. Neither
touches your project directory — if the app won't create a
.cannet/unasked, it won't remove one either. Clear all data caches empties every one and removes nothing. Clearing the open project discards the session in progress, and deleting it is unavailable while its cache is mapped. A project directory you deleted outside the app is listed as project gone until you delete its row, so Clear means the same thing everywhere; one whose.cannet_prjyou moved away — leaving the.cannet/un-paired — says no project file, which is otherwise a cache with nothing pointing at it. And if the open project is living in cannet's cache space, its row offers Save as… — this list is the one place you see that it is.
Add plot panel opens a signal plot (Phase 4): a uPlot-based oscilloscope-style view, docked like any other panel. It's backed by a trace element, like the trace panels — same windowed view over the session buffer with Start / Stop / Pause / Clear — it just renders signal values over time instead of message rows: while running it follows the live capture, Pause/Stop freeze the window (which also stops the re-sampling), Clear re-anchors what's plotted to "now".
- Y-axis mode. Each plot area carries a y-axis-mode selector
(next to fit y) with three values per ADR 0026: unified
(one axis; all series overlaid), per-unit (one axis per
declared unit; unitless series share an axis), and individual
(one axis per series). On any axis, series sharing a declared unit
share one y scale (the union of their observed ranges) and each
unit group auto-scales independently to fill the axis; the y-tick
labels always show the primary signal's real engineering values
(click a series row to promote it). Switching modes re-stacks the
area's canvases; the side panel for each derived axis lists only
the signals it draws. The area-level chrome (filter editor,
y-axis-mode selector itself, remove ×) appears only on the top
derived axis so there's one source of truth per logical area.
An enum-only axis renders as a logic-analyzer lane: the line
is stepped, the y-tick labels are symbolic (
<raw> "<label>"), and each held segment carries an opaque label box, all sat in a centered horizontal band down the middle of the plot (│ Idle │ Running │). Decoupling the label band from the value's y position means a value table with many entries still gets readable labels rather than collapsing each label to a few pixels — the line shows the held value, the ribbon shows the label. The lane activates whenever a single enum signal sits on its own axis — i.e. an area with one enum signal in any mode, or any enum that ends up alone underindividual. Enum break-out onto its own axis underper-unitis still pending — seeplans/backlog.md. - Plot areas. A plot panel is a stack of plot areas — it starts
with one; add plot area appends more, all sharing one time axis,
and they flex to fill the panel (one fills it; several split it).
Once a panel holds more than one, the grip (⠿) at the left of an
area's signal-panel heading drags that area to another area's
position in the stack. Each
plot area has a uPlot canvas (time axis at the bottom) plus a signal
panel beside it listing that area's signals: a color swatch (click
to hide / show the line — the value keeps updating, the swatch dims;
right-click the swatch to pick the series' color from the
browser's color picker), the name, and the value — at cursor A when one is placed, else at the
mouse crosshair, else the latest sample. While the pointer is over the
panel, the bottom-most area's
time (s)axis label also carries the crosshair's own time — the instant those readouts are taken at — as elapsed time on the shared timeline (ADR 0024); it reverts to the plain label when the pointer leaves. (Cursors A and B already label their own lines with their times.) The signal-panel head shows the H1/H2 Y-cursor values + ΔH when those are placed; y scales are always auto-derived (per ADR 0026) and fit y refits the auto-norm latch to the visible window. With a DBC attached, the toolbar's add signal… dropdown lists every(message, signal)pair the database defines; picking one drops it into the focused plot area (click an area to focus it). Drag a signal row to re-order it, onto another plot area, or onto another plot panel (cross-panel drops in a copy); × removes it. A signal keeps the color it was given when added — re-ordering / moving doesn't recolor it. The shared x-axis spans 0 to the longest plotted signal across the panel's areas, so a signal added late still shows over the existing span. - Zoom, pan & follow. Wheel zooms x on every area; shift + wheel pans x (synced); right-drag box-zooms x; ⌘/ctrl + wheel zooms y on the hovered area (buried — y is usually set with the per-area range control); fit data refits x to the full signal extent. Follow live keeps every area pinned to the capture's growing edge while keeping the current visible x-width (it just slides right); a manual x pan/zoom turns it off, the same way a manual scroll leaves auto-scroll in a trace panel.
- Show points. A tri-state toggle on the toolbar (
auto/off/on) that applies to every series on every axis of every area in the panel:auto(default) defers to uPlot's density-aware mode (points appear only when the sample-to-pixel ratio is low enough),offforces no points,onforces points always. Persists in the project file. - Cursors & measurements (both off by default). The toolbar's cursors selector turns on X cursors (left-click places A, right-click places B, drawn through every area — a small Δt chip shows on the plot between them), Y cursors (per-area H1 / H2 — values and ΔH show in the area's signal-panel head, plus a chip on the plot), or + note (left-click drops an event note at that time); clear cursors removes them all. The measurements toggle reveals a readout strip whose cells are configurable (the measurements ▾ checklist): A, B, Δt, 1/Δt, and per-trace value@A / value@B / Δ / min / max / mean over [A, B]. Event markers — the capture-start "T0" plus your notes — draw as vertical lines across the areas; the event log under the panel renames (click the label) and removes notes.
- Number formatting. Every float the plot shows — the signal
panel, the cursor readouts, the measurement strip, the y-axis tick
labels — reads under one magnitude rule, so a value can't read
0.0001in the panel and1.0e-4on the axis beside it. It goes exponential only when it is small or large: below1e-4or at1e6and up (both are settings — Exponential below / Exponential from). Otherwise it is written out in full, to six significant figures in a readout and three on a tick, with no trailing padding — so a six-figure reading just above the small threshold writes nine decimals (0.000123456) rather than switching. Zero always reads0. In exponential form the mantissa carries a fixed five decimals with the trailing zeros kept (1.00000e-6), so two readings of one magnitude are one width; Exponential mantissa decimals changes that width. A signal whose DBC gives it a fixed precision keeps that precision instead, and one the DBC marks as a raw bit field still reads in hex. A log-scaled y-axis always labels exponentially — its ticks are decades, and mixing the two notations on one axis reads as two quantities. - Performance. Each re-sample slices only the trace element's window out of the store by frame index (so the work is bounded by the window, not the whole capture), and the result is min/max-decimated host-side to ≈the plot's pixel width before it reaches uPlot (spikes survive the decimation), and the live plot re-samples incrementally on a self-paced loop at ≈15 Hz — each tick only the frames appended since the previous one are decoded and appended to a bounded per-signal cache, so a long capture isn't re-decoded every tick. Pause stops the loop. The toolbar shows the realised update rate, the worst recent re-sample time, and the device-pixel ratio.
Multiple plot panels can be open, each independent; the areas, signal
assignments, y-ranges, follow-live, cursor mode, and measurement
selection round-trip through the project file (the play state, like a
trace panel's window, is session-only). Notes are session-scoped (the
plot panels read and write a shared host store) and persist to disk
inside the BLF as GLOBAL_MARKER records (no sidecar — ADR 0010) —
see the Phase-9 section below. (Still pending — see
plans/tasks/0023-plot-measurements-and-triggers.md and plans/backlog.md:
per-trace y offset/gain and log scale, triggers, math channels,
CSV/image export.)
Add transmit panel opens the transmit panel: a single column of collapsible frame-tiles, each composing one CAN / CAN FD message that can be sent on demand or scheduled cyclically.
The transmit messages are a host-owned model, not panel state: the
Rust host holds the single TX-message pool (transmit_frames), runs
periodic schedules on host threads, and persists the pool in the
project file (Project.transmit_frames). The panel is a thin view —
it lists the pool, renders the subset its element's frameIds group
names (in that order), and routes every edit / send / start / stop
through a Tauri command. So a running periodic keeps emitting at its
true cadence regardless of UI frame-rate, edits to a running message
take effect on the next emitted frame without a stop/start, any number
of messages can share an id/bus, and two transmit panels grouping the
same message stay consistent. (Phase 13 Step 9; ADR 0003.)
Each tile carries an id (hex), addressing mode (standard / extended), destination bus, kind (classic / FD / remote / error), payload, BRS where applicable, DLC for remote frames, a manual-vs-periodic mode toggle, a cycle-time when periodic, and an optional free-text description (the message's name is the DBC message name resolved from its id). Tiles drag-reorder via a bus-tinted handle on the left edge (which rewrites the group order).
The collapsed face is the everyday control surface:
- Per-byte hex cells. The payload is shown as a row of two-digit
hex cells (8 for classic, up to 64 for FD; FD wraps).
Tab/Shift+Tabtraverses cells. - Send / cycle controls. A manual/periodic toggle picks between
a
sendbutton (one-shot) or a period-ms input +start/stoppair (cyclic). The periodic schedule runs on a host thread at the message's cycle-time (not a UI-ratesetInterval); removing the message, flipping back to manual, or stopping it ends the loop, and reopening a project leaves every periodic stopped until you pressstart. - Identity strip. Description, bus, hex id, the DBC message name
when the id matches a loaded DBC, and a per-frame
×remove (confirm-on-click so an accidental tap doesn't drop a frame).
Expanding a tile reveals the frame-shape strip (kind, extended,
BRS / DLC) and — when the id binds to a DBC message — the signals
table. Rows are spreadsheet-dense: name · value · unit · range.
Range comes from the DBC SG_ min/max when set, else derived from
the signal's factor / offset / size / signed. Plain signals show a
numeric input; enum signals show a combobox that filters the
VAL_ labels as the user types and also accepts a raw number for
out-of-table values. Multiplexed messages show only the active arm
of the mux — switching the multiplexor zeroes the new arm's bits so
it starts fresh. Messages that declare extended multiplexing
(m<N>M) fall back to bytes-only editing with a note in the
expanded face.
Per-signal edits go through the host's encode_frame Tauri command,
which partial-encodes the named signals into the current payload
without disturbing any other byte. See
ADR 0017
for the ownership and source-of-truth rules.
Where a sent frame goes:
- Always into the trace as a
Tx-direction tx-confirm row, just like a real analyzer shows for its own transmits. The transmit pipeline is observable end-to-end even with no remote source open. - If a remote session is open, also onto the wire as a one-frame
FrameBatchenvelope on the bus's bound interface. The BLF replay server is read-only and rejects the transmit withError::TX_REJECTED, which surfaces in the system messages log (no per-frame status panel — successful sends show as Tx-confirm rows in the trace; failures are visible in the log). Acannet-server --virtual-busserver accepts transmits on its allocated participant id and fans them out to every other subscriber; a solo subscriber's transmit reaches no recipients and comes back asError::NO_ACKNOWLEDGERinstead.
Enum / state signals render symbolically wherever they appear:
- A trace row's expanded signal lines show
<value> "<label>"for signals with a matchingVAL_row; numeric signals are unchanged. - The transmit panel's signals table renders enum signals as a combobox of labelled values (above).
- A plot area that contains exactly one signal with a value table
switches to enum mode: the line is rendered stepped (no
interpolation between codes) and the y-axis ticks become symbolic
(
<raw> "<label>", one per value-table row), with auto-norm disabled. Multi-signal areas / mixed enum + numeric areas keep the numeric rendering.
cannet-server --virtual-bus exposes one factory interface,
virtual:bus0, and hosts a multi-client virtual CAN bus (ADR 0021).
Each connecting client Subscribes to the factory, the server
allocates them a fresh participant (virtual:bus0/p<n> returned via
InterfaceAllocated), and every transmit from one participant fans
out as Rx frames to every other participant tagged with the
sender's allocated id.
cargo run -p cannet-server -- --virtual-bus
# → virtual-bus mode: factory virtual:bus0 (speed 500000 bit/s, fd data off)
# → listening on 127.0.0.1:50051Tunable via --speed-bps (arbitration-phase bit rate, default
500 000) and --fd-data-speed-bps (data-phase bit rate for FD frames
with BRS; 0 leaves the bus classic-only). Runtime reconfiguration
goes through the wire's ConfigureBus envelope and takes effect on
the next arbitration round.
Bridges. Any session may install a bridge with AttachBridge { remote_address, interface_id, name }. The server opens a session to
the remote endpoint, subscribes to the named interface, and wires the
resulting frame stream into the bus as a bridge participant. The new
bridge is published as virtual:bus0/bridge-<name> and every open
WatchInterfaces stream gets a fresh snapshot. DetachBridge { name }
tears it down; the same WatchInterfaces push announces the removal.
Pointing a bridge at another cannet-server --virtual-bus's factory
yields the CAN-over-IP gateway shape: traffic on one server fans
out across the bridge to the other and vice-versa.
cannet-client factory subscribes. The Rust client now
understands InterfaceAllocated: a Subscription::factory(id, ch)
sends the factory subscribe, waits for the server's response, and
surfaces the allocated participant id through
Subscription::effective_id() on the resolved session. Frames the
server fans out (tagged with each sender's allocated id) are routed
back to the originating factory subscription by prefix-match so the
caller's channel mapping holds.
GUI integration (Phase 13 Step 7). A logical bus (project state) routes from a source (a host-side data path) — see ADR 0023. The project schema (v6) carries three source kinds on each binding:
kind: "remote"— a(server, interface)on a remotecannet-serveror the local sidecar. v5 entries migrate here.kind: "remote-virtual-bus"— subscribe to a remote virtual-bus server's factory id; the GUI uses the allocated participant id when transmitting.kind: "local-virtual-bus"— bind to a virtual bus defined inProject.local_virtual_buses(id + name + bus_config + bridges). The Tauri host instantiates oneSharedBusper definition on project open; many bindings may target the same virtual bus.
Each logical bus has a single combo on its row that lists every
source the project knows about — sidecar interfaces, remote
interfaces, and the project's virtual buses — plus + Add server…
and + Add virtual bus. Picking from the combo writes the binding.
Step 6's multi-client fan-out means the same source may be picked
for many logical buses; the GUI no longer hides "in use" options.
A dedicated Virtual buses section lets the user rename, configure,
add bridges to, or delete each virtual bus the project owns; the
host applies bus_config edits via SharedBus::reconfigure and
manages bridge teardown.
Add RBS panel opens a rest-of-bus simulation (ADR 0028): cannet transmits a configured set of DBC messages on their cadence with live, editable signal values — playing every node except the device under test.
The configuration is a human-editable .cannet_rbs JSON
document of sparse overrides nested bus → ecu → message, keyed
by the project's logical bus names and hex CAN ids (trailing x =
extended). A signal absent from the config keeps tracking its DBC
default (GenSigStartValue, else the config's fill_bit);
period_ms falls back to GenMsgCycleTime. A fresh RBS panel is
immediately usable — the config starts in memory, pre-seeded with
the project's current buses — and Save prompts for a
.cannet_rbs path the first time. The project then references the
file by path through a nameable RBS element (multiple per
project), so simulation configs are switched and forked with
ordinary file operations.
In the panel:
- The tree-grid lists every DBC message on each configured bus,
grouped per transmitter ECU, with ANDed enable checkboxes at
bus / ECU / message level. Messages are enabled by default
(rest-of-bus: everything plays unless muted — mutes persist as the
config's flat
disabled_messageslist). Buses whose name doesn't match a project bus render inert (greyed) rather than failing the load. - Signal cells show the live decode of the message's payload buffer;
editing partial-encodes into it (enum labels and
0x…raw hex are accepted), an overridden cell is marked and a light × clears it back to DBC-tracking. The fzf filter narrows by message / signal name. - Run (persisted in the project, default off) starts the enabled messages on the host scheduler; actual transmission gates on per-bus connectivity (a bus that connects starts its messages, a drop stops them). A project saved with RBS running resumes on open; the global kill-switch (runtime-only) stops every RBS transmission at once.
- Save writes the override edits back to the file; Save all
(command palette) saves the project plus every dirty
.cannet_rbs, and the exit prompt covers both. Save dialogs default to.cannet_prj/.cannet_rbs;.jsonis still accepted on open.
Calculated fields (ADR 0027) are signals recomputed on every
send: a sequence counter (increment + rollover) and/or a CRC
(a crc-catalog named algorithm or raw Rocksoft parameters,
computed over a byte-aligned bit range of the just-encoded payload,
optionally prefixed with hex bytes — the AUTOSAR E2E Data ID case).
The designation lives in the DBC as cannet attributes on the
destination signal:
BA_DEF_ SG_ "CannetCounter" STRING ;
BA_DEF_ SG_ "CannetCrc" STRING ;
BA_DEF_ SG_ "CannetDisplay" STRING ;
BA_ "CannetCounter" SG_ 1042 AliveCtr "increment=1;rollover=15";
BA_ "CannetCrc" SG_ 1042 Crc8 "alg=CRC-8/SAE-J1850;range=0:56;prefix=A3";
BA_ "CannetDisplay" SG_ 1042 Crc8 "radix=hex";
(see examples/cannet-demo.dbc's BmsCommand message). Both the
RBS panel and the transmit panel expose the same configuration
editor; a per-message override replaces the DBC default wholesale
per field. On the receive side, frames on a configured (bus, id)
are verified at ingest: a bad CRC or out-of-sequence counter paints
the trace row red, per-id validity is queryable
(fetch_field_validity), and a valid→invalid transition logs a
rate-limited Info system message. cannet's own transmissions are
exempt.
That block is the full set of cannet's DBC attributes (ADR 0043).
They are all per-signal STRINGs taking a key=value; value, an
empty value means unconfigured, and cannet reads them but never
writes a DBC — a hand-edited file, or whatever generates yours,
authors them. CannetDisplay is a render mode rather than a
transmit designation: radix=hex shows a raw integer bit field (an
unscaled, unitless, non-enum signal — an id, a serial, a flag word)
as 0xDEADBEEF instead of base 10, which is what such signals read
as by default. Asking for it on a signal that has a unit, a scale
factor or a VAL_ table logs a warning and changes nothing, as does
an unrecognised key or value.
Note: plain
cargo run -p cannet-guiwill build the Rust host on its own but won't bring up a usable window — the host expects either a Vite dev server (whichtauri devstarts for you) or a built frontend atapps/gui/dist. Use thepnpm tauricommands above.
Add color map opens a value→color map config panel (ADR 0029).
A color map is a standalone, DBC-informed project element that targets
one signal and assigns colors to its values — enum states each get a
color (seeded from the DBC VAL_ table), or a numeric band can be
given a color over an inclusive [min, max] range. Unlike a filter it
isn't wired through the graph: it's ambient, so any view rendering
the target signal shows the color. The expanded trace rows tint the
signal's value cell; a plot fills the enum logic-analyzer lane box
for each held value. Maps live in the project file and resolve
first-match — the first map (and within it, the first rule) that covers
a value wins. This is a first-cut prototype; the rule editor and
numeric-signal plot rendering will grow.
Phase 6 makes "logical bus" the abstraction frames belong to and introduces filter elements + a visual project graph.
Logical buses. The project panel grows a Logical buses section where you can add / rename / remove project-owned buses (each carries a stable id, display name, and optional speed / FD hints). Buses are project state — they round-trip through the project file alongside the panel layout.
Interface bindings. The project panel also lists Interface
bindings: each binding maps a (server, interface_id) pair onto a
logical bus. The section's Add binding form takes a server address,
optionally Discovers its interfaces, and pairs a chosen interface
with one of the project's buses. Each bus is allowed at most one
binding (one interface per bus); a bus that already has a binding is
hidden from the picker.
The toolbar's Connect button (or Connect all in the project
panel) iterates every unique server in interface_bindings, opens one
gRPC session per server, and subscribes only to the bound interfaces.
The host's pump thread stamps every received frame with the chosen
bus_id. Disconnect ends every session. Server addresses no
longer live in the toolbar — they're per-binding configuration. A
binding's server is either the literal "local" (sentinel meaning
"the local sidecar at whatever address it's bound to this session" —
the sidecar's port is randomised per launch, so persisting a literal
host:port would orphan the binding on every reload) or a
host:port for a specific remote cannet-server; the frontend
resolves "local" to the live sidecar address before invoking the
connect command.
Connection feedback. Connecting, failing, and configuring are all
visible where the action is, not only in System Messages. Each
logical-bus row carries a marker for its binding — unbound, not connected, connecting…, connected, or error: <reason> — and so
does each bound interface row in the Connection section, so a
multi-channel device shows which of its channels actually came up. The
state is the host's (connection_state.rs, read through
get_connection_states and the connection-states-changed event) and
only moves on a real outcome: the interface list, the subscribe, the
pump's exit. A binding whose interface the server doesn't expose gets
its own error rather than being dropped silently.
A connected bus also shows a live: line with the hardware
configuration the host actually sent for it, which is not always
what the fields below say. ConfigureBus is only pushed at connect
(edit while connected and the row's pending chip says so — reconnect
applies it), and a bus with neither a bitrate nor FD pinned sends no
ConfigureBus at all, so the row reads driver default (nothing sent) rather than echoing the input's greyed placeholder. The wire
has no applied-config response (ADR 0022 makes ConfigureBus
fire-and-forget), so this is "what was sent", not "what timing
registers the controller landed on" — which no layer of the stack
reports.
BLF channel mapping. Opening a BLF now pre-scans the file for its distinct channels (capped at 200k frames for huge BLFs) and shows a modal where each channel is mapped to a logical bus or marked as "skip". Skipped channels are dropped before they reach the trace store; mapped channels stream in tagged with their bus.
Per-bus DBC scoping. Each DBC entry in the project panel grows a row of checkboxes — one per defined logical bus — that control which buses the DBC decodes for. A DBC with no boxes checked is unscoped ("all buses", the migration default for v2 projects). A DBC scoped to bus A doesn't decode bus-B frames; an unassigned frame matches only unscoped DBCs.
Default: receive from every bus. Each consumer (trace, plot, filter) carries a
sources: string[] list of upstream producer ids — bus ids or filter
ids — with the literal "*" as a wildcard meaning "every bus in the
project, including ones added later." Freshly created consumers
default to ["*"], so a new trace or plot starts wired to every
bus without any explicit configuration. Transmit elements mirror
this on the producer side via sinks: string[], but without the
wildcard — a fresh transmit pre-fills with every currently-known bus
and a future bus added later is a deliberate choice (firing onto an
unintended bus is more surprising than missing one).
Configuring a consumer's sources. Right-click anywhere in a
trace or plot panel to open a context menu with checkboxes for
each project bus (and an "All" toggle that re-collapses to the
wildcard) plus any defined filter elements. Unchecking a bus
narrows the consumer; re-checking everything snaps back to
["*"]. The transmit panel has the same right-click affordance
for its sinks list.
Filter elements. A new project element {kind: "filter"} carries
a structured predicate ({all | any | bus | id_range | id_list | name_regex | signal_equals}) and its own sources list so it can
chain after other filters or buses. The fetch path
(fetch_trace_range, fetch_by_id_page) accepts an optional
predicate that drops records that don't pass; the trace store stays
one filter-agnostic session buffer, and each consumer scopes what
it renders. There's no expression DSL — the predicate is built in
the filter node's inline editor (see below) or by hand in the
project file.
Project graph panel. Add one from the toolbar's Add graph panel button (or restore a saved one from the project file). Each project element gets a shape that matches what it is:
- Bus — a wide horizontal rail (the logical aggregator);
- Gateway — an interface binding linking a wire-level interface to a bus (bidirectional);
- Transmit — frames composed and sent onto each bus in
sinks; - Sink —
traceandplotpanels consume the buses + filters listed insources; - Filter — same consumer shape as sink, plus a predicate that
drops non-matching frames; downstream consumers reference it via
their own
sources.
The panel's toolbar exposes a + filter button that creates a
filter element fanning in from every bus. Each consumer node carries
a + filter affordance that does the same thing but inherits
the consumer's current sources and inserts the filter between the
consumer and its previous inputs ("Insert filter upstream"). Each
filter node has an inline predicate editor (caret to expand) that
builds the structured predicate without touching JSON.
Edges encode the wiring: gateway ↔ bus (bidirectional), bus →
consumer, filter → consumer, transmit → bus. Right-click an edge
to delete it (the wildcard "*" source expands into the explicit
"all buses except this one" list on first removal). Node positions
and the viewport persist in the panel's dockview params. The
graph is the spatial view onto the same project state the project
panel shows as lists — see
docs/adr/0012-project-panel-graph-split.md
for the split of responsibilities.
Transmit by bus. The transmit panel composes a frame per
project bus listed in the transmit element's sinks; the host
resolves each bus_id to the matching session's wire channel via
the project's interface bindings. There's no per-frame "channel"
control anymore — that was the leaky Tauri-host detail.
BLF round-trip preserves bus assignment. Save Capture writes each frame on the BLF channel matching its bus's position in the project's bus list, so a multi-bus capture can be reloaded into the same project with the channel-map modal pre-seeded to the right bus per channel — no manual remap.
Project schema version. PROJECT_SCHEMA_VERSION bumped 2 → 3. A
v2 project opens by way of an in-memory migration that lifts
dbc_paths into dbcs (each unscoped) and defaults buses and
interface_bindings to empty; the on-disk version is rewritten the
next time you save.
Phase 7 adds a structured log bus and a panel that surfaces it.
Host-side log bus. The Tauri host owns a bounded in-process ring
of { ts, source, level, message } entries (apps/gui/src-tauri/src/ system_log.rs). sys_debug! / sys_info! / sys_warn! /
sys_error! macros fan each event into both the ring and
tracing-subscriber's fmt layer so dev stderr keeps working.
info is reserved for what the user's own actions produced — an app
nobody is touching emits none; the app's own chatter (health
samples, lifecycle breadcrumbs, sidecar status) is debug. A
per-(source, template) rate
limiter caps any one emitter at five entries per second; the first
drop in a window records a single suppression note so the panel
doesn't go silent under a flood. The ring's depth and the limiter's
budget are settings (system_log_ring_capacity,
system_log_rate_limit), and setting the budget to 0 turns the
limiter off — diagnosing a message flood is exactly when you want all
of it. Sources currently in use:
project, dbc, connection, blf-import (vendor sidecars will
use sidecar:<vendor> in Phase 8).
System Messages panel. Add it from the toolbar's System
messages button. The panel renders a virtualised list filterable by
source and by minimum level (default info — a session's worth of
what you did; drop it to debug for the app's internal breadcrumbs,
which reach the rolling log file either way). Copy-all and
double-click-to-copy
put entries on the clipboard; Clear empties the ring. Per-panel
filter state lives in dockview params; the bus itself is
session-scoped (it isn't written into the project file).
Unread-error indicator. The toolbar button doubles as a badge: the red pill shows the number of warn+error entries that arrived since the panel last gained focus. Clicking the button focuses the panel (or opens one) and clears the badge.
Wire-level surface. cannet-wire's Envelope grew a fifth
variant — LogMessage { ts, level, source, message } — alongside
Error. The two are distinct: Error still ends the session, a
Log is informational and the session continues. The host's
system_log::bridge_wire_log translates an incoming wire log into
the local bus; Phase 8's vendor sidecar is the first real producer.
Phase 8 plugs in real hardware sources by way of a single auto-launched
python-can sidecar that lives at
servers/cannet-python-can/. The sidecar
speaks the same cannet-wire gRPC protocol as cannet-server, so the
host pipeline is unchanged — interfaces show up in the project graph
view the same way the BLF replay fixture's do, just under
vendor-prefixed names with a paren-delimited key:value metadata
list. Examples:
vector:VN1640A(SN:12345, ch:0)— Vector card serial + per-card channel.kvaser:1(SN:67890, ch:0)— Kvaser card serial + per-card channel.pcan:PCAN_USBBUS1(h:0x51, ch:0)— PEAK slot constant + channel handle integer + controller number. PCAN-Basic doesn't standardly expose a per-device factory serial, so the handle integer is the stable per-attached-channel anchor;uid:<n>joins the list when the user has set a non-default device ID in PCAN-View.
For Kvaser and PCAN, the body alone (everything before () is what
python-can needs to open the channel — the paren metadata is
identity for the GUI, and _bus_kwargs_for strips it before handing
the body off. Vector is different: the open path reads SN: and
ch: out of the parens and passes serial= + channel= to
python-can's vector backend, so the driver resolves the physical
channel via get_channel_configs and never calls xlGetApplConfig.
That bypasses Vector Hardware Config's application-channel mapping
entirely, so an unmapped slot in the VHC app view can't break open
or close.
Auto-launch. The GUI's Tauri host spawns the sidecar at startup
(apps/gui/src-tauri/src/sidecar.rs); the user does not run anything
in servers/cannet-python-can/ by hand. The sidecar binds to an
OS-assigned ephemeral port (127.0.0.1:0) and reports the actual
address back on its sidecar\tlistening\t<addr> banner; the host
parses it into SidecarState and exposes it through the
get_sidecar_status Tauri command and the sidecar-status-changed
event, which the project panel's "Local sidecar" row reads so the
user can bind interfaces without typing an address. The sidecar's
stdout / stderr and exit code feed the System Messages panel
tagged sidecar:python-can. A crashing sidecar gets a budget of
auto-restart attempts per session (sidecar_restart_budget, three by
default); once the budget is exhausted, the restart_sidecar Tauri
command — the project panel's Local interfaces → Restart button —
clears it.
Detailed sidecar logfile. Stderr is not the whole story: it is
what the panel shows, so it stays at sidecar_log_level. The host
also launches the sidecar with --log-file, pointing at
sidecar-python-can.log in the same per-OS log directory as the
host's own rolling cannet.log (%LOCALAPPDATA%\<id>\logs on
Windows, ~/Library/Logs/<id> on macOS, ~/.local/share/<id>/logs on
Linux). That file always records at debug, whatever the panel is
set to: every gRPC command with its arguments and outcome
(enumerated interface ids, subscribe attempts, configure with the
requested values and the ones the driver was handed, disconnects)
plus every driver traceback — which is what a per-channel connect
failure needs to be diagnosable after the fact. It rotates at 1 MB
across five generations, so it costs at most ~5 MB of disk. The
frame streams are the deliberate exception: transmit and receive log
their lifecycle and faults, never per-frame content. The sidecar
reports the path back on startup, so the System Messages panel says
detailed log: <path> — that plus cannet.log is what to attach to
a bug report. Running the sidecar by hand (uv run cannet-python-can) writes no file unless you pass --log-file
yourself.
Lifecycle: dies with the host. The host pipes the sidecar's
stdin and writes nothing to it. When the host process exits (clean
or not), the OS closes the pipe and the sidecar's stdin-EOF watcher
calls server.stop(grace=2.0) — no orphaned sidecar holds hardware
open. The same mechanism covers panics and SIGKILL; it does not
require a RunEvent::Exit handler or a Windows job-object.
uv resolution. uv is fetched, not bundled — see
docs/adr/0015-fetched-runtime-binaries.md.
The host launcher resolves uv in this order:
- Local fetch —
tools/uv/uv[.exe]next to the GUI executable.scripts/fetch-uv.shdownloads the pinned binary for the current OS / arch intotools/uv/for local dev. This is a developer convenience; shipped builds run a frozen sidecar instead (seedocs/adr/0036-frozen-python-can-sidecar.md). uvonPATH— install viahttps://docs.astral.sh/uv/.python3 -m cannet_python_can— last-resort fallback when neither is available. The host logs a warn-level System Message asking the user to installuvfor the supported flow.
uv materialises the sidecar's venv lazily on first launch and
installs Python itself if missing, so there is no pre-installed-Python
prerequisite.
Per-vendor prerequisites. None of the vendor SDKs are bundled — they are runtime, user-installed dependencies:
| Vendor | SDK | OS | python-can backend |
|---|---|---|---|
| Vector | XL Driver Library | Windows (full), Linux (partial) | vector |
| Kvaser | CANlib SDK | Windows, Linux, macOS (partial) | kvaser |
| PEAK | PCAN-Basic API | Windows, Linux, macOS | pcan |
A vendor with no SDK installed contributes zero channels and does not
break the others. The full per-vendor smoke-test procedure lives in
servers/cannet-python-can/SMOKE.md;
CI cannot run it.
Swapping the driver library. The sidecar's
driver.py
defines a small adapter protocol (list_channels, open, recv,
send, close). To replace python-can:
uv pip install <your-driver>into the sidecar venv (or editservers/cannet-python-can/pyproject.tomland re-runuv sync).- Write a module exposing a top-level
Drivercallable returning a matching object. - Name it in Settings → Connection → Driver module, which the host
forwards to the sidecar.
CANNET_DRIVER_MODULE=<your_module>in the environment the GUI launches with still works and takes precedence for that run (the override is reported in System Messages).
A patched or replaced sidecar build is reachable the same way:
Settings → Connection → Sidecar directory points cannet at a
cannet-python-can package directory of your own, and
CANNET_SIDECAR_DIR overrides it for one run.
The wire-level code does not change. See
servers/cannet-python-can/LICENSING.md
for the LGPL analysis that motivates this layout.
Phase 9 makes captures persistable and re-loadable, with user-placed notes round-tripping alongside.
Save Capture. The toolbar grows a Save capture… action
(disabled when the session buffer is empty). It writes the entire
session buffer — every frame on every bus, classic / FD / error /
remote — to a single .blf file via the new
BlfCaptureWriter wrapper. Writes
stream to <file>.blf.part and rename into place on completion
(atomic — a mid-write crash leaves no half-file behind at the
destination). Save confirmation, frame count, and byte size all
surface in the System Messages panel tagged capture. The BLF's
underlying f64-seconds timestamp storage drops sub-microsecond
precision for modern absolute timestamps; the host warns at save
time when that drift measurably exceeds 1 µs (the documented
precision floor).
Notes. Notes are placed by the plot panel's + note cursor
mode (left-click on the canvas drops a labelled marker at that
time). They now live in a single, session-scoped store on the
host (apps/gui/src-tauri/src/notes.rs) — a note placed in plot
panel A is visible in plot panel B over the same timeline. Edits
flow through add_note / rename_note / remove_note Tauri
commands; the host broadcasts the updated chronological list via
the notes-changed event. Clearing the trace store wipes the
notes with it.
Save Capture writes notes inside the BLF as GLOBAL_MARKER
records (BLF object type 96 — Vector's native annotation type, so
third-party tools like CANalyzer see them too). Open BLF reads
those markers back into the session-scoped store. No sidecar
file is written, per ADR 0010.
Recent BLFs. The toolbar grows a Recent dropdown next to Open BLF… that lists the last 8 opened BLF paths, persisted host-side (ADR 0032). Picking one fast-paths through the standard Open BLF flow (the channel-mapping modal still runs because each BLF can route differently onto the current project's buses); a successful Save Capture promotes the saved path too, so "what did I just save?" is a one-click re-open.
Project schema v3 → v4. PROJECT_SCHEMA_VERSION bumps to 4.
Notes used to live in each plot panel's dockview params; the v4
migration strips them out (the host's session-scoped store owns
them now). Phase-4-vintage projects open cleanly with the
migration running on parse; the on-disk version is rewritten the
next time you save.
Phase 2 splits the data source out behind a gRPC service. The
cannet-server binary loads a BLF and replays it on a loop;
the GUI's toolbar grew a connection panel that consumes the
same protocol.
In one terminal, start a server:
cargo run -p cannet-server -- examples/cannet-demo.blf
# → loaded N interface(s) from examples/cannet-demo.blf
# → listening on 127.0.0.1:50051It exposes the BLF's channels as gRPC interfaces (blf:0,
blf:1, …) and replays them on a loop while a client is
subscribed.
CLI flags:
--bind <addr>— listen address (default127.0.0.1:50051).--rate <multiplier>— replay pacing.1.0plays the BLF at its recorded cadence (real-time emulation, the closest match to a hardware bus);100plays it 100× faster;0(the default) disables pacing entirely and emits frames as fast as the consumer drains. The default is intended for development and tests; for a realistic emulation, use--rate 1.
The server is single-client per process and rejects client
transmits with Error::TX_REJECTED (BLF is read-only — the
rejection surfaces inline on the GUI's transmit panel). Stop with
Ctrl-C.
In another terminal, start the GUI as usual (pnpm --dir apps/gui tauri dev). In the project panel's Interface bindings
section, type the server address (the Add-binding form defaults to
127.0.0.1:50051), hit Discover to list its interfaces, pick one,
pair it with a bus, and Add binding. Clicking the toolbar's
Connect subscribes to every bound interface across every server
in the project and starts streaming frames into the trace view.
Disconnect ends every session. The GUI can attach a DBC the same
way it does for a local BLF — decoding runs against whichever frames
are currently flowing.
The Open BLF… and Connect flows share the same trace store,
so frames from either source render through the same view.
pnpm --dir apps/gui tauri build produces a single platform-native
executable (with the React bundle embedded) plus an installer for each
target's distribution format. Sizes below are from the Phase-1 build —
they'll grow as features land.
| Path (relative to repo root) | Platform | Size | Notes |
|---|---|---|---|
target/release/cannet-gui |
host platform | ~11 MB | The standalone executable. Links dynamically against the platform's WebView library. |
target/release/bundle/deb/cannet_<ver>_amd64.deb |
Linux (Debian/Ubuntu) | ~3.3 MB | apt install ./cannet_*.deb. |
target/release/bundle/rpm/cannet-<ver>-1.x86_64.rpm |
Linux (Fedora/RHEL/openSUSE) | ~3.3 MB | dnf install ./cannet-*.rpm. |
target/release/bundle/appimage/cannet_<ver>_amd64.AppImage |
Linux (any glibc-compatible distro) | ~80 MB* | Self-contained: bundles WebKitGTK and friends. chmod +x and run. |
target/release/bundle/dmg/cannet_<ver>_x64.dmg |
macOS | — | Drag-to-Applications disk image. |
target/release/bundle/macos/cannet.app |
macOS | — | The raw .app bundle, codesignable. |
target/release/bundle/msi/cannet_<ver>_x64_en-US.msi |
Windows | — | MSI installer. |
target/release/bundle/nsis/cannet_<ver>_x64-setup.exe |
Windows | — | NSIS installer. |
* AppImage size is approximate; the bundling step needs FUSE on the
build host, so it doesn't run in some sandboxed CI environments. The
.deb / .rpm paths above are confirmed sizes from a recent local
release build.
The bare cannet-gui binary is not statically self-contained:
- Linux: depends on
libwebkit2gtk-4.1-0at runtime (same package family installed during the build prerequisites). If you want a hand-it-to-someone-else single file, ship the AppImage. - Windows: depends on the Microsoft Edge WebView2 runtime. Win11 and current Win10 ship it; older systems install it once.
- macOS: uses the system WebKit framework; no extra runtime.
Tauri can't cross-compile — each target is built on the matching OS.
The release workflow does this automatically: pushing a vX.Y.Z tag
first runs the full CI suite, then (only if it passes) builds the macOS
arm64 and Windows x64 bundles on native GitHub Actions runners and
publishes them to a draft pre-release (see § Downloads and
.github/workflows/release.yml). The
committed version stays 0.0.0; the binary stamps its own
git describe version (shown in the settings panel's About section)
and the installer takes its version from the tag.
These run automatically on every pull request and push to main via
.github/workflows/ci.yml; run them locally
with:
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
pnpm --dir apps/gui test # frontend unit tests (vitest)
pnpm --dir apps/gui build # type-checks and bundles the frontendThe Python sidecar (servers/cannet-python-can)
is checked with ruff (lint + format),
mypy, and pytest — run from that directory:
uv sync --extra dev
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv run pytestA pre-commit config
(.pre-commit-config.yaml) runs the same
tools as CI as a local commit gate. Every hook is a local
language: system hook that invokes the tools this repo already pins
(uv, cargo, pnpm) — there are no third-party hook repos to keep in
sync, and the checks match ci.yml. Two extra hooks stop machine-local
paths leaking into the repo: one rewrites absolute paths in
.cannet_prj files to project-relative form (ADR 0030), and one
blocks any staged file still carrying an absolute path from your
home directory or clone location.
Enable it once per clone (needs the pre-commit tool — e.g.
uv tool install pre-commit or pipx install pre-commit):
pre-commit installLinters that take file arguments (ruff) run only on the staged files;
the sidecar's whole-project checks (mypy, pytest) and cargo clippy
run when files in their area are staged. cargo test is scoped to
the crates the commit touches, so a break in a crate that merely
depends on what you changed is caught by CI rather than locally — the
trade-off, and the measurements behind it, are written out in
.pre-commit-config.yaml. The frontend hook runs the vitest suite
and the build typecheck concurrently. Run everything on demand with
pre-commit run --all-files.
cannet is free software, distributed under the terms of the
MIT License (MIT). See LICENSE for the full text.
Copyright © 2026 Ben Hefner.