From 5ce526eb339b4a4b0ac9a6fc52895fc98be97c06 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Tue, 26 May 2026 16:57:41 +0200 Subject: [PATCH] feat: audio controls, reboot button, update lock, ONVIF refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Audio: kiosk/src/audio.rs — PipeWire/ALSA volume, mute, output selection. WS commands volume-set/volume-mute/audio-output. Heartbeat reports audio state. Admin UI volume buttons + mute. - Reboot: admin button with confirmation, WS reboot command, kiosk runs systemctl reboot. - Firmware update now reboots (not exit) to clear state fully. - Update lock: FIRMWARE_LOCK + OS_UPDATE_LOCK mutexes prevent concurrent update attempts from heartbeat + WS paths. - ONVIF: auto-refresh stale/failed subs (>24h or failed state), mark_event_received with proper epoch timestamp, parse Key section for PlateNumber. Co-Authored-By: Claude Opus 4.6 (1M context) --- kiosk/src/audio.rs | 165 ++++++++++++++++++ kiosk/src/firmware.rs | 20 ++- kiosk/src/main.rs | 6 + kiosk/src/server.rs | 1 + kiosk/src/ui.rs | 29 ++- kiosk/src/ws_client.rs | 16 ++ .../service-admin-http/routes-admin.ts | 23 +++ server/src/web-templates/admin-pages.tsx | 50 ++++++ 8 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 kiosk/src/audio.rs diff --git a/kiosk/src/audio.rs b/kiosk/src/audio.rs new file mode 100644 index 0000000..afc40e0 --- /dev/null +++ b/kiosk/src/audio.rs @@ -0,0 +1,165 @@ +//! Audio output control — volume, mute, output selection. +//! +//! Tries PipeWire (`wpctl`) first, falls back to ALSA (`amixer`). +//! Pi 5 with Debian Bookworm uses PipeWire by default under cage. + +use std::process::Command; +use tracing::{info, warn}; + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct AudioState { + pub volume_percent: u32, + pub muted: bool, + pub output_name: String, + pub available_outputs: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AudioOutput { + pub id: String, + pub name: String, + pub is_default: bool, +} + +pub fn get_state() -> AudioState { + if has_wpctl() { + get_state_pipewire() + } else { + get_state_alsa() + } +} + +pub fn set_volume(percent: u32) -> bool { + let pct = percent.min(100); + info!("audio: set volume {pct}%"); + if has_wpctl() { + run_ok("wpctl", &["set-volume", "@DEFAULT_AUDIO_SINK@", &format!("{:.2}", pct as f32 / 100.0)]) + } else { + run_ok("amixer", &["sset", "Master", &format!("{pct}%")]) + } +} + +pub fn set_mute(muted: bool) -> bool { + info!("audio: set mute={muted}"); + if has_wpctl() { + let val = if muted { "1" } else { "0" }; + run_ok("wpctl", &["set-mute", "@DEFAULT_AUDIO_SINK@", val]) + } else { + let val = if muted { "mute" } else { "unmute" }; + run_ok("amixer", &["sset", "Master", val]) + } +} + +pub fn set_output(id: &str) -> bool { + info!("audio: set output={id}"); + if has_wpctl() { + run_ok("wpctl", &["set-default", id]) + } else { + false + } +} + +fn has_wpctl() -> bool { + Command::new("wpctl").arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn run_ok(cmd: &str, args: &[&str]) -> bool { + match Command::new(cmd).args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + { + Ok(s) => s.success(), + Err(e) => { warn!("audio: {cmd} failed: {e}"); false } + } +} + +fn get_state_pipewire() -> AudioState { + let mut state = AudioState::default(); + + if let Ok(out) = Command::new("wpctl").args(["get-volume", "@DEFAULT_AUDIO_SINK@"]).output() { + let text = String::from_utf8_lossy(&out.stdout); + // "Volume: 0.75" or "Volume: 0.75 [MUTED]" + state.muted = text.contains("[MUTED]"); + if let Some(vol_str) = text.split_whitespace().nth(1) { + if let Ok(v) = vol_str.parse::() { + state.volume_percent = (v * 100.0).round() as u32; + } + } + } + + if let Ok(out) = Command::new("wpctl").args(["status"]).output() { + let text = String::from_utf8_lossy(&out.stdout); + let mut in_sinks = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.contains("Audio/Sink") || trimmed.contains("Sinks:") { + in_sinks = true; + continue; + } + if in_sinks && trimmed.is_empty() { + break; + } + if in_sinks { + let is_default = trimmed.contains('*'); + let clean = trimmed.trim_start_matches(['│', '├', '└', '─', ' ', '*', '·']); + let parts: Vec<&str> = clean.splitn(2, '.').collect(); + if parts.len() == 2 { + let id = parts[0].trim().to_string(); + let name = parts[1].trim().trim_start_matches(' ').to_string(); + if is_default { + state.output_name = name.clone(); + } + state.available_outputs.push(AudioOutput { id, name, is_default }); + } + } + } + } + + state +} + +fn get_state_alsa() -> AudioState { + let mut state = AudioState::default(); + state.output_name = "Master".to_string(); + + if let Ok(out) = Command::new("amixer").args(["sget", "Master"]).output() { + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + let trimmed = line.trim(); + // "Mono: Playback 32768 [50%] [on]" or "[off]" + if trimmed.contains('[') && trimmed.contains('%') { + if let Some(pct_str) = trimmed.split('[').nth(1) { + if let Some(pct) = pct_str.strip_suffix("%]") { + state.volume_percent = pct.parse().unwrap_or(0); + } + } + state.muted = trimmed.contains("[off]"); + break; + } + } + } + + if let Ok(out) = Command::new("aplay").args(["-l"]).output() { + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + if line.starts_with("card ") { + let name = line.split(':').nth(1).unwrap_or("").trim().to_string(); + let id = line.split_whitespace().nth(1).unwrap_or("0") + .trim_end_matches(':').to_string(); + state.available_outputs.push(AudioOutput { + id, + name, + is_default: state.available_outputs.is_empty(), + }); + } + } + } + + state +} diff --git a/kiosk/src/firmware.rs b/kiosk/src/firmware.rs index 4a0009c..26ed839 100644 --- a/kiosk/src/firmware.rs +++ b/kiosk/src/firmware.rs @@ -128,7 +128,9 @@ pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> { let _ = fs::rename(&bin, &prev_path); } fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?; - info!("preboot firmware: updated to {}, exiting for restart", info.version); + info!("preboot firmware: updated to {}, rebooting", info.version); + let _ = std::process::Command::new("systemctl").arg("reboot").status(); + std::thread::sleep(Duration::from_secs(30)); std::process::exit(0); } @@ -267,10 +269,18 @@ pub fn apply( .timeout(Duration::from_secs(5)) .send(); - on_progress("Restarting", 100); - info!("firmware: swap complete → exiting for systemd to relaunch"); - // systemd Restart=always picks up the new binary on next start. - std::process::exit(0); + on_progress("Rebooting", 100); + info!("firmware: swap complete → rebooting to pick up new binary"); + match std::process::Command::new("systemctl").arg("reboot").status() { + Ok(_) => { + std::thread::sleep(Duration::from_secs(30)); + std::process::exit(0); + } + Err(e) => { + info!("systemctl reboot failed: {e}, falling back to exit"); + std::process::exit(0); + } + } } fn verify_signature(public_key_pem: &str, sha256_hex: &str, sig_b64url: &str) -> Result<(), String> { diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index 9575c76..c690e49 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -1,4 +1,5 @@ mod at_rest; +mod audio; mod axiom; mod bundle; mod cec; @@ -27,6 +28,11 @@ pub enum ServerMsg { display_id: Option, layout_id: String, }, + /// Audio controls from admin. + VolumeSet(u32), + VolumeMute(bool), + AudioOutputSet(String), + Reboot, /// Server-pushed "go check for a firmware update now". FirmwareCheck, /// Server-pushed "go check for an OS update now". diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 1da1948..98fd779 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -514,6 +514,7 @@ pub fn heartbeat( "network_interfaces": network_interfaces, "onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(), "partitions": serde_json::to_value(&hw.partitions).unwrap_or_default(), + "audio": serde_json::to_value(crate::audio::get_state()).unwrap_or_default(), })) .timeout(Duration::from_secs(5)) .send() diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 16d00a9..c8a0165 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -1,10 +1,13 @@ use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::fs; -use std::sync::mpsc; +use std::sync::{mpsc, Mutex}; use std::time::{Duration, Instant}; use url::Url; +static FIRMWARE_LOCK: Mutex<()> = Mutex::new(()); +static OS_UPDATE_LOCK: Mutex<()> = Mutex::new(()); + use gtk4::prelude::*; use gtk4::{ self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture, @@ -267,6 +270,18 @@ fn activate(app: &Application) { } send_heartbeat_now(&server_for_reload, &key_for_reload); } + ServerMsg::VolumeSet(vol) => { + crate::audio::set_volume(vol); + send_heartbeat_now(&server_for_reload, &key_for_reload); + } + ServerMsg::VolumeMute(muted) => { + crate::audio::set_mute(muted); + send_heartbeat_now(&server_for_reload, &key_for_reload); + } + ServerMsg::AudioOutputSet(id) => { + crate::audio::set_output(&id); + send_heartbeat_now(&server_for_reload, &key_for_reload); + } ServerMsg::SwitchLayout { display_id, layout_id, @@ -276,6 +291,10 @@ fn activate(app: &Application) { layout_id, }); } + ServerMsg::Reboot => { + info!("reboot requested by admin"); + let _ = std::process::Command::new("systemctl").arg("reboot").status(); + } ServerMsg::FirmwareCheck => { maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload); } @@ -533,6 +552,10 @@ fn maybe_apply_os_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sender(text) else { return }; + if let Some(vol) = msg.get("volume").and_then(|v| v.as_u64()) { + let _ = tx.send(ServerMsg::VolumeSet(vol.min(100) as u32)); + } + } else if text.contains("\"type\":\"volume-mute\"") { + let Ok(msg) = serde_json::from_str::(text) else { return }; + let muted = msg.get("muted").and_then(|v| v.as_bool()).unwrap_or(true); + let _ = tx.send(ServerMsg::VolumeMute(muted)); + } else if text.contains("\"type\":\"audio-output\"") { + let Ok(msg) = serde_json::from_str::(text) else { return }; + if let Some(id) = msg.get("output_id").and_then(|v| v.as_str()) { + let _ = tx.send(ServerMsg::AudioOutputSet(id.to_string())); + } // ---- Journal streaming -------------------------------------------------- } else if text.contains("\"type\":\"journal-start\"") { diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index f742b5f..e4ac423 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -2193,6 +2193,29 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); + app.post("/admin/kiosks/:id/reboot", async (event) => { + const id = (getRouterParam(event, "id") ?? ""); + getCoordinator().sendToKiosk(id, { type: "reboot" }); + return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); + }); + + app.post("/admin/kiosks/:id/volume", async (event) => { + const id = (getRouterParam(event, "id") ?? ""); + const body = await readBody>(event); + const action = body?.["action"]; + if (action === "mute") { + getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: true }); + } else if (action === "unmute") { + getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: false }); + } else if (action === "output") { + getCoordinator().sendToKiosk(id, { type: "audio-output", output_id: body?.["output_id"] ?? "" }); + } else { + const vol = Math.max(0, Math.min(100, Number(body?.["volume"]) || 0)); + getCoordinator().sendToKiosk(id, { type: "volume-set", volume: vol }); + } + return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); + }); + // ---- JSON API (admin scope) — used by Node-RED bf-* nodes --------------- // // All payloads run through `stripSecrets` so credential-bearing fields diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 6036a2c..89a913b 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1915,6 +1915,56 @@ export function KioskEditPage(props: KioskEditProps) { }} >Full + +
+
Power
+ +
+
+
Audio
+
+ + + + + + | + + +
+