Serial link
Between your computer and the Tercio FD, each CAN frame travels as one small packet with a checksum, wrapped so that a zero byte always means “end of frame”. Write this once and you can talk to the bus from any language.
You only need this page to write your own host software. The Python and Arduino libraries and Tercio Control already do it.
The packet
Section titled “The packet”| Field | Meaning |
|---|---|
id |
The CAN identifier, little-endian: 0x000–0x7FF. 0x800 addresses the adapter itself. |
opcode |
The first data byte of the CAN frame. |
payload |
The rest of the CAN frame’s data, 0–63 bytes. |
crc16 |
CRC-16/CCITT-FALSE over id, opcode and payload, little-endian. |
CRC-16/CCITT-FALSE: polynomial 0x1021, initial value 0xFFFF, no reflection, no final XOR. The check value of the ASCII string 123456789 is 0x29B1.
On the wire
Section titled “On the wire”The packet is COBS-encoded (Consistent Overhead Byte Stuffing), which removes every 0x00 byte, and followed by a single 0x00 delimiter:
wire = COBS(packet) + 00A receiver collects bytes up to each 0x00, COBS-decodes them, checks the length (5–68 bytes) and the CRC, and drops anything that fails. After a glitch or when a port opens mid-frame, the next 0x00 brings both sides back in step.
Examples
Section titled “Examples”| Frame | Packet | On the wire |
|---|---|---|
Enable node 1: ID 0x101, 10 01 |
01 01 10 01 16 D6 |
07 01 01 10 01 16 D6 00 |
Stop, broadcast: ID 0x000, 11 |
00 00 11 8C CE |
01 01 04 11 8C CE 00 |
Adapter GetStatus: ID 0x800, 01 |
00 08 01 14 55 |
01 05 08 01 14 55 00 |
The frame builder shows the packet and wire bytes for any command.
Behaviour
Section titled “Behaviour”- Host → bus. Each valid packet becomes one CAN-FD frame with the data
[opcode][payload]. If the adapter’s queue is full, it stops reading USB and the host’s writes wait: nothing is lost. A frame no node acknowledges within 50 ms is dropped and counted. - Bus → host. Every frame on the bus becomes one packet. Frames arrive with the CAN-FD length, so the payload may end in padding bytes.
- Sessions. The adapter only sends while the host has the port open (DTR set). When a host opens the port, the adapter first sends its GetInfo, so the host knows where the session starts; bytes before it are stale.
- Status. While the port is open, the adapter sends its status every 250 ms.
Adapter commands
Section titled “Adapter commands”Packets with ID 0x800 go to the adapter itself. It answers with the same ID and opcode.
| Opcode | Command | Reply |
|---|---|---|
0x00 |
GetInfo | Info, 20 bytes |
0x01 |
GetStatus | Status, 28 bytes. Also sent every 250 ms. |
0x02 |
ResetCounters | Status, after zeroing the traffic counters |
0x03 |
EnterBootloader | Empty. Then the adapter restarts into ST’s USB bootloader. |
| Offset | Field | Type | Meaning |
|---|---|---|---|
| 0 | protocol | u8 | Adapter protocol version: 1 |
| 1 | fw major | u8 | Firmware version |
| 2 | fw minor | u8 | |
| 3 | fw patch | u8 | |
| 4 | hw rev | u8 | Hardware revision |
| 5 | flags | u8 | bit 0: running from the crystal; bit 1: BOOT0 pin ignored (boot options set) |
| 6 | reserved | u16 | 0 |
| 8 | uid | 12 bytes | The adapter’s unique ID |
The status layout is on Bus health.
A minimal host
Section titled “A minimal host”The whole link layer in Python, without the library:
import serial
def crc16(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b << 8 for _ in range(8): crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF return crc
def cobs_encode(data: bytes) -> bytes: out, block = bytearray(), bytearray() for b in data: if b == 0: out += bytes([len(block) + 1]) + block block.clear() else: block.append(b) if len(block) == 254: out += b"\xff" + block block.clear() return bytes(out + bytes([len(block) + 1]) + block)
def cobs_decode(data: bytes) -> bytes: out, i = bytearray(), 0 while i < len(data): code = data[i] out += data[i + 1:i + code] i += code if code < 0xFF and i < len(data): out.append(0) return bytes(out)
def frame(can_id: int, opcode: int, payload: bytes = b"") -> bytes: packet = can_id.to_bytes(2, "little") + bytes([opcode]) + payload return cobs_encode(packet + crc16(packet).to_bytes(2, "little")) + b"\x00"
port = serial.Serial("/dev/ttyACM0", timeout=0.1)port.write(frame(0x101, 0x10, b"\x01")) # Enable node 1
buffer = b""while True: buffer += port.read(256) *chunks, buffer = buffer.split(b"\x00") for chunk in chunks: packet = cobs_decode(chunk) if len(packet) < 5 or crc16(packet[:-2]) != int.from_bytes(packet[-2:], "little"): continue # damaged: skip can_id = int.from_bytes(packet[:2], "little") print(hex(can_id), packet[2:-2].hex(" "))