← All posts

ArbyBot Development Log #1 - Building a CAN protocol with an Arduino & an MCP2515

On this page

Introduction

In this post, I’ll present my initial concept for building a communication protocol over CAN for a newer and improved ArbyBot. ArbyBot is a DIY autonomous robot using consumer-grade, off-the-shelf components, including Raspberry Pis and Arduino boards, alongside custom components that I have designed myself. The communication protocol is called ArbyBot CAN (ABC), built atop CAN 2.0B.

The purpose for designing the protocol from the ground up is both as a learning exercise and to have control over the whole protocol.

Acronyms

Acronyms utilised are presented (Table 1).

Acronym Definition
ABC ArbyBot CAN
ARM Advanced RISC Machine
AWG American Wire Gauge
CAN Controller Area Network
CAN-FD CAN Flexible Data Rate
CAN-XL CAN Extra Long
DFU Device Firmware Update/Upgrade
DIY Do-It-Yourself
ID Identifier
IDE Integrated Development Environment
IP Internet Protocol
LED Light Emitting Diode
LSB Least Significant Bit
MSB Most Significant Bit
OSI Open Systems Interconnection
P2P Peer-to-peer
RISC Reduced Instruction Set Computing
SBC Single Board Computer
SPI Serial Peripheral Interface
SSH Secure Shell
STM32 STMicroelectronics 32-bit
TCP Transmission Control Protocol
UART Universal Asynchronous Receiver-Transmitter
USB Universal Serial Bus
WIP Work-In-Progress

Table 1. Acronym definitions.

What is CAN?

Controller Area Network (CAN) is a communication protocol commonly used by embedded devices in vehicles and industrial equipment. A CAN network comprises two or more devices connected over a shared pair of signal wires, CAN-H and CAN-L. CAN does not require a central controller to operate; any node can initiate a transmission. Frames are broadcast across the bus, with each node selecting which to process. Devices can communicate directly without routing messages through a central device.

The CAN protocol provides an arbitration ID and a data payload, both of which can be configured by the transmitting device for use by a higher-level protocol. There are multiple variations of the CAN protocol (CAN 2.0B, CAN FD and CAN XL). CAN 2.0B is the one supported by the MCP2515. Within CAN 2.0B, the arbitration ID can be 11 bits or 29 bits, and the payload is restricted to a maximum of 8 bytes. Both the arbitration ID and the payload segment can be included in the higher-level protocol design.

Goal & Scope

The goal is to define a simple, lightweight communication protocol for the SBCs and microcontrollers used within ArbyBot.

The protocol should meet the following requirements:

  • Support at least 30 uniquely addressed devices.
  • Allow any device to initiate communication with another device without a central application controller.
  • Support both individually addressed and broadcast messages.
  • Carry sensor measurements and motor-control commands.
  • Support messages spanning multiple CAN frames.
  • Be implementable within the memory and processing limits of an Arduino Uno.
  • Support bidirectional communication over CAN between an Arduino Uno equipped with an MCP2515 module and an x86-64 computer running Linux with a SocketCAN-compatible CAN adapter.
  • Meet the communication deadlines established for the robot’s control functions (future work).

The intended platforms include Arduino Uno, Arduino Mega 2560, Raspberry Pi and other ARM SBCs, and STM32-based boards. The communication hardware and wiring should fit within the space available on ArbyBot.

This article introduces the proposed message format and demonstrates CAN communication between an Arduino and a host computer. The example covers transmission of heartbeat messages. Reassembly and higher-level protocol support remain future work.

Hardware & Prerequisites

The equipment list is presented:

  • Arduino Uno R3.
  • MCP2515 module.
  • USB-to-CAN device (SH-C31G).
  • Host computer (assumed to be running Fedora Linux on x86-64).
  • 2 x USB-A to USB-B cables.
  • 8 x male-to-female jumper wires.
  • 2 x wires (roughly 24 AWG) with stripped ends for the CAN-H and CAN-L connections between the MCP2515 module and SH-C31G adapter.

The proposed hardware list reflects what I have available to me now and what the software implementation is designed to support at this stage. I will explore using the MCP2518FD board in future work to make use of larger frame payloads.

For connecting the hardware, a wiring diagram is presented:

CAN bus wiring diagram featuring the MCP2515, Arduino Uno R3 & SH-C31G

Figure 1. CAN bus wiring diagram featuring the MCP2515, Arduino Uno R3 & SH-C31G.

For ease of reading, a pin-to-pin connection table is presented (Table 2).

