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>
main
Amir Alexander Abdelbaki 2026-08-14 08:46:34 +02:00
parent f4de1e7cec
commit 2f42b88c19
47 changed files with 4221 additions and 3 deletions

View File

@ -216,7 +216,7 @@
"kiosks": [ "kiosks": [
{ {
"_comment": "type must be one of: thin-client, touch-panel, door-panel, kitchen-display. hostname must be unique and a valid DNS label — it is what the HA device shows up as.", "_comment": "type must be one of: thin-client, touch-panel, door-panel, kitchen-display, steam-tv-box. hostname must be unique and a valid DNS label — it is what the HA device shows up as.",
"type": "door-panel", "type": "door-panel",
"hostname": "door-panel", "hostname": "door-panel",
"room": "hallway", "room": "hallway",
@ -253,6 +253,23 @@
"kiosk_username": "kiosk", "kiosk_username": "kiosk",
"voice_satellite": false, "voice_satellite": false,
"enable_installer": false "enable_installer": false
},
{
"_comment": "The living-room gaming box. Games run ON this machine (real GPU/CPU) — it is not the thin client's Steam Link streaming. Boots straight into Steam Big Picture; the media apps (Firefox+uBlock Origin, Spotify, mpv) are launched lazily the first time somebody leaves Big Picture. Prism Launcher is installed and registered as a non-Steam game so it runs through Steam with Steam Input active.",
"type": "steam-tv-box",
"hostname": "steam-tv-box-living",
"room": "living_room",
"friendly_name": "Living room Steam TV box",
"kiosk_username": "kiosk",
"voice_satellite": false,
"_comment_installer": "true, unlike every other kiosk here: a live-booted system keeps its writable layer in RAM, so a Steam library would vanish on reboot. This box wants a real install on a real disk.",
"enable_installer": true,
"_comment_debian_release": "Overrides household.debian_release for this image only. bookworm ships Mesa 22.3, which is too old to run current titles well, and the graphics stack is the one thing on this machine that cannot be fixed later by editing a config file.",
"debian_release": "trixie",
"_comment_gpu_vendor": "amd | intel | nvidia. Not detected — the build host cannot see this machine's hardware, and installing the NVIDIA driver on an AMD box actively breaks it. amd and intel need nothing beyond Mesa; nvidia pulls the non-free driver.",
"gpu_vendor": "amd",
"_comment_enable_cec": "Turn the television itself on and off over the HDMI cable (HDMI-CEC), driven by Home Assistant presence. Set false if the set ignores CEC or is on a receiver that misbehaves; the compositor-side blanking still works either way.",
"enable_cec": true
} }
], ],

View File

