The Wiring Manifest
The build that only exists in your head
The bench rig has been growing since chapter 40 and nobody has written any of it down. An ESP32 on the end of a USB cable. A servo taking its pulses from GPIO 18 and its current from a separate supply. An ultrasonic module triggered on GPIO 5, answering on GPIO 4 through two resistors that knock its 5 V echo down to something the board can survive. Everything works. You know exactly how it goes together, right up until a Tuesday in November when the servo dies and you are standing at the bench holding a replacement, trying to remember whether the divider was on ECHO or on TRIG.
The usual ways of remembering all leak in the same place. A photograph shows you a
bundle of jumper wires and tells you nothing about which end drives which, or what
voltage the green one carries. A drawing is better to look at and still cannot answer a
question: ask a picture whether GPIO 18 is free and it just sits there. A comment in the
code, # GPIO18 drives the servo, is true on the day you type it and
silently false the first time you move a wire, because nothing anywhere checks it. Each
of those describes the build. None of them constrains it.
So the rule for every wire from here to the finished body: the wiring lives as a list of typed records, every reference you print is generated from that list, and the list refuses to describe a build that would not survive being built. Three questions get asked automatically: is every part named actually declared, is every voltage one this rig has, and is any output pin feeding two loads at once.
The parts are the ones already on the bench: an ESP32 DevKit v1, an SG90 servo on the arm joint, an HC-SR04 ultrasonic module, and a 5 V supply that is not the USB cable. Two details from the hardware tests in chapter 42 are the reason the manifest is more than a list. The servo pulls far more current while it moves than the board's regulator can give, so it feeds from that separate supply with the grounds tied together. And the HC-SR04 drives its echo pin to a full 5 V, which an ESP32 input does not tolerate, so the signal arrives through a divider. Both facts are invisible in a photograph and both are fields in a record.
One wire, then all of them
# labs/wiring_manifest.py
from dataclasses import dataclass
@dataclass
class WireConnection:
from_component: str
from_pin: str
to_component: str
to_pin: str
signal: str # PWM, TRIG, ECHO, VCC, GND
direction: str # out, in, power, ground
volts: float # 0.0, 3.3 or 5.0
color: str = "white"
note: str = ""
if __name__ == "__main__":
wire = WireConnection(
from_component="esp32", from_pin="GPIO18",
to_component="servo_sg90", to_pin="signal",
signal="PWM", direction="out", volts=3.3, color="orange",
note="50 Hz, 0.5-2.5 ms pulse",
)
print(wire)
$ uv run python -m labs.wiring_manifest
WireConnection(from_component='esp32', from_pin='GPIO18', to_component='servo_sg90', to_pin='signal', signal='PWM', direction='out', volts=3.3, color='orange', note='50 Hz, 0.5-2.5 ms pulse')
Nine fields, and every one answers a question you will ask with a multimeter otherwise.
direction is the field doing the most work: it separates a signal, which
one pin drives and one load receives, from power and ground, which are supposed to fan
out to everything. color is there because the manifest has to survive
contact with a real bundle of jumpers; when the reference says the green wire is ECHO,
you can find it without tracing. Nothing here is validated yet. The record is only a
place to put the truth.
from dataclasses import dataclass, field
@dataclass
class Component:
name: str
kind: str # controller, actuator, sensor, passive, supply
part: str
logic_volts: float = 3.3 # the highest signal voltage its pins tolerate
@dataclass
class WiringManifest:
project: str
revision: str
components: list[Component] = field(default_factory=list)
wires: list[WireConnection] = field(default_factory=list)
def add(self, wire: WireConnection) -> None:
self.wires.append(wire)
if __name__ == "__main__":
manifest = WiringManifest(project="GLaDOS bench rig", revision="0.1.0")
manifest.components = [
Component("esp32", "controller", "ESP32 DevKit v1", logic_volts=3.3),
Component("servo_sg90", "actuator", "SG90 micro servo, arm joint", logic_volts=5.0),
]
manifest.add(wire)
print(f"{manifest.project} rev {manifest.revision}: "
f"{len(manifest.components)} components, {len(manifest.wires)} wire(s)")
$ uv run python -m labs.wiring_manifest
GLaDOS bench rig rev 0.1.0: 2 components, 1 wire(s)
field(default_factory=list) is not decoration. Writing
components: list = [] evaluates that empty list once, when the class is
defined, and every manifest you ever create shares it; add a wire to one and it appears
in all of them. The factory runs per instance instead. The other decision worth naming
is logic_volts on the component: the board tolerates 3.3 V on its pins and
the sensor tolerates 5 V, and putting that number on the part rather than repeating it
on every wire is what lets the validator check a signal against its destination in the
next stage.
Two passes, because one wire cannot be judged alone
VALID_VOLTS = {0.0, 3.3, 5.0}
SIGNAL = {"out", "in"}
def validate(self) -> list[str]:
parts = {c.name: c for c in self.components}
driven: dict[str, list[str]] = {}
for w in self.wires: # pass 1: build the index
if w.direction in SIGNAL:
driven.setdefault(f"{w.from_component}:{w.from_pin}", []).append(
f"{w.to_component}:{w.to_pin}")
errors = []
for w in self.wires: # pass 2: judge each wire
where = f"{w.from_component}:{w.from_pin} -> {w.to_component}:{w.to_pin}"
for name in (w.from_component, w.to_component):
if name not in parts:
errors.append(f"undeclared component '{name}' on {where}")
if w.volts not in VALID_VOLTS:
errors.append(f"{w.volts}V is not a rail on this build ({where})")
for pin, loads in sorted(driven.items()):
if len(loads) > 1:
errors.append(f"{pin} drives {len(loads)} loads: {', '.join(loads)}")
return errors
if __name__ == "__main__":
manifest = WiringManifest(project="GLaDOS bench rig", revision="0.2.0")
manifest.components = [
Component("esp32", "controller", "ESP32 DevKit v1", logic_volts=3.3),
Component("servo_sg90", "actuator", "SG90 micro servo, arm joint", logic_volts=5.0),
]
manifest.add(WireConnection("esp32", "GPIO18", "servo_sg90", "signal",
"PWM", "out", 3.3, "orange"))
manifest.add(WireConnection("esp32", "GPIO12", "eye_ring", "DIN",
"DATA", "out", 12.0, "white"))
for problem in manifest.validate():
print(f"[x] {problem}")
$ uv run python -m labs.wiring_manifest
[x] undeclared component 'eye_ring' on esp32:GPIO12 -> eye_ring:DIN
[x] 12.0V is not a rail on this build (esp32:GPIO12 -> eye_ring:DIN)
Two errors from one careless wire, and both are the kind that costs an evening. The
light ring exists in your plans and not in the parts list, so nothing else in the
project knows about it. Twelve volts is nowhere on this rig, so the number is either a
typo or a component that needs its own supply. Errors accumulate in a list and come
back together, the same way the config check in chapter 34 reports every bad path in
one run: you want the whole board's problems in one read, not the first one and a
shrug. Notice also that validate returns them instead of raising, so the
caller decides whether to warn, abort, or save anyway.
for w in self.wires: # pass 2, continued
sink = parts.get(w.to_component)
if w.direction in SIGNAL and sink and w.volts > sink.logic_volts:
errors.append(f"{w.volts}V signal into {sink.name}, which tolerates "
f"{sink.logic_volts}V ({where})")
if __name__ == "__main__":
manifest = WiringManifest(project="GLaDOS bench rig", revision="0.3.0")
manifest.components = [
Component("esp32", "controller", "ESP32 DevKit v1", logic_volts=3.3),
Component("hc_sr04", "sensor", "HC-SR04 ultrasonic module", logic_volts=5.0),
]
manifest.add(WireConnection("esp32", "GPIO5", "hc_sr04", "TRIG",
"TRIG", "out", 3.3, "blue"))
manifest.add(WireConnection("hc_sr04", "ECHO", "esp32", "GPIO4",
"ECHO", "in", 5.0, "green"))
problems = manifest.validate()
for problem in problems:
print(f"[x] {problem}")
print(f"{len(manifest.wires)} wires, {len(problems)} problem(s)")
$ uv run python -m labs.wiring_manifest
[x] 5.0V signal into esp32, which tolerates 3.3V (hc_sr04:ECHO -> esp32:GPIO4)
2 wires, 1 problem(s)
That is the most valuable line the program will ever print. Wiring ECHO straight to GPIO 4 is the single most common way people kill an ESP32 with an HC-SR04, it looks entirely reasonable on a breadboard, and the board gives no warning: it works for a while and then that input stops reading. The check that catches it is one comparison between a number on the wire and a number on the part it lands in. Note that TRIG passes in the same run, because 3.3 V into a part rated for 5 V is a signal arriving low, not a part being overdriven.
manifest.components.append(
Component("divider", "passive", "1 kOhm / 2 kOhm divider on ECHO", logic_volts=5.0))
manifest.add(WireConnection("hc_sr04", "ECHO", "divider", "IN",
"ECHO", "out", 5.0, "green"))
manifest.add(WireConnection("divider", "OUT", "esp32", "GPIO4",
"ECHO", "in", 3.3, "green"))
$ uv run python -m labs.wiring_manifest
3 wires, 0 problem(s)
The two resistors are a component with a name, a parts-list entry, and two wires of its own. That is the modelling decision the whole chapter turns on. A divider is easy to think of as a detail of the ECHO connection, and if you treat it that way the manifest has one 5 V wire into a 3.3 V pin with a reassuring note attached, which is exactly the build that fails. Making it a part puts the domain change where the data can see it: one wire enters at 5 V and a different wire leaves at 3.3 V, and both ends are legal because each names a component that tolerates what it receives.
The reference regenerates itself
def bench_manifest() -> WiringManifest:
m = WiringManifest(project="GLaDOS bench rig", revision="1.0.0")
m.components = [
Component("esp32", "controller", "ESP32 DevKit v1", logic_volts=3.3),
Component("psu_5v", "supply", "5 V 2 A bench supply", logic_volts=5.0),
Component("servo_sg90", "actuator", "SG90 micro servo, arm joint", logic_volts=5.0),
Component("hc_sr04", "sensor", "HC-SR04 ultrasonic module", logic_volts=5.0),
Component("divider", "passive", "1 kOhm / 2 kOhm divider on ECHO", logic_volts=5.0),
]
for w in [
WireConnection("esp32", "GPIO18", "servo_sg90", "signal",
"PWM", "out", 3.3, "orange", "50 Hz, 0.5-2.5 ms pulse"),
WireConnection("esp32", "GPIO5", "hc_sr04", "TRIG",
"TRIG", "out", 3.3, "blue", "10 us pulse starts a ping"),
WireConnection("hc_sr04", "ECHO", "divider", "IN",
"ECHO", "out", 5.0, "green", "module drives this to 5 V"),
WireConnection("divider", "OUT", "esp32", "GPIO4",
"ECHO", "in", 3.3, "green", "divided down for a 3.3 V input"),
WireConnection("psu_5v", "+5V", "servo_sg90", "VCC",
"VCC", "power", 5.0, "red", "stall current stays off the board"),
WireConnection("psu_5v", "+5V", "hc_sr04", "VCC",
"VCC", "power", 5.0, "red"),
WireConnection("psu_5v", "GND", "servo_sg90", "GND",
"GND", "ground", 0.0, "black"),
WireConnection("psu_5v", "GND", "hc_sr04", "GND",
"GND", "ground", 0.0, "black"),
WireConnection("psu_5v", "GND", "divider", "GND",
"GND", "ground", 0.0, "black", "bottom leg of the divider"),
WireConnection("psu_5v", "GND", "esp32", "GND",
"GND", "ground", 0.0, "black", "common ground or the PWM has no reference"),
]:
m.add(w)
return m
def reference(self) -> str:
head = f"{'FROM':<16}{'TO':<22}{'SIGNAL':<8}{'DIR':<8}{'V':>5} WIRE"
rows = [f"{self.project} rev {self.revision}", head, "-" * len(head)]
for w in self.wires:
rows.append(f"{w.from_component + ':' + w.from_pin:<16}"
f"{w.to_component + ':' + w.to_pin:<22}"
f"{w.signal:<8}{w.direction:<8}{w.volts:>5.1f} {w.color}")
return "\n".join(rows)
$ uv run python -m labs.wiring_manifest
GLaDOS bench rig rev 1.0.0
FROM TO SIGNAL DIR V WIRE
-----------------------------------------------------------------
esp32:GPIO18 servo_sg90:signal PWM out 3.3 orange
esp32:GPIO5 hc_sr04:TRIG TRIG out 3.3 blue
hc_sr04:ECHO divider:IN ECHO out 5.0 green
divider:OUT esp32:GPIO4 ECHO in 3.3 green
psu_5v:+5V servo_sg90:VCC VCC power 5.0 red
psu_5v:+5V hc_sr04:VCC VCC power 5.0 red
psu_5v:GND servo_sg90:GND GND ground 0.0 black
psu_5v:GND hc_sr04:GND GND ground 0.0 black
psu_5v:GND divider:GND GND ground 0.0 black
psu_5v:GND esp32:GND GND ground 0.0 black
10 wires checked. Wrote glados/data/wiring_manifest.json and glados/data/wiring_reference.txt
The rows come out in the order they were added, not sorted, because that order is the order you wire them: signals first while the board is unpowered, then power, then ground last on the ESP32 so the common reference is the final connection you make. A sorted table would look tidier and be less useful with a spool of wire in your hand. Print this, tape it inside the enclosure lid, and the November servo replacement takes four minutes.
import json
from dataclasses import asdict
from pathlib import Path
MANIFEST = Path("glados/data/wiring_manifest.json")
CARD = Path("glados/data/wiring_reference.txt")
def save(self, path: Path = MANIFEST) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({
"project": self.project,
"revision": self.revision,
"components": [asdict(c) for c in self.components],
"wires": [asdict(w) for w in self.wires],
}, indent=2) + "\n")
def main() -> None:
manifest = bench_manifest()
problems = manifest.validate()
if problems:
print(f"{len(problems)} problem(s) before you touch a wire:")
for problem in problems:
print(f" [x] {problem}")
return
print(manifest.reference())
manifest.save()
CARD.write_text(manifest.reference() + "\n")
print(f"\n{len(manifest.wires)} wires checked. Wrote {MANIFEST} and {CARD}")
if __name__ == "__main__":
main()
{
"from_component": "hc_sr04",
"from_pin": "ECHO",
"to_component": "divider",
"to_pin": "IN",
"signal": "ECHO",
"direction": "out",
"volts": 5.0,
"color": "green",
"note": "module drives this to 5 V"
}
One list, two renderings, and a validation gate in front of both: an invalid manifest
never reaches the printer or the file, so a wiring reference on disk is a reference
that passed. There is no timestamp anywhere in the JSON, on purpose. A generated file
that changes every time you regenerate it produces a diff on every commit, and a diff
you always ignore is a diff you will ignore on the day it says the ECHO wire moved. The
file changes when the wiring changes; revision is the field you bump by
hand when it does.
Why this works: an index built before any judgement
Most of the checks are local. Whether a component was declared, whether 12.0 is a voltage this rig has, whether a 5 V signal is walking into a 3.3 V pin: each of those can be answered by looking at one wire and the parts list. The conflict check cannot. Asking whether GPIO 18 drives two loads is a question about every wire at once, and no single record contains the answer.
That is what the first pass buys. It walks the wires and builds driven, a
dict from a driver pin to the list of loads hanging off it, and it decides membership by
direction: signals go in the index, power and ground do not, since a rail
feeding five components is the correct arrangement and not a fault. Only when the index
is complete does the second pass judge anything. The order matters more than it looks.
A single pass that flagged a pin the moment it appeared twice would report the conflict
against whichever wire happened to be second in the file, so the same board would produce
different messages depending on the order you typed the wires in. The two-pass version
reports the pin once, names every load on it, and gives the same answer whatever the
order.
The general move here is one you will use far outside wiring. When a rule is about a relationship between records, build the index first and evaluate second; when a rule is about a record on its own, check it in the loop. Dependency graphs, duplicate detection, reference checking across a document: same structure every time, and the tell is always a question that no single item can answer.
The obvious first version of the index does not bother with direction. A
pin should drive one thing, so record every pin and complain about repeats:
for w in self.wires:
driven.setdefault(f"{w.from_component}:{w.from_pin}", []).append(
f"{w.to_component}:{w.to_pin}") # BUG: rails indexed like signals
Run it against the full rig plus a status LED you have just added to the parts list, wired to GPIO 18 by a slip of the finger when you meant GPIO 2:
$ uv run python -m labs.wiring_manifest
3 problem(s) before you touch a wire:
[x] esp32:GPIO18 drives 2 loads: servo_sg90:signal, status_led:anode
[x] psu_5v:+5V drives 2 loads: servo_sg90:VCC, hc_sr04:VCC
[x] psu_5v:GND drives 5 loads: servo_sg90:GND, hc_sr04:GND, divider:GND, esp32:GND, status_led:cathode
The first line is a real fault: GPIO 18 is now wired to the servo and to an LED, and the servo will twitch every time she blinks. The other two lines are the supply doing its job. Nothing is wrong with the manifest there; the validator is wrong, because it treats a power rail as though it were a signal pin. With five components the noise is merely annoying. With the finished body in volume 7, where a dozen parts share 5 V and ground, a run like this prints twenty lines of correct wiring and buries the one line that matters, and you learn to skim past all of it.
The fix is the if w.direction in SIGNAL guard in pass 1: rails never enter
the index, so they can never be counted twice. Re-run and the output is one line, the
true one. A check that cries wolf is worse than no check, because it trains you to stop
reading; when a validator disagrees with a board you know is correct, suspect the
validator's model of reality before you start pulling wires.
Checkpoint, and the reference nobody has to update
- I can say which of the four checks needs the whole wire list before it can answer, and why that forces two passes instead of one.
- I can explain why the divider is a declared component with two wires instead of a note on one wire, in terms of what the validator can then see.
- Handed a manifest where every ground wire reports a conflict, I know the validator is wrong and I know which field it is ignoring.
- I can trace the 5 V ECHO error from the number on the wire to the number on the component it lands in, and name the part that gets destroyed without it.
- I know why
field(default_factory=list)appears on both list fields and what a bare= []would silently share. - I can say why the generated JSON carries no timestamp, and what a diff on every commit costs you later.
Exercise 1 — ask the manifest what is still free. Add a
free_pins() method that takes the board's usable GPIO numbers, subtracts
every pin the manifest claims on esp32, and prints what is left. This is
the question a diagram cannot answer.
Walk both ends of every wire, keep the ones where the component is
esp32 and the pin starts with GPIO, and turn the rest of
the name into an int. On a DevKit v1 the comfortably usable set is 4, 5, 12, 13,
14, 16, 17, 18, 19, 21, 22, 23, 25, 26, 27, 32 and 33; the rig claims 4, 5 and 18,
so fourteen remain. Print them sorted and you are looking at the budget for the
light ring, the second servo and the microphone board that volume 7 adds.
Exercise 2 — make the supply answer for itself. Give
WireConnection a current_ma field, give the supply component
a rating, and add a check that sums the draw on each power pin and fails when the
total passes the rating.
Sum current_ma across wires whose direction is
"power", keyed by from_component:from_pin, and compare
against the supply's rating. Today's rig is quiet: an SG90 pulls around 700 mA
stalled and the HC-SR04 about 15, so 715 mA sits well inside a 2 A supply. Add
three servos and a sixteen-LED ring at full white and you are asking for roughly
3 A, and the manifest tells you months before the brownouts do. Use the stall
current, not the idle current, because the moment every part draws at once is the
only moment the number matters.
Exercise 3 — prove the reference comes from the file. Write
from_file(path) that rebuilds a manifest out of the saved JSON, print
its reference card, then edit one voltage in the file by hand and watch the reload
refuse it.
Component(**c) and WireConnection(**w) turn each saved
dict straight back into a typed object, since the JSON keys were generated from the
field names in the first place. Reload the rig and you should see the same ten rows
and an empty problem list. Now open the file, change the divider output wire's
volts from 3.3 to 5.0, save, and reload: the domain check fires on data
that never went near the Python source. That is the difference between documentation
and a manifest, and it is the reason this file is worth committing.
The hardware now documents itself, and the document refuses to lie. What it does not yet cover is everything the reader on the other side of the enclosure needs: not which wire is green, but what they can actually say to her, what each phrase does, and which ones the permission table will refuse. Chapter 56 gives the command set the same treatment this chapter gave the wiring, keeping one list of definitions and rendering it into a printed reference, a machine-readable feed, and the answer she gives when someone asks her what she can do.