Skip to content

Repository files navigation

ecomm — Flat Binary Communication Protocol for Embedded & Systems C++

ecomm is a header-only C++17 library for message-driven communication on Arduino-class microcontrollers — primarily the ESP family (ESP32, ESP8266) — talking over serial or Wi-Fi to a coordinator and to each other. It targets the specific shape of a robotics-style workload: short, frequent, structured packets between a coordinator and a set of worker nodes, where internal memory and compile-time configurability take precedence over runtime convenience.

The wire format is flat, fixed-size, and template-configured: every field's presence and width is decided by the C++ compiler at the point a packet<> is instantiated, not negotiated at runtime. Two peers are wire-compatible exactly when they instantiate the same template arguments — there are no type tags, no length prefixes, no version negotiation on the wire. In exchange you get no dynamic allocation, no exceptions, no RTTI anywhere in the protocol or channel layers, and a packet size known at compile time.

#include <ecomm/protocol/protocol.hpp>
#include <ecomm/channels/channels.hpp>

using namespace ecomm::protocol;
using namespace ecomm::channels;

// A 32-byte, point-to-point packet with a 16-bit CRC.
using my_packet = packet<32, topology::point_to_point, no_sequence, crc16>;

arduino_serial_channel<> link{Serial};   // named `link`, not `channel` -- that name is
                                          // already ecomm::channels::channel<Impl>

my_packet out{header_type::data, header_options::none};
std::memcpy(out.payload, "hello", 5);
(void)link.send(out);   // Packet deduced; seals (computes + writes the CRC), then writes the raw bytes

if (auto in = link.try_receive<my_packet>()) {
    // *in is a structurally valid, checksum-verified my_packet
}

A byte-exact Python implementation of this same wire protocol lives alongside this library — see ecomm-python/ — so a laptop, CI runner, or Raspberry Pi can speak to this firmware directly.


Table of Contents


Overview

  • Header-only. No separate compilation; #include what you need or pull the whole library via CMake FetchContent.
  • Compile-time sized and configured. Every packet<> instantiation has a fixed sizeof, known to the compiler; there is no runtime schema negotiation.
  • No dynamic allocation, no exceptions, no RTTI anywhere in the protocol or channel layers — suitable for -fno-exceptions -fno-rtti embedded builds. See Design Philosophy.
  • Policy-based, not inheritance-based, configuration. Topology, sequencing, and checksum algorithm are independent compile-time policies; see Three independent policies.
  • Multiple transports behind one API. UART, Wi-Fi and I2C channels all expose the same send()/try_receive() surface; swapping the transport does not touch application code. See The Channels Layer.
  • ESP32, ESP8266 and STM32, the last on either the STM32duino core or bare-metal ST HAL. Per-board aggregators (ecomm/channels/<board>/channels.hpp) include exactly the subset a given board supports, over a single shared definition of each channel.
  • A verified Python twin. ecomm-python/ speaks the exact same wire protocol, checked byte-for-byte against this library's compiled output — not merely a compatible reimplementation.

Three library namespaces:

Namespace Contents
ecomm::protocol The wire format: packet_header, packet, checksum policies and compute<>, validator<Packet>, the error envelope, and the plain enums (header_type, header_options, topology).
ecomm::channels The transports: channel<Impl> (CRTP base), arduino_serial_channel, arduino_wifi_channel, esp_async_wifi_channel, arduino_i2c_master_channel, the bare-metal stm32hal_* channels, reliable_channel, send_result, and role (a channel's participation in a hub).
ecomm::fabric hub<Channels...> (explicit, caller-known-Packet send/receive) and router (heterogeneous, handler-driven dispatch).

ecomm::protocol::details and ecomm::channels' private members are implementation detail — not part of the public API, and not stable across versions.


Requirements

  • C++17, with <optional>, <string_view>, <cstddef> (std::byte), and if constexpr.
  • An Arduino-compatible toolchain for the Arduino-core channel layer (HardwareSerial, WiFiServer/WiFiClient, TwoWire for I2C, or AsyncTCP/ESPAsyncTCP for esp_async_wifi_channel), or ST's STM32 HAL for the bare-metal stm32hal_* channels (UART_HandleTypeDef, I2C_HandleTypeDef). The protocol layer alone (packets, checksums, the error envelope) has no Arduino dependency and builds on a plain host — that's what the test suite does.
  • Two dependencies, fetched automatically via CMake FetchContent: etools (compile-time metaprogramming helpers — typelist, smallest_uint_t, flag-enum operators, dispatch_factory, ...) and, transitively, eser (flat binary serialization, used internally by etools).
  • CMake 3.20+ for this repository's own build (the library itself, being header-only, imposes no CMake minimum on consumers beyond what FetchContent needs).

Installation

include(FetchContent)

FetchContent_Declare(
  ecomm
  GIT_REPOSITORY https://github.com/MarikTik/ecomm.git
  GIT_TAG        main   # or a specific release tag
)
FetchContent_MakeAvailable(ecomm)

target_link_libraries(your_target_name PRIVATE ecomm)   # ecomm is an INTERFACE target

etools (and, transitively, eser) are fetched and linked automatically — you do not declare them yourself.

Include the aggregator headers for everything in a layer, or pull individual headers:

#include <ecomm/protocol/protocol.hpp>   // packet_header, packet, checksum, validator, error envelope
#include <ecomm/channels/channels.hpp>   // channel<>, every concrete transport, reliable_channel
// or, selectively:
#include <ecomm/protocol/packet.hpp>
#include <ecomm/channels/arduino_serial_channel.hpp>

Quick Start

#include <Arduino.h>
#include <ecomm/protocol/protocol.hpp>
#include <ecomm/channels/channels.hpp>

using namespace ecomm::protocol;
using namespace ecomm::channels;

// A 32-byte, network-topology packet addressed by sender_id/receiver_id,
// with a 16-bit CRC. Both peers must instantiate this exact same alias.
using my_packet = packet<32, topology::network, no_sequence, crc16>;

arduino_serial_channel<> link{Serial};   // named `link`, not `channel` -- that name is
                                          // already ecomm::channels::channel<Impl>

void setup() {
    Serial.begin(115200);
}

void loop() {
    my_packet out{header_type::data, header_options::none};
    out.header.receiver_id = 2;
    std::memcpy(out.payload, "ping", 4);
    (void)link.send(out);   // Packet deduced; seals (computes + writes the CRC) then writes the raw bytes

    if (auto in = link.try_receive<my_packet>()) {
        // *in passed validator<my_packet>::is_valid and is addressed to this board
        Serial.print("from board ");
        Serial.println(in->header.sender_id);
    }

    delay(1000);
}

Core Concepts

Three independent policies

Every packet and header in ecomm is parameterized by three independent compile-time policies. This is the central design decision the whole library is built around:

Policy Type Values Wire effect
Topology topology (enum) point_to_point, network network adds sender_id + receiver_id (1 byte each) to the header.
Sequence tag type no_sequence, sequenced sequenced adds a 1-byte seq_num immediately after the protocol byte. Required by reliable_channel.
Checksum tag type none, sum8/16/32, crc8/16/32/64, fletcher16/32, adler32, internet16 Anything but none adds a trailing FCS field, ChecksumPolicy::size bytes wide.

They are independent — any combination is a valid, distinct packet<> instantiation — and a single device may use different combinations on different links simultaneously: a UART leaf link that is strictly point-to-point, and a Wi-Fi link that is part of a multi-node mesh, in the same firmware. Topology (and the other two policies) is a per-instantiation template parameter, not a build-wide flag.

Compile-time configuration, not runtime negotiation

packet_header<Topology, SequencePolicy, ChecksumPolicy> has eight explicit partial specializations — one per combination of the three policies — each a distinct, standard-layout type containing only the fields its combination needs. There is no runtime "does this packet have a checksum" branch anywhere in the hot path; the compiler already knows, because the type says so. packet<PacketSize, Topology, SequencePolicy, ChecksumPolicy> layers a fixed-size payload (PacketSize - sizeof(header) bytes) on top of one of those eight header specializations.

The practical consequence: two peers are wire-compatible exactly when they instantiate packet<> with identical template arguments. There is no version byte, no capability negotiation — mismatched peers simply produce or expect the wrong bytes, silently. See Assumptions & Limitations.


The Protocol Layer (ecomm::protocol)

Wire format reference

A packet is a header immediately followed by a raw payload region:

+----------------------------------------------+-------------------------------+
|              packet_header                    |    payload (PacketSize        |
|  [proto(1B)] [seq(*s)] [ids(*n)] [fcs($)]      |       - sizeof(header) bytes) |
+----------------------------------------------+-------------------------------+
(*s) seq_num present only when SequencePolicy == sequenced (1 byte)
(*n) sender_id + receiver_id present only when Topology == network (2 bytes)
($)  fcs field present only when ChecksumPolicy != none (ChecksumPolicy::size bytes)

The eight header layouts this produces, in wire order (_byte — the packed protocol byte — is always first; fcs, when present, is always last so it covers everything before it):

Topology Sequence Checksum Wire layout
point_to_point no_sequence none _byte
point_to_point no_sequence policy _byte, fcs
point_to_point sequenced none _byte, seq_num
point_to_point sequenced policy _byte, seq_num, fcs
network no_sequence none _byte, sender_id, receiver_id
network no_sequence policy _byte, sender_id, receiver_id, fcs
network sequenced none _byte, seq_num, sender_id, receiver_id
network sequenced policy _byte, seq_num, sender_id, receiver_id, fcs

The protocol byte itself (_byte, always present, always first) packs three fields via explicit shifts and masks — never bitfields, whose layout is implementation-defined and unsafe for a wire protocol:

 7..5 : type      (3 bits)  -- header_type enum, 6 values assigned (2 reserved)
    4 : error     (1 bit)   -- header_options::error
    3 : ack       (1 bit)   -- header_options::ack
    2 : encrypted (1 bit)   -- header_options::encrypted
 1..0 : version   (2 bits)  -- ECOMM_PROTOCOL_VERSION, not a constructor parameter

header_type (6 assigned values; 0x6/0x7 are reserved and must not appear on the wire until assigned): data (0x0), control (0x1), auth (0x2), session (0x3), log (0x4), firmware (0x5).

header_options (opted into |/&/^/~ via etools::meta::enable_flags): none, error, ack, encrypted.

packet_header<Topology, SequencePolicy, ChecksumPolicy>

A compact, standard-layout, data-only type — logic lives in validator, not here. Declares no data members of its own; every field is inherited from a single details::header_layout base (one of the eight specializations above), which is what makes offsetof well-defined and the in-memory layout match the wire layout exactly. _byte itself is hidden behind private inheritance; callers reach it only through typed accessors.

Member Kind Meaning
packet_header() constructor Zero-initializes every field.
packet_header(header_type, header_options) constructor Packs type into bits 7..5, opts (masked) into bits 4..2, ECOMM_PROTOCOL_VERSION into bits 1..0. Other fields zero-initialized.
type() [[nodiscard]] Decoded header_type from bits 7..5.
options() [[nodiscard]] Decoded header_options from bits 4..2.
has(header_options) [[nodiscard]] true iff every bit in the argument is set.
version() [[nodiscard]] The 2-bit version field.
raw() [[nodiscard]] The full packed protocol byte.
seq_num, sender_id, receiver_id, fcs member (where applicable) Present only in the specializations whose policies call for them — see the wire layout table above.

packet<PacketSize, Topology, SequencePolicy, ChecksumPolicy>

A fixed-size, POD-aggregate wire packet: the templated header above, followed by a raw std::byte payload[payload_size] — nothing else. Application-layer concepts (handler ids, task ids, status codes) are not part of the packet; they live in the first bytes of the payload, interpreted entirely by the layer above.

using my_packet = packet<32, topology::network, sequenced, crc16>;

static_assert(my_packet::packet_size == 32);
static_assert(my_packet::payload_size == 32 - sizeof(my_packet::header_t));

Two static_asserts guard every instantiation: PacketSize must be word-aligned (PacketSize % sizeof(std::size_t) == 0 — DMA and serial drivers on embedded targets typically require it), and PacketSize must be strictly greater than the header's sizeof so at least one payload byte exists.

Checksum policies

checksum.hpp defines fifteen layout-only policy tags (value_type + size); compute.hpp/ compute.tpp define the matching compute<Policy> specializations that actually compute a value:

Policy FCS width Notes
none 0 bytes No checksum; the header carries no FCS field at all.
sum8 / sum16 / sum32 1 / 2 / 4 Additive sum, wrapping at the accumulator width.
crc8 / crc16 / crc32 / crc64 1 / 2 / 4 / 8 Table-driven, MSB-first (non-reflected), initial=0, final_xor=0. Identical on every target.
crc8_reflected / crc16_reflected / crc32_reflected 1 / 2 / 4 LSB-first mirror: reversed polynomial, initial and final_xor all-ones. crc32_reflected is the familiar zlib/PNG CRC-32 ("123456789"0xCBF43926). On ESP targets built with ECOMM_PREFER_ESP_CRC=1 these dispatch to the mask-ROM esp_rom_crcN_le routines; everywhere else a portable table produces bit-identical values.
fletcher16 / fletcher32 2 / 4 Fletcher checksum.
adler32 4 Modified Fletcher checksum (the same algorithm used by zlib, implemented independently here).
internet16 2 RFC 1071 one's-complement sum, as used by IP/TCP/UDP headers.

crcN and crcN_reflected are different checksums, not two speeds of the same one. They produce different values for identical input, so both peers must name the same policy. Prefer plain crcN unless you specifically want the zlib-compatible variant or both ends are ESP targets. ECOMM_PREFER_ESP_CRC only selects which code computes a reflected CRC — never the value — so it is safe to set per-target without coordinating peers. Measured gain on an ESP32 at 240 MHz is about 10% (see Benchmarks); there is no CRC peripheral on the ESP32, so the ROM routine is hand-tuned software, not hardware acceleration.

validator<Packet>

A stateless policy struct with two operations, specialized on whether ChecksumPolicy is none:

  • seal(packet) — finalizes a packet before transmission: zero packet.header.fcs, compute the checksum over all PacketSize bytes, write the result back into fcs. A no-op when ChecksumPolicy == none.
  • is_valid(packet) — zero a local copy's fcs, recompute, compare against the received value. Always true when ChecksumPolicy == none. Never mutates the caller's packet.

channel<Impl> calls both automatically — seal inside send<Packet>(), is_valid inside try_receive<Packet>() — so application code never calls validator directly in the common case.

The error envelope

When a packet's header has header_options::error set, its payload is reinterpreted as a length-prefixed error record (error.hpp):

+----------------+----------------------------+---------------------+----------+
|   error_code   |   error_message_length_t   |    message bytes    |   pad    |
|   (uint16_t)   |  (uint8/16/32, compile-time)|    length bytes     |   rest   |
+----------------+----------------------------+---------------------+----------+

error_message_length_t's width is selected at compile time — via etools::meta::smallest_uint_t<ECOMM_MAX_ERROR_MESSAGE_LENGTH> — from the ECOMM_MAX_ERROR_MESSAGE_LENGTH macro (default 65535): <= 255uint8_t, <= 65535uint16_t, <= 4294967295uint32_t.

using pkt = packet<32, topology::point_to_point, no_sequence, none>;
pkt p{header_type::data, header_options::error};

error_envelope<pkt::payload_size>::write(p.payload, error_code::checksum_mismatch, "bad crc");

if (auto view = as_error(p)) {
    // view->code, view->message (not null-terminated), view->length
}

error_code's top byte is a subsystem tag, so host-side dispatch can fan out without a giant switch: 0x00xx framing, 0x01xx transport, 0x02xx dispatch/hub, and 0x4000+ (error_code::user_range_begin) reserved for application-defined codes. as_error(packet) asserts (debug builds) that header_options::error is set; as_error_unchecked(packet) skips that precondition. Both return std::nullopt — not an assertion failure — for a structurally malformed envelope (declared length overruns the payload), because that is a wire condition, not a programmer error.

Endianness note: the error envelope's memcpy-based encoding currently assumes a little-endian host, enforced by a static_assert that fails loudly on a big-endian build rather than silently producing wrong bytes. A protocol-wide endianness sweep is the tracked follow-up; until then, building on a big-endian target is not supported.

Configuration macros (config.hpp)

All overridable via a compiler flag (-D...) before including any ecomm header, each guarded by a static_assert (or, for values needed at preprocess time, #error) naming the exact constraint violated:

Macro Default Valid range Meaning
ECOMM_PROTOCOL_VERSION 0 [0, 3] The 2-bit version stamped into every header byte. Not user-overridable in practice (it's a library constant, not meant to vary per build) but validated as if it were.
ECOMM_BOARD_ID 1 [1, 254] This node's identity. 0 is reserved ("unassigned"); 255 is the broadcast address.
ECOMM_DEVICE_N 2 [1, 254] Number of unicast devices in the system (excludes the broadcast address).
ECOMM_MAX_ERROR_MESSAGE_LENGTH 65535 [1, 4294967295] Caps the error-envelope message length and selects error_message_length_t's wire width — see The error envelope.
ECOMM_DEFAULT_TOPOLOGY ECOMM_TOPOLOGY_POINT_TO_POINT ECOMM_TOPOLOGY_POINT_TO_POINT or ECOMM_TOPOLOGY_NETWORK The template default for packet_header's and packet's Topology parameter. Always overridable per-instantiation regardless of this default.
ECOMM_PREFER_ESP_CRC 0 0 or 1 1 makes the crcN_reflected policies dispatch to the ESP mask-ROM esp_rom_crcN_le routines instead of the portable table. Purely an implementation choice — it never changes a computed value, so it needs no coordination between peers. Ignored on non-ESP targets.

The Channels Layer (ecomm::channels)

channel<Impl> — the CRTP base

A self-contained, two-way endpoint. Packet is a template parameter of send/try_receive themselves, not of the channel — one instance can carry as many distinct packet types as the caller needs, each validated and sealed independently per call. Impl supplies the hardware-specific byte transport by implementing two methods, for every Packet it wishes to support:

  • void do_send(const Packet&) noexcept — write raw bytes to the physical medium.
  • bool do_try_receive(Packet&) noexcept — read raw bytes into the supplied packet; return true if a complete packet was read.

Impl may provide these as ordinary methods fixed to one Packet (if it has a genuine per-packet constraint — see esp_async_wifi_channel below), or as member templates over Packet for full flexibility (arduino_serial_channel, arduino_wifi_channel — pure byte passthrough, no per-packet state). channel<Impl> doesn't care which; ordinary overload resolution decides per call, and fails to compile with a plain "no matching function" if Impl doesn't support the requested Packet.

channel composes validator<Packet> around those primitives:

user code
    |  send<Packet>(Packet&) / try_receive<Packet>()
    v
channel<Impl>                  <- validates, seals; never allocates
    |  do_send / do_try_receive
    v
Impl (e.g. arduino_serial_channel)   <- raw bytes to/from hardware
    v
hardware
Member Returns Behavior
send<Packet>(Packet&) send_result Packet deduced from the argument — no explicit template argument needed. Seals the packet, then do_sends it. Always send_result::ok — the unreliable channel makes no delivery guarantee; ok means the bytes were handed to the transport, not that they arrived.
try_receive<Packet>() std::optional<Packet> Packet must be named explicitly — there's no argument to deduce a return type from, a hard C++ rule. do_try_receives; returns the packet only if it passes validator::is_valid and, for network-topology packets, its receiver_id is ECOMM_BOARD_ID or 0xFF (broadcast). Disengaged otherwise — nothing available, corrupt, or misaddressed are indistinguishable at this layer.

CRTP (rather than virtual dispatch) is deliberate here: it avoids the vtable and indirect-call cost on a microcontroller, at the price of the transport type being fixed at compile time — an acceptable trade on an embedded target where the transport genuinely doesn't change at runtime.

arduino_serial_channel

arduino_serial_channel<tag = 0> link{Serial};   // any HardwareSerial instance
small_packet p{...};
(void)link.send(p);                             // Packet deduced
auto in = link.try_receive<big_packet>();        // a different packet type, same instance

Wraps HardwareSerial. Reads/writes packets as raw binary blobs — no framing, no sync bytes, just sizeof(Packet) bytes back to back. do_try_receive<Packet> returns false immediately if fewer than sizeof(Packet) bytes are available (checked via Serial.available()); never blocks. Holds no per-packet state, so one instance can freely mix packet types call to call. The tag parameter distinguishes multiple instances bound to different ports (Serial, Serial1, ...); two instances sharing a tag and a port is undefined behavior. Only compiled when ARDUINO is defined.

arduino_wifi_channel

WiFiServer server{80};
arduino_wifi_channel<tag = 0> link{server};

Wraps a synchronous WiFiServer/WiFiClient pair: the firmware is the TCP server, so a PC/Pi client connects to it. Only compiled when <WiFi.h> is available. On the first try_receive/send the channel accepts an incoming client and reuses it; only one active client is tracked at a time. Recommended with ChecksumPolicy = none, since TCP already guarantees delivery and integrity. Like arduino_serial_channel, it holds no per-packet state, so one instance can carry several packet types over the same connection.

On ESP32/ESP8266, prefer esp_async_wifi_channel instead — the synchronous WiFiServer API blocks the main loop waiting for clients and bytes, which esp_async_wifi_channel avoids entirely.

esp_async_wifi_channel

AsyncServer server{80};
esp_async_wifi_channel<BufferCapacity> link{server};   // BufferCapacity in bytes
// ... later:
server.begin();

A non-blocking channel built on AsyncTCP (ESP32) / ESPAsyncTCP (ESP8266). AsyncTCP drives the TCP stack from a background FreeRTOS task (ESP32) or from interrupt context (ESP8266) and fires data callbacks as bytes arrive; the channel appends them into a fixed-capacity byte ring, keeping the main loop always responsive. Unlike the synchronous channels above, this one genuinely cannot be made packet-agnostic in the callback itself — but it doesn't need to be: the ring holds raw bytes, not typed packets, so framing happens entirely on the read side, exactly like every other channel:

[TCP task / ISR]  onData callback -> append raw bytes into the byte ring (guarded)
[main loop]       try_receive<Packet>(out) -> if >= sizeof(Packet) bytes buffered, pop that many

This means one esp_async_wifi_channel instance can still carry several packet types — the fixed part is BufferCapacity (a byte count), not a packet type. Size it to comfortably hold at least one instance of every packet type you intend to receive (try_receive<Packet> static_asserts sizeof(Packet) < BufferCapacity).

Two consequences of buffering undifferentiated bytes instead of typed packets, both driven by the same cause — the ring has no packet-boundary information at buffer time:

  • Overflow resets to a clean run at offset 0. A delivery that doesn't fit in the remaining free space can't be resolved packet-aligned (there's no way to know where the next packet boundary falls), so rather than truncating it in place wherever the ring's write position currently sits — which could itself straddle the ring's physical wraparound point — the whole ring is reset and the overflowing delivery is placed fresh at offset 0, discarding whatever was buffered and not yet read. In the common case (an existing backlog plus this delivery don't fit together, but the delivery alone does) the new delivery survives whole and untorn; only a single delivery larger than the entire ring gets truncated (its first BufferCapacity - 1 bytes are kept). Reads after an overflow can still be misaligned if the discarded backlog didn't end on a packet boundary — size BufferCapacity generously relative to your packet size(s) and burst rate to make overflow itself vanishingly unlikely.
  • Disconnect clears the entire ring, not just a trailing partial packet — with no packet-boundary information, there's no way to tell a complete-but-unread packet apart from a partial one, so the only way to guarantee two connections' bytes never blend into one corrupted phantom packet is to discard everything at the connection boundary.

This is the only component in ecomm that requires synchronization — the need is platform-imposed, not a general design choice. A minimal critical-section guard (portMUX_TYPE spinlock on ESP32, noInterrupts()/interrupts() on single-core ESP8266) protects only the ring's head/tail index updates and the size check that precedes each copy; the copies themselves happen outside the lock (the producer only ever writes into the not-yet-exposed free region, the consumer only ever reads the already-committed region — the two never actually need the lock held simultaneously). send/try_receive must be called from the same execution context and are not safe to call concurrently with each other. Only one active AsyncClient connection is managed at a time; a second connection attempt while one is active is rejected. BufferCapacity must be >= 2 (one byte is always kept empty to distinguish full from empty).

arduino_i2c_master_channel — message-atomic I2C

#include <ecomm/channels/arduino_i2c_channel.hpp>   // needs <Wire.h>

arduino_i2c_master_channel<> link{Wire, 0x20};      // bus, 7-bit slave address

Requires <Wire.h> (ESP32, ESP8266 and STM32duino all provide it). Master role only — I2C is master-clocked, so a slave cannot initiate a transfer. send() issues a write transaction; try_receive<Packet>() issues a requestFrom and reads the reply.

This channel is message-atomic, and that distinction matters. Every I2C transfer is a bus transaction delimited by START/STOP conditions: the peripheral hands up a whole message or nothing. There is no partially-arrived packet to misframe, so unlike UART and TCP this channel declares static constexpr bool is_message_atomic = true, exposes no receive_raw, and is polled directly by router with no byte-level reassembly. Offering a raw byte reader would mean shredding a framed transaction back into a stream and re-deriving boundaries the hardware already gave us.

sizeof(Packet) must not exceed ECOMM_I2C_BUFFER_LENGTH (default 128 on ESP, 32 elsewhere) — the core would silently truncate a larger transfer, so a static_assert fails the build instead. The constructor does not call Wire.begin(); bus setup is the application's job, since the bus is shared by every device on it.

The stm32hal_* channels — bare-metal STM32

#include <ecomm/channels/stm32hal/channels.hpp>     // needs an ST HAL header

stm32hal_uart_channel<>       uart{huart2};
stm32hal_i2c_master_channel<> i2c{hi2c1, 0x20};

For STM32 projects that do not use an Arduino core. These are separate implementations rather than forwarding headers because the HAL is a genuinely different API: handle structs, explicit timeouts, interrupt callbacks instead of available().

stm32hal_uart_channel owns an interrupt-fed RX ring, because the HAL provides no buffer of its own and none of its receive calls match do_try_receive's non-blocking "is a whole packet ready yet" contract. Feed it from the HAL's RX interrupt:

ecomm::channels::stm32hal_uart_channel<> ch{huart2};
ch.begin();                                     // arm the first byte

void HAL_UART_RxCpltCallback(UART_HandleTypeDef*) {
    ch.on_byte_received();                      // ISR-side producer
}

Ring head/tail access is serialised with __disable_irq/__enable_irq — on a Cortex-M the producer is an interrupt, so a spinlock would deadlock against itself. Capacity is ECOMM_STM32HAL_UART_RX_CAPACITY (default 256); a byte arriving into a full ring is dropped, and the packet it belonged to then fails its checksum. The I2C HAL channel mirrors its Arduino-core counterpart exactly, including its message-atomicity.

reliable_channel — acknowledgement and retry

reliable_channel<Impl, Packet, ClockPolicy, MaxRetries = 3, BufferDepth = 1> link{/* Impl args */};

Wraps an Impl (itself a channel<Impl>) and adds stop-and-wait reliability. Requires Packet::header_t::has_seq_num (i.e. SequencePolicy == sequenced), enforced by a static_assert.

Unlike channel<Impl>, reliable_channel fixes its own Packet per instance — its ack/retry sequence counters and inbound staging ring are per-instance state describing one ongoing packet stream, not a fact about Impl, so they can't be multiplexed across packet types the way a stateless channel<Impl> call can. Use one reliable_channel per packet type; since it owns its Impl by value (constructed from whatever arguments you pass to reliable_channel's own constructor), a stateless Impl like arduino_serial_channel<> can back several reliable_channels pointed at the same physical port, one per packet type.

send is a blocking call — it busy-polls the underlying channel for an acknowledgement, retransmitting up to MaxRetries times. Worst case, the caller's thread (or Arduino loop) is occupied for MaxRetries * ClockPolicy::timeout_ticks() ticks with no yield, sleep, or cooperative scheduling; there is no asynchronous variant.

  • seq_num is a wrapping std::uint8_t, independent per direction (_tx_seq outbound, _rx_seq expected inbound) — no ambiguity between data and ack traffic.
  • An ack for seq_num == N is sent with seq_num == N and header_options::ack set, using the same Packet type with a zeroed payload.
  • try_receive dequeues from an internal staging buffer first, then polls the underlying channel. Valid inbound data packets are auto-acked and returned (or staged, if the caller isn't ready yet). Duplicate packets (stale seq_num) are re-acked and silently discarded. Ack packets are consumed internally and never surfaced.

ClockPolicy must provide using tick_type = /* unsigned integral */;, static tick_type now() noexcept;, and static tick_type timeout_ticks() noexcept; — tick arithmetic uses unsigned subtraction, which wraps correctly on every target with no special-casing. All storage is in-object (the wrapped channel, a Packet[BufferDepth] staging array, two std::uint8_t counters) — no heap.


Design Philosophy

The three-tier error ladder

ecomm never throws C++ exceptions, anywhere, ever. Errors are handled at the earliest possible tier:

  1. static_assert (compile time). The first line of defense, used aggressively, with detailed messages — the message is the only thing a user sees when a build breaks. Packet-size word-alignment, minimum packet size, and checksum-underlying-type constraints are all enforced here.
  2. assert (debug builds). For invariants that can't be checked at compile time but are treated as programmer errors (e.g. as_error called on a packet without header_options::error set). These vanish under NDEBUG, so they must never have side effects release builds depend on.
  3. Error codes carried in packets (runtime, release). The last resort, for conditions that are part of the protocol's normal operation — checksum mismatch, malformed packet, peer timeout. Carried via the error envelope (see above); strongly typed, stable across versions, easy to inject into a response without a separate allocation path.

No dynamic allocation

No new, malloc, std::vector, or std::string inside anything under ecomm/protocol/, ecomm/channels/, or ecomm/fabric/. Every buffer is a std::byte[N] member or std::array<std::byte, N>, sized by a std::size_t template parameter the compiler knows at every call site. The only sanctioned exceptions: allocations inside vendor SDK calls (ESP-IDF, Arduino core) that can't be avoided, and — when heap ownership is genuinely unavoidable in library code — a std::unique_ptr<T, Deleter>, never a raw owning pointer.

Strategy via templates, not virtuals

Behavior varies at compile time (policy template parameters, tag types, if constexpr), not at runtime via inheritance — validator<packet<..., none>> vs. validator<packet<..., crc16>> is the canonical example: two entirely different code paths selected by the compiler, with no runtime branch anywhere. channel<Impl>'s CRTP base follows the same principle for the transport layer.


The hub module

ecomm::hub<Channels...> (ecomm/fabric/hub.hpp) combines several channels behind one send<Packet>()/try_receive<Packet>() surface, addressed with an explicit, caller-known Packet type per call — a USB hub for communication links: plug in a UART leaf link and a Wi-Fi link, then send/receive without caring which one a packet went out or came in on. Any mix of channel<Impl>-derived types and reliable_channel<Impl, Packet, ...> instances is accepted.

hub is for when you know the packet type. When you don't — polling for whatever arrives and routing it to the right handler — see the router module below instead; that's a different responsibility, with its own type. Nothing stops using both over the same channels (they're held by reference). The names mirror real networking hardware: a hub repeats to every port with no awareness of content, a router makes a forwarding decision based on what arrived — exactly the difference between these two types.

Routing is capability-based, not identity-based. Since channel<Impl> moved Packet to a per-call template parameter, most channels have no single fixed packet type to compare against — arduino_serial_channel<> and arduino_wifi_channel<> accept any packet type per call, and esp_async_wifi_channel<BufferCapacity> accepts any packet type that fits its byte ring. reliable_channel is the one exception, staying fixed to one Packet (see its own docs for why). hub reflects this: instead of asking "what is this channel's packet type," it asks "can this channel handle this Packet, right now" — checked structurally, not via a packet_t alias. The practical effect: send<Packet>() reaches every active sender that can handle Packet, which for a hub of only flexible channels usually means all of them at once.

arduino_serial_channel<> serial_link{Serial};   // e.g. the robot's own actuators
arduino_wifi_channel<>   wifi_link{server};     // e.g. the outside world

hub h{serial_link, wifi_link};   // CTAD deduces Channels... from the arguments

(void)h.send(out);                            // Packet deduced; reaches BOTH -- both are flexible
if (auto in = h.try_receive<small_packet>()) { /* first small_packet found */ }

h.set_role<decltype(wifi_link)>(role::receiver);   // no longer a sender
(void)h.send(out);                                  // now serial_link only, until re-enabled

send() deduces Packet from its argument like any function template. try_receive<Packet>() cannot deduce Packet — there's no argument to deduce a return type from, a hard rule of C++ template argument deduction — so it must be named explicitly.

Ownership: hub does not own its channels. It stores references to channels the caller already constructed and keeps alive elsewhere (typically as static/file-scope objects, exactly like every channel already does for the hardware it wraps). This is deliberate: esp_async_wifi_channel registers its own address with AsyncTCP at construction time, so an owning container that moves a channel into itself after construction would leave AsyncTCP holding a dangling pointer. Reference storage makes that bug structurally impossible — a channel is never relocated.

Two static_asserts fire at hub<...>'s own instantiation point, not deep inside some call site, if misused: every channel must be recognizable as some kind of channel — either it derives from channel<Channel> (the flexible, per-call-Packet shape), or it exposes using packet_t = SomePacket; with matching non-template send/try_receive (the fixed shape, e.g. reliable_channel) — and every channel type in the pack must be pairwise distinct (use each channel's tag parameter to disambiguate two instances of the same transport). A third check is per-call: send<Packet>/try_receive<Packet> each static_assert that at least one channel in the hub can currently handle the requested Packet — naming a type no channel supports is a compile error, not a silent no-op.

send<Packet>() returns one std::optional<send_result> per matching channel, in declaration order among that subset, disengaged for a channel that wasn't an active sender — so a reliable_channel timing out inside a hub is visible to the caller, not silently swallowed. Note that mixing a blocking reliable_channel and a non-blocking channel that both handle the same packet type means send()'s worst-case latency for that call is the sum of its matching members': hub calls every active, matching sender in sequence and cannot parallelize a blocking one away.

set_role<Channel>(role) (ecomm::channels::role, channels/role.hpp) sets a channel's participation to exactly one of four states — role::sender, role::receiver, role::transceiver (both; every channel's starting state), or role::none (neither) — in a single call, rather than toggling two independent flags with separate methods. This is the way to keep a technically-capable channel out of calls for a packet type you don't want it to see. try_receive<Packet>() polls active, matching receivers in declaration order and returns the first packet found — a channel that always has data can starve later channels in the pack within a single call; call again to keep draining.


The router module

ecomm::router (ecomm/fabric/router.hpp) polls a set of channels for whichever packet type arrives first and routes it to the handler for that type — for when you don't know the packet type in advance, the complement to hub's explicit send<Packet>/try_receive<Packet>.

Construct it once from its on_channel(...) groups, then poll it. router owns a small per-channel reassembly buffer (see below), so it must persist across polls — it is not a throwaway temporary. Class template arguments are deduced from the groups (a deduction guide is provided), so no explicit template arguments are needed:

ecomm::router r{
    on_channel(serial_link,
        [](small_packet& in) { /* ... */ },
        [](big_packet& in)   { /* ... */ }
    )
};
while (r.try_receive_any()) { }   // returns bool -- drains until nothing is left

Candidate types come from the handlers, not from a template argument: each handler is a callable taking one Packet&, and the packet types a group's handlers declare are the types polled for on that group's channel. No packet type is ever named twice. Handlers must declare a concrete parameter type; a generic [](auto& p) carries no type to poll for and is rejected with a static_assert.

Why it reassembles instead of doing a plain typed read. Streaming channels (everything derived from channel<Impl> — serial, both Wi-Fi channels) deliver raw bytes with no framing, and a typed try_receive<Packet>() consumes sizeof(Packet) bytes the moment that many are buffered, before it can check validity. With two candidate sizes on one channel that is destructive: a 48-byte packet that has only partially arrived (say 26 of 48 bytes) fails the 48-byte probe harmlessly, but then satisfies a 16-byte probe on byte count alone — consuming 16 bytes of the still-arriving larger packet, failing validation, and destroying it, with no way to recover since the transport has no peek. No backlog is required; it happens on the very first poll of a mid-arrival packet, and draining more often makes it more likely.

router avoids this entirely: for each streaming channel it pulls raw bytes (channel::receive_raw) into a per-channel buffer sized to that group's largest candidate, and frames them itself — validating a candidate before consuming it, and keeping anything that doesn't yet form a complete, valid packet for the next poll. A partially-arrived packet simply waits in the buffer until the rest arrives. reliable_channel is exempt: it is message-atomic (it delivers whole, already-framed packets and carries a single packet type), so router polls it directly with no buffering.

Candidates are framed largest-first, automatically, decided at compile time (etools::meta::sort_t with etools::meta::size_greater). A smaller candidate can validate against the leading bytes of a larger queued packet only by an FCS collision (astronomically unlikely), so testing large-to-small frames each packet correctly — handler declaration order never affects behavior. Concretely, three whole 16-byte packets buffered ahead of a 48-byte candidate now drain as three 16-byte packets (each 16-byte prefix passes its crc16 while the 48-byte crc32 interpretation fails), rather than being mistaken for one 48-byte packet and destroyed — the destructive-read bug this design fixes.

Two guardrails are enforced at compile time for a streaming channel carrying more than one candidate type: candidate sizes must be pairwise distinct (two equally-sized types can't be told apart by prefix framing), and every candidate must carry a checksum (ChecksumPolicy != none) — a checksum-less packet's is_valid is unconditionally true, so a torn read would dispatch as genuine. A channel carrying a single packet type has neither hazard and may still use none.

When different channels carry different packet types, one on_channel(...) group per channel keeps each framed only for the types it actually carries:

ecomm::router r{
    on_channel(wifi_link,
        [](telemetry_packet& in) { /* ... */ },
        [](command_packet& in)   { /* ... */ }
    ),
    on_channel(serial_link,
        [](command_packet& in)   { /* ... */ }
    )
};
while (r.try_receive_any()) { }

Channel is deduced from the argument to on_channel — no explicit template argument needed. The channels are referenced, not owned, and must outlive the router. Channels are polled in the order their groups are written; the first that yields a packet dispatches it and the call returns. A channel not named in a router is never polled by itrouter has no persistent enable/disable state the way hub::set_role does; which channels participate is fixed by the groups it was built from.


The Python Client

ecomm-python/ is a byte-exact, dependency-checked Python implementation of this same wire protocol — the packet format, every checksum algorithm, and the channel framing — so a laptop, CI runner, or Raspberry Pi can talk to real ecomm firmware directly. Every checksum algorithm and header layout it implements was verified against this library's actual compiled output, not re-derived from this document. Where this library uses compile-time template parameters, the Python side uses a runtime configuration object (PacketSchema) that plays the same role, resolved at object-construction time instead of compile time.


Examples

examples/async_tcp/ — a complete Raspberry Pi ⇄ ESP32 demo built on esp_async_wifi_channel (firmware side) and the Python AsyncTcpChannel (Pi side): the Pi serializes a small {name, age} record into a packet payload; the ESP32 parses it and replies with a greeting. Includes the firmware .cpp, the Python client, and a README with the schema-matching table required to keep two independently-written peers wire-compatible.


Benchmarks

Measured on real hardware, not simulated. Full method, caveats, and reproduction steps are in benchmarks/wifi_latency/RESULTS.md.

Boards: NodeMCU v3 (ESP8266, 80 MHz) and an ESP32 DevKit (240 MHz), both running esp_async_wifi_channel<2048> with packet<64, topology::network, no_sequence, crc32>, against a Linux host on the same 2.4 GHz LAN. Signal was a marginal −74 to −77 dBm, so the latency tails are an upper bound rather than a typical result.

Round-trip latency

Board-timed (the board stamps micros() and closes the loop against its own clock, so host-side scheduling noise drops out), 200 exchanges:

ESP8266 ESP32
min 3.25 ms 5.5–10.4 ms
avg 4.62 ms 9.2–14.8 ms
max 8.71 ms 19–203 ms
loss 0 / 200 0 / 200
corrupt 0 0

ESP32 figures span five runs across two sessions; loss and corruption were zero in every one, and the spread is 2.4 GHz channel contention rather than library behaviour — the same binary's on-chip phase 4 numbers reproduced bit-identically an hour apart.

The ESP8266 was the faster and more predictable endpoint, despite the ESP32's 3× clock and 2.3× faster checksums. Phase 4 rules out ecomm's own code — 11 µs of checksum work cannot explain a 4.6 ms gap. The difference is in the radio stack: the ESP32 Arduino core services Wi-Fi from a FreeRTOS task scheduled against the app CPU, while the ESP8266 core polls it inline from loop(). For latency-critical control loops, do not assume the bigger chip gives the faster link — measure.

Measured from the host, the same ESP8266 link showed a 6.00 ms median but a 39.8 ms max: the board's own worst case is 4.6× tighter, and that gap is Python and OS scheduling, not the library, the radio, or the protocol.

Per-packet cost of ecomm itself (radio excluded)

20,000 iterations each, timed on-chip:

Checksum policy Packet ESP8266 seal() ESP32 seal()
none 64 B 112 ns 46 ns
crc8 64 B 12.2 µs 3.58 µs
crc16 64 B 16.2 µs 4.68 µs
crc32 64 B 11.1 µs 4.76 µs
crc32 (ESP32 ROM path) 64 B 4.27 µs
crc32 256 B 42.3 µs 18.5 µs

Four things worth knowing:

  • none costs 112 ns on ESP8266, 46 ns on ESP32 — single-digit cycle counts. The policy really is free when unused, which is what the compile-time design promises.
  • Checksums are ~0.1–0.3% of end-to-end latency. Immaterial for this library's target workload.
  • crc32 is faster than crc8 and crc16 on ESP8266. All three run the same table-driven byte loop, but 32-bit operations map to native word loads while narrower types add masking. Choosing a narrower CRC to save time is a pessimisation there — pick the width for its error-detection strength, not for speed.
  • The ESP32 ROM CRC buys ~10%, not an order of magnitude (4.76 → 4.27 µs). There is no CRC peripheral on the ESP32 — esp_rom_crc32_le is a software routine in mask ROM, so both paths are byte-at-a-time table CRCs and the gap is hand-tuned assembly, not hardware acceleration. Note the crcN_reflected policies are a different checksum, not a faster crcN: the choice is wire-visible and both peers must name the same policy.

Choosing a checksum policy over TCP

These runs used crc32 to measure what a checksum costs. Over TCP, none is the correct choice — TCP already guarantees integrity, so a packet-level CRC duplicates it (this is what examples/async_tcp/ does). Measured directly, switching to none produced no detectable latency difference: the work is real but buried under radio variance. Keep checksums for unframed or lossy transports — UART, and any link where router frames several packet types, where the checksum is what stops a misframed read being dispatched as genuine.

Known limitation: unpaced sending is silently capped

Sending in a tight loop over esp_async_wifi_channel saturates at a fixed byte count — 1024 B (16 packets) on ESP8266, ~5760 B (90 packets) on ESP32 — verified by bisection on both. Every call still returns send_result::ok.

The cause is that do_send discards AsyncTCP's write() return value, which goes short once its TX buffer fills; the refusal is dropped and ok is reported for a packet the transport never queued. The cap tracks that buffer, so it is a per-platform constant, not a protocol limit. Nothing that did arrive was ever corrupt on either board — framing and checksums held throughout, so this is backpressure, not a protocol failure.

Until it is addressed: keep bursts under the platform's cap, pace sends, or use reliable_channel, whose stop-and-wait ack keeps at most one packet outstanding and surfaces a genuine failure as send_result::timeout.


Edge Cases & Behavior

Situation Behavior
try_receive() on any channel when nothing has arrived Disengaged std::optional — indistinguishable from "corrupt" or "misaddressed" at this layer.
Received packet fails validator::is_valid Silently dropped by channel::try_receive — never surfaced as an error, only as a disengaged optional.
network-topology packet's receiver_id doesn't match ECOMM_BOARD_ID or 0xFF Silently dropped by channel::try_receive (added 2026-05-28).
seal() called twice on the same packet without re-zeroing Idempotent — seal always zeroes fcs before hashing, so a second call reproduces the same value.
ChecksumPolicy == none seal is a no-op; is_valid always returns true.
as_error(packet) on a packet without header_options::error set assert fires in debug builds (a programmer error); use as_error_unchecked to skip the check.
A structurally malformed error envelope (declared length overruns the payload) as_error/as_error_unchecked return std::nullopt — a wire condition, not an assertion failure.
reliable_channel::send never receives an ack Blocks for up to MaxRetries * ClockPolicy::timeout_ticks(), then returns send_result::timeout.
A duplicate (stale seq_num) packet arrives at reliable_channel::try_receive Re-acked, discarded, not surfaced to the caller.
esp_async_wifi_channel's byte ring doesn't have room for an entire incoming delivery The ring is reset and the delivery is placed fresh at offset 0, discarding the prior backlog. The delivery survives whole unless it alone exceeds BufferCapacity, in which case only its first BufferCapacity - 1 bytes are kept.
esp_async_wifi_channel's client disconnects with unread data still buffered The entire ring is cleared, including any complete-but-unread packets — not just a trailing partial one (the ring has no packet-boundary information at buffer time to tell the two apart).
A second client connects to esp_async_wifi_channel while one is already active Rejected; only one active AsyncClient at a time.
PacketSize not word-aligned, or too small for the header Compile error (static_assert), not a runtime failure.
Building on a big-endian host Compile error (static_assert in error.hpp) rather than silently wrong wire bytes.

Assumptions & Limitations

  1. No self-description on the wire. There is no type tag, length prefix, or version negotiation at the protocol level beyond the fixed 2-bit version field. Two peers are wire-compatible exactly when they instantiate identical template arguments; a mismatch produces silently wrong bytes, not an error.
  2. Single-threaded by assumption, except esp_async_wifi_channel's narrow, platform-imposed critical section (see above). No mutexes or atomics anywhere else in the library. If a feature genuinely requires broader synchronization, that is a design discussion, not something to bolt on locally.
  3. Little-endian only, currently. The error envelope's memcpy-based encoding assumes a little-endian host (enforced by static_assert); the rest of the protocol has not yet had a dedicated endianness sweep. Building for a big-endian target is unsupported today.
  4. Trivially-copyable packets only. arduino_serial_channel, arduino_wifi_channel, and esp_async_wifi_channel all static_assert(std::is_trivially_copyable_v<Packet>) — they move packets with raw memcpy/byte-array writes.
  5. The payload has no schema. ecomm guarantees the header and a fixed byte count; how the payload's bytes are laid out is entirely the application's concern — see the error envelope for the one built-in example of imposing a sub-structure on it, and ecomm-python's README for the general principle stated explicitly.
  6. hub composes channels with an explicit, caller-known Packet per call; it holds no packet-type-to-handler dispatch table. It fans a packet out to every active sender and returns the first packet any active matching receiver has for the Packet you named — deciding what to do with a received packet, or routing whichever of several possibly-unknown types shows up, is ecomm::router's job (see The router module), not hub's.
  7. Scoped to a small, fixed-size-packet, single-owner protocol. Many conventions from general wire-protocol design — variable-length messages, streaming validation, heterogeneous-MTU links, multi-tenant routing — solve problems this protocol doesn't have. Don't assume they apply here without checking; see project/guidelines.md for the fuller version of this principle.

When to Use ecomm (and When Not To)

Good fit

  • Firmware on ESP32/ESP8266 (or other Arduino-compatible boards) that needs compact, predictable, fixed-size packet exchange with a coordinator or with other nodes.
  • A robotics-style topology: a small number of known peers, short frequent messages, latency and predictability mattering more than throughput.
  • Projects that want compile-time-checked wire compatibility (mismatched template arguments are a build-time concern, not a runtime surprise) and are willing to recompile both ends together, or keep them in careful lockstep, when the packet format changes.

Reach for something else when you need

  • A self-describing, versioned, or evolvable wire format across independently-deployed peers — Protocol Buffers, FlatBuffers, CBOR, etc.
  • Variable-length messages, streaming/partial validation, or heterogeneous-MTU links — ecomm's fixed packet size and flat wire format don't fit that shape.
  • Packet-type-to-handler dispatch across independently-deployed peers with unstable message shapes — ecomm::router handles the "which handler for this packet" routing, but only within this library's fixed-size, compile-time-typed packet model (see The router module); it is not a general message bus.

Building & Testing

The project uses CMake. BUILD_TESTING (from the standard CTest module, default ON when this is the top-level project) gates the test suite, which uses GoogleTest (fetched automatically):

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
cd build && ctest --output-on-failure
# or, for an aggregated summary with failure locations:
tools/run_tests.sh

468 tests across fourteen binaries: test_packet_header, test_packet, test_validator, test_error, test_compute, test_compute_esp_rom (protocol layer — the last two build the same source twice, against the portable table and against a mock ESP ROM, and must agree bit-for-bit); test_arduino_serial_channel, test_arduino_wifi_channel, test_arduino_i2c_channel, test_esp_async_wifi_channel, test_stm32hal_channels, test_reliable_channel (channel layer, against drop-in mock Arduino/AsyncTCP/STM32-HAL headers so the suite runs on a plain host with no hardware or vendor toolchain); test_hub, test_router (pure-software mock transports).


Project Layout

ecomm/
  CMakeLists.txt              # top-level build: fetches etools/eser, defines the `ecomm` INTERFACE target
  ecomm/
    protocol/
      protocol.hpp              # aggregator
      config.hpp                 # overridable macros (ECOMM_BOARD_ID, ECOMM_PROTOCOL_VERSION, ...)
      topology.hpp                # topology enum
      sequence.hpp                # no_sequence / sequenced tags
      node_ids.hpp                 # sender_id/receiver_id storage for network topology
      checksum.hpp                # the 12 checksum policy tags (layout only)
      compute.hpp/.tpp             # the checksum algorithms
      header_type.hpp              # header_type enum
      header_options.hpp           # header_options flag enum + bitmask
      header_layout.hpp/.tpp       # the 8 standard-layout header specializations
      packet_header.hpp/.tpp       # packet_header<Topology, SequencePolicy, ChecksumPolicy>
      packet.hpp/.tpp               # packet<PacketSize, Topology, SequencePolicy, ChecksumPolicy>
      validator.hpp/.tpp            # validator<Packet>: seal() / is_valid()
      error.hpp/.tpp                 # error_code, error_envelope<PayloadSize>, as_error()
    channels/
      channels.hpp                # aggregator (platform-conditional includes)
      send_result.hpp              # send_result enum
      role.hpp                      # channels::role enum -- a channel's participation in a hub
      channel.hpp/.tpp              # channel<Impl> CRTP base
      channel_traits.hpp            # shared capability-checking traits (used by hub and router)
      arduino_serial_channel.hpp/.tpp
      arduino_wifi_channel.hpp/.tpp
      esp_async_wifi_channel.hpp/.tpp
      arduino_i2c_channel.hpp/.tpp  # message-atomic I2C master (no receive_raw)
      reliable_channel.hpp/.tpp     # ack/retry wrapper around any channel<>
      esp32/ esp8266/ stm32duino/   # per-board aggregators (forward to the above)
      stm32hal/                     # bare-metal STM32: uart + i2c, real implementations
    fabric/
      hub.hpp/.tpp                # hub<Channels...>: explicit, caller-known-Packet send/try_receive
      router.hpp/.tpp              # router: heterogeneous, handler-driven try_receive_any
  ecomm-python/                 # the Python client (see its own README)
  examples/
    async_tcp/                    # Raspberry Pi <-> ESP32 worked example (spans both languages)
  benchmarks/
    wifi_latency/                 # on-hardware latency benchmark + RESULTS.md
  tests/                         # GoogleTest suite, mirrors ecomm/ (protocol/, channels/, fabric/)
  tools/
    run_tests.sh                 # build + run with an aggregated pass/fail summary
  project/
    guidelines.md                 # contribution and style guide

License

MIT License — permissive, no restriction on commercial use. See LICENSE for the full text.


Contributing

See project/guidelines.md for the full contribution and style guide — branching model, commit message format (three -m flags for source commits, including an API schematic; two for test-only commits), the architectural principles summarized in Design Philosophy above, and the naming/documentation conventions. In short: read the file you're about to modify first (the codebase is heavily templated — wrong assumptions about a type or signature break silently), question whether a "standard" convention actually applies to this specific, small, fixed-size-packet protocol before reaching for it, and never fabricate a rationale for a design choice you don't understand — ask instead.


Contact

Open an issue on the repository, or reach out via mtik.philosopher@gmail.com for questions and collaboration proposals.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages