# 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.