feat: add Rust client library and mux daemon

Add donglora-client (clients/rust/) and donglora-mux (mux/rust/) as
Rust implementations of the existing Python client library and mux
daemon. Both are standalone Cargo projects with no-panic guarantees
enforced by clippy deny lints.

donglora-client provides protocol types, COBS framing, USB device
discovery, serial/mux transports, and a high-level Client<T> with
send/recv. donglora-mux is a fully async (tokio) multiplexer daemon
with dongle hot-plug reconnect, per-client bounded send queues,
SetConfig locking, and StartRx/StopRx reference counting.

52 unit tests across both crates. Updates README roadmap checkboxes
and CONTRIBUTING docs to reflect the new implementations.
This commit is contained in:
swaits committed 2026-04-03 17:21:51 -06:00
1 parent f12f72a793
commit 5d66731413
21 files changed
+4049 -6

No files matched your search

+277
View File
@@ -0,0 +1,277 @@
//! COBS framing: encode/decode frames and accumulate partial reads.
//!
//! Each frame is COBS-encoded and terminated with a `0x00` sentinel byte.
//! The sentinel never appears in the encoded data, so it unambiguously
//! marks frame boundaries.
use tracing::warn;
/// COBS-encode data and append the `0x00` sentinel.
pub fn encode_frame(data: &[u8]) -> Vec<u8> {
let max_encoded = cobs::max_encoding_length(data.len());
let mut buf = vec![0u8; max_encoded];
let n = cobs::encode(data, &mut buf);
buf.truncate(n);
buf.push(0x00);
buf
}
/// COBS-decode a frame (without the sentinel). Returns `None` on decode error.
pub fn decode_frame(encoded: &[u8]) -> Option<Vec<u8>> {
if encoded.is_empty() {
return None;
}
let mut buf = vec![0u8; encoded.len()];
match cobs::decode(encoded, &mut buf) {
Ok(n) => {
buf.truncate(n);
Some(buf)
}
Err(_) => None,
}
}
/// Stateful COBS frame accumulator.
///
/// Feed raw byte chunks (from serial reads, socket reads, etc.) and extract
/// complete decoded frames. Handles partial reads, multiple frames per chunk,
/// and empty inter-frame gaps gracefully.
///
/// Used by both the sync client (`read_frame`) and the async mux to accumulate
/// data from serial and client socket streams.
pub struct FrameReader {
buf: Vec<u8>,
}
impl FrameReader {
/// Create a new empty frame reader.
pub fn new() -> Self {
Self {
buf: Vec::with_capacity(512),
}
}
/// Feed raw bytes and return all complete decoded frames.
///
/// COBS decode errors are logged and the bad frame is skipped.
/// Empty inter-frame gaps (consecutive `0x00` bytes) are ignored.
pub fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
self.buf.extend_from_slice(data);
let mut frames = Vec::new();
while let Some(sentinel_pos) = self.buf.iter().position(|&b| b == 0x00) {
let encoded = &self.buf[..sentinel_pos];
if !encoded.is_empty() {
match decode_frame(encoded) {
Some(decoded) => frames.push(decoded),
None => warn!("bad COBS frame ({} bytes) — skipped", encoded.len()),
}
}
// Remove the consumed bytes including the sentinel
self.buf.drain(..=sentinel_pos);
}
frames
}
/// Return the number of buffered bytes not yet forming a complete frame.
pub fn buffered(&self) -> usize {
self.buf.len()
}
}
impl Default for FrameReader {
fn default() -> Self {
Self::new()
}
}
/// Read one complete COBS frame from a blocking `Read` source.
///
/// Reads byte-by-byte until a `0x00` sentinel is found, then COBS-decodes.
/// Returns `Ok(None)` on timeout (zero-length read from the underlying source).
/// Returns `Err` on I/O errors or COBS decode failures.
pub fn read_frame(reader: &mut dyn std::io::Read) -> anyhow::Result<Option<Vec<u8>>> {
let mut buf = Vec::with_capacity(280);
let mut byte = [0u8; 1];
loop {
match reader.read(&mut byte) {
Ok(0) => {
// Timeout or EOF
return Ok(None);
}
Ok(_) => {
if byte[0] == 0x00 {
break;
}
buf.push(byte[0]);
}
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {
return Ok(None);
}
Err(e) => return Err(e.into()),
}
}
if buf.is_empty() {
return Ok(None);
}
decode_frame(&buf).map(Some).ok_or_else(|| anyhow::anyhow!("COBS decode error"))
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_decode_roundtrip() {
let data = b"hello world";
let encoded = encode_frame(data);
// Encoded should end with 0x00 sentinel
assert_eq!(encoded.last(), Some(&0x00));
// Encoded should not contain 0x00 except the trailing sentinel
assert!(!encoded[..encoded.len() - 1].contains(&0x00));
// Decode (without sentinel)
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded.as_deref(), Some(data.as_slice()));
}
#[test]
fn encode_decode_empty() {
// COBS encoding of empty data produces just the 0x00 sentinel.
// This is equivalent to an empty inter-frame gap and is correctly
// skipped by both FrameReader and read_frame.
let encoded = encode_frame(b"");
assert_eq!(encoded, vec![0x00]);
}
#[test]
fn encode_decode_with_zeros() {
let data = &[0x00, 0x01, 0x00, 0x02, 0x00];
let encoded = encode_frame(data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded.as_deref(), Some(data.as_slice()));
}
#[test]
fn decode_frame_invalid() {
// An invalid COBS sequence
assert!(decode_frame(&[0x00]).is_none()); // empty after stripping would be caught
// Actually the cobs crate might handle this differently, let's test empty
assert!(decode_frame(&[]).is_none());
}
#[test]
fn frame_reader_single_frame() {
let mut reader = FrameReader::new();
let data = b"test";
let frame = encode_frame(data);
let frames = reader.feed(&frame);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], data);
}
#[test]
fn frame_reader_multiple_frames_at_once() {
let mut reader = FrameReader::new();
let f1 = encode_frame(b"one");
let f2 = encode_frame(b"two");
let mut combined = f1;
combined.extend_from_slice(&f2);
let frames = reader.feed(&combined);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"one");
assert_eq!(frames[1], b"two");
}
#[test]
fn frame_reader_partial_then_complete() {
let mut reader = FrameReader::new();
let frame = encode_frame(b"split");
let mid = frame.len() / 2;
// Feed first half — no complete frames yet
let frames = reader.feed(&frame[..mid]);
assert!(frames.is_empty());
assert!(reader.buffered() > 0);
// Feed second half — now we get the frame
let frames = reader.feed(&frame[mid..]);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"split");
assert_eq!(reader.buffered(), 0);
}
#[test]
fn frame_reader_empty_gaps() {
let mut reader = FrameReader::new();
// Multiple sentinels in a row (empty gaps between frames)
let f = encode_frame(b"data");
let mut input = vec![0x00, 0x00]; // leading empty gaps
input.extend_from_slice(&f);
input.push(0x00); // trailing empty gap
let frames = reader.feed(&input);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"data");
}
#[test]
fn frame_reader_bad_cobs_skipped() {
let mut reader = FrameReader::new();
// Feed a known-bad COBS frame followed by a good one.
// A frame where the first byte claims a run longer than the frame is invalid.
let bad = &[0xFF, 0x01, 0x00]; // 0xFF says next 254 bytes, but only 1 present
let good = encode_frame(b"ok");
let mut input = bad.to_vec();
input.extend_from_slice(&good);
let frames = reader.feed(&input);
// Bad frame skipped, good frame decoded
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"ok");
}
#[test]
fn read_frame_from_bytes() {
let data = b"frame";
let encoded = encode_frame(data);
let mut cursor = std::io::Cursor::new(encoded);
let result = read_frame(&mut cursor);
assert!(result.is_ok());
assert_eq!(result.ok().flatten().as_deref(), Some(data.as_slice()));
}
#[test]
fn read_frame_timeout() {
// Empty reader → returns None (timeout)
let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
let result = read_frame(&mut cursor);
assert!(result.is_ok());
assert!(result.ok().flatten().is_none());
}
#[test]
fn read_frame_empty_gap_then_data() {
let data = b"after_gap";
let mut input = vec![0x00]; // empty gap
input.extend_from_slice(&encode_frame(data));
let mut cursor = std::io::Cursor::new(input);
// First read_frame returns None for the empty gap
let r1 = read_frame(&mut cursor);
assert_eq!(r1.ok().flatten(), None);
// Second read returns the actual frame
let r2 = read_frame(&mut cursor);
assert_eq!(r2.ok().flatten().as_deref(), Some(data.as_slice()));
}
#[test]
fn encode_all_byte_values_roundtrip() {
// Test that all possible byte values survive encode/decode
let data: Vec<u8> = (0..=255).collect();
let encoded = encode_frame(&data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded, Some(data));
}
}