GLaDOS Vol 7 · The Body
ch 71 / 99
Chapter 71

Hardware Integration

One pin number, typed into six files

Count where her pin numbers currently live. Chapter 64's SERVO_PIN = 17 sits at the top of the sweep script. Chapter 65's EyeController takes 12 and 13 as default arguments. Chapter 66's LED_PIN = 10 is a module constant. The three I2S pins for the amplifier are not in Python at all, they are a line in /boot/firmware/config.txt that the kernel reads at boot. Chapter 70's battery sampler opens /dev/spidev4.0 and never names the four pins that controller sits on. The arm's servo driver hangs off two more pins that nothing has written down yet.

Six places, none of which can see the other five. Every one of them was correct on the afternoon it was typed. The trouble starts when a number moves: you free up a pin for the light ring, edit the file in front of you, and the other four keep asserting the old arrangement to anyone who reads them.

What makes this expensive is how quietly it fails. Ask gpiozero for the same pin twice in one process and it raises GPIOPinInUse, which is a gift. Nothing else in this machine is that polite. The I2S driver claims GPIO 18 at boot, before your code exists, so a servo assigned to 18 gets a line that is already toggling at 1.4 megahertz and jitters for reasons no traceback will ever mention. A jumper moved one row on the header just lands on a different pin, and that pin sits there doing nothing while you reread perfectly correct code.

Chapter 55 built the tool for exactly this kind of problem and pointed it at a different question. The wiring manifest is keyed by the wire: it knows that a 5 V echo line must not reach a 3.3 V input, and it catches a driver feeding two loads. What it cannot answer is who owns GPIO 18 in the finished body, because the body's claims arrive one chapter at a time and land in six unrelated files. Same discipline, different key. So the rule for the rest of this build: every pin the body claims is declared once in configs/hardware.json, the map is keyed by the pin and valued by the list of everyone claiming it, and a conflict is a list longer than one.

◆ Note — two numbering schemes, and which one your fingers use

A Raspberry Pi 4B exposes 26 general-purpose pins on its 40-pin header, BCM 2 through 27. BCM is the Broadcom numbering the chip and your code use. Physical numbering is the position on the header, counted 1 to 40 down the two rows, and it is what you are actually looking at with a jumper in your hand. The two never agree: BCM 13 sits at physical 33, and physical 13 is BCM 27. Running pinout, which ships with gpiozero, prints the map for your exact board and revision, and it is the fastest way to settle an argument with yourself at the header.

A pin, and everyone who wants it

▣ Build · stage 1 — the map, from one subsystem
# labs/hardware_map.py
from collections import defaultdict

SERVOS = {
    "eye_pan":    {"bcm_pin": 12},
    "eye_tilt":   {"bcm_pin": 13},
    "bench_test": {"bcm_pin": 17},
}


def servo_claims(servos: dict) -> dict[int, list[str]]:
    """{bcm_pin: [owner, ...]} for the servos wired straight to the header."""
    claims: dict[int, list[str]] = defaultdict(list)
    for name, servo in servos.items():
        claims[servo["bcm_pin"]].append(f"servo:{name}")
    return dict(claims)


if __name__ == "__main__":
    for pin, owners in sorted(servo_claims(SERVOS).items()):
        print(f"GPIO {pin:>2}: {', '.join(owners)}")
$ uv run python -m labs.hardware_map
GPIO 12: servo:eye_pan
GPIO 13: servo:eye_tilt
GPIO 17: servo:bench_test

One decision in that function carries the whole chapter, and it looks like an accident: the value is a list, not a string. A dictionary from pin to owner would be the obvious model and it destroys the evidence, because the second assignment to a key overwrites the first and the collision you were hunting disappears at the moment it happens. A list keeps both claimants. defaultdict(list) is what makes the append legal on a key that has never been seen; a plain dict raises KeyError on the very first pin, since there is no list waiting there to append to.

▣ Build · stage 2 — a second subsystem, and the check is a count
# labs/hardware_map.py — the amplifier's three wires join the map
I2S = {"amp_max98357a": {"bcm_bclk": 18, "bcm_lrclk": 19, "bcm_din": 21}}


def claim_map(servos: dict, i2s: dict) -> dict[int, list[str]]:
    claims: dict[int, list[str]] = defaultdict(list)
    for name, servo in servos.items():
        claims[servo["bcm_pin"]].append(f"servo:{name}")
    for name, device in i2s.items():
        for key, pin in device.items():
            claims[pin].append(f"i2s:{name}:{key[4:]}")   # bcm_lrclk -> lrclk
    return dict(claims)


if __name__ == "__main__":
    stale = dict(SERVOS, eye_pan={"bcm_pin": 18})   # the number the bench rig used
    for pin, owners in sorted(claim_map(stale, I2S).items()):
        verdict = "CONFLICT" if len(owners) > 1 else "ok"
        print(f"GPIO {pin:>2} {verdict:>8}: {', '.join(owners)}")
$ uv run python -m labs.hardware_map
GPIO 13       ok: servo:eye_tilt
GPIO 17       ok: servo:bench_test
GPIO 18 CONFLICT: servo:eye_pan, i2s:amp_max98357a:bclk
GPIO 19       ok: i2s:amp_max98357a:lrclk
GPIO 21       ok: i2s:amp_max98357a:din

GPIO 18 is the bench rig's servo pin, carried forward out of habit, and in the body it belongs to the amplifier's bit clock. Notice what the detection cost: one comparison, len(owners) > 1. No set arithmetic, no comparing every claim against every other claim. Stage 1 chose a structure where the question the program has to answer is already the length of something, and every later check in this file inherits that.

Every claimed pin on the 40-pin header The header drawn as its two rows of twenty positions. Fifteen carry an owner from the map: GPIO 2 and 3 for the I2C bus, GPIO 4, 5, 6 and 7 for the second SPI controller the battery converter reads through, GPIO 9, 10 and 11 for the SPI bus the light ring holds alone, GPIO 12 and 13 for the eye servos, GPIO 17 for the bench servo, and GPIO 18, 19 and 21 for the three I2S wires to the amplifier. Every other position is power, ground, reserved, or free. 40-PIN HEADER · 15 GPIO CLAIMED · 11 FREE BCM PIN BCM power 3V3 1 2 5V power bus:i2c1 GPIO 2 3 4 5V power bus:i2c1 GPIO 3 5 6 GND ground bus:spi4 GPIO 4 7 8 GPIO 14 free ground GND 9 10 GPIO 15 free servo:bench_test GPIO 17 11 12 GPIO 18 i2s:bclk free GPIO 27 13 14 GND ground free GPIO 22 15 16 GPIO 23 free power 3V3 17 18 GPIO 24 free bus:spi0 GPIO 10 19 20 GND ground bus:spi0 GPIO 9 21 22 GPIO 25 free bus:spi0 GPIO 11 23 24 GPIO 8 free ground GND 25 26 GPIO 7 bus:spi4 reserved ID_SD 27 28 ID_SC reserved bus:spi4 GPIO 5 29 30 GND ground bus:spi4 GPIO 6 31 32 GPIO 12 servo:eye_pan servo:eye_tilt GPIO 13 33 34 GND ground i2s:lrclk GPIO 19 35 36 GPIO 16 free free GPIO 26 37 38 GPIO 20 free ground GND 39 40 GPIO 21 i2s:din
Figure 71.1 — The finished claim map on the header it describes. Fifteen of the 26 general-purpose positions are spoken for; the eye servos sit at physical 32 and 33, three rows below the position a hand counting to thirteen would find.

The file that outranks the six

▣ Build · stage 3 — the declaration, and a first run that seeds it
{
  "board": "raspberry-pi-4b",
  "revision": "0.9.0",
  "servos": {
    "eye_pan":    {"bcm_pin": 12, "note": "hardware PWM channel 0"},
    "eye_tilt":   {"bcm_pin": 13, "note": "hardware PWM channel 1"},
    "bench_test": {"bcm_pin": 17, "note": "the sweep rig, still wired"}
  },
  "i2s": {
    "amp_max98357a": {"bcm_bclk": 18, "bcm_lrclk": 19, "bcm_din": 21}
  },
  "buses": {
    "spi0": {"pins": [9, 10, 11], "exclusive": true,
             "devices": ["led:eye_ring"]},
    "spi4": {"pins": [4, 5, 6, 7], "exclusive": false,
             "devices": ["adc:battery"]},
    "i2c1": {"pins": [2, 3], "exclusive": false,
             "devices": ["pca9685_arm@0x40", "pca9685_aux@0x40"]}
  },
  "arm": {"driver": "pca9685_arm",
          "channels": {"shoulder": 0, "elbow": 1, "wrist": 2, "gripper": 3}},
  "audio": {"mic_name_substr": "ReSpeaker", "output_name_substr": "hifiberry"}
}
# labs/hardware_map.py — continued
import json
import sys
from pathlib import Path

CONFIG_PATH = Path("configs/hardware.json")
CARD = Path("glados/data/pin_map.txt")


def load_config(path: Path = CONFIG_PATH) -> dict:
    """Read the map. On a machine that has never run this, write the draft and stop."""
    if not path.exists():
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(DRAFT_CONFIG, indent=2) + "\n")
        print(f"no {path}; wrote the draft. Edit it to match your wiring, then re-run.")
        sys.exit(0)
    return json.loads(path.read_text())
$ uv run python -m labs.hardware_map
no configs/hardware.json; wrote the draft. Edit it to match your wiring, then re-run.

The arm and the audio blocks are in this file even though neither claims a header pin. Her four arm joints are channels 0 to 3 on a PCA9685, a chip that turns two I2C wires into sixteen independent pulse outputs, so the number a joint carries is a channel and not a GPIO. A second identical board rides the same two wires beside it. Sixteen channels is one board's ceiling, and the time to fit the second one is while the head is open, not after. The microphone and the amplifier are resolved by the name substrings chapter 67 argued for, because a USB index is not an identity. All of them belong here anyway: this is the file you open to ask what a subsystem is attached to, and an answer of "nothing on the header" is an answer.

The two SPI entries are not a duplicate. spi0 is the controller the light ring took in chapter 66 and spi4 is the second one chapter 70 turned on for the battery converter, and the four pins under spi4 are the ones dtoverlay=spi4-1cs hands that controller: GPIO 4 for chip select, 5 and 6 for the two data directions, 7 for the clock. Written down here they are four claims like any other. Left only in a boot config and a pair of constants in the sampler, they are four pins the map would have called free.

▣ Build · stage 4 — a bus claims pins, and two boards claim one address
# labs/hardware_map.py — continued
def extract_claims(config: dict) -> dict[int, list[str]]:
    """{bcm_pin: [owner, ...]} across every subsystem the config declares."""
    claims: dict[int, list[str]] = defaultdict(list)
    for name, servo in config.get("servos", {}).items():
        claims[servo["bcm_pin"]].append(f"servo:{name}")
    for name, device in config.get("i2s", {}).items():
        for key, pin in device.items():
            if key.startswith("bcm_"):
                claims[pin].append(f"i2s:{name}:{key[4:]}")
    for name, bus in config.get("buses", {}).items():
        for pin in bus["pins"]:
            claims[pin].append(f"bus:{name}")     # one claim per bus, whatever hangs off it
    return dict(claims)


def pin_conflicts(claims: dict[int, list[str]]) -> list[str]:
    return [f"GPIO {pin} claimed by {len(owners)}: {', '.join(owners)}"
            for pin, owners in sorted(claims.items()) if len(owners) > 1]


def bus_problems(config: dict) -> list[str]:
    """Two faults a pin count cannot see: an oversubscribed bus and a repeated address."""
    problems: list[str] = []
    for name, bus in config.get("buses", {}).items():
        devices = bus.get("devices", [])
        if bus.get("exclusive") and len(devices) > 1:
            problems.append(f"bus {name} takes one device, {len(devices)} declared: "
                            f"{', '.join(devices)}")
        at: dict[str, list[str]] = defaultdict(list)
        for device in devices:
            if "@" in device:
                node, address = device.split("@")
                at[address].append(node)
        for address, nodes in sorted(at.items()):
            if len(nodes) > 1:
                problems.append(f"bus {name} address {address} claimed by {', '.join(nodes)}")
    return problems


if __name__ == "__main__":
    config = load_config()
    problems = pin_conflicts(extract_claims(config)) + bus_problems(config)
    print(f"FAIL — {len(problems)} problem(s):" if problems else "PASS — every claim checks out")
    for problem in problems:
        print(f"  [x] {problem}")
