GLaDOS Vol 4 · One System
ch 40 / 99
Chapter 40

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.

◆ Note — what sits on the bench, and how to read its output

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

▣ Build · stage 1 — magic bytes and a body
# 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.

▣ Build · stage 2 — a length that makes the body self-delimiting
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.

▣ Build · stage 3 — four bytes of proof
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

▣ Build · stage 4 — read a frame out of a stream that dribbles
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.

▣ Build · stage 5 — the same frame, running on the board
# 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.

▣ Build · stage 6 — the host says something and gets an answer
# 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.

◆ Note — three ways the far end is not a laptop

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.

⚠ Worked failure — perfect frames, invalid magic

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

✓ Checkpoint — what you can now do
  • I can read 474c414400000030b0740f21 field by field and say what each group of bytes claims about the message behind it.
  • I can say what > buys in struct.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.
⚡ Exercises — try first, then reveal
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.