mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
Compare commits
10 commits
v0.0.124-d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a518fe17ea | ||
|
|
73dbd9b6bf | ||
|
|
c3bdcbce4c | ||
|
|
5ce526eb33 | ||
|
|
55b11f2ffa | ||
|
|
eb8abbdff9 | ||
|
|
8c59bb6b02 | ||
|
|
9eeddff680 | ||
|
|
38c78c0bb5 | ||
|
|
8381ed280e |
25 changed files with 1145 additions and 38 deletions
|
|
@ -1,2 +1,3 @@
|
||||||
d /run/betterframe 0755 bfkiosk bfkiosk -
|
d /run/betterframe 0755 bfkiosk bfkiosk -
|
||||||
d /var/lib/betterframe/kiosk 0755 bfkiosk bfkiosk -
|
d /var/lib/betterframe/kiosk 0755 bfkiosk bfkiosk -
|
||||||
|
d /var/lib/betterframe/tmp 0755 bfkiosk bfkiosk -
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -69,7 +69,7 @@ impl KioskBundle {
|
||||||
height_px: d.height_px,
|
height_px: d.height_px,
|
||||||
idle_timeout_seconds: d.idle_timeout_seconds,
|
idle_timeout_seconds: d.idle_timeout_seconds,
|
||||||
sleep_timeout_seconds: d.sleep_timeout_seconds,
|
sleep_timeout_seconds: d.sleep_timeout_seconds,
|
||||||
default_layout_id: d.default_layout_id,
|
default_layout_id: d.default_layout_id.clone(),
|
||||||
layouts: self.layouts.clone(),
|
layouts: self.layouts.clone(),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,9 @@ pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> {
|
||||||
let _ = fs::rename(&bin, &prev_path);
|
let _ = fs::rename(&bin, &prev_path);
|
||||||
}
|
}
|
||||||
fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?;
|
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);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,10 +269,18 @@ pub fn apply(
|
||||||
.timeout(Duration::from_secs(5))
|
.timeout(Duration::from_secs(5))
|
||||||
.send();
|
.send();
|
||||||
|
|
||||||
on_progress("Restarting", 100);
|
on_progress("Rebooting", 100);
|
||||||
info!("firmware: swap complete → exiting for systemd to relaunch");
|
info!("firmware: swap complete → rebooting to pick up new binary");
|
||||||
// systemd Restart=always picks up the new binary on next start.
|
match std::process::Command::new("systemctl").arg("reboot").status() {
|
||||||
|
Ok(_) => {
|
||||||
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
std::process::exit(0);
|
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> {
|
fn verify_signature(public_key_pem: &str, sha256_hex: &str, sig_b64url: &str) -> Result<(), String> {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod at_rest;
|
mod at_rest;
|
||||||
|
mod audio;
|
||||||
mod axiom;
|
mod axiom;
|
||||||
mod bundle;
|
mod bundle;
|
||||||
mod cec;
|
mod cec;
|
||||||
|
|
@ -27,6 +28,11 @@ pub enum ServerMsg {
|
||||||
display_id: Option<String>,
|
display_id: Option<String>,
|
||||||
layout_id: String,
|
layout_id: String,
|
||||||
},
|
},
|
||||||
|
/// Audio controls from admin.
|
||||||
|
VolumeSet(u32),
|
||||||
|
VolumeMute(bool),
|
||||||
|
AudioOutputSet(String),
|
||||||
|
Reboot,
|
||||||
/// Server-pushed "go check for a firmware update now".
|
/// Server-pushed "go check for a firmware update now".
|
||||||
FirmwareCheck,
|
FirmwareCheck,
|
||||||
/// Server-pushed "go check for an OS update now".
|
/// Server-pushed "go check for an OS update now".
|
||||||
|
|
|
||||||
|
|
@ -38,32 +38,73 @@ static STATUS: Mutex<Option<HashMap<String, SubStatus>>> = Mutex::new(None);
|
||||||
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
#[derive(Clone, serde::Serialize)]
|
||||||
pub struct SubStatus {
|
pub struct SubStatus {
|
||||||
pub state: &'static str, // "subscribing", "active", "failed", "stopped"
|
pub state: &'static str,
|
||||||
pub last_event_at: Option<String>,
|
pub last_event_at: Option<String>,
|
||||||
|
pub subscribed_at: Option<String>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn epoch_now() -> String {
|
||||||
|
let secs = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!("{secs}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn epoch_now_secs() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_status(cam_id: &str, state: &'static str, error: Option<String>) {
|
fn set_status(cam_id: &str, state: &'static str, error: Option<String>) {
|
||||||
let mut map = STATUS.lock().unwrap();
|
let mut map = STATUS.lock().unwrap();
|
||||||
let map = map.get_or_insert_with(HashMap::new);
|
let map = map.get_or_insert_with(HashMap::new);
|
||||||
let entry = map.entry(cam_id.to_string()).or_insert_with(|| SubStatus {
|
let entry = map.entry(cam_id.to_string()).or_insert_with(|| SubStatus {
|
||||||
state: "subscribing",
|
state: "subscribing",
|
||||||
last_event_at: None,
|
last_event_at: None,
|
||||||
|
subscribed_at: None,
|
||||||
error: None,
|
error: None,
|
||||||
});
|
});
|
||||||
entry.state = state;
|
entry.state = state;
|
||||||
entry.error = error;
|
entry.error = error;
|
||||||
|
if state == "active" {
|
||||||
|
entry.subscribed_at = Some(epoch_now());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_event_received(cam_id: &str) {
|
fn mark_event_received(cam_id: &str) {
|
||||||
let mut map = STATUS.lock().unwrap();
|
let mut map = STATUS.lock().unwrap();
|
||||||
if let Some(map) = map.as_mut() {
|
if let Some(map) = map.as_mut() {
|
||||||
if let Some(entry) = map.get_mut(cam_id) {
|
if let Some(entry) = map.get_mut(cam_id) {
|
||||||
entry.last_event_at = Some(crate::os_update::current_os_version_public()); // reuse timestamp helper... actually just use epoch
|
entry.last_event_at = Some(epoch_now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if any subscription needs a forced refresh (>24h since subscribe,
|
||||||
|
/// or currently in failed/stopped state).
|
||||||
|
pub fn needs_refresh() -> bool {
|
||||||
|
let map = STATUS.lock().unwrap();
|
||||||
|
let Some(map) = map.as_ref() else { return false };
|
||||||
|
let now = epoch_now_secs();
|
||||||
|
for status in map.values() {
|
||||||
|
if status.state == "failed" || status.state == "stopped" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(ref sub_at) = status.subscribed_at {
|
||||||
|
if let Ok(ts) = sub_at.parse::<u64>() {
|
||||||
|
if now.saturating_sub(ts) > 24 * 3600 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Get current subscription statuses for all cameras. Used by heartbeat.
|
/// Get current subscription statuses for all cameras. Used by heartbeat.
|
||||||
pub fn get_statuses() -> HashMap<String, SubStatus> {
|
pub fn get_statuses() -> HashMap<String, SubStatus> {
|
||||||
STATUS.lock().unwrap().clone().unwrap_or_default()
|
STATUS.lock().unwrap().clone().unwrap_or_default()
|
||||||
|
|
@ -185,6 +226,7 @@ fn run_subscription(
|
||||||
Ok(events) => {
|
Ok(events) => {
|
||||||
for evt in events {
|
for evt in events {
|
||||||
forward_event(server, kiosk_key, &cam.id, &evt, user, pass);
|
forward_event(server, kiosk_key, &cam.id, &evt, user, pass);
|
||||||
|
mark_event_received(&cam.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -406,6 +448,11 @@ fn parse_notification_messages(xml: &str) -> Vec<OnvifEvent> {
|
||||||
source.insert(name, value);
|
source.insert(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(key_block) = extract_section(block, "Key") {
|
||||||
|
for (name, value) in parse_simple_items(&key_block) {
|
||||||
|
data.insert(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(data_block) = extract_section(block, "Data") {
|
if let Some(data_block) = extract_section(block, "Data") {
|
||||||
for (name, value) in parse_simple_items(&data_block) {
|
for (name, value) in parse_simple_items(&data_block) {
|
||||||
data.insert(name, value);
|
data.insert(name, value);
|
||||||
|
|
@ -694,11 +741,8 @@ fn extract_digest_field(header: &str, field: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn md5_hex(input: &str) -> String {
|
fn md5_hex(input: &str) -> String {
|
||||||
use md5::{Digest, Md5};
|
let digest = md5::compute(input.as_bytes());
|
||||||
let mut hasher = Md5::new();
|
hex_lower_bytes(&digest.0)
|
||||||
hasher.update(input.as_bytes());
|
|
||||||
let result = hasher.finalize();
|
|
||||||
hex_lower_bytes(&result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hex_lower_bytes(bytes: &[u8]) -> String {
|
fn hex_lower_bytes(bytes: &[u8]) -> String {
|
||||||
|
|
|
||||||
|
|
@ -514,6 +514,7 @@ pub fn heartbeat(
|
||||||
"network_interfaces": network_interfaces,
|
"network_interfaces": network_interfaces,
|
||||||
"onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(),
|
"onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(),
|
||||||
"partitions": serde_json::to_value(&hw.partitions).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))
|
.timeout(Duration::from_secs(5))
|
||||||
.send()
|
.send()
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::mpsc;
|
use std::sync::{mpsc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
static FIRMWARE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
static OS_UPDATE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{
|
use gtk4::{
|
||||||
self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture,
|
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);
|
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 {
|
ServerMsg::SwitchLayout {
|
||||||
display_id,
|
display_id,
|
||||||
layout_id,
|
layout_id,
|
||||||
|
|
@ -276,6 +291,10 @@ fn activate(app: &Application) {
|
||||||
layout_id,
|
layout_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
ServerMsg::Reboot => {
|
||||||
|
info!("reboot requested by admin");
|
||||||
|
let _ = std::process::Command::new("systemctl").arg("reboot").status();
|
||||||
|
}
|
||||||
ServerMsg::FirmwareCheck => {
|
ServerMsg::FirmwareCheck => {
|
||||||
maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload);
|
maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload);
|
||||||
}
|
}
|
||||||
|
|
@ -311,6 +330,7 @@ fn activate(app: &Application) {
|
||||||
}
|
}
|
||||||
maybe_apply_os_update(&server, &key, &tx_progress);
|
maybe_apply_os_update(&server, &key, &tx_progress);
|
||||||
maybe_apply_firmware_update(&server, &key, &tx_progress);
|
maybe_apply_firmware_update(&server, &key, &tx_progress);
|
||||||
|
maybe_refresh_onvif(&server, &key);
|
||||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -532,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") {
|
if std::env::var("BF_ENABLE_OS_OTA").as_deref() != Ok("1") {
|
||||||
return;
|
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 {
|
let Some(info) = os_update::check(server_url, kiosk_key) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -578,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") {
|
if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") {
|
||||||
return;
|
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 current = option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
|
||||||
let Some(info) = firmware::check(server_url, kiosk_key, current) else {
|
let Some(info) = firmware::check(server_url, kiosk_key, current) else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -624,6 +652,30 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn maybe_refresh_onvif(server_url: &str, kiosk_key: &str) {
|
||||||
|
if !onvif_events::needs_refresh() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info!("onvif: refreshing stale/failed subscriptions");
|
||||||
|
let bundle = match server::load_cached_bundle() {
|
||||||
|
Some(b) => b,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let displays = bundle.normalized_displays();
|
||||||
|
let layout_cam_ids: std::collections::HashSet<String> = displays
|
||||||
|
.iter()
|
||||||
|
.flat_map(|d| d.layouts.iter())
|
||||||
|
.flat_map(|l| l.cells.iter())
|
||||||
|
.filter_map(|c| c.camera_id.clone())
|
||||||
|
.collect();
|
||||||
|
let layout_cameras: Vec<_> = bundle.cameras.iter()
|
||||||
|
.filter(|c| layout_cam_ids.contains(&c.id))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let decrypt_key = server::load_encrypt_key().or_else(|| server::load_cluster_key());
|
||||||
|
onvif_events::start(&layout_cameras, decrypt_key.as_deref(), server_url, kiosk_key);
|
||||||
|
}
|
||||||
|
|
||||||
/// Install the once-per-second watchdog that enforces idle/sleep timeouts
|
/// Install the once-per-second watchdog that enforces idle/sleep timeouts
|
||||||
/// per display. Safe to call multiple times — installs at most once.
|
/// per display. Safe to call multiple times — installs at most once.
|
||||||
fn install_idle_watchdog() {
|
fn install_idle_watchdog() {
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,8 @@ async fn handle_message(
|
||||||
if let Some(layout_id) = layout_id {
|
if let Some(layout_id) = layout_id {
|
||||||
let _ = tx.send(ServerMsg::SwitchLayout { display_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\"") {
|
} else if text.contains("\"type\":\"firmware_check\"") {
|
||||||
let _ = tx.send(ServerMsg::FirmwareCheck);
|
let _ = tx.send(ServerMsg::FirmwareCheck);
|
||||||
} else if text.contains("\"type\":\"os_check\"") {
|
} else if text.contains("\"type\":\"os_check\"") {
|
||||||
|
|
@ -183,6 +185,20 @@ async fn handle_message(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let _ = tx.send(ServerMsg::Fan(pwm));
|
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 --------------------------------------------------
|
// ---- Journal streaming --------------------------------------------------
|
||||||
} else if text.contains("\"type\":\"journal-start\"") {
|
} else if text.contains("\"type\":\"journal-start\"") {
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,7 @@ module.exports = function (RED) {
|
||||||
function BfKioskCameraEventNode(config) {
|
function BfKioskCameraEventNode(config) {
|
||||||
RED.nodes.createNode(this, config);
|
RED.nodes.createNode(this, config);
|
||||||
const node = this;
|
const node = this;
|
||||||
const filterIdRaw = (config.camera_id || "").toString().trim();
|
const filterId = (config.camera_id || "").toString().trim() || null;
|
||||||
const filterId = filterIdRaw && !isNaN(Number(filterIdRaw)) ? Number(filterIdRaw) : null;
|
|
||||||
|
|
||||||
async function handler(req, res) {
|
async function handler(req, res) {
|
||||||
const body = await readJsonBody(req);
|
const body = await readJsonBody(req);
|
||||||
|
|
@ -37,7 +36,7 @@ module.exports = function (RED) {
|
||||||
const cameraId = body.camera_id !== undefined ? body.camera_id
|
const cameraId = body.camera_id !== undefined ? body.camera_id
|
||||||
: body.source_camera_id !== undefined ? body.source_camera_id
|
: body.source_camera_id !== undefined ? body.source_camera_id
|
||||||
: null;
|
: null;
|
||||||
if (filterId !== null && Number(cameraId) !== filterId) {
|
if (filterId !== null && String(cameraId) !== filterId) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
const out = {
|
const out = {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ module.exports = function (RED) {
|
||||||
function BfTriggerAnprNode(config) {
|
function BfTriggerAnprNode(config) {
|
||||||
RED.nodes.createNode(this, config);
|
RED.nodes.createNode(this, config);
|
||||||
const node = this;
|
const node = this;
|
||||||
const filterCam = config.camera_id ? Number(config.camera_id) : null;
|
const filterCam = config.camera_id ? String(config.camera_id).trim() : null;
|
||||||
|
|
||||||
async function handler(req, res) {
|
async function handler(req, res) {
|
||||||
const body = await readJsonBody(req);
|
const body = await readJsonBody(req);
|
||||||
|
|
@ -33,7 +33,7 @@ module.exports = function (RED) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraId = body.camera_id ?? body.source_camera_id ?? null;
|
const cameraId = body.camera_id ?? body.source_camera_id ?? null;
|
||||||
if (filterCam !== null && Number(cameraId) !== filterCam) {
|
if (filterCam !== null && String(cameraId) !== filterCam) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ module.exports = function (RED) {
|
||||||
function BfTriggerEventNode(config) {
|
function BfTriggerEventNode(config) {
|
||||||
RED.nodes.createNode(this, config);
|
RED.nodes.createNode(this, config);
|
||||||
const node = this;
|
const node = this;
|
||||||
const filterCam = config.camera_id ? Number(config.camera_id) : null;
|
const filterCam = config.camera_id ? String(config.camera_id).trim() : null;
|
||||||
const filterTopic = (config.topic_filter || "").trim();
|
const filterTopic = (config.topic_filter || "").trim();
|
||||||
|
|
||||||
async function handler(req, res) {
|
async function handler(req, res) {
|
||||||
|
|
@ -27,7 +27,7 @@ module.exports = function (RED) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraId = body.camera_id ?? body.source_camera_id ?? null;
|
const cameraId = body.camera_id ?? body.source_camera_id ?? null;
|
||||||
if (filterCam !== null && Number(cameraId) !== filterCam) {
|
if (filterCam !== null && String(cameraId) !== filterCam) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ module.exports = function (RED) {
|
||||||
function BfTriggerMotionNode(config) {
|
function BfTriggerMotionNode(config) {
|
||||||
RED.nodes.createNode(this, config);
|
RED.nodes.createNode(this, config);
|
||||||
const node = this;
|
const node = this;
|
||||||
const filterCam = config.camera_id ? Number(config.camera_id) : null;
|
const filterCam = config.camera_id ? String(config.camera_id).trim() : null;
|
||||||
|
|
||||||
async function handler(req, res) {
|
async function handler(req, res) {
|
||||||
const body = await readJsonBody(req);
|
const body = await readJsonBody(req);
|
||||||
|
|
@ -34,7 +34,7 @@ module.exports = function (RED) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraId = body.camera_id ?? body.source_camera_id ?? null;
|
const cameraId = body.camera_id ?? body.source_camera_id ?? null;
|
||||||
if (filterCam !== null && Number(cameraId) !== filterCam) {
|
if (filterCam !== null && String(cameraId) !== filterCam) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { registerOsUpdateRoutes } from "./routes-os-updates.js";
|
||||||
import { registerStaticRoutes } from "./routes-static.js";
|
import { registerStaticRoutes } from "./routes-static.js";
|
||||||
import { registerCloudRoutes } from "./routes-cloud.js";
|
import { registerCloudRoutes } from "./routes-cloud.js";
|
||||||
import { registerTenantRoutes } from "./routes-tenants.js";
|
import { registerTenantRoutes } from "./routes-tenants.js";
|
||||||
|
import { registerAbleSignRoutes } from "./routes-ablesign.js";
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -238,6 +239,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
registerOsUpdateRoutes(app, deps);
|
registerOsUpdateRoutes(app, deps);
|
||||||
registerCloudRoutes(app, deps);
|
registerCloudRoutes(app, deps);
|
||||||
registerTenantRoutes(app, deps);
|
registerTenantRoutes(app, deps);
|
||||||
|
registerAbleSignRoutes(app, deps);
|
||||||
|
|
||||||
// Auth-check endpoint for Angie auth_request subrequest.
|
// Auth-check endpoint for Angie auth_request subrequest.
|
||||||
// Returns 200 if session cookie is valid + admin role, 401 otherwise.
|
// Returns 200 if session cookie is valid + admin role, 401 otherwise.
|
||||||
|
|
|
||||||
173
server/src/plugins/service-admin-http/routes-ablesign.ts
Normal file
173
server/src/plugins/service-admin-http/routes-ablesign.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
/**
|
||||||
|
* AbleSign digital signage routes.
|
||||||
|
*/
|
||||||
|
import { type H3, getRouterParam, readBody, createError } from "h3";
|
||||||
|
|
||||||
|
import { htmlPage } from "./html-response.js";
|
||||||
|
import type { AdminDeps } from "./index.js";
|
||||||
|
import * as ablesign from "../../shared/ablesign.js";
|
||||||
|
import { AbleSignPage, AbleSignScreensPage } from "../../web-templates/admin-pages.js";
|
||||||
|
|
||||||
|
export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
|
app.get("/admin/ablesign", async () => {
|
||||||
|
const accounts = await deps.repo.listAbleSignAccounts();
|
||||||
|
return htmlPage(AbleSignPage({ accounts }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/ablesign/add", async (event) => {
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const name = (body?.name ?? "").trim();
|
||||||
|
const apiKey = (body?.api_key ?? "").trim();
|
||||||
|
const workspaceId = (body?.workspace_id ?? "").trim() || undefined;
|
||||||
|
|
||||||
|
if (!name || !apiKey) {
|
||||||
|
const accounts = await deps.repo.listAbleSignAccounts();
|
||||||
|
return htmlPage(AbleSignPage({ accounts, error: "Name and API key required." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = await ablesign.testApiKey(apiKey, workspaceId);
|
||||||
|
if (!test.ok) {
|
||||||
|
const accounts = await deps.repo.listAbleSignAccounts();
|
||||||
|
return htmlPage(AbleSignPage({ accounts, error: `API key test failed: ${test.error}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = deps.secrets.encryptString(apiKey, "ablesign-key");
|
||||||
|
await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId });
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/ablesign/:id/screens", async (event) => {
|
||||||
|
const id = getRouterParam(event, "id") ?? "";
|
||||||
|
const account = await deps.repo.getAbleSignAccount(id);
|
||||||
|
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
|
||||||
|
const screens = await deps.repo.listAbleSignScreens(id);
|
||||||
|
const kiosks = await deps.repo.listKiosks();
|
||||||
|
return htmlPage(AbleSignScreensPage({ account, screens, kiosks }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/ablesign/:id/sync", async (event) => {
|
||||||
|
const id = getRouterParam(event, "id") ?? "";
|
||||||
|
const account = await deps.repo.getAbleSignAccount(id);
|
||||||
|
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
|
||||||
|
const opts = { apiKey, workspaceId: account.workspace_id || undefined };
|
||||||
|
const result = await ablesign.listScreens(opts);
|
||||||
|
|
||||||
|
for (const s of result.data) {
|
||||||
|
await deps.repo.upsertAbleSignScreen({
|
||||||
|
account_id: id,
|
||||||
|
ablesign_screen_id: String(s.id),
|
||||||
|
title: s.title,
|
||||||
|
online: !!s.heartbeatTime,
|
||||||
|
last_heartbeat_at: s.heartbeatTime || undefined,
|
||||||
|
orientation: s.orientation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await deps.repo.updateAbleSignAccount(id, {
|
||||||
|
screen_count: result.data.length,
|
||||||
|
last_sync_at: new Date().toISOString(),
|
||||||
|
last_sync_error: null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await deps.repo.updateAbleSignAccount(id, {
|
||||||
|
last_sync_at: new Date().toISOString(),
|
||||||
|
last_sync_error: (err as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${id}/screens` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/ablesign/:id/screens/add", async (event) => {
|
||||||
|
const accountId = getRouterParam(event, "id") ?? "";
|
||||||
|
const account = await deps.repo.getAbleSignAccount(accountId);
|
||||||
|
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
|
||||||
|
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const title = (body?.title ?? "").trim();
|
||||||
|
if (!title) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
|
||||||
|
const opts = { apiKey, workspaceId: account.workspace_id || undefined };
|
||||||
|
const { screen, registrationCode } = await ablesign.headlessPairScreen(opts, title);
|
||||||
|
|
||||||
|
// Poll once for token (may not be available immediately).
|
||||||
|
let screenToken: string | undefined;
|
||||||
|
try {
|
||||||
|
const poll = await ablesign.pollRegistration(registrationCode);
|
||||||
|
screenToken = poll.screenToken;
|
||||||
|
} catch { /* token may not be ready yet — kiosk can work without it initially */ }
|
||||||
|
|
||||||
|
const screenRowId = await deps.repo.createAbleSignScreen({
|
||||||
|
account_id: accountId,
|
||||||
|
ablesign_screen_id: String(screen.id),
|
||||||
|
ablesign_screen_token_encrypted: screenToken
|
||||||
|
? deps.secrets.encryptString(screenToken, "ablesign-token")
|
||||||
|
: undefined,
|
||||||
|
title: screen.title,
|
||||||
|
orientation: screen.orientation,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deps.repo.createEntity({
|
||||||
|
name: `AbleSign: ${screen.title}`,
|
||||||
|
type: "ablesign",
|
||||||
|
description: `AbleSign screen (ID: ${String(screen.id)})`,
|
||||||
|
web_url: "https://player.ablesign.tv",
|
||||||
|
ablesign_screen_id: screenRowId,
|
||||||
|
managed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deps.repo.updateAbleSignAccount(accountId, {
|
||||||
|
screen_count: (account.screen_count ?? 0) + 1,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// redirect back — error handling TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/ablesign/screens/:sid/assign", async (event) => {
|
||||||
|
const sid = getRouterParam(event, "sid") ?? "";
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const kioskId = (body?.kiosk_id ?? "").trim() || null;
|
||||||
|
await deps.repo.updateAbleSignScreen(sid, { kiosk_id: kioskId });
|
||||||
|
|
||||||
|
const screen = await deps.repo.getAbleSignScreen(sid);
|
||||||
|
const accountId = screen?.account_id ?? "";
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/ablesign/:id/delete", async (event) => {
|
||||||
|
const id = getRouterParam(event, "id") ?? "";
|
||||||
|
await deps.repo.deleteAbleSignAccount(id);
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/ablesign/screens/:sid/delete", async (event) => {
|
||||||
|
const sid = getRouterParam(event, "sid") ?? "";
|
||||||
|
const screen = await deps.repo.getAbleSignScreen(sid);
|
||||||
|
if (screen) {
|
||||||
|
try {
|
||||||
|
const account = await deps.repo.getAbleSignAccount(screen.account_id);
|
||||||
|
if (account) {
|
||||||
|
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
|
||||||
|
await ablesign.deleteScreen(
|
||||||
|
{ apiKey, workspaceId: account.workspace_id || undefined },
|
||||||
|
Number(screen.ablesign_screen_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch { /* best-effort remote delete */ }
|
||||||
|
await deps.repo.deleteAbleSignScreen(sid);
|
||||||
|
}
|
||||||
|
const accountId = screen?.account_id ?? "";
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -790,6 +790,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const id = (getRouterParam(event, "id") ?? "");
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const ent = await deps.repo.getEntityById(id);
|
const ent = await deps.repo.getEntityById(id);
|
||||||
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
||||||
|
if ((ent as any).managed) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/entities/${String(id)}` } });
|
||||||
|
}
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const patch: {
|
const patch: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -2193,6 +2196,29 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
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 ---------------
|
// ---- JSON API (admin scope) — used by Node-RED bf-* nodes ---------------
|
||||||
//
|
//
|
||||||
// All payloads run through `stripSecrets` so credential-bearing fields
|
// All payloads run through `stripSecrets` so credential-bearing fields
|
||||||
|
|
|
||||||
|
|
@ -626,7 +626,17 @@ function registerKioskRoutes(
|
||||||
const kiosk = await auth.verifyKioskKey(token);
|
const kiosk = await auth.verifyKioskKey(token);
|
||||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||||
|
|
||||||
const body = validateBody(EventBody, await readBody(event));
|
const raw = await readBody(event);
|
||||||
|
let body: ReturnType<typeof EventBody["parse"]>;
|
||||||
|
try {
|
||||||
|
body = validateBody(EventBody, raw);
|
||||||
|
} catch (err: any) {
|
||||||
|
event.context.obs?.log.warn("event validation failed: {msg} body={raw}", {
|
||||||
|
msg: err.message ?? "unknown",
|
||||||
|
raw: JSON.stringify(raw).slice(0, 500),
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const payload = (body.payload ?? {}) as Record<string, unknown>;
|
const payload = (body.payload ?? {}) as Record<string, unknown>;
|
||||||
event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic });
|
event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic });
|
||||||
|
|
||||||
|
|
@ -725,11 +735,11 @@ function registerKioskRoutes(
|
||||||
nodered.forward(body.topic, out, markForwarded);
|
nodered.forward(body.topic, out, markForwarded);
|
||||||
mqtt.publishEvent(kiosk.id, body.topic, out);
|
mqtt.publishEvent(kiosk.id, body.topic, out);
|
||||||
|
|
||||||
// ONVIF events: also forward to the fixed onvif.event route so the
|
nodered.forward("camera.event", out);
|
||||||
// bf-trigger-motion / bf-trigger-anpr / bf-trigger-event nodes
|
|
||||||
// receive them without needing per-topic route registration.
|
|
||||||
if (body.source_type === "onvif") {
|
if (body.source_type === "onvif") {
|
||||||
nodered.forward("onvif.event", out);
|
nodered.forward("onvif.event", out);
|
||||||
|
nodered.forward("onvif.motion", out);
|
||||||
|
nodered.forward("onvif.anpr", out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
226
server/src/shared/ablesign.ts
Normal file
226
server/src/shared/ablesign.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
/**
|
||||||
|
* AbleSign API client — screen registration, playlist management.
|
||||||
|
*
|
||||||
|
* Base URL: https://api.ablesign.tv/api/v1
|
||||||
|
* Auth: Bearer ak_... (API key from AbleSign CMS)
|
||||||
|
*
|
||||||
|
* The player at player.ablesign.tv uses an internal registration API
|
||||||
|
* (POST /api/screens/registration) that doesn't need auth. We use the
|
||||||
|
* public API (v1) with the admin's API key for all management ops, and
|
||||||
|
* the internal player API for headless screen registration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = "https://api.ablesign.tv/api/v1";
|
||||||
|
const PLAYER_API = "https://api.ablesign.tv/api";
|
||||||
|
|
||||||
|
export interface AbleSignScreen {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
orientation: string;
|
||||||
|
heartbeatTime?: string;
|
||||||
|
screenGroupId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbleSignPlaylistItem {
|
||||||
|
id?: string;
|
||||||
|
mediafileId?: string;
|
||||||
|
webAppId?: string;
|
||||||
|
displayDuration?: number;
|
||||||
|
sequenceNumber?: number;
|
||||||
|
transition?: string;
|
||||||
|
transitionSpeedLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbleSignPlaylist {
|
||||||
|
defaultTransition?: string;
|
||||||
|
defaultTransitionSpeedLabel?: string;
|
||||||
|
shufflePlay?: boolean;
|
||||||
|
items: AbleSignPlaylistItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbleSignRegistration {
|
||||||
|
id: number;
|
||||||
|
code: number;
|
||||||
|
screenId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiOpts {
|
||||||
|
apiKey: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers(opts: ApiOpts): Record<string, string> {
|
||||||
|
const h: Record<string, string> = {
|
||||||
|
"Authorization": `Bearer ${opts.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
};
|
||||||
|
if (opts.workspaceId) {
|
||||||
|
h["Workspace-Id"] = opts.workspaceId;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
opts: ApiOpts,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 10000);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: headers(opts),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text().catch(() => "");
|
||||||
|
throw new Error(`AbleSign API ${method} ${path}: HTTP ${resp.status} — ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
if (resp.status === 204) return undefined as T;
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listScreens(opts: ApiOpts): Promise<{ data: AbleSignScreen[]; totalItems: number }> {
|
||||||
|
return apiFetch("GET", "/screens?limit=200", opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScreen(opts: ApiOpts, screenId: number): Promise<AbleSignScreen> {
|
||||||
|
return apiFetch("GET", `/screens/${screenId}`, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerScreen(
|
||||||
|
opts: ApiOpts,
|
||||||
|
registrationCode: string,
|
||||||
|
title: string,
|
||||||
|
orientation: string = "landscape",
|
||||||
|
): Promise<AbleSignScreen> {
|
||||||
|
return apiFetch("POST", "/screens", opts, {
|
||||||
|
registrationCode,
|
||||||
|
title,
|
||||||
|
orientation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateScreen(
|
||||||
|
opts: ApiOpts,
|
||||||
|
screenId: number,
|
||||||
|
patch: { title?: string; description?: string; orientation?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await apiFetch("PUT", `/screens/${screenId}`, opts, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteScreen(opts: ApiOpts, screenId: number): Promise<void> {
|
||||||
|
await apiFetch("DELETE", `/screens/${screenId}`, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlaylist(opts: ApiOpts, screenId: number): Promise<AbleSignPlaylist> {
|
||||||
|
return apiFetch("GET", `/screens/${screenId}/playlist`, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePlaylist(
|
||||||
|
opts: ApiOpts,
|
||||||
|
screenId: number,
|
||||||
|
playlist: AbleSignPlaylist,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiFetch("PUT", `/screens/${screenId}/playlist`, opts, playlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPlaylistItems(
|
||||||
|
opts: ApiOpts,
|
||||||
|
screenId: number,
|
||||||
|
items: AbleSignPlaylistItem[],
|
||||||
|
position: "start" | "end" = "end",
|
||||||
|
): Promise<void> {
|
||||||
|
await apiFetch("POST", `/screens/${screenId}/playlist_items`, opts, { items, position });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWebApps(opts: ApiOpts): Promise<{ data: Array<{ id: string; title: string; url?: string }> }> {
|
||||||
|
return apiFetch("GET", "/web_apps?limit=200", opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWebApp(
|
||||||
|
opts: ApiOpts,
|
||||||
|
title: string,
|
||||||
|
url: string,
|
||||||
|
): Promise<{ id: string; title: string }> {
|
||||||
|
return apiFetch("POST", "/web_apps", opts, { title, url });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMediaFiles(opts: ApiOpts): Promise<{ data: Array<{ id: string; title: string; fileType: string; thumbnailURL?: string }> }> {
|
||||||
|
return apiFetch("GET", "/media_files?limit=200", opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate headless screen registration via the player's internal API.
|
||||||
|
* No auth required — mimics what player.ablesign.tv does on load.
|
||||||
|
*/
|
||||||
|
export async function initiatePlayerRegistration(): Promise<AbleSignRegistration> {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), 10000);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${PLAYER_API}/screens/registration`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "x-app-version": "46" },
|
||||||
|
body: JSON.stringify({ platformType: "Web", softwareVersionCode: 46 }),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`registration init: HTTP ${resp.status}`);
|
||||||
|
return (await resp.json()) as AbleSignRegistration;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll for registration completion. Returns screenId when paired, -1 while pending.
|
||||||
|
*/
|
||||||
|
export async function pollRegistration(code: number): Promise<{ screenId: number; screenToken?: string }> {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), 10000);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${PLAYER_API}/screens/registration/${code}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "Accept": "application/json", "x-app-version": "46" },
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`registration poll: HTTP ${resp.status}`);
|
||||||
|
const data = (await resp.json()) as Record<string, unknown>;
|
||||||
|
return { screenId: (data.screenId as number) ?? -1, screenToken: data.screenToken as string | undefined };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full headless pairing flow:
|
||||||
|
* 1. Initiate registration → get code
|
||||||
|
* 2. Register screen via admin API with that code
|
||||||
|
* 3. Return screen details + any token for the player
|
||||||
|
*/
|
||||||
|
export async function headlessPairScreen(
|
||||||
|
opts: ApiOpts,
|
||||||
|
title: string,
|
||||||
|
orientation: string = "landscape",
|
||||||
|
): Promise<{ screen: AbleSignScreen; registrationCode: number }> {
|
||||||
|
const reg = await initiatePlayerRegistration();
|
||||||
|
const screen = await registerScreen(opts, String(reg.code), title, orientation);
|
||||||
|
return { screen, registrationCode: reg.code };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testApiKey(apiKey: string, workspaceId?: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await listScreens({ apiKey, workspaceId });
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,8 +77,8 @@ export const EventBody = av.object(
|
||||||
{
|
{
|
||||||
topic: av.string().minLength(1).maxLength(512),
|
topic: av.string().minLength(1).maxLength(512),
|
||||||
source_type: av.string().maxLength(32).default("system"),
|
source_type: av.string().maxLength(32).default("system"),
|
||||||
camera_id: av.nullable(av.string().maxLength(64)).default(null),
|
camera_id: av.optional(av.nullable(av.string().maxLength(64))).default(null),
|
||||||
property_op: av.nullable(av.string().maxLength(32)).default(null),
|
property_op: av.optional(av.nullable(av.string().maxLength(32))).default(null),
|
||||||
payload: av.any().default({}),
|
payload: av.any().default({}),
|
||||||
},
|
},
|
||||||
{ unknownKeys: "strip" },
|
{ unknownKeys: "strip" },
|
||||||
|
|
@ -155,9 +155,13 @@ export const PasswordChangeBody = av.object(
|
||||||
export function validateBody<T>(schema: { safeParse(input: unknown): { success: boolean; data?: T; error?: unknown } }, raw: unknown): T {
|
export function validateBody<T>(schema: { safeParse(input: unknown): { success: boolean; data?: T; error?: unknown } }, raw: unknown): T {
|
||||||
const result = schema.safeParse(raw);
|
const result = schema.safeParse(raw);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const msg = typeof result.error === "object" && result.error && "message" in result.error
|
let msg = "invalid request body";
|
||||||
? String((result.error as any).message)
|
const err = result.error as any;
|
||||||
: "invalid request body";
|
if (err?.issues) {
|
||||||
|
msg = err.issues.map((i: any) => `${i.path?.join?.(".") ?? "?"}: ${i.message}`).join("; ");
|
||||||
|
} else if (err?.message) {
|
||||||
|
msg = String(err.message);
|
||||||
|
}
|
||||||
throw Object.assign(new Error(msg), { status: 400, statusText: "Bad Request" });
|
throw Object.assign(new Error(msg), { status: 400, statusText: "Bad Request" });
|
||||||
}
|
}
|
||||||
return result.data as T;
|
return result.data as T;
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,8 @@ export function rowToEntity(r: Row): Entity {
|
||||||
html_content: sn(r["html_content"]),
|
html_content: sn(r["html_content"]),
|
||||||
web_url: sn(r["web_url"]),
|
web_url: sn(r["web_url"]),
|
||||||
dashboard_id: sn(r["dashboard_id"]),
|
dashboard_id: sn(r["dashboard_id"]),
|
||||||
|
ablesign_screen_id: sn(r["ablesign_screen_id"]),
|
||||||
|
managed: !!r["managed"],
|
||||||
created_at: s(r["created_at"]),
|
created_at: s(r["created_at"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -689,4 +689,36 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
RAISE NOTICE 'UUIDv7 backfill: all integer-looking IDs replaced with UUIDs';
|
RAISE NOTICE 'UUIDv7 backfill: all integer-looking IDs replaced with UUIDs';
|
||||||
END $$`,
|
END $$`,
|
||||||
|
|
||||||
|
// ---- AbleSign digital signage integration -----------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS ablesign_accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
api_key_encrypted TEXT NOT NULL,
|
||||||
|
workspace_id TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
screen_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_sync_at TIMESTAMPTZ,
|
||||||
|
last_sync_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS ablesign_screens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL REFERENCES ablesign_accounts(id) ON DELETE CASCADE,
|
||||||
|
ablesign_screen_id TEXT NOT NULL,
|
||||||
|
ablesign_screen_token_encrypted TEXT,
|
||||||
|
kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
orientation TEXT NOT NULL DEFAULT 'landscape',
|
||||||
|
online BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
last_heartbeat_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(account_id, ablesign_screen_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_account ON ablesign_screens(account_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_kiosk ON ablesign_screens(kiosk_id)`,
|
||||||
|
`ALTER TABLE entities DROP CONSTRAINT IF EXISTS entities_type_check`,
|
||||||
|
`ALTER TABLE entities ADD CONSTRAINT entities_type_check CHECK(type IN ('camera', 'html', 'web', 'dashboard', 'ablesign'))`,
|
||||||
|
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS ablesign_screen_id TEXT REFERENCES ablesign_screens(id) ON DELETE CASCADE`,
|
||||||
|
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false`,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2307,11 +2307,13 @@ export class Repository {
|
||||||
html_content?: string | null;
|
html_content?: string | null;
|
||||||
web_url?: string | null;
|
web_url?: string | null;
|
||||||
dashboard_id?: string | null;
|
dashboard_id?: string | null;
|
||||||
|
ablesign_screen_id?: string | null;
|
||||||
|
managed?: boolean;
|
||||||
}): Promise<Entity> {
|
}): Promise<Entity> {
|
||||||
const id = uuidv7();
|
const id = uuidv7();
|
||||||
await this._run(
|
await this._run(
|
||||||
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id)
|
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id, ablesign_screen_id, managed)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
id,
|
id,
|
||||||
input.name,
|
input.name,
|
||||||
|
|
@ -2319,8 +2321,10 @@ export class Repository {
|
||||||
input.description ?? null,
|
input.description ?? null,
|
||||||
input.type === "camera" ? (input.camera_id ?? null) : null,
|
input.type === "camera" ? (input.camera_id ?? null) : null,
|
||||||
input.type === "html" ? (input.html_content ?? null) : null,
|
input.type === "html" ? (input.html_content ?? null) : null,
|
||||||
input.type === "web" ? (input.web_url ?? null) : null,
|
input.type === "web" || input.type === "ablesign" ? (input.web_url ?? null) : null,
|
||||||
input.type === "dashboard" ? (input.dashboard_id ?? null) : null,
|
input.type === "dashboard" ? (input.dashboard_id ?? null) : null,
|
||||||
|
input.type === "ablesign" ? (input.ablesign_screen_id ?? null) : null,
|
||||||
|
input.managed ?? false,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
void this.notify("entities", "create", id);
|
void this.notify("entities", "create", id);
|
||||||
|
|
@ -2405,7 +2409,7 @@ export class Repository {
|
||||||
if (await this.getEntityByName(name)) {
|
if (await this.getEntityByName(name)) {
|
||||||
name = `${camera.name} (cam ${camera.id.slice(0, 8)})`;
|
name = `${camera.name} (cam ${camera.id.slice(0, 8)})`;
|
||||||
}
|
}
|
||||||
return this.createEntity({ name, type: "camera", camera_id: camera.id });
|
return this.createEntity({ name, type: "camera", camera_id: camera.id, managed: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateKiosk(id: string, patch: Partial<Kiosk>): Promise<void> {
|
async updateKiosk(id: string, patch: Partial<Kiosk>): Promise<void> {
|
||||||
|
|
@ -2628,4 +2632,123 @@ export class Repository {
|
||||||
async deleteCloudAccount(id: string): Promise<void> {
|
async deleteCloudAccount(id: string): Promise<void> {
|
||||||
await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]);
|
await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// AbleSign accounts + screens
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
async listAbleSignAccounts(): Promise<any[]> {
|
||||||
|
return this._all("SELECT * FROM ablesign_accounts ORDER BY created_at DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAbleSignAccount(id: string): Promise<any | undefined> {
|
||||||
|
return this._get("SELECT * FROM ablesign_accounts WHERE id = ?", [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAbleSignAccount(input: {
|
||||||
|
name: string;
|
||||||
|
api_key_encrypted: string;
|
||||||
|
workspace_id?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = uuidv7();
|
||||||
|
await this._run(
|
||||||
|
`INSERT INTO ablesign_accounts (id, name, api_key_encrypted, workspace_id)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[id, input.name, input.api_key_encrypted, input.workspace_id ?? null],
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAbleSignAccount(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (k === "id" || k === "created_at") continue;
|
||||||
|
sets.push(`${k} = ?`);
|
||||||
|
vals.push(v === undefined ? null : v);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
await this._run(`UPDATE ablesign_accounts SET ${sets.join(", ")} WHERE id = ?`, vals);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAbleSignAccount(id: string): Promise<void> {
|
||||||
|
await this._run("DELETE FROM ablesign_accounts WHERE id = ?", [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAbleSignScreens(accountId?: string): Promise<any[]> {
|
||||||
|
if (accountId) {
|
||||||
|
return this._all("SELECT * FROM ablesign_screens WHERE account_id = ? ORDER BY title", [accountId]);
|
||||||
|
}
|
||||||
|
return this._all("SELECT * FROM ablesign_screens ORDER BY title");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAbleSignScreen(id: string): Promise<any | undefined> {
|
||||||
|
return this._get("SELECT * FROM ablesign_screens WHERE id = ?", [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAbleSignScreenByKiosk(kioskId: string): Promise<any | undefined> {
|
||||||
|
return this._get("SELECT * FROM ablesign_screens WHERE kiosk_id = ?", [kioskId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAbleSignScreen(input: {
|
||||||
|
account_id: string;
|
||||||
|
ablesign_screen_id: string;
|
||||||
|
ablesign_screen_token_encrypted?: string;
|
||||||
|
kiosk_id?: string;
|
||||||
|
title: string;
|
||||||
|
orientation?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = uuidv7();
|
||||||
|
await this._run(
|
||||||
|
`INSERT INTO ablesign_screens (id, account_id, ablesign_screen_id, ablesign_screen_token_encrypted, kiosk_id, title, orientation)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[id, input.account_id, input.ablesign_screen_id, input.ablesign_screen_token_encrypted ?? null, input.kiosk_id ?? null, input.title, input.orientation ?? "landscape"],
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAbleSignScreen(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (k === "id" || k === "created_at") continue;
|
||||||
|
sets.push(`${k} = ?`);
|
||||||
|
vals.push(v === undefined ? null : v);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
await this._run(`UPDATE ablesign_screens SET ${sets.join(", ")} WHERE id = ?`, vals);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAbleSignScreen(id: string): Promise<void> {
|
||||||
|
await this._run("DELETE FROM ablesign_screens WHERE id = ?", [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertAbleSignScreen(input: {
|
||||||
|
account_id: string;
|
||||||
|
ablesign_screen_id: string;
|
||||||
|
title: string;
|
||||||
|
online: boolean;
|
||||||
|
last_heartbeat_at?: string;
|
||||||
|
orientation?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const existing = await this._get<{ id: string }>(
|
||||||
|
"SELECT id FROM ablesign_screens WHERE account_id = ? AND ablesign_screen_id = ?",
|
||||||
|
[input.account_id, input.ablesign_screen_id],
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await this._run(
|
||||||
|
`UPDATE ablesign_screens SET title = ?, online = ?, last_heartbeat_at = COALESCE(?, last_heartbeat_at), orientation = COALESCE(?, orientation) WHERE id = ?`,
|
||||||
|
[input.title, input.online, input.last_heartbeat_at ?? null, input.orientation ?? null, existing.id],
|
||||||
|
);
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
return this.createAbleSignScreen({
|
||||||
|
account_id: input.account_id,
|
||||||
|
ablesign_screen_id: input.ablesign_screen_id,
|
||||||
|
title: input.title,
|
||||||
|
orientation: input.orientation,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ export type StreamRole = "main" | "sub" | "other";
|
||||||
export type StreamSelector = "auto" | "main" | "sub";
|
export type StreamSelector = "auto" | "main" | "sub";
|
||||||
export type StreamPolicy = "auto" | "always_main" | "always_sub";
|
export type StreamPolicy = "auto" | "always_main" | "always_sub";
|
||||||
export type LayoutPriority = "hot" | "normal" | "cold";
|
export type LayoutPriority = "hot" | "normal" | "cold";
|
||||||
export type CellContentType = "none" | "camera" | "web" | "html";
|
export type CellContentType = "none" | "camera" | "web" | "html" | "ablesign";
|
||||||
export type EntityType = "camera" | "html" | "web" | "dashboard";
|
export type EntityType = "camera" | "html" | "web" | "dashboard" | "ablesign";
|
||||||
|
|
||||||
export interface Entity {
|
export interface Entity {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -25,6 +25,10 @@ export interface Entity {
|
||||||
web_url: string | null;
|
web_url: string | null;
|
||||||
/** Node-RED dashboard tab id; populated when type === "dashboard". */
|
/** Node-RED dashboard tab id; populated when type === "dashboard". */
|
||||||
dashboard_id: string | null;
|
dashboard_id: string | null;
|
||||||
|
/** AbleSign screen row id; populated when type === "ablesign". */
|
||||||
|
ablesign_screen_id: string | null;
|
||||||
|
/** True for entities auto-created by camera sync, cloud cams, AbleSign. Read-only in UI. */
|
||||||
|
managed: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
export type DesiredPowerState = "follow_layout" | "on" | "standby";
|
export type DesiredPowerState = "follow_layout" | "on" | "standby";
|
||||||
|
|
|
||||||
|
|
@ -1915,6 +1915,56 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
}}
|
}}
|
||||||
>Full</button>
|
>Full</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -4329,3 +4379,163 @@ export function TenantEditPage(props: TenantEditPageProps) {
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- AbleSign Pages ---------------------------------------------------------
|
||||||
|
|
||||||
|
interface AbleSignPageProps {
|
||||||
|
accounts: any[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AbleSignPage(props: AbleSignPageProps) {
|
||||||
|
return (
|
||||||
|
<Layout title="AbleSign" activeNav="ablesign">
|
||||||
|
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Accounts</h1>
|
||||||
|
|
||||||
|
{props.error ? <div class="alert alert-error" style="margin-bottom:1rem">{props.error}</div> : ""}
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="font-size:1.1rem; margin:0 0 1rem">Add Account</h2>
|
||||||
|
<form method="POST" action="/admin/ablesign/add" style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:end">
|
||||||
|
<label style="font-size:0.85rem">
|
||||||
|
{"Name"}<br/>
|
||||||
|
<input type="text" name="name" required style="width:12rem" placeholder="My AbleSign" />
|
||||||
|
</label>
|
||||||
|
<label style="font-size:0.85rem">
|
||||||
|
{"API Key"}<br/>
|
||||||
|
<input type="password" name="api_key" required style="width:16rem" placeholder="ak_..." />
|
||||||
|
</label>
|
||||||
|
<label style="font-size:0.85rem">
|
||||||
|
{"Workspace ID (optional)"}<br/>
|
||||||
|
<input type="text" name="workspace_id" style="width:8rem" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.accounts.length > 0 ? (
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Screens</th>
|
||||||
|
<th>Last Sync</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{props.accounts.map((a: any) => (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/admin/ablesign/${String(a.id)}/screens`}>{a.name}</a></td>
|
||||||
|
<td>{String(a.screen_count ?? 0)}</td>
|
||||||
|
<td style="font-size:0.85rem">
|
||||||
|
{a.last_sync_at ? formatTime(a.last_sync_at) : "Never"}
|
||||||
|
{a.last_sync_error && <span style="color:red" title={a.last_sync_error}>{" (error)"}</span>}
|
||||||
|
</td>
|
||||||
|
<td style="display:flex; gap:0.25rem">
|
||||||
|
<a href={`/admin/ablesign/${String(a.id)}/screens`} class="btn btn-sm btn-ghost">Screens</a>
|
||||||
|
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`} style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-ghost">Sync</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action={`/admin/ablesign/${String(a.id)}/delete`} style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : ""}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AbleSignScreensPageProps {
|
||||||
|
account: any;
|
||||||
|
screens: any[];
|
||||||
|
kiosks: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
||||||
|
const a = props.account;
|
||||||
|
return (
|
||||||
|
<Layout title={`AbleSign — ${String(a.name)}`} activeNav="ablesign">
|
||||||
|
<h1 style="font-size:1.5rem; margin:0 0 0.5rem">{a.name} — Screens</h1>
|
||||||
|
<p style="color:#999; margin:0 0 1.5rem; font-size:0.85rem">
|
||||||
|
{String(a.screen_count ?? 0)} screens
|
||||||
|
{a.last_sync_at && ` · synced ${formatTime(a.last_sync_at)}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="font-size:1rem; margin:0 0 0.75rem">Add Screen</h2>
|
||||||
|
<form method="POST" action={`/admin/ablesign/${String(a.id)}/screens/add`} style="display:flex; gap:0.5rem; align-items:end">
|
||||||
|
<label style="font-size:0.85rem">
|
||||||
|
{"Screen Name"}<br/>
|
||||||
|
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
|
||||||
|
</form>
|
||||||
|
<p style="font-size:0.8rem; color:#999; margin:0.5rem 0 0">
|
||||||
|
Creates a new screen in AbleSign and pairs it automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1rem">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem">
|
||||||
|
<h2 style="font-size:1rem; margin:0">Screens</h2>
|
||||||
|
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`}>
|
||||||
|
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.screens.length === 0 ? (
|
||||||
|
<p style="color:#999; font-size:0.85rem">No screens yet. Add one above or sync from AbleSign.</p>
|
||||||
|
) : (
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Orientation</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Assigned Kiosk</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{props.screens.map((s: any) => (
|
||||||
|
<tr>
|
||||||
|
<td>{s.title}</td>
|
||||||
|
<td style="font-size:0.85rem">{s.orientation}</td>
|
||||||
|
<td>
|
||||||
|
{s.online
|
||||||
|
? <span class="badge badge-green">Online</span>
|
||||||
|
: <span class="badge badge-gray">Offline</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/assign`}
|
||||||
|
style="display:flex; gap:0.25rem; align-items:center">
|
||||||
|
<select name="kiosk_id" style="font-size:0.85rem; max-width:14rem">
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{props.kiosks.map((k: any) => (
|
||||||
|
<option value={String(k.id)} selected={k.id === s.kiosk_id}>{k.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-sm btn-ghost">Assign</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/delete`} style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
||||||
<NavItem href="/admin/os-updates" label="OS Updates" icon="●" active={a === "os-updates"} />
|
<NavItem href="/admin/os-updates" label="OS Updates" icon="●" active={a === "os-updates"} />
|
||||||
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="☁" active={a === "cloud"} />
|
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="☁" active={a === "cloud"} />
|
||||||
|
<NavItem href="/admin/ablesign" label="AbleSign" icon="▶" active={a === "ablesign"} />
|
||||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||||
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||||
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue