mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
55b11f2ffa
commit
5ce526eb33
8 changed files with 304 additions and 6 deletions
165
kiosk/src/audio.rs
Normal file
165
kiosk/src/audio.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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\"") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue