Continuously captures candump output from can0, writes it in time-bucketed
segments, compresses each segment with zstd, and uploads finished segments
to a private cloud remote. The capture is lossless — every frame, including
heartbeats, is kept; redundancy is collapsed by compression, not by dropping data.
candump -L can0 ──► canlog-rotate ──► /var/spool/canlog/*.zst ──► rclone ──► cloud
(per-session segments, (finished segments, (canlog-upload,
zstd-compressed) on the SD card) every 10 min)
The wheelchair's CAN bus is silent while the chair is powered off. candump
runs continuously regardless, so a power-on handshake is never missed — but
the rotator does not waste files on silence:
- A segment opens lazily, when the first frame of activity arrives.
- After
CAN_IDLEseconds (default 10) of silence, the segment closes and no new one opens until traffic resumes. That silence is a session boundary. - A session lasting longer than
CAN_WINDOWseconds (default 300) is split into multiple segments — purely a size cap; the session continues.
Each segment's filename records why it closed, so a gap between segments is never ambiguous:
| reason | meaning |
|---|---|
idle |
bus went silent — the session ended here |
cont |
hit the CAN_WINDOW size cap — the same session continues next segment |
stop |
the logger was stopped (service restart/shutdown) mid-session |
So a time gap after an idle or stop segment is an expected session
boundary (the chair was off). A gap with no such marker would indicate a
genuinely missing file.
| File | Role |
|---|---|
canlog-rotate |
Reads candump -L, writes/rotates/compresses segments |
canlogger.service |
Runs the capture pipeline under systemd |
canlog-upload |
Drains the spool to the cloud via rclone (verified delete/garbage collection of old files) |
canlog-upload.service |
Oneshot unit that runs the uploader |
canlog-upload.timer |
Triggers the uploader every 10 minutes |
- Hot file —
/run/canlog/(tmpfs / RAM). The currently-growing segment. Lives in RAM to spare the SD card from ~140 writes/sec. Lost on power cut, but that's only ever the last ≤5 minutes of an active session. - Spool —
/var/spool/canlog/(SD card). Finished.zstsegments waiting to upload. On the card so they survive a power cut. Published atomically, so the uploader never sees a half-written file.
Segment filenames are session-<host>-<UTC timestamp>-<reason>.log.zst (e.g.
session-goatrnet2-20260525T185437Z-idle.log.zst). The host tag — the Pi's
hostname, or CANLOG_HOST if set — keeps a file self-identifying after it
leaves the Pi and prevents filename collisions when several Pis upload to the
same bucket. The timestamp is when the segment opened (UTC).
Easiest path: build the .deb (see Building the Debian package at the end of
this README; you'll need a Linux machine to build, so ask a maintainer if you
don't have access to one) and install it on the Pi:
sudo apt install ./canlogger_0.1.0_all.deb # apt resolves runtime depsWhat that gets you:
- Scripts in
/usr/bin/(rotator + uploader). - Systemd units in
/usr/lib/systemd/system/. - This README in
/usr/share/doc/canlogger/. canlogger.serviceenabled and started immediately — capture begins.canlog-upload.timerinstalled but NOT enabled — you turn it on after configuring rclone in step 2, since uploads can't go anywhere until then.
The runtime dependencies (can-utils, zstd, python3, rclone, bash) are
declared in the package and pulled in by apt automatically.
sudo rclone config # MUST be sudo — see "rclone config location" belowIn rclone config, create a remote named canremote (the scripts default
to this name; change CANLOG_REMOTE in canlog-upload.service if you pick
another).
Backblaze B2 (no OAuth, easier headless) — if you'd rather skip the token
dance: create a bucket and an application key in the B2 web console, choose
b2 as the remote type, and paste the key ID + key. Done.
Other rclone backends
You can use AWS S3, Google Drive, etc etc. rclone supports many
backends/remotes, we're just not documenting any here.
Test before relying on it — and test as root, since that is who the timer runs as:
sudo rclone lsd canremote: # lists folders/buckets
echo hi | sudo rclone rcat canremote:goat-rnet-logs/_test.txt && sudo rclone delete canremote:goat-rnet-logs/_test.txtsudo systemctl enable --now canlog-upload.timer
systemctl list-timers canlog-upload.timer # confirm it's scheduled
journalctl -u canlog-upload.service -f # watch a runThe timer fires the uploader every 10 minutes. To trigger a run immediately (without waiting for the next tick — useful for confirming a fresh config, or to drain the spool on demand):
sudo systemctl start canlog-upload.service # runs once, in the foreground
# of systemd; watch with journalctlThis works whether or not the timer is enabled — the service is the actual
work unit, the timer just schedules it. Safe to run alongside a timer-fired
run: the flock guard inside the script makes concurrent invocations skip
rather than collide.
rclone config location:
rclone configwrites~/.config/rclone/rclone.conffor whoever runs it. The timer runs the uploader as root, sorclone configis run withsudoabove — the config lands in/root/.config/rclone/rclone.conf, which is exactly whereRCLONE_CONFIG=incanlog-upload.servicealready points. No edit to the unit is needed. The trade-off: rclone commands you run by hand to inspect the remote must also be prefixed withsudo, or they won't see the config.
Set as Environment= lines in the systemd units.
| Variable | Default | Meaning |
|---|---|---|
CAN_WINDOW |
300 |
Max seconds per segment (size cap) |
CAN_IDLE |
10 |
Silence (s) that ends a session |
CAN_ZSTD_LEVEL |
19 |
zstd level (1–19); 19 = best ratio |
CAN_STAGE |
/run/canlog |
Hot-file dir (keep on tmpfs) |
CAN_SPOOL |
/var/spool/canlog |
Finished-segment dir (keep on SD) |
CANLOG_HOST |
system hostname | Host tag in segment filenames |
CANLOG_REMOTE |
canremote:goat-rnet-logs |
rclone destination |
CANLOG_MIN_FREE_MB |
2048 |
Warn in the journal below this free space |
CANLOG_LOCAL_RETAIN |
14d |
Keep local copies at least this long after upload |
- Two-phase upload/prune. The uploader runs in two phases: first
rclone copyuploads any new segments and checksum-verifies them, leaving locals in place; thenrclone checkidentifies which locals are older thanCANLOG_LOCAL_RETAINand confirmed present on the remote, and only those get deleted. A segment that never uploaded successfully (network down for weeks, auth broke, etc.) is never deleted — it stays local until an upload eventually succeeds. This gives you a working grace period where fresh segments are still on disk for local inspection, without giving up the "nothing deleted unconfirmed" guarantee. - Backlog is bounded by the SD card. If the network is down for a long
time, segments accumulate at ~58 MB/day (at the measured frame rate). The
50 GB free buys well over a year.
CANLOG_MIN_FREE_MBlogs a warning when free space gets low — checkjournalctl -u canlog-uploadif you suspect a stuck uploader. Logging itself never stops. - Clock caveat (no RTC fitted). Timestamps are wall-clock (
CLOCK_REALTIME). On a network-less cold boot the clock is wrong until NTP syncs, then jumps. A segment spanning that jump is still lossless but contains one large forward time delta. When parsing, treat a non-monotonic inter-frame delta as a clock step, not a bus event — and if you need true wall-clock time for pre-jump frames, re-derive it by counting backward from the first post-jump frame.
Each .zst is one independent time-ordered segment in candump log format:
(1779730174.490373) can0 00E#B44121B800000000
i.e. (epoch.microseconds) interface ID#DATA. R after the data marks a
remote-transmission-request frame; an ID with no #data is a zero-length frame.
Filenames are session-<host>-YYYYMMDDTHHMMSSZ-<reason>.log.zst (UTC), and
because the timestamp is fixed-width they sort chronologically as plain
text — a glob expands in time order. With logs from several Pis in one place,
glob per host (session-goatrnet2-*.log.zst) so you merge one machine's
stream at a time; segments from different Pis are independent captures and
should not be interleaved.
Unlike a naive time-sliced log, these segments are not contiguous in time —
there are real gaps wherever the chair was powered off. The <reason> suffix
(see the Sessions table above) tells you which gaps are boundaries:
- A run of consecutive segments ending in
cont,cont, …, then oneidle(orstop) is one session. Concatenate that run to rebuild the session. - A gap after an
idle/stopsegment is the chair being off — expected. - A gap that is not preceded by an
idle/stopmarker means a segment is genuinely missing (failed upload, deleted file) — worth investigating.
Normally a cont segment is followed by more segments of the same session.
A cont that is the last file with no time-adjacent successor has two
possible causes:
- The session ended exactly at a
CAN_WINDOWboundary — the size cap fired, the segment closedcont, and the bus then went quiet before the next segment opened. Rare, harmless; the timestamp gap in the data confirms it. - The Pi lost power, or the rotator was
SIGKILLed, mid-session — there was no chance to write a closingidle/stopsegment, and the in-progress segment (in tmpfs) was lost with it. Bounded byCAN_WINDOW: at most the last ~5 minutes of that session.
A normal chair power-off does NOT produce this — it produces a clean idle
segment, because the Pi runs on its own independent power supply and keeps
logging (the bus simply goes quiet). So a cont-with-no-successor specifically
indicates the Pi went down, not the chair.
Topology assumption: the above holds while the Pi is on its own power supply, separate from the wheelchair. If the Pi is ever moved to an SMPS fed from the R-net 24 V bus, this distinction must be re-verified — measure whether the 24 V rail (and thus the Pi) stays energised when the chair is switched off. Only if it does will
idle(chair off) andcont-no-successor (power lost) remain distinguishable. We expect this to be true - but verify.
# Everything from one Pi -> one candump log (gaps between sessions included):
zstdcat session-goatrnet2-*.log.zst > merged.log
# A specific time range — filenames carry a UTC timestamp, so glob the window:
zstdcat session-goatrnet2-20260525T18*.log.zst > afternoon.log
# Just one session — glob its opening timestamp through the closing 'idle':
zstdcat session-goatrnet2-20260525T1854*.log.zst > one-session.logzstdcat is just zstd -dc. Because every segment is a complete, independent
stream, concatenating their decompressed output is all the "merging" needed —
there is no shared dictionary or cross-segment state to reconstruct.
# Wireshark/tshark can read the candump text log directly — no conversion needed.
# Decompress to a file and open it:
zstdcat session-goatrnet2-*.log.zst > merged.log # then File > Open in Wireshark
# or pipe straight in:
zstdcat session-goatrnet2-*.log.zst | wireshark -k -i -
# Replay onto a (virtual) CAN interface:
zstdcat session-goatrnet2-*.log.zst | canplayer -I - # needs vcan0/can0 up
# grep / awk a single CAN ID across the whole capture without unpacking to disk:
zstdcat session-goatrnet2-*.log.zst | grep ' 00E#'Note on Wireshark: Wireshark understands the candump log format natively — just open a decompressed
merged.log(or pipe it in). Nolog2pcapor pcap conversion step is required. For quick text inspection,> merged.logplusgrep/awkis often faster anyway.
zstd -t session-goatrnet2-*.log.zst # verifies every segment's checksum; silent = OKWithin a single session, frames are continuous. Across sessions there are real gaps (chair powered off) — those are expected, not errors. This scan flags clock jumps and reports gaps, which you then read against the segment boundaries (a gap at a session boundary is fine; one mid-session is not):
zstdcat session-goatrnet2-*.log.zst | awk '
{ t = $1; gsub(/[()]/,"",t); t += 0 }
NR>1 && t < prev { printf "clock step at line %d: %.6f -> %.6f\n", NR, prev, t }
NR>1 && t-prev > 1 { printf "gap at line %d: %.3f s\n", NR, t-prev }
{ prev = t }'A backward step is the NTP cold-boot jump described above. A large positive gap
is normally a session boundary (chair powered off) — cross-check it against the
segment filenames: if the gap falls between an idle/stop segment and the
next session it is expected; if it falls inside a run of cont segments,
a segment is missing.
The source tree is a standard Debian source package: debian/ directory plus
the files it installs. Building produces a single .deb you can drop on any
Pi (or other Debian-derivative system) running an arm64/armhf userland —
the package is architecture-all since everything in it is a script.
You don't have to build locally at all. The Build .deb workflow
(.github/workflows/build-deb.yml) does the whole thing on a GitHub runner
and hands you back the .deb as a downloadable artifact:
- Go to the repo's Actions tab → Build .deb → Run workflow.
- Pick the branch to build from (
mainor any feature branch — it's aworkflow_dispatch, so it runs on whatever ref you choose), then Run workflow. - When the run finishes (~40s), open it and download the
canlogger_[version]artifact from the Artifacts section. It's a zip containing the.deb(plus the.buildinfo/.changesmetadata).
That's the path to prefer for a release build — no Debian toolchain on your
machine required. Build locally (below) when you're iterating and want the
.deb in hand immediately.
Tooling (one-time, on whatever host you build from):
sudo apt install build-essential debhelper dpkg-dev pandoc lintianYou need a Debian/Ubuntu-style Linux for this — the Debian build tools
(dpkg-buildpackage, debhelper, lintian) don't exist on macOS or Windows,
and aren't straightforward to install there. In order of least friction:
- Build on the Pi itself. Zero setup: the Pi already runs Raspberry Pi OS.
apt installthe tooling above and build in place. dpkg-buildpackage on a Pi isn't fast in general, but this package is tiny (a few scripts, one pandoc invocation), so a full build takes only tens of seconds. Simplest option, and it puts the artifacts right where you'll test them. - Build in Docker on macOS.
docker run --rm -it -v "$PWD":/src debian:bookworm bash,apt updatethen install the build deps inside, cd to /src and rundpkg-buildpackage -us -uc -b. The package (canlogger_[version]_all.deb) will get built in.., so bring it into /src before you exit docker; that will make sure it's present on your mac when you're done. The package is architecture-all(pure scripts, no compiled code), so an Apple Silicon Mac building a Debian package for an ARM Pi is fine — no cross-compilation concerns. - Build on any Debian/Ubuntu box, then
scpthe.debto the Pi. Fastest if you already have such a machine handy.
From the directory containing this README (i.e. the package source root):
dpkg-buildpackage -us -uc -bFlags:
-us -uc— don't sign the source/changes (no GPG key required).-b— binary-only build; doesn't try to produce a source tarball.
Output lands in the parent directory (Debian convention):
../canlogger_0.1.0_all.deb
../canlogger_0.1.0_amd64.buildinfo
../canlogger_0.1.0_amd64.changes
The .deb is what you ship; the others are build metadata.
If you built on a separate machine, copy the .deb over first:
scp ../canlogger_0.1.5_all.deb pi@goatrnet.local:/tmp/
ssh pi@goatrnet.local
sudo apt install /tmp/canlogger_0.1.5_all.debIf you built on the Pi itself, the .deb is already there — just install
it directly:
sudo apt install ../canlogger_0.1.5_all.debUsing apt install (not dpkg -i) means apt will resolve and fetch any
missing runtime dependencies. On install you should see canlogger.service
get enabled and started; verify with systemctl status canlogger.service.
Bump the version in debian/changelog (use dch -i if you have devscripts
installed, or edit by hand following the existing entry's format) and rebuild.
Reinstalling the same version with apt install --reinstall also works for
quick iteration without bumping.
To clean build artifacts:
dh clean # or: rm -rf debian/canlogger debian/.debhelper debian/*.log debian/*.substvarsUse Dockerfile and compose to set up a virtual environment similar to rpi for development purposes.
rnet_meet_greet.py - interactive script to run on a new chair to learn it's parameters for future work. You need to be sshed into the rpi in order to run it and interact with it. The script generates 2 files:
rnet_meet_greet_profile.jsonwhich will be eventually be used as input to other scripts (like beeper script)rnet_meet_greet_report.txtwhich is a human readable info on what we were able to identify and can work with and what data wasn't recognized (for instance the horn wasn't registered, so we won't succeed running the beeper script)
direction_beeper.py makes the chair beep in a predefined pattern, changing the pattern between driving forward, backward, left and right.