Internals#

For contributors and the curious: how the crate is put together, and why it’s shaped that way. None of this is needed to use the driver — it’s the map you’d want before changing it.

The transport seam#

All I/O goes through one small trait, Transport, with two implementations: SerialTransport for real hardware and MockTransport for tests. M0601 and Bus are generic over it, so the exact code that ships runs unchanged against a scripted in-memory bus — there are no cfg(test) branches threading through the driver logic.

The trait’s pace method is the neat part. A real transport reports how long a delay would take; the mock reports zero. The driver takes that value under the bus lock but performs the actual sleep outside the lock, so one motor’s mode-switch sequence doesn’t stall another motor’s 50 Hz loop — and under the mock, “sleeps” are free, so loop tests run instantly instead of in real milliseconds.

send_recv is where the latency work lives: it writes, waits, then reads exactly the bytes the OS says are buffered rather than issuing a blocking read, treating TimedOut as “no more data.” That’s what keeps a silent motor from adding the full port timeout to a real-time cycle.

Bus and M0601#

Bus<T> owns the port behind an Arc<Mutex> and hands out M0601<T> handles that are cheap to clone and safe to send across threads — they all share the one physical port. The bus is what enforces the inter-frame gap and runs the round-major group operations; a single M0601::open is really just Bus::open(...).motor(id) under the hood.

The lock is poison-tolerant on purpose. The guarded transport holds no invariant a panic could corrupt mid-update — each call is one complete frame exchange — and the stop paths above all must keep working even if another thread panicked while holding the port. A stop that deadlocked because a different thread died is the opposite of safe. For the same reason, the Debug impl uses try_lock and reports rather than blocks, so it can run from a panic path that already holds the lock without deadlocking the formatter.

The control TUI#

control splits cleanly. A poll thread owns the serial port and runs the 50 Hz loop; a UI thread owns the terminal and only ever edits a small shared state struct. No lock is ever held across serial I/O — the hold times are nanoseconds against a 20 ms budget — so the two never contend meaningfully.

The teardown is the interesting engineering. Every exit path funnels through the same sequence: clear the running flag, join the poll thread, and let its epilogue run safe_stop. RAII guards make that hold even through a panic. A TermGuard restores the terminal (raw mode off, leave the alt-screen, cursor back) and a StopGuard clears running and joins the poll thread — declared in an order such that on unwind the terminal is restored first (so a panic message is readable) and the motor stops second. The TermGuard is armed the instant raw mode is enabled, because a ? between enabling raw mode and arming the guard would return with the tty still raw and nothing left to fix it. The poll loop even wraps itself in catch_unwind so that a panic in the loop body still runs the ~300 ms braked stop before propagating.

The upshot is the guarantee the CLI docs promise: short of SIGKILL or power loss, there is no way to leave control with the wheel driven.

The single unsafe block#

The crate is #![deny(unsafe_code)]deny, not forbid, because there’s exactly one exception and forbid couldn’t be locally overridden. That exception is the pair of Linux TIOCGSERIAL/TIOCSSERIAL ioctls in low_latency, which set the FTDI latency flag on a file descriptor the crate owns, operating on a locally defined UAPI struct. It’s kept behind a scoped allow with per-call SAFETY comments, so the crate-wide deny still covers everything else. Everything outside those two ioctls is unsafe-free.

Doc compile checks#

The Rust snippets in this site and the doc comments aren’t decoration — they’re compiled. m0601/examples/usage_doc_check.rs is a real example the CI builds, and the frame-level claims in protocol.rs are backed by doctests with exact expected bytes (and the parse_feedback double-decode is a deliberate regression guard against the two-layouts bug). If a documented signature drifts from the code, the build breaks. That’s the intended contract: the docs can be wrong about prose, but not about the API.

Workspace layout#

m0601/        the library crate (what you depend on)      [published]
  src/        lib, bus, protocol, types, slew, transport, error, low_latency
  examples/   four_wheel_minimal.rs — the driver on one screen
              usage_doc_check.rs    — the compiled doc snippets
  tests/      vectors.rs   golden protocol bytes
              bus_mock.rs  driver behavior against MockTransport
              spacing.rs   inter-frame gap timing
              hardware.rs  hardware-in-the-loop (all #[ignore]d)
m0601-cli/    the CLI crate (binary `m0601`)              [published]
  src/        main.rs + cmd/{scan,info,monitor,drive,set_id,raw,control/*}
m0601-quad/   four-wheel skid-steer sample app            [publish = false]
  src/        main.rs + config, rover, pilot, mix, safety, ui, logger, …
  wheels.toml the sample wheel map
docs/         this site (Hugo + hugo-book)

m0601-quad is not published: it’s the reference implementation you clone and read, not a dependency. See the sample app.

This site is the canonical documentation. The root README.md is the project’s landing page; USAGE.md and PROTOCOL.md are short pointers into these pages, kept so in-repo links don’t dead-end.