Device A Pin A Device B Pin B
Arduino Uno R3 2 MCP2515 INT
Arduino Uno R3 13 MCP2515 SCK
Arduino Uno R3 11 MCP2515 SI
Arduino Uno R3 12 MCP2515 SO
Arduino Uno R3 10 MCP2515 CS
Arduino Uno R3 GND MCP2515 GND
Arduino Uno R3 5V MCP2515 VCC
MCP2515 CAN-H SH-C31G CAN-H
MCP2515 CAN-L SH-C31G CAN-L
SH-C31G CAN-GND Arduino Uno R3 GND

Table 2. Pin-to-pin connection list.

Both the SH-C31G and Arduino Uno should be connected to USB ports on the host computer.

Protocol Design

The CAN 2.0B protocol provides the physical and data-link foundations for communication, corresponding to the first two layers of the Open Systems Interconnection (OSI) model. The ABC protocol builds on CAN by defining device addressing, message fragmentation, and payload protocol selection.

The CAN frame exposes two primary binary data segments: the arbitration ID and the data payload. The addressing and message-handling layer will use only the arbitration ID for its metadata. An extended arbitration ID of 29 bits will be used instead of the standard 11-bit identifier.

The ABC protocol builds upon the two-layer CAN protocol by describing an adaptation layer that can be used to support additional protocols held within the payload data segment. A placeholder protocol will be defined to test the transport: a zeroed-out payload communicating a heartbeat or signal of life. Future work will determine how to transport Internet Protocol (IP) packets across the CAN bus.

The protocol layers are listed (Table 3).

Layer Responsibility Implementation
1. Physical - OSI layer 1 Electrical signalling, bit timing, wiring and termination. CAN transceivers, CAN-H/CAN-L wiring and terminating resistors; the MCP2515 handles bit timing and synchronisation.
2. CAN data link - OSI layer 2 CAN frame structure, identifiers, arbitration, acknowledgements and error handling. The MCP2515 CAN controller.
3. ABC - Addressing and message handling Identify senders and destinations, select the payload protocol, and split messages into packets with metadata for reassembly. The arbitration-ID format, ID encoders/decoders and fragmentation logic. Receiver-side reassembly remains future work.
4. Application payload Define the meaning and binary encoding of message contents: command codes, parameters and sensor values, including their data types, byte order and units. Interpret received messages using the selected payload protocol. Arduino and Python payload encoders, decoders and command handlers. The initial convention is a heartbeat of eight zero bytes under protocol 0; sensor and motor-command formats remain to be defined.

Table 3. The layers of CAN 2.0B, ABC and the Application Payload.

A flowchart is presented (Figure 2) showing a visualisation of the proposed protocol stack.

---
config:
    flowchart:
        wrappingWidth: 1000
        rankSpacing: 32
        padding: 10
        subGraphTitleMargin:
            top: 6
            bottom: 16
---
flowchart TB
    accTitle: Four-layer CAN communication stack
    accDescr: A diagram showing the four layers of the proposed communication stack.

    subgraph Custom["<span style='white-space:nowrap'>Proposed Communication Stack</span>"]
        direction TB
        L4["<div style='width:800px'>4. Application payload - Heartbeats, sensor data, motor commands</div>"]
        L3["<div style='width:800px'>3. ArbyBot CAN - Addressing and message fragmentation</div>"]
        L4 --> L3
    end
    subgraph CAN["<span style='white-space:nowrap'>Controller Area Network (CAN 2.0B)</span>"]
        direction TB
        L2["<div style='width:800px'>2. CAN data link - Frames, arbitration, error handling</div>"]
        L1["<div style='width:800px'>1. Physical - Signalling, CAN-H/CAN-L, termination</div>"]
        L2 --> L1
    end

    Custom --> CAN

    style Custom fill:none,stroke:#93a1a1,stroke-width:1px,stroke-dasharray:2 4
    style CAN fill:none,stroke:#93a1a1,stroke-width:1px,stroke-dasharray:2 4
    classDef layer stroke:#93a1a1,color:#586e75
    class L1,L2,L3,L4 layer
    style L4 fill:#eee7f1
    style L3 fill:#e4edf0
    style L2 fill:#e7eedf
    style L1 fill:#f4e6d5

Figure 2. Protocol layer flowchart.

Arbitration ID Format

The arbitration ID identifies the intended recipient, the sender, the payload protocol and the message priority. Lower priority values take precedence during arbitration.

The term packet will be used in place of CAN frame. A message comprises one or more packets sharing the same sender, destination, protocol and message counter. Each message carries application data that the receiver reconstructs from its constituent packets.

The packet index increments modulo 32 within a message, allowing the receiver to check the expected sequence. The first and last flags identify the message boundaries. The message counter remains constant throughout a message and increments modulo 8 for each new message. Packets belonging to a message must be transmitted in sequence, beginning with the first packet. A sender must not reuse a message counter while packets from its previous use remain pending for transmission. Upon receipt of a first packet, the receiver should discard any incomplete message with the same sender ID, target ID, protocol and message counter, and begin assembling the new message.

The proposed arbitration ID format is presented (Table 4). Bit offsets count from zero at the most significant bit (MSB). Bit positions count from zero at the least significant bit (LSB), matching the positions used in the software.

Bit offset Bit position Size Name Data Validation Description
0-1 28-27 2 bits priority Integer: 0-3. The message priority determines collision arbitration on the network, wherein a lower value takes precedence.
2-4 26-24 3 bits protocol Integer: 0-7. The payload protocol informs the recipient what the data or protocol looks like within the payload body.
5-11 23-17 7 bits target_id Integer: 0-127. The destination of the packet informs recipients whether to discard a message. There are 128 possible address values, with 0 reserved for broadcast to all devices.
12-18 16-10 7 bits sender_id Integer: 1-127. The sender ID identifies the origin of the packet and is limited to 127 devices due to the reservation of the 0 value.
19-21 9-7 3 bits message_counter Integer: 0-7. The message counter informs the receiver which packets belong to the same message and increments for each message sent. The counter retains the same value when the packet index wraps around.
22-26 6-2 5 bits packet_index Integer: 0-31. The packet index identifies which part of a message the payload belongs to and the expected sequence in which the packets are to be reassembled. This value wraps around for messages which are longer than 32 packets.
27 1 1 bit is_first_packet Integer: 0-1 treated as a boolean. The first packet signifies the starting packet of a message, sent by a transmitter.
28 0 1 bit is_last_packet Integer: 0-1 treated as a boolean. The last packet signifies the final packet of a message, sent by a transmitter. If all packets were received in order from the first packet to the last packet, the message can be reconstructed by the receiver; otherwise, it can be discarded.

Table 4. Proposed Arbitration ID format.

A packet diagram visualising the fields of the Arbitration ID in order is presented (Figure 3), written with the MSB first.

---
title: ABC Arbitration ID — 29 bits
config:
    packet:
        bitsPerRow: 29
        bitWidth: 32
        showBits: true
---
packet
    accTitle: Fields of the 29-bit ABC arbitration ID
    accDescr: An ABC arbitration ID consisting of a priority, protocol identifier, target device ID, sender device ID, message counter, packet index and first/last bits.
    0-1: "Priority"
    2-4: "Protocol"
    5-11: "Target ID"
    12-18: "Sender ID"
    19-21: "Counter"
    22-26: "Index"
    27: "F"
    28: "L"

Figure 3. Arbitration ID Packet Diagram.

A single-packet message sets both the first and last flags. The receiver should discard an incomplete message if it detects an unexpected packet index or a reassembly timeout. There exists an edge case for message completeness in which the loss of 32 consecutive packets within a message could go undetected by the receiver, because the packet index wraps around to the expected value. If no other check detects the loss, the receiver could reassemble and accept the message with missing data.

The proposed protocol was based on the open source CubeSat Space Protocol v2 or libcsp. Changes include:

  • destination addresses limited to 7 bits (0-127).
  • sender addresses limited to 7 bits (1-127).
  • destination 0 designated as broadcast to all listeners, with sender 0 prohibited.
  • packet_count and frame_count were renamed to message_counter and packet_index with extra bits added for increased packet loss detection.

Test Case

The first test case of the proposed protocol will verify reception of broadcast heartbeats. A heartbeat will be defined in this test as 8 bytes of zeroed data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00].

  • Each device will independently broadcast a heartbeat every two seconds while listening for incoming packets.
  • Each device will log heartbeats that it hears to the console for inspection.
  • Each device will listen to any sender ID emitting to target ID 0 (broadcast).
  • Each device will transmit the heartbeat using target ID 0 (broadcast) and its sender ID defined as:
    • Host computer: 27.
    • Arduino: 49.

The Arduino’s log output will be visible in the serial monitor (within the Arduino IDE), and the host’s output will be visible in the terminal running the Python client.

A sequence diagram is presented, showing the pattern of communication between the Arduino and the host computer (Figure 4). Time runs downwards. Each arrow represents a broadcast packet observed by the other node; the parallel sections represent independent tasks.

