GLaDOS Vol 6 · Ready for the House
ch 53 / 99
Chapter 53

The Device Contract

Three devices, three ways to fail

Her body is going to arrive one part at a time. A servo that holds an angle, an ultrasonic range finder that measures distance by timing an echo, later a temperature module that answers over two wires. Underneath, they have nothing in common. The servo wants a pulse repeated fifty times a second. The range finder wants a ten-microsecond trigger and a stopwatch on the pin that answers. The temperature module wants a register number and a read. Three protocols, and every one of them can fail in a way the others cannot.

The provider contracts from chapter 44 already solved half of this. They fixed the vocabulary: any speech engine you hand her answers to synthesize, so the caller stops caring which engine it holds. Devices need that too, and they need something the vocabulary cannot give them, because what actually repeats at a device call site is not the name of the method. It is the four lines around it. Did this device come up? Attempt the read. What do we do when the wire is loose? Say which device we were talking to.

Written once, those four lines are fine. The trouble is that they get written everywhere: in the automation engine that reacts to someone walking up, in the vitals window, in a diagnostic print you added at midnight and never removed. Each copy is slightly different, because you were in a hurry in three of those places, and the copy that forgot its try is the one that ends a conversation the first time a jumper wire works itself loose.

So the rule for every driver from here on: the base class owns the sequence a read goes through, and a subclass supplies only the step that differs. Guard, attempt, label. The device-specific part is a hole in the middle of a procedure that is written down exactly once.

The sequence on the base, the step in the subclass

▣ Build · stage 1 — the obligations, and one device that meets them
# glados/devices.py
from abc import ABC, abstractmethod


class DeviceDriver(ABC):
    def __init__(self, name: str, pin: int) -> None:
        self.name = name
        self.pin = pin
        self._initialized = False

    @abstractmethod
    def initialize(self) -> bool:
        """Claim the hardware. Set self._initialized, return whether it worked."""

    @abstractmethod
    def read(self) -> dict:
        """Return this device's current state. May raise."""

    @abstractmethod
    def write(self, value) -> bool:
        """Apply value to the device. A read-only device returns False."""


class SimulatedServo(DeviceDriver):
    def __init__(self, pin: int) -> None:
        super().__init__("arm_servo", pin)
        self._angle = 0

    def initialize(self) -> bool:
        self._initialized = True
        print(f"[sim] arm_servo ready on pin {self.pin}")
        return True

    def read(self) -> dict:
        return {"angle": self._angle}

    def write(self, angle: int) -> bool:
        self._angle = max(0, min(180, int(angle)))
        print(f"[sim] arm_servo -> {self._angle} deg")
        return True


if __name__ == "__main__":
    servo = SimulatedServo(pin=18)
    servo.initialize()
    servo.write(90)
    servo.write(250)
    print(servo.read())
$ uv run python -m glados.devices
[sim] arm_servo ready on pin 18
[sim] arm_servo -> 90 deg
[sim] arm_servo -> 180 deg
{'angle': 180}

Three obligations, because there are three things you do to a device: bring it up, ask it something, tell it something. Two details carry forward from the simulated body of chapter 27. The clamp max(0, min(180, int(angle))) sits inside write, where no caller can go around it, so the 250 lands at 180 instead of grinding a real servo against its mechanical stop. And _initialized starts False in the base constructor, which is the one piece of state the base needs to own if it is going to make any promise at all about reads. Right now it owns it and nothing consults it.

▣ Build · stage 2 — one concrete method that everything calls
class DeviceDriver(ABC):
    # ... __init__ and the three abstract methods as above ...

    def safe_read(self) -> dict:
        if not self._initialized:
            return {"device": self.name, "error": "not initialized"}
        try:
            reading = self.read()
        except Exception as exc:
            return {"device": self.name, "error": f"{type(exc).__name__}: {exc}"}
        return {"device": self.name, **reading}


if __name__ == "__main__":
    servo = SimulatedServo(pin=18)
    print("before:", servo.safe_read())
    servo.initialize()
    servo.write(90)
    print("after: ", servo.safe_read())
$ uv run python -m glados.devices
before: {'device': 'arm_servo', 'error': 'not initialized'}
[sim] arm_servo ready on pin 18
[sim] arm_servo -> 90 deg
after:  {'device': 'arm_servo', 'angle': 90}

safe_read is the first method in this class with a body, and it is the whole point of the class. Read it as three steps in a fixed order: refuse if the device never came up, attempt the device-specific read, stamp the answer with the name of the device that produced it. Only the middle step varies between devices, and the middle step is precisely the one written as self.read(). The stamp matters more than it looks. A caller polling four devices gets four dicts back, and without "device" in each one it has to remember which answer came from where.

A device that fails, and one that never arrives

▣ Build · stage 3 — an exception where the caller expects a number
import random


class SimulatedDistance(DeviceDriver):
    def __init__(self, trig_pin: int, echo_pin: int) -> None:
        super().__init__("range_finder", trig_pin)
        self.echo_pin = echo_pin

    def initialize(self) -> bool:
        self._initialized = True
        print(f"[sim] range_finder ready on pins {self.pin}/{self.echo_pin}")
        return True

    def read(self) -> dict:
        if random.random() < 0.2:
            raise RuntimeError("echo timeout after 38 ms")
        return {"distance_cm": round(random.uniform(5.0, 100.0), 1)}

    def write(self, value) -> bool:
        return False


if __name__ == "__main__":
    sensor = SimulatedDistance(trig_pin=23, echo_pin=24)
    sensor.initialize()
    for _ in range(5):
        print(sensor.safe_read())
$ uv run python -m glados.devices   # simulated jitter: your five lines will differ
[sim] range_finder ready on pins 23/24
{'device': 'range_finder', 'distance_cm': 41.7}
{'device': 'range_finder', 'distance_cm': 62.3}
{'device': 'range_finder', 'error': 'RuntimeError: echo timeout after 38 ms'}
{'device': 'range_finder', 'distance_cm': 18.9}
{'device': 'range_finder', 'error': 'RuntimeError: echo timeout after 38 ms'}

An ultrasonic module really does fail this way: it fires a pulse and waits for the reflection, and a soft surface, a steep angle or an empty room means the reflection never comes back, so the driver gives up after a timeout. The one-in-five failure rate here is a rehearsal dial, higher than any sane module, set so a five-line run shows you both outcomes. Notice what the loop did with the bad reads: it kept going. Two timeouts became two dictionaries, the other three lines still carry distances, and no caller anywhere wrote a try. The error string starts with RuntimeError because str(exc) alone is sometimes the empty string, and {'error': ''} in a log at two in the morning tells you nothing at all.

▣ Build · stage 4 — the real driver, and a device that is not here yet
# glados/devices.py — the driver for the metal, plus the runner
class PiServo(DeviceDriver):
    def __init__(self, pin: int, name: str = "head_servo") -> None:
        super().__init__(name, pin)
        self._pwm = None
        self._angle = 0

    def initialize(self) -> bool:
        try:
            import RPi.GPIO as GPIO
        except ImportError as exc:
            print(f"[pi ] {self.name} unavailable: {exc}")
            return False
        GPIO.setmode(GPIO.BCM)
        GPIO.setup(self.pin, GPIO.OUT)
        self._pwm = GPIO.PWM(self.pin, 50)
        self._pwm.start(0)
        self._initialized = True
        print(f"[pi ] {self.name} ready on pin {self.pin}")
        return True

    def read(self) -> dict:
        return {"angle": self._angle}

    def write(self, angle: int) -> bool:
        self._angle = max(0, min(180, int(angle)))
        self._pwm.ChangeDutyCycle(5.0 + (self._angle / 180.0) * 5.0)
        return True


def main() -> None:
    devices: list[DeviceDriver] = [
        SimulatedServo(pin=18),
        SimulatedDistance(trig_pin=23, echo_pin=24),
        PiServo(pin=12),
    ]
    for device in devices:
        device.initialize()
    for device in devices:
        print(device.safe_read())


if __name__ == "__main__":
    main()
$ uv run python -m glados.devices   # on the desktop, no GPIO anywhere
[sim] arm_servo ready on pin 18
[sim] range_finder ready on pins 23/24
[pi ] head_servo unavailable: No module named 'RPi'
{'device': 'arm_servo', 'angle': 0}
{'device': 'range_finder', 'distance_cm': 33.2}
{'device': 'head_servo', 'error': 'not initialized'}

The pulse widths from chapter 27 turn into duty cycle here, at the only place in the project that knows what a duty cycle is. At 50 Hz each cycle is 20 milliseconds, so a 1 ms pulse is 5 percent and a 2 ms pulse is 10 percent, and the arithmetic in write spreads 0 to 180 degrees across that band. The import lives inside initialize, so this module still loads on a laptop, and the third line of output is what that buys: the Pi driver announced honestly that it could not come up, returned False, left _initialized alone, and the run continued. When you do run this on the board, the last line reads {'device': 'head_servo', 'angle': 0} instead, and nothing else about the program changes.

Why this works: a method with a hole in it

The pattern has a name. A template method is a concrete method on a base class that lays out the steps of an operation and delegates one or more of them to methods the subclass must supply. Chapter 44 used abstract base classes for a different job: every method there was abstract, so the base held a vocabulary and no behavior whatsoever. Here the proportions invert. safe_read is behavior, fully written, and the abstract methods are the gaps it reaches into.

The mechanism that makes the gap work is ordinary attribute lookup. When safe_read executes self.read(), Python resolves read on the actual object at that instant, finds SimulatedDistance.read or PiServo.read, and calls it. The base class never learns which. That is why it can promise something none of its subclasses could promise individually: a call to safe_read returns a dict. The promise holds because the variable part was pushed downward and the invariant part was kept up top, where one edit changes the behavior of every driver you will ever write.

Be honest about the limit. Nothing stops a caller from writing sensor.read() and getting the exception in the face, because Python has no private methods, only a naming convention and a habit. The guarantee is a door, not a wall. What the design buys is that the safe door is the obvious one, it is the one every example in this book uses, and a stray raw read shows up in review as a call that skipped the label.

