Date: 2026-02-01
Status: Design Complete (v1 scope)
References: protocols.h, packets.h, status.h (v2+), cobs.h design documents
PacketProtocol is a reliable serial transport library for Arduino ↔ RPi/Host communication. It provides:
- Automatic frame synchronization via COBS encoding
- Deterministic packet structure with sequence tracking
- Hardwired RESET handshake for clean state machine
- Best-effort sensor data (freshness > completeness)
- Reliable control (CONTROL packets preempt everything)
Architecture: Single serialized channel (UART/USB CDC) with strict ordering, no parallel data streams.
┌─────────────────────────────────────────┐
│ Application Layer (ROS2 Node / Sketch) │
├─────────────────────────────────────────┤
│ Protocol Engine (State Machine) │ ← ok() pattern (v2+)
│ - Handshake (RESET/RESET_ACK) │ ← ConnectionState
│ - SEQ validation │ ← DESYNC recovery
│ - Watchdog (timeout → RESET) │
├─────────────────────────────────────────┤
│ Packet Layer │
│ - build() / parse() │ ← protocols.h types
│ - SEQ management (currentSeq/advanceSeq) │
├─────────────────────────────────────────┤
│ COBS Framing │
│ - encode() / decode() │ ← 0x00 frame delimiters
│ - Automatic sync on frame boundary │
├─────────────────────────────────────────┤
│ Serial Transport (Platform-Specific) │
│ - Arduino Serial / POSIX termios │
│ - Byte-level I/O │
└─────────────────────────────────────────┘
┌─────────────────┐
│ protocols.h │ (enums, constants, types)
│ - PacketType │
│ - ControlPayload│
│ - ConnectionState
│ - StatusCode │
└────────┬────────┘
│
┌────┴─────────────────────┬──────────┐
│ │ │
┌───▼──────┐ ┌──────────┐ ┌─▼────┐ ┌─▼──────┐
│ packets.h │ │ status.h │ │ cobs.h │ (v1 only)
│ - Packet │ │ - Status │ │ │
│ - build() │ │ - ok() │ │ - encode│
│ - parse() │ │ - get() │ │ - decode│
│ - SEQ mgmt│ │ - set() │ │ │
└─────────┘ └──────────┘ └────────┘
▲ ▲
└──────────┬───────────────┘
│
┌──────▼──────────┐
│ Protocol Engine │
│ (RX + TX) │
└─────────────────┘
✅ protocols.h - Packet types, CONTROL/DATA/COMMAND, SEQ semantics
✅ packets.h - Build/parse wire format, SEQ management (pure build, advanceSeq)
✅ cobs.h - Encode/decode, automatic frame sync
⏳ status.h - Full ok() pattern, StatusCode tracking
⏳ watchdog - Timeout detection (can use simple timer instead)
⏳ Fragmentation - Payloads > 255 bytes split across packets
Why v1 is complete: RESET/RESET_ACK handshake works without status tracking. RX thread can drop malformed packets silently. No explicit state monitoring needed for basic operation.
[ COBS-Encoded Packet ] [ 0x00 Frame Delimiter ]
[ TYPE ][ LEN ][ SEQ ][ PAYLOAD... ]
Byte 0 : TYPE (0x00=CONTROL, 0x01=DATA, 0x02=COMMAND)
Byte 1 : LEN (0-255, payload length)
Byte 2 : SEQ (0-255, wraps, auto-managed)
Byte 3-N : PAYLOAD (0-255 bytes)
Raw (pre-COBS):
[ 0x00 ][ 0x01 ][ 0x00 ][ 0x01 ]
TYPE LEN SEQ VERSION
After COBS encode (no zeros to escape):
[ 0x05 ][ 0x00 ][ 0x01 ][ 0x00 ][ 0x01 ]
On wire:
[ 0x05 ][ 0x00 ][ 0x01 ][ 0x00 ][ 0x01 ][ 0x00 ]
▲
frame delimiter
1. Build RESET packet
- Type: CONTROL
- Payload: VERSION (1 byte)
- SEQ: 0 (auto-managed)
2. Encode with COBS, add 0x00 delimiter, write to serial
3. Advance SEQ (only on successful write)
- pkt.advanceSeq() (SEQ: 0 → 1)
4. Wait for RESET_ACK (blocking with timeout)
5. On receive RESET_ACK:
- Parse packet
- Verify VERSION matches
- Transition to ACTIVE state
- status.set(OK) [v2+]
6. Send application data (DATA/COMMAND packets)
1. RX byte pump running
- Accumulate bytes until 0x00
- COBS decode
- Dispatch by TYPE
2. Receive RESET packet
- Parse: TYPE=CONTROL, payload[0]=VERSION
- Reset protocol state
- pkt.resetSeq() (SEQ → 0)
- Clear any pending data
3. Build RESET_ACK packet
- Type: CONTROL, Payload: VERSION
- SEQ: 0 (after reset)
4. Encode and send RESET_ACK
- pkt.advanceSeq() (SEQ: 0 → 1)
5. Enter ACTIVE state, accept DATA/COMMAND
Application
↓
Build packet (TYPE, PAYLOAD)
├─ pkt.build(type, payload) [pure, no side effects]
├─ Returns: [ TYPE ][ LEN ][ SEQ ][ PAYLOAD ]
↓
COBS encode
├─ encoded = cobs_encode(raw_pkt)
├─ Add 0x00 frame delimiter
↓
Serial write
├─ written = serial.write(encoded)
├─ if (written == len(encoded))
│ pkt.advanceSeq() ← Only advance on full write
└─ else
log warn, retry or discard
Serial read (byte pump thread)
↓
Accumulate bytes until 0x00
├─ If 0x00: frame boundary found
├─ If buffer empty: noise, discard
↓
COBS decode
├─ try: raw_pkt = cobs_decode(buffer)
├─ catch: malformed, drop frame, continue
↓
Parse packet
├─ info = pkt.parse(raw_pkt)
├─ Extract: TYPE, SEQ, PAYLOAD
↓
Dispatch by TYPE
├─ CONTROL: Protocol layer (RESET_ACK, EVENT, etc)
├─ DATA: Application (sensor data)
└─ COMMAND: Application (command handler)
Protocol layer validates SEQ
├─ delta = recv_seq - expected_seq
├─ if (delta < 0 || delta > DESYNC_THRESHOLD)
│ status.set(DESYNC) [v2+] or send RESET
└─ else
update expected_seq for next packet
┌─────────┐ RESET ┌─────────┐
│ IDLE │────────────────────→ │WAIT_ACK │
└─────────┘ (seq=0, version) └────┬────┘
│
RESET_ACK
(seq=0, version)
│
┌────▼────┐
│ ACTIVE │
└─────────┘
Every 5 seconds (or less frequent):
Host → Device: HEARTBEAT packet
- Type: CONTROL
- Payload: empty (LEN=0)
- Shows liveness
Device processes and continues (no response needed)
Device detects something noteworthy (low battery, sensor cal needed)
Device → Host: EVENT packet
- Type: CONTROL
- Payload: EVENT_CODE (1 byte)
- Protocol stays ACTIVE
Host logs event, may decide to escalate with RESET if repeated
- Monotonic counter (0-255, wraps)
- Detects dropped/reordered packets
- Enables asymmetric reset detection
- No ACKs per packet (CONTROL-only packets ACK'd, not data)
- No retransmission (drop lost packets)
- No time alignment (just packet order)
Building:
auto raw = pkt.build(type, payload); // Uses current SEQ
// pkt.currentSeq() == 0 (for first packet)
encoded = cobs_encode(raw);
written = serial.write(encoded);
if (written == len(encoded)) {
pkt.advanceSeq(); // Now pkt.currentSeq() == 1
}Parsing:
auto info = pkt.parse(raw); // Extract SEQ from packet
uint8_t recv_seq = info.seq;
// Validate
if (recv_seq != expected_seq) {
if (gap is small) → tolerate, continue
if (gap is large) → send RESET (desync)
if (backward) → send RESET (asymmetric)
}RESET:
pkt.resetSeq(); // SEQ = 0
// Next packet will use SEQ=0Corrupt bytes received: 0xAA 0xBB 0xCC 0x00
RX thread tries to decode [0xAA, 0xBB, 0xCC]
→ COBS decode fails (invalid structure)
→ Exception caught, frame dropped
→ Continue waiting for next 0x00
Next good frame arrives: 0x02 0x01 0x02 0x00
RX thread successfully decodes and dispatches
Result: Automatic recovery at frame boundary.
Host sends packet, device reboots
Host waits for response (timeout 5 sec)
→ No response, status.set(TIMEOUT) [v2+]
→ Host sends RESET
Device boots, receives RESET
→ Resets protocol state (SEQ=0)
→ Sends RESET_ACK
Host receives RESET_ACK
→ status.set(OK) [v2+]
→ Resume normal operation
Random bytes on line: 0xFF 0x12 0x34 0x00
RX thread tries to decode [0xFF, 0x12, 0x34]
→ offset=0xFF, expects 254 bytes but only 1 left
→ Tries to read past end
→ COBS decode fails
→ Frame dropped
Next real packet comes in cleanly
Result: Noise is contained to single frame, auto-recovered.
Serial read/write happens in exactly one place.
while (true) {
uint8_t byte = serial.read(); // Blocking
if (byte == 0x00) {
// Frame boundary
try {
auto raw = cobs_decode(buffer);
dispatch_packet(raw); // No protocol state here!
} catch (...) {
// Drop frame
}
buffer.clear();
} else {
buffer.push_back(byte);
if (buffer.size() > MAX_ENCODED) {
buffer.clear(); // Sanity check
}
}
}Properties:
- No protocol state (just byte pump)
- Never blocks on application logic
- Exception-safe (drop frame, continue)
while (true) {
// Handle incoming packets (dispatch queue)
if (packet_available()) {
auto pkt = packet_queue.pop();
handle_packet(pkt); // Updates connection state
}
// Check watchdog
if (time_since_last_packet > TIMEOUT) {
send_reset();
}
// Send application data (if ACTIVE)
if (connection_state == ACTIVE && data_available()) {
auto data = app_queue.pop();
send_packet(data);
}
}Properties:
- Owns protocol state (ConnectionState, expected SEQ, timeout counter)
- Owns TX queue
- Can call pkt.build(), pkt.advanceSeq() safely
Priority:
1. CONTROL packets (RESET, EVENT)
2. DATA & COMMAND (same queue, FIFO)
Rules:
- Only send when ACTIVE (not during handshake)
- After RESET, flush queue
- RESET_ACK flushes pending data
- Implement protocols.h (enums, constants)
- Implement packets.h/packets.cpp (build/parse, SEQ)
- Implement cobs.h/cobs.cpp (encode/decode)
- Unit tests for above
- RX byte pump (accumulate until 0x00, COBS decode, dispatch)
- TX path (build, encode, write, advanceSeq on success)
- Simple queue (std::queue for packets)
- Platform layer (Arduino Serial wrapper, POSIX termios wrapper)
- ConnectionState machine (IDLE → WAIT_ACK → ACTIVE)
- RESET/RESET_ACK handshake
- SEQ validation (tolerate small gaps, send RESET on large gap)
- Simple watchdog (5 sec timeout, send RESET)
- Unit tests for each module
- Integration tests (RX/TX roundtrip)
- Stress tests (bit corruption, noise)
- Real hardware tests (Arduino ↔ RPi)
- status.h / status.cpp (ok() pattern, StatusCode tracking)
- Fragmentation (payloads > 255 bytes)
- Advanced recovery (exponential backoff, per-packet ACKs)
- Performance optimization (streaming COBS, ring buffers)
PROTOCOL_VERSION = 0x01
MAX_PAYLOAD_SIZE = 255 (bytes, LEN field is 1 byte)
WATCHDOG_TIMEOUT_MS = 5000
SEQ_TOLERANCE = 5 (small drop tolerated)
SEQ_DESYNC_THRESHOLD = 50 (large jump triggers RESET)
TYPE 0x00 = CONTROL (RESET, RESET_ACK, HEARTBEAT, EVENT)
TYPE 0x01 = DATA (sensor/telemetry, best-effort)
TYPE 0x02 = COMMAND (configuration/control)
0x00 = RESET (hard sync)
0x01 = RESET_ACK (acknowledge reset)
0x02 = HEARTBEAT (liveness, optional)
0x03 = EVENT (noteworthy condition)
IDLE = Not connected, no handshake
WAIT_ACK = Sent RESET, waiting for RESET_ACK
ACTIVE = Handshake complete, normal ops
ERROR = Fault detected (v2+, for status tracking)
PacketProtocol/
├── include/
│ └── PacketProtocol/
│ ├── protocols.h (enums, constants)
│ ├── packets.h (Packet class)
│ ├── status.h (Status class, v2+)
│ └── cobs.h (encode/decode)
├── src/
│ ├── packets.cpp (Packet implementation)
│ ├── status.cpp (Status implementation, v2+)
│ ├── cobs.cpp (COBS implementation)
│ └── protocol_engine.cpp (RX thread, TX thread)
├── platform/
│ ├── arduino/
│ │ └── serial_wrapper.cpp (Arduino-specific)
│ └── linux/
│ └── serial_wrapper.cpp (POSIX-specific)
├── test/
│ ├── test_packets.cpp
│ ├── test_cobs.cpp
│ └── test_protocol.cpp
└── design/
├── design_protocols_h.md
├── design_packets_h.md
├── design_status_h.md
├── design_cobs_h.md
└── ARCHITECTURE.md (this file)
- RESET is authoritative - Only way to change protocol state (besides initial IDLE)
- SEQ is observability - No CRC/ACK, SEQ gives visibility into packet loss
- COBS gives framing - 0x00 is frame boundary, automatic sync on corruption
- Best-effort sensor data - New overwrites old, bounded memory, fresh always
- Pure functions - build() and encode() have no side effects, safe to retry
- Single writer - Only one thread writes SEQ/state (Protocol thread)
- No hidden heuristics - All state transitions explicit, auditable, deterministic
- design_protocols_h.md - Packet types, enums, constants
- design_packets_h.md - Wire format, build/parse, SEQ management
- design_status_h.md - ok() pattern (v2+), status tracking
- design_cobs_h.md - COBS encoding/decoding, frame sync
- serial_protocol_architecture_notes.md - Original architecture notes