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
# 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.
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
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.
# 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.
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.
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
- I can name the three steps
safe_readperforms in order, and say which one runs outside thetryblock. - I can explain why
readis abstract whilesafe_readis concrete, and what a subclass gives up by overriding the concrete one. - I can predict what
safe_readreturns for a device whoseinitializereturnedFalse, 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 Exceptionlets through, and why a polling loop wants it that way. - Shown an
AttributeErroronself._initialized, I can name the missing constructor call from the traceback alone.
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.