GLaDOS Vol 8 · The Jetson Brain
ch 82 / 99
Chapter 82

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.

◆ Note — the bench for this volume, and how to read its output

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

▣ Build · stage 1 — a name for the board, from files nobody wrote by hand
# 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.

▣ Build · stage 2 — read the operator's file without dying on it
# 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

▣ Build · stage 3 — the migration, written down as data
# 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.

▣ Build · stage 4 — three layers, applied in order
# 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.

▣ Build · stage 5 — leave the answer on disk
# 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.

Three configuration layers merged into one resolved registry Three source boxes feed one merge expression: the registry defaults from chapter 31, the operator's configs/hardware.json, and the platform override table keyed by the detected board. They are spread left to right into one dict, so the platform override is applied last and wins every key it declares. The result is a resolved registry in which the Whisper device reads cuda on the Jetson and cpu on the Pi. THREE LAYERS, SPREAD LEFT TO RIGHT · THE LAST ONE WINS registry defaults safe on any board hardware.json what the operator edited PLATFORM_OVERRIDES keyed by the detected board {**defaults, **on_disk, **override} resolved registry stt.device is cuda here, cpu on the Pi read by every module asked by none of them
Figure 82.1 — Three layers collapse into one dict per component. The order of the three spreads is the only thing deciding which board she comes up as.

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.

⚠ Worked failure — the migration that succeeded at Pi speed

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

✓ Checkpoint — what you can now do
  • 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.json is 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.
⚡ Exercises — try first, then reveal
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.