DeclarativeCodec. CruciHiL never generates codec code from your spec — it interprets the spec directly. Interpret, don’t codegen.
The codec × transport matrix
A custom interface is a (codec, transport) pair:- The codec defines what bytes mean — implemented against the
SignalCodecABC (definitions(),encode(),decode()). Built-ins:DeclarativeCodec(interprets a wire-format YAML) andDBCCodec(cantools, for DBC-described payloads on non-CAN links). - The transport defines how bytes move — datagram, stream, or transactional.
CustomBusEngine is created per [rig.custom.<iface>] section: it owns the codec, the transport, its own signal store, and a scheduler with the same absolute-time cyclic-transmission semantics and mutation hooks as the CAN engine.
The three transport shapes
For stream transports, the
MagicLengthFramer scans for the spec’s magic bytes, reads the length field, verifies the checksum, and emits complete frames. Line noise or a torn frame is skipped byte-by-byte until the next magic — the framer never wedges. A stream interface therefore requires a declarative spec with framing.kind: magic_length.
Virtual twins and fault hooks
Every transport shape ships with a virtual twin — this is a hard rule, so virtual-first development and mutation verification work on every bench bus shape:
Datagram and stream twins implement the standard fault-hook contract —
block, corrupt, and delay (with unblock / clear_corrupt / clear_delay) — selected by a bytes prefix. The transactional twin supports block/unblock as a bus-level fault (every transaction errors while blocked). These hooks are what fault injection and mutation verification use, so crucihil verify can prove your tests catch regressions on custom buses too.
The wire-format YAML
A real spec (from the CruciHiL test suite — motor telemetry over a datagram link):Top-level fields
string
required
Protocol name.
integer
default:"1"
Spec version.
string
default:"\"little\""
Byte order for all fields, length fields, and checksums.
"little" or "big".object
Frame delimiting.
kind is "none" (default — for datagram transports where boundaries are preserved) or "magic_length" (required for stream transports). For magic_length: magic is a list of byte values (at least one), length_field takes size (1 or 2) and includes_header (boolean), and checksum takes kind (crc8, crc16, crc32, sum8, or "none") and covers ("payload" or "frame").list
required
At least one message. Message names and ids must be unique across the spec.
Message fields
string
required
Message name — the first half of the
"MessageName.SignalName" signal identifier.integer
required
Message id used for decode dispatch.
integer
default:"0"
Cyclic transmission period in milliseconds when the message is started with
rig.bus.start().list
required
At least one field per message. Each field accepts
name, type (one of uint8, int8, uint16, int16, uint32, int32, float32, float64), scale (non-zero, default 1.0), offset (default 0.0), min, max, unit, initial (default 0.0), enum (raw value → label map), and transform.The transform escape hatch
One weird field — a lookup table, a bit-packed hack — doesn’t force you to abandon the declarative spec. A field can name a pair of Python hooks ('pkg.module:function' form), where decode maps raw wire value → physical and encode is the inverse:
importlib when the spec loads, so a missing module fails at startup, never mid-test.
[rig.custom.<iface>] — TOML configuration
string
required
The transport. Built-in names:
virtual_datagram, virtual_stream, virtual_spi, virtual_i2c — or a dotted module path ("module.path:ClassName") to your own class implementing DatagramTransport or StreamTransport.string
required
Path to the protocol grammar — a declarative wire-format YAML, or a
.dbc file for the built-in DBC codec. Relative to the directory containing the TOML file.string
default:"\"declarative\""
Codec selection:
"declarative" (the built-in interpreter; automatically uses the DBC codec when definitions ends in .dbc), "dbc", or a "module:Class" path to a hand-written SignalCodec (Tier 3).string
default:"\"datagram\""
Transport shape:
"datagram" or "stream". Stream interfaces require a declarative spec with framing.kind: magic_length — the rig refuses to start otherwise, because a stream transport cannot delimit frames without one.table
default:"{}"
Passed as keyword arguments to the transport’s
connect() — connection parameters belong in TOML, never in test code (Rule R1).Writing tests — rig.bus
Tests use rig.bus with semantics identical to rig.can. Signals are "MessageName.SignalName", and the interface is resolved from the signal name, so tests stay hardware-free (Rule R1) — the same test runs whether the bytes ride UDP, UART, or a proprietary link.
send(message, fields) for one-shot transmission and interfaces() to list configured custom interfaces. expect() never raises on condition failure — it returns an ExpectResult; assert result.passed.
The round-trip verification harness
A spec is proven, never trusted. Atrig.connect(), every custom-interface codec must pass the round-trip verification harness before it loads: decode(encode(x)) ≈ x is checked across each signal’s range (min, max, midpoint, plus random samples snapped to the field’s quantization grid), the decoded field set must match the spec, and a second round trip must be an exact fixed point. Optional sample captures are replayed through decode() as well.
A codec that fails raises ConfigurationError — the rig never starts, and any test run against it is marked blocked, not fail. A lying spec can never silently corrupt test results.
AI import — crucihil author --protocol
You usually already have the protocol written down somewhere — a C packet-struct header, a protobuf file, or a prose document. Import it:
Escape hatches
When the declarative spec can’t express your protocol:- Tier 3 codec — set
codec = "myorg.codecs:MyCodec"to a class implementing theSignalCodecABC (definitions(),encode(),decode()). It is still gated by the verification harness. - Custom transport — set
backend = "module.path:ClassName"to a class implementing the datagram, stream, or transactional transport ABC. Resolved viaimportlibat connect time. - Scaffold stubs — generate a starting point for any of these:
See also
- Rig Configuration — the full rig TOML reference
- Rig Backends — built-in backends and the module-path extension point
crucihil scaffold— generate adapter stubs- Python test API — the full
rigfixture surface