$ uv run python -m labs.hardware_map
FAIL — 1 problem(s):
  [x] bus i2c1 address 0x40 claimed by pca9685_arm, pca9685_aux

A bus contributes exactly one claim per pin no matter how many devices hang off it, and that single line is what keeps the pin count honest. Two devices sharing SDA and SCL is not a fault, it is the entire point of a bus, and a validator that counted claimants instead of owners would report GPIO 2 and 3 as conflicts on a build that is correct. Chapter 55 hit the same wall with power rails and solved it with a guard in the first pass; a bus is that lesson applied to signal pins.

The fault it does report is not a pin collision at all, and no pin map could have seen it. Both PCA9685 boards left the factory strapped to I2C address 0x40, because that is the address every one of them ships with. Two chips answering to the same address on the same two wires both drive the data line during a reply, so what the Pi reads back is neither board's answer. The cure is on the board, not in the file: each one carries six solder pads labelled A0 to A5, and bridging A0 on the second board adds one to its address. Bridge it, check with i2cdetect -y 1 that 0x40 and 0x41 now both answer, and write down what you did.

// configs/hardware.json — the corrected bus map, with the aux board's A0 pad bridged
{
  "revision": "1.0.0",
  "buses": {
    "spi0": {"pins": [9, 10, 11], "exclusive": true,
             "devices": ["led:eye_ring"]},
    "spi4": {"pins": [4, 5, 6, 7], "exclusive": false,
             "devices": ["adc:battery"]},
    "i2c1": {"pins": [2, 3], "exclusive": false,
             "devices": ["pca9685_arm@0x40", "pca9685_aux@0x41"]}
  }
}
$ uv run python -m labs.hardware_map
PASS — every claim checks out

The exclusive flag has been quiet through all of that, so prove it does something. Hang a second device off the light ring's bus and ask again:

# labs/bus_probe.py
from labs.hardware_map import bus_problems, load_config

if __name__ == "__main__":
    probe = load_config()
    probe["buses"]["spi0"]["devices"].append("adc:battery")
    for problem in bus_problems(probe):
        print(f"  [x] {problem}")
$ uv run python -m labs.bus_probe
  [x] bus spi0 takes one device, 2 declared: led:eye_ring, adc:battery

The ring does not speak SPI: it borrows the data line and pushes a raw bit stream down it at 2.4 megahertz, with no chip select input to ignore anything addressed elsewhere. So a second device on that bus arrives at the ring as pixel colours. That is what "exclusive": true declares, and it is the reason the battery converter reads through a controller of its own instead: spi0 claims three pins and can never hold a second device, spi4 claims four and could. One flag in the config carries a difference that is otherwise buried in a datasheet.

The card you take to the header

▣ Build · stage 5 — physical positions, the free list, and drivers that ask
# labs/hardware_map.py — continued
BCM_TO_PHYSICAL = {
    2: 3,   3: 5,   4: 7,   5: 29,  6: 31,  7: 26,  8: 24,  9: 21,
    10: 19, 11: 23, 12: 32, 13: 33, 14: 8,  15: 10, 16: 36,
    17: 11, 18: 12, 19: 35, 20: 38, 21: 40, 22: 15, 23: 16,
    24: 18, 25: 22, 26: 37, 27: 13,
}


def report(config: dict, claims: dict[int, list[str]]) -> str:
    head = f"{'BCM':>4}  {'PHYS':>4}  OWNER"
    lines = [f"GLaDOS body rev {config['revision']} on {config['board']}", "", head, "-" * 44]
    for pin in sorted(claims):
        for owner in claims[pin]:
            lines.append(f"{pin:>4}  {BCM_TO_PHYSICAL.get(pin, '?'):>4}  {owner}")
    free = sorted(set(BCM_TO_PHYSICAL) - set(claims))
    lines += ["", f"{len(claims)} pins claimed, {len(free)} free: {free}"]
    return "\n".join(lines)


def pin_of(claims: dict[int, list[str]], owner: str) -> int:
    """The BCM pin a named owner holds. Raises if that owner claims no header pin."""
    for pin, owners in sorted(claims.items()):
        if owner in owners:
            return pin
    raise KeyError(f"{owner!r} claims no GPIO pin in this map")


