diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index a6124991..92566504 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -39,6 +39,19 @@ const KIOSK_LAUNCHER: &str = const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service"; const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher"; +// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio +// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the +// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the +// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure). +// The router daemon handles routing + the ELD re-modeset nudge; this heal +// installs the packages, group membership, script and unit on deployed nodes. +const AUDIO_ROUTER: &str = + include_str!("../../../image-recipe/configs/archipelago-audio-router.sh"); +const AUDIO_SERVICE: &str = + include_str!("../../../image-recipe/configs/archipelago-audio-router.service"); +const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router"; +const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service"; + // Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs // write the identical file at build time (image-recipe/_archived/ // build-auto-installer-iso.sh); this heals already-deployed nodes via OTA. @@ -794,6 +807,78 @@ pub async fn ensure_kiosk_hardened() { } } +/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older +/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs +/// under the lingering user manager — no logind seat, so no udev ACLs on +/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race +/// nudge) installed and current. No-op on nodes without the kiosk — audio +/// only matters where media plays on an attached display. +pub async fn ensure_audio_stack() { + if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() { + return; // no kiosk → no display audio to route + } + + // Package install runs via systemd-run (host_sudo), outside the service + // sandbox — /usr and the dpkg database are read-only in our namespace. + if fs::metadata("/usr/bin/pactl").await.is_err() { + info!("audio: PipeWire stack missing — installing packages"); + let _ = host_sudo(&["apt-get", "update", "-qq"]).await; + match host_sudo(&[ + "apt-get", + "install", + "-y", + "-qq", + "--no-install-recommends", + "pipewire", + "pipewire-pulse", + "pipewire-alsa", + "wireplumber", + "alsa-utils", + ]) + .await + { + Ok(s) if s.success() => info!("audio: PipeWire stack installed"), + Ok(s) => { + warn!("audio: package install exited with {} — will retry next start", s); + return; + } + Err(e) => { + warn!("audio: package install failed: {:#} — will retry next start", e); + return; + } + } + } + + let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await; + + let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err(); + let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER) + .await + .unwrap_or(false); + if script_changed { + let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await; + } + let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE) + .await + .unwrap_or(false); + + if script_changed || unit_changed { + if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await { + warn!("audio: daemon-reload failed: {:#}", e); + } + } + if unit_was_missing { + // First install on this node — bring it up now and on every boot. + let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await; + info!("audio: router installed and enabled (HDMI routing + ELD heal)"); + } else if script_changed || unit_changed { + // Content update: restart only if it's running — never re-enable a + // unit an operator deliberately disabled. + let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await; + info!("audio: router updated"); + } +} + /// Patch the nginx site config to add missing backend proxy blocks. Older ISO /// configs shipped individual per-endpoint `location` blocks, so missing /// endpoints silently fell through to the SPA `index.html` and the frontend diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 1da85d94..60260786 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -386,6 +386,15 @@ async fn main() -> Result<()> { // flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed. tokio::spawn(bootstrap::ensure_kiosk_hardened()); + // HDMI audio: install the PipeWire stack + audio-router daemon on kiosk + // nodes (older ISOs shipped no audio stack; the router also heals the + // boot-time ELD race that leaves HDMI silently unavailable). + tokio::spawn(bootstrap::ensure_audio_stack()); + + // Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when + // DHCP renumbering strands them — HA never re-resolves on its own. + tokio::spawn(api::rpc::wyoming_satellite_keeper()); + // Spawn periodic container snapshot (for crash recovery) crash_recovery::spawn_snapshot_task(config.data_dir.clone()); diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index ae94eef8..a71931f2 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -382,6 +382,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install xorg \ xdotool \ chromium \ + pipewire \ + pipewire-pulse \ + pipewire-alsa \ + wireplumber \ + alsa-utils \ unclutter \ fonts-liberation \ fonts-noto-color-emoji \ @@ -420,7 +425,10 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen # Create archipelago user with password "archipelago" -RUN useradd -m -s /bin/bash -G sudo,dialout archipelago && \ +# audio group: PipeWire runs under the lingering user manager (no logind seat +# session), so udev's seat ACLs on /dev/snd never apply — group access is the +# only way the kiosk's audio can open the hardware. +RUN useradd -m -s /bin/bash -G sudo,dialout,audio archipelago && \ echo "archipelago:archipelago" | chpasswd && \ echo "root:archipelago" | chpasswd && \ echo "archipelago ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/archipelago @@ -2876,6 +2884,35 @@ fi echo "KIOSKSVC" } >> "$ARCH_DIR/auto-install.sh" +# Audio router: same splice-from-configs/ pattern as the kiosk (single source +# of truth, also embedded in the binary for bootstrap self-heal). Keeps HDMI +# audio working: profile/default-sink routing + the ELD boot-race re-modeset. +AUDIO_ROUTER_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.sh" +AUDIO_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.service" +for _audio_src in "$AUDIO_ROUTER_SRC" "$AUDIO_SERVICE_SRC"; do + if [ ! -f "$_audio_src" ]; then + echo "ERROR: audio config file missing: $_audio_src" >&2 + exit 1 + fi +done +if grep -qx 'AUDIOROUTER' "$AUDIO_ROUTER_SRC" || grep -qx 'AUDIOSVC' "$AUDIO_SERVICE_SRC"; then + echo "ERROR: audio config contains a reserved heredoc terminator line (AUDIOROUTER/AUDIOSVC)" >&2 + exit 1 +fi +{ + echo "" + echo "# Audio router — HDMI profile/default-sink follow + ELD boot-race heal." + echo "# Payloads spliced at ISO-build time from image-recipe/configs/ (source of truth)." + echo "cat > /mnt/target/usr/local/bin/archipelago-audio-router <<'AUDIOROUTER'" + cat "$AUDIO_ROUTER_SRC" + echo "AUDIOROUTER" + echo "chmod +x /mnt/target/usr/local/bin/archipelago-audio-router" + echo "" + echo "cat > /mnt/target/etc/systemd/system/archipelago-audio-router.service <<'AUDIOSVC'" + cat "$AUDIO_SERVICE_SRC" + echo "AUDIOSVC" +} >> "$ARCH_DIR/auto-install.sh" + cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT' # Toggle script: sudo archipelago-kiosk enable|disable|status @@ -3255,6 +3292,7 @@ chroot /mnt/target systemctl enable archipelago-first-boot-secrets.service 2>/de chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true +chroot /mnt/target systemctl enable archipelago-audio-router.service 2>/dev/null || true chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true # Enable claude-api-proxy (create symlink manually — chroot systemctl can fail) chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \ diff --git a/image-recipe/configs/archipelago-audio-router.service b/image-recipe/configs/archipelago-audio-router.service new file mode 100644 index 00000000..fc1408c5 --- /dev/null +++ b/image-recipe/configs/archipelago-audio-router.service @@ -0,0 +1,20 @@ +[Unit] +Description=Archipelago audio router (HDMI hot-plug follow + ELD boot-race heal) +# Talks to the archipelago user's PipeWire (running under the lingering user +# manager) and pokes the kiosk X server for the ELD re-modeset nudge; start +# after both are plausibly up. Missing/inactive units here are harmless. +After=user@1000.service archipelago-kiosk.service +Wants=user@1000.service +ConditionPathExists=/usr/local/bin/archipelago-audio-router + +[Service] +Type=simple +User=archipelago +ExecStart=/usr/local/bin/archipelago-audio-router +Restart=always +RestartSec=10 +# Polls a few pactl calls every 5s — keep it invisible to the scheduler. +Nice=10 + +[Install] +WantedBy=multi-user.target diff --git a/image-recipe/configs/archipelago-audio-router.sh b/image-recipe/configs/archipelago-audio-router.sh new file mode 100644 index 00000000..8b4bf5b3 --- /dev/null +++ b/image-recipe/configs/archipelago-audio-router.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# Archipelago audio router — keep audio flowing out of the display the user is +# actually looking at. +# +# The kiosk's Chromium plays through PipeWire-Pulse, but WirePlumber's stock +# profile priorities rank the laptop's analog output above HDMI, so a node +# driving a TV plays video sound out of its own tiny speakers (or nowhere). +# ALSA jack detection (ELD) tells us when an HDMI/DP sink has a listening +# monitor; this daemon polls that via the card's profile availability and: +# +# - switches the card to the best *available* HDMI stereo profile (the +# "+input:analog-stereo" combined variant when offered, so the mic keeps +# working), and back to analog when HDMI is unplugged; +# - keeps the default sink pointed at the routed output and migrates any +# live streams so playback follows a hot-plug without a page reload; +# - unmutes the routed sink (HDMI additionally forced to 100% — the TV owns +# the real volume control; analog volume is left where the user set it). +# +# Runs as the archipelago user (systemd system unit with User=archipelago), +# talks only to the user-session PipeWire; polling is a few pactl calls every +# 5s — negligible. Surround profiles are deliberately ignored: stereo is the +# lowest-common-denominator every TV decodes. + +# - re-modesets an external output once when it is connected but no ELD +# reports a monitor: the kiosk's boot-time Xorg modeset can beat the +# i915→HDA audio-component bind, the ELD notify is lost, and every HDMI +# profile stays "available: no" forever (no sound, no error). One +# off/on cycle re-delivers the ELD (verified on Framework PT / LG TV). + +RUNTIME_DIR="/run/user/$(id -u)" +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNTIME_DIR}" +export DISPLAY="${DISPLAY:-:0}" + +# The user manager socket-activates pipewire, but after a live package install +# (bootstrap self-heal) nothing has poked it yet — start best-effort. +systemctl --user start pipewire.socket pipewire-pulse.socket 2>/dev/null || true +systemctl --user start wireplumber.service 2>/dev/null || true + +LAST_SINK="" +NUDGED_OUTPUTS="" + +# Re-deliver a lost ELD by cycling the connected external output once. Guarded: +# only when X answers, only when NO ELD anywhere reports a monitor, and only +# once per connector while it stays connected (a display with no audio support +# never produces an ELD — without the flag we would blank it every pass). +eld_nudge_once() { + # Any ELD already valid → audio path is live; reset the nudge memory. + if grep -q "monitor_present[[:space:]]*1" /proc/asound/card*/eld* 2>/dev/null; then + NUDGED_OUTPUTS="" + return 0 + fi + + local xrandr_out conn name output mode + xrandr_out=$(xrandr --query 2>/dev/null) || return 0 + + for conn in /sys/class/drm/card*-*/status; do + [ -e "$conn" ] || continue + [ "$(cat "$conn" 2>/dev/null)" = "connected" ] || continue + name=${conn%/status}; name=${name##*/card?-} + case "$name" in eDP*|LVDS*) continue ;; esac + case " $NUDGED_OUTPUTS " in *" $name "*) continue ;; esac + + # DRM connector names match the modesetting driver's output names. + output=$(printf '%s\n' "$xrandr_out" | awk -v n="$name" '$1 == n && $2 == "connected" {print $1; exit}') + [ -n "$output" ] || continue + + # Keep the mode the kiosk chose (it may have capped a 4K panel); + # --auto only as a fallback. + mode=$(printf '%s\n' "$xrandr_out" | awk -v out="$output" ' + $1 == out { active = 1; next } + active && /^[[:space:]]+[0-9]+x[0-9]+/ { if ($0 ~ /\*/) { print $1; exit } } + active && /^[^[:space:]]/ { active = 0 }') + + NUDGED_OUTPUTS="$NUDGED_OUTPUTS $name" + xrandr --output "$output" --off 2>/dev/null || true + sleep 1 + if [ -n "$mode" ]; then + xrandr --output "$output" --mode "$mode" 2>/dev/null \ + || xrandr --output "$output" --auto 2>/dev/null || true + else + xrandr --output "$output" --auto 2>/dev/null || true + fi + sleep 2 + done +} + +route_once() { + local cards_dump card active want sink default_sink + cards_dump=$(pactl list cards 2>/dev/null) || return 0 + [ -n "$cards_dump" ] || return 0 + + # One line per card: "\t\t" + # wanted = highest-priority available output:hdmi-stereo* profile, else + # highest-priority available output:analog* profile. + while IFS=$'\t' read -r card active want; do + [ -n "$card" ] && [ -n "$want" ] || continue + if [ "$active" != "$want" ]; then + pactl set-card-profile "$card" "$want" 2>/dev/null || true + fi + done < <(printf '%s\n' "$cards_dump" | awk ' + function flush() { + if (card != "") print card "\t" active "\t" (hdmi != "" ? hdmi : analog) + card=""; active=""; hdmi=""; analog=""; hdmi_p=-1; analog_p=-1 + } + /^Card #/ { flush() } + /^\tName: / { card=$2 } + /^\t\toutput:/ { + line=$0; sub(/^\t\t/, "", line) + prof=line; sub(/: .*/, "", prof) + prio=0 + if (match(line, /priority: [0-9]+/)) prio=substr(line, RSTART+10, RLENGTH-10)+0 + avail = (line ~ /available: yes/ || line ~ /availability unknown/) + if (!avail) next + if (prof ~ /^output:hdmi-stereo/) { if (prio > hdmi_p) { hdmi=prof; hdmi_p=prio } } + else if (prof ~ /^output:analog/) { if (prio > analog_p) { analog=prof; analog_p=prio } } + } + /^\tActive Profile: / { active=$3 } + END { flush() } + ') + + # Point the default sink at HDMI when one exists, else the first sink. + sink=$(pactl list short sinks 2>/dev/null | awk '/hdmi/{print $2; exit}') + [ -n "$sink" ] || sink=$(pactl list short sinks 2>/dev/null | awk 'NR==1{print $2}') + [ -n "$sink" ] || return 0 + + default_sink=$(pactl get-default-sink 2>/dev/null) + if [ "$sink" != "$default_sink" ] || [ "$sink" != "$LAST_SINK" ]; then + pactl set-default-sink "$sink" 2>/dev/null || true + pactl set-sink-mute "$sink" 0 2>/dev/null || true + case "$sink" in + *hdmi*) pactl set-sink-volume "$sink" 100% 2>/dev/null || true ;; + esac + # Migrate live streams so playing audio follows the hot-plug. + pactl list short sink-inputs 2>/dev/null | while read -r id _; do + pactl move-sink-input "$id" "$sink" 2>/dev/null || true + done + LAST_SINK="$sink" + fi +} + +while true; do + eld_nudge_once + route_once + sleep 5 +done