sequenceDiagram
    accTitle: Independent CAN heartbeats
    accDescr: The Arduino and Linux host independently broadcast heartbeats every two seconds and log received heartbeats. Neither heartbeat is a reply.
    participant A as Arduino Uno<br/>ID 49
    participant H as Linux host<br/>ID 27

    Note over A,H: Independent timers<br/>Shared CAN bus

    par Arduino heartbeat task
        loop Every 2 seconds
            A->>H: Broadcast heartbeat<br/>Sender 49 · eight zero bytes
            Note over H: Log received<br/>heartbeat
        end
    and Host heartbeat task
        loop Every 2 seconds
            H->>A: Broadcast heartbeat<br/>Sender 27 · eight zero bytes
            Note over A: Log received<br/>heartbeat
        end
    end

    Note over A,H: One packet on the bus at a time.<br/>Simultaneous attempts<br/>are resolved by arbitration.

Figure 4. Heartbeat test sequence diagram.

Both heartbeat tasks will run concurrently, but packets will occupy the shared CAN bus sequentially. If the devices attempt to transmit at the same time, CAN arbitration will determine which transmission proceeds first.

Procedure and success criteria

  1. Configure and bring up the host’s can0 interface at 125 kbit/s, then start the Arduino and Python client.
  2. Open the Arduino serial monitor at 115200 baud and observe the Python terminal alongside it.
  3. Once both devices are running, let the test continue for at least 30 seconds to exercise message-counter wraparound.
  4. Confirm that both consoles repeatedly log heartbeats from the other device, approximately two seconds apart, while both devices continue sending their own heartbeats.
  5. Check each sender’s received counter sequence for progression from 0 through 7 and back to 0. Compare available transmission and reception logs, and record any gaps, timing anomalies, or reported errors. A transmit-side log alone is not proof that the other device received the packet.

This test will demonstrate bidirectional single-packet communication under the test conditions. It will not establish delivery guarantees or latency limits under heavier bus traffic.

Implementation

A set of test scripts was developed to demonstrate the proposed protocol using the Arduino Uno R3, MCP2515 CAN module, and SH-C31G adapter. The following section describes the experimental setup and the scripts used to exercise the protocol.

The instructions are provided “as is” as a rough guide to configuring this setup. No warranty is provided, and no guarantee is made that these instructions are correct or will be maintained. This is purely for informational purposes.

Hardware

The MCP2515, Arduino Uno R3 and SH-C31G USB-to-CAN interface should be connected as described in Table 2 (illustrated in Figure 1). The Arduino Uno R3 and SH-C31G should be connected via their USB-B ports to USB ports on the host computer.

Check that the Arduino’s green power LED and the MCP2515 module’s red power LED are illuminated.

Ensure that the SH-C31G has not booted into DFU mode. On the adapter used here, normal operation is indicated by one red LED and two green LEDs. If only the red LED is illuminated, unplug and reconnect the USB cable, then check the indicators again.

Software

The software provided is a work-in-progress (WIP) implementation and therefore incomplete at this stage, but is sufficient for demonstrating the functionality of transmitting and receiving heartbeats between the Arduino and host computer.

Arduino

Configure the autowp/arduino-mcp2515 library for use within the Arduino IDE. Once you have confirmed that the Arduino is connected to the Arduino IDE, upload the Arduino script (Code Snippet 1). Set the serial monitor baud rate to 115200 to view its output. An 8 MHz oscillator is assumed by the provided software, update as required.

arduino_can_heartbeat.inoDownload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
#include <SPI.h>
#include <mcp2515.h>
#include <stdio.h>
#include <string.h>

// Uno R3 + MCP2515: CS=10, 8 MHz crystal, classic CAN at 125 kbit/s.
MCP2515 canController(10, 4000000);
const uint8_t DEVICE_ID = 49;
const unsigned long HEARTBEAT_MS = 2000;
const uint8_t LOG_CAPACITY = 16;
const uint8_t TXREQ0_STATUS = 0x04; // MCP2515 READ_STATUS: TXB0 pending.

struct PacketLog {
struct can_frame packet;
bool outgoing;
};
PacketLog logQueue[LOG_CAPACITY];
uint8_t logHead = 0, logTail = 0, logCount = 0;
uint16_t droppedLogs = 0;
bool hardwareOverflow = false, heartbeatProblem = false;
char logLine[224];
size_t logLength = 0, logPosition = 0;
uint8_t messageCounter = 0;
unsigned long lastHeartbeat = 0;
bool ready = false;

uint32_t encodeId(uint8_t priority, uint8_t protocol, uint8_t target,
uint8_t sender, uint8_t counter, uint8_t index,
bool first, bool last) {
return ((uint32_t)priority << 27) // 2 bits: 28-27
| ((uint32_t)protocol << 24) // 3 bits: 26-24
| ((uint32_t)target << 17) // 7 bits: 23-17
| ((uint32_t)sender << 10) // 7 bits: 16-10
| ((uint32_t)counter << 7) // 3 bits: 9-7
| ((uint32_t)index << 2) // 5 bits: 6-2
| (first ? 2UL : 0UL) // 1 bit: 1
| (last ? 1UL : 0UL); // 1 bit: 0
}

void queuePacketLog(const struct can_frame &packet, bool outgoing) {
if (logCount == LOG_CAPACITY) {
if (droppedLogs != 65535) ++droppedLogs;
return;
}
logQueue[logTail].packet = packet;
logQueue[logTail].outgoing = outgoing;
logTail = (logTail + 1) % LOG_CAPACITY;
++logCount;
}

void queueReceivedPacket(const struct can_frame &packet) {
if (!(packet.can_id & CAN_EFF_FLAG)
|| (packet.can_id & (CAN_RTR_FLAG | CAN_ERR_FLAG))
|| packet.can_dlc > 8) return;

const uint8_t target = (packet.can_id >> 17) & 0x7F;
if (target != DEVICE_ID && target != 0) return;

const uint8_t sender = (packet.can_id >> 10) & 0x7F;
if (sender == 0) return; // Reserved: only target ID 0 means broadcast.

const uint8_t index = (packet.can_id >> 2) & 0x1F;
if ((packet.can_id & 2) && index != 0) return;

queuePacketLog(packet, false);
}

void pollReception() {
if (canController.getErrorFlags()
& (MCP2515::EFLG_RX0OVR | MCP2515::EFLG_RX1OVR)) {
hardwareOverflow = true;
canController.clearRXnOVRFlags();
}

// Read both buffers from one snapshot, rather than always preferring RXB0.
const uint8_t pending = canController.getInterrupts();
struct can_frame packet;
if ((pending & MCP2515::CANINTF_RX0IF)
&& canController.readMessage(MCP2515::RXB0, &packet) == MCP2515::ERROR_OK) {
queueReceivedPacket(packet);
}
if ((pending & MCP2515::CANINTF_RX1IF)
&& canController.readMessage(MCP2515::RXB1, &packet) == MCP2515::ERROR_OK) {
queueReceivedPacket(packet);
}
}

void pollHeartbeat() {
const unsigned long now = millis();
if (now - lastHeartbeat < HEARTBEAT_MS) return;
lastHeartbeat = now;

// Do not overwrite a frame still awaiting arbitration/ACK. Never wait here.
if ((canController.getStatus() & TXREQ0_STATUS)
|| (canController.getErrorFlags() & MCP2515::EFLG_TXBO)) {
heartbeatProblem = true;
return;
}

struct can_frame heartbeat = {}; // Zero-initialize all payload bytes.
heartbeat.can_id = CAN_EFF_FLAG | encodeId(
3, 0, 0, DEVICE_ID, messageCounter, 0, true, true);
heartbeat.can_dlc = 8; // Transmit the full eight-byte zero payload.
messageCounter = (messageCounter + 1) % 8;
canController.clearTXInterrupts();
if (canController.sendMessage(MCP2515::TXB0, &heartbeat) != MCP2515::ERROR_OK) {
heartbeatProblem = true;
}
else {
// The controller accepted a transmit request; completion is not confirmed.
queuePacketLog(heartbeat, true);
}
}

void prepareLogLine() {
if (logCount) {
const PacketLog entry = logQueue[logHead];
const struct can_frame &packet = entry.packet;
logHead = (logHead + 1) % LOG_CAPACITY;
--logCount;
const uint32_t id = packet.can_id & CAN_EFF_MASK;
// Explicit unsigned casts match printf's %u on AVR.
logLength = snprintf(logLine, sizeof(logLine),
"%s: priority=%u protocol=%u target_id=%u sender_id=%u "
"message_counter=%u packet_index=%u is_first_packet=%u is_last_packet=%u payload=[",
entry.outgoing ? "QUEUED" : "RECV",
(unsigned)((id >> 27) & 3), (unsigned)((id >> 24) & 7),
(unsigned)((id >> 17) & 127), (unsigned)((id >> 10) & 127),
(unsigned)((id >> 7) & 7), (unsigned)((id >> 2) & 31),
(unsigned)((id >> 1) & 1), (unsigned)(id & 1));
const char hex[] = "0123456789ABCDEF";
for (uint8_t i = 0; i < packet.can_dlc; ++i) {
if (i) logLine[logLength++] = ' ';
logLine[logLength++] = hex[packet.data[i] >> 4];
logLine[logLength++] = hex[packet.data[i] & 15];
}
logLine[logLength++] = ']';
logLine[logLength++] = '\r';
logLine[logLength++] = '\n';
} else if (droppedLogs) {
logLength = snprintf(logLine, sizeof(logLine),
"Serial log queue full: %u packet logs dropped\r\n", (unsigned)droppedLogs);
droppedLogs = 0;
} else if (hardwareOverflow) {
strcpy(logLine, "MCP2515 receive overflow: packets lost\r\n");
logLength = strlen(logLine);
hardwareOverflow = false;
} else if (heartbeatProblem) {
strcpy(logLine, "Heartbeat pending/failed; check CAN peer and bus\r\n");
logLength = strlen(logLine);
heartbeatProblem = false;
} else {
logLength = 0;
}
logPosition = 0;
}

void pollLogging() {
if (logPosition == logLength) prepareLogLine();
const int available = Serial.availableForWrite();
if (available <= 0) return;
size_t count = logLength - logPosition;
if (count > (size_t)available) count = available;
if (count) {
// Only enqueue what fits: never wait for serial transmission.
logPosition += Serial.write(
(const uint8_t *)logLine + logPosition, count);
}
}

void setup() {
Serial.begin(115200);
SPI.begin();
ready = canController.reset() == MCP2515::ERROR_OK
&& canController.setBitrate(CAN_125KBPS, MCP_8MHZ) == MCP2515::ERROR_OK
&& canController.setNormalMode() == MCP2515::ERROR_OK;
if (ready) Serial.println(F("Device 49: classic CAN logger + 2s heartbeat"));
else Serial.println(F("CAN initialization failed; reset Arduino"));
lastHeartbeat = millis();
}

void loop() {
if (ready) {
pollReception();
pollHeartbeat();
}
pollLogging();
}

Code Snippet 1. Arduino MCP2515 heartbeat sketch.

Both received-packet and outgoing-packet logs use the same bounded queue, drained only when serial buffer space is available. QUEUED indicates that the MCP2515 accepted a transmission request; it does not confirm transmission completion or reception by another device.

To determine that it is working, monitor the serial output and compare it with the output recorded in the demonstration section.

USB-to-CAN (SH-C31G)

For the host computer to communicate with the USB-to-CAN interface, SocketCAN must be configured and the CAN interface brought online:

1. Install the utilities

Open a terminal and use dnf to install iproute and can-utils on the host computer running Fedora Linux:

shell
1
sudo dnf install iproute can-utils

2. Connect the USB CAN adapter and identify its interface

shell
1
ip -brief link

An interface named can0 should be listed, indicating that Linux has recognised the CAN adapter. It may initially be shown as DOWN.

3. Configure the bitrate and enable the interface

shell
1
2
3
sudo ip link set can0 down
sudo ip link set can0 type can bitrate 125000
sudo ip link set can0 up

4. Check the configuration and listen for traffic

shell
1
2
ip -details -statistics link show can0
candump can0

Check that the interface is UP and reports bitrate 125000. If the Arduino has already been configured, you should see CAN frames appearing in the terminal. More information can be found in the can-utils software documentation. These settings need reapplying after a reboot or disconnect unless you configure persistence.

Python Client

The Python client comprises two files: gateway.py and main.py. The Gateway provides a class with various useful methods for sending and receiving data via the USB-to-CAN interface using the python-can library. The main.py script is the entry point to the program, which starts the loop for sending the heartbeat via the Gateway.

The file gateway.py is provided:

gateway.pyDownload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
import can
import asyncio
from dataclasses import dataclass

@dataclass(frozen=True)
class ReceivedMessage:
priority: int
protocol: int
target_id: int
sender_id: int
message_counter: int
payload: bytes


class Subscription:
def __init__(
self,
*,
gateway: "Gateway",
protocol: int | None,
sender_id: int | None,
target_id: int | None,
queue_size: int,
) -> None:
self.gateway = gateway
self.protocol = protocol
self.sender_id = sender_id
self.target_id = target_id

self.queue: asyncio.Queue[ReceivedMessage] = asyncio.Queue(
maxsize=queue_size,
)
self.dropped_messages = 0