def main() -> None:
    config = load_config()
    claims = extract_claims(config)
    print(report(config, claims))
    problems = pin_conflicts(claims) + bus_problems(config)
    if problems:
        print(f"\nFAIL — {len(problems)} problem(s):")
        for problem in problems:
            print(f"  [x] {problem}")
        sys.exit(1)
    CARD.write_text(report(config, claims) + "\n")
    print("\nPASS — no pin claimed twice, no bus oversubscribed.")
    print(f"Wrote {CARD}")


if __name__ == "__main__":
    main()
$ uv run python -m labs.hardware_map
GLaDOS body rev 1.0.0 on raspberry-pi-4b

 BCM  PHYS  OWNER
--------------------------------------------
   2     3  bus:i2c1
   3     5  bus:i2c1
   4     7  bus:spi4
   5    29  bus:spi4
   6    31  bus:spi4
   7    26  bus:spi4
   9    21  bus:spi0
  10    19  bus:spi0
  11    23  bus:spi0
  12    32  servo:eye_pan
  13    33  servo:eye_tilt
  17    11  servo:bench_test
  18    12  i2s:amp_max98357a:bclk
  19    35  i2s:amp_max98357a:lrclk
  21    40  i2s:amp_max98357a:din

15 pins claimed, 11 free: [8, 14, 15, 16, 20, 22, 23, 24, 25, 26, 27]

PASS — no pin claimed twice, no bus oversubscribed.
Wrote glados/data/pin_map.txt

GPIO 7 and 8 are the two chip selects SPI0 would otherwise hold, and dtoverlay=spi0-0cs in the boot config hands both back: nothing on that bus gets selected, so nothing needs a select line. Only 8 shows as free, because 7 was picked up again immediately as the second controller's clock, which is the sort of second-hand fact a map is for. Eleven free pins is not a lot of headroom for a machine that is nearly finished, and seeing the number is the point. A free list computed by set difference answers "where does the next device go" in the same breath as "is anything broken", off the same map.

▣ Build · stage 6 — the drivers stop carrying their own numbers
# labs/hardware_bind.py
from glados.devices import PiServo             # the DeviceDriver subclass from chapter 53
from labs.hardware_map import BCM_TO_PHYSICAL, extract_claims, load_config, pin_of

if __name__ == "__main__":
    claims = extract_claims(load_config())
    for owner in ("servo:eye_pan", "servo:eye_tilt"):
        pin = pin_of(claims, owner)
        print(f"{owner:<16} -> GPIO {pin} (physical {BCM_TO_PHYSICAL[pin]})")

    eye_pan = PiServo(pin=pin_of(claims, "servo:eye_pan"), name="eye_pan")
    eye_pan.initialize()
    print(eye_pan.safe_read())

    try:
        pin_of(claims, "arm:shoulder")
    except KeyError as exc:
        print(f"KeyError: {exc}")
$ uv run python -m labs.hardware_bind   # measured on the bench — yours will vary
servo:eye_pan    -> GPIO 12 (physical 32)
servo:eye_tilt   -> GPIO 13 (physical 33)
[pi ] eye_pan ready on pin 12
{'device': 'eye_pan', 'angle': 0}
KeyError: "'arm:shoulder' claims no GPIO pin in this map"

Chapter 53's DeviceDriver already takes its pin as a constructor argument, so binding a driver to the map is one call and no rewrite. Two lines of that output earn a second look. initialize() has to run before the read, because safe_read tests the flag that initialize sets and answers {'device': 'eye_pan', 'error': 'not initialized'} on a driver that never claimed its pin. The angle that comes back is 0 rather than a centred 90: the driver reports the last angle it was told to hold, and nothing has told it one yet. A servo knows what it was commanded, never where the horn actually sits.

The last line is the other one. Asking for the shoulder's pin raises, and that is correct: the shoulder is channel 0 on the PCA9685 and has no header pin to hold. A lookup that returned a plausible number there would be worse than useless, because the caller would go on to drive it.

Why this works: counting owners instead of comparing claims

The alternative model is the one everybody reaches for first: keep a flat list of (pin, owner) pairs, since that is literally what the wiring is. Every question then gets expensive. Finding a duplicate means comparing each pair against every other pair, which is quadratic and, more to the point, fiddly enough that people write it wrong. Asking whether GPIO 19 is free means scanning the whole list. Asking who owns GPIO 19 means scanning it again.

