Creating a CRUMBS Device Family
A family is a named collection of CRUMBS peripheral types that share a common firmware base. For each type in the family you write one *_ops.h header that encapsulates every command the controller can send or query. This keeps application code clean and platform-agnostic.
This guide walks through creating a complete ops header from scratch, using a toy two-channel thermometer (therm_ops.h) as the running example.
Anatomy of an ops header
An ops header is a single C header containing:
- A
#definefor your device’sTYPE_ID #defineconstants for every opcodestatic inlinesender functions (*_send_*) — one per SET commandstatic inlinequery functions (*_query_*) — one per GET command (sends the SET_REPLY trigger)- Result structs (
*_result_t) — one per GET command static inlinecombined get functions (*_get_*) — query + delay + read + parse in one call
All functions are static inline so the header is self-contained — no .c file is needed.
All of them take a const crumbs_device_t *dev as their first argument — a bound device handle
that bundles the context, address, and platform I/O callbacks (see src/crumbs_i2c.h).
Step 1: Choose a type ID and opcodes
Pick a type ID that does not conflict with existing families. The LHWIT family uses 0x01–0x04. Document your choices clearly.
/** @brief Type ID for 2-channel thermometer device. */
#define THERM_TYPE_ID 0x07
/* SET operations */
#define THERM_OP_SET_SAMPLE_RATE 0x01 /**< Set samples/second [rate:u8] */
/* GET operations (via SET_REPLY) */
#define THERM_OP_GET_TEMP 0x80 /**< Get both temperatures [ch0:i16][ch1:i16] */
#define THERM_OP_GET_SAMPLE_RATE 0x81 /**< Get current sample rate [rate:u8] */
Convention: SET opcodes start at 0x01, GET opcodes start at 0x80. This avoids the reserved opcode 0xFE (SET_REPLY).
Step 2: Write the boilerplate
#ifndef THERM_OPS_H
#define THERM_OPS_H
#include "crumbs.h"
#include "crumbs_message_helpers.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ... your type ID and opcode defines here ... */
/* ... your functions and structs here ... */
#ifdef __cplusplus
}
#endif
#endif /* THERM_OPS_H */
Step 3: Write the sender functions
One sender per SET command. Use crumbs_msg_init + crumbs_msg_add_* helpers to build the payload, then crumbs_controller_send to transmit.
/**
* @brief Set thermometer sample rate.
* @param dev Bound device handle.
* @param rate Samples per second (1–10).
* @return 0 on success.
*/
static inline int therm_send_set_sample_rate(const crumbs_device_t *dev, uint8_t rate)
{
crumbs_message_t msg;
crumbs_msg_init(&msg, THERM_TYPE_ID, THERM_OP_SET_SAMPLE_RATE);
crumbs_msg_add_u8(&msg, rate);
return crumbs_controller_send(dev->ctx, dev->addr, &msg, dev->write_fn, dev->io);
}
Step 4: Write the query functions
One query function per GET command. It sends the SET*REPLY trigger that tells the peripheral which opcode to include in its next read response. Mark them @internal — they are called by the corresponding \_get*\* wrapper and should rarely be called directly.
/** @internal Used by therm_get_temp(); prefer that for combined query+read. */
static inline int therm_query_temp(const crumbs_device_t *dev)
{
crumbs_message_t msg;
crumbs_msg_init(&msg, 0, CRUMBS_CMD_SET_REPLY); /* type_id=0 is fine for SET_REPLY */
crumbs_msg_add_u8(&msg, THERM_OP_GET_TEMP);
return crumbs_controller_send(dev->ctx, dev->addr, &msg, dev->write_fn, dev->io);
}
/** @internal Used by therm_get_sample_rate(); prefer that for combined query+read. */
static inline int therm_query_sample_rate(const crumbs_device_t *dev)
{
crumbs_message_t msg;
crumbs_msg_init(&msg, 0, CRUMBS_CMD_SET_REPLY);
crumbs_msg_add_u8(&msg, THERM_OP_GET_SAMPLE_RATE);
return crumbs_controller_send(dev->ctx, dev->addr, &msg, dev->write_fn, dev->io);
}
Step 5: Define result structs
One struct per GET command, matching the wire payload exactly.
/**
* @brief Result of THERM_OP_GET_TEMP.
* Wire payload: [ch0:i16][ch1:i16] (little-endian, 0.01 °C units)
*/
typedef struct {
int16_t temp_ch0; /**< Channel 0 temperature in 0.01 °C units. */
int16_t temp_ch1; /**< Channel 1 temperature in 0.01 °C units. */
} therm_temp_result_t;
/**
* @brief Result of THERM_OP_GET_SAMPLE_RATE.
*/
typedef struct {
uint8_t rate; /**< Samples per second. */
} therm_sample_rate_result_t;
Step 6: Write the combined _get_* functions
This is the key abstraction. Each _get_* function performs the full three-step flow internally:
*_query_*()— sends the SET_REPLY writedelay_fn(CRUMBS_DEFAULT_QUERY_DELAY_US)— waits for the peripheral to stage its replycrumbs_controller_read()— reads + decodes the reply frame- Parse the payload into the result struct
/**
* @brief Read both temperature channels from the thermometer.
* @param dev Bound device handle.
* @param out Output struct (must not be NULL).
* @return 0 on success, non-zero on error.
*/
static inline int therm_get_temp(const crumbs_device_t *dev, therm_temp_result_t *out)
{
crumbs_message_t reply;
int rc;
if (!out)
return -1;
rc = therm_query_temp(dev);
if (rc != 0)
return rc;
dev->delay_fn(CRUMBS_DEFAULT_QUERY_DELAY_US);
rc = crumbs_controller_read(dev->ctx, dev->addr, &reply, dev->read_fn, dev->io);
if (rc != 0)
return rc;
if (reply.type_id != THERM_TYPE_ID || reply.opcode != THERM_OP_GET_TEMP)
return -1;
/* Parse [ch0:i16][ch1:i16] using crumbs_msg_read_u16 then cast */
uint16_t raw0, raw1;
rc = crumbs_msg_read_u16(reply.data, reply.data_len, 0, &raw0);
if (rc != 0)
return rc;
rc = crumbs_msg_read_u16(reply.data, reply.data_len, 2, &raw1);
if (rc != 0)
return rc;
out->temp_ch0 = (int16_t)raw0;
out->temp_ch1 = (int16_t)raw1;
return 0;
}
Why
crumbs_controller_readinstead of callingread_fndirectly?
crumbs_controller_readvalidates the frame length, callscrumbs_decode_message(CRC check + struct population), and returns a cleancrumbs_message_t. Callingread_fndirectly skips CRC validation and leaves raw bytes in a local buffer, which every caller would have to manage identically. The core function exists precisely so ops headers don’t have to repeat this 4-line pattern.
Step 7: Reduce boilerplate with crumbs_ops.h (optional)
The _query_* + _get_* pair and single-parameter _send_* functions are entirely deterministic.
src/crumbs_ops.h provides macros that generate the identical code from a single line:
#include "crumbs_ops.h"
/* Replaces therm_query_sample_rate + therm_get_sample_rate (~22 lines) */
CRUMBS_DEFINE_GET_OP(therm, sample_rate,
THERM_TYPE_ID, THERM_OP_GET_SAMPLE_RATE,
therm_sample_rate_result_t, therm_parse_sample_rate)
/* Replaces therm_send_set_sample_rate (~6 lines) */
CRUMBS_DEFINE_SEND_OP(therm, set_sample_rate,
THERM_TYPE_ID, THERM_OP_SET_SAMPLE_RATE,
uint8_t rate,
crumbs_msg_add_u8(&_m, rate))
For therm_get_temp the multi-line parse step must first be extracted into a named function
(therm_parse_temp) and then passed to the macro, or written by hand as in Step 6.
What the macros cover:
CRUMBS_DEFINE_GET_OP— standard 1:1 opcode→result GETs (no extra query parameters)CRUMBS_DEFINE_SEND_OP— single-parameter SETsCRUMBS_DEFINE_SEND_OP_0— zero-parameter SETs (e.g. aclearcommand)
What must still be written by hand:
- SET operations with 2+ parameters
- Parameterized queries (e.g. “get history entry N”) where the index must be packed into the query payload
- Parse logic — result structs and parse functions are always hand-written
The existing lhwit_family ops headers (led_ops.h, servo_ops.h, etc.) are concrete
reference implementations showing both covered and uncovered cases.
Step 8: Use the ops header on Linux
#include "crumbs_linux.h"
#include "therm_ops.h"
crumbs_context_t ctx;
crumbs_linux_i2c_t lw;
crumbs_linux_init_controller(&ctx, &lw, "/dev/i2c-1", 0);
crumbs_device_t dev = {
.ctx = &ctx,
.addr = THERM_ADDR,
.write_fn = crumbs_linux_i2c_write,
.read_fn = crumbs_linux_read,
.delay_fn = crumbs_linux_delay_us,
.io = &lw,
};
/* SET: configure sample rate */
therm_send_set_sample_rate(&dev, 5);
/* GET: read temperatures */
therm_temp_result_t temps;
int rc = therm_get_temp(&dev, &temps);
if (rc == 0) {
printf("Ch0: %.2f C\n", temps.temp_ch0 / 100.0);
printf("Ch1: %.2f C\n", temps.temp_ch1 / 100.0);
}
crumbs_linux_close(&lw);
Step 9: Use the ops header on Arduino
#include "crumbs_arduino.h"
#include "therm_ops.h"
crumbs_context_t ctx;
crumbs_arduino_init_controller(&ctx);
crumbs_device_t dev = {
.ctx = &ctx,
.addr = THERM_ADDR,
.write_fn = crumbs_arduino_wire_write,
.read_fn = crumbs_arduino_read,
.delay_fn = crumbs_arduino_delay_us,
.io = NULL,
};
therm_temp_result_t temps;
int rc = therm_get_temp(&dev, &temps);
if (rc == 0) {
Serial.print("Ch0: ");
Serial.println(temps.temp_ch0 / 100.0);
}
The ops header is identical on both platforms. Bundle the platform-specific callbacks into a
crumbs_device_t device handle; call sites then reduce to a single &dev argument.
Peripheral implementation
The ops header covers the controller side. On the peripheral side you write the firmware that receives commands and responds to read requests. CRUMBS provides symmetrical per-opcode dispatch for both SET and GET operations.
SET operations — per-opcode handler table
Register one handler per opcode. The library dispatches automatically:
static void handle_set_sample_rate(crumbs_context_t *ctx, uint8_t opcode,
const uint8_t *data, uint8_t len, void *user)
{
uint8_t rate;
if (crumbs_msg_read_u8(data, len, 0, &rate) == 0)
g_sample_rate = rate;
}
/* In setup(): */
crumbs_register_handler(&ctx, THERM_OP_SET_SAMPLE_RATE, handle_set_sample_rate, NULL);
Adding a new SET op = one crumbs_register_handler call + one handler function. No switch needed.
GET operations — per-opcode reply handler table
Register one reply handler per GET opcode with crumbs_register_reply_handler(). The library
dispatches automatically when the controller issues a read:
static void handle_get_temp(crumbs_context_t *ctx, crumbs_message_t *reply, void *user)
{
(void)ctx; (void)user;
crumbs_msg_init(reply, THERM_TYPE_ID, THERM_OP_GET_TEMP);
crumbs_msg_add_u16(reply, (uint16_t)g_temp_ch0);
crumbs_msg_add_u16(reply, (uint16_t)g_temp_ch1);
}
static void handle_get_sample_rate(crumbs_context_t *ctx, crumbs_message_t *reply, void *user)
{
(void)ctx; (void)user;
crumbs_msg_init(reply, THERM_TYPE_ID, THERM_OP_GET_SAMPLE_RATE);
crumbs_msg_add_u8(reply, g_sample_rate);
}
static void handle_version(crumbs_context_t *ctx, crumbs_message_t *reply, void *user)
{
(void)ctx; (void)user;
crumbs_build_version_reply(reply, THERM_TYPE_ID, 1, 0, 0);
}
/* In setup(): */
crumbs_register_reply_handler(&ctx, 0, handle_version, NULL);
crumbs_register_reply_handler(&ctx, THERM_OP_GET_TEMP, handle_get_temp, NULL);
crumbs_register_reply_handler(&ctx, THERM_OP_GET_SAMPLE_RATE, handle_get_sample_rate, NULL);
Adding a new GET op = one crumbs_register_reply_handler call + one handler function. No switch needed.
GET operations — on_request callback (alternative)
The on_request callback is an alternative: one function with an explicit switch on
ctx->requested_opcode. Use it if you prefer a single dispatch point, or when porting
from older CRUMBS code:
static void on_request(crumbs_context_t *ctx, crumbs_message_t *reply)
{
switch (ctx->requested_opcode)
{
case 0:
crumbs_build_version_reply(reply, THERM_TYPE_ID, 1, 0, 0);
break;
case THERM_OP_GET_TEMP:
crumbs_msg_init(reply, THERM_TYPE_ID, THERM_OP_GET_TEMP);
crumbs_msg_add_u16(reply, (uint16_t)g_temp_ch0);
crumbs_msg_add_u16(reply, (uint16_t)g_temp_ch1);
break;
default:
/* Unknown opcode — return empty reply as safe fallback */
crumbs_msg_init(reply, THERM_TYPE_ID, ctx->requested_opcode);
break;
}
}
crumbs_set_callbacks(&ctx, NULL, on_request, NULL);
Always include a
default:case that callscrumbs_msg_init(reply, TYPE_ID, ctx->requested_opcode). This ensures the reply struct is never left in an uninitialised state if an unknown opcode arrives.
When both reply handlers and on_request are configured, reply handlers take priority;
on_request is called only for opcodes that have no registered reply handler.
Choosing the right mechanism
| Mechanism | Use for | Notes |
|---|---|---|
crumbs_register_handler() |
SET operations | One per opcode; preferred for all incoming writes |
crumbs_register_reply_handler() |
GET operations | One per opcode; preferred for all read replies |
on_request callback |
GET operations (alternative) | Single switch; backward-compatible; used as fallback when no reply handler matches |
on_message callback |
Advanced use only | Fires before handler table for every write; for logging/monitors, not device logic |
hello_peripheral.ino uses on_message for brevity (one callback, no handler table). For a real
family peripheral, use crumbs_register_handler() for each SET opcode and
crumbs_register_reply_handler() for each GET opcode — see examples/families_usage/ for the
complete pattern.
Reference: payload helper functions
| Helper | Description |
|---|---|
crumbs_msg_add_u8(msg, v) |
Append 1-byte value to payload |
crumbs_msg_add_u16(msg, v) |
Append 2-byte LE value |
crumbs_msg_add_u32(msg, v) |
Append 4-byte LE value |
crumbs_msg_read_u8(data, len, off, &v) |
Read 1 byte at offset |
crumbs_msg_read_u16(data, len, off, &v) |
Read 2 bytes LE at offset |
crumbs_msg_read_u32(data, len, off, &v) |
Read 4 bytes LE at offset |
Maximum payload is CRUMBS_MAX_PAYLOAD bytes (27). All multi-byte values are little-endian.
Reference: platform function pointers
| Symbol | Linux | Arduino |
|---|---|---|
crumbs_i2c_write_fn |
crumbs_linux_i2c_write |
crumbs_arduino_wire_write |
crumbs_i2c_read_fn |
crumbs_linux_read |
crumbs_arduino_read |
crumbs_delay_fn |
crumbs_linux_delay_us |
crumbs_arduino_delay_us |
io context |
(void *)&lw (crumbs_linux_i2c_t) |
NULL (uses global Wire) |
Checklist
- Type ID chosen and documented, does not conflict with existing families
- All opcodes defined with payload format in comments
- One
*_send_*function per SET operation - One
*_query_*function per GET operation (sends SET_REPLY) - One
*_result_tstruct per GET operation - One
*_get_*function per GET operation (query + delay + read + parse + identity check) - All
_get_*functions return 0 on success, non-zero on error CRUMBS_DEFINE_GET_OP/CRUMBS_DEFINE_SEND_OPused for 1:1 ops (or equivalent hand-written)- Header guard (
#ifndef / #define / #endif) in place #ifdef __cplusplus extern "C"wrapper present for C++ compatibility- Works with both Linux and Arduino function pointer combinations
- Peripheral: one
crumbs_register_handler()call per SET opcode - Peripheral: one
crumbs_register_reply_handler()call per GET opcode (oron_requestwithdefault:fallback) - Peripheral:
on_messagenot used for device logic (handler table used instead)