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/<steamID3>/ 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 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| agent | ||
| configs | ||
| live-build/config | ||
| README.md | ||
README.md
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 pgreps.
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.vdflives under~/.steam/steam/userdata/<steamID3>/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 readsshortcuts.vdfat 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-prismrefuses to rewrite ashortcuts.vdfthat 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:
"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:
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 <hostname> # 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/<kiosk_username>/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
- Confirm it is actually accelerated before blaming any game. From the maintenance
shell (
Super+Shift+Ctrl+M):vulkaninfo | head -n 20 # should name your GPU, not llvmpipe glxinfo -B # same - Set the wayvnc password. wayvnc will not be running until you do — it fails
closed, same as the thin client:
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.sudo sh -c 'openssl rand -base64 24 > /etc/wayvnc/wayvnc-password' sudo chmod 600 /etc/wayvnc/wayvnc-password sudo chown <kiosk_username>:<kiosk_username> /etc/wayvnc/wayvnc-password swaymsg reload - Log into Steam, then restart the session once so the Prism shortcut registers.
- 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). - Leave Big Picture and confirm Firefox, mpv and Spotify appear. Then go back into a game and confirm they are not killed.
- Pull the power on the container host and re-check: the box must still boot into
Big Picture and play a game.
steamtv-agentconnects 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..gitignorecoversiso-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-picturefalls back to plain Xwayland if not).- The Flathub application IDs (
com.spotify.Client,org.prismlauncher.PrismLauncher) — believed correct, not checked against Flathub. - The
shortcuts.vdfbinary 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.