@ -29,6 +29,12 @@ hosts/
Chromium window showing pantry-vision's unload/consume/ Chromium window showing pantry-vision's unload/consume/
expired/edit frontend, camera capture via the browser expired/edit frontend, camera capture via the browser
itself itself
steam-tv-box/ Living-room gaming box: boots into Steam Big Picture and
runs games LOCALLY (real GPU — not thin-client's Steam
Link streaming). Prism Launcher runs through Steam so
Steam Input is live for Minecraft. The media apps
(Firefox+uBlock Origin, Spotify, mpv) are launched lazily,
the first time somebody leaves Big Picture
door-panel/ Single-purpose Sway kiosk by the door/wardrobe: door-panel/ Single-purpose Sway kiosk by the door/wardrobe:
identity's weather+clothing/who's-home/groceries-low identity's weather+clothing/who's-home/groceries-low
dashboard by default, registration on demand — this dashboard by default, registration on demand — this
@ -98,6 +104,7 @@ chores/ Presence/calendar-driven household chore nudging +
- [ ] ESP32-S3-Touch-LCD-1.85C-V2 voice satellite + status display (`firmware/esp32-s3-touch-lcd-1.85c/`) — ESPHome config written and passes `esphome config`, not yet flashed to real hardware; `media_player`/`weather` entity IDs still need to be chosen, see `docs/project-plan.md` §4 - [ ] ESP32-S3-Touch-LCD-1.85C-V2 voice satellite + status display (`firmware/esp32-s3-touch-lcd-1.85c/`) — ESPHome config written and passes `esphome config`, not yet flashed to real hardware; `media_player`/`weather` entity IDs still need to be chosen, see `docs/project-plan.md` §4
- [ ] Headless audio endpoint (`hosts/audio-endpoint/`) — per-room independent Spotify Connect appliance for rooms without a thin client, arm64 (Raspberry Pi + HiFiBerry Amp2, rpi-image-gen) and amd64 (mini PC + USB DAC/amp, live-build) build pipelines written, **neither built/flashed/booted on real hardware** — rpi-image-gen's exact config schema in particular is unverified, see `hosts/audio-endpoint/README.md` - [ ] Headless audio endpoint (`hosts/audio-endpoint/`) — per-room independent Spotify Connect appliance for rooms without a thin client, arm64 (Raspberry Pi + HiFiBerry Amp2, rpi-image-gen) and amd64 (mini PC + USB DAC/amp, live-build) build pipelines written, **neither built/flashed/booted on real hardware** — rpi-image-gen's exact config schema in particular is unverified, see `hosts/audio-endpoint/README.md`
- [ ] Sway touch panel (`hosts/touch-panel/`) — touch-driven Sway image: full Spotify GUI (Flathub), a dedicated Home Assistant Chromium kiosk window, a general web browser, an always-on touch dock for app switching, an on-screen keyboard (toggled manually, no auto-show), and `touchpanel-agent` (HA MQTT control, same LLM-mediated-through-HA security model as the thin client) — built, **no touch-panel hardware chosen and nothing booted on real metal**, see `hosts/touch-panel/README.md` - [ ] Sway touch panel (`hosts/touch-panel/`) — touch-driven Sway image: full Spotify GUI (Flathub), a dedicated Home Assistant Chromium kiosk window, a general web browser, an always-on touch dock for app switching, an on-screen keyboard (toggled manually, no auto-show), and `touchpanel-agent` (HA MQTT control, same LLM-mediated-through-HA security model as the thin client) — built, **no touch-panel hardware chosen and nothing booted on real metal**, see `hosts/touch-panel/README.md`
- [ ] Steam TV box (`hosts/steam-tv-box/`) — living-room machine that boots straight into **Steam Big Picture** and plays games **locally** (native `steam-installer` + i386 multiarch + full Mesa/Vulkan stack, `steam-devices`, gamemode, gamescope when the release has it) — the opposite end of `hosts/thin-client/`'s Steam Link streaming, and the only host here pinned to **trixie** (per-kiosk `debian_release` override; bookworm's Mesa 22.3 is too old for a machine that renders). **Prism Launcher** is installed from Flathub and registered as a *non-Steam game* so Minecraft runs through Steam with Steam Input and the Steam Controller API live. Leaving Big Picture drops into the same media set as the other monitor clients — Firefox with uBlock Origin/SponsorBlock, Spotify, mpv — **launched lazily**, on two independent triggers (the Steam process exiting, and a sway workspace-focus IPC watcher), both idempotent, and never torn down when you go back into a game. `steamtv-agent` adds a session-mode sensor, mode/screen/audio-output selects, app-launch buttons and a CEC display switch over the same MQTT-only control surface as the other kiosks. Written and validated end-to-end through `validate-config.py`/`config-export.py`, **never built, never flashed, never booted, no hardware chosen** — the `shortcuts.vdf` binary format, the Flathub app IDs, gamescope's availability and the NVIDIA package names are all reasoned rather than verified, see `hosts/steam-tv-box/README.md`
- [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes. All four stock movements now run off the camera — **unload** (a scan loop, one confirm per item, with pack size and where to put it away), **consume** (asks which brand and how many), **list expired** (cleared by scanning what you're binning, booked out as spoiled), and **edit inventory** (the deliberately camera-free correction screen) — with two invariants: stock counts *individual units* (a twelve-pack of eggs is twelve) and folds *brand-free* via Grocy product groups (12 of brand X + 10 of brand Y = 22 eggs, expandable per brand). Built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md` - [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes. All four stock movements now run off the camera — **unload** (a scan loop, one confirm per item, with pack size and where to put it away), **consume** (asks which brand and how many), **list expired** (cleared by scanning what you're binning, booked out as spoiled), and **edit inventory** (the deliberately camera-free correction screen) — with two invariants: stock counts *individual units* (a twelve-pack of eggs is twelve) and folds *brand-free* via Grocy product groups (12 of brand X + 10 of brand Y = 22 eggs, expandable per brand). Built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md`
- [x] Where-is-it-actually, for multiple fridges — `docs/fridge-item-location.md`: separates "which appliance" (a software-only change: Grocy locations + a transfer action) from "which shelf" (a *hint* at best) and "exact position" (occlusion makes it unbuildable), and rules out interior cameras on power/condensation/18 °C grounds — which is exactly the compartment the question starts from. **Built**: appliances as Grocy locations, `POST /transfer` with a "Move to…" picker on the edit screen, and the door-sensor→camera→hint path (`pantry-vision/doorway.py`, `POST /doorway-event` — an HA automation on a Zigbee contact sensor, answered 202 with the camera burst running off-thread; what it recognises is stored in its own SQLite file with a timestamp and a confidence and is **never** written to stock). **Not bought, not tested**: no door sensor, no doorway camera, and the assumption the camera half rests on — that a local vision model can identify an item in a moving hand at ~1.5 m — has never been checked (open decision #40). An appliance can be configured with a sensor and no camera, which is still the recommended way to start - [x] Where-is-it-actually, for multiple fridges — `docs/fridge-item-location.md`: separates "which appliance" (a software-only change: Grocy locations + a transfer action) from "which shelf" (a *hint* at best) and "exact position" (occlusion makes it unbuildable), and rules out interior cameras on power/condensation/18 °C grounds — which is exactly the compartment the question starts from. **Built**: appliances as Grocy locations, `POST /transfer` with a "Move to…" picker on the edit screen, and the door-sensor→camera→hint path (`pantry-vision/doorway.py`, `POST /doorway-event` — an HA automation on a Zigbee contact sensor, answered 202 with the camera burst running off-thread; what it recognises is stored in its own SQLite file with a timestamp and a confidence and is **never** written to stock). **Not bought, not tested**: no door sensor, no doorway camera, and the assumption the camera half rests on — that a local vision model can identify an item in a moving hand at ~1.5 m — has never been checked (open decision #40). An appliance can be configured with a sensor and no camera, which is still the recommended way to start
- [x] Per-person **colour** and a settable **profile picture** in `identity` — eight colours assigned automatically at registration, avoiding any colour already worn by somebody with the same initial (an Anna and an Amir are two identical "A"s on a wall panel, and the colour is what makes that readable), then least-used overall; editable in the admin panel, backfilled oldest-first for existing people so nobody's colour reshuffles on restart. `color` + `initial` now ride on `/people`, `/presence` and every `/floorplan/presence` occupant, so no consumer derives an initial or invents a palette. New `POST /people/<id>/photo` sets a picture without a walk to the door panel — the registration capture was the only source before, which left a device-less household member unable to have a face at all. **The palette values sit on the 2-bits-per-channel lattice a colour Pebble renders natively**, so the colour on a watch is the colour in the panel, see below - [x] Per-person **colour** and a settable **profile picture** in `identity` — eight colours assigned automatically at registration, avoiding any colour already worn by somebody with the same initial (an Anna and an Amir are two identical "A"s on a wall panel, and the colour is what makes that readable), then least-used overall; editable in the admin panel, backfilled oldest-first for existing people so nobody's colour reshuffles on restart. `color` + `initial` now ride on `/people`, `/presence` and every `/floorplan/presence` occupant, so no consumer derives an initial or invents a palette. New `POST /people/<id>/photo` sets a picture without a walk to the door panel — the registration capture was the only source before, which left a device-less household member unable to have a face at all. **The palette values sit on the 2-bits-per-channel lattice a colour Pebble renders natively**, so the colour on a watch is the colour in the panel, see below

View File

@ -935,9 +935,89 @@ entirely container-host services plus additional Frigate camera sources.
- Does `chores`' optional LLM message-phrasing (`chore_reminder_style`) ever change *who* gets nudged or *when*, rather than only the wording of the notification? (It must not — and a failed/empty LLM call must fall back to the plain template, never block the nudge from going out.) - Does `chores`' optional LLM message-phrasing (`chore_reminder_style`) ever change *who* gets nudged or *when*, rather than only the wording of the notification? (It must not — and a failed/empty LLM call must fall back to the plain template, never block the nudge from going out.)
- Does `chores`' household calendar busy-check, if unreachable or misconfigured, ever become the reason nudges stop going out entirely? (It must not — it fails open, treating an error as "not busy.") - Does `chores`' household calendar busy-check, if unreachable or misconfigured, ever become the reason nudges stop going out entirely? (It must not — it fails open, treating an error as "not busy.")
### Phase 21 — Steam TV box (local gaming + media, living room)
New host directory `hosts/steam-tv-box/`. No container-host changes.
1. **A different device from the Phase 11 thin client, not a variant of it** — and the
distinction is the whole point. The thin client runs *Steam Link*: it streams a game
rendered on some other PC and needs no graphics performance at all. This box is the
other end of that — a real GPU/CPU machine that renders games **locally**. It reuses
the thin client's live-build toolchain and `configs/`+`agent/` split, not its
live-build tree, exactly the relationship `hosts/touch-panel/` already has to it.
2. **It is the first host to override `household.debian_release`.** A new optional
per-kiosk `debian_release` key (emitted for every kiosk by `config-export.py`, used
only by this builder) pins this image to **trixie**. bookworm's Mesa is 22.3, which
is fine for every other host here — they are a browser and a Python agent — and not
fine for the one machine that renders. The graphics stack is also the only thing on
this box that cannot be fixed later by editing a config file and rebuilding a
service, which is why it is worth a config-schema change rather than a comment.
3. **Native Steam, not the Flatpak**: `steam-installer` from contrib, installed by a
hook rather than the package list because it is unusable until `dpkg
--add-architecture i386` has run and live-build installs package lists *before*
hooks. Plus the i386 half of the Mesa/Vulkan/SDL/PulseAudio stack (a great many
shipped Linux builds are still 32-bit and fail with an opaque GL-context error
without it), `steam-devices`' udev rules (without which pads are root-only and Big
Picture shows no controller at all), `gamemode`, `mangohud`, and `gamescope` when the
release has it. The Flatpak was rejected deliberately: its sandbox complicates GPU
driver matching, controller udev access and mounting a games disk, all of which are
this host's whole job. Spotify and Prism, which have none of those constraints, do
come from Flathub.
4. **`gpu_vendor` is configuration, not detection** (`amd`/`intel`/`nvidia`, validated).
The build host cannot see the target machine, and installing the NVIDIA driver on an
AMD box actively breaks it. `nvidia` additionally sets `WLR_NO_HARDWARE_CURSORS=1`,
whose absence shows up as an invisible pointer — easy to miss on a box driven with a
gamepad.
5. **The media apps do not exist until you leave Big Picture.** The box boots to Steam
and nothing else; Firefox (uBlock Origin + SponsorBlock), Spotify and mpv are started
the first time somebody actually leaves it. Not tidiness: this is the only machine
here where a background browser and a background Electron app cost something visible
— GPU memory, timer wakeups, and an open Spotify audio stream that turns 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 ("Exit Steam"), and
`session-watcher` subscribes to sway workspace-focus events ("Exit Big Picture mode",
alt-tab, the remote's channel keys, HA's Screen select — Steam is still running, so
the first trigger never fires). Both call an idempotent `media-session start`, guarded
per-app by `pgrep` and as a whole by `flock`, so whichever fires first wins.
6. **Window-title matching was rejected.** The obvious implementation — poll for a window
titled "Steam Big Picture Mode" — loses to Steam client rewrites, and a media session
that silently stops appearing after a Steam update reads as a broken image rather than
a moved string. A sway workspace name is a contract this repo owns and can only change
by editing two files together.
7. **Re-entering Steam tears nothing down.** 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 and is wired to one explicit HA button; nothing calls it
automatically, and neither direction of the Mode select does either.
8. **Prism Launcher runs *through* Steam.** Launched directly it gets a Flatpak window
and a Minecraft that sees a raw evdev pad. `steam-shortcut-prism` writes Prism into
the user's binary `shortcuts.vdf` as a non-Steam game and `prism-launch` starts it via
`steam://rungameid/…`, so it runs inside the Steam Runtime with **Steam Input active**
— configurable pads with per-game bindings, a working overlay, gyro/back-buttons, and
an entry in the Big Picture library. This cannot be baked into the ISO (`shortcuts.vdf`
lives under a `userdata/<steamID3>/` that does not exist until somebody logs in, and no
Steam credentials go into an image), so it runs per-session, idempotently, and
**before Steam starts** — Steam rewrites that file from memory on exit, so anything
written under a running client is discarded. It falls back to a direct launch when
Steam is absent or nobody has logged in, and refuses to rewrite a `shortcuts.vdf` that
already holds other entries rather than risk eating them.
9. **`enable_installer` is `true` here, unlike every other kiosk.** Every other kiosk in
this project is stateless by design and boots live. This one is not: a live system
keeps its writable layer in RAM, so a Steam library and a few modpacks — hundreds of
gigabytes — would vanish on reboot. `validate-config.py` warns when this type has the
installer off.
10. **`steamtv-agent` adds one genuinely new entity to the kiosk vocabulary**: a
**session-mode sensor** (`gaming`/`steam`/`media`/`idle`, read from two cheap local
facts — is Steam's process alive, and which workspace is focused), which is what lets
HA answer "is somebody playing?" without guessing from power draw: don't dim the room
mid-game, don't announce the doorbell over a raid, count the room as occupied though
nobody has moved in forty minutes. The rest — media 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.
--- ---
## 4. Open decisions (Phases 6, 1120) ## 4. Open decisions (Phases 6, 1121)
These need a decision before their respective implementation steps can be built — everything above is written to accommodate any answer, but nothing should be built against an unresolved item. These need a decision before their respective implementation steps can be built — everything above is written to accommodate any answer, but nothing should be built against an unresolved item.
@ -983,3 +1063,4 @@ These need a decision before their respective implementation steps can be built
40. **Whether a vision model can identify a grocery item in a moving hand at doorway distance** (new, Phase 17) — the assumption the entire `/doorway-event` path rests on, and a materially harder task than the kitchen display's held-still-against-a-plain-background one. It is also the cheapest open decision on this list to close: point the kitchen's existing webcam at somebody walking past with a carton and run `/identify` on the frames. Until then the door sensors are the part worth deploying, and `PANTRY_DOOR_APPLIANCES` entries can be configured without a camera — see `docs/fridge-item-location.md`, whose recommended order (sensors, then a month of living with them, *then* one camera) the code deliberately does not shortcut. 40. **Whether a vision model can identify a grocery item in a moving hand at doorway distance** (new, Phase 17) — the assumption the entire `/doorway-event` path rests on, and a materially harder task than the kitchen display's held-still-against-a-plain-background one. It is also the cheapest open decision on this list to close: point the kitchen's existing webcam at somebody walking past with a carton and run `/identify` on the frames. Until then the door sensors are the part worth deploying, and `PANTRY_DOOR_APPLIANCES` entries can be configured without a camera — see `docs/fridge-item-location.md`, whose recommended order (sensors, then a month of living with them, *then* one camera) the code deliberately does not shortcut.
41. **Whether `groceries_out_of_place` can tell a carton of milk from a fruit bowl** (new, Phase 20) — its prompt lists what to ignore precisely because the general question gets a YES for any normal kitchen, but that is a mitigation written blind. The failure mode is not a missed chore, it is a false one every two hours, which is how a household learns to ignore the notification channel entirely. Point it at a *clean* counter for a day before trusting a YES, and note this is the first chore type whose false positives cost more than its false negatives. 41. **Whether `groceries_out_of_place` can tell a carton of milk from a fruit bowl** (new, Phase 20) — its prompt lists what to ignore precisely because the general question gets a YES for any normal kitchen, but that is a mitigation written blind. The failure mode is not a missed chore, it is a false one every two hours, which is how a household learns to ignore the notification channel entirely. Point it at a *clean* counter for a day before trusting a YES, and note this is the first chore type whose false positives cost more than its false negatives.
42. **Contact-sensor battery life on an appliance door is unverified** (new, Phase 17) — a fridge door opens far more often than the window these sensors are sold for, and the sensor sits in a cold, humid draught even when mounted outside the compartment (which `docs/components.md` says to do, for the separate reason that a coin cell at 18 °C is a false economy). Check one after a month before buying more. 42. **Contact-sensor battery life on an appliance door is unverified** (new, Phase 17) — a fridge door opens far more often than the window these sensors are sold for, and the sensor sits in a cold, humid draught even when mounted outside the compartment (which `docs/components.md` says to do, for the separate reason that a coin cell at 18 °C is a false economy). Check one after a month before buying more.
43. **Nothing in `hosts/steam-tv-box/` has been built, flashed, or booted, and no hardware has been chosen** (new, Phase 21) — the standing fleet-wide caveat, but it bites harder here because every claim this host makes is a hardware claim. Four things are specifically reasoned rather than verified, in rough order of how loudly they fail: the **`shortcuts.vdf` binary format and non-Steam AppID derivation** (CRC32 of `"Exe"`+`AppName` with the high bit set — the long-standing community format, documented by Valve nowhere; if it is wrong, Prism simply never appears in the library and nothing logs why), **`gamescope`'s availability** in trixie (the hook installs it if apt has it and `steam-big-picture` falls back to plain Xwayland if not, so this costs polish rather than function), the **Flathub application IDs** (`com.spotify.Client`, `org.prismlauncher.PrismLauncher` — same unverified-app-ID caveat as #17), and the **NVIDIA driver package names** for the target release. The cheapest of these to close is the first: log into Steam once, restart the session, and look at the Big Picture library.

View File

@ -0,0 +1,290 @@
# 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.

View File

@ -0,0 +1,8 @@
# The ISO installs this from apt (python3-paho-mqtt) rather than pip — see
# live-build/config/package-lists/steam-tv-box.list.chroot. This file is for running
# the agent outside the image (development, a venv on a test box). The code works
# against both the 1.x and 2.x callback APIs.
paho-mqtt>=1.6
# Config is read from /etc/steamtv-agent/config.env by a small parser in main.py, so
# there is no python-dotenv dependency.

View File

@ -0,0 +1,39 @@
[Unit]
Description=Steam TV box agent (Home Assistant MQTT control surface for Sway)
Documentation=file:///opt/steamtv-agent
# A system unit rather than a `systemctl --user` unit: the agent has to be reachable
# over MQTT whether or not a graphical session is up, and it has to survive sway
# restarting (a user unit bound to graphical-session.target would go down with it).
# The session-scoped bits it needs — SWAYSOCK, XDG_RUNTIME_DIR,
# DBUS_SESSION_BUS_ADDRESS — are derived at call time from the kiosk user's runtime
# directory in sway_control.SwayControl.session_env(), which also means they are
# re-resolved after every sway restart instead of being frozen at unit start.
#
# Deliberately NOT After=network-online.target: this box must boot to Big Picture and
# play a game with the container host powered off, same rule as the thin client's
# Phase 11.10.
After=network.target
[Service]
Type=simple
User=@KIOSK_USERNAME@
Group=@KIOSK_USERNAME@
WorkingDirectory=/opt/steamtv-agent
Environment=PYTHONPATH=/opt/steamtv-agent
Environment=PYTHONUNBUFFERED=1
EnvironmentFile=-/etc/steamtv-agent/config.env
ExecStart=/usr/bin/python3 -m steamtv_agent.main
Restart=always
RestartSec=5
# Deliberately gentle on shutdown, and more so than the other agents: this process is
# the parent of nothing important, but `media-session stop` reaches Firefox and Spotify,
# both of which persist state on exit.
KillMode=mixed
TimeoutStopSec=20
# Sandboxing stops here on purpose: this unit's whole job is to spawn GUI child
# processes (Firefox, the Spotify and Prism flatpaks, Steam) that need the real /tmp,
# the user's home, and the session bus.
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,3 @@
"""steamtv-agent — Home Assistant MQTT control surface for the Steam TV box."""
__version__ = "0.1.0"

View File

@ -0,0 +1,211 @@
"""Persistent audio-output selection via WirePlumber's wpctl.
Two identifiers are in play and confusing them is the main failure mode here. `wpctl`
takes a numeric object ID, which WirePlumber reassigns on every boot and every device
hotplug useless for persistence. `node.name` is stable across both, which is what
audio-config.json stores. Home Assistant is shown neither: the select lists the human
descriptions ("Built-in Audio Analog Stereo"), because those are what someone picking
an output in a mobile app can actually recognise.
Per the security note in mqtt_discovery.py, the payload from HA is only ever used to
look up an entry in the sink table parsed from `wpctl status`. It never reaches a
command line: what does is the integer ID that lookup returns.
"""
from __future__ import annotations
import logging
import re
import subprocess
from dataclasses import dataclass
from .runtime_state import ensure_runtime_copy, load_json, save_json
log = logging.getLogger(__name__)
CONFIG_FILENAME = "audio-config.json"
SYSTEM_DEFAULT = "system-default"
# `wpctl status` prints a tree; sink rows look like
# │ * 47. Built-in Audio Analog Stereo [vol: 0.65]
# with a leading "*" on the current default. The box-drawing prefix varies between
# WirePlumber versions, so the row is matched from the ID onwards rather than anchored.
_SINK_ROW = re.compile(r"(\*?)\s*(\d+)\.\s+(.*?)(?:\s+\[vol:.*)?$")
_SECTION = re.compile(r"^\s*[^\w]*\s*(\w[\w /]*):\s*$")
@dataclass(frozen=True)
class Sink:
node_id: int
description: str
node_name: str
is_default: bool
class AudioControl:
def __init__(self, env_provider):
# wpctl talks to the user's PipeWire session, so it needs the same XDG_RUNTIME_DIR
# derivation SwayControl uses for swaymsg and playerctl.
self._env_provider = env_provider
self.config_path = ensure_runtime_copy(CONFIG_FILENAME)
config = load_json(self.config_path)
self.preferred_sink = str(config.get("preferred_sink") or "")
self.fallback = str(config.get("fallback") or SYSTEM_DEFAULT)
self._sinks: list[Sink] = []
# --- wpctl --------------------------------------------------------------
def _wpctl(self, *args: str) -> str | None:
try:
result = subprocess.run(
["wpctl", *args],
env=self._env_provider(),
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
log.warning("wpctl %s failed: %s", " ".join(args), exc)
return None
if result.returncode != 0:
log.warning("wpctl %s: %s", " ".join(args), result.stderr.strip())
return None
return result.stdout
def _node_name(self, node_id: int) -> str:
output = self._wpctl("inspect", str(node_id)) or ""
for line in output.splitlines():
if "node.name" in line:
_, _, value = line.partition("=")
return value.strip().strip('"')
return ""
def list_sinks(self) -> list[Sink]:
output = self._wpctl("status")
if output is None:
self._sinks = []
return self._sinks
sinks: list[Sink] = []
in_sinks = False
for line in output.splitlines():
section = _SECTION.match(line)
if section:
# Sources, Filters and Streams also carry numbered rows, so the parser
# has to stop at the next heading rather than read to end of output.
in_sinks = section.group(1).strip() == "Sinks"
continue
if not in_sinks:
continue
match = _SINK_ROW.search(line)
if not match:
continue
node_id = int(match.group(2))
sinks.append(
Sink(
node_id=node_id,
description=match.group(3).strip() or f"Sink {node_id}",
node_name=self._node_name(node_id),
is_default=match.group(1) == "*",
)
)
self._sinks = sinks
return sinks
# --- entity surface -----------------------------------------------------
def options(self) -> list[str]:
"""Select options for HA: descriptions, plus the "let WirePlumber decide" entry."""
seen: dict[str, int] = {}
result = [SYSTEM_DEFAULT]
for sink in self._sinks:
label = sink.description
if label in seen:
# Two identical descriptions (e.g. a pair of matched HDMI outputs) would
# otherwise collapse into one unselectable option.
seen[label] += 1
label = f"{label} ({seen[label]})"
else:
seen[label] = 1
result.append(label)
return result
def current_option(self) -> str:
for sink in self._sinks:
if self.preferred_sink and sink.node_name == self.preferred_sink:
return sink.description
if self.preferred_sink:
# Configured but not present right now — say so rather than silently
# reporting whatever WirePlumber happens to be using.
return SYSTEM_DEFAULT
for sink in self._sinks:
if sink.is_default:
return sink.description
return SYSTEM_DEFAULT
def _find(self, option: str) -> Sink | None:
for sink in self._sinks:
if sink.description == option:
return sink
# Match the disambiguating "(2)" suffix options() may have added.
base = re.sub(r"\s+\(\d+\)$", "", option)
matches = [s for s in self._sinks if s.description == base]
return matches[0] if matches else None
def apply_preferred(self) -> None:
"""Called once at startup, after the sink list has been read."""
if not self.preferred_sink:
log.info("no preferred audio sink configured; leaving WirePlumber's default")
return
for sink in self._sinks:
if sink.node_name == self.preferred_sink:
self._set_default(sink)
return
# Never fatal: a docked machine booted undocked, or an HDMI display that is off,
# legitimately has no such sink. The preference stays on file for next boot.
log.warning(
"preferred audio sink %r is not currently available; falling back to %s",
self.preferred_sink,
self.fallback,
)
def _set_default(self, sink: Sink) -> None:
log.info("setting default audio sink to %s (id=%s)", sink.description, sink.node_id)
self._wpctl("set-default", str(sink.node_id))
def select(self, option: str) -> str:
"""Handle the HA select. Returns the option to publish back as state."""
option = option.strip()
self.list_sinks()
if option == SYSTEM_DEFAULT:
self.preferred_sink = ""
self._save()
log.info("audio output preference cleared; WirePlumber's default applies")
return self.current_option()
sink = self._find(option)
if sink is None:
log.warning("ignoring unknown audio output %r", option)
return self.current_option()
self._set_default(sink)
if sink.node_name:
self.preferred_sink = sink.node_name
self._save()
else:
# Without a node.name there is nothing stable to persist; the change still
# applies to this boot.
log.warning(
"sink %r has no node.name; the change applies now but will not survive a reboot",
sink.description,
)
return sink.description
def _save(self) -> None:
save_json(
self.config_path,
{"preferred_sink": self.preferred_sink, "fallback": self.fallback},
)

View File

@ -0,0 +1,146 @@
"""Turning the attached TV on and off, so an empty room does not power a panel.
The television this box is plugged into draws 60-150 W while it shows a paused game
or an empty Big Picture that nobody is in the room to look at. This module is what
Home Assistant calls when presence says the room is occupied or empty the decision
lives in HA (an area's occupancy, the same presence system everything else here
uses), and the doing lives here.
Note what this deliberately does not do: it never touches the machine's own power.
Suspending a box mid-game loses the game. Turning the panel off while the machine
keeps running is the whole point see also /usr/local/bin/display-toggle, the local
no-network version of this, bound to the remote's power button.
TWO MECHANISMS, IN THIS ORDER
-----------------------------
1. **HDMI-CEC** (`cec-ctl`, from v4l-utils). This box is the HDMI *source*,
so it can put the display into standby and wake it again over the HDMI cable
itself. That is the one that actually saves the panel's power, and it needs no
network path to the TV, no pairing, no credentials, and no account it keeps
working with the LAN down, which is this project's whole posture. Android TV
and Google TV sets implement CEC as "HDMI-CEC", "Bravia Sync", "Anynet+",
"Simplink" and a dozen other brand names for the same standard; it usually has
to be enabled in the TV's settings once.
2. **Sway DPMS** (`swaymsg output <name> power on|off`) as the fallback, and as a
belt-and-braces companion: it stops the compositor driving pixels and drops the
HDMI signal, which most panels treat as "go to sleep" on their own. It always
works because it needs nothing but the compositor already running here but on
its own it may leave a TV showing a "no signal" banner rather than sleeping,
which is why CEC is tried first.
Both are attempted on every call unless CEC is switched off, because they fail in
different ways and neither reports reliably.
ONE CAVEAT SPECIFIC TO THIS IMAGE: when Steam is running under gamescope, gamescope
owns the output, and `swaymsg output power off` blanks gamescope's surface rather
than the game inside it. CEC is what actually darkens the panel in that case, which
is another reason it is tried first here rather than kept as the fallback.
WHAT "OFF" HONESTLY MEANS
-------------------------
Standby, not disconnected. A TV in CEC standby still draws roughly half a watt to
keep listening on the HDMI line that is what makes waking it possible at all.
This turns 60-150 W of lit panel into ~0.5 W of standby; it is not a smart plug
and does not pretend to be. If a set is one of the ones that ignores CEC standby
entirely, you will see it immediately (the panel stays lit) that is what the
verification note in hosts/steam-tv-box/README.md is for.
SECURITY POSTURE, UNCHANGED
---------------------------
This is another enumerated MQTT command, exactly like the workspace switch and the
canvas buttons: HA -> MQTT -> a fixed action here. A payload never becomes an argv
element `set_power()` takes a boolean, and the device names come from local
configuration, never from the message. See mqtt_discovery.py's module docstring.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
log = logging.getLogger(__name__)
CEC_TIMEOUT_SECONDS = 10
class DisplayPower:
def __init__(self, sway, cec_device: str | None = None, outputs: str = "*",
use_cec: bool = True):
self.sway = sway
# The CEC adapter, e.g. /dev/cec0. Most systems have exactly one and cec-ctl
# finds it on its own; this is for the machine that has two.
self.cec_device = cec_device or os.environ.get("CEC_DEVICE", "")
# Which Sway outputs to power down. "*" is every output, which is right for a
# box driving one TV; name an output (e.g. "HDMI-A-1") on a machine
# where only one of several screens is the TV.
self.outputs = outputs or "*"
self.use_cec = use_cec
self.state = True
# --- CEC ----------------------------------------------------------------
def _cec(self, *args: str) -> bool:
binary = shutil.which("cec-ctl")
if not binary:
log.info("cec-ctl is not installed; falling back to DPMS only")
return False
command = [binary]
if self.cec_device:
command += ["-d", self.cec_device]
command += list(args)
try:
result = subprocess.run(
command, capture_output=True, text=True, timeout=CEC_TIMEOUT_SECONDS
)
except (OSError, subprocess.SubprocessError) as exc:
log.warning("cec-ctl %s failed: %s", " ".join(args), exc)
return False
if result.returncode != 0:
log.warning("cec-ctl %s: %s", " ".join(args), (result.stderr or "").strip())
return False
return True
# --- the one public action ---------------------------------------------
def set_power(self, on: bool) -> bool:
"""Turn the display on or off. Returns the state it believes it left it in.
Deliberately not idempotent-by-early-return: HA asking for "on" when this
object already thinks it is on must still send the wake, because the TV may
have been turned off with its own remote and nothing here would know. The
state field is for reporting, never for skipping work.
"""
log.info("display: turning the panel %s", "on" if on else "off")
if self.use_cec:
# --to 0 addresses the TV specifically (logical address 0) rather than
# broadcasting, so a soundbar or receiver on the same bus is left alone.
if on:
self._cec("--to", "0", "--image-view-on")
# Ask to become the active source too: waking a TV that then shows a
# different input is the same as not waking it.
self._cec("--to", "0", "--active-source", "phys-addr=0.0.0.0")
else:
self._cec("--to", "0", "--standby")
# Always also drive the compositor: on a set that ignores CEC this is what
# stops it displaying, and on one that honours CEC it stops this machine
# rendering to a panel nobody is looking at.
self.sway.swaymsg("output", self.outputs, "power", "on" if on else "off")
self.state = on
return self.state
def handle_command(self, payload: str) -> bool:
"""MQTT payload -> action. Anything that isn't a known ON/OFF word is ignored
rather than guessed at, per the enumerated-command rule."""
value = (payload or "").strip().upper()
if value in ("ON", "TRUE", "1"):
return self.set_power(True)
if value in ("OFF", "FALSE", "0"):
return self.set_power(False)
log.warning("display: ignoring unknown power payload %r", payload)
return self.state

View File

@ -0,0 +1,292 @@
"""steamtv-agent entrypoint."""
from __future__ import annotations
import itertools
import logging
import os
import signal
import socket
import sys
import threading
from dataclasses import dataclass, field
import paho.mqtt.client as mqtt
from .audio_control import AudioControl
from .display_power import DisplayPower
from .mpris_bridge import MprisBridge
from .mqtt_discovery import Discovery
from .session_mode import MODES, SessionMode
from .sway_control import WS_GAMES, WS_MEDIA, WS_MUSIC, WS_STEAM, WS_WEB, SwayControl
CONFIG_PATH = os.environ.get("STEAMTV_AGENT_CONFIG", "/etc/steamtv-agent/config.env")
CONFIG_KEYS = (
"MQTT_BROKER_HOST",
"MQTT_BROKER_PORT",
"MQTT_USERNAME",
"MQTT_PASSWORD",
"HA_URL",
"KIOSK_USERNAME",
"STEAMTV_NAME",
"STEAMTV_ROOM",
"GPU_VENDOR",
"ENABLE_CEC",
)
WORKSPACES = (WS_STEAM, WS_GAMES, WS_WEB, WS_MEDIA, WS_MUSIC)
# How often the session-mode sensor is recomputed. Slower than the MPRIS poll on
# purpose: "is a game running" changes on a timescale of minutes, and each check costs
# two pgreps and a swaymsg. Riding the MPRIS loop rather than adding a second thread.
SESSION_POLL_EVERY = 5
log = logging.getLogger("steamtv-agent")
@dataclass(frozen=True)
class App:
name: str
command: list[str] = field(default_factory=list)
process_pattern: str | None = None
workspace: str | None = None
focus_criteria: str | None = None
icon: str = "mdi:application"
def load_config(path: str = CONFIG_PATH) -> dict[str, str]:
values: dict[str, str] = {}
try:
with open(path, encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip('"').strip("'")
except OSError as exc:
log.warning("could not read %s (%s); falling back to the environment", path, exc)
for key in CONFIG_KEYS:
if key in os.environ:
values[key] = os.environ[key]
return values
def build_apps(_config: dict[str, str]) -> dict[str, App]:
"""The launch table. Every command is built from constants in this file — a payload
only ever selects a key here, it never contributes an argv element. See the security
note in mqtt_discovery.py."""
return {
"steam": App(
name="Steam Big Picture",
command=["/usr/local/bin/steam-big-picture"],
# No process_pattern: steam-big-picture does its own already-running check
# and knows how to ask a live client to re-open Big Picture, which is more
# than "focus the window" can do.
workspace=WS_STEAM,
icon="mdi:steam",
),
"prism": App(
name="Prism Launcher",
# Goes through Steam (steam://rungameid/…) so Steam Input and the Steam
# Controller API are live for Minecraft — see the script's header. Which is
# also why there is no workspace here: when Steam runs it, the window
# belongs to Steam's own workspace, and forcing 2:games would move it away
# from the client that owns it. 2:games is where it lands only on the
# direct-launch fallback path, and sway puts it there by focus anyway.
command=["/usr/local/bin/prism-launch"],
process_pattern="org.prismlauncher.PrismLauncher",
focus_criteria='app_id="org.prismlauncher.PrismLauncher"',
icon="mdi:minecraft",
),
"web_browser": App(
name="web browser",
command=["/usr/local/bin/web-browser"],
workspace=WS_WEB,
icon="mdi:web",
),
"media_player": App(
name="media player",
command=["/usr/local/bin/media-player"],
process_pattern="media-player-idle",
workspace=WS_MEDIA,
focus_criteria='app_id="mpv"',
icon="mdi:play-box",
),
"spotify": App(
name="Spotify",
command=["/usr/local/bin/spotify-launch"],
process_pattern="com.spotify.Client",
workspace=WS_MUSIC,
focus_criteria='app_id="com.spotify.Client"',
icon="mdi:spotify",
),
}
def make_client(client_id: str) -> mqtt.Client:
# paho-mqtt 2.x requires an explicit callback API version; older Debian releases
# ship 1.6.x and have no such argument. VERSION1 is requested when available so the
# callback signatures below are identical under both.
callback_api = getattr(mqtt, "CallbackAPIVersion", None)
if callback_api is not None:
return mqtt.Client(callback_api.VERSION1, client_id=client_id)
return mqtt.Client(client_id=client_id)
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
)
config = load_config()
hostname = socket.gethostname()
node_id = "".join(c if c.isalnum() else "_" for c in hostname).strip("_") or "steamtv"
friendly_name = config.get("STEAMTV_NAME") or f"Steam TV box ({hostname})"
# The HA area this device sits in, published as suggested_area — see
# docs/rooms-and-endpoints.md. Blank is fine and means "no suggestion".
room = config.get("STEAMTV_ROOM", "")
broker_host = config.get("MQTT_BROKER_HOST", "")
broker_port = int(config.get("MQTT_BROKER_PORT") or 1883)
sway = SwayControl()
apps = build_apps(config)
audio = AudioControl(sway.session_env)
session = SessionMode(sway)
display = DisplayPower(sway, use_cec=config.get("ENABLE_CEC", "true") != "false")
client = make_client(f"steamtv-agent-{node_id}")
if config.get("MQTT_USERNAME"):
client.username_pw_set(config["MQTT_USERNAME"], config.get("MQTT_PASSWORD") or None)
discovery = Discovery(client, node_id, friendly_name, room)
mpris = MprisBridge(discovery.publish_media_state, sway.session_env)
def on_launch(key: str) -> None:
app = apps[key]
sway.launch_app(
app.command,
process_pattern=app.process_pattern,
workspace=app.workspace,
focus_criteria=app.focus_criteria,
)
if app.workspace:
discovery.publish_workspace(app.workspace)
def on_workspace(payload: str) -> None:
name = payload.strip()
# Enumerated, never passed through: see the security note in mqtt_discovery.py.
if name not in WORKSPACES:
log.warning("ignoring unknown workspace %r", name)
return
sway.switch_workspace(name)
discovery.publish_workspace(name)
def on_session_mode(payload: str) -> None:
mode = session.select(payload)
discovery.publish_session_state(mode, session.attributes())
def on_stop_media() -> None:
mode = session.stop_media()
discovery.publish_session_state(mode, session.attributes())
def on_audio_output(payload: str) -> None:
discovery.publish_audio_output(audio.select(payload))
def on_display(payload: str) -> None:
discovery.publish_display_power(display.handle_command(payload))
def on_connect(_client, _userdata, _flags, rc):
if rc != 0:
log.error("MQTT connection refused (rc=%s)", rc)
return
log.info("connected to MQTT broker %s:%s", broker_host, broker_port)
discovery.register_media_player(mpris.handle_command, mpris.set_volume)
discovery.register_app_launchers(apps, on_launch)
discovery.register_workspace_select(WORKSPACES, on_workspace, WS_STEAM)
discovery.register_session_mode(MODES, on_session_mode, on_stop_media)
# The sink list is read here rather than at construction because WirePlumber may
# not be up yet when this service starts (it is a system unit; the session is
# not). By the time the broker connects, the session normally is.
audio.list_sinks()
audio.apply_preferred()
discovery.register_audio_output(audio.options(), audio.current_option(), on_audio_output)
discovery.register_display_power(on_display, display.state)
discovery.subscribe_all()
discovery.publish_available(True)
discovery.publish_session_state(session.current(), session.attributes())
def on_disconnect(_client, _userdata, rc):
log.warning("disconnected from MQTT broker (rc=%s); paho will retry", rc)
def on_message(_client, _userdata, message):
discovery.dispatch(message.topic, message.payload.decode("utf-8", "replace"))
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.will_set(discovery.availability_topic, "offline", qos=1, retain=True)
stop_event = threading.Event()
def handle_signal(_signum, _frame):
stop_event.set()
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
if not broker_host:
log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH)
else:
# connect_async + loop_start, never a blocking connect(): this box must boot to
# Big Picture and play a game with the container host powered off. Same
# "reactive path never depends on a remote service" rule as the thin client's
# Phase 11.10 — and it matters more here, since nobody wants their console to
# need the house's server to be up.
client.connect_async(broker_host, broker_port, keepalive=60)
client.loop_start()
log.info("steamtv-agent %s started (node_id=%s)", node_id, node_id)
ticks = itertools.count(1)
def poll_session() -> None:
if next(ticks) % SESSION_POLL_EVERY:
return
mode = session.current()
if mode == session.last_published:
return
session.last_published = mode
if broker_host:
discovery.publish_session_state(mode, session.attributes())
# The MPRIS bridge owns the main loop; the session sensor rides along on it rather
# than starting a second thread to do the same waiting.
original_poll = mpris.poll_once
def poll_both() -> None:
original_poll()
poll_session()
mpris.poll_once = poll_both # type: ignore[method-assign]
try:
mpris.run_forever(stop_event)
finally:
log.info("shutting down")
if broker_host:
discovery.publish_available(False)
client.loop_stop()
client.disconnect()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,138 @@
"""Bridges mpv's and Spotify's MPRIS state to the HA media_player entity via playerctl.
Identical in shape and reasoning to
hosts/thin-client/agent/thinclient_agent/mpris_bridge.py see that file's docstring
for why this polls via subprocess rather than holding a dbus connection open.
PLAYER_PRIORITY lists mpv first, then the Spotify GUI client. That order is the answer
to "both are alive, which one does the remote's play button reach?", and mpv wins
because it is the one holding something somebody deliberately opened.
What is NOT in that list is any game. Steam exposes no MPRIS bus, and a game's audio is
not something you "pause" from a phone the volume controls in audio_control.py are
the right surface for a game making noise, not these transport buttons.
This bridge reports "off" for most of a gaming session, and that is correct rather than
broken: on this image the media players genuinely do not exist until somebody leaves
Big Picture (see configs/session/media-session). The entity to watch for "what is this
box doing" is the session-mode sensor in session_mode.py.
"""
from __future__ import annotations
import logging
import subprocess
import threading
log = logging.getLogger(__name__)
PLAYER_PRIORITY = "mpv,spotify,%any"
_STATUS_TO_HA = {
"Playing": "playing",
"Paused": "paused",
"Stopped": "idle",
}
_METADATA_FORMAT = "{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}"
class MprisBridge:
def __init__(self, publish_state, env_provider, poll_interval: float = 2.0):
self._publish_state = publish_state
# playerctl needs DBUS_SESSION_BUS_ADDRESS, which this system service does not
# inherit; SwayControl.session_env() derives it from the kiosk user's runtime dir.
self._env_provider = env_provider
self._poll_interval = poll_interval
self._last_state: dict | None = None
def _playerctl(self, *args: str) -> str | None:
try:
result = subprocess.run(
["playerctl", "-p", PLAYER_PRIORITY, *args],
env=self._env_provider(),
capture_output=True,
text=True,
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
log.debug("playerctl %s failed: %s", " ".join(args), exc)
return None
if result.returncode != 0:
return None
return result.stdout.strip()
def read_state(self) -> dict:
status = self._playerctl("status")
if status is None:
return {"state": "off"}
state = {"state": _STATUS_TO_HA.get(status, "idle")}
metadata = self._playerctl("metadata", "--format", _METADATA_FORMAT)
if metadata:
title, artist, album, length, art_url = (metadata.split("\x1f") + [""] * 5)[:5]
state["title"] = title
state["artist"] = artist
state["album"] = album
state["art_url"] = art_url
if length.isdigit():
state["duration"] = int(length) // 1_000_000
position = self._playerctl("position")
if position:
try:
state["position"] = int(float(position))
except ValueError:
pass
volume = self._playerctl("volume")
if volume:
try:
state["volume"] = round(float(volume), 3)
except ValueError:
pass
return state
def poll_once(self) -> None:
state = self.read_state()
if state != self._last_state:
self._last_state = state
self._publish_state(state)
def run_forever(self, stop_event: threading.Event) -> None:
while not stop_event.is_set():
try:
self.poll_once()
except Exception:
log.exception("MPRIS poll failed")
stop_event.wait(self._poll_interval)
# --- command side -------------------------------------------------------
def handle_command(self, command: str) -> None:
command = command.strip().upper()
action = {
"PLAY": ("play",),
"PAUSE": ("pause",),
"PLAY_PAUSE": ("play-pause",),
"TOGGLE": ("play-pause",),
"STOP": ("stop",),
"NEXT": ("next",),
"PREVIOUS": ("previous",),
"PREV": ("previous",),
}.get(command)
if action is None:
log.warning("ignoring unknown media command %r", command)
return
log.info("media command %s", command)
self._playerctl(*action)
self.poll_once()
def set_volume(self, level: float) -> None:
level = max(0.0, min(1.0, level))
self._playerctl("volume", f"{level:.3f}")
self.poll_once()

View File

@ -0,0 +1,308 @@
"""Home Assistant MQTT Discovery payloads and command dispatch.
SECURITY BOUNDARY this module is the entire remote-control API of the Steam TV box.
Same principle as hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py and the
touch panel's, applied to this device: the local LLM never gets a network path to this
machine. The only chain is: LLM tool call -> Home Assistant service call -> MQTT ->
this dispatcher. That property holds only as long as this stays the sole inbound
control surface: no HTTP listener, no websocket server, no exposed Sway IPC socket, no
shell endpoint. A new feature belongs as another entity below, not as another listener.
It is worth restating on this host in particular, because it is the one machine here
with a real GPU, a Steam login and a games library on it i.e. the one with something
worth taking. Nothing in this image listens on a port except sshd (key-only) and wayvnc
(password-mandatory, fails closed). Steam's own remote-play ports are not opened by
anything here; if you want them, that is a deliberate decision to make in the firewall,
not a default this image ships.
Every command handler here is a fixed, enumerated action. A payload never becomes an
argv element, a shell string, or a URL host see the launch table in main.py, which
builds every command from local constants and uses the payload only to pick between
known values.
"""
from __future__ import annotations
import json
import logging
from typing import Callable
from . import __version__
log = logging.getLogger(__name__)
DISCOVERY_PREFIX = "homeassistant"
class Discovery:
def __init__(self, client, node_id: str, friendly_name: str, room: str = ""):
self.client = client
self.node_id = node_id
self.friendly_name = friendly_name
self.room = (room or "").strip()
self.base = f"steamtv/{node_id}"
self.availability_topic = f"{self.base}/availability"
self.media_state_topic = f"{self.base}/media/state"
self.session_state_topic = f"{self.base}/session/state"
self._handlers: dict[str, Callable[[str], None]] = {}
self.device = {
"identifiers": [f"steamtv_{node_id}"],
"name": friendly_name,
"manufacturer": "SmartestHome",
"model": "Steam TV box",
"sw_version": __version__,
}
# Which room this physically sits in, as an HA area_id. `suggested_area` is
# honoured by HA only when the device is FIRST discovered — moving a device
# later means moving it in HA too, this cannot un-file it. Omitted entirely
# when unset, because an empty suggested_area is not the same request as no
# suggestion. See docs/rooms-and-endpoints.md.
if self.room:
self.device["suggested_area"] = self.room
# --- plumbing -----------------------------------------------------------
def _publish_config(self, component: str, object_id: str, payload: dict) -> None:
payload = {
"availability_topic": self.availability_topic,
"device": self.device,
"unique_id": f"{self.node_id}_{object_id}",
**payload,
}
topic = f"{DISCOVERY_PREFIX}/{component}/{self.node_id}/{object_id}/config"
self.client.publish(topic, json.dumps(payload), qos=1, retain=True)
def _command_topic(self, suffix: str, handler) -> str:
topic = f"{self.base}/{suffix}"
self._handlers[topic] = handler
return topic
def subscribe_all(self) -> None:
for topic in self._handlers:
self.client.subscribe(topic, qos=1)
def dispatch(self, topic: str, payload: str) -> None:
handler = self._handlers.get(topic)
if handler is None:
log.warning("no handler for %s", topic)
return
try:
handler(payload)
except Exception:
log.exception("handler for %s failed", topic)
def publish_available(self, available: bool = True) -> None:
self.client.publish(
self.availability_topic,
"online" if available else "offline",
qos=1,
retain=True,
)
def publish_media_state(self, state: dict) -> None:
self.client.publish(self.media_state_topic, json.dumps(state), qos=0, retain=True)
# --- media --------------------------------------------------------------
def register_media_player(self, on_command, on_volume) -> None:
command_topic = self._command_topic("media/command", on_command)
volume_topic = self._command_topic("media/volume/set", lambda p: on_volume(float(p)))
# Core Home Assistant's MQTT integration has NO media_player platform — see
# hosts/thin-client/README.md's identical caveat. The button/number entities
# below give the same transport control with stock HA.
self._publish_config(
"media_player",
"media",
{
"name": "Media",
"state_topic": self.media_state_topic,
"state_template": "{{ value_json.state }}",
"command_topic": command_topic,
"volume_command_topic": volume_topic,
"volume_state_topic": self.media_state_topic,
"volume_template": "{{ value_json.volume }}",
"title_template": "{{ value_json.title }}",
"artist_template": "{{ value_json.artist }}",
"album_template": "{{ value_json.album }}",
},
)
for object_id, name, payload, icon in (
("media_play_pause", "Play/pause", "PLAY_PAUSE", "mdi:play-pause"),
("media_next", "Next track", "NEXT", "mdi:skip-next"),
("media_previous", "Previous track", "PREVIOUS", "mdi:skip-previous"),
("media_stop", "Stop", "STOP", "mdi:stop"),
):
self._publish_config(
"button",
object_id,
{
"name": name,
"command_topic": command_topic,
"payload_press": payload,
"icon": icon,
},
)
self._publish_config(
"sensor",
"media_state",
{
"name": "Playback state",
"state_topic": self.media_state_topic,
"value_template": "{{ value_json.state }}",
"json_attributes_topic": self.media_state_topic,
"icon": "mdi:play-circle",
},
)
self._publish_config(
"number",
"media_volume",
{
"name": "Volume",
"command_topic": volume_topic,
"state_topic": self.media_state_topic,
"value_template": "{{ value_json.volume }}",
"min": 0,
"max": 1,
"step": 0.05,
"mode": "slider",
"icon": "mdi:volume-high",
},
)
# --- apps / workspaces --------------------------------------------------
def register_app_launchers(self, apps, on_launch) -> None:
for key, app in apps.items():
self._publish_config(
"button",
f"launch_{key}",
{
"name": f"Launch {app.name}",
"command_topic": self._command_topic(
f"app/{key}/launch",
lambda _payload, key=key: on_launch(key),
),
"icon": app.icon,
},
)
def register_workspace_select(self, workspaces, on_workspace, state_topic_value) -> None:
self._publish_config(
"select",
"workspace",
{
"name": "Screen",
"command_topic": self._command_topic("workspace/set", on_workspace),
"state_topic": f"{self.base}/workspace/state",
"options": list(workspaces),
"icon": "mdi:view-dashboard",
},
)
self.client.publish(
f"{self.base}/workspace/state", state_topic_value, qos=1, retain=True
)
def publish_workspace(self, name: str) -> None:
self.client.publish(f"{self.base}/workspace/state", name, qos=1, retain=True)
# --- session mode -------------------------------------------------------
def register_session_mode(self, modes, on_select, on_stop_media) -> None:
"""The "what is this box doing" surface — see session_mode.py.
A sensor and a select rather than one entity, because the two are not the same
question. The sensor reports four states (gaming / steam / media / idle); the
select offers only the two that are meaningful to *ask for*. "idle" is not
something you can request, and "steam" (Steam up but not focused) is a
transitional state nobody sets on purpose.
"""
self._publish_config(
"sensor",
"session_mode",
{
"name": "Session",
"state_topic": self.session_state_topic,
"value_template": "{{ value_json.mode }}",
"json_attributes_topic": self.session_state_topic,
"icon": "mdi:gamepad-variant",
},
)
self._publish_config(
"select",
"session_mode_select",
{
"name": "Mode",
"command_topic": self._command_topic("session/mode/set", on_select),
"state_topic": self.session_state_topic,
"value_template": "{{ value_json.mode }}",
"options": list(modes),
"icon": "mdi:gamepad-variant",
},
)
# The only thing in this whole surface that shuts something down, and therefore
# its own explicit button rather than a side effect of switching mode. See
# SessionMode.select()'s docstring for why the mode switch is additive.
self._publish_config(
"button",
"stop_media",
{
"name": "Stop media apps",
"command_topic": self._command_topic(
"session/media/stop", lambda _payload: on_stop_media()
),
"icon": "mdi:close-circle-outline",
},
)
def publish_session_state(self, mode: str, attributes: dict | None = None) -> None:
payload = {"mode": mode}
if attributes:
payload.update(attributes)
self.client.publish(
self.session_state_topic, json.dumps(payload), qos=1, retain=True
)
# --- audio output -------------------------------------------------------
def register_audio_output(self, options, current, on_select) -> None:
self._publish_config(
"select",
"audio_output",
{
"name": "Audio output",
"command_topic": self._command_topic("audio/output/set", on_select),
"state_topic": f"{self.base}/audio/output/state",
"options": list(options),
"icon": "mdi:speaker",
},
)
self.publish_audio_output(current)
def publish_audio_output(self, option: str) -> None:
self.client.publish(
f"{self.base}/audio/output/state", option, qos=1, retain=True
)
# --- display power ------------------------------------------------------
def register_display_power(self, on_command, initial: bool = True) -> None:
self._publish_config(
"switch",
"display",
{
"name": "Display",
"command_topic": self._command_topic("display/set", on_command),
"state_topic": f"{self.base}/display/state",
"payload_on": "ON",
"payload_off": "OFF",
"icon": "mdi:television",
},
)
self.publish_display_power(initial)
def publish_display_power(self, on: bool) -> None:
self.client.publish(
f"{self.base}/display/state", "ON" if on else "OFF", qos=1, retain=True
)

View File

@ -0,0 +1,75 @@
"""Seeds writable runtime copies of the read-only config templates in the image.
One of this agent's config files (audio-config.json) is rewritten at runtime — the HA
"Audio output" select has to survive a reboot, and it cannot live where the build put
it, because the live image's /etc is inside a squashfs. (The thin client's copy of this
module also covers rdp-vnc.json; this box has no outbound remote-desktop client, so
audio is the only file here. The module is kept whole rather than trimmed so the two
stay comparable.)
So the same split the wayvnc password already uses applies here a committed template
that the build bakes in read-only, plus a real file created on the booted machine that
is never committed. The difference is that wayvnc's real file is written by a human and
fails closed if they forget, whereas these two are seeded automatically from the
template, because "no audio-output preference yet" is a perfectly safe state and there
is nothing to fail closed about.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import tempfile
log = logging.getLogger(__name__)
TEMPLATE_DIR = os.environ.get("STEAMTV_TEMPLATE_DIR", "/etc/steamtv-agent")
STATE_DIR = os.environ.get("STEAMTV_STATE_DIR", "/var/lib/steamtv-agent")
def ensure_runtime_copy(filename: str) -> str:
"""Return the writable path for `filename`, seeding it from the template if new."""
runtime_path = os.path.join(STATE_DIR, filename)
if os.path.exists(runtime_path):
return runtime_path
template_path = os.path.join(TEMPLATE_DIR, filename)
try:
os.makedirs(STATE_DIR, exist_ok=True)
shutil.copyfile(template_path, runtime_path)
log.info("seeded %s from %s", runtime_path, template_path)
except OSError as exc:
log.warning("could not seed %s from %s: %s", runtime_path, template_path, exc)
return runtime_path
def load_json(path: str) -> dict:
try:
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError) as exc:
log.warning("could not read %s (%s); using defaults", path, exc)
return {}
return data if isinstance(data, dict) else {}
def save_json(path: str, data: dict) -> bool:
"""Write atomically — a half-written config on a power cut would be worse than a
stale one, since these files are read unattended at boot."""
directory = os.path.dirname(path) or "."
try:
os.makedirs(directory, exist_ok=True)
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=directory, delete=False
) as handle:
json.dump(data, handle, indent=2)
handle.write("\n")
temp_path = handle.name
os.replace(temp_path, path)
except OSError as exc:
log.warning("could not write %s: %s", path, exc)
return False
return True

View File

@ -0,0 +1,146 @@
"""What is this box doing right now — gaming, media, or idle — and switching between.
WHY THIS EXISTS
---------------
Home Assistant needs to be able to answer "is somebody playing on the TV box?" without
guessing from power draw. That one sensor is worth a lot in automations: don't dim the
living room during a game, don't announce the doorbell over a raid, count the room as
occupied even though nobody has moved for forty minutes.
WHAT IT READS
-------------
Two cheap facts, both local:
- is the Steam client running (pgrep);
- which sway workspace is focused (swaymsg -t get_workspaces).
Deliberately NOT the window title. Steam's window titles and the existence of a
separate Big Picture window have both changed across client rewrites, and a sensor that
silently goes wrong after a Steam update is worse than one that is slightly coarse. The
workspace name is a contract this repo owns see the header in
configs/session/session-watcher, which makes the same call for the same reason.
MODES
-----
gaming Steam is running and 1:steam is focused. The screen is showing a game or
Big Picture.
steam Steam is running but the user has moved elsewhere (this is "exited Big
Picture but left Steam up", the case session-watcher reacts to).
media Steam is not running; the media session is what is on screen.
idle nothing is up. Only really seen in the seconds after boot, or after
somebody quit Steam and the media session has not been started yet.
SECURITY POSTURE, UNCHANGED
---------------------------
The two commands below take no arguments derived from any MQTT payload. The mode
*switch* is an enumerated action see mqtt_discovery.py's module docstring.
"""
from __future__ import annotations
import json
import logging
from .sway_control import WS_STEAM
log = logging.getLogger(__name__)
MODE_GAMING = "gaming"
MODE_STEAM = "steam"
MODE_MEDIA = "media"
MODE_IDLE = "idle"
MODES = (MODE_GAMING, MODE_MEDIA)
STEAM_PROCESS = "steam"
MEDIA_SESSION = "/usr/local/bin/media-session"
STEAM_BIG_PICTURE = "/usr/local/bin/steam-big-picture"
# What media-session itself guards on. Kept in sync with that script by hand; there is
# no shared file to read, and inventing one to hold three strings would be worse.
MEDIA_PATTERNS = (
"com.spotify.Client",
"media-player-idle",
"firefox.*--profile.*/firefox/web",
)
class SessionMode:
def __init__(self, sway):
self.sway = sway
# What current() last computed, versus what was last put on the broker. Two
# fields because current() updates the first every time it is called, so it
# cannot also serve as "have we told HA about this yet".
self.last_mode = MODE_IDLE
self.last_published = ""
# --- reading ------------------------------------------------------------
def focused_workspace(self) -> str:
output = self.sway.swaymsg("-t", "get_workspaces")
if not output:
return ""
try:
workspaces = json.loads(output)
except ValueError:
log.warning("could not parse get_workspaces output")
return ""
for workspace in workspaces:
if isinstance(workspace, dict) and workspace.get("focused"):
return str(workspace.get("name") or "")
return ""
def steam_running(self) -> bool:
# is_process (pgrep -x), not is_running (pgrep -f) — see that method's comment
# for why Steam specifically needs the exact-name match.
return self.sway.is_process(STEAM_PROCESS)
def media_running(self) -> bool:
return any(self.sway.is_running(pattern) for pattern in MEDIA_PATTERNS)
def current(self) -> str:
steam = self.steam_running()
if steam and self.focused_workspace() == WS_STEAM:
mode = MODE_GAMING
elif steam:
mode = MODE_STEAM
elif self.media_running():
mode = MODE_MEDIA
else:
mode = MODE_IDLE
self.last_mode = mode
return mode
def attributes(self) -> dict:
return {
"steam_running": self.steam_running(),
"media_running": self.media_running(),
"workspace": self.focused_workspace(),
}
# --- switching ----------------------------------------------------------
def select(self, mode: str) -> str:
"""Handle the HA select. Enumerated: anything else is ignored, not guessed at.
Note the asymmetry, which is intentional. Choosing "gaming" launches or focuses
Steam and leaves the media apps alone someone may well want music over a
game, and killing a running Spotify because a game started would lose whatever
was playing. Choosing "media" starts the media session and likewise does not
quit Steam, because quitting Steam from a phone while somebody is mid-match is
not a thing this should be able to do by accident. Both directions are additive;
the only thing that shuts anything down is the explicit "Stop media apps"
button.
"""
mode = (mode or "").strip().lower()
if mode == MODE_GAMING:
log.info("session mode -> gaming")
self.sway.launch_app([STEAM_BIG_PICTURE])
elif mode == MODE_MEDIA:
log.info("session mode -> media")
self.sway.launch_app([MEDIA_SESSION, "start", "--focus"])
else:
log.warning("ignoring unknown session mode %r", mode)
return self.current()
def stop_media(self) -> str:
log.info("stopping the media session")
self.sway.launch_app([MEDIA_SESSION, "stop"])
return self.current()

View File

@ -0,0 +1,135 @@
"""Thin wrapper around swaymsg and local process launching.
Identical in shape to hosts/thin-client/agent/thinclient_agent/sway_control.py and
hosts/touch-panel/agent/touchpanel_agent/sway_control.py duplicated rather than
imported across hosts, same convention as the rest of this repo's agents.
"""
from __future__ import annotations
import glob
import logging
import os
import subprocess
log = logging.getLogger(__name__)
# Contract with configs/sway/config — these strings must match the `set $ws_*` lines,
# and with configs/session/media-session and configs/session/session-watcher, which
# hardcode the same names in shell.
WS_STEAM = "1:steam"
WS_GAMES = "2:games"
WS_WEB = "3:web"
WS_MEDIA = "4:media"
WS_MUSIC = "5:music"
def runtime_dir() -> str:
return os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
class SwayControl:
def _socket_path(self) -> str | None:
path = os.environ.get("SWAYSOCK")
if path and os.path.exists(path):
return path
# sway names the socket sway-ipc.<uid>.<pid>.sock, so the path changes every
# time sway restarts. steamtv-agent is a system service that outlives the
# session, so the socket is re-resolved per call instead of cached at startup.
matches = sorted(glob.glob(os.path.join(runtime_dir(), "sway-ipc.*.sock")))
return matches[-1] if matches else None
def session_env(self) -> dict[str, str]:
env = dict(os.environ)
env["XDG_RUNTIME_DIR"] = runtime_dir()
env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path={runtime_dir()}/bus")
env.setdefault("WAYLAND_DISPLAY", "wayland-1")
sock = self._socket_path()
if sock:
env["SWAYSOCK"] = sock
return env
def swaymsg(self, *args: str) -> str | None:
if self._socket_path() is None:
log.warning("no sway IPC socket found; dropping command %s", " ".join(args))
return None
try:
result = subprocess.run(
["swaymsg", *args],
env=self.session_env(),
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
log.warning("swaymsg %s failed: %s", " ".join(args), exc)
return None
if result.returncode != 0:
log.warning("swaymsg %s: %s", " ".join(args), result.stderr.strip())
return None
return result.stdout
def switch_workspace(self, name: str) -> None:
log.info("switching to workspace %s", name)
self.swaymsg("workspace", name)
def focus_window(self, criteria: str) -> None:
self.swaymsg(f"[{criteria}] focus")
def is_running(self, pattern: str) -> bool:
"""Match `pattern` against full command lines (pgrep -f)."""
return self._pgrep("-f", pattern)
def is_process(self, name: str) -> bool:
"""Match `name` against process names only (pgrep -x).
Separate from is_running() because Steam is the one thing here that needs it:
its command line is `/bin/sh /usr/games/steam -gamepadui ` and it spawns a
dozen helpers (steamwebhelper, reaper, steamerrorreporter) whose command lines
all contain the word "steam". A -f match would report Steam as running long
after the client has gone, which would leave the session-mode sensor stuck on
"gaming" for the rest of the evening.
"""
return self._pgrep("-x", name)
def _pgrep(self, flag: str, pattern: str) -> bool:
try:
result = subprocess.run(
["pgrep", "-u", str(os.getuid()), flag, pattern],
capture_output=True,
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return False
return result.returncode == 0
def launch_app(
self,
command: list[str],
process_pattern: str | None = None,
workspace: str | None = None,
focus_criteria: str | None = None,
) -> None:
if workspace:
self.switch_workspace(workspace)
if process_pattern and self.is_running(process_pattern):
log.info("%s already running; focusing instead of launching", command[0])
if focus_criteria:
self.focus_window(focus_criteria)
return
log.info("launching %s", " ".join(command))
try:
subprocess.Popen(
command,
env=self.session_env(),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError as exc:
log.error("could not launch %s: %s", " ".join(command), exc)

View File

@ -0,0 +1,10 @@
{
"_comment": "TEMPLATE. Baked into the image read-only at /etc/steamtv-agent/audio-config.json. On first boot steamtv_agent/audio_control.py copies it to /var/lib/steamtv-agent/audio-config.json and from then on reads and REWRITES only that copy — the HA 'Audio output' select has to survive a reboot, and a file inside a squashfs image cannot. Same split as the wayvnc password: a committed template plus a runtime-populated real file that is gitignored.",
"_comment_preferred_sink": "A WirePlumber node.name, not a description and not a numeric ID. IDs are reassigned on every boot and on every device hotplug; node.name is stable. Find yours on the booted machine with: wpctl status then wpctl inspect <id> | grep node.name",
"_comment_this_box": "On a machine plugged into a television this is usually the HDMI sink (alsa_output.pci-....hdmi-stereo) rather than the analog one, and getting it wrong is the classic 'the game has no sound' report. Worth setting explicitly here rather than trusting WirePlumber's pick, because a TV that is switched off at boot can leave the HDMI sink absent and WirePlumber will happily default to the motherboard's headphone jack — which nothing is plugged into.",
"preferred_sink": "",
"fallback": "system-default"
}

View File

@ -0,0 +1,63 @@
{
"_comment_path": "Installed to /etc/firefox/policies/policies.json, the documented Linux location for Firefox enterprise policy. 0900-firefox.hook.chroot also links it into firefox-esr's distribution/ directory, an older location some builds read instead; whichever one the installed build honours wins and the other is ignored. Note that every key below lives OUTSIDE the 'policies' object on purpose — Firefox flags unrecognised keys *inside* it as invalid policies in about:policies.",
"_comment_extensions": "VERIFY BEFORE THE FIRST REAL BUILD. The two AMO slugs ('ublock-origin', 'sponsorblock') were confirmed against the live addons.mozilla.org listings, and /firefox/downloads/latest/<slug>/latest.xpi is AMO's documented always-current download URL, so install_url should be right. The ExtensionSettings *keys* must be each extension's real add-on ID as Firefox sees it, and those were NOT verifiable from the AMO listing pages — they are widely-published values reproduced here, not checked. If an extension silently fails to install, that key is the first suspect: install the XPI by hand once, read the ID off about:debugging#/runtime/this-firefox, and correct it here.",
"_comment_kiosk_ui": "The Disable*/UserMessaging/FirefoxHome blocks all remove a prompt, tour, or nag that would otherwise sit on an unattended screen in a shared room with nobody there to dismiss it.",
"_comment_updates": "The browser is part of the image and is replaced by rebuilding and reflashing it, so in-browser updates would only produce version drift between rooms plus a restart banner nobody is there to click.",
"policies": {
"ExtensionSettings": {
"uBlock0@raymondhill.net": {
"installation_mode": "force_installed",
"install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi",
"default_area": "menupanel"
},
"sponsorBlocker@ajay.app": {
"installation_mode": "force_installed",
"install_url": "https://addons.mozilla.org/firefox/downloads/latest/sponsorblock/latest.xpi",
"default_area": "menupanel"
}
},
"DisableProfileImport": true,
"DisableProfileRefresh": true,
"DisableFirefoxAccounts": true,
"DisableFirefoxStudies": true,
"DisableTelemetry": true,
"DisablePocket": true,
"DisableFeedbackCommands": true,
"DisableSetDesktopBackground": true,
"DontCheckDefaultBrowser": true,
"NoDefaultBookmarks": true,
"OfferToSaveLogins": false,
"PasswordManagerEnabled": false,
"PromptForDownloadLocation": false,
"AppAutoUpdate": false,
"DisableAppUpdate": true,
"UserMessaging": {
"WhatsNew": false,
"ExtensionRecommendations": false,
"FeatureRecommendations": false,
"UrlbarInterventions": false,
"SkipOnboarding": true,
"MoreFromMozilla": false
},
"FirefoxHome": {
"Search": true,
"TopSites": true,
"SponsoredTopSites": false,
"Highlights": false,
"Pocket": false,
"SponsoredPocket": false,
"Snippets": false
},
"OverrideFirstRunPage": "",
"OverridePostUpdatePage": ""
}
}

View File

@ -0,0 +1,55 @@
// Prefs for the Steam TV box's Firefox profile. Installed by
// build-steam-tv-box-iso.sh to /etc/steamtv-firefox/user.js, from where
// /usr/local/bin/web-browser copies it into
// /home/<KIOSK_USERNAME>/.mozilla/firefox/web on every launch.
//
// One profile here, not two: this image has no digest canvas — the browser is the
// general-browsing window and nothing else. Otherwise these are the thin client's
// prefs verbatim, and for the same reasons.
//
// user.js rather than prefs.js: user.js is re-applied to prefs.js on every startup, so
// nothing a stray click changes at runtime survives a restart. That matters on a kiosk
// nobody logs into to fix things.
//
// Anything expressible as enterprise policy lives in policies.json instead — policy is
// enforced and shows up in about:policies, whereas a pref is merely a default. The
// prefs here are the ones with no policy equivalent.
// The only reason userChrome.css is read at all. Without this the stylesheet in
// chrome/ is silently ignored and the window comes up with full default chrome.
user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
// First-run / post-update interstitials. An unattended screen has nobody to close them.
user_pref("browser.startup.homepage_override.mstone", "ignore");
user_pref("browser.aboutwelcome.enabled", false);
user_pref("browser.messaging-system.whatsNewPanel.enabled", false);
user_pref("trailhead.firstrun.didSeeAboutWelcome", true);
user_pref("datareporting.policy.firstRunURL", "");
user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true);
// Session restore prompts after a power cut would leave the room's display stuck on a
// "restore your session?" page instead of the digest.
user_pref("browser.sessionstore.resume_from_crash", false);
user_pref("browser.tabs.warnOnClose", false);
user_pref("browser.tabs.warnOnCloseOtherTabs", false);
user_pref("browser.warnOnQuit", false);
// No permission doorhangers on a display nobody is standing at. Autoplay is allowed
// because the digest canvas and embedded video are the point of the machine.
user_pref("permissions.default.desktop-notification", 2);
user_pref("permissions.default.geo", 2);
user_pref("media.autoplay.default", 0);
user_pref("media.autoplay.blocking_policy", 0);
// Wayland-native rather than Xwayland, matching MOZ_ENABLE_WAYLAND in kiosk-session.
// VA-API is left at its default: no thin-client hardware has been chosen yet
// (project-plan §4 #7), so forcing hardware decode here could just as easily produce a
// black video surface as a working one.
user_pref("gfx.webrender.all", true);
// The address bar in the minimal-chrome window is for typing a URL, not for a dropdown
// of suggestions covering the page.
user_pref("browser.urlbar.suggest.topsites", false);
user_pref("browser.urlbar.suggest.quicksuggest.sponsored", false);
user_pref("browser.newtabpage.activity-stream.showSponsored", false);
user_pref("browser.newtabpage.activity-stream.showSponsoredTopSites", false);

View File

@ -0,0 +1,63 @@
/* Minimal browser chrome for the Steam TV box's browsing window.
* Installed to <profile>/chrome/userChrome.css by /usr/local/bin/web-browser, and only
* read because user.js sets toolkit.legacyUserProfileCustomizations.stylesheets=true.
*
* What survives: back, forward, reload, and the address bar. Everything else goes.
* That set is deliberate those four are what someone driving this from the sofa with
* the remote's little keyboard actually needs, and every extra control is one more
* thing to mis-click on a television at two metres.
*
* Taken verbatim from hosts/thin-client/configs/firefox/userChrome.css; the two are
* meant to stay identical, so the browser behaves the same in every room.
*/
/* Tab strip. The kiosk browses one page at a time; a tab bar on a shared display just
* accumulates whatever the last person left open. */
#TabsToolbar,
#tabbrowser-tabs,
#alltabs-button {
visibility: collapse !important;
}
/* Menu bar and bookmarks bar. */
#toolbar-menubar,
#PersonalToolbar,
#PlacesToolbar {
visibility: collapse !important;
}
/* Everything on the nav bar except back / forward / reload / the address bar. */
#home-button,
#library-button,
#sidebar-button,
#fxa-toolbar-menu-button,
#unified-extensions-button,
#PanelUI-button,
#downloads-button,
#save-to-pocket-button,
#pageActionButton,
#star-button-box,
#reader-mode-button,
#customizableui-special-spring1,
#customizableui-special-spring2 {
display: none !important;
}
/* The urlbar keeps its identity box (padlock) dropping it would hide the only
* on-screen signal that a page is or is not https. */
#urlbar-container {
min-width: 0 !important;
}
#nav-bar {
border: none !important;
box-shadow: none !important;
}
/* Titlebar spacers left over once the tab strip is collapsed. */
.titlebar-buttonbox-container,
.titlebar-spacer {
display: none !important;
}
/* Findbar and notification popups stay: they are transient and user-initiated. */

View File

@ -0,0 +1,47 @@
#!/bin/sh
# The general web browser — minimal chrome, not kiosk mode. Installed to
# /usr/local/bin/web-browser.
#
# The requirement this satisfies is "a web browser with uBlock Origin", which is
# enforced by /etc/firefox/policies/policies.json (force_installed, so it cannot be
# removed by a stray click on the TV) rather than by anything here.
#
# Unlike the thin client there is only one profile on this image: no digest canvas
# means no second Firefox process to keep apart. The profile still lives under a named
# directory rather than the default, because media-session's pgrep guard matches on the
# --profile path to decide whether the browser is already up.
#
# SponsorBlock is in the policy set too. On a box whose browser exists mostly to play
# video on a television, skipping sponsor segments without a remote in hand is worth
# more here than anywhere else in the house.
set -eu
PROFILE_DIR="${HOME:-/home/$(id -un)}/.mozilla/firefox/web"
if command -v firefox-esr >/dev/null 2>&1; then
FIREFOX=firefox-esr
else
FIREFOX=firefox
fi
mkdir -p "$PROFILE_DIR/chrome"
# Re-copied on every launch rather than once at build time: this is a locked-down
# profile with no interactive customisation expected, so keeping it in lockstep with
# /etc/steamtv-firefox/ (edited by rebuilding the image) is simpler than a one-shot seed
# that could drift after a userChrome.css update.
cp /etc/steamtv-firefox/userChrome.css "$PROFILE_DIR/chrome/userChrome.css" 2>/dev/null || true
cp /etc/steamtv-firefox/user.js "$PROFILE_DIR/user.js" 2>/dev/null || true
# Focus an already-open window rather than stacking a second one: this window holds
# state (history, a half-typed URL, a logged-in video site) that a kill-and-relaunch
# would throw away — and media-session calls this every time somebody leaves Big
# Picture, so "already open" is the common case, not the exception.
if pgrep -u "$(id -u)" -f "$FIREFOX .*--profile $PROFILE_DIR" >/dev/null 2>&1; then
if [ -n "${1:-}" ]; then
exec "$FIREFOX" --profile "$PROFILE_DIR" --new-tab "$1"
fi
exit 0
fi
exec "$FIREFOX" --profile "$PROFILE_DIR" --new-instance --new-window "${1:-about:blank}"

View File

@ -0,0 +1,21 @@
# greetd — autologin straight into the kiosk Sway session, no greeter UI.
# Installed to /etc/greetd/config.toml by build-steam-tv-box-iso.sh, which substitutes
# @KIOSK_USERNAME@ with its own KIOSK_USERNAME variable on the way in.
#
# Schema note (getting these key names wrong fails silently — you get a black VT with
# no login, and greetd logs nothing obvious): the table names are exactly [terminal]
# and [default_session], and the session keys are exactly `command` and `user`.
#
# Why [default_session] and not [initial_session]: [initial_session] fires once, on the
# first greetd start after boot, and greetd falls back to [default_session] the moment
# that session ends. For a TV box that would mean one crashed sway drops the living
# room to an agreety login prompt on a screen with no keyboard in front of it. Pointing
# [default_session] itself at the kiosk user makes the autologin permanent and
# self-healing. [initial_session] is deliberately absent.
[terminal]
vt = 1
[default_session]
command = "/usr/local/bin/kiosk-session"
user = "@KIOSK_USERNAME@"

View File

@ -0,0 +1,43 @@
#!/bin/sh
# greetd's default_session command. Installed to /usr/local/bin/kiosk-session.
#
# Exists so that MQTT_BROKER_HOST / HA_URL / GPU_VENDOR are in Sway's environment:
# sway's config file has no way to read an env file itself, but every `exec` line it
# runs inherits this process's environment, so sourcing here is what lets the session
# scripts read configuration without the sway config being templated.
set -eu
# An `if` rather than `[ -r … ] && . …`: under `set -e` the && form exits the whole
# script when the file is absent, which would leave greetd with a session that dies
# instantly and a black screen.
if [ -r /etc/steamtv-agent/config.env ]; then
. /etc/steamtv-agent/config.env
fi
export MQTT_BROKER_HOST HA_URL GPU_VENDOR
export XDG_CURRENT_DESKTOP=sway
export XDG_SESSION_TYPE=wayland
export XDG_SESSION_DESKTOP=sway
export MOZ_ENABLE_WAYLAND=1
: "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}"
export XDG_RUNTIME_DIR
# Steam's client, Proton, Prism's Minecraft windows and most native titles are X11
# applications. Telling them the display is 96dpi-normal and letting Xwayland scale is
# the sane default for a TV at 1-2m viewing distance; a 4K set that looks
# postage-stamp-sized wants this raised, not the compositor's scale changed (which
# would blur Big Picture).
export GDK_BACKEND=wayland,x11
export QT_QPA_PLATFORM="wayland;xcb"
export SDL_VIDEODRIVER=wayland,x11
# NVIDIA's driver still mishandles hardware cursor planes under wlroots on a number of
# releases; the symptom is an invisible or corrupt pointer, which on a box you drive
# with a gamepad is easy to miss until someone picks up a mouse. Harmless when set on
# hardware that doesn't need it, so it is scoped to the vendor rather than always on.
if [ "${GPU_VENDOR:-}" = "nvidia" ]; then
export WLR_NO_HARDWARE_CURSORS=1
fi
exec sway

View File

@ -0,0 +1,24 @@
# Only relevant when `enable_installer` is true for this kiosk in CoreSystemConfig.json.
#
# Unlike the thin client, this one is worth turning on: a gaming box wants games on a
# real disk rather than in a live session's RAM overlay, so the normal path here is an
# actual install. See hosts/steam-tv-box/README.md.
#
# THIS FILE IS THE SOURCE, NOT THE ONE LIVE-BUILD READS.
# build-steam-tv-box-iso.sh substitutes @KEYBOARD_LAYOUT@ and writes the result to
# live-build/config/preseed.cfg on every run, so that the installer's default layout
# follows household.keyboard_layout in CoreSystemConfig.json instead of being a second
# place the layout can be wrong. The generated copy is regenerated every build — edit
# this one. (The thin client hardcodes its layout here because its installer is off by
# default; this image's is not.)
#
# live-build auto-includes config/preseed.cfg into the debian-installer's preseed when
# --debian-installer is not "none". VERIFY: this is live-build's documented mechanism
# for this, but has not been exercised — if enable_installer is turned on, confirm the
# installer's keyboard step actually shows the right layout pre-selected rather than
# falling back to its own default.
#
# No "keyboard-configuration/xkb-keymap seen true" line on purpose: this preseeds the
# *default* answer, it does not skip the question, so whoever runs the installer can
# still pick a different layout for this specific machine.
d-i keyboard-configuration/xkb-keymap select @KEYBOARD_LAYOUT@

View File

@ -0,0 +1,31 @@
# mpv defaults for the kiosk user. Installed to
# /home/<KIOSK_USERNAME>/.config/mpv/mpv.conf by build-steam-tv-box-iso.sh.
#
# input-ipc-server is what lets `media-player <file>` load into the mpv window that is
# already sitting on 4:media instead of opening a second one — two mpvs would mean two
# MPRIS players and a coin-flip as to which one the remote's play button reaches.
#
# VERIFY: `~/` expansion in an mpv.conf value is documented behaviour but has not been
# checked on this image. If the socket never appears, replace the path below with the
# absolute one for the kiosk user.
input-ipc-server=~/.mpv-socket
# Hardware decode. This box has a real GPU, which is the one place in this project
# where that is true, so 4K video should not be burning cores it could be spending on a
# game running alongside it. `auto-safe` picks a hardware decoder only from the
# combinations upstream considers reliable and silently falls back to software
# otherwise — the failure mode is a warm CPU, never a black window.
hwdec=auto-safe
# Sensible on a television: keep the aspect ratio, never letterbox into a window that
# is already fullscreen, and don't drop to a tiny window for a small file.
keep-open=yes
force-window=yes
# Audio goes to whatever WirePlumber's default sink is, which is what the agent's
# "Audio output" select actually changes. Naming a device here would silently override
# that select and make it look broken.
ao=pipewire
# Nothing here loads mpv-mpris: Debian's mpv-mpris package drops its .so into mpv's
# autoload directory, and naming it again would load the plugin twice.

View File

@ -0,0 +1,38 @@
#!/bin/sh
# The general mp3/mp4 player. Installed to /usr/local/bin/media-player.
#
# mpv, same as the thin client's — it plays everything without a codec pack, it exposes
# MPRIS through mpv-mpris (which is what puts the transport controls in Home Assistant
# and on the remote's media keys), and it takes a file, a directory or a URL equally.
#
# --idle=yes --force-window is what makes it part of a *session* rather than a one-shot
# command: with no file given it opens an empty player window and waits, so it is
# already on 4:media with a working MPRIS bus before anyone has picked something to
# play. Dropping a file on it, opening one from a share, or `media-player <path>` from
# the maintenance shell all then load into the window that is already there.
#
# The "media-player-idle" title is not decoration — media-session and the sway config
# both match on it (mpv's app_id is just "mpv", which a second, file-playing mpv would
# share), so changing it means changing those two too.
set -eu
# A second invocation with a file loads it into the running instance rather than
# opening a competing window: two mpvs means two MPRIS players and a coin-flip as to
# which one the remote's play button reaches. mpv.conf sets input-ipc-server for this.
SOCKET="${HOME:-/home/$(id -un)}/.mpv-socket"
if [ -n "${1:-}" ] && [ -S "$SOCKET" ]; then
# loadfile via the JSON IPC. The path is passed as a JSON string argument, never
# interpolated into a shell command.
if printf '{"command":["loadfile","%s","replace"]}\n' "$1" | socat - "$SOCKET" 2>/dev/null; then
exit 0
fi
# If that failed the socket is stale (mpv died without cleaning up) — fall through to
# a plain launch, which is the right outcome rather than an error.
fi
if [ -n "${1:-}" ]; then
exec mpv --title=media-player-idle "$@"
fi
exec mpv --idle=yes --force-window=yes --title=media-player-idle

View File

