From 2f42b88c1946a8367fddd126cfe7aa584575df01 Mon Sep 17 00:00:00 2001 From: The_miro Date: Fri, 14 Aug 2026 08:46:34 +0200 Subject: [PATCH] Steam TV box: a local-gaming client that falls back to a media station MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new kiosk type, hosts/steam-tv-box/, plus tools/build-steam-tv-box-iso.sh. It boots straight into Steam Big Picture and runs games ON the machine — the opposite end of hosts/thin-client's Steam Link, which streams a game rendered somewhere else and needs no graphics performance at all. Native Steam (steam-installer, contrib) goes in via a hook rather than the package list: it is unusable until `dpkg --add-architecture i386` has run, and live-build installs package lists before hooks. With it come the i386 half of the Mesa/Vulkan/SDL stack (most pre-2015 titles are still 32-bit and fail with an opaque GL-context error without it), steam-devices' udev rules, gamemode, mangohud, and gamescope when the release has it. First host to override household.debian_release. A new optional per-kiosk `debian_release` key pins this image to trixie; bookworm's Mesa 22.3 is fine for every other host here — they are a browser and a Python agent — and not fine for the one machine that renders. It is also the only thing on this box that cannot be fixed later by editing a config file. `gpu_vendor` (amd/intel/nvidia) is configuration, not detection, and the validator requires it: the build host cannot see the target machine, and installing the NVIDIA driver on an AMD box actively breaks it. The media apps do not exist until you leave Big Picture. Firefox (uBlock Origin + SponsorBlock), Spotify and mpv start the first time somebody actually leaves it — not tidiness, but because this is the one machine here where a background browser and an open Spotify audio stream turn up as stutter in a frame-limited title. Two independent triggers, because there are two ways to leave and neither can see the other: steam-session runs steam-big-picture in the foreground and continues when the client exits, and session-watcher subscribes to sway workspace-focus events for "exited Big Picture but Steam is still running". Both call an idempotent media-session start, guarded per-app by pgrep and as a whole by flock. Window-title matching was rejected: Steam's titles and window structure have moved across client rewrites, and a media session that silently stops appearing after an update reads as a broken image rather than a moved string. Going back into a game tears nothing down — people play music over games on purpose. `media-session stop` exists behind one explicit HA button and nothing calls it automatically. Prism Launcher runs through Steam, not beside it. steam-shortcut-prism writes it into the user's binary shortcuts.vdf as a non-Steam game and prism-launch starts it via steam://rungameid/, so Minecraft runs inside the Steam Runtime with Steam Input and the Steam Controller API live. That cannot be baked into the ISO (userdata// does not exist until somebody logs into Steam, and no Steam credentials go into an image), so it runs per-session and before Steam starts — Steam rewrites that file from memory on exit. It falls back to a direct launch when Steam is absent, and refuses to rewrite a shortcuts.vdf that already holds other entries rather than risk eating them. steamtv-agent adds one genuinely new entity to the kiosk vocabulary: a session-mode sensor (gaming/steam/media/idle, from two cheap local facts) so HA can answer "is somebody playing?" without guessing from power draw. The rest — transport, volume, audio-output select, workspace select, launch buttons, CEC display switch — is the thin client's surface over the same MQTT-only control boundary: no HTTP listener, no exposed Sway IPC socket, every handler an enumerated action, payloads never argv elements. enable_installer defaults true here, unlike every other kiosk: a live system keeps its writable layer in RAM and a Steam library would vanish on reboot. Never built, never flashed, never booted, no hardware chosen. Four things are reasoned rather than verified and are flagged in the host README and as open decision #43: the shortcuts.vdf binary format and non-Steam AppID derivation (Valve documents neither), gamescope's availability in trixie, the two Flathub app IDs, and the NVIDIA driver package names. Co-Authored-By: Claude Opus 5 --- CoreSystemConfig.json.template | 19 +- README.md | 7 + docs/project-plan.md | 83 +++- hosts/steam-tv-box/README.md | 290 +++++++++++++ hosts/steam-tv-box/agent/requirements.txt | 8 + .../steam-tv-box/agent/steamtv-agent.service | 39 ++ .../agent/steamtv_agent/__init__.py | 3 + .../agent/steamtv_agent/audio_control.py | 211 ++++++++++ .../agent/steamtv_agent/display_power.py | 146 +++++++ .../steam-tv-box/agent/steamtv_agent/main.py | 292 +++++++++++++ .../agent/steamtv_agent/mpris_bridge.py | 138 ++++++ .../agent/steamtv_agent/mqtt_discovery.py | 308 ++++++++++++++ .../agent/steamtv_agent/runtime_state.py | 75 ++++ .../agent/steamtv_agent/session_mode.py | 146 +++++++ .../agent/steamtv_agent/sway_control.py | 135 ++++++ .../configs/audio/audio-config.json | 10 + .../configs/firefox/policies.json | 63 +++ hosts/steam-tv-box/configs/firefox/user.js | 55 +++ .../configs/firefox/userChrome.css | 63 +++ .../steam-tv-box/configs/firefox/web-browser | 47 +++ hosts/steam-tv-box/configs/greetd/config.toml | 21 + .../steam-tv-box/configs/greetd/kiosk-session | 43 ++ .../configs/installer/preseed.cfg | 24 ++ hosts/steam-tv-box/configs/mpv/mpv.conf | 31 ++ .../steam-tv-box/configs/session/media-player | 38 ++ .../configs/session/media-session | 133 ++++++ .../steam-tv-box/configs/session/prism-launch | 99 +++++ .../configs/session/session-watcher | 60 +++ .../configs/session/spotify-launch | 26 ++ .../configs/session/steam-big-picture | 57 +++ .../configs/session/steam-session | 47 +++ .../configs/session/steam-shortcut-prism | 268 ++++++++++++ hosts/steam-tv-box/configs/sway/config | 203 +++++++++ .../steam-tv-box/configs/sway/display-toggle | 29 ++ hosts/steam-tv-box/configs/wayvnc/config | 26 ++ .../steam-tv-box/configs/wayvnc/start-wayvnc | 36 ++ .../hooks/normal/0100-user-setup.hook.chroot | 61 +++ .../hooks/normal/0200-greetd.hook.chroot | 25 ++ .../hooks/normal/0250-wayvnc.hook.chroot | 35 ++ .../hooks/normal/0300-steam.hook.chroot | 130 ++++++ .../normal/0400-flatpak-apps.hook.chroot | 59 +++ .../normal/0700-steamtv-agent.hook.chroot | 20 + .../hooks/normal/0900-firefox.hook.chroot | 22 + .../package-lists/steam-tv-box.list.chroot | 157 +++++++ tools/build-steam-tv-box-iso.sh | 393 ++++++++++++++++++ tools/config-export.py | 12 + tools/validate-config.py | 31 +- 47 files changed, 4221 insertions(+), 3 deletions(-) create mode 100644 hosts/steam-tv-box/README.md create mode 100644 hosts/steam-tv-box/agent/requirements.txt create mode 100644 hosts/steam-tv-box/agent/steamtv-agent.service create mode 100644 hosts/steam-tv-box/agent/steamtv_agent/__init__.py create mode 100644 hosts/steam-tv-box/agent/steamtv_agent/audio_control.py create mode 100644 hosts/steam-tv-box/agent/steamtv_agent/display_power.py create mode 100644 hosts/steam-tv-box/agent/steamtv_agent/main.py create mode 100644 hosts/steam-tv-box/agent/steamtv_agent/mpris_bridge.py create mode 100644 hosts/steam-tv-box/agent/steamtv_agent/mqtt_discovery.py create mode 100644 hosts/steam-tv-box/agent/steamtv_agent/runtime_state.py create mode 100644 hosts/steam-tv-box/agent/steamtv_agent/session_mode.py create mode 100644 hosts/steam-tv-box/agent/steamtv_agent/sway_control.py create mode 100644 hosts/steam-tv-box/configs/audio/audio-config.json create mode 100644 hosts/steam-tv-box/configs/firefox/policies.json create mode 100644 hosts/steam-tv-box/configs/firefox/user.js create mode 100644 hosts/steam-tv-box/configs/firefox/userChrome.css create mode 100755 hosts/steam-tv-box/configs/firefox/web-browser create mode 100644 hosts/steam-tv-box/configs/greetd/config.toml create mode 100755 hosts/steam-tv-box/configs/greetd/kiosk-session create mode 100644 hosts/steam-tv-box/configs/installer/preseed.cfg create mode 100644 hosts/steam-tv-box/configs/mpv/mpv.conf create mode 100755 hosts/steam-tv-box/configs/session/media-player create mode 100755 hosts/steam-tv-box/configs/session/media-session create mode 100755 hosts/steam-tv-box/configs/session/prism-launch create mode 100755 hosts/steam-tv-box/configs/session/session-watcher create mode 100755 hosts/steam-tv-box/configs/session/spotify-launch create mode 100755 hosts/steam-tv-box/configs/session/steam-big-picture create mode 100755 hosts/steam-tv-box/configs/session/steam-session create mode 100755 hosts/steam-tv-box/configs/session/steam-shortcut-prism create mode 100644 hosts/steam-tv-box/configs/sway/config create mode 100755 hosts/steam-tv-box/configs/sway/display-toggle create mode 100644 hosts/steam-tv-box/configs/wayvnc/config create mode 100755 hosts/steam-tv-box/configs/wayvnc/start-wayvnc create mode 100755 hosts/steam-tv-box/live-build/config/hooks/normal/0100-user-setup.hook.chroot create mode 100755 hosts/steam-tv-box/live-build/config/hooks/normal/0200-greetd.hook.chroot create mode 100755 hosts/steam-tv-box/live-build/config/hooks/normal/0250-wayvnc.hook.chroot create mode 100755 hosts/steam-tv-box/live-build/config/hooks/normal/0300-steam.hook.chroot create mode 100755 hosts/steam-tv-box/live-build/config/hooks/normal/0400-flatpak-apps.hook.chroot create mode 100755 hosts/steam-tv-box/live-build/config/hooks/normal/0700-steamtv-agent.hook.chroot create mode 100755 hosts/steam-tv-box/live-build/config/hooks/normal/0900-firefox.hook.chroot create mode 100644 hosts/steam-tv-box/live-build/config/package-lists/steam-tv-box.list.chroot create mode 100755 tools/build-steam-tv-box-iso.sh diff --git a/CoreSystemConfig.json.template b/CoreSystemConfig.json.template index f45ace3..6805305 100644 --- a/CoreSystemConfig.json.template +++ b/CoreSystemConfig.json.template @@ -216,7 +216,7 @@ "kiosks": [ { - "_comment": "type must be one of: thin-client, touch-panel, door-panel, kitchen-display. hostname must be unique and a valid DNS label — it is what the HA device shows up as.", + "_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", @@ -253,6 +253,23 @@ "kiosk_username": "kiosk", "voice_satellite": false, "enable_installer": false + }, + { + "_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 } ], diff --git a/README.md b/README.md index 69ab7e4..9841f8b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ hosts/ Chromium window showing pantry-vision's unload/consume/ expired/edit frontend, camera capture via the browser itself + steam-tv-box/ Living-room gaming box: boots into Steam Big Picture and + runs games LOCALLY (real GPU — not thin-client's Steam + Link streaming). Prism Launcher runs through Steam so + Steam Input is live for Minecraft. The media apps + (Firefox+uBlock Origin, Spotify, mpv) are launched lazily, + the first time somebody leaves Big Picture door-panel/ Single-purpose Sway kiosk by the door/wardrobe: identity's weather+clothing/who's-home/groceries-low dashboard by default, registration on demand — this @@ -98,6 +104,7 @@ chores/ Presence/calendar-driven household chore nudging + - [ ] ESP32-S3-Touch-LCD-1.85C-V2 voice satellite + status display (`firmware/esp32-s3-touch-lcd-1.85c/`) — ESPHome config written and passes `esphome config`, not yet flashed to real hardware; `media_player`/`weather` entity IDs still need to be chosen, see `docs/project-plan.md` §4 - [ ] Headless audio endpoint (`hosts/audio-endpoint/`) — per-room independent Spotify Connect appliance for rooms without a thin client, arm64 (Raspberry Pi + HiFiBerry Amp2, rpi-image-gen) and amd64 (mini PC + USB DAC/amp, live-build) build pipelines written, **neither built/flashed/booted on real hardware** — rpi-image-gen's exact config schema in particular is unverified, see `hosts/audio-endpoint/README.md` - [ ] Sway touch panel (`hosts/touch-panel/`) — touch-driven Sway image: full Spotify GUI (Flathub), a dedicated Home Assistant Chromium kiosk window, a general web browser, an always-on touch dock for app switching, an on-screen keyboard (toggled manually, no auto-show), and `touchpanel-agent` (HA MQTT control, same LLM-mediated-through-HA security model as the thin client) — built, **no touch-panel hardware chosen and nothing booted on real metal**, see `hosts/touch-panel/README.md` +- [ ] Steam TV box (`hosts/steam-tv-box/`) — living-room machine that boots straight into **Steam Big Picture** and plays games **locally** (native `steam-installer` + i386 multiarch + full Mesa/Vulkan stack, `steam-devices`, gamemode, gamescope when the release has it) — the opposite end of `hosts/thin-client/`'s Steam Link streaming, and the only host here pinned to **trixie** (per-kiosk `debian_release` override; bookworm's Mesa 22.3 is too old for a machine that renders). **Prism Launcher** is installed from Flathub and registered as a *non-Steam game* so Minecraft runs through Steam with Steam Input and the Steam Controller API live. Leaving Big Picture drops into the same media set as the other monitor clients — Firefox with uBlock Origin/SponsorBlock, Spotify, mpv — **launched lazily**, on two independent triggers (the Steam process exiting, and a sway workspace-focus IPC watcher), both idempotent, and never torn down when you go back into a game. `steamtv-agent` adds a session-mode sensor, mode/screen/audio-output selects, app-launch buttons and a CEC display switch over the same MQTT-only control surface as the other kiosks. Written and validated end-to-end through `validate-config.py`/`config-export.py`, **never built, never flashed, never booted, no hardware chosen** — the `shortcuts.vdf` binary format, the Flathub app IDs, gamescope's availability and the NVIDIA package names are all reasoned rather than verified, see `hosts/steam-tv-box/README.md` - [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes. All four stock movements now run off the camera — **unload** (a scan loop, one confirm per item, with pack size and where to put it away), **consume** (asks which brand and how many), **list expired** (cleared by scanning what you're binning, booked out as spoiled), and **edit inventory** (the deliberately camera-free correction screen) — with two invariants: stock counts *individual units* (a twelve-pack of eggs is twelve) and folds *brand-free* via Grocy product groups (12 of brand X + 10 of brand Y = 22 eggs, expandable per brand). Built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md` - [x] Where-is-it-actually, for multiple fridges — `docs/fridge-item-location.md`: separates "which appliance" (a software-only change: Grocy locations + a transfer action) from "which shelf" (a *hint* at best) and "exact position" (occlusion makes it unbuildable), and rules out interior cameras on power/condensation/−18 °C grounds — which is exactly the compartment the question starts from. **Built**: appliances as Grocy locations, `POST /transfer` with a "Move to…" picker on the edit screen, and the door-sensor→camera→hint path (`pantry-vision/doorway.py`, `POST /doorway-event` — an HA automation on a Zigbee contact sensor, answered 202 with the camera burst running off-thread; what it recognises is stored in its own SQLite file with a timestamp and a confidence and is **never** written to stock). **Not bought, not tested**: no door sensor, no doorway camera, and the assumption the camera half rests on — that a local vision model can identify an item in a moving hand at ~1.5 m — has never been checked (open decision #40). An appliance can be configured with a sensor and no camera, which is still the recommended way to start - [x] Per-person **colour** and a settable **profile picture** in `identity` — eight colours assigned automatically at registration, avoiding any colour already worn by somebody with the same initial (an Anna and an Amir are two identical "A"s on a wall panel, and the colour is what makes that readable), then least-used overall; editable in the admin panel, backfilled oldest-first for existing people so nobody's colour reshuffles on restart. `color` + `initial` now ride on `/people`, `/presence` and every `/floorplan/presence` occupant, so no consumer derives an initial or invents a palette. New `POST /people//photo` sets a picture without a walk to the door panel — the registration capture was the only source before, which left a device-less household member unable to have a face at all. **The palette values sit on the 2-bits-per-channel lattice a colour Pebble renders natively**, so the colour on a watch is the colour in the panel, see below diff --git a/docs/project-plan.md b/docs/project-plan.md index 5c083cb..077cb51 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -935,9 +935,89 @@ entirely container-host services plus additional Frigate camera sources. - Does `chores`' optional LLM message-phrasing (`chore_reminder_style`) ever change *who* gets nudged or *when*, rather than only the wording of the notification? (It must not — and a failed/empty LLM call must fall back to the plain template, never block the nudge from going out.) - Does `chores`' household calendar busy-check, if unreachable or misconfigured, ever become the reason nudges stop going out entirely? (It must not — it fails open, treating an error as "not busy.") +### Phase 21 — Steam TV box (local gaming + media, living room) + +New host directory `hosts/steam-tv-box/`. No container-host changes. + +1. **A different device from the Phase 11 thin client, not a variant of it** — and the + distinction is the whole point. The thin client runs *Steam Link*: it streams a game + rendered on some other PC and needs no graphics performance at all. This box is the + other end of that — a real GPU/CPU machine that renders games **locally**. It reuses + the thin client's live-build toolchain and `configs/`+`agent/` split, not its + live-build tree, exactly the relationship `hosts/touch-panel/` already has to it. +2. **It is the first host to override `household.debian_release`.** A new optional + per-kiosk `debian_release` key (emitted for every kiosk by `config-export.py`, used + only by this builder) pins this image to **trixie**. bookworm's Mesa is 22.3, which + is fine for every other host here — they are a browser and a Python agent — and not + fine for the one machine that renders. The graphics stack is also the only thing on + this box that cannot be fixed later by editing a config file and rebuilding a + service, which is why it is worth a config-schema change rather than a comment. +3. **Native Steam, not the Flatpak**: `steam-installer` from contrib, installed by a + hook rather than the package list because it is unusable until `dpkg + --add-architecture i386` has run and live-build installs package lists *before* + hooks. Plus the i386 half of the Mesa/Vulkan/SDL/PulseAudio stack (a great many + shipped Linux builds are still 32-bit and fail with an opaque GL-context error + without it), `steam-devices`' udev rules (without which pads are root-only and Big + Picture shows no controller at all), `gamemode`, `mangohud`, and `gamescope` when the + release has it. The Flatpak was rejected deliberately: its sandbox complicates GPU + driver matching, controller udev access and mounting a games disk, all of which are + this host's whole job. Spotify and Prism, which have none of those constraints, do + come from Flathub. +4. **`gpu_vendor` is configuration, not detection** (`amd`/`intel`/`nvidia`, validated). + The build host cannot see the target machine, and installing the NVIDIA driver on an + AMD box actively breaks it. `nvidia` additionally sets `WLR_NO_HARDWARE_CURSORS=1`, + whose absence shows up as an invisible pointer — easy to miss on a box driven with a + gamepad. +5. **The media apps do not exist until you leave Big Picture.** The box boots to Steam + and nothing else; Firefox (uBlock Origin + SponsorBlock), Spotify and mpv are started + the first time somebody actually leaves it. Not tidiness: this is the only machine + here where a background browser and a background Electron app cost something visible + — GPU memory, timer wakeups, and an open Spotify audio stream that turns up as + stutter in a frame-limited title. **Two independent triggers**, because there are two + ways to leave and neither can see the other: `steam-session` runs `steam-big-picture` + in the foreground and continues when the client exits ("Exit Steam"), and + `session-watcher` subscribes to sway workspace-focus events ("Exit Big Picture mode", + alt-tab, the remote's channel keys, HA's Screen select — Steam is still running, so + the first trigger never fires). Both call an idempotent `media-session start`, guarded + per-app by `pgrep` and as a whole by `flock`, so whichever fires first wins. +6. **Window-title matching was rejected.** The obvious implementation — poll for a window + titled "Steam Big Picture Mode" — loses to Steam client rewrites, and a media session + that silently stops appearing after a Steam update reads as a broken image rather than + a moved string. A sway workspace name is a contract this repo owns and can only change + by editing two files together. +7. **Re-entering Steam tears nothing down.** People play music over games on purpose, and + killing a running Spotify because a game started would lose whatever was playing. + `media-session stop` exists and is wired to one explicit HA button; nothing calls it + automatically, and neither direction of the Mode select does either. +8. **Prism Launcher runs *through* Steam.** Launched directly it gets a Flatpak window + and a Minecraft that sees a raw evdev pad. `steam-shortcut-prism` writes Prism into + the user's binary `shortcuts.vdf` as a non-Steam game and `prism-launch` starts it via + `steam://rungameid/…`, so it runs inside the Steam Runtime with **Steam Input active** + — configurable pads with per-game bindings, a working overlay, gyro/back-buttons, and + an entry in the Big Picture library. This cannot be baked into the ISO (`shortcuts.vdf` + lives under a `userdata//` that does not exist until somebody logs in, and no + Steam credentials go into an image), so it runs per-session, idempotently, and + **before Steam starts** — Steam rewrites that file from memory on exit, so anything + written under a running client is discarded. It falls back to a direct launch when + Steam is absent or nobody has logged in, and refuses to rewrite a `shortcuts.vdf` that + already holds other entries rather than risk eating them. +9. **`enable_installer` is `true` here, unlike every other kiosk.** Every other kiosk in + this project is stateless by design and boots live. This one is not: a live system + keeps its writable layer in RAM, so a Steam library and a few modpacks — hundreds of + gigabytes — would vanish on reboot. `validate-config.py` warns when this type has the + installer off. +10. **`steamtv-agent` adds one genuinely new entity to the kiosk vocabulary**: a + **session-mode sensor** (`gaming`/`steam`/`media`/`idle`, read from two cheap local + facts — is Steam's process alive, and which workspace is focused), which is what lets + HA answer "is somebody playing?" without guessing from power draw: don't dim the room + mid-game, don't announce the doorbell over a raid, count the room as occupied though + nobody has moved in forty minutes. The rest — media transport, volume, audio-output + select, workspace select, launch buttons, CEC display switch — is the thin client's + surface, over the same MQTT-only control boundary. + --- -## 4. Open decisions (Phases 6, 11–20) +## 4. Open decisions (Phases 6, 11–21) These need a decision before their respective implementation steps can be built — everything above is written to accommodate any answer, but nothing should be built against an unresolved item. @@ -983,3 +1063,4 @@ These need a decision before their respective implementation steps can be built 40. **Whether a vision model can identify a grocery item in a moving hand at doorway distance** (new, Phase 17) — the assumption the entire `/doorway-event` path rests on, and a materially harder task than the kitchen display's held-still-against-a-plain-background one. It is also the cheapest open decision on this list to close: point the kitchen's existing webcam at somebody walking past with a carton and run `/identify` on the frames. Until then the door sensors are the part worth deploying, and `PANTRY_DOOR_APPLIANCES` entries can be configured without a camera — see `docs/fridge-item-location.md`, whose recommended order (sensors, then a month of living with them, *then* one camera) the code deliberately does not shortcut. 41. **Whether `groceries_out_of_place` can tell a carton of milk from a fruit bowl** (new, Phase 20) — its prompt lists what to ignore precisely because the general question gets a YES for any normal kitchen, but that is a mitigation written blind. The failure mode is not a missed chore, it is a false one every two hours, which is how a household learns to ignore the notification channel entirely. Point it at a *clean* counter for a day before trusting a YES, and note this is the first chore type whose false positives cost more than its false negatives. 42. **Contact-sensor battery life on an appliance door is unverified** (new, Phase 17) — a fridge door opens far more often than the window these sensors are sold for, and the sensor sits in a cold, humid draught even when mounted outside the compartment (which `docs/components.md` says to do, for the separate reason that a coin cell at −18 °C is a false economy). Check one after a month before buying more. +43. **Nothing in `hosts/steam-tv-box/` has been built, flashed, or booted, and no hardware has been chosen** (new, Phase 21) — the standing fleet-wide caveat, but it bites harder here because every claim this host makes is a hardware claim. Four things are specifically reasoned rather than verified, in rough order of how loudly they fail: the **`shortcuts.vdf` binary format and non-Steam AppID derivation** (CRC32 of `"Exe"`+`AppName` with the high bit set — the long-standing community format, documented by Valve nowhere; if it is wrong, Prism simply never appears in the library and nothing logs why), **`gamescope`'s availability** in trixie (the hook installs it if apt has it and `steam-big-picture` falls back to plain Xwayland if not, so this costs polish rather than function), the **Flathub application IDs** (`com.spotify.Client`, `org.prismlauncher.PrismLauncher` — same unverified-app-ID caveat as #17), and the **NVIDIA driver package names** for the target release. The cheapest of these to close is the first: log into Steam once, restart the session, and look at the Big Picture library. diff --git a/hosts/steam-tv-box/README.md b/hosts/steam-tv-box/README.md new file mode 100644 index 0000000..d7c1dde --- /dev/null +++ b/hosts/steam-tv-box/README.md @@ -0,0 +1,290 @@ +# Steam TV box + +A living-room machine that boots straight into **Steam Big Picture** and runs games +**locally** — real GPU, real CPU, games installed on this box. When you leave Big +Picture it becomes the same media station as every other monitor client in the house: +Spotify, a browser with uBlock Origin, and a general mp3/mp4 player. + +**This is not the thin client.** `hosts/thin-client/` runs *Steam Link*, which streams +a game rendered on some other PC and needs no graphics performance at all. This image +is the other end of that: it is the machine that renders. Different distro release, +different driver stack, different session. + +What ends up on the image: + +| | | +|---|---| +| Compositor | Sway, no bars, no lock screen, workspaces `1:steam` / `2:games` / `3:web` / `4:media` / `5:music` | +| Autologin | greetd, `default_session` straight into `/usr/local/bin/kiosk-session` | +| Default app | **Steam Big Picture** (`-gamepadui`), under gamescope when available | +| Gaming | Native `steam-installer` + i386 multiarch + full Mesa/Vulkan stack, `steam-devices` udev rules, gamemode, MangoHud | +| Minecraft | **Prism Launcher** (Flathub), registered as a non-Steam game so it runs *through* Steam with Steam Input live | +| Browser | Firefox ESR, minimal chrome, uBlock Origin + SponsorBlock force-installed | +| Music | Spotify (Flathub, the real GUI client) | +| Player | mpv + mpv-mpris, opened idle on `4:media` | +| Audio | PipeWire/WirePlumber, persistent output selection from HA | +| Remote control (human) | wayvnc on `0.0.0.0:5900`, authentication required, fails closed | +| Remote control (HA/LLM) | `steamtv-agent`, a systemd service publishing HA MQTT-discovery entities | +| TV power | HDMI-CEC (`cec-ctl`) + Sway DPMS, driven by HA presence | +| Debian release | **trixie**, not the household default — see below | + +## The one design decision worth reading + +**The media apps do not exist until you leave Big Picture.** + +The box boots to Steam and nothing else. No Firefox, no Spotify, no mpv. They are +started the first time somebody actually leaves Big Picture, and they stay up from then +on. + +That is not tidiness. This is the only machine in the project where a background +browser and a background Electron app cost something you can see: they hold GPU memory, +they wake the CPU on timers, and Spotify keeps an audio stream open that turns up as +stutter in a title that is already frame-limited. A gaming session should not pay for +media features it is not using. + +There are **two triggers**, because there are two different ways to leave and neither +one can see the other: + +| Way out | What notices | File | +|---|---|---| +| "Exit Steam", or the client crashing | `steam-big-picture` runs in the foreground; when it returns, the wrapper carries on to the next line | `configs/session/steam-session` | +| "Exit Big Picture mode", alt-tab, the remote's channel keys, HA's Screen select | a sway IPC subscription to workspace-focus events | `configs/session/session-watcher` | + +Both call `media-session start`, which is idempotent — every launch is guarded by its +own `pgrep` and the whole path holds an `flock` — so whichever fires first wins and the +second is free. Leaving and re-entering Big Picture repeatedly costs three `pgrep`s. + +**Deliberately rejected:** matching on a window titled "Steam Big Picture Mode". Steam's +window titles, classes, and whether Big Picture is even a separate window have all +changed across client rewrites, and a media session that silently stops appearing after +a Steam update looks like a broken image rather than a moved string. A sway workspace +name is a contract this repo owns. + +**Going back into Steam does not stop anything.** People play music over games on +purpose, and killing a running Spotify because a game started would lose whatever was +playing. `media-session stop` exists, is wired to an HA button ("Stop media apps"), and +nothing calls it automatically. + +## Prism Launcher runs through Steam + +Launched directly, Prism gets you a Flatpak window and a Minecraft that sees a raw +evdev gamepad — which is to say, no controller support worth the name. So it does not +launch directly. `/usr/local/bin/steam-shortcut-prism` registers Prism as a **non-Steam +game** in `shortcuts.vdf`, and `/usr/local/bin/prism-launch` asks Steam to run it via +`steam://rungameid/…`. + +What that buys: the game runs inside the Steam Runtime with **Steam Input active**, so +the Steam Controller API is present for it. Pads arrive as configurable Steam +controllers with per-game bindings, the overlay works, gyro and back-buttons on +Deck-style pads work, and Prism shows up in the Big Picture library instead of being a +hole you fall out of the UI into. + +Two consequences you need to know about: + +- **It cannot be baked into the ISO.** `shortcuts.vdf` lives under + `~/.steam/steam/userdata//config/`, which does not exist until somebody has + logged into Steam on the machine — and no Steam credentials go into an image. So + registration happens per-session, is idempotent, and quietly no-ops until the first + login. **After you first log into Steam, restart the session (or reboot) once.** +- **It runs before Steam starts, from `steam-session`.** Steam reads `shortcuts.vdf` at + startup and rewrites it from memory at shutdown, so anything written underneath a + running client is silently discarded. + +If Steam is not installed, or nobody has logged in yet, `prism-launch` falls back to +running Prism directly. That is a working Minecraft with worse controller support, which +beats refusing to start. + +> **VERIFY:** the binary VDF encoding and the non-Steam AppID derivation (CRC32 of +> `"Exe"`+`AppName`, high bit set) are the long-standing community format — Valve +> documents neither, and neither was checked against a real client from this +> environment. The test is simply: does *Prism Launcher* appear in the Big Picture +> library, and does the pad work in Minecraft. `steam-shortcut-prism` refuses to +> rewrite a `shortcuts.vdf` that already has other entries in it (it backs it up and +> tells you to add the entry by hand) rather than risk eating shortcuts you added +> yourself. + +## Why trixie and not the household release + +`household.debian_release` is `bookworm`, whose Mesa is 22.3. That is fine for every +other host here — they are a browser and a Python agent, and none of them care what +Mesa they have. It is not fine for the one machine that renders games. + +So this kiosk sets a per-kiosk override: + +```json +"debian_release": "trixie" +``` + +`tools/config-export.py` emits `CORE_KIOSK_DEBIAN_RELEASE` for every kiosk, falling back +to the household value, so the override is available to any builder but only this one +uses it. `validate-config.py` warns if a `steam-tv-box` is pinned to bookworm. The +graphics stack is the one thing on this box that cannot be fixed later by editing a +config file and rebuilding a service. + +## Before you build + +**No hardware has been chosen and nothing here has been booted on real metal.** That is +the same standing caveat as the rest of the fleet, and it bites harder on this host +than on the others: everything about a gaming box is a hardware claim. + +Set these on the kiosk entry in `CoreSystemConfig.json` (see +`CoreSystemConfig.json.template` for a complete example): + +| Key | What to put in it | +|---|---| +| `type` | `steam-tv-box` | +| `gpu_vendor` | `amd`, `intel` or `nvidia`. **Not detected** — the build host cannot see this machine, and installing the NVIDIA driver on an AMD box actively breaks it. `amd`/`intel` need nothing beyond Mesa; `nvidia` pulls the non-free driver and sets `WLR_NO_HARDWARE_CURSORS=1`. | +| `debian_release` | `trixie` | +| `enable_installer` | `true`, unlike every other kiosk here — see below | +| `enable_cec` | `true` unless the set ignores HDMI-CEC | +| `room` | the HA area, e.g. `living_room` | + +Then: + +```sh +sudo -E tools/build-steam-tv-box-iso.sh # the only steam-tv-box in the config +sudo -E tools/build-steam-tv-box-iso.sh # a specific one, if several +``` + +The build is noticeably longer than the other images: i386 multiarch plus Steam's +dependency chain is a few hundred extra packages. + +### `enable_installer` should be true here + +Every other kiosk in this project boots live, because every other kiosk is stateless by +design. This one is not. A live system keeps its writable layer in RAM, so a Steam +library and a few modpacks would vanish on reboot — and they are hundreds of gigabytes. + +Install to the disk, and give `/home` its own large partition, or mount a games disk at +`/home//Games` (created by `0100-user-setup.hook.chroot`, and the +directory Prism's Flatpak is granted access to). + +The validator warns if `enable_installer` is false on this type. + +## After the first boot + +1. **Confirm it is actually accelerated** before blaming any game. From the maintenance + shell (`Super+Shift+Ctrl+M`): + ```sh + vulkaninfo | head -n 20 # should name your GPU, not llvmpipe + glxinfo -B # same + ``` +2. **Set the wayvnc password.** wayvnc will not be running until you do — it fails + closed, same as the thin client: + ```sh + sudo sh -c 'openssl rand -base64 24 > /etc/wayvnc/wayvnc-password' + sudo chmod 600 /etc/wayvnc/wayvnc-password + sudo chown : /etc/wayvnc/wayvnc-password + swaymsg reload + ``` + It is for administering the box, not for playing over: wayvnc streams the + compositor, so a game at 120fps arrives as a slideshow. That is what a + screen-scraping protocol does, not a bug to work around. +3. **Log into Steam**, then restart the session once so the Prism shortcut registers. +4. **Set the audio output.** On a TV box the right sink is usually HDMI, and if the set + was switched off at boot WirePlumber may have defaulted to a headphone jack with + nothing in it — the classic "the game has no sound" report. Pick it in HA's *Audio + output* select; the choice persists across reboots (it is stored as a WirePlumber + `node.name`, which is stable, not as a numeric ID, which is not). +5. **Leave Big Picture** and confirm Firefox, mpv and Spotify appear. Then go back into + a game and confirm they are *not* killed. +6. **Pull the power on the container host** and re-check: the box must still boot into + Big Picture and play a game. `steamtv-agent` connects asynchronously and keeps + retrying. + +## Home Assistant entities + +Published by `steamtv-agent` via MQTT discovery, under a device named after the kiosk's +`friendly_name`: + +| Entity | Type | Notes | +|---|---|---| +| Session | sensor | `gaming` / `steam` / `media` / `idle`, plus `steam_running`, `media_running`, `workspace` attributes | +| Mode | select | `gaming` / `media`. Both directions are **additive** — see below | +| Screen | select | the five workspaces | +| Launch Steam Big Picture / Prism Launcher / web browser / media player / Spotify | buttons | | +| Stop media apps | button | the only thing in the whole surface that shuts anything down | +| Audio output | select | persisted across reboots | +| Display | switch | HDMI-CEC standby + Sway DPMS. The **panel**, never the machine | +| Volume | number | | +| Playback state | sensor | mpv/Spotify via MPRIS | +| Play/pause, Next, Previous, Stop | buttons | | + +**Why *Mode* is additive in both directions:** choosing `gaming` launches or focuses +Steam and leaves the media apps alone (music over a game is a thing people want). +Choosing `media` starts the media session and does not quit Steam, because quitting +Steam from a phone while somebody is mid-match is not something this should be able to +do by accident. + +**Playback state reads `off` for most of a gaming session.** That is correct, not +broken: the media players genuinely do not exist yet. Steam exposes no MPRIS bus and a +game's audio is not something you "pause" — for a noisy game, the volume control is the +right surface. Watch *Session* for what the box is doing. + +## Security posture + +Same as every other kiosk here, and it matters more on this one, because this is the +machine with a Steam login and a games library on it. + +- **MQTT discovery is the only inbound control surface.** No HTTP listener, no websocket + server, no exposed Sway IPC socket, no shell endpoint. Every handler is a fixed, + enumerated action; a payload never becomes an argv element, a shell string or a URL + host. See `agent/steamtv_agent/mqtt_discovery.py`. +- The local LLM has no network path here. The only chain is: LLM tool call → Home + Assistant service call → MQTT → the dispatcher. +- Nothing listens on a port except sshd (key-only, root login off, passwords off) and + wayvnc (password mandatory, refuses to start without one). +- **Steam's remote-play ports are not opened by anything in this image.** If you want + them, that is a deliberate firewall decision, not a default this ships. Nothing here + is port-forwarded; remote access is WireGuard, same as the rest of the house. +- The built ISO contains the Wi-Fi PSK and MQTT credentials from + `CoreSystemConfig.json`. Treat it as a credential. `.gitignore` covers `iso-out/`. + +## Files + +``` +hosts/steam-tv-box/ +├── configs/ +│ ├── audio/audio-config.json template; the runtime copy lives in /var/lib +│ ├── firefox/ policies.json (uBlock Origin/SponsorBlock), chrome, launcher +│ ├── greetd/ autologin config + the session wrapper +│ ├── installer/preseed.cfg source; the builder generates live-build/config/preseed.cfg +│ ├── mpv/mpv.conf IPC socket + hwdec +│ ├── session/ +│ │ ├── steam-session boots Steam, starts the media session when it exits +│ │ ├── steam-big-picture gamescope-or-Xwayland launcher, idempotent +│ │ ├── steam-shortcut-prism registers Prism as a non-Steam game (Steam Input) +│ │ ├── session-watcher sway IPC: the other "left Big Picture" trigger +│ │ ├── media-session idempotent start/stop of the media half +│ │ ├── media-player mpv wrapper +│ │ ├── prism-launch routes through Steam; falls back to direct +│ │ └── spotify-launch Flathub Spotify +│ ├── sway/ the session config + display-toggle +│ └── wayvnc/ config + the fails-closed launcher +├── agent/ +│ ├── steamtv-agent.service +│ └── steamtv_agent/ main, sway_control, mqtt_discovery, mpris_bridge, +│ audio_control, runtime_state, display_power, session_mode +└── live-build/config/ + ├── package-lists/steam-tv-box.list.chroot + └── hooks/normal/ 0100-user-setup, 0250-wayvnc, 0200-greetd, + 0300-steam, 0400-flatpak-apps, 0700-steamtv-agent, + 0900-firefox +``` + +`live-build/config/includes.chroot/` and `live-build/config/preseed.cfg` are +**generated** — wiped and rewritten by the builder on every run. Edit `configs/`. + +## Status + +Written, syntax-checked, and validated through `tools/validate-config.py` and +`tools/config-export.py`. **Never built, never flashed, never booted.** No hardware has +been chosen. Specific things that are reasoned rather than verified: + +- `gamescope`'s availability in the target Debian release (the hook installs it if + present, `steam-big-picture` falls back to plain Xwayland if not). +- The Flathub application IDs (`com.spotify.Client`, + `org.prismlauncher.PrismLauncher`) — believed correct, not checked against Flathub. +- The `shortcuts.vdf` binary format and non-Steam AppID derivation (see above). +- The NVIDIA driver package names for the target release. +- Whether the television honours CEC standby at all. diff --git a/hosts/steam-tv-box/agent/requirements.txt b/hosts/steam-tv-box/agent/requirements.txt new file mode 100644 index 0000000..843bc43 --- /dev/null +++ b/hosts/steam-tv-box/agent/requirements.txt @@ -0,0 +1,8 @@ +# The ISO installs this from apt (python3-paho-mqtt) rather than pip — see +# live-build/config/package-lists/steam-tv-box.list.chroot. This file is for running +# the agent outside the image (development, a venv on a test box). The code works +# against both the 1.x and 2.x callback APIs. +paho-mqtt>=1.6 + +# Config is read from /etc/steamtv-agent/config.env by a small parser in main.py, so +# there is no python-dotenv dependency. diff --git a/hosts/steam-tv-box/agent/steamtv-agent.service b/hosts/steam-tv-box/agent/steamtv-agent.service new file mode 100644 index 0000000..df7f822 --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv-agent.service @@ -0,0 +1,39 @@ +[Unit] +Description=Steam TV box agent (Home Assistant MQTT control surface for Sway) +Documentation=file:///opt/steamtv-agent +# A system unit rather than a `systemctl --user` unit: the agent has to be reachable +# over MQTT whether or not a graphical session is up, and it has to survive sway +# restarting (a user unit bound to graphical-session.target would go down with it). +# The session-scoped bits it needs — SWAYSOCK, XDG_RUNTIME_DIR, +# DBUS_SESSION_BUS_ADDRESS — are derived at call time from the kiosk user's runtime +# directory in sway_control.SwayControl.session_env(), which also means they are +# re-resolved after every sway restart instead of being frozen at unit start. +# +# Deliberately NOT After=network-online.target: this box must boot to Big Picture and +# play a game with the container host powered off, same rule as the thin client's +# Phase 11.10. +After=network.target + +[Service] +Type=simple +User=@KIOSK_USERNAME@ +Group=@KIOSK_USERNAME@ +WorkingDirectory=/opt/steamtv-agent +Environment=PYTHONPATH=/opt/steamtv-agent +Environment=PYTHONUNBUFFERED=1 +EnvironmentFile=-/etc/steamtv-agent/config.env +ExecStart=/usr/bin/python3 -m steamtv_agent.main +Restart=always +RestartSec=5 +# Deliberately gentle on shutdown, and more so than the other agents: this process is +# the parent of nothing important, but `media-session stop` reaches Firefox and Spotify, +# both of which persist state on exit. +KillMode=mixed +TimeoutStopSec=20 +# Sandboxing stops here on purpose: this unit's whole job is to spawn GUI child +# processes (Firefox, the Spotify and Prism flatpaks, Steam) that need the real /tmp, +# the user's home, and the session bus. +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/hosts/steam-tv-box/agent/steamtv_agent/__init__.py b/hosts/steam-tv-box/agent/steamtv_agent/__init__.py new file mode 100644 index 0000000..ea8f7d0 --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv_agent/__init__.py @@ -0,0 +1,3 @@ +"""steamtv-agent — Home Assistant MQTT control surface for the Steam TV box.""" + +__version__ = "0.1.0" diff --git a/hosts/steam-tv-box/agent/steamtv_agent/audio_control.py b/hosts/steam-tv-box/agent/steamtv_agent/audio_control.py new file mode 100644 index 0000000..20093e5 --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv_agent/audio_control.py @@ -0,0 +1,211 @@ +"""Persistent audio-output selection via WirePlumber's wpctl. + +Two identifiers are in play and confusing them is the main failure mode here. `wpctl` +takes a numeric object ID, which WirePlumber reassigns on every boot and every device +hotplug — useless for persistence. `node.name` is stable across both, which is what +audio-config.json stores. Home Assistant is shown neither: the select lists the human +descriptions ("Built-in Audio Analog Stereo"), because those are what someone picking +an output in a mobile app can actually recognise. + +Per the security note in mqtt_discovery.py, the payload from HA is only ever used to +look up an entry in the sink table parsed from `wpctl status`. It never reaches a +command line: what does is the integer ID that lookup returns. +""" + +from __future__ import annotations + +import logging +import re +import subprocess +from dataclasses import dataclass + +from .runtime_state import ensure_runtime_copy, load_json, save_json + +log = logging.getLogger(__name__) + +CONFIG_FILENAME = "audio-config.json" +SYSTEM_DEFAULT = "system-default" + +# `wpctl status` prints a tree; sink rows look like +# │ * 47. Built-in Audio Analog Stereo [vol: 0.65] +# with a leading "*" on the current default. The box-drawing prefix varies between +# WirePlumber versions, so the row is matched from the ID onwards rather than anchored. +_SINK_ROW = re.compile(r"(\*?)\s*(\d+)\.\s+(.*?)(?:\s+\[vol:.*)?$") +_SECTION = re.compile(r"^\s*[^\w]*\s*(\w[\w /]*):\s*$") + + +@dataclass(frozen=True) +class Sink: + node_id: int + description: str + node_name: str + is_default: bool + + +class AudioControl: + def __init__(self, env_provider): + # wpctl talks to the user's PipeWire session, so it needs the same XDG_RUNTIME_DIR + # derivation SwayControl uses for swaymsg and playerctl. + self._env_provider = env_provider + self.config_path = ensure_runtime_copy(CONFIG_FILENAME) + config = load_json(self.config_path) + self.preferred_sink = str(config.get("preferred_sink") or "") + self.fallback = str(config.get("fallback") or SYSTEM_DEFAULT) + self._sinks: list[Sink] = [] + + # --- wpctl -------------------------------------------------------------- + def _wpctl(self, *args: str) -> str | None: + try: + result = subprocess.run( + ["wpctl", *args], + env=self._env_provider(), + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + log.warning("wpctl %s failed: %s", " ".join(args), exc) + return None + if result.returncode != 0: + log.warning("wpctl %s: %s", " ".join(args), result.stderr.strip()) + return None + return result.stdout + + def _node_name(self, node_id: int) -> str: + output = self._wpctl("inspect", str(node_id)) or "" + for line in output.splitlines(): + if "node.name" in line: + _, _, value = line.partition("=") + return value.strip().strip('"') + return "" + + def list_sinks(self) -> list[Sink]: + output = self._wpctl("status") + if output is None: + self._sinks = [] + return self._sinks + + sinks: list[Sink] = [] + in_sinks = False + for line in output.splitlines(): + section = _SECTION.match(line) + if section: + # Sources, Filters and Streams also carry numbered rows, so the parser + # has to stop at the next heading rather than read to end of output. + in_sinks = section.group(1).strip() == "Sinks" + continue + if not in_sinks: + continue + match = _SINK_ROW.search(line) + if not match: + continue + node_id = int(match.group(2)) + sinks.append( + Sink( + node_id=node_id, + description=match.group(3).strip() or f"Sink {node_id}", + node_name=self._node_name(node_id), + is_default=match.group(1) == "*", + ) + ) + + self._sinks = sinks + return sinks + + # --- entity surface ----------------------------------------------------- + def options(self) -> list[str]: + """Select options for HA: descriptions, plus the "let WirePlumber decide" entry.""" + seen: dict[str, int] = {} + result = [SYSTEM_DEFAULT] + for sink in self._sinks: + label = sink.description + if label in seen: + # Two identical descriptions (e.g. a pair of matched HDMI outputs) would + # otherwise collapse into one unselectable option. + seen[label] += 1 + label = f"{label} ({seen[label]})" + else: + seen[label] = 1 + result.append(label) + return result + + def current_option(self) -> str: + for sink in self._sinks: + if self.preferred_sink and sink.node_name == self.preferred_sink: + return sink.description + if self.preferred_sink: + # Configured but not present right now — say so rather than silently + # reporting whatever WirePlumber happens to be using. + return SYSTEM_DEFAULT + for sink in self._sinks: + if sink.is_default: + return sink.description + return SYSTEM_DEFAULT + + def _find(self, option: str) -> Sink | None: + for sink in self._sinks: + if sink.description == option: + return sink + # Match the disambiguating "(2)" suffix options() may have added. + base = re.sub(r"\s+\(\d+\)$", "", option) + matches = [s for s in self._sinks if s.description == base] + return matches[0] if matches else None + + def apply_preferred(self) -> None: + """Called once at startup, after the sink list has been read.""" + if not self.preferred_sink: + log.info("no preferred audio sink configured; leaving WirePlumber's default") + return + + for sink in self._sinks: + if sink.node_name == self.preferred_sink: + self._set_default(sink) + return + + # Never fatal: a docked machine booted undocked, or an HDMI display that is off, + # legitimately has no such sink. The preference stays on file for next boot. + log.warning( + "preferred audio sink %r is not currently available; falling back to %s", + self.preferred_sink, + self.fallback, + ) + + def _set_default(self, sink: Sink) -> None: + log.info("setting default audio sink to %s (id=%s)", sink.description, sink.node_id) + self._wpctl("set-default", str(sink.node_id)) + + def select(self, option: str) -> str: + """Handle the HA select. Returns the option to publish back as state.""" + option = option.strip() + self.list_sinks() + + if option == SYSTEM_DEFAULT: + self.preferred_sink = "" + self._save() + log.info("audio output preference cleared; WirePlumber's default applies") + return self.current_option() + + sink = self._find(option) + if sink is None: + log.warning("ignoring unknown audio output %r", option) + return self.current_option() + + self._set_default(sink) + if sink.node_name: + self.preferred_sink = sink.node_name + self._save() + else: + # Without a node.name there is nothing stable to persist; the change still + # applies to this boot. + log.warning( + "sink %r has no node.name; the change applies now but will not survive a reboot", + sink.description, + ) + return sink.description + + def _save(self) -> None: + save_json( + self.config_path, + {"preferred_sink": self.preferred_sink, "fallback": self.fallback}, + ) diff --git a/hosts/steam-tv-box/agent/steamtv_agent/display_power.py b/hosts/steam-tv-box/agent/steamtv_agent/display_power.py new file mode 100644 index 0000000..72b915f --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv_agent/display_power.py @@ -0,0 +1,146 @@ +"""Turning the attached TV on and off, so an empty room does not power a panel. + +The television this box is plugged into draws 60-150 W while it shows a paused game +or an empty Big Picture that nobody is in the room to look at. This module is what +Home Assistant calls when presence says the room is occupied or empty — the decision +lives in HA (an area's occupancy, the same presence system everything else here +uses), and the doing lives here. + +Note what this deliberately does not do: it never touches the machine's own power. +Suspending a box mid-game loses the game. Turning the panel off while the machine +keeps running is the whole point — see also /usr/local/bin/display-toggle, the local +no-network version of this, bound to the remote's power button. + +TWO MECHANISMS, IN THIS ORDER +----------------------------- +1. **HDMI-CEC** (`cec-ctl`, from v4l-utils). This box is the HDMI *source*, + so it can put the display into standby and wake it again over the HDMI cable + itself. That is the one that actually saves the panel's power, and it needs no + network path to the TV, no pairing, no credentials, and no account — it keeps + working with the LAN down, which is this project's whole posture. Android TV + and Google TV sets implement CEC as "HDMI-CEC", "Bravia Sync", "Anynet+", + "Simplink" and a dozen other brand names for the same standard; it usually has + to be enabled in the TV's settings once. +2. **Sway DPMS** (`swaymsg output power on|off`) as the fallback, and as a + belt-and-braces companion: it stops the compositor driving pixels and drops the + HDMI signal, which most panels treat as "go to sleep" on their own. It always + works because it needs nothing but the compositor already running here — but on + its own it may leave a TV showing a "no signal" banner rather than sleeping, + which is why CEC is tried first. + +Both are attempted on every call unless CEC is switched off, because they fail in +different ways and neither reports reliably. + +ONE CAVEAT SPECIFIC TO THIS IMAGE: when Steam is running under gamescope, gamescope +owns the output, and `swaymsg output … power off` blanks gamescope's surface rather +than the game inside it. CEC is what actually darkens the panel in that case, which +is another reason it is tried first here rather than kept as the fallback. + +WHAT "OFF" HONESTLY MEANS +------------------------- +Standby, not disconnected. A TV in CEC standby still draws roughly half a watt to +keep listening on the HDMI line — that is what makes waking it possible at all. +This turns 60-150 W of lit panel into ~0.5 W of standby; it is not a smart plug +and does not pretend to be. If a set is one of the ones that ignores CEC standby +entirely, you will see it immediately (the panel stays lit) — that is what the +verification note in hosts/steam-tv-box/README.md is for. + +SECURITY POSTURE, UNCHANGED +--------------------------- +This is another enumerated MQTT command, exactly like the workspace switch and the +canvas buttons: HA -> MQTT -> a fixed action here. A payload never becomes an argv +element — `set_power()` takes a boolean, and the device names come from local +configuration, never from the message. See mqtt_discovery.py's module docstring. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess + +log = logging.getLogger(__name__) + +CEC_TIMEOUT_SECONDS = 10 + + +class DisplayPower: + def __init__(self, sway, cec_device: str | None = None, outputs: str = "*", + use_cec: bool = True): + self.sway = sway + # The CEC adapter, e.g. /dev/cec0. Most systems have exactly one and cec-ctl + # finds it on its own; this is for the machine that has two. + self.cec_device = cec_device or os.environ.get("CEC_DEVICE", "") + # Which Sway outputs to power down. "*" is every output, which is right for a + # box driving one TV; name an output (e.g. "HDMI-A-1") on a machine + # where only one of several screens is the TV. + self.outputs = outputs or "*" + self.use_cec = use_cec + self.state = True + + # --- CEC ---------------------------------------------------------------- + def _cec(self, *args: str) -> bool: + binary = shutil.which("cec-ctl") + if not binary: + log.info("cec-ctl is not installed; falling back to DPMS only") + return False + + command = [binary] + if self.cec_device: + command += ["-d", self.cec_device] + command += list(args) + + try: + result = subprocess.run( + command, capture_output=True, text=True, timeout=CEC_TIMEOUT_SECONDS + ) + except (OSError, subprocess.SubprocessError) as exc: + log.warning("cec-ctl %s failed: %s", " ".join(args), exc) + return False + + if result.returncode != 0: + log.warning("cec-ctl %s: %s", " ".join(args), (result.stderr or "").strip()) + return False + return True + + # --- the one public action --------------------------------------------- + def set_power(self, on: bool) -> bool: + """Turn the display on or off. Returns the state it believes it left it in. + + Deliberately not idempotent-by-early-return: HA asking for "on" when this + object already thinks it is on must still send the wake, because the TV may + have been turned off with its own remote and nothing here would know. The + state field is for reporting, never for skipping work. + """ + log.info("display: turning the panel %s", "on" if on else "off") + + if self.use_cec: + # --to 0 addresses the TV specifically (logical address 0) rather than + # broadcasting, so a soundbar or receiver on the same bus is left alone. + if on: + self._cec("--to", "0", "--image-view-on") + # Ask to become the active source too: waking a TV that then shows a + # different input is the same as not waking it. + self._cec("--to", "0", "--active-source", "phys-addr=0.0.0.0") + else: + self._cec("--to", "0", "--standby") + + # Always also drive the compositor: on a set that ignores CEC this is what + # stops it displaying, and on one that honours CEC it stops this machine + # rendering to a panel nobody is looking at. + self.sway.swaymsg("output", self.outputs, "power", "on" if on else "off") + + self.state = on + return self.state + + def handle_command(self, payload: str) -> bool: + """MQTT payload -> action. Anything that isn't a known ON/OFF word is ignored + rather than guessed at, per the enumerated-command rule.""" + value = (payload or "").strip().upper() + if value in ("ON", "TRUE", "1"): + return self.set_power(True) + if value in ("OFF", "FALSE", "0"): + return self.set_power(False) + log.warning("display: ignoring unknown power payload %r", payload) + return self.state diff --git a/hosts/steam-tv-box/agent/steamtv_agent/main.py b/hosts/steam-tv-box/agent/steamtv_agent/main.py new file mode 100644 index 0000000..4e58b4d --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv_agent/main.py @@ -0,0 +1,292 @@ +"""steamtv-agent entrypoint.""" + +from __future__ import annotations + +import itertools +import logging +import os +import signal +import socket +import sys +import threading +from dataclasses import dataclass, field + +import paho.mqtt.client as mqtt + +from .audio_control import AudioControl +from .display_power import DisplayPower +from .mpris_bridge import MprisBridge +from .mqtt_discovery import Discovery +from .session_mode import MODES, SessionMode +from .sway_control import WS_GAMES, WS_MEDIA, WS_MUSIC, WS_STEAM, WS_WEB, SwayControl + +CONFIG_PATH = os.environ.get("STEAMTV_AGENT_CONFIG", "/etc/steamtv-agent/config.env") + +CONFIG_KEYS = ( + "MQTT_BROKER_HOST", + "MQTT_BROKER_PORT", + "MQTT_USERNAME", + "MQTT_PASSWORD", + "HA_URL", + "KIOSK_USERNAME", + "STEAMTV_NAME", + "STEAMTV_ROOM", + "GPU_VENDOR", + "ENABLE_CEC", +) + +WORKSPACES = (WS_STEAM, WS_GAMES, WS_WEB, WS_MEDIA, WS_MUSIC) + +# How often the session-mode sensor is recomputed. Slower than the MPRIS poll on +# purpose: "is a game running" changes on a timescale of minutes, and each check costs +# two pgreps and a swaymsg. Riding the MPRIS loop rather than adding a second thread. +SESSION_POLL_EVERY = 5 + +log = logging.getLogger("steamtv-agent") + + +@dataclass(frozen=True) +class App: + name: str + command: list[str] = field(default_factory=list) + process_pattern: str | None = None + workspace: str | None = None + focus_criteria: str | None = None + icon: str = "mdi:application" + + +def load_config(path: str = CONFIG_PATH) -> dict[str, str]: + values: dict[str, str] = {} + try: + with open(path, encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + values[key.strip()] = value.strip().strip('"').strip("'") + except OSError as exc: + log.warning("could not read %s (%s); falling back to the environment", path, exc) + + for key in CONFIG_KEYS: + if key in os.environ: + values[key] = os.environ[key] + + return values + + +def build_apps(_config: dict[str, str]) -> dict[str, App]: + """The launch table. Every command is built from constants in this file — a payload + only ever selects a key here, it never contributes an argv element. See the security + note in mqtt_discovery.py.""" + return { + "steam": App( + name="Steam Big Picture", + command=["/usr/local/bin/steam-big-picture"], + # No process_pattern: steam-big-picture does its own already-running check + # and knows how to ask a live client to re-open Big Picture, which is more + # than "focus the window" can do. + workspace=WS_STEAM, + icon="mdi:steam", + ), + "prism": App( + name="Prism Launcher", + # Goes through Steam (steam://rungameid/…) so Steam Input and the Steam + # Controller API are live for Minecraft — see the script's header. Which is + # also why there is no workspace here: when Steam runs it, the window + # belongs to Steam's own workspace, and forcing 2:games would move it away + # from the client that owns it. 2:games is where it lands only on the + # direct-launch fallback path, and sway puts it there by focus anyway. + command=["/usr/local/bin/prism-launch"], + process_pattern="org.prismlauncher.PrismLauncher", + focus_criteria='app_id="org.prismlauncher.PrismLauncher"', + icon="mdi:minecraft", + ), + "web_browser": App( + name="web browser", + command=["/usr/local/bin/web-browser"], + workspace=WS_WEB, + icon="mdi:web", + ), + "media_player": App( + name="media player", + command=["/usr/local/bin/media-player"], + process_pattern="media-player-idle", + workspace=WS_MEDIA, + focus_criteria='app_id="mpv"', + icon="mdi:play-box", + ), + "spotify": App( + name="Spotify", + command=["/usr/local/bin/spotify-launch"], + process_pattern="com.spotify.Client", + workspace=WS_MUSIC, + focus_criteria='app_id="com.spotify.Client"', + icon="mdi:spotify", + ), + } + + +def make_client(client_id: str) -> mqtt.Client: + # paho-mqtt 2.x requires an explicit callback API version; older Debian releases + # ship 1.6.x and have no such argument. VERSION1 is requested when available so the + # callback signatures below are identical under both. + callback_api = getattr(mqtt, "CallbackAPIVersion", None) + if callback_api is not None: + return mqtt.Client(callback_api.VERSION1, client_id=client_id) + return mqtt.Client(client_id=client_id) + + +def main() -> int: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stdout, + ) + + config = load_config() + hostname = socket.gethostname() + node_id = "".join(c if c.isalnum() else "_" for c in hostname).strip("_") or "steamtv" + friendly_name = config.get("STEAMTV_NAME") or f"Steam TV box ({hostname})" + # The HA area this device sits in, published as suggested_area — see + # docs/rooms-and-endpoints.md. Blank is fine and means "no suggestion". + room = config.get("STEAMTV_ROOM", "") + + broker_host = config.get("MQTT_BROKER_HOST", "") + broker_port = int(config.get("MQTT_BROKER_PORT") or 1883) + + sway = SwayControl() + apps = build_apps(config) + audio = AudioControl(sway.session_env) + session = SessionMode(sway) + display = DisplayPower(sway, use_cec=config.get("ENABLE_CEC", "true") != "false") + + client = make_client(f"steamtv-agent-{node_id}") + if config.get("MQTT_USERNAME"): + client.username_pw_set(config["MQTT_USERNAME"], config.get("MQTT_PASSWORD") or None) + + discovery = Discovery(client, node_id, friendly_name, room) + mpris = MprisBridge(discovery.publish_media_state, sway.session_env) + + def on_launch(key: str) -> None: + app = apps[key] + sway.launch_app( + app.command, + process_pattern=app.process_pattern, + workspace=app.workspace, + focus_criteria=app.focus_criteria, + ) + if app.workspace: + discovery.publish_workspace(app.workspace) + + def on_workspace(payload: str) -> None: + name = payload.strip() + # Enumerated, never passed through: see the security note in mqtt_discovery.py. + if name not in WORKSPACES: + log.warning("ignoring unknown workspace %r", name) + return + sway.switch_workspace(name) + discovery.publish_workspace(name) + + def on_session_mode(payload: str) -> None: + mode = session.select(payload) + discovery.publish_session_state(mode, session.attributes()) + + def on_stop_media() -> None: + mode = session.stop_media() + discovery.publish_session_state(mode, session.attributes()) + + def on_audio_output(payload: str) -> None: + discovery.publish_audio_output(audio.select(payload)) + + def on_display(payload: str) -> None: + discovery.publish_display_power(display.handle_command(payload)) + + def on_connect(_client, _userdata, _flags, rc): + if rc != 0: + log.error("MQTT connection refused (rc=%s)", rc) + return + log.info("connected to MQTT broker %s:%s", broker_host, broker_port) + discovery.register_media_player(mpris.handle_command, mpris.set_volume) + discovery.register_app_launchers(apps, on_launch) + discovery.register_workspace_select(WORKSPACES, on_workspace, WS_STEAM) + discovery.register_session_mode(MODES, on_session_mode, on_stop_media) + # The sink list is read here rather than at construction because WirePlumber may + # not be up yet when this service starts (it is a system unit; the session is + # not). By the time the broker connects, the session normally is. + audio.list_sinks() + audio.apply_preferred() + discovery.register_audio_output(audio.options(), audio.current_option(), on_audio_output) + discovery.register_display_power(on_display, display.state) + discovery.subscribe_all() + discovery.publish_available(True) + discovery.publish_session_state(session.current(), session.attributes()) + + def on_disconnect(_client, _userdata, rc): + log.warning("disconnected from MQTT broker (rc=%s); paho will retry", rc) + + def on_message(_client, _userdata, message): + discovery.dispatch(message.topic, message.payload.decode("utf-8", "replace")) + + client.on_connect = on_connect + client.on_disconnect = on_disconnect + client.on_message = on_message + client.will_set(discovery.availability_topic, "offline", qos=1, retain=True) + + stop_event = threading.Event() + + def handle_signal(_signum, _frame): + stop_event.set() + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + if not broker_host: + log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH) + else: + # connect_async + loop_start, never a blocking connect(): this box must boot to + # Big Picture and play a game with the container host powered off. Same + # "reactive path never depends on a remote service" rule as the thin client's + # Phase 11.10 — and it matters more here, since nobody wants their console to + # need the house's server to be up. + client.connect_async(broker_host, broker_port, keepalive=60) + client.loop_start() + + log.info("steamtv-agent %s started (node_id=%s)", node_id, node_id) + + ticks = itertools.count(1) + + def poll_session() -> None: + if next(ticks) % SESSION_POLL_EVERY: + return + mode = session.current() + if mode == session.last_published: + return + session.last_published = mode + if broker_host: + discovery.publish_session_state(mode, session.attributes()) + + # The MPRIS bridge owns the main loop; the session sensor rides along on it rather + # than starting a second thread to do the same waiting. + original_poll = mpris.poll_once + + def poll_both() -> None: + original_poll() + poll_session() + + mpris.poll_once = poll_both # type: ignore[method-assign] + + try: + mpris.run_forever(stop_event) + finally: + log.info("shutting down") + if broker_host: + discovery.publish_available(False) + client.loop_stop() + client.disconnect() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hosts/steam-tv-box/agent/steamtv_agent/mpris_bridge.py b/hosts/steam-tv-box/agent/steamtv_agent/mpris_bridge.py new file mode 100644 index 0000000..7878092 --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv_agent/mpris_bridge.py @@ -0,0 +1,138 @@ +"""Bridges mpv's and Spotify's MPRIS state to the HA media_player entity via playerctl. + +Identical in shape and reasoning to +hosts/thin-client/agent/thinclient_agent/mpris_bridge.py — see that file's docstring +for why this polls via subprocess rather than holding a dbus connection open. + +PLAYER_PRIORITY lists mpv first, then the Spotify GUI client. That order is the answer +to "both are alive, which one does the remote's play button reach?", and mpv wins +because it is the one holding something somebody deliberately opened. + +What is NOT in that list is any game. Steam exposes no MPRIS bus, and a game's audio is +not something you "pause" from a phone — the volume controls in audio_control.py are +the right surface for a game making noise, not these transport buttons. + +This bridge reports "off" for most of a gaming session, and that is correct rather than +broken: on this image the media players genuinely do not exist until somebody leaves +Big Picture (see configs/session/media-session). The entity to watch for "what is this +box doing" is the session-mode sensor in session_mode.py. +""" + +from __future__ import annotations + +import logging +import subprocess +import threading + +log = logging.getLogger(__name__) + +PLAYER_PRIORITY = "mpv,spotify,%any" + +_STATUS_TO_HA = { + "Playing": "playing", + "Paused": "paused", + "Stopped": "idle", +} + +_METADATA_FORMAT = "{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}" + + +class MprisBridge: + def __init__(self, publish_state, env_provider, poll_interval: float = 2.0): + self._publish_state = publish_state + # playerctl needs DBUS_SESSION_BUS_ADDRESS, which this system service does not + # inherit; SwayControl.session_env() derives it from the kiosk user's runtime dir. + self._env_provider = env_provider + self._poll_interval = poll_interval + self._last_state: dict | None = None + + def _playerctl(self, *args: str) -> str | None: + try: + result = subprocess.run( + ["playerctl", "-p", PLAYER_PRIORITY, *args], + env=self._env_provider(), + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + log.debug("playerctl %s failed: %s", " ".join(args), exc) + return None + if result.returncode != 0: + return None + return result.stdout.strip() + + def read_state(self) -> dict: + status = self._playerctl("status") + if status is None: + return {"state": "off"} + + state = {"state": _STATUS_TO_HA.get(status, "idle")} + + metadata = self._playerctl("metadata", "--format", _METADATA_FORMAT) + if metadata: + title, artist, album, length, art_url = (metadata.split("\x1f") + [""] * 5)[:5] + state["title"] = title + state["artist"] = artist + state["album"] = album + state["art_url"] = art_url + if length.isdigit(): + state["duration"] = int(length) // 1_000_000 + + position = self._playerctl("position") + if position: + try: + state["position"] = int(float(position)) + except ValueError: + pass + + volume = self._playerctl("volume") + if volume: + try: + state["volume"] = round(float(volume), 3) + except ValueError: + pass + + return state + + def poll_once(self) -> None: + state = self.read_state() + if state != self._last_state: + self._last_state = state + self._publish_state(state) + + def run_forever(self, stop_event: threading.Event) -> None: + while not stop_event.is_set(): + try: + self.poll_once() + except Exception: + log.exception("MPRIS poll failed") + stop_event.wait(self._poll_interval) + + # --- command side ------------------------------------------------------- + def handle_command(self, command: str) -> None: + command = command.strip().upper() + action = { + "PLAY": ("play",), + "PAUSE": ("pause",), + "PLAY_PAUSE": ("play-pause",), + "TOGGLE": ("play-pause",), + "STOP": ("stop",), + "NEXT": ("next",), + "PREVIOUS": ("previous",), + "PREV": ("previous",), + }.get(command) + + if action is None: + log.warning("ignoring unknown media command %r", command) + return + + log.info("media command %s", command) + self._playerctl(*action) + self.poll_once() + + def set_volume(self, level: float) -> None: + level = max(0.0, min(1.0, level)) + self._playerctl("volume", f"{level:.3f}") + self.poll_once() diff --git a/hosts/steam-tv-box/agent/steamtv_agent/mqtt_discovery.py b/hosts/steam-tv-box/agent/steamtv_agent/mqtt_discovery.py new file mode 100644 index 0000000..8f8a88c --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv_agent/mqtt_discovery.py @@ -0,0 +1,308 @@ +"""Home Assistant MQTT Discovery payloads and command dispatch. + +SECURITY BOUNDARY — this module is the entire remote-control API of the Steam TV box. + +Same principle as hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py and the +touch panel's, applied to this device: the local LLM never gets a network path to this +machine. The only chain is: LLM tool call -> Home Assistant service call -> MQTT -> +this dispatcher. That property holds only as long as this stays the sole inbound +control surface: no HTTP listener, no websocket server, no exposed Sway IPC socket, no +shell endpoint. A new feature belongs as another entity below, not as another listener. + +It is worth restating on this host in particular, because it is the one machine here +with a real GPU, a Steam login and a games library on it — i.e. the one with something +worth taking. Nothing in this image listens on a port except sshd (key-only) and wayvnc +(password-mandatory, fails closed). Steam's own remote-play ports are not opened by +anything here; if you want them, that is a deliberate decision to make in the firewall, +not a default this image ships. + +Every command handler here is a fixed, enumerated action. A payload never becomes an +argv element, a shell string, or a URL host — see the launch table in main.py, which +builds every command from local constants and uses the payload only to pick between +known values. +""" + +from __future__ import annotations + +import json +import logging +from typing import Callable + +from . import __version__ + +log = logging.getLogger(__name__) + +DISCOVERY_PREFIX = "homeassistant" + + +class Discovery: + def __init__(self, client, node_id: str, friendly_name: str, room: str = ""): + self.client = client + self.node_id = node_id + self.friendly_name = friendly_name + self.room = (room or "").strip() + self.base = f"steamtv/{node_id}" + self.availability_topic = f"{self.base}/availability" + self.media_state_topic = f"{self.base}/media/state" + self.session_state_topic = f"{self.base}/session/state" + self._handlers: dict[str, Callable[[str], None]] = {} + + self.device = { + "identifiers": [f"steamtv_{node_id}"], + "name": friendly_name, + "manufacturer": "SmartestHome", + "model": "Steam TV box", + "sw_version": __version__, + } + # Which room this physically sits in, as an HA area_id. `suggested_area` is + # honoured by HA only when the device is FIRST discovered — moving a device + # later means moving it in HA too, this cannot un-file it. Omitted entirely + # when unset, because an empty suggested_area is not the same request as no + # suggestion. See docs/rooms-and-endpoints.md. + if self.room: + self.device["suggested_area"] = self.room + + # --- plumbing ----------------------------------------------------------- + def _publish_config(self, component: str, object_id: str, payload: dict) -> None: + payload = { + "availability_topic": self.availability_topic, + "device": self.device, + "unique_id": f"{self.node_id}_{object_id}", + **payload, + } + topic = f"{DISCOVERY_PREFIX}/{component}/{self.node_id}/{object_id}/config" + self.client.publish(topic, json.dumps(payload), qos=1, retain=True) + + def _command_topic(self, suffix: str, handler) -> str: + topic = f"{self.base}/{suffix}" + self._handlers[topic] = handler + return topic + + def subscribe_all(self) -> None: + for topic in self._handlers: + self.client.subscribe(topic, qos=1) + + def dispatch(self, topic: str, payload: str) -> None: + handler = self._handlers.get(topic) + if handler is None: + log.warning("no handler for %s", topic) + return + try: + handler(payload) + except Exception: + log.exception("handler for %s failed", topic) + + def publish_available(self, available: bool = True) -> None: + self.client.publish( + self.availability_topic, + "online" if available else "offline", + qos=1, + retain=True, + ) + + def publish_media_state(self, state: dict) -> None: + self.client.publish(self.media_state_topic, json.dumps(state), qos=0, retain=True) + + # --- media -------------------------------------------------------------- + def register_media_player(self, on_command, on_volume) -> None: + command_topic = self._command_topic("media/command", on_command) + volume_topic = self._command_topic("media/volume/set", lambda p: on_volume(float(p))) + + # Core Home Assistant's MQTT integration has NO media_player platform — see + # hosts/thin-client/README.md's identical caveat. The button/number entities + # below give the same transport control with stock HA. + self._publish_config( + "media_player", + "media", + { + "name": "Media", + "state_topic": self.media_state_topic, + "state_template": "{{ value_json.state }}", + "command_topic": command_topic, + "volume_command_topic": volume_topic, + "volume_state_topic": self.media_state_topic, + "volume_template": "{{ value_json.volume }}", + "title_template": "{{ value_json.title }}", + "artist_template": "{{ value_json.artist }}", + "album_template": "{{ value_json.album }}", + }, + ) + + for object_id, name, payload, icon in ( + ("media_play_pause", "Play/pause", "PLAY_PAUSE", "mdi:play-pause"), + ("media_next", "Next track", "NEXT", "mdi:skip-next"), + ("media_previous", "Previous track", "PREVIOUS", "mdi:skip-previous"), + ("media_stop", "Stop", "STOP", "mdi:stop"), + ): + self._publish_config( + "button", + object_id, + { + "name": name, + "command_topic": command_topic, + "payload_press": payload, + "icon": icon, + }, + ) + + self._publish_config( + "sensor", + "media_state", + { + "name": "Playback state", + "state_topic": self.media_state_topic, + "value_template": "{{ value_json.state }}", + "json_attributes_topic": self.media_state_topic, + "icon": "mdi:play-circle", + }, + ) + + self._publish_config( + "number", + "media_volume", + { + "name": "Volume", + "command_topic": volume_topic, + "state_topic": self.media_state_topic, + "value_template": "{{ value_json.volume }}", + "min": 0, + "max": 1, + "step": 0.05, + "mode": "slider", + "icon": "mdi:volume-high", + }, + ) + + # --- apps / workspaces -------------------------------------------------- + def register_app_launchers(self, apps, on_launch) -> None: + for key, app in apps.items(): + self._publish_config( + "button", + f"launch_{key}", + { + "name": f"Launch {app.name}", + "command_topic": self._command_topic( + f"app/{key}/launch", + lambda _payload, key=key: on_launch(key), + ), + "icon": app.icon, + }, + ) + + def register_workspace_select(self, workspaces, on_workspace, state_topic_value) -> None: + self._publish_config( + "select", + "workspace", + { + "name": "Screen", + "command_topic": self._command_topic("workspace/set", on_workspace), + "state_topic": f"{self.base}/workspace/state", + "options": list(workspaces), + "icon": "mdi:view-dashboard", + }, + ) + self.client.publish( + f"{self.base}/workspace/state", state_topic_value, qos=1, retain=True + ) + + def publish_workspace(self, name: str) -> None: + self.client.publish(f"{self.base}/workspace/state", name, qos=1, retain=True) + + # --- session mode ------------------------------------------------------- + def register_session_mode(self, modes, on_select, on_stop_media) -> None: + """The "what is this box doing" surface — see session_mode.py. + + A sensor and a select rather than one entity, because the two are not the same + question. The sensor reports four states (gaming / steam / media / idle); the + select offers only the two that are meaningful to *ask for*. "idle" is not + something you can request, and "steam" (Steam up but not focused) is a + transitional state nobody sets on purpose. + """ + self._publish_config( + "sensor", + "session_mode", + { + "name": "Session", + "state_topic": self.session_state_topic, + "value_template": "{{ value_json.mode }}", + "json_attributes_topic": self.session_state_topic, + "icon": "mdi:gamepad-variant", + }, + ) + + self._publish_config( + "select", + "session_mode_select", + { + "name": "Mode", + "command_topic": self._command_topic("session/mode/set", on_select), + "state_topic": self.session_state_topic, + "value_template": "{{ value_json.mode }}", + "options": list(modes), + "icon": "mdi:gamepad-variant", + }, + ) + + # The only thing in this whole surface that shuts something down, and therefore + # its own explicit button rather than a side effect of switching mode. See + # SessionMode.select()'s docstring for why the mode switch is additive. + self._publish_config( + "button", + "stop_media", + { + "name": "Stop media apps", + "command_topic": self._command_topic( + "session/media/stop", lambda _payload: on_stop_media() + ), + "icon": "mdi:close-circle-outline", + }, + ) + + def publish_session_state(self, mode: str, attributes: dict | None = None) -> None: + payload = {"mode": mode} + if attributes: + payload.update(attributes) + self.client.publish( + self.session_state_topic, json.dumps(payload), qos=1, retain=True + ) + + # --- audio output ------------------------------------------------------- + def register_audio_output(self, options, current, on_select) -> None: + self._publish_config( + "select", + "audio_output", + { + "name": "Audio output", + "command_topic": self._command_topic("audio/output/set", on_select), + "state_topic": f"{self.base}/audio/output/state", + "options": list(options), + "icon": "mdi:speaker", + }, + ) + self.publish_audio_output(current) + + def publish_audio_output(self, option: str) -> None: + self.client.publish( + f"{self.base}/audio/output/state", option, qos=1, retain=True + ) + + # --- display power ------------------------------------------------------ + def register_display_power(self, on_command, initial: bool = True) -> None: + self._publish_config( + "switch", + "display", + { + "name": "Display", + "command_topic": self._command_topic("display/set", on_command), + "state_topic": f"{self.base}/display/state", + "payload_on": "ON", + "payload_off": "OFF", + "icon": "mdi:television", + }, + ) + self.publish_display_power(initial) + + def publish_display_power(self, on: bool) -> None: + self.client.publish( + f"{self.base}/display/state", "ON" if on else "OFF", qos=1, retain=True + ) diff --git a/hosts/steam-tv-box/agent/steamtv_agent/runtime_state.py b/hosts/steam-tv-box/agent/steamtv_agent/runtime_state.py new file mode 100644 index 0000000..9f415c0 --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv_agent/runtime_state.py @@ -0,0 +1,75 @@ +"""Seeds writable runtime copies of the read-only config templates in the image. + +One of this agent's config files (audio-config.json) is rewritten at runtime — the HA +"Audio output" select has to survive a reboot, and it cannot live where the build put +it, because the live image's /etc is inside a squashfs. (The thin client's copy of this +module also covers rdp-vnc.json; this box has no outbound remote-desktop client, so +audio is the only file here. The module is kept whole rather than trimmed so the two +stay comparable.) + +So the same split the wayvnc password already uses applies here — a committed template +that the build bakes in read-only, plus a real file created on the booted machine that +is never committed. The difference is that wayvnc's real file is written by a human and +fails closed if they forget, whereas these two are seeded automatically from the +template, because "no audio-output preference yet" is a perfectly safe state and there +is nothing to fail closed about. +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import tempfile + +log = logging.getLogger(__name__) + +TEMPLATE_DIR = os.environ.get("STEAMTV_TEMPLATE_DIR", "/etc/steamtv-agent") +STATE_DIR = os.environ.get("STEAMTV_STATE_DIR", "/var/lib/steamtv-agent") + + +def ensure_runtime_copy(filename: str) -> str: + """Return the writable path for `filename`, seeding it from the template if new.""" + runtime_path = os.path.join(STATE_DIR, filename) + if os.path.exists(runtime_path): + return runtime_path + + template_path = os.path.join(TEMPLATE_DIR, filename) + try: + os.makedirs(STATE_DIR, exist_ok=True) + shutil.copyfile(template_path, runtime_path) + log.info("seeded %s from %s", runtime_path, template_path) + except OSError as exc: + log.warning("could not seed %s from %s: %s", runtime_path, template_path, exc) + + return runtime_path + + +def load_json(path: str) -> dict: + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError) as exc: + log.warning("could not read %s (%s); using defaults", path, exc) + return {} + return data if isinstance(data, dict) else {} + + +def save_json(path: str, data: dict) -> bool: + """Write atomically — a half-written config on a power cut would be worse than a + stale one, since these files are read unattended at boot.""" + directory = os.path.dirname(path) or "." + try: + os.makedirs(directory, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=directory, delete=False + ) as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + temp_path = handle.name + os.replace(temp_path, path) + except OSError as exc: + log.warning("could not write %s: %s", path, exc) + return False + return True diff --git a/hosts/steam-tv-box/agent/steamtv_agent/session_mode.py b/hosts/steam-tv-box/agent/steamtv_agent/session_mode.py new file mode 100644 index 0000000..616af4f --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv_agent/session_mode.py @@ -0,0 +1,146 @@ +"""What is this box doing right now — gaming, media, or idle — and switching between. + +WHY THIS EXISTS +--------------- +Home Assistant needs to be able to answer "is somebody playing on the TV box?" without +guessing from power draw. That one sensor is worth a lot in automations: don't dim the +living room during a game, don't announce the doorbell over a raid, count the room as +occupied even though nobody has moved for forty minutes. + +WHAT IT READS +------------- +Two cheap facts, both local: + - is the Steam client running (pgrep); + - which sway workspace is focused (swaymsg -t get_workspaces). + +Deliberately NOT the window title. Steam's window titles and the existence of a +separate Big Picture window have both changed across client rewrites, and a sensor that +silently goes wrong after a Steam update is worse than one that is slightly coarse. The +workspace name is a contract this repo owns — see the header in +configs/session/session-watcher, which makes the same call for the same reason. + +MODES +----- + gaming — Steam is running and 1:steam is focused. The screen is showing a game or + Big Picture. + steam — Steam is running but the user has moved elsewhere (this is "exited Big + Picture but left Steam up", the case session-watcher reacts to). + media — Steam is not running; the media session is what is on screen. + idle — nothing is up. Only really seen in the seconds after boot, or after + somebody quit Steam and the media session has not been started yet. + +SECURITY POSTURE, UNCHANGED +--------------------------- +The two commands below take no arguments derived from any MQTT payload. The mode +*switch* is an enumerated action — see mqtt_discovery.py's module docstring. +""" + +from __future__ import annotations + +import json +import logging + +from .sway_control import WS_STEAM + +log = logging.getLogger(__name__) + +MODE_GAMING = "gaming" +MODE_STEAM = "steam" +MODE_MEDIA = "media" +MODE_IDLE = "idle" + +MODES = (MODE_GAMING, MODE_MEDIA) + +STEAM_PROCESS = "steam" +MEDIA_SESSION = "/usr/local/bin/media-session" +STEAM_BIG_PICTURE = "/usr/local/bin/steam-big-picture" + +# What media-session itself guards on. Kept in sync with that script by hand; there is +# no shared file to read, and inventing one to hold three strings would be worse. +MEDIA_PATTERNS = ( + "com.spotify.Client", + "media-player-idle", + "firefox.*--profile.*/firefox/web", +) + + +class SessionMode: + def __init__(self, sway): + self.sway = sway + # What current() last computed, versus what was last put on the broker. Two + # fields because current() updates the first every time it is called, so it + # cannot also serve as "have we told HA about this yet". + self.last_mode = MODE_IDLE + self.last_published = "" + + # --- reading ------------------------------------------------------------ + def focused_workspace(self) -> str: + output = self.sway.swaymsg("-t", "get_workspaces") + if not output: + return "" + try: + workspaces = json.loads(output) + except ValueError: + log.warning("could not parse get_workspaces output") + return "" + for workspace in workspaces: + if isinstance(workspace, dict) and workspace.get("focused"): + return str(workspace.get("name") or "") + return "" + + def steam_running(self) -> bool: + # is_process (pgrep -x), not is_running (pgrep -f) — see that method's comment + # for why Steam specifically needs the exact-name match. + return self.sway.is_process(STEAM_PROCESS) + + def media_running(self) -> bool: + return any(self.sway.is_running(pattern) for pattern in MEDIA_PATTERNS) + + def current(self) -> str: + steam = self.steam_running() + if steam and self.focused_workspace() == WS_STEAM: + mode = MODE_GAMING + elif steam: + mode = MODE_STEAM + elif self.media_running(): + mode = MODE_MEDIA + else: + mode = MODE_IDLE + self.last_mode = mode + return mode + + def attributes(self) -> dict: + return { + "steam_running": self.steam_running(), + "media_running": self.media_running(), + "workspace": self.focused_workspace(), + } + + # --- switching ---------------------------------------------------------- + def select(self, mode: str) -> str: + """Handle the HA select. Enumerated: anything else is ignored, not guessed at. + + Note the asymmetry, which is intentional. Choosing "gaming" launches or focuses + Steam and leaves the media apps alone — someone may well want music over a + game, and killing a running Spotify because a game started would lose whatever + was playing. Choosing "media" starts the media session and likewise does not + quit Steam, because quitting Steam from a phone while somebody is mid-match is + not a thing this should be able to do by accident. Both directions are additive; + the only thing that shuts anything down is the explicit "Stop media apps" + button. + """ + mode = (mode or "").strip().lower() + if mode == MODE_GAMING: + log.info("session mode -> gaming") + self.sway.launch_app([STEAM_BIG_PICTURE]) + elif mode == MODE_MEDIA: + log.info("session mode -> media") + self.sway.launch_app([MEDIA_SESSION, "start", "--focus"]) + else: + log.warning("ignoring unknown session mode %r", mode) + return self.current() + + def stop_media(self) -> str: + log.info("stopping the media session") + self.sway.launch_app([MEDIA_SESSION, "stop"]) + return self.current() diff --git a/hosts/steam-tv-box/agent/steamtv_agent/sway_control.py b/hosts/steam-tv-box/agent/steamtv_agent/sway_control.py new file mode 100644 index 0000000..18eacc8 --- /dev/null +++ b/hosts/steam-tv-box/agent/steamtv_agent/sway_control.py @@ -0,0 +1,135 @@ +"""Thin wrapper around swaymsg and local process launching. + +Identical in shape to hosts/thin-client/agent/thinclient_agent/sway_control.py and +hosts/touch-panel/agent/touchpanel_agent/sway_control.py — duplicated rather than +imported across hosts, same convention as the rest of this repo's agents. +""" + +from __future__ import annotations + +import glob +import logging +import os +import subprocess + +log = logging.getLogger(__name__) + +# Contract with configs/sway/config — these strings must match the `set $ws_*` lines, +# and with configs/session/media-session and configs/session/session-watcher, which +# hardcode the same names in shell. +WS_STEAM = "1:steam" +WS_GAMES = "2:games" +WS_WEB = "3:web" +WS_MEDIA = "4:media" +WS_MUSIC = "5:music" + + +def runtime_dir() -> str: + return os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + + +class SwayControl: + def _socket_path(self) -> str | None: + path = os.environ.get("SWAYSOCK") + if path and os.path.exists(path): + return path + # sway names the socket sway-ipc...sock, so the path changes every + # time sway restarts. steamtv-agent is a system service that outlives the + # session, so the socket is re-resolved per call instead of cached at startup. + matches = sorted(glob.glob(os.path.join(runtime_dir(), "sway-ipc.*.sock"))) + return matches[-1] if matches else None + + def session_env(self) -> dict[str, str]: + env = dict(os.environ) + env["XDG_RUNTIME_DIR"] = runtime_dir() + env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path={runtime_dir()}/bus") + env.setdefault("WAYLAND_DISPLAY", "wayland-1") + sock = self._socket_path() + if sock: + env["SWAYSOCK"] = sock + return env + + def swaymsg(self, *args: str) -> str | None: + if self._socket_path() is None: + log.warning("no sway IPC socket found; dropping command %s", " ".join(args)) + return None + try: + result = subprocess.run( + ["swaymsg", *args], + env=self.session_env(), + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + log.warning("swaymsg %s failed: %s", " ".join(args), exc) + return None + if result.returncode != 0: + log.warning("swaymsg %s: %s", " ".join(args), result.stderr.strip()) + return None + return result.stdout + + def switch_workspace(self, name: str) -> None: + log.info("switching to workspace %s", name) + self.swaymsg("workspace", name) + + def focus_window(self, criteria: str) -> None: + self.swaymsg(f"[{criteria}] focus") + + def is_running(self, pattern: str) -> bool: + """Match `pattern` against full command lines (pgrep -f).""" + return self._pgrep("-f", pattern) + + def is_process(self, name: str) -> bool: + """Match `name` against process names only (pgrep -x). + + Separate from is_running() because Steam is the one thing here that needs it: + its command line is `/bin/sh /usr/games/steam -gamepadui …` and it spawns a + dozen helpers (steamwebhelper, reaper, steamerrorreporter) whose command lines + all contain the word "steam". A -f match would report Steam as running long + after the client has gone, which would leave the session-mode sensor stuck on + "gaming" for the rest of the evening. + """ + return self._pgrep("-x", name) + + def _pgrep(self, flag: str, pattern: str) -> bool: + try: + result = subprocess.run( + ["pgrep", "-u", str(os.getuid()), flag, pattern], + capture_output=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + def launch_app( + self, + command: list[str], + process_pattern: str | None = None, + workspace: str | None = None, + focus_criteria: str | None = None, + ) -> None: + if workspace: + self.switch_workspace(workspace) + + if process_pattern and self.is_running(process_pattern): + log.info("%s already running; focusing instead of launching", command[0]) + if focus_criteria: + self.focus_window(focus_criteria) + return + + log.info("launching %s", " ".join(command)) + try: + subprocess.Popen( + command, + env=self.session_env(), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as exc: + log.error("could not launch %s: %s", " ".join(command), exc) diff --git a/hosts/steam-tv-box/configs/audio/audio-config.json b/hosts/steam-tv-box/configs/audio/audio-config.json new file mode 100644 index 0000000..8307313 --- /dev/null +++ b/hosts/steam-tv-box/configs/audio/audio-config.json @@ -0,0 +1,10 @@ +{ + "_comment": "TEMPLATE. Baked into the image read-only at /etc/steamtv-agent/audio-config.json. On first boot steamtv_agent/audio_control.py copies it to /var/lib/steamtv-agent/audio-config.json and from then on reads and REWRITES only that copy — the HA 'Audio output' select has to survive a reboot, and a file inside a squashfs image cannot. Same split as the wayvnc password: a committed template plus a runtime-populated real file that is gitignored.", + + "_comment_preferred_sink": "A WirePlumber node.name, not a description and not a numeric ID. IDs are reassigned on every boot and on every device hotplug; node.name is stable. Find yours on the booted machine with: wpctl status then wpctl inspect | grep node.name", + + "_comment_this_box": "On a machine plugged into a television this is usually the HDMI sink (alsa_output.pci-....hdmi-stereo) rather than the analog one, and getting it wrong is the classic 'the game has no sound' report. Worth setting explicitly here rather than trusting WirePlumber's pick, because a TV that is switched off at boot can leave the HDMI sink absent and WirePlumber will happily default to the motherboard's headphone jack — which nothing is plugged into.", + + "preferred_sink": "", + "fallback": "system-default" +} diff --git a/hosts/steam-tv-box/configs/firefox/policies.json b/hosts/steam-tv-box/configs/firefox/policies.json new file mode 100644 index 0000000..e374e0c --- /dev/null +++ b/hosts/steam-tv-box/configs/firefox/policies.json @@ -0,0 +1,63 @@ +{ + "_comment_path": "Installed to /etc/firefox/policies/policies.json, the documented Linux location for Firefox enterprise policy. 0900-firefox.hook.chroot also links it into firefox-esr's distribution/ directory, an older location some builds read instead; whichever one the installed build honours wins and the other is ignored. Note that every key below lives OUTSIDE the 'policies' object on purpose — Firefox flags unrecognised keys *inside* it as invalid policies in about:policies.", + + "_comment_extensions": "VERIFY BEFORE THE FIRST REAL BUILD. The two AMO slugs ('ublock-origin', 'sponsorblock') were confirmed against the live addons.mozilla.org listings, and /firefox/downloads/latest//latest.xpi is AMO's documented always-current download URL, so install_url should be right. The ExtensionSettings *keys* must be each extension's real add-on ID as Firefox sees it, and those were NOT verifiable from the AMO listing pages — they are widely-published values reproduced here, not checked. If an extension silently fails to install, that key is the first suspect: install the XPI by hand once, read the ID off about:debugging#/runtime/this-firefox, and correct it here.", + + "_comment_kiosk_ui": "The Disable*/UserMessaging/FirefoxHome blocks all remove a prompt, tour, or nag that would otherwise sit on an unattended screen in a shared room with nobody there to dismiss it.", + + "_comment_updates": "The browser is part of the image and is replaced by rebuilding and reflashing it, so in-browser updates would only produce version drift between rooms plus a restart banner nobody is there to click.", + + "policies": { + "ExtensionSettings": { + "uBlock0@raymondhill.net": { + "installation_mode": "force_installed", + "install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi", + "default_area": "menupanel" + }, + "sponsorBlocker@ajay.app": { + "installation_mode": "force_installed", + "install_url": "https://addons.mozilla.org/firefox/downloads/latest/sponsorblock/latest.xpi", + "default_area": "menupanel" + } + }, + + "DisableProfileImport": true, + "DisableProfileRefresh": true, + "DisableFirefoxAccounts": true, + "DisableFirefoxStudies": true, + "DisableTelemetry": true, + "DisablePocket": true, + "DisableFeedbackCommands": true, + "DisableSetDesktopBackground": true, + "DontCheckDefaultBrowser": true, + "NoDefaultBookmarks": true, + "OfferToSaveLogins": false, + "PasswordManagerEnabled": false, + "PromptForDownloadLocation": false, + + "AppAutoUpdate": false, + "DisableAppUpdate": true, + + "UserMessaging": { + "WhatsNew": false, + "ExtensionRecommendations": false, + "FeatureRecommendations": false, + "UrlbarInterventions": false, + "SkipOnboarding": true, + "MoreFromMozilla": false + }, + + "FirefoxHome": { + "Search": true, + "TopSites": true, + "SponsoredTopSites": false, + "Highlights": false, + "Pocket": false, + "SponsoredPocket": false, + "Snippets": false + }, + + "OverrideFirstRunPage": "", + "OverridePostUpdatePage": "" + } +} diff --git a/hosts/steam-tv-box/configs/firefox/user.js b/hosts/steam-tv-box/configs/firefox/user.js new file mode 100644 index 0000000..649504d --- /dev/null +++ b/hosts/steam-tv-box/configs/firefox/user.js @@ -0,0 +1,55 @@ +// Prefs for the Steam TV box's Firefox profile. Installed by +// build-steam-tv-box-iso.sh to /etc/steamtv-firefox/user.js, from where +// /usr/local/bin/web-browser copies it into +// /home//.mozilla/firefox/web on every launch. +// +// One profile here, not two: this image has no digest canvas — the browser is the +// general-browsing window and nothing else. Otherwise these are the thin client's +// prefs verbatim, and for the same reasons. +// +// user.js rather than prefs.js: user.js is re-applied to prefs.js on every startup, so +// nothing a stray click changes at runtime survives a restart. That matters on a kiosk +// nobody logs into to fix things. +// +// Anything expressible as enterprise policy lives in policies.json instead — policy is +// enforced and shows up in about:policies, whereas a pref is merely a default. The +// prefs here are the ones with no policy equivalent. + +// The only reason userChrome.css is read at all. Without this the stylesheet in +// chrome/ is silently ignored and the window comes up with full default chrome. +user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true); + +// First-run / post-update interstitials. An unattended screen has nobody to close them. +user_pref("browser.startup.homepage_override.mstone", "ignore"); +user_pref("browser.aboutwelcome.enabled", false); +user_pref("browser.messaging-system.whatsNewPanel.enabled", false); +user_pref("trailhead.firstrun.didSeeAboutWelcome", true); +user_pref("datareporting.policy.firstRunURL", ""); +user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true); + +// Session restore prompts after a power cut would leave the room's display stuck on a +// "restore your session?" page instead of the digest. +user_pref("browser.sessionstore.resume_from_crash", false); +user_pref("browser.tabs.warnOnClose", false); +user_pref("browser.tabs.warnOnCloseOtherTabs", false); +user_pref("browser.warnOnQuit", false); + +// No permission doorhangers on a display nobody is standing at. Autoplay is allowed +// because the digest canvas and embedded video are the point of the machine. +user_pref("permissions.default.desktop-notification", 2); +user_pref("permissions.default.geo", 2); +user_pref("media.autoplay.default", 0); +user_pref("media.autoplay.blocking_policy", 0); + +// Wayland-native rather than Xwayland, matching MOZ_ENABLE_WAYLAND in kiosk-session. +// VA-API is left at its default: no thin-client hardware has been chosen yet +// (project-plan §4 #7), so forcing hardware decode here could just as easily produce a +// black video surface as a working one. +user_pref("gfx.webrender.all", true); + +// The address bar in the minimal-chrome window is for typing a URL, not for a dropdown +// of suggestions covering the page. +user_pref("browser.urlbar.suggest.topsites", false); +user_pref("browser.urlbar.suggest.quicksuggest.sponsored", false); +user_pref("browser.newtabpage.activity-stream.showSponsored", false); +user_pref("browser.newtabpage.activity-stream.showSponsoredTopSites", false); diff --git a/hosts/steam-tv-box/configs/firefox/userChrome.css b/hosts/steam-tv-box/configs/firefox/userChrome.css new file mode 100644 index 0000000..9723def --- /dev/null +++ b/hosts/steam-tv-box/configs/firefox/userChrome.css @@ -0,0 +1,63 @@ +/* Minimal browser chrome for the Steam TV box's browsing window. + * Installed to /chrome/userChrome.css by /usr/local/bin/web-browser, and only + * read because user.js sets toolkit.legacyUserProfileCustomizations.stylesheets=true. + * + * What survives: back, forward, reload, and the address bar. Everything else goes. + * That set is deliberate — those four are what someone driving this from the sofa with + * the remote's little keyboard actually needs, and every extra control is one more + * thing to mis-click on a television at two metres. + * + * Taken verbatim from hosts/thin-client/configs/firefox/userChrome.css; the two are + * meant to stay identical, so the browser behaves the same in every room. + */ + +/* Tab strip. The kiosk browses one page at a time; a tab bar on a shared display just + * accumulates whatever the last person left open. */ +#TabsToolbar, +#tabbrowser-tabs, +#alltabs-button { + visibility: collapse !important; +} + +/* Menu bar and bookmarks bar. */ +#toolbar-menubar, +#PersonalToolbar, +#PlacesToolbar { + visibility: collapse !important; +} + +/* Everything on the nav bar except back / forward / reload / the address bar. */ +#home-button, +#library-button, +#sidebar-button, +#fxa-toolbar-menu-button, +#unified-extensions-button, +#PanelUI-button, +#downloads-button, +#save-to-pocket-button, +#pageActionButton, +#star-button-box, +#reader-mode-button, +#customizableui-special-spring1, +#customizableui-special-spring2 { + display: none !important; +} + +/* The urlbar keeps its identity box (padlock) — dropping it would hide the only + * on-screen signal that a page is or is not https. */ +#urlbar-container { + min-width: 0 !important; +} + +#nav-bar { + border: none !important; + box-shadow: none !important; +} + +/* Titlebar spacers left over once the tab strip is collapsed. */ +.titlebar-buttonbox-container, +.titlebar-spacer { + display: none !important; +} + +/* Findbar and notification popups stay: they are transient and user-initiated. */ diff --git a/hosts/steam-tv-box/configs/firefox/web-browser b/hosts/steam-tv-box/configs/firefox/web-browser new file mode 100755 index 0000000..1367b75 --- /dev/null +++ b/hosts/steam-tv-box/configs/firefox/web-browser @@ -0,0 +1,47 @@ +#!/bin/sh +# The general web browser — minimal chrome, not kiosk mode. Installed to +# /usr/local/bin/web-browser. +# +# The requirement this satisfies is "a web browser with uBlock Origin", which is +# enforced by /etc/firefox/policies/policies.json (force_installed, so it cannot be +# removed by a stray click on the TV) rather than by anything here. +# +# Unlike the thin client there is only one profile on this image: no digest canvas +# means no second Firefox process to keep apart. The profile still lives under a named +# directory rather than the default, because media-session's pgrep guard matches on the +# --profile path to decide whether the browser is already up. +# +# SponsorBlock is in the policy set too. On a box whose browser exists mostly to play +# video on a television, skipping sponsor segments without a remote in hand is worth +# more here than anywhere else in the house. +set -eu + +PROFILE_DIR="${HOME:-/home/$(id -un)}/.mozilla/firefox/web" + +if command -v firefox-esr >/dev/null 2>&1; then + FIREFOX=firefox-esr +else + FIREFOX=firefox +fi + +mkdir -p "$PROFILE_DIR/chrome" + +# Re-copied on every launch rather than once at build time: this is a locked-down +# profile with no interactive customisation expected, so keeping it in lockstep with +# /etc/steamtv-firefox/ (edited by rebuilding the image) is simpler than a one-shot seed +# that could drift after a userChrome.css update. +cp /etc/steamtv-firefox/userChrome.css "$PROFILE_DIR/chrome/userChrome.css" 2>/dev/null || true +cp /etc/steamtv-firefox/user.js "$PROFILE_DIR/user.js" 2>/dev/null || true + +# Focus an already-open window rather than stacking a second one: this window holds +# state (history, a half-typed URL, a logged-in video site) that a kill-and-relaunch +# would throw away — and media-session calls this every time somebody leaves Big +# Picture, so "already open" is the common case, not the exception. +if pgrep -u "$(id -u)" -f "$FIREFOX .*--profile $PROFILE_DIR" >/dev/null 2>&1; then + if [ -n "${1:-}" ]; then + exec "$FIREFOX" --profile "$PROFILE_DIR" --new-tab "$1" + fi + exit 0 +fi + +exec "$FIREFOX" --profile "$PROFILE_DIR" --new-instance --new-window "${1:-about:blank}" diff --git a/hosts/steam-tv-box/configs/greetd/config.toml b/hosts/steam-tv-box/configs/greetd/config.toml new file mode 100644 index 0000000..e1e92a7 --- /dev/null +++ b/hosts/steam-tv-box/configs/greetd/config.toml @@ -0,0 +1,21 @@ +# greetd — autologin straight into the kiosk Sway session, no greeter UI. +# Installed to /etc/greetd/config.toml by build-steam-tv-box-iso.sh, which substitutes +# @KIOSK_USERNAME@ with its own KIOSK_USERNAME variable on the way in. +# +# Schema note (getting these key names wrong fails silently — you get a black VT with +# no login, and greetd logs nothing obvious): the table names are exactly [terminal] +# and [default_session], and the session keys are exactly `command` and `user`. +# +# Why [default_session] and not [initial_session]: [initial_session] fires once, on the +# first greetd start after boot, and greetd falls back to [default_session] the moment +# that session ends. For a TV box that would mean one crashed sway drops the living +# room to an agreety login prompt on a screen with no keyboard in front of it. Pointing +# [default_session] itself at the kiosk user makes the autologin permanent and +# self-healing. [initial_session] is deliberately absent. + +[terminal] +vt = 1 + +[default_session] +command = "/usr/local/bin/kiosk-session" +user = "@KIOSK_USERNAME@" diff --git a/hosts/steam-tv-box/configs/greetd/kiosk-session b/hosts/steam-tv-box/configs/greetd/kiosk-session new file mode 100755 index 0000000..22c475e --- /dev/null +++ b/hosts/steam-tv-box/configs/greetd/kiosk-session @@ -0,0 +1,43 @@ +#!/bin/sh +# greetd's default_session command. Installed to /usr/local/bin/kiosk-session. +# +# Exists so that MQTT_BROKER_HOST / HA_URL / GPU_VENDOR are in Sway's environment: +# sway's config file has no way to read an env file itself, but every `exec` line it +# runs inherits this process's environment, so sourcing here is what lets the session +# scripts read configuration without the sway config being templated. +set -eu + +# An `if` rather than `[ -r … ] && . …`: under `set -e` the && form exits the whole +# script when the file is absent, which would leave greetd with a session that dies +# instantly and a black screen. +if [ -r /etc/steamtv-agent/config.env ]; then + . /etc/steamtv-agent/config.env +fi +export MQTT_BROKER_HOST HA_URL GPU_VENDOR + +export XDG_CURRENT_DESKTOP=sway +export XDG_SESSION_TYPE=wayland +export XDG_SESSION_DESKTOP=sway +export MOZ_ENABLE_WAYLAND=1 + +: "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}" +export XDG_RUNTIME_DIR + +# Steam's client, Proton, Prism's Minecraft windows and most native titles are X11 +# applications. Telling them the display is 96dpi-normal and letting Xwayland scale is +# the sane default for a TV at 1-2m viewing distance; a 4K set that looks +# postage-stamp-sized wants this raised, not the compositor's scale changed (which +# would blur Big Picture). +export GDK_BACKEND=wayland,x11 +export QT_QPA_PLATFORM="wayland;xcb" +export SDL_VIDEODRIVER=wayland,x11 + +# NVIDIA's driver still mishandles hardware cursor planes under wlroots on a number of +# releases; the symptom is an invisible or corrupt pointer, which on a box you drive +# with a gamepad is easy to miss until someone picks up a mouse. Harmless when set on +# hardware that doesn't need it, so it is scoped to the vendor rather than always on. +if [ "${GPU_VENDOR:-}" = "nvidia" ]; then + export WLR_NO_HARDWARE_CURSORS=1 +fi + +exec sway diff --git a/hosts/steam-tv-box/configs/installer/preseed.cfg b/hosts/steam-tv-box/configs/installer/preseed.cfg new file mode 100644 index 0000000..8bb5e1b --- /dev/null +++ b/hosts/steam-tv-box/configs/installer/preseed.cfg @@ -0,0 +1,24 @@ +# Only relevant when `enable_installer` is true for this kiosk in CoreSystemConfig.json. +# +# Unlike the thin client, this one is worth turning on: a gaming box wants games on a +# real disk rather than in a live session's RAM overlay, so the normal path here is an +# actual install. See hosts/steam-tv-box/README.md. +# +# THIS FILE IS THE SOURCE, NOT THE ONE LIVE-BUILD READS. +# build-steam-tv-box-iso.sh substitutes @KEYBOARD_LAYOUT@ and writes the result to +# live-build/config/preseed.cfg on every run, so that the installer's default layout +# follows household.keyboard_layout in CoreSystemConfig.json instead of being a second +# place the layout can be wrong. The generated copy is regenerated every build — edit +# this one. (The thin client hardcodes its layout here because its installer is off by +# default; this image's is not.) +# +# live-build auto-includes config/preseed.cfg into the debian-installer's preseed when +# --debian-installer is not "none". VERIFY: this is live-build's documented mechanism +# for this, but has not been exercised — if enable_installer is turned on, confirm the +# installer's keyboard step actually shows the right layout pre-selected rather than +# falling back to its own default. +# +# No "keyboard-configuration/xkb-keymap seen true" line on purpose: this preseeds the +# *default* answer, it does not skip the question, so whoever runs the installer can +# still pick a different layout for this specific machine. +d-i keyboard-configuration/xkb-keymap select @KEYBOARD_LAYOUT@ diff --git a/hosts/steam-tv-box/configs/mpv/mpv.conf b/hosts/steam-tv-box/configs/mpv/mpv.conf new file mode 100644 index 0000000..b858be2 --- /dev/null +++ b/hosts/steam-tv-box/configs/mpv/mpv.conf @@ -0,0 +1,31 @@ +# mpv defaults for the kiosk user. Installed to +# /home//.config/mpv/mpv.conf by build-steam-tv-box-iso.sh. +# +# input-ipc-server is what lets `media-player ` load into the mpv window that is +# already sitting on 4:media instead of opening a second one — two mpvs would mean two +# MPRIS players and a coin-flip as to which one the remote's play button reaches. +# +# VERIFY: `~/` expansion in an mpv.conf value is documented behaviour but has not been +# checked on this image. If the socket never appears, replace the path below with the +# absolute one for the kiosk user. +input-ipc-server=~/.mpv-socket + +# Hardware decode. This box has a real GPU, which is the one place in this project +# where that is true, so 4K video should not be burning cores it could be spending on a +# game running alongside it. `auto-safe` picks a hardware decoder only from the +# combinations upstream considers reliable and silently falls back to software +# otherwise — the failure mode is a warm CPU, never a black window. +hwdec=auto-safe + +# Sensible on a television: keep the aspect ratio, never letterbox into a window that +# is already fullscreen, and don't drop to a tiny window for a small file. +keep-open=yes +force-window=yes + +# Audio goes to whatever WirePlumber's default sink is, which is what the agent's +# "Audio output" select actually changes. Naming a device here would silently override +# that select and make it look broken. +ao=pipewire + +# Nothing here loads mpv-mpris: Debian's mpv-mpris package drops its .so into mpv's +# autoload directory, and naming it again would load the plugin twice. diff --git a/hosts/steam-tv-box/configs/session/media-player b/hosts/steam-tv-box/configs/session/media-player new file mode 100755 index 0000000..dd9e38e --- /dev/null +++ b/hosts/steam-tv-box/configs/session/media-player @@ -0,0 +1,38 @@ +#!/bin/sh +# The general mp3/mp4 player. Installed to /usr/local/bin/media-player. +# +# mpv, same as the thin client's — it plays everything without a codec pack, it exposes +# MPRIS through mpv-mpris (which is what puts the transport controls in Home Assistant +# and on the remote's media keys), and it takes a file, a directory or a URL equally. +# +# --idle=yes --force-window is what makes it part of a *session* rather than a one-shot +# command: with no file given it opens an empty player window and waits, so it is +# already on 4:media with a working MPRIS bus before anyone has picked something to +# play. Dropping a file on it, opening one from a share, or `media-player ` from +# the maintenance shell all then load into the window that is already there. +# +# The "media-player-idle" title is not decoration — media-session and the sway config +# both match on it (mpv's app_id is just "mpv", which a second, file-playing mpv would +# share), so changing it means changing those two too. +set -eu + +# A second invocation with a file loads it into the running instance rather than +# opening a competing window: two mpvs means two MPRIS players and a coin-flip as to +# which one the remote's play button reaches. mpv.conf sets input-ipc-server for this. +SOCKET="${HOME:-/home/$(id -un)}/.mpv-socket" + +if [ -n "${1:-}" ] && [ -S "$SOCKET" ]; then + # loadfile via the JSON IPC. The path is passed as a JSON string argument, never + # interpolated into a shell command. + if printf '{"command":["loadfile","%s","replace"]}\n' "$1" | socat - "$SOCKET" 2>/dev/null; then + exit 0 + fi + # If that failed the socket is stale (mpv died without cleaning up) — fall through to + # a plain launch, which is the right outcome rather than an error. +fi + +if [ -n "${1:-}" ]; then + exec mpv --title=media-player-idle "$@" +fi + +exec mpv --idle=yes --force-window=yes --title=media-player-idle diff --git a/hosts/steam-tv-box/configs/session/media-session b/hosts/steam-tv-box/configs/session/media-session new file mode 100755 index 0000000..078be31 --- /dev/null +++ b/hosts/steam-tv-box/configs/session/media-session @@ -0,0 +1,133 @@ +#!/bin/sh +# Brings up (or takes down) the media half of this box. Installed to +# /usr/local/bin/media-session. +# +# WHAT THIS IS FOR +# ---------------- +# The requirement was: boot into Big Picture, and on leaving it have the same media +# functions as the other clients that drive a monitor — Spotify, a general web browser +# with uBlock Origin, a general mp3/mp4 player, and working audio — with those apps +# only launched when Big Picture is actually left. This script is the "launch them" +# half; steam-session and session-watcher are the two things that call it. +# +# WHY LAZY AT ALL +# --------------- +# Not tidiness. A machine that is playing a game is the one machine here where a +# background Firefox and a background Electron/Spotify client cost something visible: +# they hold GPU memory, they wake the CPU on timers, and Spotify in particular keeps an +# audio stream open that shows up as stutter in a title that is already frame-limited. +# Starting them the first time somebody leaves the game means a gaming session pays +# nothing for media features it isn't using. +# +# IDEMPOTENCE +# ----------- +# Every launch below is guarded by its own pgrep, and the whole start path holds an +# flock, so: +# - the two triggers firing at once cannot produce two Spotifys; +# - leaving and re-entering Big Picture repeatedly costs one pgrep per app; +# - an app the user closed by hand comes back the next time they leave the game, +# which is what "the media session is up" should mean. +# There is deliberately no "already started" flag file: a flag would go stale the +# moment somebody quit one of the apps, and the per-app check is the honest question. +# +# WHAT LEAVING BIG PICTURE DOES *NOT* DO +# -------------------------------------- +# Going back into Steam does not stop any of this. Killing a running Spotify because +# someone launched a game would lose whatever was playing — and people do play music +# over a game on purpose. `media-session stop` exists for when you actually want the +# machine quiet (it is wired to an HA button); nothing calls it automatically. +set -eu + +WS_WEB="3:web" +WS_MEDIA="4:media" +WS_MUSIC="5:music" + +LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp}" +LOCK_FILE="${LOCK_DIR}/steamtv-media-session.lock" + +log() { echo "media-session: $*"; } + +# Launch `command` on `workspace` unless something matching `pattern` is already this +# user's. The workspace switch happens before the launch for the same reason +# steamtv_agent.sway_control.launch_app does it that way: sway places a new window on +# whatever workspace is focused when it maps, and there is no reliable per-app `assign` +# rule for two Firefox profiles or for a flatpak's app_id. +launch_unless_running() { + pattern="$1" + workspace="$2" + shift 2 + + if pgrep -u "$(id -u)" -f "$pattern" >/dev/null 2>&1; then + log "$1 already running" + return 0 + fi + + log "launching $1 on $workspace" + swaymsg workspace "$workspace" >/dev/null 2>&1 || true + setsid "$@" >/dev/null 2>&1 & + # Give sway a moment to map the window on the workspace we just switched to, before + # the next launch switches away again. Crude, and correct: the alternative is + # subscribing to window events for each app, which is a lot of machinery for a path + # that runs once per session. + sleep 1 +} + +start() { + focus_after="${1:-}" + + launch_unless_running "com.spotify.Client" "$WS_MUSIC" /usr/local/bin/spotify-launch + launch_unless_running "media-player-idle" "$WS_MEDIA" /usr/local/bin/media-player + # web-browser has its own focus-an-existing-window logic (it holds state a relaunch + # would throw away), so the pattern here matches its profile directory rather than + # the bare binary — a second Firefox on a different profile is a different window. + launch_unless_running "firefox.*--profile.*/firefox/web" "$WS_WEB" /usr/local/bin/web-browser + + if [ "$focus_after" = "--focus" ]; then + swaymsg workspace "$WS_WEB" >/dev/null 2>&1 || true + fi + + log "media session up" +} + +stop() { + # pkill by the same patterns the launches are guarded by, so start/stop cannot + # disagree about what counts as "running". SIGTERM only — Spotify and Firefox both + # persist state on exit, and a SIGKILL here is how you get a "Firefox didn't shut + # down properly" dialog on the TV next time. + for pattern in "com.spotify.Client" "media-player-idle" "firefox.*--profile.*/firefox/web"; do + pkill -u "$(id -u)" -f "$pattern" >/dev/null 2>&1 || true + done + log "media session stopped" +} + +case "${1:-start}" in + start) + shift 2>/dev/null || true + # flock serialises the two triggers. -n: if another start is already in flight, + # this one has nothing to add — the in-flight one will launch the same apps. + if command -v flock >/dev/null 2>&1; then + exec 9>"$LOCK_FILE" + if ! flock -n 9; then + log "another start is already running; nothing to do" + exit 0 + fi + fi + start "${1:-}" + ;; + stop) + stop + ;; + status) + for pattern in "com.spotify.Client" "media-player-idle" "firefox.*--profile.*/firefox/web"; do + if pgrep -u "$(id -u)" -f "$pattern" >/dev/null 2>&1; then + echo "running: $pattern" + else + echo "stopped: $pattern" + fi + done + ;; + *) + echo "usage: media-session [start [--focus] | stop | status]" >&2 + exit 2 + ;; +esac diff --git a/hosts/steam-tv-box/configs/session/prism-launch b/hosts/steam-tv-box/configs/session/prism-launch new file mode 100755 index 0000000..496c7b9 --- /dev/null +++ b/hosts/steam-tv-box/configs/session/prism-launch @@ -0,0 +1,99 @@ +#!/bin/sh +# Prism Launcher (Minecraft). Installed to /usr/local/bin/prism-launch. +# +# Prism rather than the official launcher: it runs offline once an account has been +# authenticated, it manages several instances/modpacks side by side, and it is packaged +# on Flathub, so it comes from the same channel as Spotify here and Steam Link on the +# thin client. 0400-flatpak-apps.hook.chroot installs it. +# +# THIS RUNS THROUGH STEAM, NOT BESIDE IT +# -------------------------------------- +# Called with no arguments, this does not start Prism directly — it asks Steam to start +# it, via the non-Steam-game shortcut that /usr/local/bin/steam-shortcut-prism +# registers. That indirection is the entire point: a Prism launched by Steam runs inside +# the Steam Runtime with Steam Input active, so the Steam Controller API is present for +# it. Gamepads arrive as configurable Steam controllers with per-game bindings, the +# overlay works, gyro and back-buttons on Deck-style pads work, and Minecraft appears in +# Big Picture's library like any other title instead of being a hole you fall out of the +# UI into. Launched directly, Minecraft sees a raw evdev pad and none of that exists. +# +# `--steam` is what Steam passes back in when it runs the shortcut (it is the shortcut's +# LaunchOptions), and it is how this script knows not to bounce the request back to +# Steam a second time. Without that flag the two would call each other forever. +# +# FALLBACKS, IN ORDER: if Steam is not installed, or nobody has logged into it yet (so +# there is no shortcut and no game id), this runs Prism directly. That is a working +# Minecraft with worse controller support, which is the right outcome — better than +# refusing to start on a box where somebody has not signed into Steam. +# +# Java: the image ships openjdk-21-jre and openjdk-8-jre, and the hook grants the +# sandbox read access to /usr/lib/jvm, so Prism's auto-detection finds both without +# needing to download a runtime on first launch. 21 covers current Minecraft, 8 covers +# anything before 1.17 — which is most of what a modpack older than a couple of years +# actually wants. +# +# Login is interactive and on-device the first time (a Microsoft account is required — +# Mojang's device-code flow, so it is a short code typed on a phone rather than a +# password typed with a gamepad). The Flatpak's own persistent data directory +# (~/.var/app/org.prismlauncher.PrismLauncher) keeps that across restarts, and nothing +# here reseeds it — unlike the kiosk Firefox profiles, staying logged in is the point. +# +# Instances live under ~/Games if that is where you put them; see the README on +# mounting a real disk there, since this is the other thing on this box (with the Steam +# library) that will not fit in a live session's RAM overlay. +set -eu + +GAME_ID_FILE="${HOME:-/home/$(id -un)}/.local/state/steamtv/prism-gameid" +DIRECT="" + +# Strip the marker so it is never passed on to Prism itself, and keep any real +# arguments (an instance name, a .mrpack to import) for the direct launch below. +if [ "${1:-}" = "--steam" ]; then + DIRECT="yes" + shift +fi + +run_direct() { + if ! flatpak info org.prismlauncher.PrismLauncher >/dev/null 2>&1; then + echo "prism-launch: Prism Launcher is not installed. Install it with:" >&2 + echo " flatpak install -y flathub org.prismlauncher.PrismLauncher" >&2 + exit 1 + fi + exec flatpak run org.prismlauncher.PrismLauncher "$@" +} + +# Steam is already the parent — run the real thing. +if [ -n "$DIRECT" ]; then + run_direct "$@" +fi + +# Arguments mean somebody is opening a specific instance or file from a shell; hand +# that straight to Prism rather than through a Steam URL that cannot carry it. +if [ -n "${1:-}" ]; then + run_direct "$@" +fi + +if ! command -v steam >/dev/null 2>&1; then + echo "prism-launch: Steam is not installed; launching Prism directly (no Steam Input)." + run_direct +fi + +if [ ! -r "$GAME_ID_FILE" ]; then + # steam-shortcut-prism writes this on every session, so its absence means that script + # has never run — not that Steam is unusable. + echo "prism-launch: no Steam shortcut registered yet; launching Prism directly." + echo " Run /usr/local/bin/steam-shortcut-prism (with Steam closed) to fix that." + run_direct +fi + +GAME_ID="$(head -n 1 "$GAME_ID_FILE" | tr -dc '0-9')" + +if [ -z "$GAME_ID" ]; then + echo "prism-launch: $GAME_ID_FILE is malformed; launching Prism directly." >&2 + run_direct +fi + +# `steam -applaunch` does not work for non-Steam shortcuts — the rungameid URL is the +# only handle Steam exposes for them. A running client picks this up immediately; a +# stopped one starts first and then launches it. +exec steam "steam://rungameid/${GAME_ID}" diff --git a/hosts/steam-tv-box/configs/session/session-watcher b/hosts/steam-tv-box/configs/session/session-watcher new file mode 100755 index 0000000..070f364 --- /dev/null +++ b/hosts/steam-tv-box/configs/session/session-watcher @@ -0,0 +1,60 @@ +#!/bin/sh +# Watches sway for the user leaving Big Picture. Installed to /usr/local/bin/session-watcher. +# +# THIS IS THE "MEDIA APPS ONLY START WHEN YOU LEAVE BIG PICTURE" MECHANISM, HALF TWO. +# Read steam-session's header first — it explains why there are two halves. +# +# The case this one covers: Steam is *still running*, but the user is no longer looking +# at it. "Exit Big Picture mode" (which drops to Steam's desktop client), the remote's +# channel keys, $mod+3, or Home Assistant's Screen select — all of those change the +# focused sway workspace and none of them make the Steam process exit, so steam-session +# never resumes and something else has to notice. +# +# WHY WORKSPACE EVENTS AND NOT WINDOW TITLES +# ------------------------------------------ +# The obvious implementation is to poll for a window titled "Steam Big Picture Mode" +# and react when it goes away. That was rejected: the title, the window class and the +# very existence of a separate Big Picture window have all changed across Steam client +# rewrites, and a media session that silently stops appearing after a Steam update is a +# bad failure — it looks like the image is broken, not like a string moved. A sway +# workspace name is a contract this repo owns and can only change by editing the sway +# config and steamtv_agent/sway_control.py together. +# +# Cost: one blocked read on a socket, and a jq per workspace switch. It publishes +# nothing and listens on nothing — the session-mode sensor in Home Assistant is +# steamtv-agent asking sway, not this script pushing anywhere. (Adding an inbound +# listener here would breach the MQTT-is-the-only-control-surface rule; see +# steamtv_agent/mqtt_discovery.py.) +set -eu + +WS_STEAM="1:steam" + +if ! command -v jq >/dev/null 2>&1; then + echo "session-watcher: jq is missing; the media session will only start when Steam" >&2 + echo " itself exits (steam-session's path). Install jq and reload sway." >&2 + exit 1 +fi + +# `swaymsg -m` streams one JSON object per event line and blocks forever. If sway goes +# away the pipe closes and this exits — which is correct, since sway restarting means +# `exec_always` starts a fresh watcher. +swaymsg -t subscribe -m '["workspace"]' 2>/dev/null | while read -r event; do + # "focus" is the only change that means the user went somewhere; "init", "empty" and + # "rename" fire during ordinary window churn and must not trigger a launch. + change=$(printf '%s' "$event" | jq -r '.change // empty' 2>/dev/null) || continue + [ "$change" = "focus" ] || continue + + current=$(printf '%s' "$event" | jq -r '.current.name // empty' 2>/dev/null) || continue + [ -n "$current" ] || continue + # An `if` rather than `[ … ] && continue`: the && form returns non-zero when the test + # is false, which under `set -e` would kill the watcher the first time somebody moved + # to a non-Steam workspace — i.e. exactly once, silently, on the first use. + if [ "$current" = "$WS_STEAM" ]; then + continue + fi + + # No --focus: the user has already chosen where they want to be, and yanking them to + # the browser workspace because they pressed the channel key would be worse than + # doing nothing. This only makes sure the apps exist. + /usr/local/bin/media-session start >/dev/null 2>&1 || true +done diff --git a/hosts/steam-tv-box/configs/session/spotify-launch b/hosts/steam-tv-box/configs/session/spotify-launch new file mode 100755 index 0000000..98851e1 --- /dev/null +++ b/hosts/steam-tv-box/configs/session/spotify-launch @@ -0,0 +1,26 @@ +#!/bin/sh +# Full Spotify GUI client. Installed to /usr/local/bin/spotify-launch. +# +# The real, official Spotify Linux client — its own library browser, search and +# playlists — not spotifyd/librespot. Those are the thin client's and audio-endpoint's +# headless Spotify Connect *receivers*, which have no UI at all; this machine is sat in +# front of with a remote in hand, so it wants the browsable app, same as the touch +# panel. Spotify ships no apt package, so it comes from Flathub +# (0400-flatpak-apps.hook.chroot installs it). +# +# Login is interactive, on-device, on first launch (a Spotify account is needed; +# Premium for playback). The Flatpak's persistent data directory +# (~/.var/app/com.spotify.Client) keeps that session across restarts. +# +# Started lazily — see media-session. Spotify is one of the two apps that measurably +# costs a running game something if it sits in the background, which is most of why the +# media half of this image does not start at boot. +set -eu + +if ! flatpak info com.spotify.Client >/dev/null 2>&1; then + echo "spotify-launch: Spotify is not installed. Install it with:" >&2 + echo " flatpak install -y flathub com.spotify.Client" >&2 + exit 1 +fi + +exec flatpak run com.spotify.Client "$@" diff --git a/hosts/steam-tv-box/configs/session/steam-big-picture b/hosts/steam-tv-box/configs/session/steam-big-picture new file mode 100755 index 0000000..c59551d --- /dev/null +++ b/hosts/steam-tv-box/configs/session/steam-big-picture @@ -0,0 +1,57 @@ +#!/bin/sh +# Launches Steam in Big Picture (gamepad UI). Installed to /usr/local/bin/steam-big-picture. +# +# This is the image's default application: the sway config execs steam-session, which +# execs this, and this is what the TV shows within a few seconds of power-on. Nothing +# else is running at that point — see media-session for why. +# +# GAMESCOPE +# --------- +# When gamescope is present (0300-steam.hook.chroot installs it if the release has it) +# Steam runs nested inside it. That is what Valve ships on the Deck and it buys three +# things that matter on a television: a fixed output resolution and refresh rate that a +# game cannot change out from under the compositor, integer/FSR scaling so a 1080p game +# on a 4K set is sharp rather than smeared, and a framerate limiter. Without gamescope +# Steam runs directly on Xwayland — fully functional, just without those. +# +# IDEMPOTENT: relaunching while Steam is already up focuses the existing client instead +# of starting a second one (Steam would refuse anyway, but noisily, and the second +# process would sit in the session doing nothing). This is what makes the HA "Launch +# Steam" button and the remote's own key safe to press repeatedly. +set -eu + +WS_STEAM="1:steam" + +if ! command -v steam >/dev/null 2>&1; then + echo "steam-big-picture: Steam is not installed on this image — see" >&2 + echo " live-build/config/hooks/normal/0300-steam.hook.chroot, which logs why." >&2 + exit 1 +fi + +# Already running: switch to it and focus rather than starting another client. +if pgrep -u "$(id -u)" -x steam >/dev/null 2>&1; then + swaymsg workspace "$WS_STEAM" >/dev/null 2>&1 || true + swaymsg '[class="^[Ss]team$"] focus' >/dev/null 2>&1 || true + # Steam's own URL handler is the documented way to ask a *running* client to go back + # into Big Picture; there is no command-line flag that does it to an existing process. + exec steam steam://open/bigpicture +fi + +# -gamepadui is the current Big Picture. -tenfoot is the old one and is gone; if this +# ever stops opening the gamepad UI, that flag name is the first thing to check against +# the installed client (`steam -help`). +STEAM_ARGS="-gamepadui -nochatui -nofriendsui" + +if command -v gamescope >/dev/null 2>&1 && [ "${STEAMTV_USE_GAMESCOPE:-auto}" != "false" ]; then + # -f fullscreen, -e Steam integration (lets Steam drive resolution per game), + # --adaptive-sync hands VRR through to a set that supports it. + # Output geometry is deliberately NOT pinned here: gamescope defaults to the + # connected display's native mode, and hardcoding 1920x1080 would be wrong on the 4K + # set this is most likely plugged into. Pin it in STEAMTV_GAMESCOPE_ARGS if a + # specific title needs it. + # shellcheck disable=SC2086 + exec gamescope -f -e --adaptive-sync ${STEAMTV_GAMESCOPE_ARGS:-} -- steam $STEAM_ARGS +fi + +# shellcheck disable=SC2086 +exec steam $STEAM_ARGS diff --git a/hosts/steam-tv-box/configs/session/steam-session b/hosts/steam-tv-box/configs/session/steam-session new file mode 100755 index 0000000..521fae3 --- /dev/null +++ b/hosts/steam-tv-box/configs/session/steam-session @@ -0,0 +1,47 @@ +#!/bin/sh +# The session's first and normally only process. Installed to /usr/local/bin/steam-session. +# +# THIS IS THE "MEDIA APPS ONLY START WHEN YOU LEAVE BIG PICTURE" MECHANISM, HALF ONE. +# +# The box boots to Steam and nothing else: no browser, no Spotify, no player. Those are +# started the first time somebody actually leaves Big Picture. Two independent triggers +# do that, because there are two different ways to leave and neither one can see the +# other: +# +# 1. THIS SCRIPT — "Exit Steam" / the client crashing. steam-big-picture runs in the +# foreground, so when the Steam client goes away this script resumes on the line +# after it and brings the media session up, focused. +# 2. /usr/local/bin/session-watcher — "Exit Big Picture mode", alt-tabbing, the +# remote's channel keys, or the HA workspace select. Steam is still running, so +# trigger 1 never fires; what changes is the focused sway workspace, which the +# watcher subscribes to. +# +# Both call `media-session start`, which is idempotent, so whichever fires first wins +# and the second is a no-op. That is the whole design — no window-title matching, no +# polling for Big Picture's internal state, nothing that breaks when Valve reshuffles +# the UI. See media-session for the guards that make double-firing free. +# +# WHY THIS IS NOT A LOOP: if Steam exits, it stays exited. Relaunching it automatically +# would make "Exit Steam" impossible to act on from the sofa, and the box would never +# be usable as anything but a games console. Getting back in is a button (HA, the +# remote's Home key, or $mod+s) — see the sway config. +set -eu + +# Register Prism Launcher as a non-Steam game BEFORE Steam starts. Ordering is not +# incidental: Steam reads shortcuts.vdf at startup and rewrites it from memory when it +# exits, so anything written while it is running is silently discarded. This is also +# why the registration lives here rather than in an `exec_always` in the sway config, +# where it would race the client. Idempotent, and a no-op until somebody has logged +# into Steam on this machine — see the script's own header for why it cannot be baked +# into the image. +/usr/local/bin/steam-shortcut-prism || \ + echo "steam-session: could not register the Prism shortcut; Prism will still launch," \ + "just without Steam Input" + +/usr/local/bin/steam-big-picture || \ + echo "steam-session: Steam exited non-zero; falling through to the media session anyway" + +# --focus because this path means the screen is now showing nothing at all: Steam has +# quit and the user is looking at an empty compositor. The watcher's path deliberately +# does not focus, since there the user already chose where to be. +exec /usr/local/bin/media-session start --focus diff --git a/hosts/steam-tv-box/configs/session/steam-shortcut-prism b/hosts/steam-tv-box/configs/session/steam-shortcut-prism new file mode 100755 index 0000000..8361f29 --- /dev/null +++ b/hosts/steam-tv-box/configs/session/steam-shortcut-prism @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Registers Prism Launcher as a Steam "non-Steam game". Installed to +/usr/local/bin/steam-shortcut-prism, run by steam-session before Steam starts. + +WHY +--- +Launching Prism directly gets you a plain Flatpak window and a Minecraft that sees a +raw evdev gamepad, which is to say: no controller support worth the name. Launching it +*through* Steam puts it inside the Steam Runtime with Steam Input active, which is what +makes the Steam Controller API present for it. Concretely that means the overlay works, +the pad shows up as a configurable controller with Steam's own per-game bindings, the +box's Steam Controller / Deck-style pads get their gyro and back buttons, and Big +Picture treats Minecraft like any other title in the library instead of a hole you fall +out of the UI into. + +There is no command-line flag for this. Steam's only mechanism for a non-Steam game is +an entry in the user's binary `shortcuts.vdf`, which is what this writes. + +WHY IT CANNOT BE BAKED INTO THE IMAGE +------------------------------------- +shortcuts.vdf lives under ~/.steam/steam/userdata//config/, and that +directory does not exist until somebody has logged into Steam on this machine. The +account is not known at build time and must not be — no Steam credentials go into an +ISO. So this runs per-session instead, is idempotent, and no-ops quietly when Steam has +never been logged into (the common state on a freshly flashed box, right up until +somebody signs in on the TV). + +WHY IT RUNS BEFORE STEAM STARTS +------------------------------- +Steam reads shortcuts.vdf at startup and rewrites it from memory at shutdown. Writing +it underneath a running client means the change is silently reverted the next time +Steam exits. steam-session calls this first for that reason; running it by hand while +Steam is up is refused below rather than being allowed to quietly do nothing. + +FORMAT NOTE / VERIFY BEFORE TRUSTING IT +--------------------------------------- +The binary VDF encoding below (0x00 map, 0x01 string, 0x02 int32, 0x08 end) and the +non-Steam AppID derivation (CRC32 of Exe+AppName, high bit set) are the long-standing, +widely-reimplemented community format — Valve documents neither. They are believed +correct but were NOT verified against a real Steam client from this environment. The +check on first boot is simply: does "Prism Launcher" appear in Big Picture's library, +and does the pad work inside Minecraft. If the file turns out to be malformed Steam +discards it silently, which is why this keeps a .bak (see below) rather than writing in +place. +""" + +from __future__ import annotations + +import binascii +import glob +import os +import shutil +import subprocess +import sys +import time + +APP_NAME = "Prism Launcher" +# The wrapper, not `flatpak run …` directly: prism-launch is where the "is it even +# installed" check and the Java/instance notes live, and pointing Steam at a stable +# path means this entry does not change when the Flatpak app ID does. +EXE = "/usr/local/bin/prism-launch" +START_DIR = "/usr/local/bin" +# --steam tells prism-launch it is already inside Steam's runtime, so it does not +# recurse back through `steam steam://rungameid/...` and launch itself forever. +LAUNCH_OPTIONS = "--steam" + +USERDATA_GLOB = os.path.expanduser("~/.steam/steam/userdata/*/config") +# Flatpak'd and Snap'd Steam put userdata elsewhere; this image installs Steam from +# apt, so the path above is the real one. The alternates are checked anyway because +# somebody debugging on a laptop will have one of them. +ALT_GLOBS = ( + os.path.expanduser("~/.local/share/Steam/userdata/*/config"), + os.path.expanduser("~/.var/app/com.valvesoftware.Steam/data/Steam/userdata/*/config"), +) + + +def log(message: str) -> None: + print(f"steam-shortcut-prism: {message}") + + +# --- binary VDF ----------------------------------------------------------------- +def _string(key: str, value: str) -> bytes: + return b"\x01" + key.encode("utf-8") + b"\x00" + value.encode("utf-8") + b"\x00" + + +def _int32(key: str, value: int) -> bytes: + return b"\x02" + key.encode("utf-8") + b"\x00" + value.to_bytes(4, "little", signed=False) + + +def shortcut_app_id(exe: str, app_name: str) -> int: + """The 32-bit ID Steam gives a non-Steam shortcut. + + CRC32 of the Exe field concatenated with AppName, with the top bit set. Steam + quotes the Exe field in the file it writes, and the CRC is taken over the quoted + form — getting that wrong produces an ID that no `steam://rungameid/` URL matches, + which looks exactly like "the shortcut didn't work" with nothing in any log. + """ + key = f'"{exe}"{app_name}'.encode("utf-8") + return binascii.crc32(key) | 0x80000000 + + +def run_game_id(app_id: int) -> int: + """The 64-bit ID `steam://rungameid/` wants for a shortcut.""" + return (app_id << 32) | 0x02000000 + + +def encode_shortcuts(entries: list[dict]) -> bytes: + out = bytearray(b"\x00shortcuts\x00") + for index, entry in enumerate(entries): + out += b"\x00" + str(index).encode("ascii") + b"\x00" + out += _int32("appid", entry["appid"]) + out += _string("AppName", entry["AppName"]) + out += _string("Exe", entry["Exe"]) + out += _string("StartDir", entry["StartDir"]) + out += _string("icon", entry.get("icon", "")) + out += _string("ShortcutPath", entry.get("ShortcutPath", "")) + out += _string("LaunchOptions", entry.get("LaunchOptions", "")) + out += _int32("IsHidden", 0) + # AllowDesktopConfig + AllowOverlay are the two that matter for the whole point + # of this file: the overlay is what carries Steam Input's binding UI, and + # desktop-config is what lets a pad still work when Big Picture is not focused. + out += _int32("AllowDesktopConfig", 1) + out += _int32("AllowOverlay", 1) + out += _int32("OpenVR", 0) + out += _int32("Devkit", 0) + out += _string("DevkitGameID", "") + out += _int32("DevkitOverrideAppID", 0) + out += _int32("LastPlayTime", entry.get("LastPlayTime", 0)) + out += b"\x00tags\x00\x08" + out += b"\x08" + out += b"\x08\x08" + return bytes(out) + + +def decode_app_names(data: bytes) -> list[str]: + """Just enough parsing to answer "is our entry already in here?". + + A full VDF reader is not needed and would be more to get wrong: this only has to + decide between rewriting the file and leaving it alone. + """ + names = [] + marker = b"\x01AppName\x00" + position = data.find(marker) + while position != -1: + start = position + len(marker) + end = data.find(b"\x00", start) + if end == -1: + break + names.append(data[start:end].decode("utf-8", "replace")) + position = data.find(marker, end) + return names + + +# --- the work ------------------------------------------------------------------- +def config_dirs() -> list[str]: + found = sorted(glob.glob(USERDATA_GLOB)) + for pattern in ALT_GLOBS: + found += sorted(glob.glob(pattern)) + # userdata/0/ is Steam's placeholder for "no account", not a real profile. + return [d for d in found if os.path.basename(os.path.dirname(d)) != "0"] + + +def steam_is_running() -> bool: + try: + return subprocess.run( + ["pgrep", "-u", str(os.getuid()), "-x", "steam"], + capture_output=True, + check=False, + timeout=5, + ).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def write_shortcut(config_dir: str, app_id: int) -> bool: + path = os.path.join(config_dir, "shortcuts.vdf") + + existing = b"" + if os.path.exists(path): + try: + with open(path, "rb") as handle: + existing = handle.read() + except OSError as exc: + log(f"could not read {path}: {exc}") + return False + + if APP_NAME in decode_app_names(existing): + log(f"{APP_NAME} is already in {path}") + return True + + if existing: + # Anything already in shortcuts.vdf was put there by hand, and this parser is + # not good enough to rewrite the file without losing it. So: back it up, tell + # the human exactly what to do, and refuse rather than destroy their entries. + backup = f"{path}.bak-{int(time.time())}" + try: + shutil.copyfile(path, backup) + except OSError as exc: + log(f"could not back up {path}: {exc}") + return False + log(f"{path} already has other shortcuts in it; backed it up to {backup}.") + log("Refusing to rewrite it — this script only knows how to write a file it") + log("owns entirely, and rewriting would drop the entries already there.") + log(f"Add {APP_NAME} by hand in Steam (Games -> Add a Non-Steam Game -> {EXE}),") + log(f"then set its launch options to: {LAUNCH_OPTIONS}") + return False + + entry = { + "appid": app_id, + "AppName": APP_NAME, + "Exe": f'"{EXE}"', + "StartDir": f'"{START_DIR}"', + "LaunchOptions": LAUNCH_OPTIONS, + } + + try: + os.makedirs(config_dir, exist_ok=True) + temporary = f"{path}.tmp" + with open(temporary, "wb") as handle: + handle.write(encode_shortcuts([entry])) + os.replace(temporary, path) + except OSError as exc: + log(f"could not write {path}: {exc}") + return False + + log(f"registered {APP_NAME} in {path} (appid {app_id})") + return True + + +def main() -> int: + app_id = shortcut_app_id(EXE, APP_NAME) + game_id = run_game_id(app_id) + + # Written unconditionally, even when there is no Steam profile yet: prism-launch + # reads this to build its steam://rungameid/ URL, and it is derived from two + # constants in this file, so it is correct whether or not the shortcut exists yet. + state_dir = os.path.expanduser("~/.local/state/steamtv") + try: + os.makedirs(state_dir, exist_ok=True) + with open(os.path.join(state_dir, "prism-gameid"), "w", encoding="utf-8") as handle: + handle.write(f"{game_id}\n") + except OSError as exc: + log(f"could not record the game id: {exc}") + + if steam_is_running(): + log("Steam is running — it would overwrite shortcuts.vdf on exit and discard") + log("anything written now. Quit Steam and re-run, or just let the next session") + log("do it (steam-session runs this before Steam starts).") + return 1 + + dirs = config_dirs() + if not dirs: + log("no Steam userdata directory yet — nobody has logged into Steam on this") + log("machine. Nothing to do; this will register itself on the session after") + log("the first Steam login.") + return 0 + + ok = True + for config_dir in dirs: + # Every logged-in account on the box gets the entry: which one is signed in at + # any moment is not knowable here, and a stale entry for an account that never + # plays Minecraft costs nothing. + ok = write_shortcut(config_dir, app_id) and ok + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hosts/steam-tv-box/configs/sway/config b/hosts/steam-tv-box/configs/sway/config new file mode 100644 index 0000000..4587c9a --- /dev/null +++ b/hosts/steam-tv-box/configs/sway/config @@ -0,0 +1,203 @@ +# Sway session for the Steam TV box. +# Installed to /home//.config/sway/config by build-steam-tv-box-iso.sh. +# +# Every `exec` below inherits the environment set by /usr/local/bin/kiosk-session, +# which sources /etc/steamtv-agent/config.env — that is how GPU_VENDOR and the MQTT +# settings get here without this file being templated. +# +# THE SHAPE OF THIS SESSION, IN ONE PARAGRAPH: the box powers on, greetd autologins, +# sway starts, and the only thing it launches is Steam in Big Picture. The browser, +# Spotify and the player do not exist yet. The first time somebody leaves Big Picture — +# by quitting Steam, by exiting Big Picture mode, by pressing a workspace key, or via +# Home Assistant — the media session is brought up behind them. See steam-session and +# session-watcher, which are the two halves of that. + +set $mod Mod4 +set $ws_steam 1:steam +set $ws_games 2:games +set $ws_web 3:web +set $ws_media 4:media +set $ws_music 5:music + +# Workspace names are a contract with steamtv_agent/sway_control.py, +# steamtv_agent/session_mode.py, /usr/local/bin/media-session and +# /usr/local/bin/session-watcher — changing one side means changing all of them. + +# --------------------------------------------------------------------------- +# Output / input +# --------------------------------------------------------------------------- +output * bg #0b0b10 solid_color + +input type:keyboard { + xkb_layout @KEYBOARD_LAYOUT@ +} + +# A gamepad is not an input device to the compositor — it goes straight to Steam and to +# games via evdev, so there is nothing to configure here for it. What does need +# configuring is a mouse/trackball on the coffee table, if there is one. +input type:pointer { + accel_profile adaptive +} + +# --------------------------------------------------------------------------- +# Look — no bars, no borders, no gaps. This is a television. +# --------------------------------------------------------------------------- +default_border none +default_floating_border none +hide_edge_borders both +gaps inner 0 +gaps outer 0 +# Games and Big Picture must never be tiled next to something else; every workspace +# here holds exactly one thing that fills the screen. +for_window [class="^[Ss]team$"] fullscreen enable +for_window [class="^gamescope$"] fullscreen enable +for_window [app_id="^gamescope$"] fullscreen enable +for_window [app_id="mpv"] fullscreen enable + +# --------------------------------------------------------------------------- +# Remote control (administration, not play) +# --------------------------------------------------------------------------- +# exec_always so a `swaymsg reload` re-establishes it. start-wayvnc refuses to run +# until /etc/wayvnc/wayvnc-password has been set on this machine. +# +# Worth being explicit: this is for fixing the box, not for playing over. wayvnc streams +# the compositor, so a game rendering at 120fps arrives as a slideshow — that is not a +# defect to work around, it is what a screen-scraping protocol does. +exec_always /usr/local/bin/start-wayvnc + +# The workspace-focus half of the leave-Big-Picture detection. Its own exit-on- +# sway-death behaviour makes exec_always safe across a reload: the old watcher's pipe +# closes with the old sway, and reload starts one watcher, not a second. +exec_always /usr/local/bin/session-watcher + +# steamtv-agent is NOT started here. systemd owns it (steamtv-agent.service, enabled by +# 0700-steamtv-agent.hook.chroot) so that it is up and connected to Mosquitto whether or +# not a graphical session ever came up, and so it survives a sway restart. Starting it +# from sway too would give two competing MQTT clients. + +# --------------------------------------------------------------------------- +# The default application +# --------------------------------------------------------------------------- +# This is the whole boot behaviour: land on 1:steam and start Big Picture. steam-session +# blocks on the Steam client and starts the media session when it exits. +# +# No `assign [class="steam"]` rule: Steam maps several windows (splash, client, an +# optional gamescope surface) and an assign rule would scatter them across workspaces +# mid-launch. Switching workspace first and letting them map where focus already is +# gets the same result without the race. +exec swaymsg workspace $ws_steam +exec /usr/local/bin/steam-session + +# --------------------------------------------------------------------------- +# Idle +# --------------------------------------------------------------------------- +# Never lock: this is a shared living-room machine, and a lock screen would make the TV +# unusable to anyone not holding a keyboard. +# +# 20 minutes, and only the display — noticeably longer than the thin client's 15, +# because "nobody has touched an input device" is a much weaker signal here. Watching a +# two-hour film, or a cutscene, or a turn in a slow strategy game are all legitimately +# input-free, and the inhibit rules below are what keep the screen alive through them. +exec swayidle -w \ + timeout 1200 'swaymsg "output * power off"' \ + resume 'swaymsg "output * power on"' + +# Anything playing keeps the screen on. Steam gets `focus` rather than `fullscreen` +# because Big Picture is fullscreen essentially always, and a game that alt-tabs itself +# briefly should not start the idle countdown. +for_window [class="^[Ss]team$"] inhibit_idle focus +for_window [class="^gamescope$"] inhibit_idle focus +for_window [app_id="^gamescope$"] inhibit_idle focus +for_window [app_id="mpv"] inhibit_idle visible +for_window [app_id="firefox-esr"] inhibit_idle fullscreen +for_window [app_id="com.spotify.Client"] inhibit_idle focus + +# --------------------------------------------------------------------------- +# Local keys. A gamepad drives Steam and games; these are for the keyboard on the back +# of the remote, and for standing in front of the machine. +# --------------------------------------------------------------------------- + +# Maintenance shell. A deliberately obscure chord (not $mod+Return, which is the +# ordinary local-terminal key below) so it is not something a visitor bumps into, +# floating so it overlays whatever is running instead of tiling against it. +bindsym $mod+Shift+Ctrl+m exec foot --title maintenance-shell +for_window [title="maintenance-shell"] floating enable, resize set width 900 height 550, move position center + +bindsym $mod+Return exec foot +bindsym $mod+q kill +bindsym $mod+f fullscreen toggle +bindsym $mod+Shift+c reload + +# Getting back into Steam after "Exit Steam" — the counterpart to steam-session's +# deliberate refusal to relaunch it in a loop. Idempotent, so holding the key does +# nothing worse than focusing the client. +bindsym $mod+s exec /usr/local/bin/steam-big-picture +# And bringing the media half up by hand, for the case where somebody wants Spotify +# without leaving the game. +bindsym $mod+m exec /usr/local/bin/media-session start +# Prism/Minecraft. This goes through Steam (steam://rungameid/…) so Steam Input is +# active for it — see prism-launch. Which is also why its window lands on 1:steam and +# not 2:games when Steam is running: it *is* a Steam game as far as the client is +# concerned, and Big Picture shows it in the library. +bindsym $mod+p exec /usr/local/bin/prism-launch + +bindsym $mod+1 workspace $ws_steam +bindsym $mod+2 workspace $ws_games +bindsym $mod+3 workspace $ws_web +bindsym $mod+4 workspace $ws_media +bindsym $mod+5 workspace $ws_music +bindsym $mod+Left focus left +bindsym $mod+Right focus right +bindsym $mod+Up focus up +bindsym $mod+Down focus down + +# --------------------------------------------------------------------------- +# Remote control — the standardised media-key set. +# +# Same device class as the thin client's: a wireless USB remote that presents as two +# HID keyboards (TV controls on the front, a small keyboard on the back). Nothing to +# configure per-device; the front buttons arrive as the XF86* keysyms below. +# +# DELIBERATELY NOT BOUND: plain arrows and Return. Steam's Big Picture, mpv and every +# web page need them, and a remote's D-pad and OK button send exactly those. Stealing +# them at the compositor would make Big Picture unnavigable with the remote — which is +# most of what the remote is for here. Window focus stays on $mod+arrows above. +# +# `wev` from the maintenance shell prints the keysym for any button that is not bound. +# --------------------------------------------------------------------------- + +# Transport. `playerctl -p mpv,spotify` matches the two players this image runs; the +# order is the priority when both are alive. Steam's own media has no MPRIS bus and is +# not a target — a game's audio is not something you "pause". +bindsym XF86AudioPlay exec playerctl -p mpv,spotify play-pause +bindsym XF86AudioPause exec playerctl -p mpv,spotify pause +bindsym XF86AudioStop exec playerctl -p mpv,spotify stop +bindsym XF86AudioNext exec playerctl -p mpv,spotify next +bindsym XF86AudioPrev exec playerctl -p mpv,spotify previous +bindsym XF86AudioForward exec playerctl -p mpv,spotify position 30+ +bindsym XF86AudioRewind exec playerctl -p mpv,spotify position 10- + +# Volume. Sinks, not players — the volume rocker should move the room's volume whether +# the noise is coming from Spotify or from a game, and a game is the common case here. +bindsym XF86AudioRaiseVolume exec wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ +bindsym XF86AudioLowerVolume exec wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- +bindsym XF86AudioMute exec wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle + +# Navigation. +bindsym XF86ChannelUp workspace next +bindsym XF86ChannelDown workspace prev +# Home goes back to the games, because on this box that is home. +bindsym XF86HomePage exec /usr/local/bin/steam-big-picture +bindsym XF86Back workspace back_and_forth +bindsym XF86Forward workspace back_and_forth + +# Power. **The display, not the machine** — same reasoning as the thin client, and more +# so here: `poweroff` from the sofa would drop whatever game is running, unsaved. +bindsym XF86PowerOff exec display-toggle +bindsym XF86Sleep exec display-toggle +bindsym XF86ScreenSaver exec display-toggle + +# Deliberately no exit binding: `swaymsg exit` would drop to a black VT and greetd would +# autologin straight back in. Use SSH or the maintenance shell to administer. + +workspace $ws_steam diff --git a/hosts/steam-tv-box/configs/sway/display-toggle b/hosts/steam-tv-box/configs/sway/display-toggle new file mode 100755 index 0000000..c11fdf5 --- /dev/null +++ b/hosts/steam-tv-box/configs/sway/display-toggle @@ -0,0 +1,29 @@ +#!/bin/sh +# Turn every output off, or back on. Installed to /usr/local/bin/display-toggle and +# bound to the remote's power/sleep buttons in the Sway config. +# +# WHY THE POWER BUTTON DOES NOT POWER ANYTHING OFF: on a TV, that button turns the +# picture off. Here, `poweroff` would drop whatever game is running — unsaved — and +# take the room's screen away until somebody walks over to the machine. So the remote's +# power button does the thing the person pressing it actually meant. +# +# THE WAKE SIDE IS THE HARD HALF. With outputs powered off, Sway is still running and +# still receiving keys, so pressing power again lands here and turns them back on. That +# is why this is a toggle rather than two bindings: there is no other way back. It also +# means a stuck remote button cannot leave the screen dark — the next press fixes it. +# +# This is the local, no-network path. Home Assistant's "Display" switch goes through +# steamtv_agent/display_power.py instead, which also drives HDMI-CEC so the television +# itself goes to standby rather than just showing "no signal". +set -eu + +# `swaymsg -t get_outputs` reports each output's power state in a field still named +# "dpms" (the *command* was renamed to `power`; the JSON key was not). If ANY output is +# still on, the intent of a press is "turn it off"; only when everything is already +# dark does a press mean "wake up". That ordering matters on a multi-output machine, +# where asking per-output would leave the remote toggling one screen at a time. +if swaymsg -t get_outputs | grep -q '"dpms": true'; then + swaymsg 'output * power off' +else + swaymsg 'output * power on' +fi diff --git a/hosts/steam-tv-box/configs/wayvnc/config b/hosts/steam-tv-box/configs/wayvnc/config new file mode 100644 index 0000000..556012a --- /dev/null +++ b/hosts/steam-tv-box/configs/wayvnc/config @@ -0,0 +1,26 @@ +# wayvnc — installed to /etc/wayvnc/config by build-steam-tv-box-iso.sh. +# Format is plain key=value, one per line (not TOML, not INI sections). +# +# There is deliberately NO `password=` line in this file. wayvnc only accepts the +# password inline, so committing one here would put a live credential for a full +# remote-control channel into git. Instead /usr/local/bin/start-wayvnc reads +# /etc/wayvnc/wayvnc-password (mode 0600, never committed) and writes a merged config +# into $XDG_RUNTIME_DIR at session start. If that file still holds the build-time +# sentinel, start-wayvnc refuses to launch — no unauthenticated VNC server, ever. + +# Bound to all interfaces on purpose: wayvnc is this project's remote-control channel +# (the confirmed replacement for RDP), so it has to be reachable from the LAN, not +# just loopback. That is exactly why the auth below is not optional. +address=0.0.0.0 +port=5900 + +enable_auth=true +username=@KIOSK_USERNAME@ + +# wayvnc >= 0.7 uses this for RSA-AES auth. +rsa_private_key_file=/etc/wayvnc/rsa_key.pem + +# wayvnc <= 0.6 authenticates over TLS instead and needs these two; harmless on newer +# builds. Both are generated by 0300-wayvnc.hook.chroot, self-signed. +private_key_file=/etc/wayvnc/tls_key.pem +certificate_file=/etc/wayvnc/tls_cert.pem diff --git a/hosts/steam-tv-box/configs/wayvnc/start-wayvnc b/hosts/steam-tv-box/configs/wayvnc/start-wayvnc new file mode 100755 index 0000000..10428f6 --- /dev/null +++ b/hosts/steam-tv-box/configs/wayvnc/start-wayvnc @@ -0,0 +1,36 @@ +#!/bin/sh +# Launches wayvnc with a password that is never stored in the repo or in /etc/wayvnc/config. +# Installed to /usr/local/bin/start-wayvnc, started from the sway config. +set -eu + +BASE_CONFIG=/etc/wayvnc/config +PASSWORD_FILE=/etc/wayvnc/wayvnc-password +SENTINEL='CHANGEME-SET-ON-FIRST-BOOT' + +if [ ! -r "$PASSWORD_FILE" ]; then + echo "start-wayvnc: $PASSWORD_FILE is missing or unreadable — refusing to start." >&2 + exit 1 +fi + +PASSWORD="$(head -n 1 "$PASSWORD_FILE" | tr -d '\r\n')" + +# Fail closed. An operator who forgets this step gets no remote access, rather than a +# remote-control channel anyone on the LAN can open. +if [ -z "$PASSWORD" ] || [ "$PASSWORD" = "$SENTINEL" ]; then + echo "start-wayvnc: no wayvnc password set. Run, as root, on this machine:" >&2 + echo " openssl rand -base64 24 > $PASSWORD_FILE && chmod 600 $PASSWORD_FILE" >&2 + echo " chown $(id -un):$(id -gn) $PASSWORD_FILE" >&2 + echo "Then restart the session. Refusing to start an unauthenticated VNC server." >&2 + exit 1 +fi + +RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/wayvnc" +mkdir -p "$RUNTIME_DIR" +chmod 700 "$RUNTIME_DIR" + +RUNTIME_CONFIG="$RUNTIME_DIR/config" +umask 077 +cp "$BASE_CONFIG" "$RUNTIME_CONFIG" +printf 'password=%s\n' "$PASSWORD" >> "$RUNTIME_CONFIG" + +exec wayvnc --config="$RUNTIME_CONFIG" diff --git a/hosts/steam-tv-box/live-build/config/hooks/normal/0100-user-setup.hook.chroot b/hosts/steam-tv-box/live-build/config/hooks/normal/0100-user-setup.hook.chroot new file mode 100755 index 0000000..14add62 --- /dev/null +++ b/hosts/steam-tv-box/live-build/config/hooks/normal/0100-user-setup.hook.chroot @@ -0,0 +1,61 @@ +#!/bin/sh +# Creates the kiosk account the whole image is built around. +# +# live-build runs chroot_local-includes BEFORE chroot_local-hooks, so +# /etc/steamtv-agent/config.env (written by build-steam-tv-box-iso.sh) already exists +# here. Sourcing it is why hooks don't need placeholder/sed templating. +set -eu + +. /etc/steamtv-agent/config.env + +if ! id "$KIOSK_USERNAME" >/dev/null 2>&1; then + useradd --create-home --shell /bin/bash --comment "Steam TV box kiosk session" "$KIOSK_USERNAME" +fi + +# Same list as the thin client, plus `games` — some titles and emulators expect it, and +# `gamemode` needs the user in a group its daemon accepts before it will honour a +# governor request. +for grp in audio video input render dialout netdev plugdev seat _seatd games gamemode bluetooth; do + if getent group "$grp" >/dev/null 2>&1; then + adduser "$KIOSK_USERNAME" "$grp" >/dev/null + fi +done + +# No password is baked in: the account is locked so it can never be used to log in +# remotely, while the physical console still autologins via greetd. +passwd --lock "$KIOSK_USERNAME" >/dev/null + +adduser "$KIOSK_USERNAME" sudo >/dev/null + +# Passwordless sudo is a deliberate call, not laziness: this image autologins to an +# unattended interactive Sway session at the physical console, so anyone standing in +# front of the machine already has the equivalent of a root shell. Requiring a password +# here would buy nothing while making the locked account unadministrable. The +# boundaries that actually matter are the wayvnc password and key-only SSH below. +cat > "/etc/sudoers.d/010-${KIOSK_USERNAME}" < /etc/ssh/sshd_config.d/10-steam-tv-box.conf <<'EOF' +PermitRootLogin no +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +EOF + +if [ -d "/home/${KIOSK_USERNAME}/.ssh" ]; then + chmod 700 "/home/${KIOSK_USERNAME}/.ssh" + [ -f "/home/${KIOSK_USERNAME}/.ssh/authorized_keys" ] && \ + chmod 600 "/home/${KIOSK_USERNAME}/.ssh/authorized_keys" +fi + +# Games are large and this box has a real disk, so the library lives outside the live +# image's writable overlay by convention — see hosts/steam-tv-box/README.md for +# mounting a games disk here. Created either way so Steam's own first-run path exists. +mkdir -p "/home/${KIOSK_USERNAME}/Games" + +chown -R "${KIOSK_USERNAME}:${KIOSK_USERNAME}" "/home/${KIOSK_USERNAME}" + +systemctl enable ssh >/dev/null 2>&1 || true diff --git a/hosts/steam-tv-box/live-build/config/hooks/normal/0200-greetd.hook.chroot b/hosts/steam-tv-box/live-build/config/hooks/normal/0200-greetd.hook.chroot new file mode 100755 index 0000000..decd47c --- /dev/null +++ b/hosts/steam-tv-box/live-build/config/hooks/normal/0200-greetd.hook.chroot @@ -0,0 +1,25 @@ +#!/bin/sh +# Makes greetd the boot target so the machine comes up straight in the kiosk Sway +# session, which in turn launches Steam Big Picture (config in /etc/greetd/config.toml, +# shipped via includes.chroot). +set -eu + +. /etc/steamtv-agent/config.env + +chmod 0755 /usr/local/bin/kiosk-session + +# greetd's own package user; it still needs to exist even though no greeter UI runs. +if ! id greeter >/dev/null 2>&1; then + useradd --system --create-home --home-dir /var/lib/greetd --shell /usr/sbin/nologin greeter +fi + +systemctl enable greetd +systemctl set-default graphical.target + +# live-config would otherwise autologin its own account on tty1 and fight greetd for +# the VT. build-steam-tv-box-iso.sh passes `noautologin` on the kernel command line; +# masking the getty on vt1 makes that robust even if someone edits the boot args. +systemctl mask getty@tty1.service + +mkdir -p "/home/${KIOSK_USERNAME}/.config/sway" +chown -R "${KIOSK_USERNAME}:${KIOSK_USERNAME}" "/home/${KIOSK_USERNAME}/.config" diff --git a/hosts/steam-tv-box/live-build/config/hooks/normal/0250-wayvnc.hook.chroot b/hosts/steam-tv-box/live-build/config/hooks/normal/0250-wayvnc.hook.chroot new file mode 100755 index 0000000..1153bb5 --- /dev/null +++ b/hosts/steam-tv-box/live-build/config/hooks/normal/0250-wayvnc.hook.chroot @@ -0,0 +1,35 @@ +#!/bin/sh +# Prepares wayvnc's auth material. Deliberately does NOT set a password. +set -eu + +. /etc/steamtv-agent/config.env + +mkdir -p /etc/wayvnc +chmod 0755 /usr/local/bin/start-wayvnc + +# wayvnc's RSA-AES auth needs a key pair; it is machine-local and carries no secret +# that belongs in git, so generating it at build time is fine. +if [ ! -f /etc/wayvnc/rsa_key.pem ]; then + openssl genrsa -out /etc/wayvnc/rsa_key.pem 2048 2>/dev/null +fi + +# Self-signed TLS material, needed only by wayvnc <= 0.6 whose auth path is TLS-based +# rather than RSA-AES. Harmless on newer versions. +if [ ! -f /etc/wayvnc/tls_key.pem ]; then + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout /etc/wayvnc/tls_key.pem -out /etc/wayvnc/tls_cert.pem \ + -subj "/CN=steam-tv-box" 2>/dev/null +fi + +# Sentinel, not a password. start-wayvnc refuses to launch while this value is still +# here, so the failure mode of "operator forgot to set a password" is "no VNC server" +# rather than "an unauthenticated VNC server on the LAN". A real value must never be +# committed — see hosts/steam-tv-box/README.md. +if [ ! -f /etc/wayvnc/wayvnc-password ]; then + printf 'CHANGEME-SET-ON-FIRST-BOOT\n' > /etc/wayvnc/wayvnc-password +fi + +chmod 0600 /etc/wayvnc/wayvnc-password /etc/wayvnc/rsa_key.pem /etc/wayvnc/tls_key.pem +chown "${KIOSK_USERNAME}:${KIOSK_USERNAME}" \ + /etc/wayvnc/wayvnc-password /etc/wayvnc/rsa_key.pem /etc/wayvnc/tls_key.pem +chmod 0644 /etc/wayvnc/tls_cert.pem /etc/wayvnc/config diff --git a/hosts/steam-tv-box/live-build/config/hooks/normal/0300-steam.hook.chroot b/hosts/steam-tv-box/live-build/config/hooks/normal/0300-steam.hook.chroot new file mode 100755 index 0000000..136a347 --- /dev/null +++ b/hosts/steam-tv-box/live-build/config/hooks/normal/0300-steam.hook.chroot @@ -0,0 +1,130 @@ +#!/bin/sh +# Native Steam, the 32-bit graphics stack it needs, and the optional extras around it. +# +# WHY THIS IS A HOOK AND NOT A LINE IN steam-tv-box.list.chroot +# ------------------------------------------------------------ +# live-build installs config/package-lists/* BEFORE it runs config/hooks/normal/*, and +# `steam-installer` is unusable until i386 is a known architecture: it is an amd64 +# package whose entire job is to pull in an i386 dependency chain. Enabling multiarch +# has to happen first, and the only place in a live-build tree that can run a command +# before apt sees a package name is here. +# +# `steam-installer` (not `steam`) is Debian's own wrapper: it fetches Valve's bootstrap +# on first launch and keeps itself updated from Valve thereafter, which is what you +# want for a client that talks to a moving service. It lives in contrib, so +# build-steam-tv-box-iso.sh passes `--archive-areas "main contrib non-free +# non-free-firmware"` to `lb config`; without that this hook fails on the first +# apt-get install and the image comes up with no Steam at all. +set -eu + +. /etc/steamtv-agent/config.env + +export DEBIAN_FRONTEND=noninteractive + +echo "0300-steam: enabling i386 multiarch" +dpkg --add-architecture i386 +apt-get update + +# --- The 32-bit graphics/audio stack ----------------------------------------------- +# Proton's own runtime is 64-bit, but a great many shipped Linux builds and every +# pre-2015 title in a Steam library are 32-bit, and they fail with an opaque "failed to +# create GL context" rather than anything that names the missing library. Installing +# these up front is much cheaper than diagnosing that on a TV with a gamepad. +I386_PACKAGES=" +libgl1-mesa-dri:i386 +libglx-mesa0:i386 +mesa-vulkan-drivers:i386 +libvulkan1:i386 +libasound2-plugins:i386 +libpulse0:i386 +libsdl2-2.0-0:i386 +libopenal1:i386 +" + +# shellcheck disable=SC2086 +if apt-get install -y --no-install-recommends $I386_PACKAGES; then + echo "0300-steam: installed the i386 graphics/audio stack." +else + echo "0300-steam: WARNING — the i386 stack did not install cleanly. 64-bit titles will" + echo " still run; 32-bit ones will fail with GL/Vulkan context errors. Retry on the" + echo " booted image with: sudo apt-get install $(echo $I386_PACKAGES | tr '\n' ' ')" +fi + +# --- Steam itself ------------------------------------------------------------------ +# steam-devices ships the udev rules for Steam Controllers, Steam Decks used as pads, +# and the DualShock/DualSense/Xbox families. Without it the pads are visible only to +# root and Big Picture shows no controller at all — which on a machine with no keyboard +# in front of it is indistinguishable from a broken image. +if apt-get install -y steam-installer steam-devices; then + echo "0300-steam: installed steam-installer + steam-devices." +else + echo "0300-steam: ERROR — could not install steam-installer. The most likely cause is" + echo " that contrib is missing from the archive areas (see this file's header), or" + echo " the chroot had no network. This image will boot to the media session with no" + echo " Steam on it. Fix and rebuild rather than shipping it." +fi + +# --- gamescope (optional) ---------------------------------------------------------- +# The micro-compositor Valve uses on the Steam Deck. On a TV it is what gives Big +# Picture a fixed resolution and refresh rate independent of the desktop, integer +# scaling, and a framerate limiter — noticeably better than handing a game the raw +# output. Not required: /usr/local/bin/steam-big-picture detects it at launch and runs +# Steam directly under Xwayland when it is absent, so this failing is cosmetic. +if apt-get install -y --no-install-recommends gamescope; then + echo "0300-steam: installed gamescope; Big Picture will run nested inside it." +else + echo "0300-steam: note — gamescope is not available in this release. steam-big-picture" + echo " will fall back to plain Xwayland, which works but has no framerate limiter" + echo " and no integer scaling." +fi + +# --- Vendor GPU driver ------------------------------------------------------------- +# GPU_VENDOR comes from `gpu_vendor` in CoreSystemConfig.json. AMD and Intel need +# nothing beyond the Mesa packages already in the package list plus the firmware blobs; +# NVIDIA needs its own non-free driver, and installing that on an AMD box actively +# breaks it, which is why this is a config value and not a guess. +case "${GPU_VENDOR:-amd}" in + nvidia) + echo "0300-steam: GPU_VENDOR=nvidia — installing the non-free NVIDIA driver" + # libnvidia-gl:i386 is the 32-bit GL/Vulkan half, the exact counterpart of the + # Mesa i386 packages above and needed for the same 32-bit titles. + if apt-get install -y nvidia-driver libnvidia-gl-535:i386 2>/dev/null \ + || apt-get install -y nvidia-driver nvidia-driver-libs:i386; then + echo "0300-steam: NVIDIA driver installed." + echo "0300-steam: VERIFY ON FIRST BOOT — sway on NVIDIA still wants" + echo " WLR_NO_HARDWARE_CURSORS=1 on some driver versions; kiosk-session sets it" + echo " when GPU_VENDOR=nvidia. Check \`vulkaninfo | head\` reports the NVIDIA ICD." + else + echo "0300-steam: WARNING — the NVIDIA driver did not install. Check that non-free" + echo " is in the archive areas and that the driver package name matches this" + echo " release, then install it on the booted machine." + fi + ;; + intel) + echo "0300-steam: GPU_VENDOR=intel — Mesa (already installed) is the whole driver." + if ! apt-get install -y --no-install-recommends intel-media-va-driver-non-free; then + echo "0300-steam: note — intel-media-va-driver-non-free unavailable; video decode" + echo " falls back to the free driver, which is fine for games and slower for 4K video." + fi + ;; + *) + echo "0300-steam: GPU_VENDOR=${GPU_VENDOR:-amd} — Mesa (already installed) is the whole driver." + ;; +esac + +# --- Let games ask for more file descriptors and memory maps ----------------------- +# Proton/DXVK open a lot of both, and the distro defaults are what produce the +# "shader cache failed" and "vm.max_map_count" class of crashes late in a session. +cat > /etc/security/limits.d/90-steam-tv-box.conf <<'EOF' +# Proton and DXVK hold thousands of file descriptors open per running game. +* soft nofile 1048576 +* hard nofile 1048576 +EOF + +cat > /etc/sysctl.d/90-steam-tv-box.conf <<'EOF' +# Several Proton titles (and anything using esync/fsync) exceed the default map count +# and die with an allocation failure that names nothing useful. +vm.max_map_count = 2147483642 +EOF + +apt-get clean diff --git a/hosts/steam-tv-box/live-build/config/hooks/normal/0400-flatpak-apps.hook.chroot b/hosts/steam-tv-box/live-build/config/hooks/normal/0400-flatpak-apps.hook.chroot new file mode 100755 index 0000000..f184d93 --- /dev/null +++ b/hosts/steam-tv-box/live-build/config/hooks/normal/0400-flatpak-apps.hook.chroot @@ -0,0 +1,59 @@ +#!/bin/sh +# Flathub remote + the two apps this image needs that Debian does not package: +# the official Spotify client and Prism Launcher. +# +# Same distribution channel and the same "don't pin a version that will just rot" +# reasoning as hosts/touch-panel's 0300-flatpak-spotify.hook.chroot and +# hosts/thin-client's 0400-flatpak-steamlink.hook.chroot. +# +# Note what is NOT here: Steam. Steam is installed natively from apt by +# 0300-steam.hook.chroot, deliberately — the Flatpak runs sandboxed with its own +# runtime, which complicates GPU driver matching, controller udev access and mounting +# a games disk, all of which matter on a machine whose whole job is running games +# locally. Prism and Spotify have none of those constraints, so the sandbox is a +# straightforward win for them. +set -eu + +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + +# VERIFY BEFORE THE FIRST REAL BUILD: confirm both application IDs against the live +# Flathub listing (`flatpak search Spotify`, `flatpak search "Prism Launcher"`). +# Believed correct — both are the publishers' own official listings — but not checked +# against Flathub from this environment. +SPOTIFY_APP_ID="com.spotify.Client" +PRISM_APP_ID="org.prismlauncher.PrismLauncher" + +install_app() { + app_id="$1" + label="$2" + if flatpak install -y --noninteractive flathub "$app_id"; then + echo "0400-flatpak-apps: installed ${app_id}." + else + echo "0400-flatpak-apps: WARNING — could not install ${app_id} during the build (no" + echo " network in the chroot, or the app ID is wrong). Run this on the booted image" + echo " instead: flatpak install -y flathub ${app_id}" + echo " Until then the ${label} launcher opens nothing." + fi +} + +install_app "$SPOTIFY_APP_ID" "Spotify" +install_app "$PRISM_APP_ID" "Prism Launcher" + +# Prism needs a Java runtime it can actually see. The Flatpak's sandbox has its own +# filesystem view, so the openjdk packages in the package list are not visible inside +# it by default — this grants read access to the host's JVMs so Prism's auto-detection +# finds them and Minecraft launches with no network on first run. +if flatpak override --filesystem=/usr/lib/jvm:ro "$PRISM_APP_ID" 2>/dev/null; then + echo "0400-flatpak-apps: granted Prism Launcher read access to /usr/lib/jvm." +else + echo "0400-flatpak-apps: note — could not set the /usr/lib/jvm override (Prism is" + echo " probably not installed yet). Prism will download its own JRE on first launch," + echo " which needs network at that moment. To fix it offline later:" + echo " flatpak override --user --filesystem=/usr/lib/jvm:ro ${PRISM_APP_ID}" +fi + +# The games directory, so both Prism instances and any Steam library added later sit in +# one predictable place — see hosts/steam-tv-box/README.md on mounting a real disk there. +if flatpak override --filesystem=~/Games "$PRISM_APP_ID" 2>/dev/null; then + echo "0400-flatpak-apps: granted Prism Launcher access to ~/Games." +fi diff --git a/hosts/steam-tv-box/live-build/config/hooks/normal/0700-steamtv-agent.hook.chroot b/hosts/steam-tv-box/live-build/config/hooks/normal/0700-steamtv-agent.hook.chroot new file mode 100755 index 0000000..798e74e --- /dev/null +++ b/hosts/steam-tv-box/live-build/config/hooks/normal/0700-steamtv-agent.hook.chroot @@ -0,0 +1,20 @@ +#!/bin/sh +# Installs the steamtv-agent systemd unit. +# +# The package itself arrives under /opt/steamtv-agent via includes.chroot and runs on +# the system interpreter against apt's python3-paho-mqtt, so there is nothing to +# pip-install and no PEP 668 problem to work around here. +set -eu + +install -m 0644 /opt/steamtv-agent/steamtv-agent.service \ + /etc/systemd/system/steamtv-agent.service + +chmod 0644 /etc/steamtv-agent/config.env +chown -R root:root /opt/steamtv-agent + +# Writable runtime state (the persisted audio-output choice) — steamtv_agent's +# runtime_state.py seeds this from the read-only templates in /etc on first start, and +# cannot create the directory itself once /etc is a squashfs. +install -d -m 0755 /var/lib/steamtv-agent + +systemctl enable steamtv-agent diff --git a/hosts/steam-tv-box/live-build/config/hooks/normal/0900-firefox.hook.chroot b/hosts/steam-tv-box/live-build/config/hooks/normal/0900-firefox.hook.chroot new file mode 100755 index 0000000..3ff58b6 --- /dev/null +++ b/hosts/steam-tv-box/live-build/config/hooks/normal/0900-firefox.hook.chroot @@ -0,0 +1,22 @@ +#!/bin/sh +# Firefox enterprise policy + kiosk-chrome plumbing. Identical in mechanism to +# hosts/thin-client's 0900-firefox.hook.chroot — see that file for the full reasoning. +# +# policies.json is installed to /etc/firefox/policies/policies.json by includes.chroot +# (the documented Linux location), and linked here into firefox-esr's own distribution/ +# directory too — an older location some builds read instead of the system one. +# Whichever the installed firefox-esr honours wins; the other is inert. +set -eu + +FIREFOX_LIB_DIR="/usr/lib/firefox-esr" + +if [ -d "$FIREFOX_LIB_DIR" ]; then + mkdir -p "$FIREFOX_LIB_DIR/distribution" + ln -sf /etc/firefox/policies/policies.json "$FIREFOX_LIB_DIR/distribution/policies.json" + echo "0900-firefox: linked policies.json into $FIREFOX_LIB_DIR/distribution/" +else + echo "0900-firefox: $FIREFOX_LIB_DIR not found (firefox-esr not installed yet, or a" + echo " different path in this Debian release) — /etc/firefox/policies/policies.json" + echo " still applies if the package's install order runs after this hook; verify" + echo " about:policies shows uBlock Origin/SponsorBlock as force-installed on first boot." +fi diff --git a/hosts/steam-tv-box/live-build/config/package-lists/steam-tv-box.list.chroot b/hosts/steam-tv-box/live-build/config/package-lists/steam-tv-box.list.chroot new file mode 100644 index 0000000..64aeb4d --- /dev/null +++ b/hosts/steam-tv-box/live-build/config/package-lists/steam-tv-box.list.chroot @@ -0,0 +1,157 @@ +# Steam-TV-Box package list (live-build .list.chroot format: one package per line). +# +# This machine is a LOCAL gaming box first and a media station second: real GPU, real +# CPU, games run here rather than being streamed in. That is the difference from +# hosts/thin-client, which runs Steam *Link* (a client for a PC somewhere else) and +# needs no graphics performance at all. +# +# Because of that, this image is built against Debian trixie rather than the household +# default — see the debian_release note in tools/build-steam-tv-box-iso.sh. Mesa 22.3 +# (bookworm) predates most of the driver work that current titles rely on, and a +# gaming box on an old Mesa is the one thing here that cannot be fixed later by +# rebuilding a config file. +# +# NOT in this list and installed by hooks instead: +# steam-installer, steam-devices -> 0300-steam.hook.chroot (needs `dpkg +# --add-architecture i386` + `apt-get update` first, and live-build installs +# package lists BEFORE it runs hooks, so it cannot be done from here) +# the i386 halves of the graphics stack -> same hook, same reason +# Spotify, Prism Launcher -> 0400-flatpak-apps.hook.chroot (Flathub) + +# --- Wayland compositor / session --- +sway +swayidle +swaybg +# Steam, Prism Launcher's Minecraft windows and most native games are X11 clients, so +# Xwayland is load-bearing here rather than a nicety. +xwayland +greetd +foot + +# --- Remote view/control (same wayvnc-with-a-mandatory-password posture as the thin +# client; it is for administering the box, not for playing over) --- +wayvnc + +# --- Graphics: the amd64 half. The i386 half is installed by 0300-steam.hook.chroot +# after multiarch is enabled, and 32-bit games/Proton runtimes genuinely still +# need it in 2026. --- +mesa-vulkan-drivers +mesa-va-drivers +mesa-vdpau-drivers +libgl1-mesa-dri +libglx-mesa0 +libvulkan1 +# vulkaninfo/vkcube — the fastest way to answer "is this box actually accelerated?" +# from the maintenance shell before blaming a game. +vulkan-tools +# glxinfo, for the same question on the GL side. +mesa-utils +libva-drm2 +vainfo + +# --- Gaming runtime bits --- +# Lets a game ask the system for the performance governor while it runs and hand it +# back afterwards, instead of pinning this box to "performance" around the clock. +gamemode +# On-screen FPS/frametime overlay, opt-in per launch (MANGOHUD=1). Diagnostics, not +# decoration: it is how you tell a CPU-bound stutter from a GPU-bound one. +mangohud +# Controller support. Steam's own controller stack covers Steam games; these cover +# everything outside it (Prism/Minecraft, emulators, the browser). +joystick +evtest +bluez +bluetooth +# Steam's runtime and many native titles link against these directly. +libsdl2-2.0-0 +libopenal1 +# Proton/Wine prerequisites that apt will otherwise pull only as recommends. +cabextract +p7zip-full +# Xbox/PlayStation pad firmware, plus the general non-free firmware blobs a real GPU +# needs. Requires the contrib + non-free + non-free-firmware archive areas, which +# tools/build-steam-tv-box-iso.sh passes to `lb config`. +firmware-linux +firmware-amd-graphics +firmware-realtek +firmware-iwlwifi +firmware-misc-nonfree + +# --- Browser / media (the "same media functions as the other monitor clients" half) --- +firefox-esr +mpv +mpv-mpris +libmpv2 +playerctl +# wev prints the keysym for each key press — how to find out what a given TV remote's +# buttons actually send. Same reasoning as the thin client's. +wev + +# --- Audio --- +pipewire +pipewire-pulse +pipewire-alsa +wireplumber +alsa-utils +# 32-bit games play through pipewire-pulse's ALSA/Pulse shim; libpulse0:i386 comes with +# the i386 batch in 0300-steam.hook.chroot. + +# --- Flatpak apps: Spotify and Prism Launcher (0400-flatpak-apps.hook.chroot) --- +flatpak +# Portals — Flatpak apps use these for file pickers and screen sharing under Wayland. +# Without them Prism's "choose a Java binary" dialog and Spotify's file dialogs come up +# empty rather than failing loudly, which is a confusing way to lose an evening. +xdg-desktop-portal +xdg-desktop-portal-wlr +xdg-desktop-portal-gtk + +# --- Java for Prism Launcher's own use --- +# Prism can download its own JREs, but only if it has network at the time. Shipping +# both the LTS and the current runtime means Minecraft launches offline too: 8 for +# anything pre-1.17, 21 for current versions. +openjdk-21-jre +openjdk-8-jre + +# --- Agent runtime --- +python3 +python3-venv +# trixie ships paho-mqtt 2.x; steamtv_agent is written against both the 1.x and 2.x +# callback APIs (see make_client() in main.py), so apt's package is used as-is. +python3-paho-mqtt +procps + +# --- Networking --- +network-manager + +# --- Out-of-band admin --- +openssh-server +sudo + +# --- Session plumbing --- +# session-watcher parses `swaymsg -t subscribe` events with jq; the sway config's +# workspace logic depends on it. +jq +# HDMI-CEC (cec-ctl) for turning the TV itself on and off — steamtv_agent/display_power.py. +v4l-utils +# media-player talks to a running mpv over its JSON IPC socket (mpv.conf's +# input-ipc-server) so a second `media-player ` loads into the existing window +# instead of opening a competing MPRIS player. +socat + +# --- Misc --- +ca-certificates +curl +openssl +less +vim-tiny +git + +# NOTE: gamescope is NOT listed here on purpose. It is the right way to run Big +# Picture on a TV (integer-scaled, its own framerate limiter, no compositor tearing), +# but its availability in the target release was not verified from this environment — +# 0300-steam.hook.chroot installs it if apt has it and steam-big-picture falls back to +# plain Xwayland if it does not, so a missing gamescope costs polish rather than a +# broken image. +# NOTE: NVIDIA users — see 0300-steam.hook.chroot. The driver is not in this list +# because it must not be installed on an AMD/Intel box, and which one this is comes +# from `gpu_vendor` in CoreSystemConfig.json. diff --git a/tools/build-steam-tv-box-iso.sh b/tools/build-steam-tv-box-iso.sh new file mode 100755 index 0000000..782f4f3 --- /dev/null +++ b/tools/build-steam-tv-box-iso.sh @@ -0,0 +1,393 @@ +#!/usr/bin/env bash +# +# Smart Home Steam-TV-Box ISO Builder +# Target: builds a Debian live ISO on a Debian/Ubuntu build machine +# +# Drives `lb config && lb build` over hosts/steam-tv-box/live-build/ to produce the +# living-room gaming + media image: +# - greetd autologin straight into a kiosk Sway session (no greeter UI) +# - boots into STEAM BIG PICTURE and nothing else +# - native, local gaming: steam-installer + i386 multiarch + the full Mesa/Vulkan +# stack (this box has a real GPU; it is NOT the thin client's Steam Link streaming) +# - Prism Launcher (Minecraft), registered as a non-Steam game so it runs *through* +# Steam with Steam Input and the Steam Controller API live +# - a media session — Firefox (uBlock Origin + SponsorBlock), Spotify, mpv — that is +# launched LAZILY, the first time somebody leaves Big Picture +# - steamtv-agent (Python, systemd) — HA MQTT-discovery entities + swaymsg control +# - wayvnc for administration (password mandatory, fails closed) +# +# This script is also the single point that keeps configs/ (the human-edited source of +# truth, reviewed in git) in sync with live-build/config/includes.chroot/ (the +# generated tree that actually gets baked into the image). Never hand-edit anything +# under includes.chroot — it is wiped and regenerated on every run. The same now goes +# for live-build/config/preseed.cfg, which is generated from configs/installer/. +# +# Run as: sudo -E tools/build-steam-tv-box-iso.sh [hostname] +# +# Configuration comes from CoreSystemConfig.json — see tools/README.md. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file. +# +# sudo -E tools/build-steam-tv-box-iso.sh # the only steam-tv-box +# sudo -E tools/build-steam-tv-box-iso.sh # a specific one, if several +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/coreconfig.sh +source "${SCRIPT_DIR}/lib/coreconfig.sh" + +core_select_kiosk "steam-tv-box" "${1:-}" + +# CORE_KIOSK_DEBIAN_RELEASE is the per-kiosk `debian_release` override, falling back to +# household.debian_release when the kiosk does not set one. It exists mostly for this +# image: the household default is bookworm, whose Mesa (22.3) predates most of the +# driver work current titles depend on. Every other host here is a browser and a +# Python agent and does not care what Mesa it has; this one is the only machine in the +# project that renders anything demanding, and it is also the only one where being a +# release behind cannot be fixed later by editing a config file. Set +# "debian_release": "trixie" on this kiosk — see CoreSystemConfig.json.template. +DEBIAN_RELEASE="$CORE_KIOSK_DEBIAN_RELEASE" +KIOSK_USERNAME="$CORE_KIOSK_USERNAME" +IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME" +KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT" +ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER" +MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST" +MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT" +MQTT_USERNAME="$CORE_MQTT_USERNAME" +MQTT_PASSWORD="$CORE_MQTT_PASSWORD" +SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY" +STEAMTV_NAME="$CORE_KIOSK_FRIENDLY_NAME" +# The HA area this device physically sits in, from the kiosk's `room` in +# CoreSystemConfig.json. The agent publishes it as suggested_area so HA files the +# device in the right room by itself — see docs/rooms-and-endpoints.md. +STEAMTV_ROOM="${CORE_KIOSK_ROOM:-}" +HA_URL="$CORE_HA_URL" +# Which GPU is in this machine: amd | intel | nvidia. Read by 0300-steam.hook.chroot +# (to install the NVIDIA driver, or deliberately not to) and by kiosk-session (the +# wlroots cursor workaround). Guessing this wrong on an AMD box by installing the +# NVIDIA driver actively breaks it, which is why it is configuration and not detection. +GPU_VENDOR="${CORE_KIOSK_GPU_VENDOR:-amd}" +ENABLE_CEC="${CORE_KIOSK_ENABLE_CEC:-true}" + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +HOST_DIR="${CORE_REPO_ROOT}/hosts/steam-tv-box" +CONFIGS_DIR="${HOST_DIR}/configs" +AGENT_DIR="${HOST_DIR}/agent" +LIVE_BUILD_DIR="${HOST_DIR}/live-build" +INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot" +PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/steam-tv-box.list.chroot" + +# --------------------------------------------------------------------------- +# Sanity checks +# --------------------------------------------------------------------------- +if [[ $EUID -ne 0 ]]; then + echo "Warning: not running as root. 'lb build' needs root to bootstrap and chroot," + echo " and will fail partway through. Re-run with: sudo $0" + echo " Continuing anyway so you can at least regenerate includes.chroot..." +fi + +if ! grep -qi "debian\|ubuntu" /etc/os-release; then + echo "Warning: live-build targets a Debian/Ubuntu build host. Proceeding anyway..." +fi + +if ! command -v lb &> /dev/null; then + if [[ $EUID -eq 0 ]]; then + echo "--- Installing live-build ---" + apt-get update + apt-get install -y live-build + else + echo "live-build is not installed and this script is not running as root." >&2 + echo " Install it first: sudo apt-get install live-build" >&2 + exit 1 + fi +else + echo "--- live-build already installed, skipping ---" +fi + +if [[ ! -f "$PACKAGE_LIST" ]]; then + echo "Missing package list: $PACKAGE_LIST" >&2 + exit 1 +fi + +case "$GPU_VENDOR" in + amd|intel|nvidia) ;; + *) + echo "gpu_vendor is '${GPU_VENDOR}' — must be one of: amd, intel, nvidia." >&2 + echo " Fix it on this kiosk in CoreSystemConfig.json and re-run." >&2 + exit 1 + ;; +esac + +if [[ "$DEBIAN_RELEASE" == "bookworm" ]]; then + echo "Note: building this image against bookworm. Its Mesa is 22.3, which is old for" + echo " a machine whose job is running games locally — expect missing Vulkan features" + echo " and poor performance in anything recent. Set \"debian_release\": \"trixie\" on" + echo " this kiosk in CoreSystemConfig.json unless you have a specific reason not to." +fi + +echo +echo "=== Smart Home Steam-TV-Box ISO Builder ===" +echo "Debian release : $DEBIAN_RELEASE" +echo "Kiosk user : $KIOSK_USERNAME" +echo "Image hostname : $IMAGE_HOSTNAME" +echo "GPU vendor : $GPU_VENDOR" +echo "MQTT broker : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}" +echo "Home Assistant : $HA_URL" +echo "HDMI-CEC : $ENABLE_CEC" +echo "Debian installer : $ENABLE_INSTALLER" +echo "Keyboard layout : $KEYBOARD_LAYOUT" +echo + +# --------------------------------------------------------------------------- +# 1. Regenerate includes.chroot from configs/ and agent/ +# --------------------------------------------------------------------------- +echo "--- Regenerating $INCLUDES ---" +rm -rf "$INCLUDES" +mkdir -p \ + "$INCLUDES/etc/greetd" \ + "$INCLUDES/etc/wayvnc" \ + "$INCLUDES/etc/steamtv-agent" \ + "$INCLUDES/etc/firefox/policies" \ + "$INCLUDES/etc/steamtv-firefox" \ + "$INCLUDES/usr/local/bin" \ + "$INCLUDES/opt/steamtv-agent" \ + "$INCLUDES/home/${KIOSK_USERNAME}/.config/sway" \ + "$INCLUDES/home/${KIOSK_USERNAME}/.config/mpv" \ + "$INCLUDES/home/${KIOSK_USERNAME}/.ssh" + +# @KIOSK_USERNAME@ and @KEYBOARD_LAYOUT@ are the only templated tokens in the configs. +# Everything else the hooks need is read at build time from /etc/steamtv-agent/ +# config.env (written below), which live-build copies in via chroot_local-includes +# *before* it runs chroot_local-hooks — that ordering is what lets the hooks be plain +# scripts with no outer-shell variables of their own. +subst() { + sed -e "s/@KIOSK_USERNAME@/${KIOSK_USERNAME}/g" \ + -e "s/@KEYBOARD_LAYOUT@/${KEYBOARD_LAYOUT}/g" "$1" > "$2" +} + +subst "${CONFIGS_DIR}/greetd/config.toml" "$INCLUDES/etc/greetd/config.toml" +subst "${CONFIGS_DIR}/wayvnc/config" "$INCLUDES/etc/wayvnc/config" +subst "${CONFIGS_DIR}/sway/config" "$INCLUDES/home/${KIOSK_USERNAME}/.config/sway/config" +subst "${AGENT_DIR}/steamtv-agent.service" "$INCLUDES/opt/steamtv-agent/steamtv-agent.service" + +install -m 0755 "${CONFIGS_DIR}/greetd/kiosk-session" "$INCLUDES/usr/local/bin/kiosk-session" +install -m 0755 "${CONFIGS_DIR}/wayvnc/start-wayvnc" "$INCLUDES/usr/local/bin/start-wayvnc" +# Bound to the remote's power/sleep buttons — see the Sway config's remote-control +# section for why those turn the display off rather than the machine. +install -m 0755 "${CONFIGS_DIR}/sway/display-toggle" "$INCLUDES/usr/local/bin/display-toggle" + +# The session: the two halves of the leave-Big-Picture detection, the launchers they +# call, and the Steam shortcut registration that puts Prism inside Steam Input. +install -m 0755 "${CONFIGS_DIR}/session/steam-session" "$INCLUDES/usr/local/bin/steam-session" +install -m 0755 "${CONFIGS_DIR}/session/steam-big-picture" "$INCLUDES/usr/local/bin/steam-big-picture" +install -m 0755 "${CONFIGS_DIR}/session/steam-shortcut-prism" "$INCLUDES/usr/local/bin/steam-shortcut-prism" +install -m 0755 "${CONFIGS_DIR}/session/session-watcher" "$INCLUDES/usr/local/bin/session-watcher" +install -m 0755 "${CONFIGS_DIR}/session/media-session" "$INCLUDES/usr/local/bin/media-session" +install -m 0755 "${CONFIGS_DIR}/session/media-player" "$INCLUDES/usr/local/bin/media-player" +install -m 0755 "${CONFIGS_DIR}/session/spotify-launch" "$INCLUDES/usr/local/bin/spotify-launch" +install -m 0755 "${CONFIGS_DIR}/session/prism-launch" "$INCLUDES/usr/local/bin/prism-launch" + +# fleet-bootstrap: fetch this machine's published monitoring script and run it. Opt-in — +# with no /etc/fleet-bootstrap.conf it exits 0 and does nothing. +install -m 0755 "${CORE_REPO_ROOT}/tools/fleet-bootstrap.sh" "$INCLUDES/usr/local/bin/fleet-bootstrap" + +install -m 0644 "${CONFIGS_DIR}/mpv/mpv.conf" "$INCLUDES/home/${KIOSK_USERNAME}/.config/mpv/mpv.conf" + +# Console (VT/TTY) keymap — separate from Sway's own xkb_layout above, since greetd +# briefly owns the console before Sway starts. +mkdir -p "$INCLUDES/etc/default" +cat > "$INCLUDES/etc/default/keyboard" < "$INCLUDES/home/${KIOSK_USERNAME}/.ssh/authorized_keys" + chmod 600 "$INCLUDES/home/${KIOSK_USERNAME}/.ssh/authorized_keys" + echo " Baked an SSH authorized_keys entry for ${KIOSK_USERNAME}." +else + echo " No SSH_AUTHORIZED_KEY set — SSH admin access will not be possible on this image." +fi + +# --------------------------------------------------------------------------- +# 2. Runtime config, read by steamtv-agent, the sway session wrapper, and the hooks +# --------------------------------------------------------------------------- +echo "--- Writing /etc/steamtv-agent/config.env into includes.chroot ---" +cat > "$INCLUDES/etc/steamtv-agent/config.env" < "${LIVE_BUILD_DIR}/config/preseed.cfg" + +# --------------------------------------------------------------------------- +# 4. lb config +# --------------------------------------------------------------------------- +cd "$LIVE_BUILD_DIR" + +chmod +x config/hooks/normal/*.hook.chroot + +if [[ -e .build ]]; then + echo "--- Previous build found, running 'lb clean' (package cache is kept) ---" + lb clean +fi + +INSTALLER_MODE="none" +if [[ "$ENABLE_INSTALLER" == "true" ]]; then + INSTALLER_MODE="live" +fi + +echo "--- Running lb config ---" +# live-build auto-discovers config/package-lists/*.list.chroot and +# config/hooks/normal/*.hook.chroot; there is no flag to point at them individually. +# +# --archive-areas: contrib is REQUIRED — steam-installer lives there and +# 0300-steam.hook.chroot fails without it, producing an image with no Steam on it. +# non-free is required for the NVIDIA driver (and is harmless on an AMD/Intel box, +# since nothing from it is installed unless gpu_vendor says so). +lb config \ + --distribution "$DEBIAN_RELEASE" \ + --architectures amd64 \ + --linux-flavours amd64 \ + --archive-areas "main contrib non-free non-free-firmware" \ + --binary-images iso-hybrid \ + --debian-installer "$INSTALLER_MODE" \ + --iso-application "SmartestHome Steam TV Box" \ + --iso-publisher "SmartestHome" \ + --iso-volume "smarthome-steam-tv-box" \ + --memtest none \ + --bootappend-live "boot=live components quiet splash noautologin username=${KIOSK_USERNAME} hostname=${IMAGE_HOSTNAME}" + +# noautologin: live-config would otherwise autologin its own account on tty1 and fight +# greetd for the VT (0200-greetd.hook.chroot masks getty@tty1 as a belt-and-braces +# second line of defence). + +# --------------------------------------------------------------------------- +# 5. lb build +# --------------------------------------------------------------------------- +echo "--- Running lb build (this takes a while and needs network) ---" +echo " Longer than the other images: the i386 multiarch stack and Steam's" +echo " dependency chain are a few hundred extra packages." +lb build + +ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)" +ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}" +ISO_PATH="$(core_publish_image "$ISO_PATH" "steam-tv-box" "$IMAGE_HOSTNAME")" + +echo +echo "=== Done ===" +echo "ISO written to:" +echo " ${ISO_PATH}" +echo +echo "What's in it:" +echo " greetd : autologin as '${KIOSK_USERNAME}' straight into Sway on vt1" +echo " Boots to : Steam Big Picture, and nothing else" +echo " Steam : NATIVE (steam-installer + i386 multiarch) — games run on this" +echo " machine. This is not the thin client's Steam Link streaming." +echo " GPU stack : Mesa/Vulkan (${GPU_VENDOR}), 64- and 32-bit" +echo " Prism Launcher : Flathub, registered as a non-Steam game so it launches THROUGH" +echo " Steam with Steam Input / the Steam Controller API active" +echo " Media session : Firefox (uBlock Origin + SponsorBlock), Spotify, mpv —" +echo " started the FIRST TIME you leave Big Picture, not at boot" +echo " Sway : workspaces 1:steam / 2:games / 3:web / 4:media / 5:music" +echo " wayvnc : 0.0.0.0:5900, auth REQUIRED, refuses to start without a password" +echo " steamtv-agent : system service, MQTT ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}" +echo " Audio output : HA select, persisted to audio-config.json" +echo " Keyboard layout : ${KEYBOARD_LAYOUT} (console + Sway)" +echo " Maintenance shell: Super+Shift+Ctrl+M opens a floating terminal locally" +echo +echo "Next steps:" +echo " 1. Write the ISO to a USB stick:" +echo " sudo dd if=${ISO_PATH} of=/dev/sdX bs=4M status=progress oflag=sync" +echo " (double-check /dev/sdX with 'lsblk' first — dd will happily eat the wrong disk)" +if [[ "$ENABLE_INSTALLER" != "true" ]]; then + echo " 2. NOTE: enable_installer is false, so this boots live — games would live in RAM" + echo " and vanish on reboot. For a real gaming box set \"enable_installer\": true on" + echo " this kiosk in CoreSystemConfig.json and rebuild, then install to the disk." +else + echo " 2. Boot the target machine and run the installer. Give /home its own large" + echo " partition (or mount a games disk at /home/${KIOSK_USERNAME}/Games) — a Steam" + echo " library and a few modpacks are hundreds of gigabytes." +fi +echo " 3. Confirm it lands in Big Picture with no login prompt, and that a gamepad is" +echo " recognised (that is steam-devices' udev rules doing their job)." +echo " 4. Check the box is actually accelerated before blaming any game — from the" +echo " maintenance shell (Super+Shift+Ctrl+M):" +echo " vulkaninfo | head -n 20 # should name your GPU, not llvmpipe" +echo " glxinfo -B # same" +echo " 5. SET THE WAYVNC PASSWORD — wayvnc will NOT be running until you do:" +echo " sudo sh -c 'openssl rand -base64 24 > /etc/wayvnc/wayvnc-password'" +echo " sudo chmod 600 /etc/wayvnc/wayvnc-password" +echo " sudo chown ${KIOSK_USERNAME}:${KIOSK_USERNAME} /etc/wayvnc/wayvnc-password" +echo " swaymsg reload" +echo " 6. Log into Steam on the TV. Until you do, Prism cannot be registered as a" +echo " non-Steam game (there is no user profile to register it in) and will fall" +echo " back to launching directly, without Steam Input. After the first login," +echo " restart the session (or reboot) — steam-session registers the shortcut on" +echo " the way in, before Steam starts, because Steam overwrites shortcuts.vdf on" +echo " exit. Then confirm 'Prism Launcher' is in the Big Picture library and that" +echo " the pad works inside Minecraft." +echo " 7. Leave Big Picture and confirm the media session appears: Firefox on 3:web," +echo " mpv on 4:media, Spotify on 5:music. Then go back into a game and confirm" +echo " they are NOT killed (that is deliberate — see configs/session/media-session)." +echo " 8. Confirm steamtv-agent connected and registered:" +echo " systemctl status steamtv-agent" +echo " In Home Assistant, a '${STEAMTV_NAME}' device should appear under the MQTT" +echo " integration with: Session (sensor), Mode (select), Screen (select), Launch" +echo " buttons for Steam/Prism/browser/player/Spotify, Stop media apps (button)," +echo " Audio output (select), Display (switch), Volume (number), Playback state" +echo " (sensor) and the transport buttons." +echo " 9. Set the audio output. On a TV box the right sink is usually HDMI, and if the" +echo " set was off at boot WirePlumber may have defaulted to a headphone jack with" +echo " nothing in it — the classic 'the game has no sound' report. Pick it in HA's" +echo " 'Audio output' select; the choice persists across reboots." +echo +echo "Then pull the power on the container host and re-check: the box must still boot" +echo "into Big Picture and play a game. steamtv-agent connects asynchronously and will" +echo "simply keep retrying." +echo +echo "Rebuilding later: edit hosts/steam-tv-box/configs/* or agent/*, then re-run this" +echo "script — includes.chroot is regenerated from them every time." diff --git a/tools/config-export.py b/tools/config-export.py index 65622d9..d6c9dc4 100755 --- a/tools/config-export.py +++ b/tools/config-export.py @@ -225,6 +225,18 @@ def main(argv: list[str]) -> int: emit("CORE_KIOSK_ENABLE_INSTALLER", kiosk.get("enable_installer", False)) emit("CORE_KIOSK_ENABLE_STEAM_LINK", kiosk.get("enable_steam_link", False)) emit("CORE_KIOSK_ENABLE_GESTURE_CONTROL", kiosk.get("enable_gesture_control", False)) + # A per-kiosk Debian release, falling back to the household one. Only the + # steam-tv-box currently sets it: that machine renders games locally, and + # bookworm's Mesa is too old for that, while nothing else here cares what Mesa + # it has. Emitted for every kiosk (not just that type) so a builder can read one + # variable without knowing whether an override was in play. + emit("CORE_KIOSK_DEBIAN_RELEASE", + kiosk.get("debian_release") or cfg["household"]["debian_release"]) + # steam-tv-box only: which GPU is in the machine, and whether to drive the TV + # over HDMI-CEC. Both are decisions about physical hardware that the build host + # cannot see, so they are configuration rather than detection. + emit("CORE_KIOSK_GPU_VENDOR", kiosk.get("gpu_vendor", "amd")) + emit("CORE_KIOSK_ENABLE_CEC", kiosk.get("enable_cec", True)) # A per-kiosk wake word overrides the household default. emit("CORE_KIOSK_WAKE_WORD", kiosk.get("wake_word", cfg.get("voice", {}).get("wake_word", "ok_nabu"))) diff --git a/tools/validate-config.py b/tools/validate-config.py index 07f9f68..e585b7d 100755 --- a/tools/validate-config.py +++ b/tools/validate-config.py @@ -24,9 +24,12 @@ from pathlib import Path from typing import Any HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") -KIOSK_TYPES = {"thin-client", "touch-panel", "door-panel", "kitchen-display"} +KIOSK_TYPES = {"thin-client", "touch-panel", "door-panel", "kitchen-display", "steam-tv-box"} TIERS = {"auto", "gpu", "cpu"} ARCHITECTURES = {"amd64", "arm64"} +# Which GPU is in a steam-tv-box. Not detected: installing the NVIDIA driver on an +# AMD box breaks it, and the build has no way to see hardware it is not running on. +GPU_VENDORS = {"amd", "intel", "nvidia"} # Tokens the validator insists on, and the enable-flag that makes each one required. # A token for a disabled service is not an error — you shouldn't have to invent a @@ -507,6 +510,32 @@ def validate_kiosks(cfg: dict, rep: Report) -> None: if not kiosk.get("kiosk_username"): rep.error(f"{where}.kiosk_username", "missing") + # A per-kiosk Debian release, overriding household.debian_release. Exists for + # the steam-tv-box, whose Mesa version is the one thing on that machine that + # cannot be fixed later by editing a config file — see + # tools/build-steam-tv-box-iso.sh. Allowed on any kiosk; only checked for shape + # here, because whether a given release exists is a question for the mirror. + release = kiosk.get("debian_release") + if release is not None and (not isinstance(release, str) or not release.strip()): + rep.error(f"{where}.debian_release", + f"must be a Debian suite name like 'bookworm' or 'trixie', got {release!r}") + + if ktype == "steam-tv-box": + vendor = kiosk.get("gpu_vendor") + if vendor not in GPU_VENDORS: + rep.error(f"{where}.gpu_vendor", + f"a steam-tv-box must say which GPU it has — one of " + f"{sorted(GPU_VENDORS)}, got {vendor!r}. This is not detected " + "because installing the NVIDIA driver on an AMD box breaks it.") + if str(kiosk.get("debian_release") or "").strip() == "bookworm": + rep.warn(f"{where}.debian_release", + "bookworm's Mesa is 22.3, which is old for a machine whose job is " + "running games locally — 'trixie' is the intended release for this type") + if not kiosk.get("enable_installer"): + rep.warn(f"{where}.enable_installer", + "false means this image boots live, so a Steam library would live in " + "RAM and vanish on reboot — a real gaming box wants an installed system") + # Audio endpoints share the hostname namespace with kiosks: they are all devices on # one network, and two of anything answering to the same name is the same problem # regardless of what kind of device they are.