The First Wire to Another Machine
Two processes that trusted each other
The wire in chapter 30 ran between two Python processes on one machine, over loopback, both written by you the same afternoon. Newline framing was enough there because the medium was perfect and the two ends agreed by construction. Today the far end is a microcontroller on the end of a USB cable: a different Python, a few hundred kilobytes of RAM, and a habit of restarting without asking. The commands this link eventually carries will move a servo attached to something that can hit you.
Three things change once the other end is hardware. The board reboots on its own, for a brownout or a watchdog or your finger on the reset button, and its bootloader prints startup text into the very channel your protocol uses. A serial line carries no error correction of its own, so a marginal cable or a baud rate off by a hair delivers bytes that are wrong and perfectly well-formed. And the receiver has no way to ask for a repeat unless the protocol gives it one, because there is no TCP underneath to notice that anything went missing.
So the frame grows a header, and the rule for the rest of the book is this: every message is twelve fixed header bytes (four magic bytes, a four-byte big-endian length, four checksum bytes) followed by exactly that many body bytes, and nothing acts on a body whose checksum disagrees with its header. Twelve bytes of overhead per command, bought with about thirty lines of code, in exchange for a link that can tell a valid command from a corrupt one before a motor turns.
One ESP32 development board of the common sort, roughly the price of a sandwich, and
a USB cable that carries data and not only power. Flash MicroPython onto it with
esptool, then copy files across with mpremote; both install
with uv tool install and both are documented far better than a paragraph
here could manage. From this chapter to the end of the book, any output captured from
hardware is one reading from one bench. Port names, boot text, and every timing you
see below will differ on yours, and the prose says so wherever it matters.
The frame, one field at a time
# labs/hardware_protocol.py
import json
MAGIC = b"GLAD"
def encode_message(payload: dict) -> bytes:
body = json.dumps(payload).encode("utf-8")
return MAGIC + body
def decode_message(data: bytes) -> dict:
if data[:4] != MAGIC:
raise ValueError("Invalid magic bytes")
return json.loads(data[4:].decode("utf-8"))
if __name__ == "__main__":
msg = {"command": "arm.move", "params": {"angle": 90}}
print("Round-trip OK:", decode_message(encode_message(msg)) == msg)
$ uv run python -m labs.hardware_protocol
Round-trip OK: True
Four bytes that spell nothing to anyone else are the cheapest question a receiver can
ask: are these mine? Junk from a wrong port, a leftover boot message, a stream read
half a byte out of step, all fail here for a few nanoseconds of comparison instead of
arriving at json.loads dressed as data. The stage still cheats, though:
data[4:] assumes the buffer holds exactly one body.
import struct
def encode_message(payload: dict) -> bytes:
body = json.dumps(payload).encode("utf-8")
return MAGIC + struct.pack(">I", len(body)) + body
def decode_message(data: bytes) -> dict:
if data[:4] != MAGIC:
raise ValueError("Invalid magic bytes")
length = struct.unpack(">I", data[4:8])[0]
return json.loads(data[8:8 + length].decode("utf-8"))
if __name__ == "__main__":
frame = encode_message({"command": "arm.move", "params": {"angle": 90}})
print("Body length declared:", struct.unpack(">I", frame[4:8])[0], "bytes")
$ uv run python -m labs.hardware_protocol
Round-trip OK: True
Body length declared: 48 bytes
struct.pack(">I", n) writes a 32-bit unsigned integer as four bytes,
and the > pins big-endian order so the two ends never argue about which
byte comes first. That argument is real: your laptop stores integers low byte first,
plenty of other silicon does not, and a length field read backwards asks for two
billion bytes. Declare the order in the format string and neither machine gets a vote.
import hashlib
def encode_message(payload: dict) -> bytes:
body = json.dumps(payload).encode("utf-8")
checksum = hashlib.md5(body).digest()[:4]
return MAGIC + struct.pack(">I", len(body)) + checksum + body
def decode_message(data: bytes) -> dict:
if data[:4] != MAGIC:
raise ValueError("Invalid magic bytes")
length, checksum = struct.unpack(">I4s", data[4:12])
body = data[12:12 + length]
if len(body) < length:
raise ValueError(f"Short frame: {len(body)} of {length} body bytes")
if hashlib.md5(body).digest()[:4] != checksum:
raise ValueError("Checksum mismatch")
return json.loads(body.decode("utf-8"))
if __name__ == "__main__":
frame = encode_message({"command": "arm.move", "params": {"angle": 90}})
print("Frame size:", len(frame), "bytes")
print("Header:", frame[:12].hex())
tampered = bytearray(frame)
tampered[15] ^= 0xFF # flip every bit of one body byte
try:
decode_message(bytes(tampered))
except ValueError as err:
print("Tamper detected:", err)
$ uv run python -m labs.hardware_protocol
Round-trip OK: True
Body length declared: 48 bytes
Frame size: 60 bytes
Header: 474c414400000030b0740f21
Tamper detected: Checksum mismatch
Read that header by eye and the layout stops being abstract: 474c4144 is
GLAD in ASCII, 00000030 is 48 in hexadecimal, and
b0740f21 is the first four bytes of the body's MD5 digest. Twelve plus
forty-eight is the sixty the program printed. Byte 15 lands in the body, since the
header ends at 12, so flipping it changes the recomputed digest and the guard fires.
Four bytes of digest miss a corruption roughly once in four billion, which is a
different league from the zero protection the line offers on its own.
Off the desk and onto the wire
from typing import Callable
def read_frame(read: Callable[[int], bytes]) -> dict:
def read_exactly(n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = read(n - len(buf))
if not chunk:
raise ConnectionError("stream ended mid-frame")
buf += chunk
return buf
window = read_exactly(4)
while window != MAGIC: # resync one byte at a time
window = window[1:] + read_exactly(1)
length, checksum = struct.unpack(">I4s", read_exactly(8))
body = read_exactly(length)
if hashlib.md5(body).digest()[:4] != checksum:
raise ValueError("Checksum mismatch")
return json.loads(body.decode("utf-8"))
if __name__ == "__main__":
JUNK = b"ets Jun 8 2016\r\n"
stream = JUNK + encode_message({"command": "arm.move", "params": {"angle": 90}})
pos = 0
def dribble(n: int) -> bytes: # hand back 3 bytes at most
global pos
chunk = stream[pos:pos + min(n, 3)]
pos += len(chunk)
return chunk
print(f"Skipped {len(JUNK)} junk bytes, read:", read_frame(dribble))
$ uv run python -m labs.hardware_protocol
Skipped 17 junk bytes, read: {'command': 'arm.move', 'params': {'angle': 90}}
read_frame takes a read function, not a socket or a serial port, so the
same code drives a fake stream in a test and a real cable in a minute. Two ideas earn
their lines. read_exactly loops until it has every byte it asked for,
because one read is never one message. And the resync loop slides a four-byte window
forward until the magic appears, which turns garbage on the line from a fatal error
into a delay of a few bytes.
# esp32/main.py — copied to the board with: mpremote cp esp32/main.py :main.py
import hashlib
import json
import struct
import sys
import micropython
from machine import Pin
MAGIC = b"GLAD"
LED = Pin(2, Pin.OUT)
def read_exactly(n):
buf = b""
while len(buf) < n:
chunk = sys.stdin.buffer.read(n - len(buf))
if chunk:
buf += chunk
return buf
def read_frame():
window = read_exactly(4)
while window != MAGIC:
window = window[1:] + read_exactly(1)
length, checksum = struct.unpack(">I4s", read_exactly(8))
body = read_exactly(length)
if hashlib.md5(body).digest()[:4] != checksum:
raise ValueError("Checksum mismatch")
return json.loads(body.decode("utf-8"))
def send_frame(payload):
body = json.dumps(payload).encode("utf-8")
checksum = hashlib.md5(body).digest()[:4]
sys.stdout.buffer.write(MAGIC + struct.pack(">I", len(body)) + checksum + body)
def handle(msg):
command = msg.get("command")
if command == "ping":
return {"status": "ok", "command": "ping"}
if command == "led.set":
LED.value(1 if msg.get("params", {}).get("state") == "on" else 0)
return {"status": "ok", "command": command, "led": LED.value()}
return {"status": "error", "reason": "unknown command: %s" % command}
def main():
micropython.kbd_intr(-1) # 0x03 in a frame is data, not Ctrl-C
while True:
try:
msg = read_frame()
except ValueError as err:
send_frame({"status": "error", "reason": str(err)})
continue
send_frame(handle(msg))
main()
The board's half is the same protocol written twice, and that duplication is
deliberate: MicroPython cannot import your laptop's package, so the frame layout lives
in two files that must agree byte for byte. Note what the far end does with a bad
checksum. It answers, saying what went wrong, and goes back to reading. A
microcontroller that stops talking is a microcontroller you drive to the bench and
power-cycle, so every path through main ends back at
read_frame.
# labs/hardware_link.py
import time
import serial
from labs.hardware_protocol import encode_message, read_frame
PORT = "/dev/ttyUSB0"
BAUD = 115200
def open_link(port: str = PORT, baud: int = BAUD) -> serial.Serial:
link = serial.Serial(port, baud, timeout=2)
time.sleep(2.0) # opening the port resets the board
link.reset_input_buffer() # drop whatever the bootloader printed
return link
def command(link: serial.Serial, payload: dict) -> dict:
link.write(encode_message(payload))
return read_frame(link.read)
def main() -> None:
with open_link() as link:
for payload in [
{"command": "ping"},
{"command": "led.set", "params": {"state": "on"}},
{"command": "led.set", "params": {"state": "off"}},
{"command": "dance"},
]:
started = time.perf_counter()
reply = command(link, payload)
elapsed_ms = (time.perf_counter() - started) * 1000
print(f"{payload['command']:<8} -> {reply} ({elapsed_ms:.0f} ms)")
if __name__ == "__main__":
main()
$ uv run python -m labs.hardware_link # measured on the bench — yours will vary
ping -> {'status': 'ok', 'command': 'ping'} (11 ms)
led.set -> {'status': 'ok', 'command': 'led.set', 'led': 1} (14 ms)
led.set -> {'status': 'ok', 'command': 'led.set', 'led': 0} (13 ms)
dance -> {'status': 'error', 'reason': 'unknown command: dance'} (13 ms)
The blue light on the board comes on and goes off. That is the whole point of the
chapter. Set PORT to whatever your machine calls the board:
/dev/ttyUSB0 for the common USB-serial chips on Linux,
/dev/ttyACM0 for boards with native USB,
/dev/tty.usbserial-0001 on macOS, COM3 or thereabouts on
Windows. Run uv run python -m serial.tools.list_ports -v and the machine
will tell you. The round trips landed in the low tens of milliseconds here; roughly
half of that is the bytes themselves at 115200 baud and the rest is MicroPython
parsing JSON, so your figures will sit wherever your board and cable put them.
micropython.kbd_intr(-1) matters more than it looks. The board's USB
serial doubles as its interactive prompt, so a 0x03 byte inside your
binary frame would otherwise read as Ctrl-C and kill the program mid-command; that one
call says the channel is now data. Second, hashlib.md5 is present in the
usual ESP32 firmware but is compiled out of some builds, and an
AttributeError on import is the board telling you so. Third, firmware
older than about 2023 spells the modules ujson, ustruct, and
uhashlib.
Why this works: a fixed header before a variable body
Every field answers a question the receiver cannot answer any other way, and the fields sit in the order the questions arrive in. Are these bytes mine, and if not, where does mine start? How many body bytes follow, so I know when to stop reading? Do those bytes still say what the sender meant? Only the last question needs the body, so it comes last; the first two must be answerable from a block whose size is known before anything is read, and that is what makes the header a fixed twelve bytes and the body variable.
The checksum is the field that changes what the link means. Without it, corruption is
indistinguishable from intent: a flipped bit in {"angle": 90} can produce
{"angle": 10}, a perfectly valid command that swings an arm somewhere you
never asked for. With it, corrupt frames are rejected and something well-defined
happens instead. That is why both ends must agree exactly on which bytes the checksum
covers. Hash the body on one side and the magic plus the body on the other and every
valid message fails, with no corruption anywhere in sight.
One honest limit. Four bytes of MD5 catch accidents, not attackers: anyone who can write to the line can compute a matching digest as easily as you can. This link is a cable between two devices you own, and that is the threat model it fits. When she reaches across a network in volume 10, the answer is a real signature or a TLS tunnel, not a longer digest.
The first version of read_frame everyone writes reads twelve bytes and
trusts them, and open_link just opens the port:
def open_link(port=PORT, baud=BAUD):
return serial.Serial(port, baud, timeout=2) # BUG: no pause, no flush
def read_frame(read):
header = read(12)
if header[:4] != MAGIC: # BUG: refuses instead of resyncing
raise ValueError("Invalid magic bytes")
...
$ uv run python -m labs.hardware_link
Traceback (most recent call last):
File ".../labs/hardware_link.py", line 31, in main
reply = command(link, payload)
File ".../labs/hardware_link.py", line 22, in command
return read_frame(link.read)
File ".../labs/hardware_protocol.py", line 41, in read_frame
raise ValueError("Invalid magic bytes")
ValueError: Invalid magic bytes
Both ends are correct. The board is sending a valid frame and the host is refusing it,
which is the most confusing failure a protocol has. Print the header instead of
raising and the mystery ends in one line: b'ets Jun 8 2'. Opening a
serial port toggles the DTR and RTS lines, the auto-reset circuit reads that as a
reset, and the ROM bootloader prints its startup message at 115200 baud into the same
channel. Your first twelve bytes are the front of that banner, and the frame you
wanted is sitting right behind it. The fix is both halves of stages 4 and 6: wait out
the boot and flush the buffer, then let the magic bytes do the job they were added
for, sliding forward until the stream lines up. Anything else sharing a channel with
your protocol is data your reader has to survive.
Checkpoint, and a volume that became a system
- I can read
474c414400000030b0740f21field by field and say what each group of bytes claims about the message behind it. - I can say what
>buys instruct.pack(">I", n)and what goes wrong on a link where the two ends disagree about byte order. - I can explain why the magic bytes are a resync marker and not only a sanity check, and name the thing on a real board that makes resyncing necessary.
- I can describe the corruption a checksum prevents in terms of what the arm does, and state what four bytes of MD5 do not protect against.
- Handed "invalid magic bytes" from a board that is provably sending valid frames, I know to print the bytes I actually received before suspecting either program.
- I can explain why the board answers a bad checksum instead of stopping.
Exercise 1 — refuse the runaway payload. The length field
can declare four gigabytes. Add a MAX_BODY of 65535 to
encode_message and to the decoder, then try to send a body of 70,000
bytes.
Check len(body) straight after serializing and raise a
ValueError naming both numbers, and refuse an oversized declared
length in read_frame before calling read_exactly. The
encoder side saves you from your own bug; the decoder side is the one that
matters, since a corrupted length field is exactly how a board with 300 kB of RAM
gets asked to buffer a gigabyte and dies without a message.
Exercise 2 — number the frames. Add a four-byte sequence number to the header, making it sixteen bytes, and have each end print a warning when a number arrives out of order. Send ten commands and watch the counters agree.
Pack two big-endian integers with ">II" and move the checksum and
body along by four. The interesting part is not the code, it is the deployment:
the moment you copy the new main.py to the board, an old host talking
to a new board reads a sequence number as a length. Protocol changes are
all-at-once changes unless a version field says otherwise, and adding one is a
fair fourth field.
Exercise 3 — pull the cable. Start the command loop, then
unplug the board mid-conversation. Make command() report the loss and
return None instead of raising, and prove it by unplugging again.
Two failures arrive by different doors: serial.SerialException when
the operating system removes the device, and the ConnectionError your
own read_exactly raises when the read times out mid-frame. Catch
both, print something like [warn] link lost on /dev/ttyUSB0, return
None, and the host survives an unplugged cable. What it cannot yet do
is notice the board came back. That is a supervisor's job, and it opens the next
volume.
Look at what volume 4 built. Every subsystem's settings live in one printable registry instead of a dozen files. The core takes its listener, its brain, and its voice as arguments, so it runs on fakes before a model loads. Startup probes every component and fails loudly at second zero, and the config is checked for missing files and impossible numbers before a frame of audio is recorded. She picks her actions with the model that reads meaning, then validates the name against a registry that will not invent hardware. She watches her own CPU, memory, and latency into a rolling log. Her facts are searchable and ranked by how much she trusts them. Her tuning is a sweep with saved results instead of a hunch, and her roadmap computes what is left. The scripts became a system.
And as of this chapter she reaches past her own machine. A command leaves your laptop as sixty bytes, crosses a cable, and lights a lamp on a board that answers in the same language. Volume 5 is called Alive on the Bench, and that is where she stops being software with a serial port. A watchdog supervises the pieces and restarts what dies. The first physical prototype gets built and wired. Modules get contracts they must satisfy and an integration test harness that checks them together. A behavior engine and a mood layer decide what she does with an idle moment, a hardware controller turns her actions into motion through the link you just proved, and telemetry reports what the body is doing while it does it. Clear some desk space.