def matches(self, message: ReceivedMessage) -> bool:
return (
(self.protocol is None or self.protocol == message.protocol)
and (self.sender_id is None or self.sender_id == message.sender_id)
and (self.target_id is None or self.target_id == message.target_id)
)

async def receive(self) -> ReceivedMessage:
return await self.queue.get()

def close(self) -> None:
self.gateway.unsubscribe(self)

async def __aenter__(self) -> "Subscription":
return self

async def __aexit__(self, exc_type, exc_value, traceback) -> None:
self.close()

class Gateway:

def __init__(self, channel: str):
self._message_counter = 0
self._bus = can.Bus(
interface="socketcan",
channel=channel,
ignore_config=True,
fd=False,
receive_own_messages=False,
)

self._notifier: can.Notifier | None = None
self._subscriptions: set[Subscription] = set()

@staticmethod
def encode_arbitration_id(
*,
priority: int = 3,
protocol: int,
target_id: int,
sender_id: int,
message_counter: int,
packet_index: int,
is_first_packet: bool,
is_last_packet: bool,
) -> int:

if not isinstance(priority, int):
raise TypeError("Priority must be an integer")
if priority < 0 or priority > 3:
raise ValueError("Priority must be between 0 and 3")

if not isinstance(target_id, int):
raise TypeError("Target ID must be an integer")
if target_id < 0 or target_id > 127:
raise ValueError("Target ID must be between 0 and 127")

if not isinstance(sender_id, int):
raise TypeError("Sender ID must be an integer")
if sender_id < 1 or sender_id > 127:
raise ValueError("Sender ID must be between 1 and 127")

if not isinstance(protocol, int):
raise TypeError("Protocol must be an integer")
if protocol < 0 or protocol > 7:
raise ValueError("Protocol must be between 0 and 7")

if not isinstance(message_counter, int):
raise TypeError("Transfer counter must be an integer")
if message_counter < 0 or message_counter > 7:
raise ValueError("Transfer counter must be between 0 and 7")

if not isinstance(packet_index, int):
raise TypeError("Fragment index must be an integer")
if packet_index < 0 or packet_index > 31:
raise ValueError("Fragment index must be between 0 and 31")

if not isinstance(is_first_packet, bool):
raise TypeError("is_first_packet must be a bool")
if not isinstance(is_last_packet, bool):
raise TypeError("is_last_packet must be a bool")

if is_first_packet and packet_index != 0:
raise ValueError("The first packet must have index 0")

return (
(priority << 27) # 2 bits: 28–27
| (protocol << 24) # 3 bits: 26–24
| (target_id << 17) # 7 bits: 23–17
| (sender_id << 10) # 7 bits: 16–10
| (message_counter << 7) # 3 bits: 9–7
| (packet_index << 2) # 5 bits: 6–2
| (int(is_first_packet) << 1) # 1 bit: 1
| int(is_last_packet) # 1 bit: 0
)

async def send_message(
self,
target_id: int,
sender_id: int,
protocol: int,
payload: bytes,
priority: int = 3,
) -> None:
if not isinstance(payload, bytes):
raise TypeError("Payload must be bytes")
if not payload:
raise ValueError("Payload must not be empty")

