SmartestHome/hosts/steam-tv-box
Amir Alexander Abdelbaki 2f42b88c19 Steam TV box: a local-gaming client that falls back to a media station
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>
2026-08-14 08:46:34 +02:00
..
agent Steam TV box: a local-gaming client that falls back to a media station 2026-08-14 08:46:34 +02:00
configs Steam TV box: a local-gaming client that falls back to a media station 2026-08-14 08:46:34 +02:00
live-build/config Steam TV box: a local-gaming client that falls back to a media station 2026-08-14 08:46:34 +02:00
README.md Steam TV box: a local-gaming client that falls back to a media station 2026-08-14 08:46:34 +02:00

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.vdf lives 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 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:

"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

  1. 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
    
  2. Set the wayvnc password. wayvnc will not be running until you do — it fails closed, same as the thin client:
    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
    
    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.