Protocol
CRUMBS protocol 1.0. The frame below has been the wire format since library
0.7; SET_REPLY (opcode 0xFE) was added in 0.10.0, and receivers have rejected
over-long buffers since 0.12.4. Nothing on the wire changes without a new
protocol major version. This document is normative; the library
version (CRUMBS_VERSION) is a separate number and moves independently.
CRUMBS is a message layer over plain I²C. The I²C address selects the device; everything inside the transaction is one CRUMBS frame.
Frame
┌─────────┬────────┬──────────┬──────────────────┬──────┐
│ type_id │ opcode │ data_len │ data[0..data_len)│ crc8 │
│ 1 byte │ 1 byte │ 1 byte │ 0–27 bytes │ 1 B │
└─────────┴────────┴──────────┴──────────────────┴──────┘
| Field | Meaning |
|---|---|
type_id |
Device class the frame is addressed to or comes from. 0x00 = any. |
opcode |
Command or query identifier, scoped to the type_id. 0xFE is reserved. |
data_len |
Payload length, 0–27. |
data |
Opaque bytes; the application defines the layout. |
crc8 |
CRC-8 over the preceding 3 + data_len bytes. |
Frame length is 4 + data_len: 4 bytes minimum, 31 maximum
(CRUMBS_MESSAGE_MAX_SIZE). The 31-byte ceiling is chosen so a frame fits the
32-byte buffer of the AVR Arduino Wire library.
A receiver rejects a frame when the buffer is shorter than 4 bytes, data_len
exceeds 27, the buffer is shorter or longer than 4 + data_len, or the CRC
does not match. Only a CRC mismatch counts as a CRC error in the receiver’s
statistics; structural failures do not.
CRC-8
| Parameter | Value |
|---|---|
| Width | 8 |
| Polynomial | 0x07 (x⁸ + x² + x + 1) |
| Init | 0x00 |
| Reflect in/out | no / no |
| XOR out | 0x00 |
| Check value | crc8("123456789") = 0xF4 |
These are the parameters of CRC-8/SMBUS. The CRC covers type_id, opcode,
data_len and data, in that order, and excludes the CRC byte. The reference
implementation is a 16-entry nibble table generated by pycrc
(src/crc/crc8_nibble.c); crumbs_crc8() is the public entry point.
Multi-byte values
The payload is opaque to the protocol. The library’s helpers
(crumbs_msg_add_u16() and friends) write integers little-endian and floats
as their 4 native bytes; a family that uses them inherits that convention.
Transactions
Two I²C transaction shapes carry every CRUMBS exchange.
SET — one write. The controller writes a frame; the peripheral validates it
and dispatches on opcode. Nothing comes back on the bus.
GET — a write, then a read.
controller ─ write ─▶ [type][0xFE][0x01][op][crc] SET_REPLY: stage opcode `op`
(wait)
controller ─ read ─▶ up to 31 bytes the peripheral's reply frame
The peripheral stores op as its requested opcode and builds the reply when
the read arrives. The requested opcode persists until the next SET_REPLY, so a
controller can read the same value repeatedly with one SET_REPLY.
SET_REPLY (0xFE)
The only opcode the protocol reserves. The library intercepts it before any
user code runs: it is never delivered to on_message or to a handler, and a
handler registered for 0xFE is never called.
- The generated wrappers send it with
type_id 0x00and a one-byte payload (hand-written senders may use the target’s type). A receiver accepts anydata_len ≥ 1and usesdata[0]; an empty payload leaves the requested opcode unchanged. - The initial requested opcode after initialisation is
0x00. - A SET_REPLY frame is subject to the type check below like any other frame.
The read
A controller reads 31 bytes and trims to the length the reply’s own header
declares; on both Arduino and Linux a peripheral that has finished its frame
leaves the remaining bytes reading as 0xFF, and a decoder that receives the
untrimmed buffer rejects it as over-long. A peripheral with neither a reply
handler for the requested opcode nor an on_request callback gives the HAL
nothing to write (the AVR Wire core then clocks out a single 0x00), and
the read fails to decode either way; a handler that returns without filling
the reply sends the empty frame 00 00 00 00.
When the reply is built
The peripheral builds its reply inside the I²C request callback — after the
controller has addressed it for reading and before the first byte is clocked
out. On AVR that callback runs in the TWI interrupt with SCL held low by the
hardware (clock stretching), so the controller’s read waits for the reply to
be encoded. Incoming frames are processed in the receive interrupt the same
way, and a read that arrives while that is still running is address-ACKed by
the hardware and stretched until the interrupt returns. The controller must
therefore tolerate clock stretching. CRUMBS_DEFAULT_QUERY_DELAY_US (10 ms),
the pause the generated getters insert between the SET_REPLY write and the
read, is margin on top of that, chosen conservatively; the header says to
reduce it only with an oscilloscope on the bus.
Two limits apply. The Raspberry Pi’s Broadcom I²C controller does not honour
clock stretching: when a peripheral’s interrupt is late it reads 0xFF bytes
instead of waiting, which fails the CRC — measured as a few percent of first
reads in feastorg/Slice_DCMT#3,
unchanged by halving the bus clock or lengthening the pre-read delay. And
SMBus, whose CRC this protocol shares, caps a target’s cumulative stretching at
25 ms per message (T_LOW:SEXT) and a controller’s at 10 ms per byte
(T_LOW:MEXT); a reply handler that finishes well inside 25 ms keeps CRUMBS
usable on SMBus-timed controllers.
Type check
A peripheral may declare its own type_id (crumbs_set_type_id()). Once
declared, an incoming frame is dropped — before SET_REPLY handling and before
any callback — when its type_id is neither 0x00 nor the declared value. A
peripheral that has not declared a type accepts every frame. The check runs
after CRC validation, so a corrupt frame is reported as a CRC error whatever
its type.
On the controller side, crumbs_controller_read_expect() applies the mirror
check to a reply: opcode must match, and type_id must match unless the
expected type is 0x00.
Number spaces
I²C address
CRUMBS uses 7-bit addressing. I²C reserves 0x00–0x07 and 0x78–0x7F,
leaving 0x08–0x77. Within it,
avoid addresses another standard on the same bus may drive, worst first:
| Avoid | Why |
|---|---|
0x0C |
SMBus Alert Response Address: a host reads it expecting every alerting device to answer |
0x08–0x0B |
SMBus Host, Smart Battery Charger, Selector and Battery (SMBus 3.3.1, Table 17) |
0x48–0x4B |
SMBus prototype addresses, “not intended for production parts and should never be assigned to any device” (SMBus §6.2.2.3); LM75-class temperature sensors occupy 0x48–0x4F |
0x50–0x57 |
24Cxx EEPROMs and DIMM SPD, the most contested block on a real bus |
0x61 |
SMBus Device Default Address (ARP); the Atlas Scientific EZO-DO also ships here |
0x10–0x17 is reserved by neither the I²C specification nor SMBus, and is
where the library’s examples default. Every address is a default: the
integrator owns the map, and the controller-side scanners exist so a conflict
is found on the bench rather than in the field.
type_id
0x00 is the wildcard and must not be assigned to a device. 0x01–0xFF
identify a device class, not an individual — two devices of the same type are
told apart by address. Each type owns an independent opcode space.
opcode
| Value | Status |
|---|---|
0xFE |
Reserved: SET_REPLY. |
0x00 |
Convention: version information (below). |
| all others | Free per type. Convention: SET commands low, GET queries from 0x80 up, so a reply’s opcode says what it is. |
0x00 is an ordinary opcode to the library; only the convention gives it
meaning.
Opcode 0x00: version information
A peripheral should answer opcode 0x00 with five bytes:
| Byte | Content |
|---|---|
| 0–1 | CRUMBS_VERSION of the library the peripheral was built with, little-endian |
| 2 | module major |
| 3 | module minor |
| 4 | module patch |
crumbs_build_version_reply(reply, type_id, major, minor, patch) produces
exactly this frame. A controller that reads it can tell the device’s type,
its firmware version and which CRUMBS release it runs; a peripheral that
implements it answers a bare read with it, since the requested opcode starts
at 0x00. For compatibility, treat the module version as semantic: a major
mismatch is incompatible, and a peripheral’s minor must be at least the
controller’s. crumbs_controller_scan_for_crumbs_with_types() reports the
type_id of whatever frame each device returns.
CRUMBS_VERSION is major × 10000 + minor × 100 + patch (0.15.0 → 1500).
For the module version, bump major for an incompatible opcode or payload
change, minor for additions, patch for fixes.
Discovery
The controller-side scanners take one of two probes:
- Read probe (
crumbs_controller_scan_for_crumbs*, both modes): read the address and accept it if the bytes decode as a CRUMBS frame. The CRC is the discriminator, so a foreign device whose first bytes happen to be CRC-consistent is reported as present; the tests keep a deliberate example. In non-strict mode an address whose read did not decode is written an all-zero frame (00 00 00 00) and read once more. A CRUMBS peripheral dispatches that frame as an opcode-0x00SET with no payload, so a family that binds SET opcode0x00will see it; a 24Cxx EEPROM takes it as a page write. - Address probe (
crumbs_arduino_scan,crumbs_linux_scan): I²C-level only. Strict mode reads one byte, which consumes a byte from whatever device answers; non-strict mode is an address-only ACK check. Neither decodes a frame.
See api-reference.md for the signatures and return values.