291 lines
16 KiB
Markdown
291 lines
16 KiB
Markdown
# 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.
|