2026 · Solo
Bits → Messages
A serial-communication simulator whose engine also drives a real device node — the same UART runs the diagram in the browser and the /dev/ttys* you open with screen.
- protocols:
- six, compared
- tests:
- 239
- core runtime deps:
- 0
Problem
Serial is the first protocol most embedded engineers meet and the last one they understand. Tutorials stop at "set both ends to 115200 8N1", which is exactly where the interesting failures start: a link that delivers plausible wrong bytes, a checksum that passes on corrupted data, a message delivered twice with nothing anywhere reporting an error. I wanted those failures reproducible on demand, on a port you can actually open.
Constraints
The engine has zero runtime dependencies, does no I/O, and creates no timers — every state machine takes a tick count from the caller. No native modules, so the pseudo-terminal comes from the Python standard library. And it has to be a real device node: a simulation that only talks to its own UI proves nothing.
Approach
One UART engine, imported unchanged by the browser instrument and by the server that drives a real port. Bit-level transmitter, a 16× oversampling receiver that votes three mid-bit samples two-of-three, FIFO and overrun, RTS/CTS and XON/XOFF, COBS framing, CRC-16, stop-and-wait ARQ. Four transports sit behind one interface — in-process, TCP, PTY, and physical hardware — so nothing above them can tell which one it got.
Why it's technically hard
Being correct where it would be invisible if I weren't. A UART aligns once on the start-bit falling edge and free-runs; my first receiver timed each sample from the end of the previous bit instead, giving every bit after the first 15 ticks rather than 16 and running the receiver about 6% fast. That is code which works until the frame gets long.
Fixing it made the tolerance budget fall out, and the budget is asymmetric:
+3.90% / −4.64% for 8N1 at 16×, because majority voting binds on the latest
sample when the transmitter is fast, and edge detection is always late by up to
a tick. Strip the quantisation term and it reproduces the AVR datasheet's
Rslow and Rfast term for term. The test asserts both derivations agree, then
discards them and finds the receiver's real breaking point by bisection.
That sweep turned up something I had not predicted: back-to-back frames cost the fast side half a percentage point, because the next start bit is already under way when the stop bit resolves. One idle bit buys it back — a concrete reason to reach for 8N2 on a marginal link, though the second stop bit is never sampled.
Result
Starting the server prints a device path, and screen connects to it like
hardware. The fault injector does not scramble bytes: for a baud mismatch it
decodes the payload with a receiver tuned to the wrong rate and puts those bytes
on the wire, so the corruption is what a mistuned UART would really produce —
deterministic, and reproducible from the spec string alone. 239 tests cover it,
and the integration test opens the character device for real, on macOS and on
the Linux CI runner.
One message, nine ways
The instrument's JOURNEY panel takes whatever you type and follows it across: characters to bytes, bytes to transitions on wires, a receiver deciding when to look, and back to characters. The same message goes nine ways, and the comparison is the content — every difference between them is a different answer to one question, and every one is a trade rather than an improvement.
UART infers timing from an agreed rate, so it needs a start bit, accurate clocks at both ends, and a framing-error flag for when that fails. SPI spends a wire on the clock and deletes every timing failure at once — and spends nothing on checking, which is why a CPOL/CPHA mismatch is completely silent. I2C spends a wire too, then buys a shared bus with the complexity it saved: open-drain lines, START and STOP framing that costs no byte value, seven-bit addressing, and an acknowledgement on every ninth clock.
CAN shares a pair as well, but resolves collisions instead of avoiding them. The bus is a wired-AND, so every node transmits and reads at once, and any node that sends recessive while reading dominant knows it has lost and withdraws mid-frame — without disturbing a single bit of the winner's message. Lowest identifier wins, which makes priority a property of the wire rather than of a scheduler.
1-Wire spends nothing at all: the master's falling edge starts each slot and the width of the low period is the bit, so time carries the data and one conductor carries everything, slowly. Manchester puts the clock inside the data by guaranteeing an edge in the middle of every cell, buying DC balance and freedom from any agreed rate at exactly twice the signalling rate.
USB gets the same clock-recovery guarantee as Manchester far more cheaply — NRZI plus one stuffed bit per six ones bounds how long the line can sit still — and answers "who talks?" by making the question unaskable, since the host schedules every transaction.
RS-232 and RS-485 are the odd pair: they are not framing at all. They carry ordinary UART bytes and argue about voltage, and they are the only signals here drawn in real volts rather than levels. Set a disturbance and flip between them. Four volts of induced noise destroys RS-232 — whose margin against the ±5.5 V charge-pump drivers actually fitted is only 2.5 V — and the identical disturbance is invisible to RS-485, because it lands on both wires equally and cancels in the subtraction. That one fact is why one reaches 15 m and the other 1200.
Each breaks in the way that is characteristic of it. The one I would point at is CAN alone on a bus: perfectly good wiring, a perfectly formed frame, and it cannot transmit at all, because the ACK slot is pulled dominant by a receiver and there isn't one.
What I'd do next
All eleven rungs are drawn. The ladder stops at a shared bus on purpose: above multidrop the next honest layer is addressing and routing, and that is a different subject rather than a taller version of this one. Knowing where to stop is part of the design.
The other half of the thesis is done too. A WebSocket bridge — hand-rolled to RFC 6455, because framing a stream is the same problem level 08 solves with a delimiter and level 11 solves with silence — lets the instrument scope a live link. Press connect and the trace stops being computed: it is drawn from bytes that actually crossed the port, and anything typed into the browser crosses it for real, where a terminal on the same device node sees the reply.
That needs a server running locally, so on a static site the panel stays idle and says so. An honest limit rather than a missing feature, and the reason it never connects on its own.