#!/usr/bin/env bash # # Smart Home Thin-Client ISO Builder # Target: builds a Debian 12 (Bookworm) live ISO on a Debian/Ubuntu build machine # # Drives `lb config && lb build` over hosts/thin-client/live-build/ to produce the # Sway kiosk media-station image described in docs/project-plan.md Phase 11: # - greetd autologin straight into a kiosk Sway session (no greeter UI) # - wayvnc for interactive remote view/control (the confirmed replacement for RDP) # - thinclient-agent (Python, systemd) — HA MQTT-discovery entities + swaymsg control # - Firefox kiosk workspace pointed at digest-web (Phase 12) # - mpv + mpv-mpris, playerctl, PipeWire audio # - Steam Link via Flathub, under Xwayland # - wyoming-satellite + openWakeWord (OPT-IN, mic-enabled rooms only) # # 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. # # Run as: sudo ./build-thin-client-iso.sh # # EDIT THE VARIABLES BELOW BEFORE RUNNING. set -euo pipefail # --------------------------------------------------------------------------- # CONFIGURATION — edit these before running # --------------------------------------------------------------------------- DEBIAN_RELEASE="bookworm" # Matches the container host's OS KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around IMAGE_HOSTNAME="thin-client" # Hostname baked into the image THINCLIENT_NAME="Living room thin client" # Friendly name shown on the HA device # Console + Sway keyboard layout. This IS the "installer choice" in this image's # architecture: there is no interactive keymap prompt in the normal live-boot path (see # ENABLE_INSTALLER below for the one case where a real prompt exists), so a per-image # build variable is what stands in for it — build one ISO per keyboard layout you need. KEYBOARD_LAYOUT="de" # xkb layout name (`localectl list-x11-keymap-layouts`) ENABLE_STEAM_LINK="true" # Install the Steam Link flatpak from Flathub ENABLE_INSTALLER="false" # "true" adds a debian-installer to the ISO (install to disk) # --- Voice satellite — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY ----------- # Per docs/project-plan.md Phase 11.8, only the specific rooms that have a microphone # run wyoming-satellite. This is therefore a PER-IMAGE decision, not a universal one: # build one ISO with this "false" for the silent rooms, and a second ISO with it "true" # for the mic-enabled ones. The exact mic-enabled room list is still an open decision # (project-plan §4 #6) — do not flip this on until it has been chosen. ENABLE_VOICE_SATELLITE="false" VOICE_SATELLITE_NAME="Living room" # Shown in HA's Wyoming/Assist device list VOICE_WAKE_WORD="ok_nabu" # openWakeWord model name # --- Camera gesture control — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY ------- # Open hand moves the pointer, fist clicks. Same per-image, per-room logic as the mic # above: only build this into the image of a room that is actually getting a webcam. # This flag only decides whether MediaPipe and its ~400 MB dependency tree are INSTALLED. # Whether the camera is ever OPENED is a second, separate gate — the "enabled" flag in # configs/gesture-control/gesture-config.json, which is false by default even here, so a # gesture-capable image still ships with the camera off. See the privacy section in # hosts/thin-client/README.md. ENABLE_GESTURE_CONTROL="false" # --- Where the thin client talks to ---------------------------------------- # The container host from Phase 1 (Mosquitto + Home Assistant). Fill in its LAN IP. MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto MQTT_BROKER_PORT="1883" MQTT_USERNAME="" # Leave empty while Mosquitto runs allow_anonymous MQTT_PASSWORD="" # Never commit a real value here — see README HA_URL="http://192.168.1.10:8123" # <-- EDIT: Home Assistant URL # digest-web is the static-file service that Phase 12's digest-engine renders into. # It is built by a separate workstream; until it is deployed this is just a placeholder # and the kiosk Firefox workspace will show a connection error (harmless — the session # must still come up with the container host powered off, per Phase 11.10). DIGEST_WEB_URL="http://192.168.1.10:8081" # <-- EDIT once digest-web is deployed # admin-web (Phase 13) — the sys-admin-llm's on-demand display surface. Same # placeholder handling as DIGEST_WEB_URL above: harmless until deployed, the admin # workspace just won't have anything to open yet (and unlike the digest workspace it # is never auto-launched at session start anyway — see configs/sway/config). ADMIN_WEB_URL="http://192.168.1.10:8094" # <-- EDIT once admin-web is deployed # The container host's gallery-smb share (ENABLE_GALLERY_SMB in # setup-container-host.sh), used by the idle-timeout slideshow. Just the host — # idle-gallery.sh always mounts the fixed "gallery" share name. GALLERY_SMB_HOST="192.168.1.10" # <-- EDIT: container-host IP running gallery-smb # Optional: an SSH public key to bake into the kiosk account for out-of-band admin. # The image ships with password auth disabled, so without this the only admin path is # the local console or wayvnc. SSH_AUTHORIZED_KEY="" # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" THIN_CLIENT_DIR="$(dirname "$SCRIPT_DIR")" CONFIGS_DIR="${THIN_CLIENT_DIR}/configs" AGENT_DIR="${THIN_CLIENT_DIR}/agent" LIVE_BUILD_DIR="${THIN_CLIENT_DIR}/live-build" INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot" PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/thin-client.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 if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then echo "Warning: MQTT_BROKER_HOST is still the placeholder IP." echo " Edit it at the top of this script to your container host's real LAN address," echo " or the thin client won't show up as Home Assistant entities." fi if [[ "$DIGEST_WEB_URL" == "http://192.168.1.10:8081" ]]; then echo "Warning: DIGEST_WEB_URL is still the placeholder." echo " Fill it in once Phase 12's digest-web service is deployed. The image builds" echo " and boots fine without it — the digest workspace just won't load anything." fi if [[ "$ADMIN_WEB_URL" == "http://192.168.1.10:8094" ]]; then echo "Warning: ADMIN_WEB_URL is still the placeholder." echo " Fill it in once Phase 13's admin-web service is deployed. The image builds" echo " and boots fine without it — 'Show admin canvas' just won't load anything." fi if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room" echo " (\"${VOICE_SATELLITE_NAME}\"). Do not flash it to a room without a microphone." fi if [[ "$ENABLE_GESTURE_CONTROL" == "true" ]]; then echo "Note: ENABLE_GESTURE_CONTROL=true — this image is for a CAMERA-ENABLED room." echo " The camera still stays off until \"enabled\" is set to true in" echo " /var/lib/thinclient-agent/gesture-config.json on the booted machine." fi echo echo "=== Smart Home Thin-Client ISO Builder ===" echo "Debian release : $DEBIAN_RELEASE" echo "Kiosk user : $KIOSK_USERNAME" echo "Image hostname : $IMAGE_HOSTNAME" echo "MQTT broker : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}" echo "Home Assistant : $HA_URL" echo "digest-web : $DIGEST_WEB_URL" echo "admin-web : $ADMIN_WEB_URL" echo "Steam Link : $ENABLE_STEAM_LINK" echo "Voice satellite : $ENABLE_VOICE_SATELLITE" echo "Gesture control : $ENABLE_GESTURE_CONTROL" 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/thinclient-agent" \ "$INCLUDES/etc/firefox/policies" \ "$INCLUDES/etc/thinclient-firefox" \ "$INCLUDES/usr/local/bin" \ "$INCLUDES/opt/thinclient-agent" \ "$INCLUDES/opt/gesture-control" \ "$INCLUDES/home/${KIOSK_USERNAME}/.config/sway" \ "$INCLUDES/home/${KIOSK_USERNAME}/.config/eww" \ "$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/thinclient-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}/thinclient-agent.service" "$INCLUDES/opt/thinclient-agent/thinclient-agent.service" install -m 0755 "${CONFIGS_DIR}/greetd/kiosk-session" "$INCLUDES/usr/local/bin/kiosk-session" install -m 0755 "${CONFIGS_DIR}/sway/digest-browser" "$INCLUDES/usr/local/bin/digest-browser" install -m 0755 "${CONFIGS_DIR}/sway/admin-browser" "$INCLUDES/usr/local/bin/admin-browser" install -m 0755 "${CONFIGS_DIR}/sway/capture-view" "$INCLUDES/usr/local/bin/capture-view" install -m 0755 "${CONFIGS_DIR}/wayvnc/start-wayvnc" "$INCLUDES/usr/local/bin/start-wayvnc" 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, and the maintenance shell (foot, under # Sway) already gets the Sway layout either way. /etc/default/keyboard is what # console-setup and most desktop layers read for the initial layout. mkdir -p "$INCLUDES/etc/default" cat > "$INCLUDES/etc/default/keyboard" < "$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 thinclient-agent, the sway session wrapper, and hooks # --------------------------------------------------------------------------- echo "--- Writing /etc/thinclient-agent/config.env into includes.chroot ---" cat > "$INCLUDES/etc/thinclient-agent/config.env" <