@ -0,0 +1,133 @@
#!/bin/sh
# Brings up (or takes down) the media half of this box. Installed to
# /usr/local/bin/media-session.
#
# WHAT THIS IS FOR
# ----------------
# The requirement was: boot into Big Picture, and on leaving it have the same media
# functions as the other clients that drive a monitor — Spotify, a general web browser
# with uBlock Origin, a general mp3/mp4 player, and working audio — with those apps
# only launched when Big Picture is actually left. This script is the "launch them"
# half; steam-session and session-watcher are the two things that call it.
#
# WHY LAZY AT ALL
# ---------------
# Not tidiness. A machine that is playing a game is the one machine here where a
# background Firefox and a background Electron/Spotify client cost something visible:
# they hold GPU memory, they wake the CPU on timers, and Spotify in particular keeps an
# audio stream open that shows up as stutter in a title that is already frame-limited.
# Starting them the first time somebody leaves the game means a gaming session pays
# nothing for media features it isn't using.
#
# IDEMPOTENCE
# -----------
# Every launch below is guarded by its own pgrep, and the whole start path holds an
# flock, so:
# - the two triggers firing at once cannot produce two Spotifys;
# - leaving and re-entering Big Picture repeatedly costs one pgrep per app;
# - an app the user closed by hand comes back the next time they leave the game,
# which is what "the media session is up" should mean.
# There is deliberately no "already started" flag file: a flag would go stale the
# moment somebody quit one of the apps, and the per-app check is the honest question.
#
# WHAT LEAVING BIG PICTURE DOES *NOT* DO
# --------------------------------------
# Going back into Steam does not stop any of this. Killing a running Spotify because
# someone launched a game would lose whatever was playing — and people do play music
# over a game on purpose. `media-session stop` exists for when you actually want the
# machine quiet (it is wired to an HA button); nothing calls it automatically.
set -eu
WS_WEB="3:web"
WS_MEDIA="4:media"
WS_MUSIC="5:music"
LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp}"
LOCK_FILE="${LOCK_DIR}/steamtv-media-session.lock"
log() { echo "media-session: $*"; }
# Launch `command` on `workspace` unless something matching `pattern` is already this
# user's. The workspace switch happens before the launch for the same reason
# steamtv_agent.sway_control.launch_app does it that way: sway places a new window on
# whatever workspace is focused when it maps, and there is no reliable per-app `assign`
# rule for two Firefox profiles or for a flatpak's app_id.
launch_unless_running() {
pattern="$1"
workspace="$2"
shift 2
if pgrep -u "$(id -u)" -f "$pattern" >/dev/null 2>&1; then
log "$1 already running"
return 0
fi
log "launching $1 on $workspace"
swaymsg workspace "$workspace" >/dev/null 2>&1 || true
setsid "$@" >/dev/null 2>&1 &
# Give sway a moment to map the window on the workspace we just switched to, before
# the next launch switches away again. Crude, and correct: the alternative is
# subscribing to window events for each app, which is a lot of machinery for a path
# that runs once per session.
sleep 1
}
start() {
focus_after="${1:-}"
launch_unless_running "com.spotify.Client" "$WS_MUSIC" /usr/local/bin/spotify-launch
launch_unless_running "media-player-idle" "$WS_MEDIA" /usr/local/bin/media-player
# web-browser has its own focus-an-existing-window logic (it holds state a relaunch
# would throw away), so the pattern here matches its profile directory rather than
# the bare binary — a second Firefox on a different profile is a different window.
launch_unless_running "firefox.*--profile.*/firefox/web" "$WS_WEB" /usr/local/bin/web-browser
if [ "$focus_after" = "--focus" ]; then
swaymsg workspace "$WS_WEB" >/dev/null 2>&1 || true
fi
log "media session up"
}
stop() {
# pkill by the same patterns the launches are guarded by, so start/stop cannot
# disagree about what counts as "running". SIGTERM only — Spotify and Firefox both
# persist state on exit, and a SIGKILL here is how you get a "Firefox didn't shut
# down properly" dialog on the TV next time.
for pattern in "com.spotify.Client" "media-player-idle" "firefox.*--profile.*/firefox/web"; do
pkill -u "$(id -u)" -f "$pattern" >/dev/null 2>&1 || true
done
log "media session stopped"
}
case "${1:-start}" in
start)
shift 2>/dev/null || true
# flock serialises the two triggers. -n: if another start is already in flight,
# this one has nothing to add — the in-flight one will launch the same apps.
if command -v flock >/dev/null 2>&1; then
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "another start is already running; nothing to do"
exit 0
fi
fi
start "${1:-}"
;;
stop)
stop
;;
status)
for pattern in "com.spotify.Client" "media-player-idle" "firefox.*--profile.*/firefox/web"; do
if pgrep -u "$(id -u)" -f "$pattern" >/dev/null 2>&1; then
echo "running: $pattern"
else
echo "stopped: $pattern"
fi
done
;;
*)
echo "usage: media-session [start [--focus] | stop | status]" >&2
exit 2
;;
esac

View File

@ -0,0 +1,99 @@
#!/bin/sh
# Prism Launcher (Minecraft). Installed to /usr/local/bin/prism-launch.
#
# Prism rather than the official launcher: it runs offline once an account has been
# authenticated, it manages several instances/modpacks side by side, and it is packaged
# on Flathub, so it comes from the same channel as Spotify here and Steam Link on the
# thin client. 0400-flatpak-apps.hook.chroot installs it.
#
# THIS RUNS THROUGH STEAM, NOT BESIDE IT
# --------------------------------------
# Called with no arguments, this does not start Prism directly — it asks Steam to start
# it, via the non-Steam-game shortcut that /usr/local/bin/steam-shortcut-prism
# registers. That indirection is the entire point: a Prism launched by Steam runs inside
# the Steam Runtime with Steam Input active, so the Steam Controller API is present for
# it. Gamepads arrive as configurable Steam controllers with per-game bindings, the
# overlay works, gyro and back-buttons on Deck-style pads work, and Minecraft appears in
# Big Picture's library like any other title instead of being a hole you fall out of the
# UI into. Launched directly, Minecraft sees a raw evdev pad and none of that exists.
#
# `--steam` is what Steam passes back in when it runs the shortcut (it is the shortcut's
# LaunchOptions), and it is how this script knows not to bounce the request back to
# Steam a second time. Without that flag the two would call each other forever.
#
# FALLBACKS, IN ORDER: if Steam is not installed, or nobody has logged into it yet (so
# there is no shortcut and no game id), this runs Prism directly. That is a working
# Minecraft with worse controller support, which is the right outcome — better than
# refusing to start on a box where somebody has not signed into Steam.
#
# Java: the image ships openjdk-21-jre and openjdk-8-jre, and the hook grants the
# sandbox read access to /usr/lib/jvm, so Prism's auto-detection finds both without
# needing to download a runtime on first launch. 21 covers current Minecraft, 8 covers
# anything before 1.17 — which is most of what a modpack older than a couple of years
# actually wants.
#
# Login is interactive and on-device the first time (a Microsoft account is required —
# Mojang's device-code flow, so it is a short code typed on a phone rather than a
# password typed with a gamepad). The Flatpak's own persistent data directory
# (~/.var/app/org.prismlauncher.PrismLauncher) keeps that across restarts, and nothing
# here reseeds it — unlike the kiosk Firefox profiles, staying logged in is the point.
#
# Instances live under ~/Games if that is where you put them; see the README on
# mounting a real disk there, since this is the other thing on this box (with the Steam
# library) that will not fit in a live session's RAM overlay.
set -eu
GAME_ID_FILE="${HOME:-/home/$(id -un)}/.local/state/steamtv/prism-gameid"
DIRECT=""
# Strip the marker so it is never passed on to Prism itself, and keep any real
# arguments (an instance name, a .mrpack to import) for the direct launch below.
if [ "${1:-}" = "--steam" ]; then
DIRECT="yes"
shift
fi
run_direct() {
if ! flatpak info org.prismlauncher.PrismLauncher >/dev/null 2>&1; then
echo "prism-launch: Prism Launcher is not installed. Install it with:" >&2
echo " flatpak install -y flathub org.prismlauncher.PrismLauncher" >&2
exit 1
fi
exec flatpak run org.prismlauncher.PrismLauncher "$@"
}
# Steam is already the parent — run the real thing.
if [ -n "$DIRECT" ]; then
run_direct "$@"
fi
# Arguments mean somebody is opening a specific instance or file from a shell; hand
# that straight to Prism rather than through a Steam URL that cannot carry it.
if [ -n "${1:-}" ]; then
run_direct "$@"
fi
if ! command -v steam >/dev/null 2>&1; then
echo "prism-launch: Steam is not installed; launching Prism directly (no Steam Input)."
run_direct
fi
if [ ! -r "$GAME_ID_FILE" ]; then
# steam-shortcut-prism writes this on every session, so its absence means that script
# has never run — not that Steam is unusable.
echo "prism-launch: no Steam shortcut registered yet; launching Prism directly."
echo " Run /usr/local/bin/steam-shortcut-prism (with Steam closed) to fix that."
run_direct
fi
GAME_ID="$(head -n 1 "$GAME_ID_FILE" | tr -dc '0-9')"
if [ -z "$GAME_ID" ]; then
echo "prism-launch: $GAME_ID_FILE is malformed; launching Prism directly." >&2
run_direct
fi
# `steam -applaunch` does not work for non-Steam shortcuts — the rungameid URL is the
# only handle Steam exposes for them. A running client picks this up immediately; a
# stopped one starts first and then launches it.
exec steam "steam://rungameid/${GAME_ID}"

View File

@ -0,0 +1,60 @@
#!/bin/sh
# Watches sway for the user leaving Big Picture. Installed to /usr/local/bin/session-watcher.
#
# THIS IS THE "MEDIA APPS ONLY START WHEN YOU LEAVE BIG PICTURE" MECHANISM, HALF TWO.
# Read steam-session's header first — it explains why there are two halves.
#
# The case this one covers: Steam is *still running*, but the user is no longer looking
# at it. "Exit Big Picture mode" (which drops to Steam's desktop client), the remote's
# channel keys, $mod+3, or Home Assistant's Screen select — all of those change the
# focused sway workspace and none of them make the Steam process exit, so steam-session
# never resumes and something else has to notice.
#
# WHY WORKSPACE EVENTS AND NOT WINDOW TITLES
# ------------------------------------------
# The obvious implementation is to poll for a window titled "Steam Big Picture Mode"
# and react when it goes away. That was rejected: the title, the window class and the
# very existence of a separate Big Picture window have all changed across Steam client
# rewrites, and a media session that silently stops appearing after a Steam update is a
# bad failure — it looks like the image is broken, not like a string moved. A sway
# workspace name is a contract this repo owns and can only change by editing the sway
# config and steamtv_agent/sway_control.py together.
#
# Cost: one blocked read on a socket, and a jq per workspace switch. It publishes
# nothing and listens on nothing — the session-mode sensor in Home Assistant is
# steamtv-agent asking sway, not this script pushing anywhere. (Adding an inbound
# listener here would breach the MQTT-is-the-only-control-surface rule; see
# steamtv_agent/mqtt_discovery.py.)
set -eu
WS_STEAM="1:steam"
if ! command -v jq >/dev/null 2>&1; then
echo "session-watcher: jq is missing; the media session will only start when Steam" >&2
echo " itself exits (steam-session's path). Install jq and reload sway." >&2
exit 1
fi
# `swaymsg -m` streams one JSON object per event line and blocks forever. If sway goes
# away the pipe closes and this exits — which is correct, since sway restarting means
# `exec_always` starts a fresh watcher.
swaymsg -t subscribe -m '["workspace"]' 2>/dev/null | while read -r event; do
# "focus" is the only change that means the user went somewhere; "init", "empty" and
# "rename" fire during ordinary window churn and must not trigger a launch.
change=$(printf '%s' "$event" | jq -r '.change // empty' 2>/dev/null) || continue
[ "$change" = "focus" ] || continue
current=$(printf '%s' "$event" | jq -r '.current.name // empty' 2>/dev/null) || continue
[ -n "$current" ] || continue
# An `if` rather than `[ … ] && continue`: the && form returns non-zero when the test
# is false, which under `set -e` would kill the watcher the first time somebody moved
# to a non-Steam workspace — i.e. exactly once, silently, on the first use.
if [ "$current" = "$WS_STEAM" ]; then
continue
fi
# No --focus: the user has already chosen where they want to be, and yanking them to
# the browser workspace because they pressed the channel key would be worse than
# doing nothing. This only makes sure the apps exist.
/usr/local/bin/media-session start >/dev/null 2>&1 || true
done

View File

@ -0,0 +1,26 @@
#!/bin/sh
# Full Spotify GUI client. Installed to /usr/local/bin/spotify-launch.
#
# The real, official Spotify Linux client — its own library browser, search and
# playlists — not spotifyd/librespot. Those are the thin client's and audio-endpoint's
# headless Spotify Connect *receivers*, which have no UI at all; this machine is sat in
# front of with a remote in hand, so it wants the browsable app, same as the touch
# panel. Spotify ships no apt package, so it comes from Flathub
# (0400-flatpak-apps.hook.chroot installs it).
#
# Login is interactive, on-device, on first launch (a Spotify account is needed;
# Premium for playback). The Flatpak's persistent data directory
# (~/.var/app/com.spotify.Client) keeps that session across restarts.
#
# Started lazily — see media-session. Spotify is one of the two apps that measurably
# costs a running game something if it sits in the background, which is most of why the
# media half of this image does not start at boot.
set -eu
if ! flatpak info com.spotify.Client >/dev/null 2>&1; then
echo "spotify-launch: Spotify is not installed. Install it with:" >&2
echo " flatpak install -y flathub com.spotify.Client" >&2
exit 1
fi
exec flatpak run com.spotify.Client "$@"

View File

@ -0,0 +1,57 @@
#!/bin/sh
# Launches Steam in Big Picture (gamepad UI). Installed to /usr/local/bin/steam-big-picture.
#
# This is the image's default application: the sway config execs steam-session, which
# execs this, and this is what the TV shows within a few seconds of power-on. Nothing
# else is running at that point — see media-session for why.
#
# GAMESCOPE
# ---------
# When gamescope is present (0300-steam.hook.chroot installs it if the release has it)
# Steam runs nested inside it. That is what Valve ships on the Deck and it buys three
# things that matter on a television: a fixed output resolution and refresh rate that a
# game cannot change out from under the compositor, integer/FSR scaling so a 1080p game
# on a 4K set is sharp rather than smeared, and a framerate limiter. Without gamescope
# Steam runs directly on Xwayland — fully functional, just without those.
#
# IDEMPOTENT: relaunching while Steam is already up focuses the existing client instead
# of starting a second one (Steam would refuse anyway, but noisily, and the second
# process would sit in the session doing nothing). This is what makes the HA "Launch
# Steam" button and the remote's own key safe to press repeatedly.
set -eu
WS_STEAM="1:steam"
if ! command -v steam >/dev/null 2>&1; then
echo "steam-big-picture: Steam is not installed on this image — see" >&2
echo " live-build/config/hooks/normal/0300-steam.hook.chroot, which logs why." >&2
exit 1
fi
# Already running: switch to it and focus rather than starting another client.
if pgrep -u "$(id -u)" -x steam >/dev/null 2>&1; then
swaymsg workspace "$WS_STEAM" >/dev/null 2>&1 || true
swaymsg '[class="^[Ss]team$"] focus' >/dev/null 2>&1 || true
# Steam's own URL handler is the documented way to ask a *running* client to go back
# into Big Picture; there is no command-line flag that does it to an existing process.
exec steam steam://open/bigpicture
fi
# -gamepadui is the current Big Picture. -tenfoot is the old one and is gone; if this
# ever stops opening the gamepad UI, that flag name is the first thing to check against
# the installed client (`steam -help`).
STEAM_ARGS="-gamepadui -nochatui -nofriendsui"
if command -v gamescope >/dev/null 2>&1 && [ "${STEAMTV_USE_GAMESCOPE:-auto}" != "false" ]; then
# -f fullscreen, -e Steam integration (lets Steam drive resolution per game),
# --adaptive-sync hands VRR through to a set that supports it.
# Output geometry is deliberately NOT pinned here: gamescope defaults to the
# connected display's native mode, and hardcoding 1920x1080 would be wrong on the 4K
# set this is most likely plugged into. Pin it in STEAMTV_GAMESCOPE_ARGS if a
# specific title needs it.
# shellcheck disable=SC2086
exec gamescope -f -e --adaptive-sync ${STEAMTV_GAMESCOPE_ARGS:-} -- steam $STEAM_ARGS
fi
# shellcheck disable=SC2086
exec steam $STEAM_ARGS

View File

@ -0,0 +1,47 @@
#!/bin/sh
# The session's first and normally only process. Installed to /usr/local/bin/steam-session.
#
# THIS IS THE "MEDIA APPS ONLY START WHEN YOU LEAVE BIG PICTURE" MECHANISM, HALF ONE.
#
# The box boots to Steam and nothing else: no browser, no Spotify, no player. Those are
# started the first time somebody actually leaves Big Picture. Two independent triggers
# do that, because there are two different ways to leave and neither one can see the
# other:
#
# 1. THIS SCRIPT — "Exit Steam" / the client crashing. steam-big-picture runs in the
# foreground, so when the Steam client goes away this script resumes on the line
# after it and brings the media session up, focused.
# 2. /usr/local/bin/session-watcher — "Exit Big Picture mode", alt-tabbing, the
# remote's channel keys, or the HA workspace select. Steam is still running, so
# trigger 1 never fires; what changes is the focused sway workspace, which the
# watcher subscribes to.
#
# Both call `media-session start`, which is idempotent, so whichever fires first wins
# and the second is a no-op. That is the whole design — no window-title matching, no
# polling for Big Picture's internal state, nothing that breaks when Valve reshuffles
# the UI. See media-session for the guards that make double-firing free.
#
# WHY THIS IS NOT A LOOP: if Steam exits, it stays exited. Relaunching it automatically
# would make "Exit Steam" impossible to act on from the sofa, and the box would never
# be usable as anything but a games console. Getting back in is a button (HA, the
# remote's Home key, or $mod+s) — see the sway config.
set -eu
# Register Prism Launcher as a non-Steam game BEFORE Steam starts. Ordering is not
# incidental: Steam reads shortcuts.vdf at startup and rewrites it from memory when it
# exits, so anything written while it is running is silently discarded. This is also
# why the registration lives here rather than in an `exec_always` in the sway config,
# where it would race the client. Idempotent, and a no-op until somebody has logged
# into Steam on this machine — see the script's own header for why it cannot be baked
# into the image.
/usr/local/bin/steam-shortcut-prism || \
echo "steam-session: could not register the Prism shortcut; Prism will still launch," \
"just without Steam Input"
/usr/local/bin/steam-big-picture || \
echo "steam-session: Steam exited non-zero; falling through to the media session anyway"
# --focus because this path means the screen is now showing nothing at all: Steam has
# quit and the user is looking at an empty compositor. The watcher's path deliberately
# does not focus, since there the user already chose where to be.
exec /usr/local/bin/media-session start --focus

View File

