Skip to content
Protocol

Serial link

3 min readFirmware v2 · protocol v2

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.

Packet13 bytes
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.

The packet is COBS-encoded (Consistent Overhead Byte Stuffing), which removes every 0x00 byte, and followed by a single 0x00 delimiter:

wire = COBS(packet) + 00

A 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.

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.

  • 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.

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.
Adapter info20 bytes
OffsetFieldTypeMeaning
0protocolu8Adapter protocol version: 1
1fw majoru8Firmware version
2fw minoru8
3fw patchu8
4hw revu8Hardware revision
5flagsu8bit 0: running from the crystal; bit 1: BOOT0 pin ignored (boot options set)
6reservedu160
8uid12 bytesThe adapter’s unique ID

The status layout is on Bus health.

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(" "))