Expand description
§automotive-wire-codec
The L0 foundation of a layered, no_std, no-alloc automotive diagnostic protocol
suite (DoIP, UDS, later SOME/IP). It provides the shared zero-copy codec traits and
big-endian byte-level leaf helpers that every protocol core (L1) implements — and
nothing else: no framing, no concrete message types, no owned forms, no alloc.
§Features
- Zero-copy decode — [
Decode] borrows directly from the input buffer; no allocation, no intermediate copies. no_std/ no-alloc — builds on bare-metal targets (verified in CI againstthumbv6m-none-eabi).- Nested encode without a staging buffer — [
Encode::encoded_size] is exact and correct by construction (the default counts bytes through an infallible sink), so an outer protocol can size a header and serialize an inner value directly into the same buffer. - Generic, ergonomic errors — L1 crates keep their own rich error enum; leaf helpers
and trait defaults construct errors generically via small
Frombounds, so calls compose through?with no turbofish.
§Error model
L0 defines no protocol error type. It defines two tiny error fragments —
[Incomplete] (a read ran out of bytes) and [TrailingBytes] (bytes remained after
an exact decode) — and the traits require the L1 error to be constructible From
them. This preserves each L1 crate’s rich, typed error enum while letting shared
trait defaults and leaf helpers construct errors generically. Encode-side I/O
failures surface as [embedded_io::ErrorKind]; the [Encode] error bound is
From<embedded_io::ErrorKind>. Because the L1 error implements these From bounds,
helper calls (read_u8(buf)?, write_u16_be(w, x)?) compose through ? with no
turbofish and no generic error parameter at the call site.
§The decode / decode_exact contract
[Decode::decode] consumes from the front of the buffer and returns the
remainder, so nested and sequential decodes thread the remainder along:
use automotive_wire_codec::{read_u8, read_u16_be, Decode, Incomplete, TrailingBytes};
struct SomeType(u8);
#[derive(Debug)]
enum DemoErr { Incomplete(Incomplete), TrailingBytes(TrailingBytes) }
impl From<Incomplete> for DemoErr { fn from(e: Incomplete) -> Self { DemoErr::Incomplete(e) } }
impl From<TrailingBytes> for DemoErr { fn from(e: TrailingBytes) -> Self { DemoErr::TrailingBytes(e) } }
impl<'a> Decode<'a> for SomeType {
type Error = DemoErr;
fn decode(buf: &'a [u8]) -> Result<(Self, &'a [u8]), Self::Error> {
let (b, rest) = read_u8(buf)?;
Ok((SomeType(b), rest))
}
}
fn run(buf: &[u8]) -> Result<(), DemoErr> {
let (a, rest) = read_u8(buf)?;
let (b, rest) = read_u16_be(rest)?;
let (c, rest) = SomeType::decode(rest)?; // nested Decode composes the same way
let _ = (a, b, c, rest);
Ok(())
}
run(&[0u8, 1, 2, 3, 4]).unwrap();[Decode::decode_exact] instead requires the whole buffer to be consumed, returning
[TrailingBytes] otherwise — use it at a message boundary where framing has already
delimited the frame. L0 has no opinion on framing; that is an L1 concern.
§Nested encode with no staging buffer
Because [Encode::encoded_size] is separate from [Encode::encode] and
&mut [u8] is an [embedded_io::Write] sink, an outer protocol serializes an inner
value directly into one buffer — no second allocation or copy:
use automotive_wire_codec::{write_u32_be, Encode};
struct Header { payload_len: u32 }
impl Header {
fn new(payload_len: u32) -> Self { Header { payload_len } }
}
impl Encode for Header {
type Error = embedded_io::ErrorKind;
fn encoded_size(&self) -> Result<usize, Self::Error> { Ok(4) }
fn encode(&self, writer: &mut impl embedded_io::Write) -> Result<usize, Self::Error> {
write_u32_be(writer, self.payload_len)
}
}
struct Inner(u32);
impl Encode for Inner {
type Error = embedded_io::ErrorKind;
fn encoded_size(&self) -> Result<usize, Self::Error> { Ok(4) }
fn encode(&self, writer: &mut impl embedded_io::Write) -> Result<usize, Self::Error> {
write_u32_be(writer, self.0)
}
}
let inner = Inner(42);
let mut tx_buf = [0u8; 8];
let payload_len = inner.encoded_size()?;
let header = Header::new(payload_len as u32);
let mut writer: &mut [u8] = &mut tx_buf; // one buffer
let mut total = header.encode(&mut writer)?; // writes header, advances `writer`
total += inner.encode(&mut writer)?; // writes inner into the remainder
assert_eq!(total, 8);
Ok::<(), embedded_io::ErrorKind>(())The closed-form encoded_size overrides matter here: the default
encoded_size counts by running encode against a counting sink, so sizing a
nested message with the default re-encodes each subtree once per level. Keep
closed-form overrides on types used for length-prefix pre-sizing.
§Consumer idioms
Patterns every protocol crate on this codec ends up needing. They are conventions, not API — codified here so each consumer doesn’t re-derive them.
§Framing: decode a fixed header, re-slice the length-prefixed payload
A sans-io framer decodes the fixed-size header, then slices the payload out of the remainder using the header’s length field:
let (header, rest) = Header::decode(buf)?; // fixed-size prefix
let payload_len = header.payload_length as usize;
let payload = rest.get(..payload_len) // delimit by declared length
.ok_or(Incomplete { needed: payload_len, available: rest.len() })?;
let remainder = &rest[payload_len..]; // start of the next frame§Dispatch: self-identifying vs externally-discriminated payloads
Decode deliberately is not a dispatch mechanism. Two standard shapes:
- Self-identifying (open set): the discriminant is the first byte(s) of the
buffer. Write an inherent
fn decode(buf) -> Result<Self, E>on the enum that reads the tag and delegates; unknown tags decode to a catch-all variant. - Externally discriminated: the tag lives in a sibling structure (e.g. a
header’s payload-type field) and is stripped before the payload bytes are
seen. Write
fn decode(buf: &[u8], tag: PayloadType) -> Result<Self, E>— a trait method cannot express dispatch-by-external-tag, and should not try.
§Validated views: validate once, then re-slice
A validated (L2) view over a lazy decode layer should not re-run fallible
decodes on every accessor. Construct-time: drain DecodeIter::iter() once,
surfacing the first error; cache counts/offsets. Accessors: re-slice the
already-validated bytes with purpose-built infallible iterators. The typed
Decode/DecodeIter layer is the validation pass, not the hot path.
§Length prefixes: precompute, then one linear pass
When a length field precedes the bytes it measures, compute it from
encoded_size() before writing — sizes here are pure functions of the value,
so no backfill pass is needed (see the nested-encode example above).
Size-changing post-hoc transforms (encode, then rewrite bytes to a different
length — e.g. an E2E protect step) are deliberately out of scope for Encode;
model those as a consumer-owned two-phase API.
§Why slice-first (no Read-based decode)
Decoding through a streaming Read cannot produce
Incomplete { needed, available } — a reader doesn’t know available until it
has consumed the stream. Buffer first, then decode the slice.
§Usage
Add the dependency:
cargo add automotive-wire-codecImplement [Encode] and [Decode] for a message type using the big-endian leaf
helpers:
use automotive_wire_codec::{read_u16_be, write_u16_be, Decode, Encode, Incomplete, TrailingBytes};
#[derive(Debug, PartialEq)]
struct Ping {
session_id: u16,
}
#[derive(Debug)]
enum PingError {
Incomplete(Incomplete),
TrailingBytes(TrailingBytes),
Io(embedded_io::ErrorKind),
}
impl From<Incomplete> for PingError {
fn from(e: Incomplete) -> Self {
PingError::Incomplete(e)
}
}
impl From<TrailingBytes> for PingError {
fn from(e: TrailingBytes) -> Self {
PingError::TrailingBytes(e)
}
}
impl From<embedded_io::ErrorKind> for PingError {
fn from(e: embedded_io::ErrorKind) -> Self {
PingError::Io(e)
}
}
impl<'a> Decode<'a> for Ping {
type Error = PingError;
fn decode(buf: &'a [u8]) -> Result<(Self, &'a [u8]), Self::Error> {
let (session_id, rest) = read_u16_be(buf)?;
Ok((Ping { session_id }, rest))
}
}
impl Encode for Ping {
type Error = PingError;
fn encode(&self, writer: &mut impl embedded_io::Write) -> Result<usize, Self::Error> {
Ok(write_u16_be(writer, self.session_id)?)
}
}
fn main() -> Result<(), PingError> {
// Round-trip: encode into a buffer, then decode it back.
let ping = Ping { session_id: 0x1234 };
let mut buf = [0u8; 2];
let mut writer: &mut [u8] = &mut buf;
ping.encode(&mut writer)?;
let decoded = Ping::decode_exact(&buf)?;
assert_eq!(decoded, ping);
Ok(())
}See the crate docs for the full API,
including the [DecodeIter] trait for repeated elements, the variable-width
[read_be_uint]/[read_be_uint_into] helpers, and
[Encode::encode_to_slice] for fixed-buffer encoding.
Migrating a protocol crate onto these traits? See MIGRATION.md.
§no_std
This crate is no_std and does not require alloc. unsafe_code is forbidden
(#![forbid(unsafe_code)] at the workspace lint level). CI builds against a bare-metal
Cortex-M0 target (thumbv6m-none-eabi) to catch any std/alloc leaking in through a
dependency.
§Minimum Supported Rust Version (MSRV)
The MSRV is tracked in Cargo.toml’s rust-version field (currently 1.85) and enforced
in CI.
§License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Structs§
- Counting
Sink - Infallible
embedded_io::Writesink that counts bytes and stores nothing. - Decode
Iterator - Iterator produced by
DecodeIter::iter. Threads the remaining buffer between calls toDecodeIter::decode_next; terminates onOk(None)or the first error. - Incomplete
- A read ran out of bytes:
neededwere required, onlyavailablepresent. - Insufficient
Buffer - An output slice was too small for the bytes an encode needed to write.
- Invalid
Width - A variable-width read/write was requested with an out-of-range byte width.
- Trailing
Bytes - Bytes remained after a
decode_exactthat should have consumed the whole buffer.
Enums§
- Encode
ToSlice Error - Error from
Encode::encode_to_slice. - Read
Uint Error - Error from the variable-width read helpers (
read_be_uint,read_be_uint_into). - Write
Uint Error - Error from the variable-width write helper (
write_be_uint).
Traits§
- BeUint
- Unsigned-integer targets for
read_be_uint_into. Sealed: implemented foru8,u16,u32,u64,u128only. - Decode
- RX-side: zero-copy decode borrowing from
buf. The value is valid only as long asbuflives. - Decode
Iter - RX-side: decode a sequence of same-typed elements from a buffer. Implement this only for protocols that have repeated elements (e.g. a UDS DTC record list).
- Encode
- TX-side: serialize
selfinto anembedded_io::Writesink.
Functions§
- ensure_
len Ok(())ifbufhas at leastneededbytes, elseIncomplete.- minimal_
be_ len - Minimal number of big-endian bytes needed to represent
value— computes the width to pass towrite_be_uintfor protocols that emit minimal-width length/size/address fields. - read_
array - Read a fixed-size
N-byte array (e.g. a 17-byte VIN, a 6-byte EID). - read_
be_ uint - Read a variable-width (
0..=16byte) big-endian unsigned integer into au128. - read_
be_ uint_ into - Read a variable-width big-endian unsigned integer directly into
T, validating the width againstT’s size — noascast at the call site, and a width the target cannot hold is a typed data error. - read_
optional_ array - Read an optional fixed-size trailing array:
Some+ remainder whenNbytes are present,(None, buf)(buffer untouched) when they are not. - read_u8
- Read a single byte.
- read_
u16_ be - Read a big-endian
u16. - read_
u32_ be - Read a big-endian
u32. - read_
u64_ be - Read a big-endian
u64. - read_
u128_ be - Read a big-endian
u128. - take
- Split
nbytes off the front ofbuf, returning(head, tail). - write_
all - Write a raw byte slice verbatim (e.g. an opaque payload). Returns
bytes.len(). - write_
be_ uint - Write the low
nbytes (0..=16) ofvalue, big-endian. Returnsn. - write_
u8 - Write a single byte. Returns
1. - write_
u16_ be - Write a big-endian
u16. Returns2. - write_
u32_ be - Write a big-endian
u32. Returns4. - write_
u64_ be - Write a big-endian
u64. Returns8. - write_
u128_ be - Write a big-endian
u128. Returns16.