◆ Note — what except Exception deliberately misses

A bare except: catches everything, including Ctrl-C, which turns a polling loop into something you have to kill from another terminal. except Exception is narrower on purpose: KeyboardInterrupt and SystemExit descend from BaseException and not from Exception, so they sail straight through the handler and stop the program the way you asked. That inheritance split exists for exactly this pattern. Catch broadly when you mean hardware trouble; never catch the two signals that mean a human wants out.

⚠ Worked failure — the driver that crashed the crash handler

You add a fourth device late in the evening, a thermometer, and give it a constructor that stores its own fields:

class SimulatedThermometer(DeviceDriver):
    def __init__(self, address: int) -> None:
        self.name = "thermometer"          # BUG: no super().__init__(...)
        self.address = address

    def initialize(self) -> bool:
        self._initialized = True
        return True

    def read(self) -> dict:
        return {"celsius": 21.4}

    def write(self, value) -> bool:
        return False
$ uv run python -m glados.devices
Traceback (most recent call last):
  File "/home/you/GladOS/glados/devices.py", line 164, in <module>
    main()
  File "/home/you/GladOS/glados/devices.py", line 159, in main
    print(device.safe_read())
          ^^^^^^^^^^^^^^^^^^
  File "/home/you/GladOS/glados/devices.py", line 21, in safe_read
    if not self._initialized:
           ^^^^^^^^^^^^^^^^^
AttributeError: 'SimulatedThermometer' object has no attribute '_initialized'

The method whose entire job is to stop exceptions raised one. Read the last frame and the reason is plain: the failure is on the guard line, and the guard runs before the try, so the handler was never armed when the attribute lookup went looking for state the base constructor was never given a chance to set. Skipping super().__init__("thermometer", address) skipped _initialized = False with it. The fix is that one call. There is a second fix worth knowing, since this species of bug will recur in any base class holding state: declare _initialized: bool = False in the class body as well, and every instance inherits the default even when a subclass constructor goes its own way. Belt and braces, and the braces cost one line.

Checkpoint, and a system nothing starts yet

✓ Checkpoint — what you can now do
  • I can name the three steps safe_read performs in order, and say which one runs outside the try block.
  • I can explain why read is abstract while safe_read is concrete, and what a subclass gives up by overriding the concrete one.
  • I can predict what safe_read returns for a device whose initialize returned False, without running it.
  • I know why the error string carries the exception's class name and not only its message.
  • I can say which two exceptions except Exception lets through, and why a polling loop wants it that way.
  • Shown an AttributeError on self._initialized, I can name the missing constructor call from the traceback alone.
⚡ Exercises — try first, then reveal
Exercise 1 — the other half of the template. Add safe_write(self, value) to DeviceDriver, following the same three steps, and call it on the servo before and after initialize().

Same skeleton, one different middle: guard on _initialized, call self.write(value) inside a try, return {"device": self.name, "ok": result} when it comes back. The two runs print {'device': 'arm_servo', 'error': 'not initialized'} and then {'device': 'arm_servo', 'ok': True}. Point the same call at the range finder and you get {'ok': False}, no error, because a read-only device refusing a write is an answer and not a fault. Two template methods now share one guard, which is the moment to pull that guard into a small helper if you like the symmetry.

Exercise 2 — remember the last good reading. Cache each successful reading on the driver, and have the error branch return that cached value with "stale": True and an age in seconds. Run the range finder loop until a timeout hits.

Store self._last and self._last_at in the base constructor, set both on the success path, and in the except branch return the cached dict with "stale": True and "age_s": round(time.monotonic() - self._last_at, 1) merged in. A timeout now prints something like {'device': 'range_finder', 'distance_cm': 62.3, 'stale': True, 'age_s': 0.4}. This is the right answer for a control loop that would rather steer on a half-second-old distance than on nothing, and the wrong answer for a safety cutoff, where old data is worse than a known gap. The age field is what lets the caller decide.

Exercise 3 — a driver that lies about its return type. Write a read that returns a bare 21.4 instead of a dict, call safe_read on it, and explain the traceback before you fix it.

You get TypeError: 'float' object is not a mapping, raised on the return {"device": self.name, **reading} line, which sits after the try and is therefore unprotected. Two fixes, and they are not equal. Moving the merge inside the try turns the mistake into {'error': 'TypeError: ...'}, so a bad driver looks exactly like a loose wire. Checking isinstance(reading, dict) and returning {"error": "read() returned float, expected dict"} keeps the two apart. Programmer errors and hardware errors deserve different words, because you fix them in different places.

Every device she will ever have now comes up, refuses, or fails in one documented way, and the code that polls them is a loop with no error handling in it. What is still missing is anything that starts the program in the first place. She runs when you type a command in a terminal you have to leave open. Next chapter fixes that with a systemd unit generated from the environment it finds, so the user, the paths and the Python interpreter written into that file are read off the machine instead of typed from memory and quietly wrong.