Migrating the GLaDOS Stack
A tree that already runs, on a board it has never met
The Jetson boots without a monitor, CUDA answers when asked, Ollama has been measured generating on the GPU instead of quietly falling back, and the 40-pin header has been rechecked against a chip that is not the Pi's. What the board still does not have is her. Seven volumes of code are sitting on a Raspberry Pi on the other side of the desk, and the remaining job is to put that tree on the Jetson and have it come up working.
People call that job a port, and the word does damage. A port sounds like reopening
every file and adjusting it for the new machine. Open them and there is almost nothing
to adjust. Ollama is reached over HTTP on localhost, and localhost is
localhost on any board. configs/personality.json is text. The memory
database is a SQLite file. The capture loop asks the operating system for 16 kHz mono
frames, the event bus is pure Python, and every prompt she has ever been given is a
string. None of that knows what silicon it is on, and none of it needs to.
Go through the tree looking for code that does know, and the list is short enough to
count on one hand. faster-whisper is constructed with a device and a numeric format,
and the Pi's answer to both was cpu and int8. The speech
synthesizer takes the same pair. The GPIO module is named RPi.GPIO on one
board and Jetson.GPIO on the other, which is the swap chapter 81 put
behind a guarded import. And the model weights live on an SD card over there and belong
on the NVMe drive over here. Four differences. The compute type rides along with the
Whisper device because a device and its numeric format are one decision: float16 on a
CPU is not a faster CPU, it is a slower one.
The obvious way to write four differences down is four if statements, one
in stt.py, one in tts.py, one beside the GPIO import, one
wherever a model path gets built. That works for an evening. The cost arrives later,
when the answer to "what will this board actually use?" can only be assembled by
reading four files, and when you fix the model path in one of them and forget the
other. So the design for this chapter:
resolve board settings in one place, then pass that exact result to the
consumers that need it. A printed configuration proves which values won the
merge. It cannot prove that an engine used those values or that CUDA is working.
A Jetson Orin Nano Super developer kit running JetPack 6.2.2 off a 500 GB NVMe drive, the Raspberry Pi 4B that Volumes 1 through 7 were built on, and an ordinary x86 laptop with neither board's marker files. Every command below runs on all three, which is the point of the chapter, and the transcripts say which machine produced them. Home directories and disk paths differ per machine; where one appears in an output, yours will read differently.
Ask the filesystem which board this is
# labs/platform_config.py
from pathlib import Path
TEGRA_RELEASE = Path("/etc/nv_tegra_release")
DEVICE_TREE_MODEL = Path("/proc/device-tree/model")
def detect_platform() -> str:
"""Name this board from the fingerprints its firmware and installer left."""
if TEGRA_RELEASE.exists():
return "jetson"
try:
model = DEVICE_TREE_MODEL.read_text(errors="replace").lower()
except OSError:
return "unknown"
return "rpi" if "raspberry pi" in model else "unknown"
if __name__ == "__main__":
print(f"platform: {detect_platform()}")
$ uv run python -m labs.platform_config # run on all three machines
platform: jetson
platform: rpi
platform: unknown
There is no portable way to ask Python what board it is running on. The
platform module reports the kernel and the processor architecture, and
both of these boards answer aarch64 Linux to that question, so it cannot tell them
apart. What can tell them apart is the disk. The JetPack installer writes
/etc/nv_tegra_release, the file chapter 76 parsed for a release version;
here its mere existence is the whole answer, and no parsing is needed. Raspberry Pi
firmware writes the board's marketing name into the device tree, where the kernel
exposes it as a file. Neither marker moves between reboots, and neither can be
present on the wrong board.
Two small defensive choices. The device-tree file is read inside a
try instead of behind an exists() test, because a file that
exists and cannot be read leads to the same conclusion as a file that is absent, and
one code path is easier to trust than two. And errors="replace" is there
because device-tree strings are NUL-terminated: the bytes on that file end with a
zero byte that is not valid text in every decoder, and a diagnostic function should
never be the thing that crashes. Anything unrecognised comes back as
"unknown", which is the correct name for a laptop and a key the table in
the next section really declares.
# labs/platform_config.py — continued
import json
HARDWARE_PATH = Path("configs/hardware.json")
def load_json_safe(path: Path) -> dict:
"""The file as a dict, or an empty one, with a line saying which happened."""
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
print(f"[config] ignoring {path}: {type(exc).__name__}")
return {}
if __name__ == "__main__":
print(f"platform: {detect_platform()}")
print(f"on disk: {load_json_safe(HARDWARE_PATH)}")
$ uv run python -m labs.platform_config # on the Jetson, before any config has been copied over
[config] ignoring configs/hardware.json: FileNotFoundError
platform: jetson
on disk: {}
An empty dict is a working answer here, and that is a deliberate design decision
rather than laziness about error handling. A board being set up for the first time
has no hardware.json, and a hand-edited one can carry a trailing comma.
If either case raises, the script dies before it has told you the one thing you most
wanted to know, which is what board it thinks it is on; you then spend ten minutes
debugging a comma while believing you are debugging a migration. Degrading to an
empty overlay costs nothing, because the layer underneath is chapter 31's registry of
defaults and the layer above is the platform table.
The printed line is what keeps degradation honest. Silence would mean a missing file
and a present file produce identical output, and the day someone puts
hardware.json in the wrong directory you would have no way to see it.
One line, on the way past, and the difference is visible.
One table for the whole difference
# labs/platform_config.py — continued
PLATFORM_OVERRIDES: dict[str, dict[str, dict]] = {
"jetson": {
"stt": {"device": "cuda", "compute_type": "float16"},
"tts": {"device": "cuda"},
"llm": {"model_storage": "/mnt/nvme/ollama/models"},
"gpio": {"library": "Jetson.GPIO"},
},
"rpi": {
"stt": {"device": "cpu", "compute_type": "int8"},
"tts": {"device": "cpu"},
"llm": {"model_storage": "/home/pi/.ollama/models"},
"gpio": {"library": "RPi.GPIO"},
},
"unknown": {
"stt": {"device": "cpu", "compute_type": "int8"},
"tts": {"device": "cpu"},
"llm": {"model_storage": str(Path.home() / ".ollama" / "models")},
"gpio": {"library": "none"},
},
}
Read that table as the settings this resolver proposes for each board. It does not install drivers, relocate Ollama's storage or replace every earlier composition root. The keys are the component names chapter 31's registry already uses, so a row here lands in exactly one component's settings and nowhere else.
The "unknown" entry is a real board, not a fallback nobody meant. A
laptop gets CPU inference, a model directory under the home directory, and a GPIO
library named "none". That GPIO row records intended configuration;
chapter 81's guarded import does its own detection and does not consume this row.
The entry lets the resolver be written, run and argued with
on the machine you are sitting at, days before you ever SSH into the Jetson.
# labs/platform_config.py — continued
from labs.system_config import ComponentConfig, SystemConfig, build_default_config
def build_board_config() -> SystemConfig:
"""Chapter 31's registry, plus the one component the body needs."""
cfg = build_default_config()
cfg.add_component(ComponentConfig("gpio", True, {"library": "none"}))
return cfg
def resolve(platform: str, on_disk: dict) -> SystemConfig:
"""Defaults, then the operator's file, then the board. The board wins."""
overrides = PLATFORM_OVERRIDES[platform]
resolved = SystemConfig()
for comp in build_board_config().components:
settings = {**comp.settings,
**on_disk.get(comp.name, {}),
**overrides.get(comp.name, {})}
resolved.add_component(ComponentConfig(comp.name, comp.enabled, settings))
return resolved
def print_resolved(platform: str, cfg: SystemConfig) -> None:
width = max(len(c.name) for c in cfg.components)
print(f"platform: {platform}")
for comp in cfg.components:
print(f" {comp.name:<{width}} {json.dumps(comp.settings)}")
def main() -> None:
platform = detect_platform()
print_resolved(platform, resolve(platform, load_json_safe(HARDWARE_PATH)))
if __name__ == "__main__":
main()
$ uv run python -m labs.platform_config | grep -E 'platform|stt|tts|memory|llm|gpio' # on the Jetson, still no hardware.json
[config] ignoring configs/hardware.json: FileNotFoundError
platform: jetson
stt {"model": "base", "device": "cuda", "compute_type": "float16", "sample_rate": 16000}
tts {"voice": "en_US-lessac-medium", "sample_rate": 22050, "device": "cuda"}
memory {"db_path": "glados/data/memory.db"}
llm {"model": "llama3.2:3b", "host": "http://localhost:11434", "model_storage": "/mnt/nvme/ollama/models"}
gpio {"library": "Jetson.GPIO"}
$ uv run python -m labs.platform_config | grep -E 'platform|stt|tts|memory|llm|gpio' # the same command, same tree, on the laptop
[config] ignoring configs/hardware.json: FileNotFoundError
platform: unknown
stt {"model": "base", "device": "cpu", "compute_type": "int8", "sample_rate": 16000}
tts {"voice": "en_US-lessac-medium", "sample_rate": 22050, "device": "cpu"}
memory {"db_path": "glados/data/memory.db"}
llm {"model": "llama3.2:3b", "host": "http://localhost:11434", "model_storage": "/home/dev/.ollama/models"}
gpio {"library": "none"}
The registry holds thirteen components and print_resolved prints all
of them, so the pipe through grep is there to put the rows this chapter
is arguing about next to each other; drop it and you get the whole config. Two
boards, one tree, one command, and rows that show the proposed settings.
The changed values are visible and the values that did not change are
visible too: the model name, the Ollama host and the database path are identical in
both transcripts. Neither run constructed a speech engine. Notice the key order in
the tts line, where
device appears at the end. A dict spread keeps the position of a key it
overwrites and appends a key it introduces, so the tail of each line is the part the
platform table added.
resolve builds a fresh SystemConfig and never writes into
the one build_board_config returned. Nothing forces that here, since the
factory hands out new dicts on every call, but the habit is what makes the function
safe to call twice in one process with two different platform names, which is exactly
what one of the exercises does.
# labs/platform_config.py — continued
RESOLVED_PATH = Path("glados/data/resolved_hardware.json")
def save_resolved(cfg: SystemConfig, path: Path) -> Path:
"""Record resolved settings for diagnosis, without claiming a consumer loaded them."""
path.parent.mkdir(parents=True, exist_ok=True)
body = {c.name: c.settings for c in cfg.components if c.enabled}
path.write_text(json.dumps(body, indent=2) + "\n")
return path
def main() -> None:
platform = detect_platform()
cfg = resolve(platform, load_json_safe(HARDWARE_PATH))
print_resolved(platform, cfg)
print(f"recorded at {save_resolved(cfg, RESOLVED_PATH)}")
$ uv run python -m labs.platform_config # on the Jetson, unfiltered this time
platform: jetson
audio_capture {"sample_rate": 16000, "channels": 1, "chunk": 1024}
stt {"model": "base", "device": "cuda", "compute_type": "float16", "sample_rate": 16000}
wake_word {"phrase": "glados", "threshold": 0.6}
tts {"voice": "en_US-lessac-medium", "sample_rate": 22050, "device": "cuda"}
memory {"db_path": "glados/data/memory.db"}
context {"turns": 6, "facts": 4}
llm {"model": "llama3.2:3b", "host": "http://localhost:11434", "model_storage": "/mnt/nvme/ollama/models"}
safety {"policy": "configs/safety.json"}
drivers {"port": "/dev/ttyUSB0", "baud": 115200}
automation {"rules": "configs/automation.json"}
scheduler {"tick_seconds": 30}
voice_loop {"max_turns": 0}
gpio {"library": "Jetson.GPIO"}
recorded at glados/data/resolved_hardware.json
Chapter 34 saved the voice loop's settings for the same reason: three days after a
strange session, memory offers several plausible accounts of what was configured and
a file offers one. The distinction that matters is which file. Nothing loads
resolved_hardware.json at startup, because loading it would put a stale
copy of the answer ahead of the live detection and reintroduce the bug the next
section is about. It is a record, written for you, and it is the right thing to paste
into a bug report. Record the actual engine/provider alongside it; a desired device
and an observed device answer different questions.
Now connect the resolver to the voice composition root. Add this function to
labs/platform_config.py; call it instead of
build_core(build_default_config()) when assembling the board's voice
loop. The plain python -m labs.platform_config command above still only
prints and saves settings, so it remains usable without model downloads.
from glados.core import GladOSCore
def build_board_core() -> GladOSCore:
"""Resolve once at this composition root, validate, then construct the voice providers."""
platform = detect_platform()
cfg = resolve(platform, load_json_safe(HARDWARE_PATH))
errors = validate_platform(platform, cfg)
if errors:
raise ValueError("; ".join(errors))
save_resolved(cfg, RESOLVED_PATH)
from labs.wire_providers import build_core
return build_core(cfg)
validate_platform is defined in the next section; finish the file before
calling this function. The resulting core accepts the same run_turn
call as chapter 32. Chapter 44's WhisperSTT passes both
device and compute_type to WhisperModel.
Its F5 provider passes tts.device to F5TTS when
tts.engine is f5. The default Piper provider does not use
that device row and remains a separate CPU voice path. The older chapter 32
composition root also reads the Whisper compute type and F5 device from settings.
This wiring covers those speech providers only. Ollama runs in its own service;
changing llm.model_storage here does not set that service's
OLLAMA_MODELS environment. GPIO selection and the older standalone
wake-word/transcription scripts keep their own startup paths. Inspect the entry
point your service actually runs before calling the migration complete, then check
the installed backend and measure a real turn. A merge validator cannot do that
measurement for you.
Why this works: identity from disk, precedence from the merge
Two independent mechanisms carry the whole design, and they are useful far away from
this board. The first is that a machine's identity is discoverable even when it is not
askable. Firmware and installers leave artifacts behind at fixed paths, and the
presence of one file can be a stronger signal than any string a program will hand you,
because nothing puts /etc/nv_tegra_release on a Raspberry Pi. Detection
built on that evidence is three file checks and a default, cheap enough to run at every
startup and stable enough to trust when it does.
The second is dict-merge precedence, and it is the same rule chapter 22 used to keep an
old preferences file loadable: in {**a, **b} the two dicts are unpacked
left to right and a duplicate key keeps the value seen last. Chapter 22 spread the
saved file over the defaults so a user's choices beat the built-in ones. This chapter
spreads a third layer on top of both, because the ordering encodes authority. The
defaults are the least authoritative, since they were written without knowing anything
about this machine. The operator's file is more so, since a person typed it about this
installation. The platform table is the most authoritative of the three on the four
keys it declares, because those keys are not preferences at all. No amount of wanting
cuda on a Raspberry Pi will produce a CUDA device.
That is the generalization to carry off this page. Layered configuration is not really about convenience; it is a way of writing down who is allowed to be right about what, and the spread order is where you write it. Get the layers in the wrong order and every value still resolves to something, every module still starts, and the system runs on the losing layer's opinion.
You copy the tree across with rsync -av ~/glados/ orin:~/glados/, which
copies configs/hardware.json along with everything else, since it is a
file in the tree like any other. On the Pi that file held one operator edit and one
hardware setting:
{
"llm": {"max_history": 40},
"stt": {"device": "cpu"}
}
You also wrote the merge the other way round, and the reasoning felt solid at the time: a file an operator edited on this specific installation is more specific than a table in the source, so surely the file should win.
# labs/platform_config.py — the version that reads perfectly well
settings = {**comp.settings,
**overrides.get(comp.name, {}),
**on_disk.get(comp.name, {})}
$ uv run python -m labs.platform_config | grep -E 'platform|stt|tts|memory|llm|gpio' # on the Jetson, after the copy
platform: jetson
stt {"model": "base", "device": "cpu", "compute_type": "float16", "sample_rate": 16000}
tts {"voice": "en_US-lessac-medium", "sample_rate": 22050, "device": "cuda"}
memory {"db_path": "glados/data/memory.db"}
llm {"model": "llama3.2:3b", "host": "http://localhost:11434", "model_storage": "/mnt/nvme/ollama/models", "max_history": 40}
gpio {"library": "Jetson.GPIO"}
recorded at glados/data/resolved_hardware.json
The resolver prints no exception and detects the board correctly. That alone cannot
tell you how a turn would run: a consumer that ignores this config may keep using
its old defaults, while a consumer that receives cpu plus
float16 may reject the unsupported combination. Treat this capture as
evidence of a bad merge, not a GPU benchmark or a successful voice startup.
Work down the output one line at a time. Line one says jetson, so
detection is fine. The tts row says cuda and the
llm row carries the NVMe path, so the table is being read and applied.
Then the stt row: compute_type is
float16, which only the platform table sets, sitting beside a
device of cpu, which only the copied file sets. One
component, two layers, and the losing layer won a single key. That narrows the cause
to the merge itself, and the merge is three lines long.
The repair is to move one line, and by itself it is not enough. The same mistake will
be available again the next time somebody edits that function, and it will be just as
quiet. What the migration needs is the thing chapter 34's validate_config
did for the voice loop: a statement of what must be true, checked before the loop
starts, reported as a list.
# labs/platform_config.py — the rows the board is allowed to insist on
def validate_platform(platform: str, cfg: SystemConfig) -> list[str]:
"""Every row the platform table declares must survive into the result."""
errors: list[str] = []
for name, rows in PLATFORM_OVERRIDES[platform].items():
comp = cfg.get_component(name)
if comp is None:
errors.append(f"{name}: no such component in the registry")
continue
for key, want in rows.items():
got = comp.settings.get(key)
if got != want:
errors.append(f"{name}.{key} is {got!r}, {platform} requires {want!r}")
return errors
def main() -> None:
platform = detect_platform()
cfg = resolve(platform, load_json_safe(HARDWARE_PATH))
print_resolved(platform, cfg)
errors = validate_platform(platform, cfg)
if errors:
print("config rejected:")
for error in errors:
print(f" - {error}")
raise SystemExit(1)
print(f"recorded at {save_resolved(cfg, RESOLVED_PATH)}")
$ uv run python -m labs.platform_config; echo "exit=$?" # the broken merge, now audible
platform: jetson
stt {"model": "base", "device": "cpu", "compute_type": "float16", "sample_rate": 16000}
llm {"model": "llama3.2:3b", "host": "http://localhost:11434", "model_storage": "/mnt/nvme/ollama/models", "max_history": 40}
tts {"voice": "en_US-lessac-medium", "sample_rate": 22050, "device": "cuda"}
memory {"db_path": "glados/data/memory.db"}
gpio {"library": "Jetson.GPIO"}
config rejected:
- stt.device is 'cpu', jetson requires 'cuda'
exit=1
The check is not testing the hardware. It compares the resolved config against the
table it was resolved from, which sounds circular until you notice that those two
things pass through the merge and can therefore disagree. Every layered configuration
has some set of values the top layer is not permitted to lose, and naming that set
out loud converts a silent wrong answer into an exit code. Put the override back last
and the same run ends with the recorded path and exit=0.
One more thing to take from the transcript: max_history is still 40 in
both runs. The correct merge does not throw the operator's file away, it only outranks
it on four keys. A layer that loses an argument about the Whisper device keeps
winning every argument the platform table never enters.
Checkpoint, and a board with room she has never had
- I can name the four settings that differ between the Pi build and the Jetson build, and say why the compute type is not a fifth.
- Given an unfamiliar board, I can find a file whose presence identifies it and turn that into a detector that returns a usable name on a machine it has never seen.
- I can order three configuration layers by authority and say what each one is allowed to be right about.
- Handed a resolved config line where one key came from the platform table and its neighbour came from disk, I can read that as evidence about the merge order.
- I can explain why the recorded
resolved_hardware.jsonis written but never read back at startup. - I can add a rule that makes a lost override fail at second zero instead of showing up as a machine that feels slow.
Exercise 1 — resolve a board you are not sitting at. Let the detected platform be overridden from the command line, then print the Jetson's resolved config from your laptop.
Detection stays the default and an argument replaces it, so the flag exists for inspection and never changes what happens on a real board:
import sys
def main() -> None:
platform = sys.argv[1] if len(sys.argv) > 1 else detect_platform()
if platform not in PLATFORM_OVERRIDES:
raise SystemExit(f"no such platform: {platform} "
f"(known: {', '.join(PLATFORM_OVERRIDES)})")
print_resolved(platform, resolve(platform, load_json_safe(HARDWARE_PATH)))
$ uv run python -m labs.platform_config jetson # on the laptop platform: jetson stt {"model": "base", "device": "cuda", "compute_type": "float16", "sample_rate": 16000} llm {"model": "llama3.2:3b", "host": "http://localhost:11434", "model_storage": "/mnt/nvme/ollama/models"} tts {"voice": "en_US-lessac-medium", "sample_rate": 22050, "device": "cuda"} memory {"db_path": "glados/data/memory.db"} gpio {"library": "Jetson.GPIO"} $ uv run python -m labs.platform_config orinnano no such platform: orinnano (known: jetson, rpi, unknown)
The membership test earns its two lines. Without it a typo reaches
PLATFORM_OVERRIDES[platform] and comes out as a
KeyError from inside resolve, which is a stack trace
about a dictionary when the actual mistake was a word.
Exercise 2 — catch an override key that no component declares.
Add "devcie": "cuda" to the Jetson's stt row and watch the
resolved config accept it in silence. Then stop it.
A spread merges any key, spelled correctly or not, so the typo becomes a real
setting that nothing reads while device keeps its default. Chapter
22's unknown-key guard is the answer, applied to the table instead of the save
file:
def check_override_keys() -> list[str]:
"""Every override key must already exist in the component it targets."""
base = {c.name: set(c.settings) for c in build_board_config().components}
problems: list[str] = []
for platform, components in PLATFORM_OVERRIDES.items():
for name, rows in components.items():
known = base.get(name)
if known is None:
problems.append(f"{platform}.{name}: no such component")
continue
for key in rows:
if key not in known:
problems.append(f"{platform}.{name}.{key}: not a declared setting")
return problems
$ uv run python -m labs.platform_config
jetson.stt.devcie: not a declared setting
jetson.tts.device: not a declared setting
jetson.llm.model_storage: not a declared setting
rpi.tts.device: not a declared setting
rpi.llm.model_storage: not a declared setting
unknown.tts.device: not a declared setting
unknown.llm.model_storage: not a declared setting
Seven findings, and only one is a typo. The other six are two keys counted once
per board: device on tts and
model_storage on llm are settings this chapter
introduced from the table and the registry never declared, so the guard is right
to complain about every place they appear. Declare both in
build_board_config with defaults that suit a machine with no GPU and
six of the seven go quiet, which is the honest fix. A guard relaxed enough to let
the override table keep inventing keys could not have caught devcie
either.
Exercise 3 — add a fourth board without touching a function. An Orange Pi 5 has a 40-pin header, no CUDA, and its own GPIO module. Support it, and count the lines you had to change outside the table.
PLATFORM_OVERRIDES["orangepi"] = {
"stt": {"device": "cpu", "compute_type": "int8"},
"tts": {"device": "cpu"},
"llm": {"model_storage": "/home/orangepi/.ollama/models"},
"gpio": {"library": "OPi.GPIO"},
}
def detect_platform() -> str:
if TEGRA_RELEASE.exists():
return "jetson"
try:
model = DEVICE_TREE_MODEL.read_text(errors="replace").lower()
except OSError:
return "unknown"
for marker, name in (("raspberry pi", "rpi"), ("orange pi", "orangepi")):
if marker in model:
return name
return "unknown"
$ uv run python -m labs.platform_config orangepi # on the laptop
platform: orangepi
stt {"model": "base", "device": "cpu", "compute_type": "int8", "sample_rate": 16000}
llm {"model": "llama3.2:3b", "host": "http://localhost:11434", "model_storage": "/home/orangepi/.ollama/models"}
tts {"voice": "en_US-lessac-medium", "sample_rate": 22050, "device": "cpu"}
memory {"db_path": "glados/data/memory.db"}
gpio {"library": "OPi.GPIO"}
One table entry and one loop over marker strings, and the detector's growth is the
interesting part: recognising a board is the only job that genuinely needs new
logic per board, because every board announces itself differently. Everything
downstream of the name stayed untouched, including
validate_platform, which now enforces the new rows without being
told they exist.
The resolver, validator and board-aware composition function now connect the speech settings. GPU transcription still requires a backend check and a measured turn on the target board.