Body simulator
Control code for a body that is still on the drawing board
Three volumes in, she hears, speaks, remembers, and holds a grudge for a configurable number of turns. All of it is software. The body comes later: servos to turn her head, an LED to show her state, a distance sensor to notice you walking up. None of that hardware is on your bench yet. So this chapter answers a planning question: what can you build today so that the day her first servo arrives is about wiring, not about debugging logic?
The obvious answer, wait for the hardware, has two costs. The first is the development loop: code that imports a GPIO library at the top of the file runs only on a machine that has one, so every change means copy to the board, run, read the error, copy again. The second cost is physical. A hobby servo has a limited travel; an out-of-range pulse can drive it against a stop until something gives. The allowed travel depends on the servo and its mount, not a universal 180-degree limit. On real hardware, a logic bug is a repair bill. On your desktop, the same bug is a wrong number in a dict.
Control logic reads and writes a device configuration, while a backend handles the device operation. The simulator below checks numerical guards behind that function signature; it does not establish a real driver’s behavior.
A hobby servo does not receive an angle. It receives a pulse repeated fifty times a second, and the width of that pulse encodes the target: roughly 1 millisecond means 0 degrees, roughly 2 milliseconds means 180. That scheme is called PWM, pulse-width modulation. The simulator deals in degrees on purpose: degrees are what the rest of her code means, and converting them to pulse widths is the future driver's job, at the hardware boundary where it belongs.
Devices as data, moves as functions
# labs/body_sim.py
DEFAULT_DEVICES: dict = {
"arm_servo": {"pin": 18, "type": "pwm", "enabled": False, "angle": 0},
"status_led": {"pin": 17, "type": "gpio", "enabled": False, "state": "off"},
}
def simulate_move_servo(config: dict, device_name: str, angle: int) -> None:
config[device_name]["angle"] = angle
print(f"[SIM] {device_name} moved to {config[device_name]['angle']} degrees")
simulate_move_servo(DEFAULT_DEVICES, "arm_servo", 90)
$ uv run python labs/body_sim.py
[SIM] arm_servo moved to 90 degrees
Two decisions to notice. Each device is an entry in a dict, with its pin, its type, and its current state as plain fields: her body, as far as the logic is concerned, is data you can print, save, and diff. And the function takes the config as its first argument instead of reaching for a hardware library, so "moving" a servo means mutating a dict and announcing it. That print line is the entire difference between this backend and the real one.
def simulate_move_servo(config: dict, device_name: str, angle: int) -> None:
if device_name not in config:
print(f"Device not found: {device_name}")
return
config[device_name]["angle"] = max(0, min(180, angle))
print(f"[SIM] {device_name} moved to {config[device_name]['angle']} degrees")
simulate_move_servo(DEFAULT_DEVICES, "arm_servo", 250)
simulate_move_servo(DEFAULT_DEVICES, "arm_servo", -30)
simulate_move_servo(DEFAULT_DEVICES, "gripper", 45)
$ uv run python labs/body_sim.py
[SIM] arm_servo moved to 180 degrees
[SIM] arm_servo moved to 0 degrees
Device not found: gripper
max(0, min(180, angle)) pins any number into the range 0 to 180:
for 250, the inner min yields 180 and the outer max
keeps it; for -30, the min passes -30 through and the
max raises it to 0. The clamp lives inside the function, not at
the call sites, because call sites multiply: a typo, a bad sensor reading, or
a confused language model can all produce an unsafe angle, and none of them
can get past a limit enforced where the value is applied. The membership guard
above it turns a would-be KeyError into a readable message, since
asking for a device she does not have should be a fact, not a crash.
import copy
import json
from pathlib import Path
DEVICE_CONFIG = Path("configs/devices.json")
def load_device_config(path: Path) -> dict:
if path.exists():
with open(path) as f:
return json.load(f)
return copy.deepcopy(DEFAULT_DEVICES)
def save_device_config(config: dict, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(config, f, indent=2)
config = load_device_config(DEVICE_CONFIG)
simulate_move_servo(config, "arm_servo", 135)
save_device_config(config, DEVICE_CONFIG)
print(f"angle on disk: {json.loads(DEVICE_CONFIG.read_text())['arm_servo']['angle']}")
$ uv run python labs/body_sim.py
[SIM] arm_servo moved to 135 degrees
angle on disk: 135
Chapter 22 taught the load-or-defaults pattern for her preferences; here it
keeps the simulator's last commanded angle. The input is the requested angle; the
clamp turns it into the command stored in angle. In this simulator,
assigning that number is the whole move. On hardware, a saved command says nothing
about a stalled joint or an arm somebody moved while power was off. Physical position
remains unknown until a suitable measurement or supervised startup establishes it.
No position feedback reaches this program.
Neither zero nor the saved command is a safe automatic restart target. Keep motion
disabled while an operator checks clearance and supports the mechanism as needed;
use the selected hardware's reviewed startup or homing procedure before enabling
ordinary moves. An open-loop hobby servo needs supervision, and even its first pulse
can cause movement. Persisted state helps diagnostics; it does not replace that
procedure or an independent stop. One software detail:
the fallback is copy.deepcopy, not .copy(), because
a shallow copy shares the nested per-device dicts, and mutating the "copy"
would silently rewrite your defaults. Exercise 3 makes that bug visible.
# labs/body_sim.py — full file
import copy
import json
import random
from pathlib import Path
DEVICE_CONFIG = Path("configs/devices.json")
DEFAULT_DEVICES: dict = {
"arm_servo": {"pin": 18, "type": "pwm", "enabled": False, "angle": 0},
"status_led": {"pin": 17, "type": "gpio", "enabled": False, "state": "off"},
}
def load_device_config(path: Path) -> dict:
if path.exists():
with open(path) as f:
return json.load(f)
return copy.deepcopy(DEFAULT_DEVICES)
def save_device_config(config: dict, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(config, f, indent=2)
def simulate_move_servo(config: dict, device_name: str, angle: int) -> None:
if device_name not in config:
print(f"Device not found: {device_name}")
return
config[device_name]["angle"] = max(0, min(180, angle))
print(f"[SIM] {device_name} moved to {config[device_name]['angle']} degrees")
def simulate_read_distance() -> float:
return round(random.uniform(5.0, 50.0), 1)
def main() -> None:
config = load_device_config(DEVICE_CONFIG)
simulate_move_servo(config, "arm_servo", 90)
print(f"[SIM] distance sensor: {simulate_read_distance()} cm")
save_device_config(config, DEVICE_CONFIG)
print(f"State saved to {DEVICE_CONFIG}")
if __name__ == "__main__":
main()
$ uv run python labs/body_sim.py
[SIM] arm_servo moved to 90 degrees
[SIM] distance sensor: 23.7 cm
State saved to configs/devices.json
The distance figure is random by design, so yours will differ on every run.
simulate_read_distance is the sensor side of the same idea:
code that reacts to "someone is closer than 30 cm" can be written and tested
against plausible fake readings long before an ultrasonic module exists to
produce real ones.
Why this works: intent above, mechanism below
simulate_move_servo(config, "arm_servo", 90) states an intent: set
this named device to this angle. It says nothing about how the bits reach a
motor, and that silence is the entire design. The simulator's mechanism is a
print statement; the future driver's mechanism is a PWM duty cycle on pin 18.
Both accept the same arguments, mutate the same config dict, and honor the same
clamp. Everything above the call (the mood system deciding she should turn away
from you, the event bus carrying the command) stays identical whichever backend
is underneath, so choosing between them collapses to a single import at startup.
This is a hardware abstraction layer, and you have been living on top of them
all along: your operating system does exactly this so that the same
open() call works on any brand of disk. Two properties do the work
here. The config dict is the shared contract, one source of truth about pins and
state that both backends read and write. And the safety rule travels with the
action: the clamp sits inside the move function, so swapping backends cannot
bypass it and no caller has to remember it. Rules enforced by position beat
rules enforced by discipline.
The simulator checks ranges, state changes and persistence. It cannot test torque, wiring or a supply under load; the assembled body needs supervised tests and its own startup procedure.
The tempting shortcut is to put the real driver's import at the top of the file now, so everything is ready for the board later:
import RPi.GPIO as GPIO # BUG: top-level import of a board-only library
GPIO.setmode(GPIO.BCM)
GPIO.setup(18, GPIO.OUT)
$ uv run python labs/body_sim.py
Traceback (most recent call last):
File "/home/you/GladOS/labs/body_sim.py", line 2, in <module>
import RPi.GPIO as GPIO
ModuleNotFoundError: No module named 'RPi'
Read the traceback's location line: the failure is in <module>,
at import time, before any function has run. That placement is the real
damage. It does not matter that simulate_move_servo never touches
GPIO; with the import at module scope, you cannot even
import body_sim on the desktop to test the clamp, so one line
written "for later" disables every test you can run today. The fix is
positional: the simulator imports no hardware library at all, and the future
driver will import its own lazily, inside the function, behind a
try/except that falls back to simulation. The module then loads
anywhere, and where it runs decides what it drives.
Checkpoint, before the metal
- I can trace
max(0, min(180, angle))by hand for 250, -30, and 90, and say why the clamp lives inside the move function instead of at any call site. - I can explain what the device config dict contributes: one source of truth about pins and state that both backends read and write.
- I know a top-level board-only import fails at import time, not call time, and exactly what that blocks on a desktop.
- I can distinguish the requested angle, the saved command and an unknown physical position, and explain why restart needs supervised preparation.
- I can list what the simulator proves (ranges, guards, state) and what only the physical build can (torque, noise, power).
Exercise 1 — give the LED a verb. Write
simulate_toggle_led(config, device_name): flip the LED's
state between "on" and "off", with the
same missing-device guard, and print a [SIM] line. Call it twice
and predict both outputs first.
Read the current state, compute the opposite
("on" if current == "off" else "off"), write it back, print
it. Two calls on status_led print toggled to on
then toggled to off: a toggle's output depends on state, your
first control function where calling order matters. Save the config and
check the file; the LED's state should round-trip exactly like the angle
did.
Exercise 2 — sweep, do not snap. Write
simulate_sweep(config, device_name, start, end, steps) that
moves the servo in increments, printing each intermediate angle with a short
time.sleep between steps. Route every step through
simulate_move_servo. Why that routing rule?
Each step's angle is start + round((end - start) * i / steps)
for i from 0 through steps. Routing through the
move function means every intermediate value hits the clamp and the guard,
so a sweep to 400 flattens harmlessly at 180. On the future hardware this
function becomes the difference between a head that turns and a head that
snaps: small steps with pauses are how a position-commanded servo moves
smoothly.
Exercise 3 — corrupt your defaults on purpose. Change
load_device_config to return DEFAULT_DEVICES.copy()
instead of the deepcopy, delete configs/devices.json, then move
the servo and print DEFAULT_DEVICES. Explain what you see before
you fix it.
DEFAULT_DEVICES["arm_servo"]["angle"] now reads 135, or
wherever you moved it: the shallow copy duplicated the outer dict but
shares the nested per-device dicts, so the "copy" and the defaults are the
same objects one level down. Your fallback drifts with use, and every
future fresh start inherits a stale pose. copy.deepcopy
duplicates the whole tree. The general rule: a shallow copy protects one
level, and nested state needs a deep one.
Her body now exists as a contract: devices as data, moves as clamped functions, state that survives a restart. But the system around that contract keeps growing, and when a pipeline of ears, mind, voice, and now simulated motion misbehaves, "something is slow" stops being useful information. Next chapter replaces guessing with measurement: a structured logger and a decorator that stamps every stage with its duration, and never swallows an error on the way through.