feat: audio controls, reboot button, update lock, ONVIF refresh

- 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) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-26 16:57:41 +02:00
parent 55b11f2ffa
commit 5ce526eb33
No known key found for this signature in database
8 changed files with 304 additions and 6 deletions

165
kiosk/src/audio.rs Normal file
View file

@ -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<AudioOutput>,
}
#[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::<f32>() {
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
}

View file

@ -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,11 +269,19 @@ 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.
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> {
let vk = VerifyingKey::from_public_key_pem(public_key_pem)

View file

@ -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<String>,
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".

View file

@ -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()

View file

@ -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<Wo
if std::env::var("BF_ENABLE_OS_OTA").as_deref() != Ok("1") {
return;
}
let Ok(_lock) = OS_UPDATE_LOCK.try_lock() else {
info!("os-update: another update already in progress, skipping");
return;
};
let Some(info) = os_update::check(server_url, kiosk_key) else {
return;
};
@ -579,6 +602,10 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sen
if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") {
return;
}
let Ok(_lock) = FIRMWARE_LOCK.try_lock() else {
info!("firmware: another update already in progress, skipping");
return;
};
let current = option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
let Some(info) = firmware::check(server_url, kiosk_key, current) else {
return;

View file

@ -169,6 +169,8 @@ async fn handle_message(
if let Some(layout_id) = layout_id {
let _ = tx.send(ServerMsg::SwitchLayout { display_id, layout_id });
}
} else if text.contains("\"type\":\"reboot\"") {
let _ = tx.send(ServerMsg::Reboot);
} else if text.contains("\"type\":\"firmware_check\"") {
let _ = tx.send(ServerMsg::FirmwareCheck);
} else if text.contains("\"type\":\"os_check\"") {
@ -183,6 +185,20 @@ async fn handle_message(
return;
};
let _ = tx.send(ServerMsg::Fan(pwm));
} else if text.contains("\"type\":\"volume-set\"") {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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\"") {

View file

@ -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<Record<string, string>>(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

View file

@ -1915,6 +1915,56 @@ export function KioskEditPage(props: KioskEditProps) {
}}
>Full</button>
</div>
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px solid #f0f0f0; display:flex; gap:0.5rem; align-items:center">
<div style="font-size:0.8rem; font-weight:600">Power</div>
<button type="button" class="btn btn-sm btn-ghost" style="color:#c00" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/reboot`,
"hx-swap": "none",
"hx-confirm": "Reboot this kiosk? It will be offline for ~30 seconds.",
}}>Reboot</button>
</div>
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px solid #f0f0f0">
<div style="font-size:0.8rem; font-weight:600; margin-bottom:0.5rem">Audio</div>
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap">
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "0" }),
"hx-swap": "none",
}}>0%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "25" }),
"hx-swap": "none",
}}>25%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "50" }),
"hx-swap": "none",
}}>50%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "75" }),
"hx-swap": "none",
}}>75%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "100" }),
"hx-swap": "none",
}}>100%</button>
<span style="color:#999">|</span>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ action: "mute" }),
"hx-swap": "none",
}}>Mute</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ action: "unmute" }),
"hx-swap": "none",
}}>Unmute</button>
</div>
</div>
</div>
</div>