A tiny end-to-end demo: two Raspberry Pi 4Bs exchanging short text messages over a Classic CAN bus at 1 Mbps, with a UAVCAN-style priority convention (arbitration ID = priority digit, lower = higher priority).
| Role | Hardware | Sends priorities |
|---|---|---|
| EULER | Waveshare RS485 CAN HAT (MCP2515) | P0, P3 |
| NEWTON | UCAN USB-C adapter (gs_usb) | P1, P2 |
node.py # plain implementation: raw python-can, 8-byte frames
node_pycyphal.py # pycyphal implementation: full UAVCAN/Cyphal v1 stack
priority_test.py # arbitration demo: EULER P3 vs NEWTON P2 + a P0 burst
ansible/provision.yml # one playbook: apt + overlay + can0 up + code sync + tmux helpers
ansible/inventory.yml # reads IPs/user from environment (populated from .env)
cantest # the only Mac-side script — your daily entry point
.env.example # template for local config (copy to .env and fill in)
NODE_IMPL in .env selects which messenger runs on the Pis:
NODE_IMPL |
Runs | Wire format | Extra deps installed |
|---|---|---|---|
plain |
node.py |
raw CAN frames, arbitration ID = priority | none beyond python3-can |
pycyphal |
node_pycyphal.py |
UAVCAN/Cyphal v1 over Classic CAN, uavcan.primitive.String.1.0 |
pycyphal venv + compiled DSDL |
Both Pis must run the same implementation — a Cyphal publisher and a raw-frame listener cannot interoperate. To switch:
$EDITOR .env # set NODE_IMPL=pycyphal (or plain)
./cantest up # provisions deps for the new mode (idempotent, no-op when unchanged)
./cantest stop && ./cantest startAll host-specific values live in a local-only .env (gitignored).
cp .env.example .env
$EDITOR .env # set REMOTE_USER, EULER_IP, NEWTON_IPThis project is tested and maintained for Ubuntu 24.04 LTS on Raspberry Pi 4B.
The Ansible playbook is specifically tailored to handle the apt package dependencies
and system configuration for this OS version.
brew install ansible tmux
ansible-galaxy collection install ansible.posix community.general
# Passwordless SSH to both Pis:
ssh-copy-id "$REMOTE_USER@$EULER_IP"
ssh-copy-id "$REMOTE_USER@$NEWTON_IP"
./cantest sudoless # one-time: enable passwordless sudo on both Pis
./cantest up # full provision (apt, overlays, can0 up, deploy code)EULER will reboot during the first up to activate the MCP2515 overlay.
Re-run ./cantest up afterwards if the run was interrupted.
./cantest start # launches node.py inside a tmux session on both Pis
./cantest monitor # split-pane local tmux — live output + type into either side
./cantest stop # kills the program on both PisIn monitor:
- Left pane = EULER (allowed
P0,P3) - Right pane = NEWTON (allowed
P1,P2) Ctrl-b oswitches between panes.Ctrl-b ddetaches the local view (programs keep running on the Pis).
Type into stdin:
##P TEXT
P— priority digit, must be in that node's allowed set (0,3on EULER;1,2on NEWTON)TEXT— alphanumeric, ≤ 6 characters (limit comes from a single Classic-CAN data field: see Frame size below)
Examples:
##0 HI # EULER, priority 0 (highest)
##3 BYE # EULER, priority 3
##1 HELLO # NEWTON
##2 OK # NEWTON
- TX lines (sender) show no timestamp:
TX P0 HI - RX lines (receiver) show a timestamp and the payload in cyan:
[12:34:56] RX P0 HI - Priority tag color: P0 red, P1 orange, P2 yellow, P3 lime.
./cantest sync # only push node.py changes (no apt, no reboot)
./cantest canup # only re-bring up can0 on both Pis
./cantest status # is each remote tmux session alive?
./cantest logs # split-pane tail -F of both Pis (read-only)
./cantest logs euler # just one Pi's log
./cantest send euler "##0 HI" # inject one line remotely without attaching
./cantest monitor --rc # restart can0 on both Pis before attaching
./cantest deploy-test # push priority_test.py to both Pis + reset can0
./cantest priority-test # run the arbitration test (no deploy)priority_test.py is a small standalone script that demonstrates how lower
arbitration IDs win the bus.
Sequence:
- EULER starts sending
P3frames every 50 ms. - After 5 s, NEWTON starts sending
P2frames every 50 ms (P2 wins arbitration over P3, so EULER's TX rate visibly drops). - After EULER receives 20
P2frames, it sends a singleP0frame. - NEWTON ends as soon as it sees that
P0(highest priority).
Run it:
./cantest deploy-test # one-time per script change: scp + restart can0
./cantest priority-test # run the test; output from both Pis is interleavedOutput is interleaved live from both nodes. Sent: lines are local TX,
Recv: lines are RX. Note that the NEWTON payload prints as NEWTON_P
because Classical CAN caps the data field at 8 bytes (see Frame size).
Three wires between the EULER HAT screw terminals and the NEWTON UCAN screw terminals:
| EULER (RS485 CAN HAT) | NEWTON (UCAN USB-C) |
|---|---|
CAN_H |
CAN_H |
CAN_L |
CAN_L |
GND |
GND |
Termination: 120 Ω at each end (jumper on the HAT, switch/jumper on the UCAN). Bus speed: 1 Mbps.
The TEXT field is capped at 6 alphanumeric characters in node.py, but the
hard physical limit is the CAN data field:
| Protocol | Max payload per frame |
|---|---|
| Classic CAN (this project) | 8 bytes |
| CAN FD | up to 64 bytes |
The MCP2515 chip on the Waveshare HAT only speaks Classic CAN, so 8 bytes is the ceiling here regardless of what the UCAN adapter would otherwise support. To send anything longer than 8 bytes you'd have to fragment in software (this is what UAVCAN/Cyphal's transfer protocol does on top of Classic CAN).
ssh "$REMOTE_USER@$EULER_IP" "candump can0" # listener
ssh "$REMOTE_USER@$NEWTON_IP" "cansend can0 123#DEADBEEF" # senderThe listener should print can0 123 [4] DE AD BE EF.
mcp251x ... didn't enter in conf mode after reset / err=-110on EULER — the MCP2515 SPI clock is too fast for the HAT's traces. The playbook already setsspimaxfrequency=2000000. If you still see this, do a cold power cycle of EULER (full power off ≥ 10 s, then back on); a warm reboot is not enough.can0missing on NEWTON — the UCAN adapter isn't enumerating. Try a different USB port or a known-good USB-C data cable, then checklsusb | grep -i candleanddmesg | grep gs_usb.- Frames don't cross the bus — termination jumper missing on one end,
or
CAN_H/CAN_Lswapped. can0isBUS-OFF/ERROR-PASSIVE(check withip -details -statistics link show can0) — almost always a physical- layer problem: missing/incorrect 120 Ω termination, swapped H/L, or no shared GND. After fixing the wiring, restart the interface on both Pis with./cantest monitor --rc(or manuallysudo ip link set can0 down && sudo ip link set can0 up type can bitrate 1000000).OSError: [Errno 105] No buffer space availablewhen sending on Cyphal — the controller is inBUS-OFF/ERROR-PASSIVE, see above.OSError: [Errno 22] Invalid argumentwhen sending — payload is longer than 8 bytes. Classical CAN's data field is capped at 8 bytes; truncate or pad before callingbus.send()../cantest upfails on Ubuntu 24.04 with "held broken packages" — re-run with./cantest up --tags bootstrap. The playbook installsbuild-essentialand its known-conflicting dependencies (bzip2,zlib1g-dev) in a separate transaction first to avoid this../cantest upkeeps prompting for sudo —./cantest sudolesswas skipped or didn't take. Re-run it.- Wi-Fi stuck
DORMANTafter a router/SSID change — the saved netplan credentials no longer match. SSH in over Ethernet (use macOS Internet Sharing if the Pi has no network), edit/etc/netplan/50-cloud-init.yaml, thensudo netplan apply.

