{
  "_README": [
    "SmartestHome — the single source of truth for every build script in tools/.",
    "",
    "Copy this to CoreSystemConfig.json (that filename is gitignored), fill it in, then",
    "run any builder in tools/. Nothing in tools/ has its own copy of an IP, a port, a",
    "token or a URL — every build script reads this file, so a value can only ever be",
    "wrong in one place instead of six.",
    "",
    "ANYTHING DERIVABLE IS DERIVED, NEVER TYPED. You give the subnet prefix once and one",
    "last octet per host; the builders compute every address and every service URL from",
    "those. That is what makes the container-host and llm-host ISOs a TWINNED pair: the",
    "container host learns the LLM host's address because it was computed from the same",
    "two numbers, not because someone typed it into two files and kept them in sync.",
    "",
    "THIS FILE WILL CONTAIN SECRETS once filled in (Wi-Fi PSK, service tokens, MQTT and",
    "HA credentials) and those secrets get burnt into the ISOs the builders produce.",
    "Treat both this file and the built ISOs as credentials: .gitignore covers them, but",
    "an ISO on a USB stick in a drawer is still every token in this household.",
    "",
    "Run 'tools/validate-config.py' at any time to check it. Every builder runs it first",
    "and refuses to build on an error."
  ],

  "household": {
    "_comment": "Baked into every image. timezone must be a real zoneinfo name; keyboard_layout an xkb layout ('localectl list-x11-keymap-layouts').",
    "timezone": "Europe/Vienna",
    "locale": "en_US.UTF-8",
    "keyboard_layout": "de",
    "debian_release": "bookworm"
  },

  "network": {
    "_comment": "subnet_prefix is the first three octets of the smart-home VLAN, no trailing dot. Every host below places itself in it by last octet, which is also how the validator guarantees they can actually reach each other.",
    "subnet_prefix": "192.168.30",
    "netmask": "255.255.255.0",
    "gateway_last_octet": 1,
    "dns_servers": ["192.168.30.1"],
    "wifi": {
      "_comment": "Used by kiosk images that have no wired connection. Leave ssid empty if every device is wired; the validator will then not complain about an empty psk.",
      "ssid": "",
      "psk": ""
    }
  },

  "container_host": {
    "_comment": "The Phase 1 machine: Home Assistant, Mosquitto, Zigbee2MQTT, Frigate, Grocy and this repo's own services. Everything else in the household points at this address.",
    "hostname": "smarthome-core",
    "ip_last_octet": 12,
    "install_disk": "/dev/sda",
    "admin_username": "amir",
    "enable": {
      "_comment": "Mirrors setup-container-host.sh's ENABLE_* flags. Turning one off here also stops the builders from demanding the tokens it would have needed.",
      "identity": true,
      "pantry_vision": true,
      "digest_engine": false,
      "admin_canvas": false,
      "trash_calendar": false,
      "transit": false,
      "trip_planning": false,
      "chores": true,
      "ntfy": true,
      "node_red": true,
      "netdata": true,
      "homepage": true,
      "portainer": true,
      "mealie": false,
      "gallery_smb": false,
      "photos_web": false,
      "workshop": false,
      "music_assistant": false,
      "backups": false
    }
  },

  "llm_host": {
    "_comment": "The Phase 3 Ollama machine. Its address is what the container host's OLLAMA_HOST is derived from — change ip_last_octet here and every consumer follows automatically.",
    "hostname": "smarthome-llm",
    "ip_last_octet": 13,
    "install_disk": "/dev/sda",
    "admin_username": "amir",
    "tier": "auto",
    "text_model_gpu": "qwen2.5:14b-instruct",
    "text_model_cpu": "qwen2.5:7b-instruct",
    "vision_model": "llava",
    "pull_vision_model": true,
    "keep_alive": "30m",
    "max_loaded_models": 1,
    "num_parallel": 1
  },

  "ports": {
    "_comment": "The whole published-port inventory, matching docs/network-integration.md §4. The validator FAILS on any duplicate here, which is the mechanism that keeps a collision from reaching a deployment. NOTE music_assistant: its own default is 8095, which collides with pantry_vision — and because it runs network_mode:host, Compose's own port-conflict check never fires (project-plan open decision #31). 8101 is set here to resolve that, but you must also configure Music Assistant itself to listen on it; this file cannot make it move.",
    "home_assistant": 8123,
    "mqtt": 1883,
    "zigbee2mqtt": 8080,
    "node_red": 1880,
    "homepage": 3000,
    "ntfy": 8090,
    "portainer": 9000,
    "gallery_smb": 445,
    "mealie": 9925,
    "frigate": 5000,
    "grocy": 9283,
    "digest_web": 8091,
    "admin_web": 8094,
    "pantry_vision": 8095,
    "pantry_web": 8096,
    "identity": 8097,
    "identity_web": 8098,
    "transit": 8099,
    "otp": 8100,
    "music_assistant": 8101,
    "workshop": 8102,
    "workshop_web": 8103,
    "photos_web": 2283,
    "ollama": 11434,
    "proxy_http": 80,
    "proxy_https": 443
  },

  "secrets": {
    "_comment": "Generate the tokens with: openssl rand -hex 32. Each is required only if the service that uses it is enabled above; the validator says which. ha_token is a Long-Lived Access Token from HA's own UI (profile -> Security) and cannot be generated ahead of time — leave it empty for the first build and re-run once HA is up. The opnsense_api_* pair is the same kind of thing: only OPNsense can mint it (System -> Access -> Users -> API keys), so it is never generated here either.",
    "identity_token": "",
    "pantry_vision_token": "",
    "transit_token": "",
    "mqtt_username": "",
    "mqtt_password": "",
    "ha_token": "",
    "_opnsense_keys": "One entry per firewall in the opnsense list above, keyed by its `name`. A single-firewall household writes {\"main\": {...}} and is done. Keyed rather than positional because a list that has to line up with another list by index is a bug waiting for the day somebody reorders one of them.",
    "opnsense_keys": {
      "main": { "api_key": "", "api_secret": "" }
    },
    "_checkmk": "A CheckMK automation user with the read-only Guest role, and its AUTOMATION SECRET (Setup -> Users -> the user -> Automation secret) \u2014 not its login password.",
    "checkmk_username": "",
    "checkmk_secret": "",
    "_freeipa_bind_password": "The password for freeipa.bind_dn. Prefer a dedicated service account with READ-ONLY access to the household subtree \u2014 the mirror never writes to the directory, so a bind that can is a grant with no matching use.",
    "freeipa_bind_password": "",
    "_workshop_token": "Required when container_host.enable.workshop is true. openssl rand -hex 32",
    "workshop_token": "",
    "_gitea": "Optional, and only for the workshop assistant's own repositories. Gitea -> Settings -> Applications -> Generate New Token, scope write:repository. STRONGLY PREFER a dedicated 'workshop-bot' user scoped to one organisation over a token on your own account: this token can create repositories, and the blast radius of a leaked env file should be one org of generated repos rather than everything you own. The assistant commits and pushes but never rewrites history, and repo deletion is not implemented at all \u2014 see workshop/README.md.",
    "gitea_url": "",
    "gitea_token": "",
    "gitea_owner": "",
    "ssh_authorized_key": "",
    "kiosk_password": "",
    "admin_password_hash": ""
  },

  "opnsense": [
    {
      "_comment": "EVERY OPNsense firewall in the household, as a list. It was a single object until more than one firewall became plausible; a list is the shape that does not need changing again, and a one-firewall household just has one entry. `name` distinguishes them everywhere downstream — in the digest's network section, in the workshop health table, and in the alert text itself — so 'the IDS is quiet' can never silently mean 'one of the two is quiet'. Used by digest-engine's network digest (GET /api/ids/service/status and POST /api/ids/service/query_alerts \u2014 two read endpoints, nothing else, ever) and by workshop's health poller. A build writes this block plus the secrets into IDSconf.json; long-form field docs live in digest-engine/IDSconf.json.example. An empty list skips that file entirely. TWO THINGS THIS DOES NOT DO: it does not enable Suricata (Services -> Intrusion Detection on the firewall, then download a ruleset), and it does not enable the ingest (ENABLE_OPNSENSE_IDS_INGEST=true in digest-engine.env). SCOPE EACH API KEY: its own OPNsense user with only the 'Services: Intrusion Detection' privilege \u2014 which still covers api/ids/* including start/stop, because OPNsense ACLs are page-level, so the read-only guarantee comes from this project calling exactly two endpoints and not from the firewall enforcing it.",
      "name": "main",
      "base_url": "",
      "verify_tls": true,
      "interfaces": [],
      "max_alerts_scanned": 5000,
      "top_signatures": 8,
      "top_hosts": 5,
      "packet_capture_reference": ""
    }
  ],

  "checkmk": {
    "_comment": "An existing CheckMK server, polled read-only for host/service state. Feeds two places: digest-engine's network section (so a failing disk shows up in the quarter-daily digest) and workshop's own infra_status table (so the workshop display can overlay system health on the gallery). Nothing here ever acknowledges, downtimes or reschedules anything \u2014 the API user only needs to read. site is the CheckMK site name, which is the path segment in every URL: http://<host>/<site>/check_mk/api/1.0/...",
    "_credentials": "CheckMK: Setup -> Users -> add a user, Roles = 'Guest' (read-only), then its 'Automation secret' \u2014 NOT its login password. Put the username and secret in secrets.checkmk_username / secrets.checkmk_secret. A Guest-role automation user cannot change anything, which is the actual boundary here rather than a promise about which endpoints get called.",
    "base_url": "",
    "site": "cmk",
    "verify_tls": true,
    "_only_problems": "true keeps the payload to hosts and services that are not OK, which is what both consumers want; false pulls everything and is mostly useful once, to see what the site knows about.",
    "only_problems": true,
    "max_rows": 200
  },

  "identity_provider": {
    "_comment": "An EXTERNAL identity provider (Keycloak) in front of the household's web surfaces. NOTHING IN THIS REPO IMPLEMENTS SSO YET \u2014 this block exists so the decision and its values are recorded in one place before the work happens, and so the proxy config can be generated from it when it does. Leave issuer_url empty to skip it entirely, which is the current default and the tested path.",
    "_pairs_with_freeipa": "KEYCLOAK AND FREEIPA ARE ONE DECISION, NEVER TWO. Keycloak federates FreeIPA as its user store; it is not a place people are created. Configuring Keycloak without FreeIPA would mean a second, parallel set of household accounts \u2014 which is the specific outcome having a directory exists to prevent \u2014 so the validator refuses either one alone. Group membership comes from FreeIPA and rides through Keycloak as a claim; roles are never assigned in Keycloak itself, or the directory stops being the answer to 'who is in this household'. WHAT IT WOULD PROTECT: the proxy-fronted web UIs (Home Assistant, the photo frontend, workshop, identity's admin panel). WHAT IT MUST NEVER PROTECT: the kiosk-to-service APIs \u2014 pantry-vision, identity's /presence, workshop's own API \u2014 which are bearer-token gated because a wall panel cannot complete an interactive login. Putting an OIDC redirect in front of those turns every kiosk into a dead screen.",
    "issuer_url": "",
    "_realm": "Keycloak realm name; part of the issuer URL too, kept separately because the proxy config and any client library both want it on its own.",
    "realm": "smarthome",
    "client_id": "smarthome-proxy",
    "_protected_hosts": "Which proxied hostnames would sit behind SSO. Advisory until the work is done \u2014 see the _comment above.",
    "protected_hosts": []
  },

  "freeipa": {
    "_comment": "An existing FreeIPA domain, mirrored INTO identity as a source of people and group memberships. NOT IMPLEMENTED YET \u2014 this block records the decision and its values ahead of the work, like identity_provider above. Leave server empty to skip it, which is the current default and the only tested path.",
    "_direction": "ONE WAY, FreeIPA -> identity, and it must stay that way. identity holds household facts FreeIPA has no opinion about (BLE identifiers, chore reminder style, digest preferences, a colour) and writing any of that back would make a directory serving real logins into a store of smart-home preferences. A mirrored person is matched on uid and their name/group memberships are refreshed; everything else identity knows about them is left alone.",
    "_groups": "Group membership maps to roles here. `chore_exempt_group` members are dropped from the chore rotation exactly as the per-person flag does today \u2014 which means a FreeIPA group can grant an exemption but must never remove one somebody set by hand, or a directory sync would silently re-enrol a guest. `household_group` is who gets mirrored at all: without it every service account in the directory becomes a household member.",
    "_naming": "EVERY group this project reads is named USR_HA_<parameter>, matching the field name after the _group suffix is dropped: household_group -> USR_HA_household, chore_exempt_group -> USR_HA_chore_exempt, admin_group -> USR_HA_admins. The prefix is what makes a directory shared with other systems auditable \u2014 'which groups does the smart home read?' is answerable with one filter instead of by reading this file. The validator warns on anything that does not follow it rather than erroring, since an existing directory may already have its own convention and renaming groups in FreeIPA is not a thing a config file should force.",
    "server": "",
    "domain": "",
    "base_dn": "",
    "bind_dn": "",
    "verify_tls": true,
    "household_group": "USR_HA_household",
    "chore_exempt_group": "USR_HA_chore_exempt",
    "admin_group": "USR_HA_admins",
    "_sync_interval_minutes": "How often the mirror runs, once it exists. Directory changes are not urgent \u2014 somebody joining the household is a thing you also tell the door panel about.",
    "sync_interval_minutes": 60
  },

  "proxy": {
    "_comment": "One HTTPS front door for this repo's own services (Caddy), plus an HTTP->HTTPS redirect. Not a household-wide gateway — Home Assistant, Grocy and friends keep their own ports. hostname is what the certificate is issued for and what you type in the browser; it must resolve to the container host (a DNS override on OPNsense, a hosts entry, or just use the IP with tls: internal). tls 'internal' makes Caddy run its own CA — no external dependency, but browsers show a warning until you install its root (tools/export-proxy-ca.sh). tls 'custom' uses cert_file/key_file, which is how you'd use a real cert obtained via a DNS-01 challenge without exposing anything. See proxy/README.md.",
    "enabled": true,
    "hostname": "home.example.lan",
    "tls": "internal",
    "cert_file": "",
    "key_file": ""
  },

  "voice": {
    "_comment": "Defaults for any kiosk with voice_satellite enabled; a kiosk may override wake_word individually.",
    "wake_word": "ok_nabu"
  },

  "mic_follow": {
    "_comment": "Follow-me microphone switching. A person walks out of the room their PC is in; the locator says which room they are in now; that machine's live microphone switches to one that can hear them, and back again when they return. Built for a voice call that has to survive a smoke break, and written per CLIENT so a second person with a second desktop is a second entry here and nothing else.",
    "_how_it_decides": "Home Assistant owns the decision and the desktop agent owns the mechanism. The generated automations watch presence_entity and, ONLY while that client's Follow-me switch is on, set its Mic input select. Switch off is not 'ignore me' — it is an active guarantee that the machine is on its own desk microphone.",
    "_locator_honesty": "presence_entity must be an entity whose STATE is an HA area_id. The only identity-bearing room-level source in this household is BLE (Bermuda, surfaced by identity/); RuView tells you a room is occupied but not by whom, and Frigate recognises faces at the door, not per room. So RuView/Frigate can corroborate but cannot drive this on their own — see mic-follow/README.md section 3 before trusting the room this switches on.",
    "enabled": false,
    "clients": [
      {
        "_comment": "One desktop machine and the one person it follows. node_id must be unique, lowercase, and is what every generated entity_id contains.",
        "node_id": "amir_desktop",
        "friendly_name": "Amir's desktop",
        "person": "amir",
        "room": "amirs_room",
        "presence_entity": "sensor.amir_ble_area",
        "_desk_source": "Any part of a PipeWire source name or description — run `mic-follow/desktop_agent.py --list-sources` on that machine to see the real strings. This is the microphone the client returns to whenever follow-me is off.",
        "desk_source": "MV6",
        "_move_streams": "Applications whose ALREADY-RUNNING capture stream gets moved as well as the default being changed. Anyone with a studio mic has picked it explicitly in Discord, and an explicitly-picked device does not follow the default.",
        "move_streams": ["Discord"],
        "_dwell": "Seconds the person has to be in a room before the microphone follows them. Leaving is slower than returning on purpose: a walk past the door should not move your microphone, but sitting back down should give you your good one back quickly.",
        "dwell_seconds": 20,
        "return_dwell_seconds": 5,
        "_on_unknown_room": "hold | desk. What happens in a room with no microphone configured. 'hold' keeps the last one (you might be walking through); 'desk' is the honest one (nothing in that room can hear you).",
        "on_unknown_room": "hold",
        "reconcile_seconds": 10,
        "_sources": "One per room this client can be heard in. A source is either LOCAL (a microphone plugged into this machine — a wireless clip-on receiver, say: give `source` a pattern and nothing else) or REMOTE (a microphone on another machine, streamed here — give it a `remote` block and mic-follow generates the rest). start_command/stop_command are the escape hatch for anything neither of those covers; the STOP hook is the important one, because a room microphone still streaming after the switch left it is a hot mic in somebody's flat.",
        "_remote": "A microphone on another machine in the household — for the Loggia, the touch panel that is already in there. Audio travels over SSH (that panel already runs sshd as its admin path and already trusts this desktop's key), so there is no new listening service, no ACL to maintain, and audio exists ONLY while the stream is running — a property of the transport rather than a promise. host/user are that machine; mic_source is a pattern for ITS microphone, empty meaning its own default; latency_ms buys smoothness over wifi at the cost of delay. See mic-follow/README.md section 6.",
        "sources": [
          {
            "room": "loggia",
            "source": "micfollow_loggia",
            "description": "Loggia panel mic",
            "remote": {
              "host": "touch-panel-loggia",
              "user": "kiosk",
              "mic_source": "",
              "latency_ms": 40,
              "rate": 48000,
              "channels": 1,
              "ssh_key": ""
            }
          }
        ]
      }
    ]
  },

  "stream_dock": {
    "_comment": "A desk-side Stream Dock (MiraBox N4 Pro / Ajazz AKP05 family: 10 LCD keys, 4 RGB-lit rotary encoders) driving ONE room's colour lamps through OpenDeck. This block generates the dock's key/dial bindings, its knob-LED colours and the LED sync service's environment. It does not build an image and nothing here runs on the container host — the dock hangs off a desktop machine. See stream-dock/README.md.",
    "_room": "An HA area_id, the same vocabulary as every kiosk (docs/rooms-and-endpoints.md). It labels the generated bindings and names the systemd unit; `lights` is what actually gets controlled, because a dial has to name entities, not an area.",
    "enabled": false,
    "room": "living_room",
    "lights": ["light.living_room_lamp"],
    "_steps": "How far one encoder detent moves things. rgb_step is in 0-255 channel units (8 = 32 detents end to end); brightness_step_pct is percentage points. tick_bucket_ms is the plugin's own aggregation window — ticks inside it are summed into one service call, so spinning the dial fast does not queue fifty calls at Home Assistant.",
    "rgb_step": 8,
    "brightness_step_pct": 5,
    "tick_bucket_ms": 120,
    "_knob_leds": "The four encoder rings. r/g/b each glow their own channel's current value in their own colour; the fourth glows in the colour the room is actually emitting (rgb scaled by brightness). THIS NEEDS THE DEVICE PLUGIN TO RE-READ leds.toml AT RUNTIME, WHICH IT DOES NOT DO TODAY — the sync service writes the file correctly and then runs apply_command, which is the hook where that gap gets closed. Read stream-dock/README.md section 5 before relying on it.",
    "knob_leds": {
      "enabled": true,
      "brightness": 100,
      "min_channel_led": 0,
      "poll_seconds": 2.0,
      "debounce_ms": 400,
      "config_path": "",
      "_apply": "How a fresh leds.toml gets in front of the device plugin, which reads that file at startup and (as far as anyone has checked) not again. apply_strategy is what stream-dock/apply-leds.sh does after each write: 'none' writes the file and stops there; 'signal' sends SIGHUP to the plugin process; 'restart-plugin' kills it and lets OpenDeck respawn it; 'restart-opendeck' restarts the whole app. RUN `stream-dock/apply-leds.sh --probe` WITH THE DOCK PLUGGED IN AND SET WHAT IT TELLS YOU — its first test is whether the plugin already picks the file up on its own, in which case 'none' is the right answer and the rings are live for free. apply_command overrides all of it with a command of your own; leave it empty to use the strategy.",
      "apply_strategy": "none",
      "apply_command": "",
      "_apply_pacing": "apply_min_interval_seconds rate-limits the strategy: a spun dial changes the colour on every service call, and 'restart-plugin' at that rate would re-initialise the device continuously. The last state is never dropped — it is applied once the interval elapses. plugin_process_pattern is what pgrep matches to find the device plugin; the probe prints the candidates on your machine.",
      "apply_min_interval_seconds": 2.0,
      "plugin_process_pattern": "akp05",
      "_layer_gate": "The dock's lighting controls live on their own OpenDeck layer, and the rings belong to whatever layer is showing. layer_gate_command is run before every LED update: exit 0 means the lighting layer is up and the rings are ours to drive, exit 1 means it is not, and any other outcome (or a command that cannot run) is treated as 'not ours' — a service that cannot tell which layer is showing must not paint over another one's rings. Empty means no gate: drive the rings always. stream-dock/layer-active.sh is a starting implementation, and it needs verifying against a real OpenDeck install — see stream-dock/README.md section 6.",
      "layer_gate_command": "",
      "_idle": "What the rings do when the lighting layer is NOT showing. Defaults to the CyberQueer palette from ~/Dotfiles/colors.conf — COLOR_HIGHLIGHT, COLOR_DARK, COLOR_RED — chasing across the four rings, so an idle dock matches the rest of the desktop instead of sitting on the last lamp colour it happened to see. Bare 6-digit hex, same format as colors.conf. An empty list leaves the rings untouched instead. idle_cycle_seconds is how long each step holds; 0 holds the first colour forever, which is what you want when apply_command is expensive, because every step of the chase costs one apply.",
      "idle_colors": ["E40046", "5018DD", "F50505"],
      "idle_cycle_seconds": 3.0
    }
  },

  "kiosks": [
    {
      "_comment": "type must be one of: thin-client, touch-panel, door-panel, kitchen-display, steam-tv-box. hostname must be unique and a valid DNS label — it is what the HA device shows up as.",
      "type": "door-panel",
      "hostname": "door-panel",
      "room": "hallway",
      "friendly_name": "Door panel",
      "kiosk_username": "kiosk",
      "voice_satellite": true,
      "enable_installer": false
    },
    {
      "type": "kitchen-display",
      "hostname": "kitchen-display",
      "room": "kitchen",
      "friendly_name": "Kitchen fridge display",
      "kiosk_username": "kiosk",
      "voice_satellite": false,
      "enable_installer": false
    },
    {
      "type": "thin-client",
      "hostname": "thin-client-living",
      "room": "living_room",
      "friendly_name": "Living room thin client",
      "kiosk_username": "kiosk",
      "voice_satellite": false,
      "enable_installer": false,
      "enable_steam_link": true,
      "enable_gesture_control": false
    },
    {
      "type": "touch-panel",
      "hostname": "touch-panel-kitchen",
      "room": "kitchen",
      "friendly_name": "Kitchen touch panel",
      "kiosk_username": "kiosk",
      "voice_satellite": false,
      "enable_installer": false
    },
    {
      "_comment": "The Loggia's Lenovo all-in-one (docs/components.md #Have). Same touch-panel image as the kitchen's, plus Discord: this is the balcony panel, and the point of the fourth app is that a voice call survives stepping outside — you switch the call onto this machine on the way out and back onto the desktop on the way in.",
      "type": "touch-panel",
      "hostname": "touch-panel-loggia",
      "room": "loggia",
      "friendly_name": "Loggia touch panel",
      "kiosk_username": "kiosk",
      "voice_satellite": false,
      "enable_installer": false,
      "_comment_enable_discord": "touch-panel only. Installs the Discord Flatpak, adds a 4:discord workspace, a Discord dock button and an HA app-launch button, and starts it logged-in at session boot. Off everywhere else — a kitchen panel does not want a chat client.",
      "enable_discord": true
    },
    {
      "_comment": "The living-room gaming box. Games run ON this machine (real GPU/CPU) — it is not the thin client's Steam Link streaming. Boots straight into Steam Big Picture; the media apps (Firefox+uBlock Origin, Spotify, mpv) are launched lazily the first time somebody leaves Big Picture. Prism Launcher is installed and registered as a non-Steam game so it runs through Steam with Steam Input active.",
      "type": "steam-tv-box",
      "hostname": "steam-tv-box-living",
      "room": "living_room",
      "friendly_name": "Living room Steam TV box",
      "kiosk_username": "kiosk",
      "voice_satellite": false,
      "_comment_installer": "true, unlike every other kiosk here: a live-booted system keeps its writable layer in RAM, so a Steam library would vanish on reboot. This box wants a real install on a real disk.",
      "enable_installer": true,
      "_comment_debian_release": "Overrides household.debian_release for this image only. bookworm ships Mesa 22.3, which is too old to run current titles well, and the graphics stack is the one thing on this machine that cannot be fixed later by editing a config file.",
      "debian_release": "trixie",
      "_comment_gpu_vendor": "amd | intel | nvidia. Not detected — the build host cannot see this machine's hardware, and installing the NVIDIA driver on an AMD box actively breaks it. amd and intel need nothing beyond Mesa; nvidia pulls the non-free driver.",
      "gpu_vendor": "amd",
      "_comment_enable_cec": "Turn the television itself on and off over the HDMI cable (HDMI-CEC), driven by Home Assistant presence. Set false if the set ignores CEC or is on a receiver that misbehaves; the compositor-side blanking still works either way.",
      "enable_cec": true
    }
  ],

  "audio_endpoints": [
    {
      "_comment": "Headless Spotify Connect appliances for rooms with no thin client. arch picks the toolchain — and they are genuinely different toolchains producing different artifacts, not one image for both: 'amd64' is a mini PC + USB DAC built with live-build (an .iso), 'arm64' is a Raspberry Pi + HiFiBerry Amp2 built with rpi-image-gen (an .img). build-all.sh builds every entry here, so listing both architectures gets you both. hostname doubles as the Spotify Connect device name and must be unique across kiosks too — they're all devices on one network.",
      "hostname": "audio-endpoint-livingroom",
      "room": "living_room",
      "friendly_name": "Living room",
      "arch": "amd64"
    },
    {
      "hostname": "audio-endpoint-kitchen",
      "room": "kitchen",
      "friendly_name": "Kitchen",
      "arch": "arm64"
    }
  ],

  "build": {
    "_comment": "Where finished images land, relative to the repo root. Gitignored — see .gitignore — because these carry every secret in this file. arm64_prebake bakes each arm64 audio endpoint's hostname into its own image, so every device in the household is named by the build rather than typed into Raspberry Pi Imager afterwards; it costs one full rpi-image-gen run per room. Set it false to build a single generic .img and name each unit at flash time instead.",
    "output_dir": "iso-out",
    "arm64_prebake": true
  }
}
