# 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/<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:

```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 <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`):
   ```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 <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.
