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.
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
# 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.
# 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.
The file that outranks the six
{
"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.
# 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
# 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.
# 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.
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
- 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.
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.