The First Hardware Tests
Three parts that all look fine
The link from chapter 40 carries framed commands to a board that answers in the same language, and so far the only thing on the far end has been an LED. Hang the rest of her first body off it: a hobby servo for the arm, an ultrasonic module so she can tell when you walk up, and that same status light. Power everything on and the servo twitches to a position, the sensor returns numbers that look like distances, the LED blinks on command. Nothing is smoking. Everything appears to work.
The trouble is that hardware never volunteers how well it works. A servo that stops at 172 degrees behaves exactly like one that reaches 180, right up until her arm needs those last eight degrees to clear the desk. An ultrasonic module reading three centimetres long produces numbers indistinguishable from correct ones. And six weeks from now, when the arm starts stalling early, you will want to know whether the servo degraded or was always like that. Eyeballing it today leaves that question unanswerable forever, because you never wrote down what today looked like.
So the rule for every component that joins her from here on: write down the value you expect and the tolerance you will accept before you take a reading, store the reading beside them, and compute pass or fail from the pair. Chapter 25 made you fix an experiment's hypothesis before running it, for exactly the reason your memory rewrites results after seeing them. A tolerance is that same hypothesis, in millimetres and degrees.
An ESP32 board on the end of the USB cable from chapter 40, a nine-gram SG90 servo signalled from GPIO 18, and an HC-SR04 ultrasonic module on GPIO 5 (trigger) and GPIO 4 (echo). Two wiring facts that cost people boards: the servo draws far more current when it moves than the board's regulator can supply, so it gets its own 5 V supply with the grounds tied together, and the HC-SR04 echo pin swings to 5 V, which an ESP32 input does not want, so it arrives through a two-resistor divider. Every number captured in this chapter came off one bench with one set of parts. Yours will land somewhere else, and the point of the chapter is that you will know where.
A record that cannot flatter itself
# labs/hardware_tests.py
from dataclasses import dataclass
@dataclass
class HardwareTest:
component: str
check: str
expected: float
tolerance: float
unit: str
measured: float | None = None
note: str = ""
@property
def passed(self) -> bool:
if self.measured is None:
return False
return abs(self.measured - self.expected) <= self.tolerance
if __name__ == "__main__":
test = HardwareTest("arm_servo", "travel_0_to_180",
expected=180.0, tolerance=8.0, unit="deg")
print(test)
print("passed before any measurement:", test.passed)
test.measured = 168.0
print("passed at 168 deg:", test.passed)
$ uv run python -m labs.hardware_tests
HardwareTest(component='arm_servo', check='travel_0_to_180', expected=180.0, tolerance=8.0, unit='deg', measured=None, note='')
passed before any measurement: False
passed at 168 deg: False
Two design decisions carry the whole chapter. First, expected and
tolerance are numbers, not the strings a hand-written note would use, so
the comparison is arithmetic instead of judgement. Second, passed is a
@property, computed on every read from the fields around it, so no
sequence of edits can leave a stored True sitting next to a measurement
that disagrees with it. Notice what the first print says: a test that has not run yet
reports False. Unmeasured and passing must never be the same answer, and
the cheapest way to guarantee that is a None the property refuses.
# esp32/main.py — added to the handler from chapter 40
import time
from machine import PWM, Pin, time_pulse_us
SERVO = PWM(Pin(18), freq=50)
TRIG = Pin(5, Pin.OUT)
ECHO = Pin(4, Pin.IN)
def servo_write(angle):
angle = max(0, min(180, int(angle))) # the same clamp, now in front of a motor
pulse_us = 500 + (angle / 180) * 2000 # 0.5 ms at 0 deg, 2.5 ms at 180 deg
SERVO.duty_ns(int(pulse_us * 1000))
return angle
def read_distance_cm():
TRIG.value(0)
time.sleep_us(2)
TRIG.value(1)
time.sleep_us(10)
TRIG.value(0)
us = time_pulse_us(ECHO, 1, 30000) # negative when no echo comes back
if us < 0:
return None
return us / 58.0
def handle(msg):
command = msg.get("command")
if command == "servo.set":
applied = servo_write(msg.get("params", {}).get("angle", 0))
return {"status": "ok", "command": command, "angle": applied}
if command == "distance.read":
return {"status": "ok", "command": command, "cm": read_distance_cm()}
... # ping and led.set as before
The clamp written in chapter 27 against a simulator is now the last thing standing
between a bad number and a gearbox, and it lives on the board, not on the laptop, so
that a corrupt or confused host cannot route around it. Below the clamp is
the conversion the simulator deliberately postponed: 50 pulses a second, with the
width of each pulse carrying the angle. And us / 58.0 is the sensor's
arithmetic in one constant. Sound covers about 34,300 centimetres per second, the
pulse travels to the target and back, so a centimetre of distance costs roughly 58
microseconds of flight time.
Three components, three honest ways to get a number
import time
from labs.hardware_link import command, open_link
def measure_servo_travel(link) -> HardwareTest:
test = HardwareTest("arm_servo", "travel_0_to_180",
expected=180.0, tolerance=8.0, unit="deg")
command(link, {"command": "servo.set", "params": {"angle": 0}})
time.sleep(1.0)
low = float(input(" protractor reading at commanded 0 deg: "))
command(link, {"command": "servo.set", "params": {"angle": 180}})
time.sleep(1.0)
high = float(input(" protractor reading at commanded 180 deg: "))
test.measured = high - low
test.note = f"{low:.0f} to {high:.0f} deg"
return test
def measure_sensor_accuracy(link, target_cm: float = 50.0, samples: int = 5) -> HardwareTest:
test = HardwareTest("distance_sensor", f"accuracy_at_{target_cm:.0f}cm",
expected=target_cm, tolerance=2.0, unit="cm")
readings = []
for _ in range(samples):
reply = command(link, {"command": "distance.read"})
if reply.get("cm") is not None:
readings.append(reply["cm"])
time.sleep(0.1)
if not readings:
test.note = f"0/{samples} echoes returned"
return test
test.measured = sum(readings) / len(readings)
test.note = (f"{len(readings)}/{samples} echoes, "
f"spread {max(readings) - min(readings):.1f} cm")
return test
def measure_led_latency(link) -> HardwareTest:
test = HardwareTest("status_led", "round_trip_latency",
expected=15.0, tolerance=10.0, unit="ms")
samples = []
for state in ("on", "off", "on", "off"):
started = time.perf_counter()
reply = command(link, {"command": "led.set", "params": {"state": state}})
samples.append((time.perf_counter() - started) * 1000)
if reply.get("status") != "ok":
test.note = f"board refused led.set: {reply}"
return test
time.sleep(0.2)
test.measured = sum(samples) / len(samples)
test.note = f"{len(samples)} toggles, worst {max(samples):.1f} ms"
return test
Three components, three different sources for the measured number, and none of them
is the board repeating your command back. The servo has no position feedback of any
kind, so the only instrument available is you and a protractor printed on paper and
taped under the horn; the input() calls are the acceptance test admitting
that a human is the sensor here. The ultrasonic module does report a genuine physical
quantity, but a single ping is noisy, so five of them get averaged and the spread
travels along in the note where it can be read later. The LED is timed instead of
observed, because what an acceptance test can check about a lamp on a serial link is
how long the command took to be acknowledged.
import json
from datetime import datetime
from pathlib import Path
LOG = Path("glados/data/hardware_tests.json")
BENCH = "ESP32 DevKit v1, SG90 on GPIO 18, HC-SR04 on GPIO 5/4"
def log_run(tests: list[HardwareTest], path: Path = LOG, bench: str = BENCH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
runs = json.loads(path.read_text()) if path.exists() else []
runs.append({
"recorded_at": datetime.now().isoformat(timespec="seconds"),
"bench": bench,
"results": [
{"component": t.component, "check": t.check, "expected": t.expected,
"tolerance": t.tolerance, "unit": t.unit, "measured": t.measured,
"passed": t.passed, "note": t.note}
for t in tests
],
})
path.write_text(json.dumps(runs, indent=2))
The log appends runs instead of overwriting one, because a single reading answers
"does it work today" and a series answers "is it getting worse", which is the
question that actually arrives. Each run carries the timestamp and the
bench string, so a result recorded with a different servo on a different
supply can never be mistaken for a regression in this one. And the dict is written by
hand, field by field, for a reason: passed is a property, so the
automatic conversions that turn a dataclass into a dict would drop it silently. Here
it is listed alongside the numbers that produced it, which makes the file readable to
a person with no code in front of them.
CHECKS = [
("arm_servo · travel_0_to_180", measure_servo_travel),
("distance_sensor · accuracy_at_50cm", measure_sensor_accuracy),
("status_led · round_trip_latency", measure_led_latency),
]
def summarize(tests: list[HardwareTest]) -> None:
passed = sum(1 for t in tests if t.passed)
print(f"\nResults: {passed} of {len(tests)} within tolerance")
for t in tests:
status = "PASS" if t.passed else "FAIL"
measured = "not run" if t.measured is None else f"{t.measured:.1f}"
line = (f" [{status}] {t.component:<16}{t.check:<20}{measured:>7} {t.unit:<4}"
f"expected {t.expected:.1f} +/- {t.tolerance:.1f}")
print(line + (f" {t.note}" if t.note else ""))
def main() -> None:
tests = []
with open_link() as link:
for i, (label, check) in enumerate(CHECKS, start=1):
print(f"[{i}/{len(CHECKS)}] {label}")
tests.append(check(link))
log_run(tests)
summarize(tests)
print(f"Run appended to {LOG}")
if __name__ == "__main__":
main()
$ uv run python -m labs.hardware_tests # measured on the bench — yours will vary
[1/3] arm_servo · travel_0_to_180
protractor reading at commanded 0 deg: 4
protractor reading at commanded 180 deg: 172
[2/3] distance_sensor · accuracy_at_50cm
[3/3] status_led · round_trip_latency
Results: 2 of 3 within tolerance
[FAIL] arm_servo travel_0_to_180 168.0 deg expected 180.0 +/- 8.0 4 to 172 deg
[PASS] distance_sensor accuracy_at_50cm 50.9 cm expected 50.0 +/- 2.0 5/5 echoes, spread 0.7 cm
[PASS] status_led round_trip_latency 13.6 ms expected 15.0 +/- 10.0 4 toggles, worst 15.8 ms
Run appended to glados/data/hardware_tests.json
A failing acceptance run on the first day is the normal outcome, and this one is informative rather than alarming. This servo travels 168 degrees between its stops, twelve short of the spec on its packaging, which is ordinary for parts at this price. You now have two legitimate moves. Widen the tolerance to 15 degrees, on purpose, and let the log record that you decided so. Or keep the tolerance and design the arm's geometry to need only 160 degrees of travel. What you cannot do any more is not notice. The sensor and the light are inside their bands, with the sensor's 0.7 cm spread telling you the reading is steady as well as close.
[
{
"recorded_at": "2026-08-22T14:07:19",
"bench": "ESP32 DevKit v1, SG90 on GPIO 18, HC-SR04 on GPIO 5/4",
"results": [
{
"component": "arm_servo",
"check": "travel_0_to_180",
"expected": 180.0,
"tolerance": 8.0,
"unit": "deg",
"measured": 168.0,
"passed": false,
"note": "4 to 172 deg"
},
{
"component": "distance_sensor",
"check": "accuracy_at_50cm",
"expected": 50.0,
"tolerance": 2.0,
"unit": "cm",
"measured": 50.9,
"passed": true,
"note": "5/5 echoes, spread 0.7 cm"
}
]
}
]
Read that as a document and not as program output. It states which part was tested, what the tester believed the part should do, what it did, and on what rig, in a format that a colleague, a future you, or a five-line script can all parse. Commit it. The day a servo is replaced, the previous entry is what makes the new one's numbers mean something, and if you print a copy and tape it to the bench, you have the hardware specification for her body on one page.
Why this works: a threshold set before the reading
Every claim about hardware needs three numbers to be checkable, and a test record that
holds all three is answering questions no boolean can. expected says what
the part was supposed to do, which is the piece that evaporates first from memory.
tolerance says how much deviation is acceptable, which converts an opinion
into arithmetic. measured says what happened. Give a colleague
{"passed": false} and they have to rebuild the whole rig to learn anything;
give them expected 50.0, tolerance 2.0, measured 62.0 and they can tell you from across
the room that a sensor 12 cm out is a wiring or mounting fault and not noise.
The ordering matters as much as the fields. Deciding the tolerance before taking the reading is what stops the reading from setting the standard, and that failure mode is not hypothetical: measure 168 degrees first and a tolerance of 15 feels like engineering judgement, when it is really the number that makes today's part pass. Writing 8 first and then widening it deliberately, in a commit, is a different act with a different record, even though the final tolerance is identical.
One caution about what any of these numbers prove. They describe one component, alone, on a bench, with the rest of her switched off. A servo that sweeps beautifully by itself may brown out the board when it moves during a Whisper transcription and the supply sags. Component acceptance is the floor, not the ceiling, and the next chapter goes after the failures that only appear once the parts are talking to each other.
The protractor and the input() prompts feel primitive, so the obvious
improvement is to let the board report the angle it reached. It already returns one:
def measure_servo_travel(link) -> HardwareTest:
test = HardwareTest("arm_servo", "travel_0_to_180",
expected=180.0, tolerance=8.0, unit="deg")
command(link, {"command": "servo.set", "params": {"angle": 0}})
reply = command(link, {"command": "servo.set", "params": {"angle": 180}})
test.measured = float(reply["angle"]) # BUG: an echo, not a measurement
test.note = "board reported the angle"
return test
$ uv run python -m labs.hardware_tests # signal wire pulled off the servo
[1/3] arm_servo · travel_0_to_180
[2/3] distance_sensor · accuracy_at_50cm
[3/3] status_led · round_trip_latency
Results: 3 of 3 within tolerance
[PASS] arm_servo travel_0_to_180 180.0 deg expected 180.0 +/- 8.0 board reported the angle
[PASS] distance_sensor accuracy_at_50cm 50.9 cm expected 50.0 +/- 2.0 5/5 echoes, spread 0.7 cm
[PASS] status_led round_trip_latency 13.6 ms expected 15.0 +/- 10.0 4 toggles, worst 15.8 ms
The servo is lying on the bench with its signal wire in your hand and the test says
PASS, at exactly 180.0 degrees, which is the tell. Real mechanisms do not land on the
round number. Trace where that value came from: the board's reply is the output of
servo_write, which returns the clamped angle it was asked for, so the
number travelled from your own dictionary to the board and back. It proves the frame
arrived and the clamp ran, and it proves nothing about the horn, because a hobby servo
is open loop and has no wire to tell anyone where it stopped. Any test whose measured
value can be computed from its expected value has stopped being a test. Trust an
independent instrument: your eye and a protractor here, and in a later revision a
potentiometer on the joint, which is a real feedback path rather than an echo.
Checkpoint, and the parts that must now work together
- I can say why
passedis a property overexpected,toleranceandmeasured, and what a stored boolean would eventually get wrong. - I know why an unmeasured check reports False, and where the
Nonethat guarantees it lives. - Handed a run where the servo passes at exactly 180.0 with the wire unplugged, I can trace the reported number back to the command that produced it.
- I can explain why the tolerance has to be written before the measurement, and what changes when it is widened afterwards on purpose.
- I can convert an HC-SR04 echo time into centimetres and say where the 58 comes from.
- I can name what a single-component acceptance run does not cover, starting with what happens to the supply when the servo moves.
Exercise 1 — find where the tolerance stops being flat.
Run measure_sensor_accuracy at 20 cm, 50 cm and 150 cm against a flat
target, keeping the 2 cm tolerance. Print all three records and decide whether one
fixed tolerance is the right specification for this sensor.
Ultrasonic error grows with distance, and the far reading usually drifts wider
while the near one sits comfortably inside the band. Two fixes are defensible:
make the tolerance proportional (max(2.0, 0.03 * target_cm), three
percent of the distance with a floor) or declare the sensor's usable range as
20 cm to 100 cm and test only inside it. Either way, the run at 150 cm is what
told you, and it is now in the log with a date on it.
Exercise 2 — catch the average that hides a shudder. Give
HardwareTest a spread field and fail a check whose readings
disagree with each other by more than the tolerance, even when the mean is dead on.
Prove it by waving your hand near the sensor during the run.
Store max(readings) - min(readings) and extend the property to
require both abs(measured - expected) <= tolerance and
spread <= tolerance. A hand in the beam gives you readings like 51,
14, 49, 52, 13, averaging near 36 with a spread of 39, and the mean-only version
calls that a mild miss while the spread version calls it what it is. Accuracy and
precision are separate properties, and a check that only averages measures one of
them.
Exercise 3 — make the log answer a question. Write
compare_runs(path) that loads the log, pairs each component's first and
most recent measurement, and prints the drift. Run the acceptance tests twice with
the servo horn loosened by one spline in between.
Walk the runs, key each result by (component, check), keep the first
and last measured value for each key, and print a line per component reading
something like arm_servo travel_0_to_180: 168.0 -> 156.0 deg (drift
-12.0). Flag any drift larger than the tolerance. This function is the
reason the file appends rather than overwrites, and it is the beginning of the
telemetry view that closes this volume: one number, repeated over time, is the
only way a slow mechanical failure ever announces itself.
Her first body is now measured rather than assumed: an arm with a known travel, a sensor with a known error, a light with a known latency, and a file that will remember all three when you do not. Each of those parts passed on its own, which is the weakest kind of good news. The failures that cost whole evenings live in the handoffs, where transcribed text reaches the model, the model's reply reaches the voice, and a command reaches this hardware. Chapter 43 goes looking for them on purpose, starting with the test suite that reports success because it contains no tests at all.