packets: list[can.Message] = []
for i in range(0, len(payload), 8):
data = payload[i : i + 8]
arbitration_id = self.encode_arbitration_id(
priority=priority,
target_id=target_id,
sender_id=sender_id,
protocol=protocol,
message_counter=self._message_counter,
packet_index=(i // 8) % 32,
is_first_packet=i == 0,
is_last_packet=i + 8 >= len(payload),
)
packets.append(
can.Message(
arbitration_id=arbitration_id,
is_extended_id=True,
is_fd=False,
data=data,
check=True,
)
)

self._message_counter = (self._message_counter + 1) % 8
for packet in packets:
self._bus.send(packet, timeout=1.0)

def subscribe(
self,
*,
protocol: int | None = None,
sender_id: int | None = None,
target_id: int | None = None,
queue_size: int = 100,
) -> Subscription:
if not isinstance(queue_size, int):
raise TypeError("Queue size must be an integer")
if queue_size < 1:
raise ValueError("Queue size must be at least 1")

subscription = Subscription(
gateway=self,
protocol=protocol,
sender_id=sender_id,
target_id=target_id,
queue_size=queue_size,
)

self._subscriptions.add(subscription)
return subscription

def unsubscribe(self, subscription: Subscription) -> None:
self._subscriptions.discard(subscription)

def _publish_message(self, message: ReceivedMessage) -> None:
for subscription in self._subscriptions:
if not subscription.matches(message):
continue

try:
subscription.queue.put_nowait(message)
except asyncio.QueueFull:
# Explicit policy: drop this new message for this subscriber.
subscription.dropped_messages += 1

def shutdown(self) -> None:
if self._notifier is not None:
self._notifier.stop()
self._notifier = None

self._bus.shutdown()

def start_receiving(self) -> None:
if self._notifier is not None:
return

self._notifier = can.Notifier(
self._bus,
[self._receive_packet],
loop=asyncio.get_running_loop(),
)

def _receive_packet(self, packet: can.Message) -> None:
if (
not packet.is_extended_id
or packet.is_remote_frame
or packet.is_error_frame
or packet.is_fd
):
return

arbitration_id = packet.arbitration_id
sender_id = (arbitration_id >> 10) & 0x7F
if sender_id == 0:
return # Reserved: only target ID 0 means broadcast.

packet_index = (arbitration_id >> 2) & 0x1F
is_first_packet = bool(arbitration_id & 2)
is_last_packet = bool(arbitration_id & 1)
if is_first_packet and packet_index != 0:
return

# Log every valid received packet, including individual fragments.
print(
f"RECV: priority={(arbitration_id >> 27) & 0x03} "
f"protocol={(arbitration_id >> 24) & 0x07} "
f"target_id={(arbitration_id >> 17) & 0x7F} "
f"sender_id={sender_id} "
f"message_counter={(arbitration_id >> 7) & 0x07} "
f"packet_index={packet_index} "
f"is_first_packet={int(is_first_packet)} "
f"is_last_packet={int(is_last_packet)} "
f"payload=[{bytes(packet.data).hex(' ').upper()}]"
)

# For now, handle complete single-packet messages such as heartbeats.
# Multi-packet messages need reassembly before publication.
if not (is_first_packet and is_last_packet):
return

self._publish_message(ReceivedMessage(
priority=(arbitration_id >> 27) & 0x03,
protocol=(arbitration_id >> 24) & 0x07,
target_id=(arbitration_id >> 17) & 0x7F,
sender_id=sender_id,
message_counter=(arbitration_id >> 7) & 0x07,
payload=bytes(packet.data),
))

Code Snippet 2. gateway.py.

The file main.py is provided:

main.pyDownload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import asyncio
from gateway import Gateway

async def emit_heartbeats(gateway: Gateway) -> None:
while True:
await gateway.send_message(
target_id=0,
sender_id=27,
protocol=0,
payload=bytes(8),
)
await asyncio.sleep(2)


async def main() -> None:
gateway = Gateway(channel="can0")

try:
gateway.start_receiving()
print("Listening for CAN packets...")
# The notifier logs incoming packets while this task sends heartbeats.
await emit_heartbeats(gateway)
finally:
gateway.shutdown()

if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

Code Snippet 3. main.py.

Save these files to a folder, make sure Python 3 version 3.10+ is installed and accessible in a terminal, and install python-can. Then, launch main.py:

shell
1
python3 main.py

Demonstration

An example of the Arduino serial console log is presented:

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Device 49: classic CAN logger + 2s heartbeat
QUEUED: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=0 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=27 message_counter=0 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
QUEUED: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=1 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=27 message_counter=1 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
QUEUED: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=2 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=27 message_counter=2 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
QUEUED: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=3 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=27 message_counter=3 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
QUEUED: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=4 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=27 message_counter=4 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
QUEUED: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=5 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=27 message_counter=5 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
QUEUED: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=6 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]

An example of the Python client console log:

text
1
2
3
4
5
6
7
Listening for CAN packets...
RECV: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=1 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=2 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=3 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=4 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=5 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]
RECV: priority=3 protocol=0 target_id=0 sender_id=49 message_counter=6 packet_index=0 is_first_packet=1 is_last_packet=1 payload=[00 00 00 00 00 00 00 00]

Conclusion

The heartbeat testing was successful, but the implementation was limited to proving the heartbeat concept only.

The progression will be to:

  • a) Migrate to CAN-FD to increase data rates for better throughput and larger payloads for carrying IP packets.
  • b) Swap out the Arduino Uno R3 in favour of faster hardware in my possession: a Pi Pico or Nucleo-H743.
  • c) Define protocol 0 to support transmitting sensor data, setting motor speed and direction, and configuring device settings.
  • d) Implement message reassembly, including checking the packet sequence and handling timeouts.
  • e) Define and implement protocol 1 to support IP packets and TCP communication.
  • f) Configure and test the use of some example sensors and actuators over the CAN bus.
  • g) A formal design document for ABC.