Grouping by the pin does that scan once, up front, and then hands you three answers for free. len(claims[18]) > 1 is a conflict, and the list names the culprits instead of merely announcing that a culprit exists. set(BCM_TO_PHYSICAL) - set(claims) is the free list, one line of set arithmetic. claims[18] is the ownership query. All three fall out of one dictionary because the key was chosen to match the thing that has to be unique.

That is the transferable move, and it shows up far away from headers. Whenever a rule says "no two of these may share an X", make X the key and collect the sharers in a list, and the rule stops being an algorithm and becomes a length. Duplicate route paths in a web server, two services binding the same port, two migrations claiming the same version number: the same three lines every time. The follow-on decision is the one stage 4 had to make, which is what counts as one owner. Get that wrong and the check is still cheap and still lies.

⚠ Worked failure — the validator passed and the eye would not tilt

The map is clean, both servos are wired, and the centring script runs without a complaint:

$ uv run python -m labs.hardware_map
15 pins claimed, 11 free: [8, 14, 15, 16, 20, 22, 23, 24, 25, 26, 27]

PASS — no pin claimed twice, no bus oversubscribed.
$ uv run python -m labs.eye_centre   # measured on the bench — yours will vary
servo:eye_pan    -> GPIO 12  centre
servo:eye_tilt   -> GPIO 13  centre

The yoke swings left and right. The cradle does not move at all, and it does not hum, buzz or warm up either, which is the useful detail. A servo commanded past a stop grinds. A servo receiving nothing is silent and limp, exactly as it is after a detach(), so the pulse train is not arriving. The signal wire is in the header, seated firmly, in position thirteen.

# labs/hardware_map.py — added for the argument at the bench
PHYSICAL_TO_BCM = {phys: bcm for bcm, phys in BCM_TO_PHYSICAL.items()}


def where(number: int, claims: dict[int, list[str]]) -> str:
    lines = [f"BCM {number:>2}       -> physical {BCM_TO_PHYSICAL.get(number, '?'):>2}   "
             f"{', '.join(claims.get(number, ['free']))}"]
    bcm = PHYSICAL_TO_BCM.get(number)
    if bcm is None:
        lines.append(f"physical {number:>2}  -> not a GPIO (power, ground or reserved)")
    else:
        lines.append(f"physical {number:>2}  -> BCM {bcm:>7}   "
                     f"{', '.join(claims.get(bcm, ['free']))}")
    return "\n".join(lines)
$ uv run python -m labs.hardware_map --where 13
BCM 13       -> physical 33   servo:eye_tilt
physical 13  -> BCM      27   free

There it is. The wire is in physical position 13, which is BCM 27, a pin nothing claims and nothing drives. The code sent perfect pulses to BCM 13 at physical 33, three rows further down, where there is no wire. Neither the map nor the driver was wrong; the failure happened between a number on a screen and a hand counting holes, and there is no data structure anywhere that can see that.

What data can do is stop offering the ambiguous number on its own. That is why report prints the physical column beside the BCM column and writes the result to a card you take to the bench: the only number your fingers ever act on is the physical one, and the only number the code ever holds is the BCM one. Cross the two and you get silence, which is the hardest symptom to diagnose in the whole build.

Checkpoint, and the order things get bolted down in

✓ Checkpoint — what you can now do
  • I can say why the claim map stores a list per pin, and what a pin-to-owner dict would have destroyed at the moment the collision happened.
  • I can explain why two I2C devices on GPIO 2 and 3 are not a pin conflict while two devices on the ring's SPI bus are, and where that difference lives in the config.
  • Given two boards that both answer to 0x40, I can say what the bus actually returns and which pad on the second board fixes it.
  • Handed a servo that is silent and limp instead of humming, I can tell a wrong pin from a wrong angle before I touch a multimeter.
  • I can convert either direction between BCM 13 and physical 13 and say which of the two belongs in code.
  • I can compute the free-pin list from the map in one line and say how much headroom the body has left.
  • I know why pin_of("arm:shoulder") raises instead of returning a number, and what a plausible answer there would have cost.
⚡ Exercises — try first, then reveal
Exercise 1 — budget the pins you have left. Print the free list, then annotate it: which of those eleven are truly spare, and which carry a default function you would be giving up by using them?

sorted(set(BCM_TO_PHYSICAL) - set(claims)) gives the eleven. Now put a second dictionary beside it naming the functions those pins hold by default, and merge the two:

DEFAULT_FUNCTION = {7: "SPI0 CE1", 8: "SPI0 CE0", 14: "UART TX", 15: "UART RX"}

for pin in sorted(set(BCM_TO_PHYSICAL) - set(claims)):
    note = DEFAULT_FUNCTION.get(pin, "spare")
    print(f"GPIO {pin:>2}  physical {BCM_TO_PHYSICAL[pin]:>2}  {note}")
$ uv run python -m labs.pin_budget
GPIO  8  physical 24  SPI0 CE0
GPIO 14  physical  8  UART TX
GPIO 15  physical 10  UART RX
GPIO 16  physical 36  spare
GPIO 20  physical 38  spare
GPIO 22  physical 15  spare
GPIO 23  physical 16  spare
GPIO 24  physical 18  spare
GPIO 25  physical 22  spare
GPIO 26  physical 37  spare
GPIO 27  physical 13  spare

Eight of the eleven are spare with nothing attached. GPIO 7 never reaches the loop at all now that the ADC's clock holds it, so the CE1 row the table would have printed is gone with it. Taking 14 or 15 costs you the serial console, which is the one way into a Pi that has stopped answering on the network, and giving that up during assembly is a trade you want to make deliberately.

Exercise 2 — catch a pin that does not exist. Add a check that flags any claimed pin missing from BCM_TO_PHYSICAL, so a "bcm_pin": 45 typo fails in the config instead of at the header.
def range_problems(claims: dict[int, list[str]]) -> list[str]:
    return [f"GPIO {pin} is not on this header (BCM 2-27), claimed by {', '.join(owners)}"
            for pin, owners in sorted(claims.items()) if pin not in BCM_TO_PHYSICAL]
$ uv run python -m labs.hardware_map
FAIL — 1 problem(s):
  [x] GPIO 45 is not on this header (BCM 2-27), claimed by servo:eye_tilt

Add it to the list main builds and the report grows a row reading 45 ? for a pin the lookup cannot place. The question mark was already there in report, printed by BCM_TO_PHYSICAL.get(pin, '?'); this turns a mark you might skim past into a failure that stops the run.

Exercise 3 — print the strip you will actually touch. Generate an assembly card ordered by physical position instead of BCM, listing only the positions that carry a wire, so the header can be read top to bottom while you plug.
rows = sorted((BCM_TO_PHYSICAL[pin], pin, owners)
              for pin, owners in claims.items() if pin in BCM_TO_PHYSICAL)
print(f"{'PHYS':>4}  {'ROW':>5}  {'BCM':>4}  OWNER")
for phys, pin, owners in rows:
    row = "left" if phys % 2 else "right"
    print(f"{phys:>4}  {row:>5}  {pin:>4}  {', '.join(owners)}")
$ uv run python -m labs.assembly_card
PHYS    ROW   BCM  OWNER
   3   left     2  bus:i2c1
   5   left     3  bus:i2c1
   7   left     4  bus:spi4
  11   left    17  servo:bench_test
  12  right    18  i2s:amp_max98357a:bclk
  19   left    10  bus:spi0
  21   left     9  bus:spi0
  23   left    11  bus:spi0
  26  right     7  bus:spi4
  29   left     5  bus:spi4
  31   left     6  bus:spi4
  32  right    12  servo:eye_pan
  33   left    13  servo:eye_tilt
  35   left    19  i2s:amp_max98357a:lrclk
  40  right    21  i2s:amp_max98357a:din

Odd positions are the row nearest the board edge and even ones the row behind it, so the row column removes the last thing you have to work out by eye. Tape this inside the shell next to the wiring card from chapter 55 and the two together describe the whole machine: one says what connects to what, the other says where.

Every pin the body claims now exists in one file, and a run of that file tells you in milliseconds what used to take an evening with a continuity tester. That file is also the last thing standing between the parts on the bench and a machine with everything inside it. Assembly is the one stage you cannot re-run from a clean state, and it is long enough that you will stop halfway through and close the laptop, so the next chapter builds a checklist that remembers exactly which steps are already done.