@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Registers Prism Launcher as a Steam "non-Steam game". Installed to
/usr/local/bin/steam-shortcut-prism, run by steam-session before Steam starts.
WHY
---
Launching Prism directly gets you a plain Flatpak window and a Minecraft that sees a
raw evdev gamepad, which is to say: no controller support worth the name. Launching it
*through* Steam puts it inside the Steam Runtime with Steam Input active, which is what
makes the Steam Controller API present for it. Concretely that means the overlay works,
the pad shows up as a configurable controller with Steam's own per-game bindings, the
box's Steam Controller / Deck-style pads get their gyro and back buttons, and Big
Picture treats Minecraft like any other title in the library instead of a hole you fall
out of the UI into.
There is no command-line flag for this. Steam's only mechanism for a non-Steam game is
an entry in the user's binary `shortcuts.vdf`, which is what this writes.
WHY IT CANNOT BE BAKED INTO THE IMAGE
-------------------------------------
shortcuts.vdf lives under ~/.steam/steam/userdata/<steamID3>/config/, and that
directory does not exist until somebody has logged into Steam on this machine. The
account is not known at build time and must not be — no Steam credentials go into an
ISO. So this runs per-session instead, is idempotent, and no-ops quietly when Steam has
never been logged into (the common state on a freshly flashed box, right up until
somebody signs in on the TV).
WHY IT RUNS BEFORE STEAM STARTS
-------------------------------
Steam reads shortcuts.vdf at startup and rewrites it from memory at shutdown. Writing
it underneath a running client means the change is silently reverted the next time
Steam exits. steam-session calls this first for that reason; running it by hand while
Steam is up is refused below rather than being allowed to quietly do nothing.
FORMAT NOTE / VERIFY BEFORE TRUSTING IT
---------------------------------------
The binary VDF encoding below (0x00 map, 0x01 string, 0x02 int32, 0x08 end) and the
non-Steam AppID derivation (CRC32 of Exe+AppName, high bit set) are the long-standing,
widely-reimplemented community format — Valve documents neither. They are believed
correct but were NOT verified against a real Steam client from this environment. The
check on first boot is simply: does "Prism Launcher" appear in Big Picture's library,
and does the pad work inside Minecraft. If the file turns out to be malformed Steam
discards it silently, which is why this keeps a .bak (see below) rather than writing in
place.
"""
from __future__ import annotations
import binascii
import glob
import os
import shutil
import subprocess
import sys
import time
APP_NAME = "Prism Launcher"
# The wrapper, not `flatpak run …` directly: prism-launch is where the "is it even
# installed" check and the Java/instance notes live, and pointing Steam at a stable
# path means this entry does not change when the Flatpak app ID does.
EXE = "/usr/local/bin/prism-launch"
START_DIR = "/usr/local/bin"
# --steam tells prism-launch it is already inside Steam's runtime, so it does not
# recurse back through `steam steam://rungameid/...` and launch itself forever.
LAUNCH_OPTIONS = "--steam"
USERDATA_GLOB = os.path.expanduser("~/.steam/steam/userdata/*/config")
# Flatpak'd and Snap'd Steam put userdata elsewhere; this image installs Steam from
# apt, so the path above is the real one. The alternates are checked anyway because
# somebody debugging on a laptop will have one of them.
ALT_GLOBS = (
os.path.expanduser("~/.local/share/Steam/userdata/*/config"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/data/Steam/userdata/*/config"),
)
def log(message: str) -> None:
print(f"steam-shortcut-prism: {message}")
# --- binary VDF -----------------------------------------------------------------
def _string(key: str, value: str) -> bytes:
return b"\x01" + key.encode("utf-8") + b"\x00" + value.encode("utf-8") + b"\x00"
def _int32(key: str, value: int) -> bytes:
return b"\x02" + key.encode("utf-8") + b"\x00" + value.to_bytes(4, "little", signed=False)
def shortcut_app_id(exe: str, app_name: str) -> int:
"""The 32-bit ID Steam gives a non-Steam shortcut.
CRC32 of the Exe field concatenated with AppName, with the top bit set. Steam
quotes the Exe field in the file it writes, and the CRC is taken over the quoted
form — getting that wrong produces an ID that no `steam://rungameid/` URL matches,
which looks exactly like "the shortcut didn't work" with nothing in any log.
"""
key = f'"{exe}"{app_name}'.encode("utf-8")
return binascii.crc32(key) | 0x80000000
def run_game_id(app_id: int) -> int:
"""The 64-bit ID `steam://rungameid/` wants for a shortcut."""
return (app_id << 32) | 0x02000000
def encode_shortcuts(entries: list[dict]) -> bytes:
out = bytearray(b"\x00shortcuts\x00")
for index, entry in enumerate(entries):
out += b"\x00" + str(index).encode("ascii") + b"\x00"
out += _int32("appid", entry["appid"])
out += _string("AppName", entry["AppName"])
out += _string("Exe", entry["Exe"])
out += _string("StartDir", entry["StartDir"])
out += _string("icon", entry.get("icon", ""))
out += _string("ShortcutPath", entry.get("ShortcutPath", ""))
out += _string("LaunchOptions", entry.get("LaunchOptions", ""))
out += _int32("IsHidden", 0)
# AllowDesktopConfig + AllowOverlay are the two that matter for the whole point
# of this file: the overlay is what carries Steam Input's binding UI, and
# desktop-config is what lets a pad still work when Big Picture is not focused.
out += _int32("AllowDesktopConfig", 1)
out += _int32("AllowOverlay", 1)
out += _int32("OpenVR", 0)
out += _int32("Devkit", 0)
out += _string("DevkitGameID", "")
out += _int32("DevkitOverrideAppID", 0)
out += _int32("LastPlayTime", entry.get("LastPlayTime", 0))
out += b"\x00tags\x00\x08"
out += b"\x08"
out += b"\x08\x08"
return bytes(out)
def decode_app_names(data: bytes) -> list[str]:
"""Just enough parsing to answer "is our entry already in here?".
A full VDF reader is not needed and would be more to get wrong: this only has to
decide between rewriting the file and leaving it alone.
"""
names = []
marker = b"\x01AppName\x00"
position = data.find(marker)
while position != -1:
start = position + len(marker)
end = data.find(b"\x00", start)
if end == -1:
break
names.append(data[start:end].decode("utf-8", "replace"))
position = data.find(marker, end)
return names
# --- the work -------------------------------------------------------------------
def config_dirs() -> list[str]:
found = sorted(glob.glob(USERDATA_GLOB))
for pattern in ALT_GLOBS:
found += sorted(glob.glob(pattern))
# userdata/0/ is Steam's placeholder for "no account", not a real profile.
return [d for d in found if os.path.basename(os.path.dirname(d)) != "0"]
def steam_is_running() -> bool:
try:
return subprocess.run(
["pgrep", "-u", str(os.getuid()), "-x", "steam"],
capture_output=True,
check=False,
timeout=5,
).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def write_shortcut(config_dir: str, app_id: int) -> bool:
path = os.path.join(config_dir, "shortcuts.vdf")
existing = b""
if os.path.exists(path):
try:
with open(path, "rb") as handle:
existing = handle.read()
except OSError as exc:
log(f"could not read {path}: {exc}")
return False
if APP_NAME in decode_app_names(existing):
log(f"{APP_NAME} is already in {path}")
return True
if existing:
# Anything already in shortcuts.vdf was put there by hand, and this parser is
# not good enough to rewrite the file without losing it. So: back it up, tell
# the human exactly what to do, and refuse rather than destroy their entries.
backup = f"{path}.bak-{int(time.time())}"
try:
shutil.copyfile(path, backup)
except OSError as exc:
log(f"could not back up {path}: {exc}")
return False
log(f"{path} already has other shortcuts in it; backed it up to {backup}.")
log("Refusing to rewrite it — this script only knows how to write a file it")
log("owns entirely, and rewriting would drop the entries already there.")
log(f"Add {APP_NAME} by hand in Steam (Games -> Add a Non-Steam Game -> {EXE}),")
log(f"then set its launch options to: {LAUNCH_OPTIONS}")
return False
entry = {
"appid": app_id,
"AppName": APP_NAME,
"Exe": f'"{EXE}"',
"StartDir": f'"{START_DIR}"',
"LaunchOptions": LAUNCH_OPTIONS,
}
try:
os.makedirs(config_dir, exist_ok=True)
temporary = f"{path}.tmp"
with open(temporary, "wb") as handle:
handle.write(encode_shortcuts([entry]))
os.replace(temporary, path)
except OSError as exc:
log(f"could not write {path}: {exc}")
return False
log(f"registered {APP_NAME} in {path} (appid {app_id})")
return True
def main() -> int:
app_id = shortcut_app_id(EXE, APP_NAME)
game_id = run_game_id(app_id)
# Written unconditionally, even when there is no Steam profile yet: prism-launch
# reads this to build its steam://rungameid/ URL, and it is derived from two
# constants in this file, so it is correct whether or not the shortcut exists yet.
state_dir = os.path.expanduser("~/.local/state/steamtv")
try:
os.makedirs(state_dir, exist_ok=True)
with open(os.path.join(state_dir, "prism-gameid"), "w", encoding="utf-8") as handle:
handle.write(f"{game_id}\n")
except OSError as exc:
log(f"could not record the game id: {exc}")
if steam_is_running():
log("Steam is running — it would overwrite shortcuts.vdf on exit and discard")
log("anything written now. Quit Steam and re-run, or just let the next session")
log("do it (steam-session runs this before Steam starts).")
return 1
dirs = config_dirs()
if not dirs:
log("no Steam userdata directory yet — nobody has logged into Steam on this")
log("machine. Nothing to do; this will register itself on the session after")
log("the first Steam login.")
return 0
ok = True
for config_dir in dirs:
# Every logged-in account on the box gets the entry: which one is signed in at
# any moment is not knowable here, and a stale entry for an account that never
# plays Minecraft costs nothing.
ok = write_shortcut(config_dir, app_id) and ok
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,203 @@
# Sway session for the Steam TV box.
# Installed to /home/<KIOSK_USERNAME>/.config/sway/config by build-steam-tv-box-iso.sh.
#
# Every `exec` below inherits the environment set by /usr/local/bin/kiosk-session,
# which sources /etc/steamtv-agent/config.env — that is how GPU_VENDOR and the MQTT
# settings get here without this file being templated.
#
# THE SHAPE OF THIS SESSION, IN ONE PARAGRAPH: the box powers on, greetd autologins,
# sway starts, and the only thing it launches is Steam in Big Picture. The browser,
# Spotify and the player do not exist yet. The first time somebody leaves Big Picture —
# by quitting Steam, by exiting Big Picture mode, by pressing a workspace key, or via
# Home Assistant — the media session is brought up behind them. See steam-session and
# session-watcher, which are the two halves of that.
set $mod Mod4
set $ws_steam 1:steam
set $ws_games 2:games
set $ws_web 3:web
set $ws_media 4:media
set $ws_music 5:music
# Workspace names are a contract with steamtv_agent/sway_control.py,
# steamtv_agent/session_mode.py, /usr/local/bin/media-session and
# /usr/local/bin/session-watcher — changing one side means changing all of them.
# ---------------------------------------------------------------------------
# Output / input
# ---------------------------------------------------------------------------
output * bg #0b0b10 solid_color
input type:keyboard {
xkb_layout @KEYBOARD_LAYOUT@
}
# A gamepad is not an input device to the compositor — it goes straight to Steam and to
# games via evdev, so there is nothing to configure here for it. What does need
# configuring is a mouse/trackball on the coffee table, if there is one.
input type:pointer {
accel_profile adaptive
}
# ---------------------------------------------------------------------------
# Look — no bars, no borders, no gaps. This is a television.
# ---------------------------------------------------------------------------
default_border none
default_floating_border none
hide_edge_borders both
gaps inner 0
gaps outer 0
# Games and Big Picture must never be tiled next to something else; every workspace
# here holds exactly one thing that fills the screen.
for_window [class="^[Ss]team$"] fullscreen enable
for_window [class="^gamescope$"] fullscreen enable
for_window [app_id="^gamescope$"] fullscreen enable
for_window [app_id="mpv"] fullscreen enable
# ---------------------------------------------------------------------------
# Remote control (administration, not play)
# ---------------------------------------------------------------------------
# exec_always so a `swaymsg reload` re-establishes it. start-wayvnc refuses to run
# until /etc/wayvnc/wayvnc-password has been set on this machine.
#
# Worth being explicit: this is for fixing the box, not for playing over. wayvnc streams
# the compositor, so a game rendering at 120fps arrives as a slideshow — that is not a
# defect to work around, it is what a screen-scraping protocol does.
exec_always /usr/local/bin/start-wayvnc
# The workspace-focus half of the leave-Big-Picture detection. Its own exit-on-
# sway-death behaviour makes exec_always safe across a reload: the old watcher's pipe
# closes with the old sway, and reload starts one watcher, not a second.
exec_always /usr/local/bin/session-watcher
# steamtv-agent is NOT started here. systemd owns it (steamtv-agent.service, enabled by
# 0700-steamtv-agent.hook.chroot) so that it is up and connected to Mosquitto whether or
# not a graphical session ever came up, and so it survives a sway restart. Starting it
# from sway too would give two competing MQTT clients.
# ---------------------------------------------------------------------------
# The default application
# ---------------------------------------------------------------------------
# This is the whole boot behaviour: land on 1:steam and start Big Picture. steam-session
# blocks on the Steam client and starts the media session when it exits.
#
# No `assign [class="steam"]` rule: Steam maps several windows (splash, client, an
# optional gamescope surface) and an assign rule would scatter them across workspaces
# mid-launch. Switching workspace first and letting them map where focus already is
# gets the same result without the race.
exec swaymsg workspace $ws_steam
exec /usr/local/bin/steam-session
# ---------------------------------------------------------------------------
# Idle
# ---------------------------------------------------------------------------
# Never lock: this is a shared living-room machine, and a lock screen would make the TV
# unusable to anyone not holding a keyboard.
#
# 20 minutes, and only the display — noticeably longer than the thin client's 15,
# because "nobody has touched an input device" is a much weaker signal here. Watching a
# two-hour film, or a cutscene, or a turn in a slow strategy game are all legitimately
# input-free, and the inhibit rules below are what keep the screen alive through them.
exec swayidle -w \
timeout 1200 'swaymsg "output * power off"' \
resume 'swaymsg "output * power on"'
# Anything playing keeps the screen on. Steam gets `focus` rather than `fullscreen`
# because Big Picture is fullscreen essentially always, and a game that alt-tabs itself
# briefly should not start the idle countdown.
for_window [class="^[Ss]team$"] inhibit_idle focus
for_window [class="^gamescope$"] inhibit_idle focus
for_window [app_id="^gamescope$"] inhibit_idle focus
for_window [app_id="mpv"] inhibit_idle visible
for_window [app_id="firefox-esr"] inhibit_idle fullscreen
for_window [app_id="com.spotify.Client"] inhibit_idle focus
# ---------------------------------------------------------------------------
# Local keys. A gamepad drives Steam and games; these are for the keyboard on the back
# of the remote, and for standing in front of the machine.
# ---------------------------------------------------------------------------
# Maintenance shell. A deliberately obscure chord (not $mod+Return, which is the
# ordinary local-terminal key below) so it is not something a visitor bumps into,
# floating so it overlays whatever is running instead of tiling against it.
bindsym $mod+Shift+Ctrl+m exec foot --title maintenance-shell
for_window [title="maintenance-shell"] floating enable, resize set width 900 height 550, move position center
bindsym $mod+Return exec foot
bindsym $mod+q kill
bindsym $mod+f fullscreen toggle
bindsym $mod+Shift+c reload
# Getting back into Steam after "Exit Steam" — the counterpart to steam-session's
# deliberate refusal to relaunch it in a loop. Idempotent, so holding the key does
# nothing worse than focusing the client.
bindsym $mod+s exec /usr/local/bin/steam-big-picture
# And bringing the media half up by hand, for the case where somebody wants Spotify
# without leaving the game.
bindsym $mod+m exec /usr/local/bin/media-session start
# Prism/Minecraft. This goes through Steam (steam://rungameid/…) so Steam Input is
# active for it — see prism-launch. Which is also why its window lands on 1:steam and
# not 2:games when Steam is running: it *is* a Steam game as far as the client is
# concerned, and Big Picture shows it in the library.
bindsym $mod+p exec /usr/local/bin/prism-launch
bindsym $mod+1 workspace $ws_steam
bindsym $mod+2 workspace $ws_games
bindsym $mod+3 workspace $ws_web
bindsym $mod+4 workspace $ws_media
bindsym $mod+5 workspace $ws_music
bindsym $mod+Left focus left
bindsym $mod+Right focus right
bindsym $mod+Up focus up
bindsym $mod+Down focus down
# ---------------------------------------------------------------------------
# Remote control — the standardised media-key set.
#
# Same device class as the thin client's: a wireless USB remote that presents as two
# HID keyboards (TV controls on the front, a small keyboard on the back). Nothing to
# configure per-device; the front buttons arrive as the XF86* keysyms below.
#
# DELIBERATELY NOT BOUND: plain arrows and Return. Steam's Big Picture, mpv and every
# web page need them, and a remote's D-pad and OK button send exactly those. Stealing
# them at the compositor would make Big Picture unnavigable with the remote — which is
# most of what the remote is for here. Window focus stays on $mod+arrows above.
#
# `wev` from the maintenance shell prints the keysym for any button that is not bound.
# ---------------------------------------------------------------------------
# Transport. `playerctl -p mpv,spotify` matches the two players this image runs; the
# order is the priority when both are alive. Steam's own media has no MPRIS bus and is
# not a target — a game's audio is not something you "pause".
bindsym XF86AudioPlay exec playerctl -p mpv,spotify play-pause
bindsym XF86AudioPause exec playerctl -p mpv,spotify pause
bindsym XF86AudioStop exec playerctl -p mpv,spotify stop
bindsym XF86AudioNext exec playerctl -p mpv,spotify next
bindsym XF86AudioPrev exec playerctl -p mpv,spotify previous
bindsym XF86AudioForward exec playerctl -p mpv,spotify position 30+
bindsym XF86AudioRewind exec playerctl -p mpv,spotify position 10-
# Volume. Sinks, not players — the volume rocker should move the room's volume whether
# the noise is coming from Spotify or from a game, and a game is the common case here.
bindsym XF86AudioRaiseVolume exec wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
bindsym XF86AudioLowerVolume exec wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
bindsym XF86AudioMute exec wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
# Navigation.
bindsym XF86ChannelUp workspace next
bindsym XF86ChannelDown workspace prev
# Home goes back to the games, because on this box that is home.
bindsym XF86HomePage exec /usr/local/bin/steam-big-picture
bindsym XF86Back workspace back_and_forth
bindsym XF86Forward workspace back_and_forth
# Power. **The display, not the machine** — same reasoning as the thin client, and more
# so here: `poweroff` from the sofa would drop whatever game is running, unsaved.
bindsym XF86PowerOff exec display-toggle
bindsym XF86Sleep exec display-toggle
bindsym XF86ScreenSaver exec display-toggle
# Deliberately no exit binding: `swaymsg exit` would drop to a black VT and greetd would
# autologin straight back in. Use SSH or the maintenance shell to administer.
workspace $ws_steam

View File

@ -0,0 +1,29 @@
#!/bin/sh
# Turn every output off, or back on. Installed to /usr/local/bin/display-toggle and
# bound to the remote's power/sleep buttons in the Sway config.
#
# WHY THE POWER BUTTON DOES NOT POWER ANYTHING OFF: on a TV, that button turns the
# picture off. Here, `poweroff` would drop whatever game is running — unsaved — and
# take the room's screen away until somebody walks over to the machine. So the remote's
# power button does the thing the person pressing it actually meant.
#
# THE WAKE SIDE IS THE HARD HALF. With outputs powered off, Sway is still running and
# still receiving keys, so pressing power again lands here and turns them back on. That
# is why this is a toggle rather than two bindings: there is no other way back. It also
# means a stuck remote button cannot leave the screen dark — the next press fixes it.
#
# This is the local, no-network path. Home Assistant's "Display" switch goes through
# steamtv_agent/display_power.py instead, which also drives HDMI-CEC so the television
# itself goes to standby rather than just showing "no signal".
set -eu
# `swaymsg -t get_outputs` reports each output's power state in a field still named
# "dpms" (the *command* was renamed to `power`; the JSON key was not). If ANY output is
# still on, the intent of a press is "turn it off"; only when everything is already
# dark does a press mean "wake up". That ordering matters on a multi-output machine,
# where asking per-output would leave the remote toggling one screen at a time.
if swaymsg -t get_outputs | grep -q '"dpms": true'; then
swaymsg 'output * power off'
else
swaymsg 'output * power on'
fi

View File

@ -0,0 +1,26 @@
# wayvnc — installed to /etc/wayvnc/config by build-steam-tv-box-iso.sh.
# Format is plain key=value, one per line (not TOML, not INI sections).
#
# There is deliberately NO `password=` line in this file. wayvnc only accepts the
# password inline, so committing one here would put a live credential for a full
# remote-control channel into git. Instead /usr/local/bin/start-wayvnc reads
# /etc/wayvnc/wayvnc-password (mode 0600, never committed) and writes a merged config
# into $XDG_RUNTIME_DIR at session start. If that file still holds the build-time
# sentinel, start-wayvnc refuses to launch — no unauthenticated VNC server, ever.
# Bound to all interfaces on purpose: wayvnc is this project's remote-control channel
# (the confirmed replacement for RDP), so it has to be reachable from the LAN, not
# just loopback. That is exactly why the auth below is not optional.
address=0.0.0.0
port=5900
enable_auth=true
username=@KIOSK_USERNAME@
# wayvnc >= 0.7 uses this for RSA-AES auth.
rsa_private_key_file=/etc/wayvnc/rsa_key.pem
# wayvnc <= 0.6 authenticates over TLS instead and needs these two; harmless on newer
# builds. Both are generated by 0300-wayvnc.hook.chroot, self-signed.
private_key_file=/etc/wayvnc/tls_key.pem
certificate_file=/etc/wayvnc/tls_cert.pem

View File

@ -0,0 +1,36 @@
#!/bin/sh
# Launches wayvnc with a password that is never stored in the repo or in /etc/wayvnc/config.
# Installed to /usr/local/bin/start-wayvnc, started from the sway config.
set -eu
BASE_CONFIG=/etc/wayvnc/config
PASSWORD_FILE=/etc/wayvnc/wayvnc-password
SENTINEL='CHANGEME-SET-ON-FIRST-BOOT'
if [ ! -r "$PASSWORD_FILE" ]; then
echo "start-wayvnc: $PASSWORD_FILE is missing or unreadable — refusing to start." >&2
exit 1
fi
PASSWORD="$(head -n 1 "$PASSWORD_FILE" | tr -d '\r\n')"
# Fail closed. An operator who forgets this step gets no remote access, rather than a
# remote-control channel anyone on the LAN can open.
if [ -z "$PASSWORD" ] || [ "$PASSWORD" = "$SENTINEL" ]; then
echo "start-wayvnc: no wayvnc password set. Run, as root, on this machine:" >&2
echo " openssl rand -base64 24 > $PASSWORD_FILE && chmod 600 $PASSWORD_FILE" >&2
echo " chown $(id -un):$(id -gn) $PASSWORD_FILE" >&2
echo "Then restart the session. Refusing to start an unauthenticated VNC server." >&2
exit 1
fi
RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/wayvnc"
mkdir -p "$RUNTIME_DIR"
chmod 700 "$RUNTIME_DIR"
RUNTIME_CONFIG="$RUNTIME_DIR/config"
umask 077
cp "$BASE_CONFIG" "$RUNTIME_CONFIG"
printf 'password=%s\n' "$PASSWORD" >> "$RUNTIME_CONFIG"
exec wayvnc --config="$RUNTIME_CONFIG"

View File

@ -0,0 +1,61 @@
#!/bin/sh
# Creates the kiosk account the whole image is built around.
#
# live-build runs chroot_local-includes BEFORE chroot_local-hooks, so
# /etc/steamtv-agent/config.env (written by build-steam-tv-box-iso.sh) already exists
# here. Sourcing it is why hooks don't need placeholder/sed templating.
set -eu
. /etc/steamtv-agent/config.env
if ! id "$KIOSK_USERNAME" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash --comment "Steam TV box kiosk session" "$KIOSK_USERNAME"
fi
# Same list as the thin client, plus `games` — some titles and emulators expect it, and
# `gamemode` needs the user in a group its daemon accepts before it will honour a
# governor request.
for grp in audio video input render dialout netdev plugdev seat _seatd games gamemode bluetooth; do
if getent group "$grp" >/dev/null 2>&1; then
adduser "$KIOSK_USERNAME" "$grp" >/dev/null
fi
done
# No password is baked in: the account is locked so it can never be used to log in
# remotely, while the physical console still autologins via greetd.
passwd --lock "$KIOSK_USERNAME" >/dev/null
adduser "$KIOSK_USERNAME" sudo >/dev/null
# Passwordless sudo is a deliberate call, not laziness: this image autologins to an
# unattended interactive Sway session at the physical console, so anyone standing in
# front of the machine already has the equivalent of a root shell. Requiring a password
# here would buy nothing while making the locked account unadministrable. The
# boundaries that actually matter are the wayvnc password and key-only SSH below.
cat > "/etc/sudoers.d/010-${KIOSK_USERNAME}" <<EOF
${KIOSK_USERNAME} ALL=(ALL) NOPASSWD: ALL
EOF
chmod 0440 "/etc/sudoers.d/010-${KIOSK_USERNAME}"
mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/10-steam-tv-box.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
EOF
if [ -d "/home/${KIOSK_USERNAME}/.ssh" ]; then
chmod 700 "/home/${KIOSK_USERNAME}/.ssh"
[ -f "/home/${KIOSK_USERNAME}/.ssh/authorized_keys" ] && \
chmod 600 "/home/${KIOSK_USERNAME}/.ssh/authorized_keys"
fi
# Games are large and this box has a real disk, so the library lives outside the live
# image's writable overlay by convention — see hosts/steam-tv-box/README.md for
# mounting a games disk here. Created either way so Steam's own first-run path exists.
mkdir -p "/home/${KIOSK_USERNAME}/Games"
chown -R "${KIOSK_USERNAME}:${KIOSK_USERNAME}" "/home/${KIOSK_USERNAME}"
systemctl enable ssh >/dev/null 2>&1 || true

View File

@ -0,0 +1,25 @@
#!/bin/sh
# Makes greetd the boot target so the machine comes up straight in the kiosk Sway
# session, which in turn launches Steam Big Picture (config in /etc/greetd/config.toml,
# shipped via includes.chroot).
set -eu
. /etc/steamtv-agent/config.env
chmod 0755 /usr/local/bin/kiosk-session
# greetd's own package user; it still needs to exist even though no greeter UI runs.
if ! id greeter >/dev/null 2>&1; then
useradd --system --create-home --home-dir /var/lib/greetd --shell /usr/sbin/nologin greeter
fi
systemctl enable greetd
systemctl set-default graphical.target
# live-config would otherwise autologin its own account on tty1 and fight greetd for
# the VT. build-steam-tv-box-iso.sh passes `noautologin` on the kernel command line;
# masking the getty on vt1 makes that robust even if someone edits the boot args.
systemctl mask getty@tty1.service
mkdir -p "/home/${KIOSK_USERNAME}/.config/sway"
chown -R "${KIOSK_USERNAME}:${KIOSK_USERNAME}" "/home/${KIOSK_USERNAME}/.config"

View File

@ -0,0 +1,35 @@
#!/bin/sh
# Prepares wayvnc's auth material. Deliberately does NOT set a password.
set -eu
. /etc/steamtv-agent/config.env
mkdir -p /etc/wayvnc
chmod 0755 /usr/local/bin/start-wayvnc
# wayvnc's RSA-AES auth needs a key pair; it is machine-local and carries no secret
# that belongs in git, so generating it at build time is fine.
if [ ! -f /etc/wayvnc/rsa_key.pem ]; then
openssl genrsa -out /etc/wayvnc/rsa_key.pem 2048 2>/dev/null
fi
# Self-signed TLS material, needed only by wayvnc <= 0.6 whose auth path is TLS-based
# rather than RSA-AES. Harmless on newer versions.
if [ ! -f /etc/wayvnc/tls_key.pem ]; then
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout /etc/wayvnc/tls_key.pem -out /etc/wayvnc/tls_cert.pem \
-subj "/CN=steam-tv-box" 2>/dev/null
fi
# Sentinel, not a password. start-wayvnc refuses to launch while this value is still
# here, so the failure mode of "operator forgot to set a password" is "no VNC server"
# rather than "an unauthenticated VNC server on the LAN". A real value must never be
# committed — see hosts/steam-tv-box/README.md.
if [ ! -f /etc/wayvnc/wayvnc-password ]; then
printf 'CHANGEME-SET-ON-FIRST-BOOT\n' > /etc/wayvnc/wayvnc-password
fi
chmod 0600 /etc/wayvnc/wayvnc-password /etc/wayvnc/rsa_key.pem /etc/wayvnc/tls_key.pem
chown "${KIOSK_USERNAME}:${KIOSK_USERNAME}" \
/etc/wayvnc/wayvnc-password /etc/wayvnc/rsa_key.pem /etc/wayvnc/tls_key.pem
chmod 0644 /etc/wayvnc/tls_cert.pem /etc/wayvnc/config

View File

@ -0,0 +1,130 @@
#!/bin/sh
# Native Steam, the 32-bit graphics stack it needs, and the optional extras around it.
#
# WHY THIS IS A HOOK AND NOT A LINE IN steam-tv-box.list.chroot
# ------------------------------------------------------------
# live-build installs config/package-lists/* BEFORE it runs config/hooks/normal/*, and
# `steam-installer` is unusable until i386 is a known architecture: it is an amd64
# package whose entire job is to pull in an i386 dependency chain. Enabling multiarch
# has to happen first, and the only place in a live-build tree that can run a command
# before apt sees a package name is here.
#
# `steam-installer` (not `steam`) is Debian's own wrapper: it fetches Valve's bootstrap
# on first launch and keeps itself updated from Valve thereafter, which is what you
# want for a client that talks to a moving service. It lives in contrib, so
# build-steam-tv-box-iso.sh passes `--archive-areas "main contrib non-free
# non-free-firmware"` to `lb config`; without that this hook fails on the first
# apt-get install and the image comes up with no Steam at all.
set -eu
. /etc/steamtv-agent/config.env
export DEBIAN_FRONTEND=noninteractive
echo "0300-steam: enabling i386 multiarch"
dpkg --add-architecture i386
apt-get update
# --- The 32-bit graphics/audio stack -----------------------------------------------
# Proton's own runtime is 64-bit, but a great many shipped Linux builds and every
# pre-2015 title in a Steam library are 32-bit, and they fail with an opaque "failed to
# create GL context" rather than anything that names the missing library. Installing
# these up front is much cheaper than diagnosing that on a TV with a gamepad.
I386_PACKAGES="
libgl1-mesa-dri:i386
libglx-mesa0:i386
mesa-vulkan-drivers:i386
libvulkan1:i386
libasound2-plugins:i386
libpulse0:i386
libsdl2-2.0-0:i386
libopenal1:i386
"
# shellcheck disable=SC2086
if apt-get install -y --no-install-recommends $I386_PACKAGES; then
echo "0300-steam: installed the i386 graphics/audio stack."
else
echo "0300-steam: WARNING — the i386 stack did not install cleanly. 64-bit titles will"
echo " still run; 32-bit ones will fail with GL/Vulkan context errors. Retry on the"
echo " booted image with: sudo apt-get install $(echo $I386_PACKAGES | tr '\n' ' ')"
fi
# --- Steam itself ------------------------------------------------------------------
# steam-devices ships the udev rules for Steam Controllers, Steam Decks used as pads,
# and the DualShock/DualSense/Xbox families. Without it the pads are visible only to
# root and Big Picture shows no controller at all — which on a machine with no keyboard
# in front of it is indistinguishable from a broken image.
if apt-get install -y steam-installer steam-devices; then
echo "0300-steam: installed steam-installer + steam-devices."
else
echo "0300-steam: ERROR — could not install steam-installer. The most likely cause is"
echo " that contrib is missing from the archive areas (see this file's header), or"
echo " the chroot had no network. This image will boot to the media session with no"
echo " Steam on it. Fix and rebuild rather than shipping it."
fi
# --- gamescope (optional) ----------------------------------------------------------
# The micro-compositor Valve uses on the Steam Deck. On a TV it is what gives Big
# Picture a fixed resolution and refresh rate independent of the desktop, integer
# scaling, and a framerate limiter — noticeably better than handing a game the raw
# output. Not required: /usr/local/bin/steam-big-picture detects it at launch and runs
# Steam directly under Xwayland when it is absent, so this failing is cosmetic.
if apt-get install -y --no-install-recommends gamescope; then
echo "0300-steam: installed gamescope; Big Picture will run nested inside it."
else
echo "0300-steam: note — gamescope is not available in this release. steam-big-picture"
echo " will fall back to plain Xwayland, which works but has no framerate limiter"
echo " and no integer scaling."
fi
# --- Vendor GPU driver -------------------------------------------------------------
# GPU_VENDOR comes from `gpu_vendor` in CoreSystemConfig.json. AMD and Intel need
# nothing beyond the Mesa packages already in the package list plus the firmware blobs;
# NVIDIA needs its own non-free driver, and installing that on an AMD box actively
# breaks it, which is why this is a config value and not a guess.
case "${GPU_VENDOR:-amd}" in
nvidia)
echo "0300-steam: GPU_VENDOR=nvidia — installing the non-free NVIDIA driver"
# libnvidia-gl:i386 is the 32-bit GL/Vulkan half, the exact counterpart of the
# Mesa i386 packages above and needed for the same 32-bit titles.
if apt-get install -y nvidia-driver libnvidia-gl-535:i386 2>/dev/null \
|| apt-get install -y nvidia-driver nvidia-driver-libs:i386; then
echo "0300-steam: NVIDIA driver installed."
echo "0300-steam: VERIFY ON FIRST BOOT — sway on NVIDIA still wants"
echo " WLR_NO_HARDWARE_CURSORS=1 on some driver versions; kiosk-session sets it"
echo " when GPU_VENDOR=nvidia. Check \`vulkaninfo | head\` reports the NVIDIA ICD."
else
echo "0300-steam: WARNING — the NVIDIA driver did not install. Check that non-free"
echo " is in the archive areas and that the driver package name matches this"
echo " release, then install it on the booted machine."
fi
;;
intel)
echo "0300-steam: GPU_VENDOR=intel — Mesa (already installed) is the whole driver."
if ! apt-get install -y --no-install-recommends intel-media-va-driver-non-free; then
echo "0300-steam: note — intel-media-va-driver-non-free unavailable; video decode"
echo " falls back to the free driver, which is fine for games and slower for 4K video."
fi
;;
*)
echo "0300-steam: GPU_VENDOR=${GPU_VENDOR:-amd} — Mesa (already installed) is the whole driver."
;;
esac
# --- Let games ask for more file descriptors and memory maps -----------------------
# Proton/DXVK open a lot of both, and the distro defaults are what produce the
# "shader cache failed" and "vm.max_map_count" class of crashes late in a session.
cat > /etc/security/limits.d/90-steam-tv-box.conf <<'EOF'
# Proton and DXVK hold thousands of file descriptors open per running game.
* soft nofile 1048576
* hard nofile 1048576
EOF
cat > /etc/sysctl.d/90-steam-tv-box.conf <<'EOF'
# Several Proton titles (and anything using esync/fsync) exceed the default map count
# and die with an allocation failure that names nothing useful.
vm.max_map_count = 2147483642
EOF
apt-get clean

View File

@ -0,0 +1,59 @@
#!/bin/sh
# Flathub remote + the two apps this image needs that Debian does not package:
# the official Spotify client and Prism Launcher.
#
# Same distribution channel and the same "don't pin a version that will just rot"
# reasoning as hosts/touch-panel's 0300-flatpak-spotify.hook.chroot and
# hosts/thin-client's 0400-flatpak-steamlink.hook.chroot.
#
# Note what is NOT here: Steam. Steam is installed natively from apt by
# 0300-steam.hook.chroot, deliberately — the Flatpak runs sandboxed with its own
# runtime, which complicates GPU driver matching, controller udev access and mounting
# a games disk, all of which matter on a machine whose whole job is running games
# locally. Prism and Spotify have none of those constraints, so the sandbox is a
# straightforward win for them.
set -eu
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
# VERIFY BEFORE THE FIRST REAL BUILD: confirm both application IDs against the live
# Flathub listing (`flatpak search Spotify`, `flatpak search "Prism Launcher"`).
# Believed correct — both are the publishers' own official listings — but not checked
# against Flathub from this environment.
SPOTIFY_APP_ID="com.spotify.Client"
PRISM_APP_ID="org.prismlauncher.PrismLauncher"
install_app() {
app_id="$1"
label="$2"
if flatpak install -y --noninteractive flathub "$app_id"; then
echo "0400-flatpak-apps: installed ${app_id}."
else
echo "0400-flatpak-apps: WARNING — could not install ${app_id} during the build (no"
echo " network in the chroot, or the app ID is wrong). Run this on the booted image"
echo " instead: flatpak install -y flathub ${app_id}"
echo " Until then the ${label} launcher opens nothing."
fi
}
install_app "$SPOTIFY_APP_ID" "Spotify"
install_app "$PRISM_APP_ID" "Prism Launcher"
# Prism needs a Java runtime it can actually see. The Flatpak's sandbox has its own
# filesystem view, so the openjdk packages in the package list are not visible inside
# it by default — this grants read access to the host's JVMs so Prism's auto-detection
# finds them and Minecraft launches with no network on first run.
if flatpak override --filesystem=/usr/lib/jvm:ro "$PRISM_APP_ID" 2>/dev/null; then
echo "0400-flatpak-apps: granted Prism Launcher read access to /usr/lib/jvm."
else
echo "0400-flatpak-apps: note — could not set the /usr/lib/jvm override (Prism is"
echo " probably not installed yet). Prism will download its own JRE on first launch,"
echo " which needs network at that moment. To fix it offline later:"
echo " flatpak override --user --filesystem=/usr/lib/jvm:ro ${PRISM_APP_ID}"
fi
# The games directory, so both Prism instances and any Steam library added later sit in
# one predictable place — see hosts/steam-tv-box/README.md on mounting a real disk there.
if flatpak override --filesystem=~/Games "$PRISM_APP_ID" 2>/dev/null; then
echo "0400-flatpak-apps: granted Prism Launcher access to ~/Games."
fi

View File

@ -0,0 +1,20 @@
#!/bin/sh
# Installs the steamtv-agent systemd unit.
#
# The package itself arrives under /opt/steamtv-agent via includes.chroot and runs on
# the system interpreter against apt's python3-paho-mqtt, so there is nothing to
# pip-install and no PEP 668 problem to work around here.
set -eu
install -m 0644 /opt/steamtv-agent/steamtv-agent.service \
/etc/systemd/system/steamtv-agent.service
chmod 0644 /etc/steamtv-agent/config.env
chown -R root:root /opt/steamtv-agent
# Writable runtime state (the persisted audio-output choice) — steamtv_agent's
# runtime_state.py seeds this from the read-only templates in /etc on first start, and
# cannot create the directory itself once /etc is a squashfs.
install -d -m 0755 /var/lib/steamtv-agent
systemctl enable steamtv-agent

View File

@ -0,0 +1,22 @@
#!/bin/sh
# Firefox enterprise policy + kiosk-chrome plumbing. Identical in mechanism to
# hosts/thin-client's 0900-firefox.hook.chroot — see that file for the full reasoning.
#
# policies.json is installed to /etc/firefox/policies/policies.json by includes.chroot
# (the documented Linux location), and linked here into firefox-esr's own distribution/
# directory too — an older location some builds read instead of the system one.
# Whichever the installed firefox-esr honours wins; the other is inert.
set -eu
FIREFOX_LIB_DIR="/usr/lib/firefox-esr"
if [ -d "$FIREFOX_LIB_DIR" ]; then
mkdir -p "$FIREFOX_LIB_DIR/distribution"
ln -sf /etc/firefox/policies/policies.json "$FIREFOX_LIB_DIR/distribution/policies.json"
echo "0900-firefox: linked policies.json into $FIREFOX_LIB_DIR/distribution/"
else
echo "0900-firefox: $FIREFOX_LIB_DIR not found (firefox-esr not installed yet, or a"
echo " different path in this Debian release) — /etc/firefox/policies/policies.json"
echo " still applies if the package's install order runs after this hook; verify"
echo " about:policies shows uBlock Origin/SponsorBlock as force-installed on first boot."
fi

View File

@ -0,0 +1,157 @@
# Steam-TV-Box package list (live-build .list.chroot format: one package per line).
#
# This machine is a LOCAL gaming box first and a media station second: real GPU, real
# CPU, games run here rather than being streamed in. That is the difference from
# hosts/thin-client, which runs Steam *Link* (a client for a PC somewhere else) and
# needs no graphics performance at all.
#
# Because of that, this image is built against Debian trixie rather than the household
# default — see the debian_release note in tools/build-steam-tv-box-iso.sh. Mesa 22.3
# (bookworm) predates most of the driver work that current titles rely on, and a
# gaming box on an old Mesa is the one thing here that cannot be fixed later by
# rebuilding a config file.
#
# NOT in this list and installed by hooks instead:
# steam-installer, steam-devices -> 0300-steam.hook.chroot (needs `dpkg
# --add-architecture i386` + `apt-get update` first, and live-build installs
# package lists BEFORE it runs hooks, so it cannot be done from here)
# the i386 halves of the graphics stack -> same hook, same reason
# Spotify, Prism Launcher -> 0400-flatpak-apps.hook.chroot (Flathub)
# --- Wayland compositor / session ---
sway
swayidle
swaybg
# Steam, Prism Launcher's Minecraft windows and most native games are X11 clients, so
# Xwayland is load-bearing here rather than a nicety.
xwayland
greetd
foot
# --- Remote view/control (same wayvnc-with-a-mandatory-password posture as the thin
# client; it is for administering the box, not for playing over) ---
wayvnc
# --- Graphics: the amd64 half. The i386 half is installed by 0300-steam.hook.chroot
# after multiarch is enabled, and 32-bit games/Proton runtimes genuinely still
# need it in 2026. ---
mesa-vulkan-drivers
mesa-va-drivers
mesa-vdpau-drivers
libgl1-mesa-dri
libglx-mesa0
libvulkan1
# vulkaninfo/vkcube — the fastest way to answer "is this box actually accelerated?"
# from the maintenance shell before blaming a game.
vulkan-tools
# glxinfo, for the same question on the GL side.
mesa-utils
libva-drm2
vainfo
# --- Gaming runtime bits ---
# Lets a game ask the system for the performance governor while it runs and hand it
# back afterwards, instead of pinning this box to "performance" around the clock.
gamemode
# On-screen FPS/frametime overlay, opt-in per launch (MANGOHUD=1). Diagnostics, not
# decoration: it is how you tell a CPU-bound stutter from a GPU-bound one.
mangohud
# Controller support. Steam's own controller stack covers Steam games; these cover
# everything outside it (Prism/Minecraft, emulators, the browser).
joystick
evtest
bluez
bluetooth
# Steam's runtime and many native titles link against these directly.
libsdl2-2.0-0
libopenal1
# Proton/Wine prerequisites that apt will otherwise pull only as recommends.
cabextract
p7zip-full
# Xbox/PlayStation pad firmware, plus the general non-free firmware blobs a real GPU
# needs. Requires the contrib + non-free + non-free-firmware archive areas, which
# tools/build-steam-tv-box-iso.sh passes to `lb config`.
firmware-linux
firmware-amd-graphics
firmware-realtek
firmware-iwlwifi
firmware-misc-nonfree
# --- Browser / media (the "same media functions as the other monitor clients" half) ---
firefox-esr
mpv
mpv-mpris
libmpv2
playerctl
# wev prints the keysym for each key press — how to find out what a given TV remote's
# buttons actually send. Same reasoning as the thin client's.
wev
# --- Audio ---
pipewire
pipewire-pulse
pipewire-alsa
wireplumber
alsa-utils
# 32-bit games play through pipewire-pulse's ALSA/Pulse shim; libpulse0:i386 comes with
# the i386 batch in 0300-steam.hook.chroot.
# --- Flatpak apps: Spotify and Prism Launcher (0400-flatpak-apps.hook.chroot) ---
flatpak
# Portals — Flatpak apps use these for file pickers and screen sharing under Wayland.
# Without them Prism's "choose a Java binary" dialog and Spotify's file dialogs come up
# empty rather than failing loudly, which is a confusing way to lose an evening.
xdg-desktop-portal
xdg-desktop-portal-wlr
xdg-desktop-portal-gtk
# --- Java for Prism Launcher's own use ---
# Prism can download its own JREs, but only if it has network at the time. Shipping
# both the LTS and the current runtime means Minecraft launches offline too: 8 for
# anything pre-1.17, 21 for current versions.
openjdk-21-jre
openjdk-8-jre
# --- Agent runtime ---
python3
python3-venv
# trixie ships paho-mqtt 2.x; steamtv_agent is written against both the 1.x and 2.x
# callback APIs (see make_client() in main.py), so apt's package is used as-is.
python3-paho-mqtt
procps
# --- Networking ---
network-manager
# --- Out-of-band admin ---
openssh-server
sudo
# --- Session plumbing ---
# session-watcher parses `swaymsg -t subscribe` events with jq; the sway config's
# workspace logic depends on it.
jq
# HDMI-CEC (cec-ctl) for turning the TV itself on and off — steamtv_agent/display_power.py.
v4l-utils
# media-player talks to a running mpv over its JSON IPC socket (mpv.conf's
# input-ipc-server) so a second `media-player <file>` loads into the existing window
# instead of opening a competing MPRIS player.
socat
# --- Misc ---
ca-certificates
curl
openssl
less
vim-tiny
git
# NOTE: gamescope is NOT listed here on purpose. It is the right way to run Big
# Picture on a TV (integer-scaled, its own framerate limiter, no compositor tearing),
# but its availability in the target release was not verified from this environment —
# 0300-steam.hook.chroot installs it if apt has it and steam-big-picture falls back to
# plain Xwayland if it does not, so a missing gamescope costs polish rather than a
# broken image.
# NOTE: NVIDIA users — see 0300-steam.hook.chroot. The driver is not in this list
# because it must not be installed on an AMD/Intel box, and which one this is comes
# from `gpu_vendor` in CoreSystemConfig.json.

393
tools/build-steam-tv-box-iso.sh Executable file
View File

@ -0,0 +1,393 @@
#!/usr/bin/env bash
#
# Smart Home Steam-TV-Box ISO Builder
# Target: builds a Debian live ISO on a Debian/Ubuntu build machine
#
# Drives `lb config && lb build` over hosts/steam-tv-box/live-build/ to produce the
# living-room gaming + media image:
# - greetd autologin straight into a kiosk Sway session (no greeter UI)
# - boots into STEAM BIG PICTURE and nothing else
# - native, local gaming: steam-installer + i386 multiarch + the full Mesa/Vulkan
# stack (this box has a real GPU; it is NOT the thin client's Steam Link streaming)
# - Prism Launcher (Minecraft), registered as a non-Steam game so it runs *through*
# Steam with Steam Input and the Steam Controller API live
# - a media session — Firefox (uBlock Origin + SponsorBlock), Spotify, mpv — that is
# launched LAZILY, the first time somebody leaves Big Picture
# - steamtv-agent (Python, systemd) — HA MQTT-discovery entities + swaymsg control
# - wayvnc for administration (password mandatory, fails closed)
#
# This script is also the single point that keeps configs/ (the human-edited source of
# truth, reviewed in git) in sync with live-build/config/includes.chroot/ (the
# generated tree that actually gets baked into the image). Never hand-edit anything
# under includes.chroot — it is wiped and regenerated on every run. The same now goes
# for live-build/config/preseed.cfg, which is generated from configs/installer/.
#
# Run as: sudo -E tools/build-steam-tv-box-iso.sh [hostname]
#
# Configuration comes from CoreSystemConfig.json — see tools/README.md.
set -euo pipefail
# ---------------------------------------------------------------------------
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
#
# sudo -E tools/build-steam-tv-box-iso.sh # the only steam-tv-box
# sudo -E tools/build-steam-tv-box-iso.sh <hostname> # a specific one, if several
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/coreconfig.sh
source "${SCRIPT_DIR}/lib/coreconfig.sh"
core_select_kiosk "steam-tv-box" "${1:-}"
# CORE_KIOSK_DEBIAN_RELEASE is the per-kiosk `debian_release` override, falling back to
# household.debian_release when the kiosk does not set one. It exists mostly for this
# image: the household default is bookworm, whose Mesa (22.3) predates most of the
# driver work current titles depend on. Every other host here is a browser and a
# Python agent and does not care what Mesa it has; this one is the only machine in the
# project that renders anything demanding, and it is also the only one where being a
# release behind cannot be fixed later by editing a config file. Set
# "debian_release": "trixie" on this kiosk — see CoreSystemConfig.json.template.
DEBIAN_RELEASE="$CORE_KIOSK_DEBIAN_RELEASE"
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
MQTT_USERNAME="$CORE_MQTT_USERNAME"
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
STEAMTV_NAME="$CORE_KIOSK_FRIENDLY_NAME"
# The HA area this device physically sits in, from the kiosk's `room` in
# CoreSystemConfig.json. The agent publishes it as suggested_area so HA files the
# device in the right room by itself — see docs/rooms-and-endpoints.md.
STEAMTV_ROOM="${CORE_KIOSK_ROOM:-}"
HA_URL="$CORE_HA_URL"
# Which GPU is in this machine: amd | intel | nvidia. Read by 0300-steam.hook.chroot
# (to install the NVIDIA driver, or deliberately not to) and by kiosk-session (the
# wlroots cursor workaround). Guessing this wrong on an AMD box by installing the
# NVIDIA driver actively breaks it, which is why it is configuration and not detection.
GPU_VENDOR="${CORE_KIOSK_GPU_VENDOR:-amd}"
ENABLE_CEC="${CORE_KIOSK_ENABLE_CEC:-true}"
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
HOST_DIR="${CORE_REPO_ROOT}/hosts/steam-tv-box"
CONFIGS_DIR="${HOST_DIR}/configs"
AGENT_DIR="${HOST_DIR}/agent"
LIVE_BUILD_DIR="${HOST_DIR}/live-build"
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/steam-tv-box.list.chroot"
# ---------------------------------------------------------------------------
# Sanity checks
# ---------------------------------------------------------------------------
if [[ $EUID -ne 0 ]]; then
echo "Warning: not running as root. 'lb build' needs root to bootstrap and chroot,"
echo " and will fail partway through. Re-run with: sudo $0"
echo " Continuing anyway so you can at least regenerate includes.chroot..."
fi
if ! grep -qi "debian\|ubuntu" /etc/os-release; then
echo "Warning: live-build targets a Debian/Ubuntu build host. Proceeding anyway..."
fi
if ! command -v lb &> /dev/null; then
if [[ $EUID -eq 0 ]]; then
echo "--- Installing live-build ---"
apt-get update
apt-get install -y live-build
else
echo "live-build is not installed and this script is not running as root." >&2
echo " Install it first: sudo apt-get install live-build" >&2
exit 1
fi
else
echo "--- live-build already installed, skipping ---"
fi
if [[ ! -f "$PACKAGE_LIST" ]]; then
echo "Missing package list: $PACKAGE_LIST" >&2
exit 1
fi
case "$GPU_VENDOR" in
amd|intel|nvidia) ;;
*)
echo "gpu_vendor is '${GPU_VENDOR}' — must be one of: amd, intel, nvidia." >&2
echo " Fix it on this kiosk in CoreSystemConfig.json and re-run." >&2
exit 1
;;
esac
if [[ "$DEBIAN_RELEASE" == "bookworm" ]]; then
echo "Note: building this image against bookworm. Its Mesa is 22.3, which is old for"
echo " a machine whose job is running games locally — expect missing Vulkan features"
echo " and poor performance in anything recent. Set \"debian_release\": \"trixie\" on"
echo " this kiosk in CoreSystemConfig.json unless you have a specific reason not to."
fi
echo
echo "=== Smart Home Steam-TV-Box ISO Builder ==="
echo "Debian release : $DEBIAN_RELEASE"
echo "Kiosk user : $KIOSK_USERNAME"
echo "Image hostname : $IMAGE_HOSTNAME"
echo "GPU vendor : $GPU_VENDOR"
echo "MQTT broker : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
echo "Home Assistant : $HA_URL"
echo "HDMI-CEC : $ENABLE_CEC"
echo "Debian installer : $ENABLE_INSTALLER"
echo "Keyboard layout : $KEYBOARD_LAYOUT"
echo
# ---------------------------------------------------------------------------
# 1. Regenerate includes.chroot from configs/ and agent/
# ---------------------------------------------------------------------------
echo "--- Regenerating $INCLUDES ---"
rm -rf "$INCLUDES"
mkdir -p \
"$INCLUDES/etc/greetd" \
"$INCLUDES/etc/wayvnc" \
"$INCLUDES/etc/steamtv-agent" \
"$INCLUDES/etc/firefox/policies" \
"$INCLUDES/etc/steamtv-firefox" \
"$INCLUDES/usr/local/bin" \
"$INCLUDES/opt/steamtv-agent" \
"$INCLUDES/home/${KIOSK_USERNAME}/.config/sway" \
"$INCLUDES/home/${KIOSK_USERNAME}/.config/mpv" \
"$INCLUDES/home/${KIOSK_USERNAME}/.ssh"
# @KIOSK_USERNAME@ and @KEYBOARD_LAYOUT@ are the only templated tokens in the configs.
# Everything else the hooks need is read at build time from /etc/steamtv-agent/
# config.env (written below), which live-build copies in via chroot_local-includes
# *before* it runs chroot_local-hooks — that ordering is what lets the hooks be plain
# scripts with no outer-shell variables of their own.
subst() {
sed -e "s/@KIOSK_USERNAME@/${KIOSK_USERNAME}/g" \
-e "s/@KEYBOARD_LAYOUT@/${KEYBOARD_LAYOUT}/g" "$1" > "$2"
}
subst "${CONFIGS_DIR}/greetd/config.toml" "$INCLUDES/etc/greetd/config.toml"
subst "${CONFIGS_DIR}/wayvnc/config" "$INCLUDES/etc/wayvnc/config"
subst "${CONFIGS_DIR}/sway/config" "$INCLUDES/home/${KIOSK_USERNAME}/.config/sway/config"
subst "${AGENT_DIR}/steamtv-agent.service" "$INCLUDES/opt/steamtv-agent/steamtv-agent.service"
install -m 0755 "${CONFIGS_DIR}/greetd/kiosk-session" "$INCLUDES/usr/local/bin/kiosk-session"
install -m 0755 "${CONFIGS_DIR}/wayvnc/start-wayvnc" "$INCLUDES/usr/local/bin/start-wayvnc"
# Bound to the remote's power/sleep buttons — see the Sway config's remote-control
# section for why those turn the display off rather than the machine.
install -m 0755 "${CONFIGS_DIR}/sway/display-toggle" "$INCLUDES/usr/local/bin/display-toggle"
# The session: the two halves of the leave-Big-Picture detection, the launchers they
# call, and the Steam shortcut registration that puts Prism inside Steam Input.
install -m 0755 "${CONFIGS_DIR}/session/steam-session" "$INCLUDES/usr/local/bin/steam-session"
install -m 0755 "${CONFIGS_DIR}/session/steam-big-picture" "$INCLUDES/usr/local/bin/steam-big-picture"
install -m 0755 "${CONFIGS_DIR}/session/steam-shortcut-prism" "$INCLUDES/usr/local/bin/steam-shortcut-prism"
install -m 0755 "${CONFIGS_DIR}/session/session-watcher" "$INCLUDES/usr/local/bin/session-watcher"
install -m 0755 "${CONFIGS_DIR}/session/media-session" "$INCLUDES/usr/local/bin/media-session"
install -m 0755 "${CONFIGS_DIR}/session/media-player" "$INCLUDES/usr/local/bin/media-player"
install -m 0755 "${CONFIGS_DIR}/session/spotify-launch" "$INCLUDES/usr/local/bin/spotify-launch"
install -m 0755 "${CONFIGS_DIR}/session/prism-launch" "$INCLUDES/usr/local/bin/prism-launch"
# fleet-bootstrap: fetch this machine's published monitoring script and run it. Opt-in —
# with no /etc/fleet-bootstrap.conf it exits 0 and does nothing.
install -m 0755 "${CORE_REPO_ROOT}/tools/fleet-bootstrap.sh" "$INCLUDES/usr/local/bin/fleet-bootstrap"
install -m 0644 "${CONFIGS_DIR}/mpv/mpv.conf" "$INCLUDES/home/${KIOSK_USERNAME}/.config/mpv/mpv.conf"
# Console (VT/TTY) keymap — separate from Sway's own xkb_layout above, since greetd
# briefly owns the console before Sway starts.
mkdir -p "$INCLUDES/etc/default"
cat > "$INCLUDES/etc/default/keyboard" <<EOF
XKBMODEL="pc105"
XKBLAYOUT="${KEYBOARD_LAYOUT}"
XKBVARIANT=""
XKBOPTIONS=""
BACKSPACE="guess"
EOF
# Firefox: the browsing window's launcher, plus the chrome/prefs template it copies
# into its profile at launch. policies.json (uBlock Origin, SponsorBlock — the
# "browser with ublock origin" half of the requirement) is global, not per-profile.
install -m 0755 "${CONFIGS_DIR}/firefox/web-browser" "$INCLUDES/usr/local/bin/web-browser"
install -m 0644 "${CONFIGS_DIR}/firefox/policies.json" "$INCLUDES/etc/firefox/policies/policies.json"
install -m 0644 "${CONFIGS_DIR}/firefox/userChrome.css" "$INCLUDES/etc/steamtv-firefox/userChrome.css"
install -m 0644 "${CONFIGS_DIR}/firefox/user.js" "$INCLUDES/etc/steamtv-firefox/user.js"
# Persistent audio-output template — steamtv_agent's runtime_state.py seeds a writable
# runtime copy from this on first boot (see its module docstring); the file here is
# read-only and never the one actually mutated.
install -m 0644 "${CONFIGS_DIR}/audio/audio-config.json" "$INCLUDES/etc/steamtv-agent/audio-config.json"
cp -r "${AGENT_DIR}/steamtv_agent" "$INCLUDES/opt/steamtv-agent/"
install -m 0644 "${AGENT_DIR}/requirements.txt" "$INCLUDES/opt/steamtv-agent/requirements.txt"
find "$INCLUDES/opt/steamtv-agent" -name '__pycache__' -type d -prune -exec rm -rf {} +
if [[ -n "$SSH_AUTHORIZED_KEY" ]]; then
echo "$SSH_AUTHORIZED_KEY" > "$INCLUDES/home/${KIOSK_USERNAME}/.ssh/authorized_keys"
chmod 600 "$INCLUDES/home/${KIOSK_USERNAME}/.ssh/authorized_keys"
echo " Baked an SSH authorized_keys entry for ${KIOSK_USERNAME}."
else
echo " No SSH_AUTHORIZED_KEY set — SSH admin access will not be possible on this image."
fi
# ---------------------------------------------------------------------------
# 2. Runtime config, read by steamtv-agent, the sway session wrapper, and the hooks
# ---------------------------------------------------------------------------
echo "--- Writing /etc/steamtv-agent/config.env into includes.chroot ---"
cat > "$INCLUDES/etc/steamtv-agent/config.env" <<EOF
# Generated by tools/build-steam-tv-box-iso.sh — do not hand-edit
# here; change CoreSystemConfig.json at the repo root and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME}
STEAMTV_NAME=${STEAMTV_NAME}
STEAMTV_ROOM=${STEAMTV_ROOM}
MQTT_BROKER_HOST=${MQTT_BROKER_HOST}
MQTT_BROKER_PORT=${MQTT_BROKER_PORT}
MQTT_USERNAME=${MQTT_USERNAME}
MQTT_PASSWORD=${MQTT_PASSWORD}
HA_URL=${HA_URL}
GPU_VENDOR=${GPU_VENDOR}
ENABLE_CEC=${ENABLE_CEC}
EOF
chmod 0644 "$INCLUDES/etc/steamtv-agent/config.env"
# ---------------------------------------------------------------------------
# 3. Generated preseed (only consulted when the installer is enabled)
# ---------------------------------------------------------------------------
sed -e "s/@KEYBOARD_LAYOUT@/${KEYBOARD_LAYOUT}/g" \
"${CONFIGS_DIR}/installer/preseed.cfg" > "${LIVE_BUILD_DIR}/config/preseed.cfg"
# ---------------------------------------------------------------------------
# 4. lb config
# ---------------------------------------------------------------------------
cd "$LIVE_BUILD_DIR"
chmod +x config/hooks/normal/*.hook.chroot
if [[ -e .build ]]; then
echo "--- Previous build found, running 'lb clean' (package cache is kept) ---"
lb clean
fi
INSTALLER_MODE="none"
if [[ "$ENABLE_INSTALLER" == "true" ]]; then
INSTALLER_MODE="live"
fi
echo "--- Running lb config ---"
# live-build auto-discovers config/package-lists/*.list.chroot and
# config/hooks/normal/*.hook.chroot; there is no flag to point at them individually.
#
# --archive-areas: contrib is REQUIRED — steam-installer lives there and
# 0300-steam.hook.chroot fails without it, producing an image with no Steam on it.
# non-free is required for the NVIDIA driver (and is harmless on an AMD/Intel box,
# since nothing from it is installed unless gpu_vendor says so).
lb config \
--distribution "$DEBIAN_RELEASE" \
--architectures amd64 \
--linux-flavours amd64 \
--archive-areas "main contrib non-free non-free-firmware" \
--binary-images iso-hybrid \
--debian-installer "$INSTALLER_MODE" \
--iso-application "SmartestHome Steam TV Box" \
--iso-publisher "SmartestHome" \
--iso-volume "smarthome-steam-tv-box" \
--memtest none \
--bootappend-live "boot=live components quiet splash noautologin username=${KIOSK_USERNAME} hostname=${IMAGE_HOSTNAME}"
# noautologin: live-config would otherwise autologin its own account on tty1 and fight
# greetd for the VT (0200-greetd.hook.chroot masks getty@tty1 as a belt-and-braces
# second line of defence).
# ---------------------------------------------------------------------------
# 5. lb build
# ---------------------------------------------------------------------------
echo "--- Running lb build (this takes a while and needs network) ---"
echo " Longer than the other images: the i386 multiarch stack and Steam's"
echo " dependency chain are a few hundred extra packages."
lb build
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
ISO_PATH="$(core_publish_image "$ISO_PATH" "steam-tv-box" "$IMAGE_HOSTNAME")"
echo
echo "=== Done ==="
echo "ISO written to:"
echo " ${ISO_PATH}"
echo
echo "What's in it:"
echo " greetd : autologin as '${KIOSK_USERNAME}' straight into Sway on vt1"
echo " Boots to : Steam Big Picture, and nothing else"
echo " Steam : NATIVE (steam-installer + i386 multiarch) — games run on this"
echo " machine. This is not the thin client's Steam Link streaming."
echo " GPU stack : Mesa/Vulkan (${GPU_VENDOR}), 64- and 32-bit"
echo " Prism Launcher : Flathub, registered as a non-Steam game so it launches THROUGH"
echo " Steam with Steam Input / the Steam Controller API active"
echo " Media session : Firefox (uBlock Origin + SponsorBlock), Spotify, mpv —"
echo " started the FIRST TIME you leave Big Picture, not at boot"
echo " Sway : workspaces 1:steam / 2:games / 3:web / 4:media / 5:music"
echo " wayvnc : 0.0.0.0:5900, auth REQUIRED, refuses to start without a password"
echo " steamtv-agent : system service, MQTT ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
echo " Audio output : HA select, persisted to audio-config.json"
echo " Keyboard layout : ${KEYBOARD_LAYOUT} (console + Sway)"
echo " Maintenance shell: Super+Shift+Ctrl+M opens a floating terminal locally"
echo
echo "Next steps:"
echo " 1. Write the ISO to a USB stick:"
echo " sudo dd if=${ISO_PATH} of=/dev/sdX bs=4M status=progress oflag=sync"
echo " (double-check /dev/sdX with 'lsblk' first — dd will happily eat the wrong disk)"
if [[ "$ENABLE_INSTALLER" != "true" ]]; then
echo " 2. NOTE: enable_installer is false, so this boots live — games would live in RAM"
echo " and vanish on reboot. For a real gaming box set \"enable_installer\": true on"
echo " this kiosk in CoreSystemConfig.json and rebuild, then install to the disk."
else
echo " 2. Boot the target machine and run the installer. Give /home its own large"
echo " partition (or mount a games disk at /home/${KIOSK_USERNAME}/Games) — a Steam"
echo " library and a few modpacks are hundreds of gigabytes."
fi
echo " 3. Confirm it lands in Big Picture with no login prompt, and that a gamepad is"
echo " recognised (that is steam-devices' udev rules doing their job)."
echo " 4. Check the box is actually accelerated before blaming any game — from the"
echo " maintenance shell (Super+Shift+Ctrl+M):"
echo " vulkaninfo | head -n 20 # should name your GPU, not llvmpipe"
echo " glxinfo -B # same"
echo " 5. SET THE WAYVNC PASSWORD — wayvnc will NOT be running until you do:"
echo " sudo sh -c 'openssl rand -base64 24 > /etc/wayvnc/wayvnc-password'"
echo " sudo chmod 600 /etc/wayvnc/wayvnc-password"
echo " sudo chown ${KIOSK_USERNAME}:${KIOSK_USERNAME} /etc/wayvnc/wayvnc-password"
echo " swaymsg reload"
echo " 6. Log into Steam on the TV. Until you do, Prism cannot be registered as a"
echo " non-Steam game (there is no user profile to register it in) and will fall"
echo " back to launching directly, without Steam Input. After the first login,"
echo " restart the session (or reboot) — steam-session registers the shortcut on"
echo " the way in, before Steam starts, because Steam overwrites shortcuts.vdf on"
echo " exit. Then confirm 'Prism Launcher' is in the Big Picture library and that"
echo " the pad works inside Minecraft."
echo " 7. Leave Big Picture and confirm the media session appears: Firefox on 3:web,"
echo " mpv on 4:media, Spotify on 5:music. Then go back into a game and confirm"
echo " they are NOT killed (that is deliberate — see configs/session/media-session)."
echo " 8. Confirm steamtv-agent connected and registered:"
echo " systemctl status steamtv-agent"
echo " In Home Assistant, a '${STEAMTV_NAME}' device should appear under the MQTT"
echo " integration with: Session (sensor), Mode (select), Screen (select), Launch"
echo " buttons for Steam/Prism/browser/player/Spotify, Stop media apps (button),"
echo " Audio output (select), Display (switch), Volume (number), Playback state"
echo " (sensor) and the transport buttons."
echo " 9. Set the audio output. On a TV box the right sink is usually HDMI, and if the"
echo " set was off at boot WirePlumber may have defaulted to a headphone jack with"
echo " nothing in it — the classic 'the game has no sound' report. Pick it in HA's"
echo " 'Audio output' select; the choice persists across reboots."
echo
echo "Then pull the power on the container host and re-check: the box must still boot"
echo "into Big Picture and play a game. steamtv-agent connects asynchronously and will"
echo "simply keep retrying."
echo
echo "Rebuilding later: edit hosts/steam-tv-box/configs/* or agent/*, then re-run this"
echo "script — includes.chroot is regenerated from them every time."

View File

@ -225,6 +225,18 @@ def main(argv: list[str]) -> int:
emit("CORE_KIOSK_ENABLE_INSTALLER", kiosk.get("enable_installer", False)) emit("CORE_KIOSK_ENABLE_INSTALLER", kiosk.get("enable_installer", False))
emit("CORE_KIOSK_ENABLE_STEAM_LINK", kiosk.get("enable_steam_link", False)) emit("CORE_KIOSK_ENABLE_STEAM_LINK", kiosk.get("enable_steam_link", False))
emit("CORE_KIOSK_ENABLE_GESTURE_CONTROL", kiosk.get("enable_gesture_control", False)) emit("CORE_KIOSK_ENABLE_GESTURE_CONTROL", kiosk.get("enable_gesture_control", False))
# A per-kiosk Debian release, falling back to the household one. Only the
# steam-tv-box currently sets it: that machine renders games locally, and
# bookworm's Mesa is too old for that, while nothing else here cares what Mesa
# it has. Emitted for every kiosk (not just that type) so a builder can read one
# variable without knowing whether an override was in play.
emit("CORE_KIOSK_DEBIAN_RELEASE",
kiosk.get("debian_release") or cfg["household"]["debian_release"])
# steam-tv-box only: which GPU is in the machine, and whether to drive the TV
# over HDMI-CEC. Both are decisions about physical hardware that the build host
# cannot see, so they are configuration rather than detection.
emit("CORE_KIOSK_GPU_VENDOR", kiosk.get("gpu_vendor", "amd"))
emit("CORE_KIOSK_ENABLE_CEC", kiosk.get("enable_cec", True))
# A per-kiosk wake word overrides the household default. # A per-kiosk wake word overrides the household default.
emit("CORE_KIOSK_WAKE_WORD", kiosk.get("wake_word", cfg.get("voice", {}).get("wake_word", "ok_nabu"))) emit("CORE_KIOSK_WAKE_WORD", kiosk.get("wake_word", cfg.get("voice", {}).get("wake_word", "ok_nabu")))

View File

@ -24,9 +24,12 @@ from pathlib import Path
from typing import Any from typing import Any
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
KIOSK_TYPES = {"thin-client", "touch-panel", "door-panel", "kitchen-display"} KIOSK_TYPES = {"thin-client", "touch-panel", "door-panel", "kitchen-display", "steam-tv-box"}
TIERS = {"auto", "gpu", "cpu"} TIERS = {"auto", "gpu", "cpu"}
ARCHITECTURES = {"amd64", "arm64"} ARCHITECTURES = {"amd64", "arm64"}
# Which GPU is in a steam-tv-box. Not detected: installing the NVIDIA driver on an
# AMD box breaks it, and the build has no way to see hardware it is not running on.
GPU_VENDORS = {"amd", "intel", "nvidia"}
# Tokens the validator insists on, and the enable-flag that makes each one required. # Tokens the validator insists on, and the enable-flag that makes each one required.
# A token for a disabled service is not an error — you shouldn't have to invent a # A token for a disabled service is not an error — you shouldn't have to invent a
@ -507,6 +510,32 @@ def validate_kiosks(cfg: dict, rep: Report) -> None:
if not kiosk.get("kiosk_username"): if not kiosk.get("kiosk_username"):
rep.error(f"{where}.kiosk_username", "missing") rep.error(f"{where}.kiosk_username", "missing")
# A per-kiosk Debian release, overriding household.debian_release. Exists for
# the steam-tv-box, whose Mesa version is the one thing on that machine that
# cannot be fixed later by editing a config file — see
# tools/build-steam-tv-box-iso.sh. Allowed on any kiosk; only checked for shape
# here, because whether a given release exists is a question for the mirror.
release = kiosk.get("debian_release")
if release is not None and (not isinstance(release, str) or not release.strip()):
rep.error(f"{where}.debian_release",
f"must be a Debian suite name like 'bookworm' or 'trixie', got {release!r}")
if ktype == "steam-tv-box":
vendor = kiosk.get("gpu_vendor")
if vendor not in GPU_VENDORS:
rep.error(f"{where}.gpu_vendor",
f"a steam-tv-box must say which GPU it has — one of "
f"{sorted(GPU_VENDORS)}, got {vendor!r}. This is not detected "
"because installing the NVIDIA driver on an AMD box breaks it.")
if str(kiosk.get("debian_release") or "").strip() == "bookworm":
rep.warn(f"{where}.debian_release",
"bookworm's Mesa is 22.3, which is old for a machine whose job is "
"running games locally — 'trixie' is the intended release for this type")
if not kiosk.get("enable_installer"):
rep.warn(f"{where}.enable_installer",
"false means this image boots live, so a Steam library would live in "
"RAM and vanish on reboot — a real gaming box wants an installed system")
# Audio endpoints share the hostname namespace with kiosks: they are all devices on # Audio endpoints share the hostname namespace with kiosks: they are all devices on
# one network, and two of anything answering to the same name is the same problem # one network, and two of anything answering to the same name is the same problem
# regardless of what kind of device they are. # regardless of what kind of device they are.