GLaDOS Vol 5 · Alive on the Bench
ch 42 / 99
Chapter 42

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.

◆ Note — what is on the bench, and how to read the numbers below

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

▣ Build · stage 1 — the record, with pass derived rather than stored
# 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.

▣ Build · stage 2 — two new verbs on the board
# 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

▣ Build · stage 3 — measurements that come from outside the command
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.

▣ Build · stage 4 — a log you can diff against next month
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.

▣ Build · stage 5 — the acceptance run
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.

▣ Build · stage 6 — the file that outlives the session
[
  {
    "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.

⚠ Worked failure — the test that passes with the servo unplugged

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

✓ Checkpoint — what you can now do
  • I can say why passed is a property over expected, tolerance and measured, and what a stored boolean would eventually get wrong.
  • I know why an unmeasured check reports False, and where the None that 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.
⚡ Exercises — try first, then reveal
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.