mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
Compare commits
20 commits
v0.0.123-d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a518fe17ea | ||
|
|
73dbd9b6bf | ||
|
|
c3bdcbce4c | ||
|
|
5ce526eb33 | ||
|
|
55b11f2ffa | ||
|
|
eb8abbdff9 | ||
|
|
8c59bb6b02 | ||
|
|
9eeddff680 | ||
|
|
38c78c0bb5 | ||
|
|
8381ed280e | ||
|
|
5d23079086 | ||
|
|
515f7088cc | ||
|
|
b93e9484ff | ||
|
|
420463afdc | ||
|
|
9dc6119791 | ||
|
|
02b69713c3 | ||
|
|
fe9c51d3f0 | ||
|
|
108123fb86 | ||
|
|
9b4032ca8a | ||
|
|
908fd417c0 |
39 changed files with 2188 additions and 1712 deletions
|
|
@ -1,2 +1,3 @@
|
|||
d /run/betterframe 0755 bfkiosk bfkiosk -
|
||||
d /var/lib/betterframe/kiosk 0755 bfkiosk bfkiosk -
|
||||
d /var/lib/betterframe/tmp 0755 bfkiosk bfkiosk -
|
||||
|
|
|
|||
138
kiosk/Cargo.lock
generated
138
kiosk/Cargo.lock
generated
|
|
@ -2,6 +2,41 @@
|
|||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
|
|
@ -138,6 +173,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
|||
name = "betterframe-kiosk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"axum",
|
||||
"base64",
|
||||
"dirs",
|
||||
|
|
@ -149,11 +185,14 @@ dependencies = [
|
|||
"gstreamer-video",
|
||||
"gtk4",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hostname",
|
||||
"md5",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
|
|
@ -161,6 +200,7 @@ dependencies = [
|
|||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"webkit6",
|
||||
]
|
||||
|
||||
|
|
@ -263,6 +303,16 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
|
@ -326,9 +376,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
|
|
@ -381,6 +441,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -743,6 +804,16 @@ dependencies = [
|
|||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.20.12"
|
||||
|
|
@ -1151,6 +1222,24 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.4.2"
|
||||
|
|
@ -1430,6 +1519,15 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
|
|
@ -1546,6 +1644,12 @@ version = "0.7.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
|
|
@ -1655,6 +1759,12 @@ version = "1.21.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.79"
|
||||
|
|
@ -1786,6 +1896,18 @@ version = "0.3.33"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
|
|
@ -2696,6 +2818,16 @@ version = "0.2.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
@ -2714,6 +2846,12 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ urlencoding = "2"
|
|||
|
||||
# ONVIF WSSE PasswordDigest auth
|
||||
sha1 = "0.10"
|
||||
# HTTP Digest auth for camera image fetch
|
||||
md5 = "0.7"
|
||||
|
||||
# Hardware-bound at-rest encryption of state files (kiosk_key + bundle cache
|
||||
# contain camera RTSP credentials in URL form). Keys derived via HKDF from
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
|
@ -1,5 +1,35 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
fn de_flexible_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let v = serde_json::Value::deserialize(deserializer)?;
|
||||
match v {
|
||||
serde_json::Value::String(s) => Ok(s),
|
||||
serde_json::Value::Number(n) => Ok(n.to_string()),
|
||||
_ => Err(serde::de::Error::custom("expected string or number for id")),
|
||||
}
|
||||
}
|
||||
|
||||
fn de_flexible_id_opt<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
|
||||
let v = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||
match v {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(serde_json::Value::String(s)) if s.is_empty() => Ok(None),
|
||||
Some(serde_json::Value::String(s)) => Ok(Some(s)),
|
||||
Some(serde_json::Value::Number(n)) => Ok(Some(n.to_string())),
|
||||
_ => Err(serde::de::Error::custom("expected string or number for id")),
|
||||
}
|
||||
}
|
||||
|
||||
fn de_flexible_id_vec<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
|
||||
let v = Vec::<serde_json::Value>::deserialize(deserializer)?;
|
||||
v.into_iter()
|
||||
.map(|item| match item {
|
||||
serde_json::Value::String(s) => Ok(s),
|
||||
serde_json::Value::Number(n) => Ok(n.to_string()),
|
||||
_ => Err(serde::de::Error::custom("expected string or number in id array")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct KioskBundle {
|
||||
|
|
@ -39,7 +69,7 @@ impl KioskBundle {
|
|||
height_px: d.height_px,
|
||||
idle_timeout_seconds: d.idle_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(),
|
||||
}];
|
||||
}
|
||||
|
|
@ -49,37 +79,43 @@ impl KioskBundle {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleDisplay {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub width_px: u32,
|
||||
pub height_px: u32,
|
||||
pub idle_timeout_seconds: u32,
|
||||
pub sleep_timeout_seconds: u32,
|
||||
pub default_layout_id: Option<u32>,
|
||||
#[serde(default, deserialize_with = "de_flexible_id_opt")]
|
||||
pub default_layout_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleDisplayWithLayouts {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub width_px: u32,
|
||||
pub height_px: u32,
|
||||
pub idle_timeout_seconds: u32,
|
||||
pub sleep_timeout_seconds: u32,
|
||||
pub default_layout_id: Option<u32>,
|
||||
#[serde(default, deserialize_with = "de_flexible_id_opt")]
|
||||
pub default_layout_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub layouts: Vec<BundleLayout>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleLayout {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub grid_cols: u32,
|
||||
pub grid_rows: u32,
|
||||
pub priority: String,
|
||||
pub cooling_timeout_seconds: Option<u32>,
|
||||
pub preload_camera_ids: Vec<u32>,
|
||||
#[serde(default, deserialize_with = "de_flexible_id_vec")]
|
||||
pub preload_camera_ids: Vec<String>,
|
||||
pub is_default: bool,
|
||||
pub resets_idle_timer: bool,
|
||||
pub cells: Vec<BundleCell>,
|
||||
|
|
@ -137,7 +173,8 @@ pub struct SmartUrlStep {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleCamera {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub cam_type: String,
|
||||
|
|
@ -162,7 +199,8 @@ pub struct BundleCamera {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleStream {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub name: String,
|
||||
pub rtsp_uri: String,
|
||||
|
|
@ -174,7 +212,8 @@ pub struct BundleStream {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleGpioBinding {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub chip: String,
|
||||
pub pin: u32,
|
||||
pub direction: String,
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> {
|
|||
let _ = fs::rename(&bin, &prev_path);
|
||||
}
|
||||
fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?;
|
||||
info!("preboot firmware: updated to {}, exiting for restart", info.version);
|
||||
info!("preboot firmware: updated to {}, rebooting", info.version);
|
||||
let _ = std::process::Command::new("systemctl").arg("reboot").status();
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
|
|
@ -267,10 +269,18 @@ pub fn apply(
|
|||
.timeout(Duration::from_secs(5))
|
||||
.send();
|
||||
|
||||
on_progress("Restarting", 100);
|
||||
info!("firmware: swap complete → exiting for systemd to relaunch");
|
||||
// systemd Restart=always picks up the new binary on next start.
|
||||
std::process::exit(0);
|
||||
on_progress("Rebooting", 100);
|
||||
info!("firmware: swap complete → rebooting to pick up new binary");
|
||||
match std::process::Command::new("systemctl").arg("reboot").status() {
|
||||
Ok(_) => {
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("systemctl reboot failed: {e}, falling back to exit");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_signature(public_key_pem: &str, sha256_hex: &str, sig_b64url: &str) -> Result<(), String> {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ async fn local_info_handler(
|
|||
|
||||
async fn local_layout_handler(
|
||||
State(state): State<LocalServerState>,
|
||||
Path(id): Path<u32>,
|
||||
Path(id): Path<String>,
|
||||
Query(auth): Query<LocalAuth>,
|
||||
) -> Response {
|
||||
if !constant_time_eq(&auth.key, &state.local_key) {
|
||||
|
|
@ -127,7 +127,7 @@ async fn local_layout_handler(
|
|||
};
|
||||
if let Err(e) = tx.send(WorkerMsg::SwitchLayout {
|
||||
display_id: None,
|
||||
layout_id: id,
|
||||
layout_id: id.clone(),
|
||||
}) {
|
||||
warn!("local-server: send SwitchLayout failed: {e}");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "send failed").into_response();
|
||||
|
|
@ -152,7 +152,7 @@ async fn local_layout_handler(
|
|||
/// "good enough" and isolated.
|
||||
async fn local_snapshot_handler(
|
||||
State(state): State<LocalServerState>,
|
||||
Path(camera_id): Path<u32>,
|
||||
Path(camera_id): Path<String>,
|
||||
Query(auth): Query<LocalAuth>,
|
||||
) -> Response {
|
||||
if !constant_time_eq(&auth.key, &state.local_key) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
mod at_rest;
|
||||
mod audio;
|
||||
mod axiom;
|
||||
mod bundle;
|
||||
mod cec;
|
||||
|
|
@ -18,15 +19,20 @@ pub use ui::WorkerMsg;
|
|||
|
||||
pub enum ServerMsg {
|
||||
ReloadBundle,
|
||||
Standby(Option<u32>),
|
||||
Wake(Option<u32>),
|
||||
Standby(Option<String>),
|
||||
Wake(Option<String>),
|
||||
/// Some(0..=255) = manual PWM. None = restore auto.
|
||||
Fan(Option<u32>),
|
||||
/// Switch to a specific layout by ID, optionally scoped to one display.
|
||||
SwitchLayout {
|
||||
display_id: Option<u32>,
|
||||
layout_id: u32,
|
||||
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".
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ use crate::bundle::BundleCamera;
|
|||
|
||||
/// Active subscriptions keyed by camera id. Worker threads check this
|
||||
/// to know when to stop (camera removed from bundle / bundle changed).
|
||||
static ACTIVE: Mutex<Option<HashMap<u32, ()>>> = Mutex::new(None);
|
||||
static ACTIVE: Mutex<Option<HashMap<String, ()>>> = Mutex::new(None);
|
||||
|
||||
/// Holds the current generation Arc. When start() replaces it, the old
|
||||
/// Arc drops → old threads' Weak::upgrade() returns None → they exit.
|
||||
|
|
@ -34,38 +34,79 @@ static ACTIVE: Mutex<Option<HashMap<u32, ()>>> = Mutex::new(None);
|
|||
static GENERATION: Mutex<Option<Arc<()>>> = Mutex::new(None);
|
||||
|
||||
/// Subscription status per camera — reported in heartbeat for admin visibility.
|
||||
static STATUS: Mutex<Option<HashMap<u32, SubStatus>>> = Mutex::new(None);
|
||||
static STATUS: Mutex<Option<HashMap<String, SubStatus>>> = Mutex::new(None);
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct SubStatus {
|
||||
pub state: &'static str, // "subscribing", "active", "failed", "stopped"
|
||||
pub state: &'static str,
|
||||
pub last_event_at: Option<String>,
|
||||
pub subscribed_at: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn set_status(cam_id: u32, state: &'static str, 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>) {
|
||||
let mut map = STATUS.lock().unwrap();
|
||||
let map = map.get_or_insert_with(HashMap::new);
|
||||
let entry = map.entry(cam_id).or_insert_with(|| SubStatus {
|
||||
let entry = map.entry(cam_id.to_string()).or_insert_with(|| SubStatus {
|
||||
state: "subscribing",
|
||||
last_event_at: None,
|
||||
subscribed_at: None,
|
||||
error: None,
|
||||
});
|
||||
entry.state = state;
|
||||
entry.error = error;
|
||||
if state == "active" {
|
||||
entry.subscribed_at = Some(epoch_now());
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_event_received(cam_id: u32) {
|
||||
fn mark_event_received(cam_id: &str) {
|
||||
let mut map = STATUS.lock().unwrap();
|
||||
if let Some(map) = map.as_mut() {
|
||||
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
|
||||
if let Some(entry) = map.get_mut(cam_id) {
|
||||
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.
|
||||
pub fn get_statuses() -> HashMap<u32, SubStatus> {
|
||||
pub fn get_statuses() -> HashMap<String, SubStatus> {
|
||||
STATUS.lock().unwrap().clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +140,7 @@ pub fn start(
|
|||
|
||||
// Signal old workers to stop.
|
||||
let mut active = ACTIVE.lock().unwrap();
|
||||
let new_map: HashMap<u32, ()> = onvif_cams.iter().map(|c| (c.id, ())).collect();
|
||||
let new_map: HashMap<String, ()> = onvif_cams.iter().map(|c| (c.id.clone(), ())).collect();
|
||||
*active = Some(new_map);
|
||||
drop(active);
|
||||
|
||||
|
|
@ -146,18 +187,18 @@ fn run_subscription(
|
|||
}
|
||||
|
||||
// 1. CreatePullPointSubscription
|
||||
set_status(cam.id, "subscribing", None);
|
||||
set_status(&cam.id, "subscribing", None);
|
||||
let sub = match create_pullpoint(&event_url, user, pass) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("onvif-events: cam {} CreatePullPoint failed: {e}", cam.id);
|
||||
set_status(cam.id, "failed", Some(e));
|
||||
set_status(&cam.id, "failed", Some(e));
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address);
|
||||
set_status(cam.id, "active", None);
|
||||
set_status(&cam.id, "active", None);
|
||||
|
||||
// 2. Poll loop
|
||||
let poll_interval = Duration::from_secs(3);
|
||||
|
|
@ -184,12 +225,13 @@ fn run_subscription(
|
|||
match pull_messages(&sub.address, user, pass) {
|
||||
Ok(events) => {
|
||||
for evt in events {
|
||||
forward_event(server, kiosk_key, cam.id, &evt);
|
||||
forward_event(server, kiosk_key, &cam.id, &evt, user, pass);
|
||||
mark_event_received(&cam.id);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("onvif-events: cam {} pull failed: {e}", cam.id);
|
||||
set_status(cam.id, "failed", Some(e));
|
||||
set_status(&cam.id, "failed", Some(e));
|
||||
std::thread::sleep(Duration::from_secs(15));
|
||||
break; // resubscribe after backoff
|
||||
}
|
||||
|
|
@ -406,6 +448,11 @@ fn parse_notification_messages(xml: &str) -> Vec<OnvifEvent> {
|
|||
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") {
|
||||
for (name, value) in parse_simple_items(&data_block) {
|
||||
data.insert(name, value);
|
||||
|
|
@ -542,12 +589,23 @@ fn extract_attr_inline(xml: &str, attr: &str) -> Option<String> {
|
|||
|
||||
// ---- Forward to BF server --------------------------------------------------
|
||||
|
||||
fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent) {
|
||||
let payload = serde_json::json!({
|
||||
fn forward_event(
|
||||
server: &str,
|
||||
kiosk_key: &str,
|
||||
camera_id: &str,
|
||||
evt: &OnvifEvent,
|
||||
cam_user: &str,
|
||||
cam_pass: &str,
|
||||
) {
|
||||
let attachments = fetch_image_attachments(&evt.data, cam_user, cam_pass);
|
||||
let mut payload = serde_json::json!({
|
||||
"source": evt.source,
|
||||
"data": evt.data,
|
||||
"timestamp": evt.timestamp,
|
||||
});
|
||||
if !attachments.is_empty() {
|
||||
payload["attachments"] = serde_json::json!(attachments);
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"topic": evt.topic,
|
||||
"source_type": "onvif",
|
||||
|
|
@ -559,10 +617,144 @@ fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent
|
|||
.post(format!("{server}/api/kiosk/event"))
|
||||
.header("Authorization", format!("Bearer {kiosk_key}"))
|
||||
.json(&body)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send();
|
||||
}
|
||||
|
||||
fn fetch_image_attachments(
|
||||
data: &HashMap<String, String>,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
) -> HashMap<String, String> {
|
||||
let mut attachments = HashMap::new();
|
||||
let image_exts = [".jpg", ".jpeg", ".png", ".bmp"];
|
||||
for (key, value) in data {
|
||||
if !value.starts_with("http://") && !value.starts_with("https://") {
|
||||
continue;
|
||||
}
|
||||
let lower = value.to_lowercase();
|
||||
if !image_exts.iter().any(|ext| lower.contains(ext)) {
|
||||
continue;
|
||||
}
|
||||
match fetch_image_b64(value, user, pass) {
|
||||
Some(b64) => {
|
||||
let mime = if lower.contains(".png") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
};
|
||||
attachments.insert(key.clone(), format!("data:{mime};base64,{b64}"));
|
||||
}
|
||||
None => {
|
||||
warn!("onvif-events: failed to fetch image for {key}: {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments
|
||||
}
|
||||
|
||||
fn fetch_image_b64(url: &str, user: &str, pass: &str) -> Option<String> {
|
||||
use base64::Engine;
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get(url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
// Retry with digest auth if basic auth returned 401.
|
||||
if status.as_u16() == 401 {
|
||||
return fetch_image_b64_digest(url, user, pass);
|
||||
}
|
||||
warn!("onvif-events: image fetch HTTP {status} for {url}");
|
||||
return None;
|
||||
}
|
||||
let bytes = resp.bytes().ok()?;
|
||||
if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
fn fetch_image_b64_digest(url: &str, user: &str, pass: &str) -> Option<String> {
|
||||
use base64::Engine;
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.ok()?;
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("Authorization", digest_auth_header(url, user, pass)?)
|
||||
.send()
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let bytes = resp.bytes().ok()?;
|
||||
if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
fn digest_auth_header(url: &str, user: &str, pass: &str) -> Option<String> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client.get(url).timeout(Duration::from_secs(3)).send().ok()?;
|
||||
if resp.status().as_u16() != 401 {
|
||||
return None;
|
||||
}
|
||||
let www_auth = resp.headers().get("www-authenticate")?.to_str().ok()?;
|
||||
if !www_auth.to_lowercase().starts_with("digest ") {
|
||||
return None;
|
||||
}
|
||||
let realm = extract_digest_field(www_auth, "realm")?;
|
||||
let nonce = extract_digest_field(www_auth, "nonce")?;
|
||||
let qop = extract_digest_field(www_auth, "qop").unwrap_or_default();
|
||||
let uri = url::Url::parse(url).ok().map(|u| u.path().to_string()).unwrap_or_else(|| "/".to_string());
|
||||
let ha1 = md5_hex(&format!("{user}:{realm}:{pass}"));
|
||||
let ha2 = md5_hex(&format!("GET:{uri}"));
|
||||
let cnonce = format!("{:08x}", rand::random::<u32>());
|
||||
let nc = "00000001";
|
||||
let response = if qop.contains("auth") {
|
||||
md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"))
|
||||
} else {
|
||||
md5_hex(&format!("{ha1}:{nonce}:{ha2}"))
|
||||
};
|
||||
if qop.contains("auth") {
|
||||
Some(format!(
|
||||
r#"Digest username="{user}", realm="{realm}", nonce="{nonce}", uri="{uri}", response="{response}", qop=auth, nc={nc}, cnonce="{cnonce}""#
|
||||
))
|
||||
} else {
|
||||
Some(format!(
|
||||
r#"Digest username="{user}", realm="{realm}", nonce="{nonce}", uri="{uri}", response="{response}""#
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_digest_field(header: &str, field: &str) -> Option<String> {
|
||||
let pat = format!("{field}=\"");
|
||||
let start = header.find(&pat)? + pat.len();
|
||||
let end = header[start..].find('"')?;
|
||||
Some(header[start..start + end].to_string())
|
||||
}
|
||||
|
||||
fn md5_hex(input: &str) -> String {
|
||||
let digest = md5::compute(input.as_bytes());
|
||||
hex_lower_bytes(&digest.0)
|
||||
}
|
||||
|
||||
fn hex_lower_bytes(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push(HEX[(b >> 4) as usize] as char);
|
||||
s.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ---- Cluster key decryption ------------------------------------------------
|
||||
|
||||
/// Decrypt a value encrypted with secrets.encryptForCluster on the server.
|
||||
|
|
|
|||
|
|
@ -428,8 +428,8 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option<KioskBundle> {
|
|||
pub fn report_layout_change(
|
||||
server: &str,
|
||||
key: &str,
|
||||
display_id: u32,
|
||||
layout_id: u32,
|
||||
display_id: &str,
|
||||
layout_id: &str,
|
||||
layout_name: &str,
|
||||
) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
267
kiosk/src/ui.rs
267
kiosk/src/ui.rs
|
|
@ -1,10 +1,13 @@
|
|||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{mpsc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use url::Url;
|
||||
|
||||
static FIRMWARE_LOCK: Mutex<()> = Mutex::new(());
|
||||
static OS_UPDATE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture,
|
||||
|
|
@ -30,7 +33,7 @@ use crate::ws_client;
|
|||
/// even though the GTK main loop is shared.
|
||||
struct DisplayState {
|
||||
window: ApplicationWindow,
|
||||
current_layout_id: Option<u32>,
|
||||
current_layout_id: Option<String>,
|
||||
last_activity: Instant,
|
||||
is_asleep: bool,
|
||||
}
|
||||
|
|
@ -58,7 +61,7 @@ struct PipelineEntry {
|
|||
/// per (main, sub, other) stream — each with independent warmth state. When a
|
||||
/// cell switches M↔S we promote the new variant to Warm/Hot but leave the old
|
||||
/// one to cool down naturally so a quick swap back is instant.
|
||||
type PoolKey = (u32, char);
|
||||
type PoolKey = (String, char);
|
||||
|
||||
/// WebView pool entry. Same Hot/Warm/Cooling/Cold lifecycle as cameras —
|
||||
/// switching to a layout that doesn't reference a previously-loaded URL/HTML
|
||||
|
|
@ -95,7 +98,7 @@ thread_local! {
|
|||
static CURRENT_SYNC_LABEL: RefCell<String> = RefCell::new(String::from("unknown"));
|
||||
|
||||
/// Per-display state, keyed by bundle display id.
|
||||
static DISPLAYS: RefCell<HashMap<u32, DisplayState>> = RefCell::new(HashMap::new());
|
||||
static DISPLAYS: RefCell<HashMap<String, DisplayState>> = RefCell::new(HashMap::new());
|
||||
|
||||
/// Has the idle-watchdog already been installed on the main loop?
|
||||
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
||||
|
|
@ -171,7 +174,7 @@ fn activate(app: &Application) {
|
|||
// cached on-disk bundle and keep retrying every 30s in the background.
|
||||
let initial = match server::fetch_bundle(&server, &key) {
|
||||
Some(b) => {
|
||||
crate::axiom::set_kiosk_id(b.kiosk_id.to_string());
|
||||
crate::axiom::set_kiosk_id(b.kiosk_id.clone());
|
||||
info!(
|
||||
"bundle: {} cameras, {} display(s)",
|
||||
b.cameras.len(),
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -306,10 +325,12 @@ fn activate(app: &Application) {
|
|||
firmware::mark_firmware_applied();
|
||||
mark_kiosk_healthy();
|
||||
mark_rauc_slot_good();
|
||||
cleanup_stale_files();
|
||||
first_iter = false;
|
||||
}
|
||||
maybe_apply_os_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));
|
||||
}
|
||||
});
|
||||
|
|
@ -329,14 +350,14 @@ fn activate(app: &Application) {
|
|||
display_id,
|
||||
layout_id,
|
||||
} => {
|
||||
if let Some(display_id) = display_id {
|
||||
render_layout(display_id, layout_id);
|
||||
if let Some(display_id) = &display_id {
|
||||
render_layout(display_id, &layout_id);
|
||||
} else {
|
||||
switch_layout_anywhere(layout_id);
|
||||
switch_layout_anywhere(&layout_id);
|
||||
}
|
||||
}
|
||||
WorkerMsg::Standby(display_id) => standby_display(display_id),
|
||||
WorkerMsg::Wake(display_id) => wake_display(display_id),
|
||||
WorkerMsg::Standby(display_id) => standby_display(display_id.as_deref()),
|
||||
WorkerMsg::Wake(display_id) => wake_display(display_id.as_deref()),
|
||||
WorkerMsg::ShowTerminalCode(code) => show_terminal_code_overlay(&code),
|
||||
WorkerMsg::DismissTerminalCode => dismiss_terminal_code_overlay(),
|
||||
WorkerMsg::UpdateProgress(progress) => show_update_banner(progress),
|
||||
|
|
@ -350,11 +371,11 @@ pub enum WorkerMsg {
|
|||
ShowPairingCode(String),
|
||||
RenderBundle(KioskBundle, String, String),
|
||||
SwitchLayout {
|
||||
display_id: Option<u32>,
|
||||
layout_id: u32,
|
||||
display_id: Option<String>,
|
||||
layout_id: String,
|
||||
},
|
||||
Standby(Option<u32>),
|
||||
Wake(Option<u32>),
|
||||
Standby(Option<String>),
|
||||
Wake(Option<String>),
|
||||
ShowTerminalCode(String),
|
||||
DismissTerminalCode,
|
||||
/// Update progress banner — shown as overlay on all displays.
|
||||
|
|
@ -362,7 +383,7 @@ pub enum WorkerMsg {
|
|||
UpdateProgress(Option<(String, u8)>),
|
||||
}
|
||||
|
||||
fn output_name_for_display(display_id: u32) -> Option<String> {
|
||||
fn output_name_for_display(display_id: &str) -> Option<String> {
|
||||
CURRENT_BUNDLE.with(|b| {
|
||||
b.borrow()
|
||||
.as_ref()
|
||||
|
|
@ -376,7 +397,7 @@ fn output_name_for_display(display_id: u32) -> Option<String> {
|
|||
})
|
||||
}
|
||||
|
||||
fn standby_display(display_id: Option<u32>) {
|
||||
fn standby_display(display_id: Option<&str>) {
|
||||
if let Some(display_id) = display_id {
|
||||
if let Some(output_name) = output_name_for_display(display_id) {
|
||||
cec::standby_output(&output_name);
|
||||
|
|
@ -384,7 +405,7 @@ fn standby_display(display_id: Option<u32>) {
|
|||
cec::standby();
|
||||
}
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
st.is_asleep = true;
|
||||
}
|
||||
});
|
||||
|
|
@ -398,7 +419,7 @@ fn standby_display(display_id: Option<u32>) {
|
|||
}
|
||||
}
|
||||
|
||||
fn wake_display(display_id: Option<u32>) {
|
||||
fn wake_display(display_id: Option<&str>) {
|
||||
if let Some(display_id) = display_id {
|
||||
if let Some(output_name) = output_name_for_display(display_id) {
|
||||
cec::wake_output(&output_name);
|
||||
|
|
@ -406,7 +427,7 @@ fn wake_display(display_id: Option<u32>) {
|
|||
cec::wake();
|
||||
}
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
st.is_asleep = false;
|
||||
st.last_activity = Instant::now();
|
||||
}
|
||||
|
|
@ -423,9 +444,9 @@ fn wake_display(display_id: Option<u32>) {
|
|||
}
|
||||
|
||||
/// Reset activity timer for one display. If asleep, wake it.
|
||||
fn mark_activity(display_id: u32) {
|
||||
fn mark_activity(display_id: &str) {
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
st.last_activity = Instant::now();
|
||||
if st.is_asleep {
|
||||
info!("activity while asleep → waking display {display_id}");
|
||||
|
|
@ -451,11 +472,12 @@ fn send_heartbeat_now(server_url: &str, kiosk_key: &str) -> bool {
|
|||
.map(|(index, (name, width_px, height_px))| {
|
||||
let bundle_id = bundle_displays
|
||||
.get(index)
|
||||
.map(|d| d.id)
|
||||
.or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id));
|
||||
.map(|d| d.id.clone())
|
||||
.or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id.clone()));
|
||||
let power_state = bundle_id
|
||||
.as_deref()
|
||||
.and_then(|id| {
|
||||
DISPLAYS.with(|ds| ds.borrow().get(&id).map(|st| st.is_asleep))
|
||||
DISPLAYS.with(|ds| ds.borrow().get(id).map(|st| st.is_asleep))
|
||||
})
|
||||
.map(|is_asleep| if is_asleep { "standby" } else { "awake" })
|
||||
.unwrap_or("unknown")
|
||||
|
|
@ -495,14 +517,45 @@ fn mark_rauc_slot_good() {
|
|||
.status();
|
||||
}
|
||||
|
||||
fn cleanup_stale_files() {
|
||||
// Stale OS update downloads in staging dir.
|
||||
let staging = std::path::Path::new("/var/lib/betterframe/tmp");
|
||||
if staging.is_dir() {
|
||||
if let Ok(entries) = fs::read_dir(staging) {
|
||||
let cutoff = std::time::SystemTime::now() - Duration::from_secs(24 * 3600);
|
||||
for entry in entries.flatten() {
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
let old = meta.modified().map(|m| m < cutoff).unwrap_or(false);
|
||||
if old {
|
||||
info!("cleanup: removing stale staging file {}", entry.path().display());
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Old firmware .prev binary (only keep if < 7 days old as rollback safety).
|
||||
let prev = std::path::Path::new("/opt/betterframe/kiosk/betterframe-kiosk.prev");
|
||||
if prev.exists() {
|
||||
let cutoff = std::time::SystemTime::now() - Duration::from_secs(7 * 24 * 3600);
|
||||
if let Ok(meta) = prev.metadata() {
|
||||
if meta.modified().map(|m| m < cutoff).unwrap_or(false) {
|
||||
info!("cleanup: removing old firmware .prev");
|
||||
let _ = fs::remove_file(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server whether a full-OS RAUC bundle is available for this
|
||||
/// kiosk. On hit, download + sha256 + `rauc install` + reboot. On miss or
|
||||
/// error: log + keep running. Gated by BF_ENABLE_OS_OTA=1 (default OFF
|
||||
/// for dev kiosks running a non-A/B image).
|
||||
/// kiosk.
|
||||
fn maybe_apply_os_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sender<WorkerMsg>) {
|
||||
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;
|
||||
};
|
||||
|
|
@ -549,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;
|
||||
|
|
@ -595,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
|
||||
/// per display. Safe to call multiple times — installs at most once.
|
||||
fn install_idle_watchdog() {
|
||||
|
|
@ -614,8 +695,8 @@ fn install_idle_watchdog() {
|
|||
|
||||
// Snapshot per-display timing decisions so we can act outside the borrow.
|
||||
struct Action {
|
||||
display_id: u32,
|
||||
revert_to: Option<u32>,
|
||||
display_id: String,
|
||||
revert_to: Option<String>,
|
||||
sleep: bool,
|
||||
}
|
||||
let mut actions: Vec<Action> = Vec::new();
|
||||
|
|
@ -632,10 +713,10 @@ fn install_idle_watchdog() {
|
|||
let idle_to = d.idle_timeout_seconds as u64;
|
||||
let sleep_to = d.sleep_timeout_seconds as u64;
|
||||
let elapsed = st.last_activity.elapsed();
|
||||
let default_id = d.default_layout_id;
|
||||
let default_id = d.default_layout_id.clone();
|
||||
|
||||
let mut act = Action {
|
||||
display_id: *display_id,
|
||||
display_id: display_id.clone(),
|
||||
revert_to: None,
|
||||
sleep: false,
|
||||
};
|
||||
|
|
@ -643,12 +724,13 @@ fn install_idle_watchdog() {
|
|||
if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
|
||||
let cur_resets_idle = st
|
||||
.current_layout_id
|
||||
.and_then(|cur_id| d.layouts.iter().find(|l| l.id == cur_id))
|
||||
.as_ref()
|
||||
.and_then(|cur_id| d.layouts.iter().find(|l| l.id == *cur_id))
|
||||
.map(|l| l.resets_idle_timer)
|
||||
.unwrap_or(false);
|
||||
if let (Some(cur_id), Some(def_id)) = (st.current_layout_id, default_id) {
|
||||
if let (Some(cur_id), Some(def_id)) = (&st.current_layout_id, &default_id) {
|
||||
if cur_id != def_id && cur_resets_idle {
|
||||
act.revert_to = Some(def_id);
|
||||
act.revert_to = Some(def_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -667,7 +749,7 @@ fn install_idle_watchdog() {
|
|||
"idle timeout reached → reverting display {} to default",
|
||||
a.display_id
|
||||
);
|
||||
render_layout(a.display_id, layout_id);
|
||||
render_layout(&a.display_id, &layout_id);
|
||||
}
|
||||
if a.sleep {
|
||||
info!(
|
||||
|
|
@ -790,11 +872,11 @@ fn render_bundle(
|
|||
|
||||
// Collect camera IDs actually referenced in layout cells.
|
||||
let displays = bundle.normalized_displays();
|
||||
let layout_cam_ids: std::collections::HashSet<u32> = 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)
|
||||
.filter_map(|c| c.camera_id.clone())
|
||||
.collect();
|
||||
|
||||
// Only subscribe to ONVIF events for cameras in layouts (not all bundle cameras).
|
||||
|
|
@ -825,12 +907,12 @@ fn render_bundle(
|
|||
.collect();
|
||||
|
||||
// Tear down any previous per-display windows we no longer need.
|
||||
let keep_ids: std::collections::HashSet<u32> = displays.iter().map(|d| d.id).collect();
|
||||
let to_remove: Vec<u32> = DISPLAYS.with(|ds| {
|
||||
let keep_ids: std::collections::HashSet<&str> = displays.iter().map(|d| d.id.as_str()).collect();
|
||||
let to_remove: Vec<String> = DISPLAYS.with(|ds| {
|
||||
ds.borrow()
|
||||
.keys()
|
||||
.filter(|id| !keep_ids.contains(id))
|
||||
.copied()
|
||||
.filter(|id| !keep_ids.contains(id.as_str()))
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
for id in to_remove {
|
||||
|
|
@ -845,7 +927,7 @@ fn render_bundle(
|
|||
// displays is correct once the loop finishes.
|
||||
|
||||
// Build/reuse window per bundle display, then render its initial layout.
|
||||
let mut new_state: HashMap<u32, DisplayState> = HashMap::new();
|
||||
let mut new_state: HashMap<String, DisplayState> = HashMap::new();
|
||||
for (i, bd) in displays.iter().enumerate() {
|
||||
let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id));
|
||||
let (window, was_asleep) = match existing {
|
||||
|
|
@ -872,7 +954,7 @@ fn render_bundle(
|
|||
}
|
||||
};
|
||||
new_state.insert(
|
||||
bd.id,
|
||||
bd.id.clone(),
|
||||
DisplayState {
|
||||
window,
|
||||
current_layout_id: None,
|
||||
|
|
@ -892,7 +974,7 @@ fn render_bundle(
|
|||
for bd in &displays {
|
||||
let target = pick_initial_layout(bd);
|
||||
if let Some(layout_id) = target {
|
||||
render_layout(bd.id, layout_id);
|
||||
render_layout(&bd.id, &layout_id);
|
||||
} else {
|
||||
warn!("display {} has no default layout", bd.id);
|
||||
DISPLAYS.with(|ds| {
|
||||
|
|
@ -905,19 +987,19 @@ fn render_bundle(
|
|||
}
|
||||
}
|
||||
|
||||
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<u32> {
|
||||
bd.default_layout_id
|
||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id))
|
||||
.or_else(|| bd.layouts.first().map(|l| l.id))
|
||||
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<String> {
|
||||
bd.default_layout_id.clone()
|
||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id.clone()))
|
||||
.or_else(|| bd.layouts.first().map(|l| l.id.clone()))
|
||||
}
|
||||
|
||||
/// Find which display owns a given layout_id and render it there.
|
||||
fn switch_layout_anywhere(layout_id: u32) {
|
||||
fn switch_layout_anywhere(layout_id: &str) {
|
||||
let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
|
||||
let Some(bundle) = bundle else { return };
|
||||
for bd in bundle.normalized_displays() {
|
||||
if bd.layouts.iter().any(|l| l.id == layout_id) {
|
||||
render_layout(bd.id, layout_id);
|
||||
render_layout(&bd.id, layout_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -925,7 +1007,7 @@ fn switch_layout_anywhere(layout_id: u32) {
|
|||
}
|
||||
|
||||
/// Render a specific layout id on a specific display.
|
||||
fn render_layout(display_id: u32, layout_id: u32) {
|
||||
fn render_layout(display_id: &str, layout_id: &str) {
|
||||
mark_activity(display_id);
|
||||
|
||||
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
|
||||
|
|
@ -950,7 +1032,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
warn!(
|
||||
"render_layout: layout {layout_id} not on display {display_id}, falling back to default"
|
||||
);
|
||||
bd.default_layout_id
|
||||
bd.default_layout_id.as_deref()
|
||||
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
|
||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
|
||||
});
|
||||
|
|
@ -958,7 +1040,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
let Some(layout) = layout else {
|
||||
warn!("render_layout: no usable layout on display {display_id}");
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
show_empty_display_reference(&st.window, &bundle, bd);
|
||||
st.current_layout_id = None;
|
||||
}
|
||||
|
|
@ -971,10 +1053,10 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
let previous_layout_id = DISPLAYS.with(|ds| {
|
||||
let prev = ds
|
||||
.borrow()
|
||||
.get(&display_id)
|
||||
.and_then(|s| s.current_layout_id);
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
st.current_layout_id = Some(layout.id);
|
||||
.get(display_id)
|
||||
.and_then(|s| s.current_layout_id.clone());
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
st.current_layout_id = Some(layout.id.clone());
|
||||
}
|
||||
prev
|
||||
});
|
||||
|
|
@ -992,17 +1074,18 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
// Notify the server when the active layout actually changes so Node-RED
|
||||
// sees idle reverts + any other kiosk-initiated switch. Skip when the
|
||||
// layout id is unchanged (re-render of the same layout).
|
||||
if previous_layout_id != Some(layout.id) {
|
||||
if previous_layout_id.as_deref() != Some(layout.id.as_str()) {
|
||||
let layout_name = layout.name.clone();
|
||||
let layout_id_for_report = layout.id;
|
||||
let layout_id_for_report = layout.id.clone();
|
||||
let display_id_for_report = display_id.to_string();
|
||||
let server = server_url.clone();
|
||||
let key = kiosk_key.clone();
|
||||
std::thread::spawn(move || {
|
||||
server::report_layout_change(
|
||||
&server,
|
||||
&key,
|
||||
display_id,
|
||||
layout_id_for_report,
|
||||
&display_id_for_report,
|
||||
&layout_id_for_report,
|
||||
&layout_name,
|
||||
);
|
||||
});
|
||||
|
|
@ -1012,7 +1095,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
warn!("layout has no cells");
|
||||
recompute_global_state();
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
show_logo(&st.window);
|
||||
}
|
||||
});
|
||||
|
|
@ -1033,21 +1116,21 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
grid.set_vexpand(true);
|
||||
grid.set_hexpand(true);
|
||||
|
||||
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
|
||||
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
||||
let cam_map: HashMap<&str, &crate::bundle::BundleCamera> =
|
||||
bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect();
|
||||
|
||||
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
|
||||
|
||||
// Ensure preloaded cameras have pipelines even if not visible.
|
||||
for cam_id in &layout.preload_camera_ids {
|
||||
if let Some(cam) = cam_map.get(cam_id) {
|
||||
ensure_warm(*cam_id, cam, None, 0.0);
|
||||
if let Some(cam) = cam_map.get(cam_id.as_str()) {
|
||||
ensure_warm(cam_id, cam, None, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
for cell in &layout.cells {
|
||||
let cell_key: Option<String> = match cell.content_type.as_str() {
|
||||
"camera" => cell.camera_id.map(|id| {
|
||||
"camera" => cell.camera_id.as_ref().map(|id| {
|
||||
format!(
|
||||
"cam:{id}:{}",
|
||||
cell.stream_selector.as_deref().unwrap_or("auto")
|
||||
|
|
@ -1063,8 +1146,8 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
};
|
||||
let widget: gtk::Widget = match cell.content_type.as_str() {
|
||||
"camera" => {
|
||||
if let Some(cam_id) = cell.camera_id {
|
||||
if let Some(cam) = cam_map.get(&cam_id) {
|
||||
if let Some(cam_id) = cell.camera_id.as_ref() {
|
||||
if let Some(cam) = cam_map.get(cam_id.as_str()) {
|
||||
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
||||
if let Some((paintable, badge)) =
|
||||
ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area)
|
||||
|
|
@ -1149,7 +1232,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
}
|
||||
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
animate_layout_swap(&st.window, &grid);
|
||||
}
|
||||
});
|
||||
|
|
@ -1401,14 +1484,14 @@ fn recompute_global_state() {
|
|||
let mut hot_set: std::collections::HashSet<PoolKey> = std::collections::HashSet::new();
|
||||
let mut max_cooling_secs: u32 = 0;
|
||||
|
||||
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
|
||||
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
||||
let cam_map: HashMap<&str, &crate::bundle::BundleCamera> =
|
||||
bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect();
|
||||
|
||||
// Snapshot per-display active layout id outside any borrow of WARM_CAMERAS.
|
||||
let active: Vec<(u32, Option<u32>)> = DISPLAYS.with(|ds| {
|
||||
let active: Vec<(String, Option<String>)> = DISPLAYS.with(|ds| {
|
||||
ds.borrow()
|
||||
.iter()
|
||||
.map(|(id, st)| (*id, st.current_layout_id))
|
||||
.map(|(id, st)| (id.clone(), st.current_layout_id.clone()))
|
||||
.collect()
|
||||
});
|
||||
|
||||
|
|
@ -1417,7 +1500,7 @@ fn recompute_global_state() {
|
|||
// missing or no streams).
|
||||
fn cell_keys(
|
||||
layout: &crate::bundle::BundleLayout,
|
||||
cam_map: &HashMap<u32, &crate::bundle::BundleCamera>,
|
||||
cam_map: &HashMap<&str, &crate::bundle::BundleCamera>,
|
||||
out: &mut std::collections::HashSet<PoolKey>,
|
||||
) {
|
||||
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
|
||||
|
|
@ -1425,24 +1508,24 @@ fn recompute_global_state() {
|
|||
if cell.content_type != "camera" {
|
||||
continue;
|
||||
}
|
||||
let Some(cam_id) = cell.camera_id else {
|
||||
let Some(cam_id) = cell.camera_id.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(cam) = cam_map.get(&cam_id) else {
|
||||
let Some(cam) = cam_map.get(cam_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
||||
if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) {
|
||||
out.insert((cam_id, badge));
|
||||
out.insert((cam_id.clone(), badge));
|
||||
}
|
||||
}
|
||||
// Preload cameras have no cell context — let pick_stream choose
|
||||
// (typically sub). Different layouts that actually render them will
|
||||
// promote whichever badge they end up using.
|
||||
for cam_id in &layout.preload_camera_ids {
|
||||
if let Some(cam) = cam_map.get(cam_id) {
|
||||
if let Some(cam) = cam_map.get(cam_id.as_str()) {
|
||||
if let Some((_, badge)) = cam.pick_stream(None, 0.0) {
|
||||
out.insert((*cam_id, badge));
|
||||
out.insert((cam_id.clone(), badge));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1452,7 +1535,7 @@ fn recompute_global_state() {
|
|||
let active_id = active
|
||||
.iter()
|
||||
.find(|(id, _)| *id == bd.id)
|
||||
.and_then(|(_, l)| *l);
|
||||
.and_then(|(_, l)| l.clone());
|
||||
if let Some(cur_id) = active_id {
|
||||
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
||||
cell_keys(layout, &cam_map, &mut warm_set);
|
||||
|
|
@ -1475,7 +1558,7 @@ fn recompute_global_state() {
|
|||
let active_id = active
|
||||
.iter()
|
||||
.find(|(id, _)| *id == bd.id)
|
||||
.and_then(|(_, l)| *l);
|
||||
.and_then(|(_, l)| l.clone());
|
||||
if let Some(cur_id) = active_id {
|
||||
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
||||
web_keys_for_layout(layout, &mut warm_webs);
|
||||
|
|
@ -1525,7 +1608,7 @@ fn recompute_pool_states(
|
|||
continue;
|
||||
}
|
||||
if max_cooling_secs == 0 {
|
||||
to_remove.push(*key);
|
||||
to_remove.push(key.clone());
|
||||
to_stop.push(entry.pipeline.clone());
|
||||
} else {
|
||||
entry.state = WarmthState::Cooling;
|
||||
|
|
@ -1551,15 +1634,15 @@ fn recompute_pool_states(
|
|||
/// Remove warm camera entries for cameras no longer in the bundle.
|
||||
/// Immediately stops pipelines — no cooling period.
|
||||
fn purge_removed_cameras(bundle_cameras: &[crate::bundle::BundleCamera]) {
|
||||
let valid_ids: std::collections::HashSet<u32> = bundle_cameras.iter().map(|c| c.id).collect();
|
||||
let valid_ids: std::collections::HashSet<&str> = bundle_cameras.iter().map(|c| c.id.as_str()).collect();
|
||||
let mut to_remove: Vec<PoolKey> = Vec::new();
|
||||
let mut to_stop: Vec<gstreamer::Pipeline> = Vec::new();
|
||||
|
||||
WARM_CAMERAS.with(|w| {
|
||||
let mut warm = w.borrow_mut();
|
||||
for (key, entry) in warm.iter() {
|
||||
if !valid_ids.contains(&key.0) {
|
||||
to_remove.push(*key);
|
||||
if !valid_ids.contains(key.0.as_str()) {
|
||||
to_remove.push(key.clone());
|
||||
to_stop.push(entry.pipeline.clone());
|
||||
}
|
||||
}
|
||||
|
|
@ -1588,7 +1671,7 @@ fn expire_cooling_pipelines() {
|
|||
.filter(|(_, e)| {
|
||||
e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
for k in keys {
|
||||
if let Some(e) = warm.remove(&k) {
|
||||
|
|
@ -1771,13 +1854,13 @@ fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool {
|
|||
/// that sibling entry alone — recompute_pool_states will demote it to Cooling
|
||||
/// so it can be reused if the cell flips back before the cooldown elapses.
|
||||
fn ensure_warm(
|
||||
cam_id: u32,
|
||||
cam_id: &str,
|
||||
cam: &crate::bundle::BundleCamera,
|
||||
selector: Option<&str>,
|
||||
area_fraction: f32,
|
||||
) -> Option<(gtk::gdk::Paintable, char)> {
|
||||
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
|
||||
let key: PoolKey = (cam_id, desired_badge);
|
||||
let key: PoolKey = (cam_id.to_string(), desired_badge);
|
||||
|
||||
let cached = WARM_CAMERAS.with(|w| {
|
||||
w.borrow()
|
||||
|
|
@ -2179,7 +2262,7 @@ fn add_css(widget: &impl IsA<gtk::Widget>, css: &str) {
|
|||
|
||||
thread_local! {
|
||||
static TERMINAL_CODE_WIDGET: RefCell<Option<gtk::Widget>> = const { RefCell::new(None) };
|
||||
static TERMINAL_CODE_SAVED_CHILD: RefCell<Option<(u32, gtk::Widget)>> = const { RefCell::new(None) };
|
||||
static TERMINAL_CODE_SAVED_CHILD: RefCell<Option<(String, gtk::Widget)>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn show_terminal_code_overlay(code: &str) {
|
||||
|
|
@ -2189,7 +2272,7 @@ fn show_terminal_code_overlay(code: &str) {
|
|||
// Instead, replace the first display window's child with the code
|
||||
// overlay and restore it when dismissed.
|
||||
let display_id = DISPLAYS.with(|ds| {
|
||||
ds.borrow().keys().next().copied()
|
||||
ds.borrow().keys().next().cloned()
|
||||
});
|
||||
let Some(display_id) = display_id else { return };
|
||||
|
||||
|
|
@ -2201,7 +2284,7 @@ fn show_terminal_code_overlay(code: &str) {
|
|||
// Save current child for restore.
|
||||
let old_child = win.child();
|
||||
if let Some(ref c) = old_child {
|
||||
TERMINAL_CODE_SAVED_CHILD.with(|s| *s.borrow_mut() = Some((display_id, c.clone())));
|
||||
TERMINAL_CODE_SAVED_CHILD.with(|s| *s.borrow_mut() = Some((display_id.clone(), c.clone())));
|
||||
}
|
||||
|
||||
// Match the pairing screen layout but with red warning theme.
|
||||
|
|
|
|||
|
|
@ -151,26 +151,26 @@ async fn handle_message(
|
|||
} else if text.contains("\"type\":\"standby\"") {
|
||||
let display_id = serde_json::from_str::<serde_json::Value>(text)
|
||||
.ok()
|
||||
.and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32));
|
||||
.and_then(|m| m.get("display_id").and_then(flexible_id_from_value));
|
||||
let _ = tx.send(ServerMsg::Standby(display_id));
|
||||
} else if text.contains("\"type\":\"wake\"") {
|
||||
let display_id = serde_json::from_str::<serde_json::Value>(text)
|
||||
.ok()
|
||||
.and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32));
|
||||
.and_then(|m| m.get("display_id").and_then(flexible_id_from_value));
|
||||
let _ = tx.send(ServerMsg::Wake(display_id));
|
||||
} else if text.contains("\"type\":\"layout-switch\"") {
|
||||
let msg = serde_json::from_str::<serde_json::Value>(text).ok();
|
||||
let layout_id = msg.as_ref()
|
||||
.and_then(|m| m.get("layout_id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
.and_then(flexible_id_from_value);
|
||||
let display_id = msg.as_ref()
|
||||
.and_then(|m| m.get("display_id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
.and_then(flexible_id_from_value);
|
||||
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\"") {
|
||||
|
|
@ -185,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\"") {
|
||||
|
|
@ -366,6 +380,16 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Extract an ID from a JSON value that may be a string or a number.
|
||||
/// Mirrors the flexible ID deserialization in bundle.rs.
|
||||
fn flexible_id_from_value(v: &serde_json::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ws_url(http_url: &str, token: &str) -> String {
|
||||
let base = if let Some(rest) = http_url.strip_prefix("https://") {
|
||||
format!("wss://{}", rest.split('/').next().unwrap_or(rest))
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ module.exports = function (RED) {
|
|||
function BfKioskCameraEventNode(config) {
|
||||
RED.nodes.createNode(this, config);
|
||||
const node = this;
|
||||
const filterIdRaw = (config.camera_id || "").toString().trim();
|
||||
const filterId = filterIdRaw && !isNaN(Number(filterIdRaw)) ? Number(filterIdRaw) : null;
|
||||
const filterId = (config.camera_id || "").toString().trim() || null;
|
||||
|
||||
async function handler(req, res) {
|
||||
const body = await readJsonBody(req);
|
||||
|
|
@ -37,7 +36,7 @@ module.exports = function (RED) {
|
|||
const cameraId = body.camera_id !== undefined ? body.camera_id
|
||||
: body.source_camera_id !== undefined ? body.source_camera_id
|
||||
: null;
|
||||
if (filterId !== null && Number(cameraId) !== filterId) {
|
||||
if (filterId !== null && String(cameraId) !== filterId) {
|
||||
return res.status(200).end();
|
||||
}
|
||||
const out = {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ module.exports = function (RED) {
|
|||
function BfTriggerAnprNode(config) {
|
||||
RED.nodes.createNode(this, config);
|
||||
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) {
|
||||
const body = await readJsonBody(req);
|
||||
|
|
@ -33,7 +33,7 @@ module.exports = function (RED) {
|
|||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ module.exports = function (RED) {
|
|||
function BfTriggerEventNode(config) {
|
||||
RED.nodes.createNode(this, config);
|
||||
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();
|
||||
|
||||
async function handler(req, res) {
|
||||
|
|
@ -27,7 +27,7 @@ module.exports = function (RED) {
|
|||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ module.exports = function (RED) {
|
|||
function BfTriggerMotionNode(config) {
|
||||
RED.nodes.createNode(this, config);
|
||||
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) {
|
||||
const body = await readJsonBody(req);
|
||||
|
|
@ -34,7 +34,7 @@ module.exports = function (RED) {
|
|||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ default:
|
|||
enabled: true
|
||||
config:
|
||||
db:
|
||||
driver: ${BF_DB_DRIVER}
|
||||
sqlitePath: /var/lib/betterframe/betterframe.db
|
||||
host: ${BF_PG_HOST}
|
||||
port: ${BF_PG_PORT}
|
||||
database: ${BF_PG_DB}
|
||||
|
|
@ -57,8 +55,6 @@ default:
|
|||
enabled: true
|
||||
config:
|
||||
db:
|
||||
driver: ${BF_DB_DRIVER}
|
||||
sqlitePath: /var/lib/betterframe/betterframe.db
|
||||
host: ${BF_PG_HOST}
|
||||
port: ${BF_PG_PORT}
|
||||
database: ${BF_PG_DB}
|
||||
|
|
@ -86,8 +82,6 @@ default:
|
|||
enabled: true
|
||||
config:
|
||||
db:
|
||||
driver: ${BF_DB_DRIVER}
|
||||
sqlitePath: /var/lib/betterframe/betterframe.db
|
||||
host: ${BF_PG_HOST}
|
||||
port: ${BF_PG_PORT}
|
||||
database: ${BF_PG_DB}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { registerOsUpdateRoutes } from "./routes-os-updates.js";
|
|||
import { registerStaticRoutes } from "./routes-static.js";
|
||||
import { registerCloudRoutes } from "./routes-cloud.js";
|
||||
import { registerTenantRoutes } from "./routes-tenants.js";
|
||||
import { registerAbleSignRoutes } from "./routes-ablesign.js";
|
||||
|
||||
// ---- Config -----------------------------------------------------------------
|
||||
|
||||
|
|
@ -42,8 +43,6 @@ const ConfigSchema = av.object(
|
|||
{
|
||||
db: av.object(
|
||||
{
|
||||
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
||||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
||||
url: av.string().default(""),
|
||||
host: av.string().default("postgres"),
|
||||
port: av.int().min(1).max(65535).default(5432),
|
||||
|
|
@ -240,6 +239,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
registerOsUpdateRoutes(app, deps);
|
||||
registerCloudRoutes(app, deps);
|
||||
registerTenantRoutes(app, deps);
|
||||
registerAbleSignRoutes(app, deps);
|
||||
|
||||
// Auth-check endpoint for Angie auth_request subrequest.
|
||||
// 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 ent = await deps.repo.getEntityById(id);
|
||||
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 patch: {
|
||||
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}` } });
|
||||
});
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { AdminDeps } from "./index.js";
|
|||
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
|
||||
import { audit } from "../../shared/audit.js";
|
||||
import { createRateLimiter } from "../../shared/rate-limit.js";
|
||||
import { LoginBody, TotpBody, validateBody } from "../../shared/api-schemas.js";
|
||||
|
||||
|
||||
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||
|
|
@ -37,13 +38,14 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
});
|
||||
}
|
||||
|
||||
const body = await readBody<{ username?: string; password?: string }>(event);
|
||||
const username = (body?.username ?? "").trim();
|
||||
const password = body?.password ?? "";
|
||||
|
||||
if (!username || !password) {
|
||||
return htmlPage(LoginPage({ error: "Username and password required.", username }));
|
||||
let body: { username: string; password: string };
|
||||
try {
|
||||
body = validateBody(LoginBody, await readBody(event));
|
||||
} catch {
|
||||
return htmlPage(LoginPage({ error: "Username and password required.", username: "" }));
|
||||
}
|
||||
const username = body.username.trim();
|
||||
const password = body.password;
|
||||
|
||||
const user = await deps.repo.getUserByUsername(username);
|
||||
if (!user || !user.is_active) {
|
||||
|
|
@ -128,10 +130,15 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
|
||||
const body = await readBody<{ code?: string }>(event);
|
||||
const code = (body?.code ?? "").trim().replace(/\s/g, "");
|
||||
let totpBody: { code: string };
|
||||
try {
|
||||
totpBody = validateBody(TotpBody, await readBody(event));
|
||||
} catch {
|
||||
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
|
||||
}
|
||||
const code = totpBody.code.trim().replace(/\s/g, "");
|
||||
|
||||
if (!code || code.length !== 6) {
|
||||
if (code.length !== 6) {
|
||||
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { type H3, readBody } from "h3";
|
|||
import { htmlPage } from "./html-response.js";
|
||||
import type { AdminDeps } from "./index.js";
|
||||
import { SetupPage } from "../../web-templates/auth-pages.js";
|
||||
import { SetupBody, validateBody } from "../../shared/api-schemas.js";
|
||||
|
||||
export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
||||
app.get("/setup", async () => {
|
||||
|
|
@ -19,19 +20,19 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
|||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
|
||||
const body = await readBody<{ username?: string; password?: string }>(event);
|
||||
const username = (body?.username ?? "").trim();
|
||||
const password = body?.password ?? "";
|
||||
let body: { username: string; password: string };
|
||||
try {
|
||||
body = validateBody(SetupBody, await readBody(event));
|
||||
} catch {
|
||||
return htmlPage(SetupPage({ error: "Username (3-64 chars) and password (12+ chars) required.", username: "" }));
|
||||
}
|
||||
const username = body.username.trim();
|
||||
const password = body.password;
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!username || username.length < 3 || username.length > 64) {
|
||||
errors.push("Username must be 3–64 characters.");
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
errors.push("Username may only contain letters, digits, underscore, or hyphen.");
|
||||
}
|
||||
if (password.length < 12) {
|
||||
errors.push("Password must be at least 12 characters.");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return htmlPage(SetupPage({ error: errors.join(" "), username }));
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ import { createHash } from "node:crypto";
|
|||
import type { AuthApi } from "../../shared/auth.js";
|
||||
import type { SecretsApi } from "../../shared/secrets.js";
|
||||
import type { FirmwareChannel } from "../../shared/types.js";
|
||||
import {
|
||||
PairInitiateBody, PairClaimBody, HeartbeatBody, EventBody,
|
||||
KioskLogsBody, FirmwareAppliedBody, OsAppliedBody, validateBody,
|
||||
} from "../../shared/api-schemas.js";
|
||||
|
||||
// ---- Config -----------------------------------------------------------------
|
||||
|
||||
|
|
@ -38,8 +42,6 @@ const ConfigSchema = av.object(
|
|||
{
|
||||
db: av.object(
|
||||
{
|
||||
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
||||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
||||
url: av.string().default(""),
|
||||
host: av.string().default("postgres"),
|
||||
port: av.int().min(1).max(65535).default(5432),
|
||||
|
|
@ -294,18 +296,13 @@ function registerPairingRoutes(
|
|||
throw createError({ statusCode: 429, statusMessage: "rate limited" });
|
||||
}
|
||||
|
||||
const body = await readBody<{
|
||||
proposed_name?: string;
|
||||
hardware_model?: string;
|
||||
capabilities?: string[];
|
||||
managed_image?: boolean;
|
||||
}>(event);
|
||||
const body = validateBody(PairInitiateBody, await readBody(event));
|
||||
|
||||
const result = await initiatePairing(repo, {
|
||||
proposedName: body?.proposed_name ?? null,
|
||||
hardwareModel: body?.hardware_model ?? null,
|
||||
capabilities: body?.capabilities ?? [],
|
||||
managedImage: body?.managed_image === true,
|
||||
proposedName: body.proposed_name || null,
|
||||
hardwareModel: body.hardware_model || null,
|
||||
capabilities: body.capabilities,
|
||||
managedImage: body.managed_image,
|
||||
codeTtlSeconds: codeTtl,
|
||||
});
|
||||
|
||||
|
|
@ -321,9 +318,8 @@ function registerPairingRoutes(
|
|||
throw createError({ statusCode: 429, statusMessage: "rate limited" });
|
||||
}
|
||||
|
||||
const body = await readBody<{ code?: string }>(event);
|
||||
const code = (body?.code ?? "").trim().toUpperCase();
|
||||
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });
|
||||
const body = validateBody(PairClaimBody, await readBody(event));
|
||||
const code = body.code.trim().toUpperCase();
|
||||
|
||||
const reqObs = event.context.obs!;
|
||||
const result = await claimPairing(repo, code, reqObs);
|
||||
|
|
@ -461,44 +457,7 @@ function registerKioskRoutes(
|
|||
if (!kiosk) return { bf_kiosk_deleted: true };
|
||||
event.context.obs?.log.info("heartbeat from kiosk {id}", { id: String(kiosk.id) });
|
||||
|
||||
const body = await readBody<{
|
||||
bundle_version?: string;
|
||||
kiosk_app_version?: string;
|
||||
os_version?: string;
|
||||
displays?: Array<{
|
||||
index?: number;
|
||||
name: string;
|
||||
width_px: number;
|
||||
height_px: number;
|
||||
power_state?: "awake" | "standby" | "unknown";
|
||||
}>;
|
||||
cpu_temp_c?: number | null;
|
||||
cpu_load_percent?: number | null;
|
||||
fan_rpm?: number | null;
|
||||
fan_pwm?: number | null;
|
||||
memory_total_mb?: number | null;
|
||||
memory_used_mb?: number | null;
|
||||
disk_total_mb?: number | null;
|
||||
disk_free_mb?: number | null;
|
||||
disk_used_percent?: number | null;
|
||||
local_key?: string | null;
|
||||
local_port?: number | null;
|
||||
reported_hostname?: string | null;
|
||||
network_interfaces?: Array<Record<string, unknown>>;
|
||||
partitions?: Array<{
|
||||
device: string;
|
||||
mountpoint: string;
|
||||
total_mb: number;
|
||||
used_mb: number;
|
||||
free_mb: number;
|
||||
used_percent: number;
|
||||
}>;
|
||||
// Managed-image kiosk echoes back the version it last applied, and the
|
||||
// last apply error (if any). Server uses these to decide whether to
|
||||
// include pending_config in the response.
|
||||
managed_config_applied_version?: number;
|
||||
managed_config_error?: string | null;
|
||||
}>(event);
|
||||
const body = validateBody(HeartbeatBody, await readBody(event));
|
||||
|
||||
// Capture the kiosk's LAN-side IP from the heartbeat connection so admin
|
||||
// can render a copy-paste URL even when the kiosk has no DNS name.
|
||||
|
|
@ -507,26 +466,26 @@ function registerKioskRoutes(
|
|||
?? null;
|
||||
|
||||
await repo.touchKiosk(kiosk.id, {
|
||||
bundle_version: body?.bundle_version ?? null,
|
||||
kiosk_app_version: body?.kiosk_app_version ?? null,
|
||||
os_version: body?.os_version ?? null,
|
||||
cpu_temp_c: body?.cpu_temp_c ?? null,
|
||||
cpu_load_percent: body?.cpu_load_percent ?? null,
|
||||
fan_rpm: body?.fan_rpm ?? null,
|
||||
fan_pwm: body?.fan_pwm ?? null,
|
||||
memory_total_mb: body?.memory_total_mb ?? null,
|
||||
memory_used_mb: body?.memory_used_mb ?? null,
|
||||
disk_total_mb: body?.disk_total_mb ?? null,
|
||||
disk_free_mb: body?.disk_free_mb ?? null,
|
||||
disk_used_percent: body?.disk_used_percent ?? null,
|
||||
local_key: body?.local_key ?? null,
|
||||
local_port: body?.local_port ?? null,
|
||||
bundle_version: body.bundle_version ?? null,
|
||||
kiosk_app_version: body.kiosk_app_version ?? null,
|
||||
os_version: body.os_version ?? null,
|
||||
cpu_temp_c: body.cpu_temp_c ?? null,
|
||||
cpu_load_percent: body.cpu_load_percent ?? null,
|
||||
fan_rpm: body.fan_rpm ?? null,
|
||||
fan_pwm: body.fan_pwm ?? null,
|
||||
memory_total_mb: body.memory_total_mb ?? null,
|
||||
memory_used_mb: body.memory_used_mb ?? null,
|
||||
disk_total_mb: body.disk_total_mb ?? null,
|
||||
disk_free_mb: body.disk_free_mb ?? null,
|
||||
disk_used_percent: body.disk_used_percent ?? null,
|
||||
local_key: body.local_key ?? null,
|
||||
local_port: body.local_port ?? null,
|
||||
local_last_ip: remoteIp,
|
||||
reported_hostname: body?.reported_hostname ?? null,
|
||||
network_interfaces_json: Array.isArray(body?.network_interfaces)
|
||||
reported_hostname: body.reported_hostname ?? null,
|
||||
network_interfaces_json: Array.isArray(body.network_interfaces)
|
||||
? JSON.stringify(body.network_interfaces)
|
||||
: null,
|
||||
partitions_json: Array.isArray(body?.partitions)
|
||||
partitions_json: Array.isArray(body.partitions)
|
||||
? JSON.stringify(body.partitions)
|
||||
: null,
|
||||
});
|
||||
|
|
@ -536,7 +495,7 @@ function registerKioskRoutes(
|
|||
// successful apply (kiosk omits it). verifyKioskKey returns just {id};
|
||||
// re-read the full row to check the managed_image flag.
|
||||
const kioskFull = await repo.getKioskById(kiosk.id);
|
||||
if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") {
|
||||
if (kioskFull?.managed_image && typeof body.managed_config_applied_version === "number") {
|
||||
const patch: Record<string, unknown> = {
|
||||
managed_config_applied_version: body.managed_config_applied_version,
|
||||
managed_config_applied_at: new Date().toISOString(),
|
||||
|
|
@ -549,24 +508,24 @@ function registerKioskRoutes(
|
|||
|
||||
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
|
||||
mqtt.publishTelemetry(kiosk.id, {
|
||||
kiosk_app_version: body?.kiosk_app_version,
|
||||
bundle_version: body?.bundle_version,
|
||||
cpu_temp_c: body?.cpu_temp_c,
|
||||
cpu_load_percent: body?.cpu_load_percent,
|
||||
fan_rpm: body?.fan_rpm,
|
||||
fan_pwm: body?.fan_pwm,
|
||||
memory_total_mb: body?.memory_total_mb,
|
||||
memory_used_mb: body?.memory_used_mb,
|
||||
disk_total_mb: body?.disk_total_mb,
|
||||
disk_free_mb: body?.disk_free_mb,
|
||||
disk_used_percent: body?.disk_used_percent,
|
||||
kiosk_app_version: body.kiosk_app_version,
|
||||
bundle_version: body.bundle_version,
|
||||
cpu_temp_c: body.cpu_temp_c,
|
||||
cpu_load_percent: body.cpu_load_percent,
|
||||
fan_rpm: body.fan_rpm,
|
||||
fan_pwm: body.fan_pwm,
|
||||
memory_total_mb: body.memory_total_mb,
|
||||
memory_used_mb: body.memory_used_mb,
|
||||
disk_total_mb: body.disk_total_mb,
|
||||
disk_free_mb: body.disk_free_mb,
|
||||
disk_used_percent: body.disk_used_percent,
|
||||
ip: remoteIp,
|
||||
reported_hostname: body?.reported_hostname,
|
||||
network_interfaces: body?.network_interfaces,
|
||||
reported_hostname: body.reported_hostname,
|
||||
network_interfaces: body.network_interfaces,
|
||||
});
|
||||
|
||||
// Sync displays reported by the kiosk
|
||||
if (Array.isArray(body?.displays)) {
|
||||
if (Array.isArray(body.displays)) {
|
||||
const existing = await repo.listDisplaysForKiosk(kiosk.id);
|
||||
const seenDisplayIds = new Set<string>();
|
||||
for (const [position, reported] of body.displays.entries()) {
|
||||
|
|
@ -667,20 +626,21 @@ function registerKioskRoutes(
|
|||
const kiosk = await auth.verifyKioskKey(token);
|
||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||
|
||||
const body = await readBody<{
|
||||
topic: string;
|
||||
source_type?: string;
|
||||
camera_id?: string;
|
||||
property_op?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}>(event);
|
||||
|
||||
if (!body?.topic) throw createError({ statusCode: 400, statusMessage: "topic required" });
|
||||
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>;
|
||||
event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic });
|
||||
|
||||
// Dedup: Hikvision cameras send duplicate ONVIF events within ~1s.
|
||||
// Key = kiosk_id:camera_id:topic:source_keys_hash. Window = 2s.
|
||||
const dedupKey = `${kiosk.id}:${body.camera_id ?? 0}:${body.topic}:${JSON.stringify(body.payload?.["source"] ?? "")}`;
|
||||
const dedupKey = `${kiosk.id}:${body.camera_id ?? 0}:${body.topic}:${JSON.stringify(payload["source"] ?? "")}`;
|
||||
const now = Date.now();
|
||||
if (eventDedupCache.has(dedupKey)) {
|
||||
const lastSeen = eventDedupCache.get(dedupKey)!;
|
||||
|
|
@ -697,21 +657,38 @@ function registerKioskRoutes(
|
|||
}
|
||||
}
|
||||
|
||||
const eventId = await repo.insertEvent({
|
||||
source_kiosk_id: kiosk.id,
|
||||
source_camera_id: body.camera_id ?? null,
|
||||
source_type: (body.source_type as any) ?? "system",
|
||||
topic: body.topic,
|
||||
property_op: body.property_op ?? null,
|
||||
payload: body.payload ?? {},
|
||||
forwarded_to_nodered: false,
|
||||
});
|
||||
let eventId: string;
|
||||
try {
|
||||
eventId = await repo.insertEvent({
|
||||
source_kiosk_id: kiosk.id,
|
||||
source_camera_id: body.camera_id ?? null,
|
||||
source_type: (body.source_type as any) ?? "system",
|
||||
topic: body.topic,
|
||||
property_op: body.property_op ?? null,
|
||||
payload,
|
||||
forwarded_to_nodered: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.code === "23503") {
|
||||
eventId = await repo.insertEvent({
|
||||
source_kiosk_id: kiosk.id,
|
||||
source_camera_id: null,
|
||||
source_type: (body.source_type as any) ?? "system",
|
||||
topic: body.topic,
|
||||
property_op: body.property_op ?? null,
|
||||
payload,
|
||||
forwarded_to_nodered: false,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Side-effect: persist active layout per display so the admin UI can
|
||||
// surface "currently showing X" without having to query event_log.
|
||||
if (body.topic === "layout.changed") {
|
||||
const displayId = String(body.payload?.["display_id"] ?? "");
|
||||
const layoutId = String(body.payload?.["layout_id"] ?? "");
|
||||
const displayId = String(payload["display_id"] ?? "");
|
||||
const layoutId = String(payload["layout_id"] ?? "");
|
||||
if (displayId && layoutId) {
|
||||
try {
|
||||
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
|
||||
|
|
@ -758,11 +735,11 @@ function registerKioskRoutes(
|
|||
nodered.forward(body.topic, out, markForwarded);
|
||||
mqtt.publishEvent(kiosk.id, body.topic, out);
|
||||
|
||||
// ONVIF events: also forward to the fixed onvif.event route so the
|
||||
// bf-trigger-motion / bf-trigger-anpr / bf-trigger-event nodes
|
||||
// receive them without needing per-topic route registration.
|
||||
nodered.forward("camera.event", out);
|
||||
if (body.source_type === "onvif") {
|
||||
nodered.forward("onvif.event", out);
|
||||
nodered.forward("onvif.motion", out);
|
||||
nodered.forward("onvif.anpr", out);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -777,26 +754,19 @@ function registerKioskRoutes(
|
|||
const kiosk = await auth.verifyKioskKey(token);
|
||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||
|
||||
const body = await readBody<{
|
||||
entries?: Array<{ level?: string; message?: string; context?: Record<string, unknown>; logged_at?: string }>;
|
||||
}>(event);
|
||||
|
||||
const raw = body?.entries;
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
const body = validateBody(KioskLogsBody, await readBody(event));
|
||||
if (body.entries.length === 0) {
|
||||
throw createError({ statusCode: 400, statusMessage: "entries array required" });
|
||||
}
|
||||
if (raw.length > 100) {
|
||||
throw createError({ statusCode: 400, statusMessage: "max 100 entries per batch" });
|
||||
}
|
||||
|
||||
const validLevels = new Set(["debug", "info", "warn", "error"]);
|
||||
const entries = raw
|
||||
.filter((e) => e.message && typeof e.message === "string")
|
||||
.map((e) => ({
|
||||
level: (validLevels.has(e.level ?? "") ? e.level! : "info") as "debug" | "info" | "warn" | "error",
|
||||
message: e.message!,
|
||||
context: e.context ?? {},
|
||||
logged_at: e.logged_at,
|
||||
const entries = body.entries
|
||||
.filter((e: any) => e.message.length > 0)
|
||||
.map((e: any) => ({
|
||||
level: (validLevels.has(e.level) ? e.level : "info") as "debug" | "info" | "warn" | "error",
|
||||
message: String(e.message),
|
||||
context: (e.context ?? {}) as Record<string, unknown>,
|
||||
logged_at: e.logged_at as string | undefined,
|
||||
}));
|
||||
|
||||
const count = await repo.insertKioskLogs(kiosk.id, entries);
|
||||
|
|
@ -920,10 +890,7 @@ function registerKioskRoutes(
|
|||
const kiosk = await auth.verifyKioskKey(token);
|
||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||
|
||||
const body = await readBody<{ version: string; error?: string }>(event);
|
||||
if (!body?.version) {
|
||||
throw createError({ statusCode: 400, statusMessage: "version required" });
|
||||
}
|
||||
const body = validateBody(FirmwareAppliedBody, await readBody(event));
|
||||
await repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
|
||||
await repo.insertEvent({
|
||||
source_kiosk_id: kiosk.id,
|
||||
|
|
@ -1064,10 +1031,7 @@ function registerKioskRoutes(
|
|||
const kiosk = await auth.verifyKioskKey(token);
|
||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||
|
||||
const body = await readBody<{ version: string; error?: string }>(event);
|
||||
if (!body?.version) {
|
||||
throw createError({ statusCode: 400, statusMessage: "version required" });
|
||||
}
|
||||
const body = validateBody(OsAppliedBody, await readBody(event));
|
||||
await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
|
||||
await repo.insertEvent({
|
||||
source_kiosk_id: kiosk.id,
|
||||
|
|
|
|||
|
|
@ -36,8 +36,6 @@ const ConfigSchema = av.object(
|
|||
{
|
||||
db: av.object(
|
||||
{
|
||||
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
||||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
||||
url: av.string().default(""),
|
||||
host: av.string().default("postgres"),
|
||||
port: av.int().min(1).max(65535).default(5432),
|
||||
|
|
|
|||
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 };
|
||||
}
|
||||
}
|
||||
168
server/src/shared/api-schemas.ts
Normal file
168
server/src/shared/api-schemas.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Anyvali input schemas for all external-facing API endpoints.
|
||||
* Applied via validateBody() / validateQuery() helpers.
|
||||
*/
|
||||
import * as av from "@anyvali/js";
|
||||
|
||||
// ---- Kiosk API (service-api-http) -------------------------------------------
|
||||
|
||||
export const PairInitiateBody = av.object(
|
||||
{
|
||||
proposed_name: av.string().maxLength(128).default(""),
|
||||
hardware_model: av.string().maxLength(128).default(""),
|
||||
capabilities: av.array(av.string().maxLength(64)).default([]),
|
||||
managed_image: av.bool().default(false),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
export const PairClaimBody = av.object(
|
||||
{
|
||||
code: av.string().minLength(1).maxLength(16),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
const HeartbeatDisplay = av.object(
|
||||
{
|
||||
index: av.int().min(0).max(32).default(0),
|
||||
name: av.string().maxLength(128).default(""),
|
||||
width_px: av.int().min(0).max(16384).default(0),
|
||||
height_px: av.int().min(0).max(16384).default(0),
|
||||
power_state: av.string().maxLength(16).default("unknown"),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
const HeartbeatPartition = av.object(
|
||||
{
|
||||
device: av.string().maxLength(128).default(""),
|
||||
mountpoint: av.string().maxLength(256).default(""),
|
||||
total_mb: av.int().min(0).default(0),
|
||||
used_mb: av.int().min(0).default(0),
|
||||
free_mb: av.int().min(0).default(0),
|
||||
used_percent: av.number().min(0).max(100).default(0),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
export const HeartbeatBody = av.object(
|
||||
{
|
||||
bundle_version: av.string().maxLength(128).default(""),
|
||||
kiosk_app_version: av.string().maxLength(64).default(""),
|
||||
os_version: av.string().maxLength(64).default(""),
|
||||
displays: av.array(HeartbeatDisplay).default([]),
|
||||
cpu_temp_c: av.nullable(av.number().min(-40).max(150)).default(null),
|
||||
cpu_load_percent: av.nullable(av.number().min(0).max(100)).default(null),
|
||||
fan_rpm: av.nullable(av.int().min(0).max(50000)).default(null),
|
||||
fan_pwm: av.nullable(av.int().min(0).max(255)).default(null),
|
||||
memory_total_mb: av.nullable(av.int().min(0)).default(null),
|
||||
memory_used_mb: av.nullable(av.int().min(0)).default(null),
|
||||
disk_total_mb: av.nullable(av.int().min(0)).default(null),
|
||||
disk_free_mb: av.nullable(av.int().min(0)).default(null),
|
||||
disk_used_percent: av.nullable(av.number().min(0).max(100)).default(null),
|
||||
local_key: av.nullable(av.string().maxLength(256)).default(null),
|
||||
local_port: av.nullable(av.int().min(1).max(65535)).default(null),
|
||||
reported_hostname: av.nullable(av.string().maxLength(256)).default(null),
|
||||
network_interfaces: av.array(av.any()).default([]),
|
||||
partitions: av.array(HeartbeatPartition).default([]),
|
||||
managed_config_applied_version: av.optional(av.int().min(0)),
|
||||
managed_config_error: av.optional(av.nullable(av.string().maxLength(4096))),
|
||||
onvif_subscriptions: av.optional(av.any()),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
export const EventBody = av.object(
|
||||
{
|
||||
topic: av.string().minLength(1).maxLength(512),
|
||||
source_type: av.string().maxLength(32).default("system"),
|
||||
camera_id: av.optional(av.nullable(av.string().maxLength(64))).default(null),
|
||||
property_op: av.optional(av.nullable(av.string().maxLength(32))).default(null),
|
||||
payload: av.any().default({}),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
const KioskLogEntry = av.object(
|
||||
{
|
||||
level: av.string().maxLength(16).default("info"),
|
||||
message: av.string().maxLength(4096).default(""),
|
||||
context: av.any().default({}),
|
||||
logged_at: av.optional(av.string().maxLength(64)),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
export const KioskLogsBody = av.object(
|
||||
{
|
||||
entries: av.array(KioskLogEntry).default([]),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
export const FirmwareAppliedBody = av.object(
|
||||
{
|
||||
version: av.string().minLength(1).maxLength(64),
|
||||
error: av.optional(av.string().maxLength(4096)),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
export const OsAppliedBody = av.object(
|
||||
{
|
||||
version: av.string().minLength(1).maxLength(64),
|
||||
error: av.optional(av.string().maxLength(4096)),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
// ---- Auth (routes-auth, routes-setup) ----------------------------------------
|
||||
|
||||
export const LoginBody = av.object(
|
||||
{
|
||||
username: av.string().minLength(1).maxLength(128),
|
||||
password: av.string().minLength(1).maxLength(1024),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
export const TotpBody = av.object(
|
||||
{
|
||||
code: av.string().minLength(1).maxLength(16),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
export const SetupBody = av.object(
|
||||
{
|
||||
username: av.string().minLength(3).maxLength(64),
|
||||
password: av.string().minLength(12).maxLength(1024),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
export const PasswordChangeBody = av.object(
|
||||
{
|
||||
current_password: av.string().minLength(1).maxLength(1024),
|
||||
new_password: av.string().minLength(12).maxLength(1024),
|
||||
},
|
||||
{ unknownKeys: "strip" },
|
||||
);
|
||||
|
||||
// ---- Helper -----------------------------------------------------------------
|
||||
|
||||
export function validateBody<T>(schema: { safeParse(input: unknown): { success: boolean; data?: T; error?: unknown } }, raw: unknown): T {
|
||||
const result = schema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
let msg = "invalid request body";
|
||||
const err = result.error as any;
|
||||
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" });
|
||||
}
|
||||
return result.data as T;
|
||||
}
|
||||
|
|
@ -2,8 +2,6 @@ import * as av from "@anyvali/js";
|
|||
|
||||
export const dbConfigSchema = av.object(
|
||||
{
|
||||
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
||||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
||||
url: av.string().default(""),
|
||||
host: av.string().default("postgres"),
|
||||
port: av.int().min(1).max(65535).default(5432),
|
||||
|
|
@ -16,8 +14,6 @@ export const dbConfigSchema = av.object(
|
|||
);
|
||||
|
||||
export type DbConfig = {
|
||||
driver: "sqlite" | "postgres";
|
||||
sqlitePath: string;
|
||||
url: string;
|
||||
host: string;
|
||||
port: number;
|
||||
|
|
|
|||
|
|
@ -1,58 +1,28 @@
|
|||
/**
|
||||
* Backend-agnostic DB adapter. Repository talks to this; concrete adapters
|
||||
* (sqlite, postgres) implement it.
|
||||
* Backend-agnostic DB adapter. Repository talks to this; PG adapter implements it.
|
||||
*
|
||||
* Design choices:
|
||||
* - All methods return Promises so the Postgres path can use real async I/O.
|
||||
* The SQLite adapter wraps node:sqlite's synchronous calls in
|
||||
* Promise.resolve to keep the same interface.
|
||||
* - `?` is the canonical placeholder in SQL strings. The Postgres adapter
|
||||
* - All methods return Promises (real async I/O with PG pool).
|
||||
* - `?` is the canonical placeholder in SQL strings. The PG adapter
|
||||
* rewrites them to `$1, $2, ...` at execute time so repository code stays
|
||||
* dialect-neutral.
|
||||
* - INSERTs that need to return the new row id must use `... RETURNING id`
|
||||
* explicitly. Both SQLite (3.35+) and Postgres support it.
|
||||
*
|
||||
* Migrations and DDL fragments still differ between dialects (AUTOINCREMENT
|
||||
* vs SERIAL, STRICT vs nothing, strftime vs now()), so each backend ships
|
||||
* its own migration set rather than trying to abstract DDL.
|
||||
*/
|
||||
|
||||
export type SqlValue = string | number | bigint | boolean | null | Uint8Array;
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
export interface RunResult {
|
||||
/** New row id when the statement used `RETURNING id`, else 0n. */
|
||||
lastInsertRowid: bigint;
|
||||
/** Rows affected (approximate for some Postgres queries). */
|
||||
changes: number;
|
||||
}
|
||||
|
||||
export interface DbAdapter {
|
||||
/** Execute a write statement (INSERT / UPDATE / DELETE). */
|
||||
run(sql: string, params?: ReadonlyArray<SqlValue>): Promise<RunResult>;
|
||||
/** Single-row query. Undefined if no row. */
|
||||
get<T = Row>(sql: string, params?: ReadonlyArray<SqlValue>): Promise<T | undefined>;
|
||||
/** Multi-row query. */
|
||||
all<T = Row>(sql: string, params?: ReadonlyArray<SqlValue>): Promise<T[]>;
|
||||
/** Execute multi-statement DDL (no params, no result). */
|
||||
exec(sql: string): Promise<void>;
|
||||
/** Run a callback inside a transaction. Rolls back on throw. */
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
||||
/** Identifies the backend. */
|
||||
dialect(): "sqlite" | "postgres";
|
||||
/**
|
||||
* Set the schema search_path for multi-tenant isolation (PG only).
|
||||
* SQLite adapter implements this as a no-op.
|
||||
*/
|
||||
dialect(): "postgres";
|
||||
setSearchPath(schema: string): Promise<void>;
|
||||
/** Release the connection / pool. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DbAdapterConfig {
|
||||
driver: "sqlite" | "postgres";
|
||||
/** SQLite-only: filesystem path. */
|
||||
sqlitePath?: string;
|
||||
/** Postgres-only: connection string (postgres://user:pass@host:port/db). */
|
||||
pgUrl?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
/**
|
||||
* initDb — initialize the database from config (shared module).
|
||||
* initDb — initialize the PostgreSQL database from config.
|
||||
*
|
||||
* Replaces the init logic that was in service-store/index.ts.
|
||||
* Each service plugin calls this independently with its own config.
|
||||
* Runs PUBLIC_MIGRATIONS (global tables) then TENANT_MIGRATIONS
|
||||
* (per-tenant schema). Creates default tenant if missing.
|
||||
*/
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { dirname } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
import { MIGRATIONS } from "./migrations.js";
|
||||
import { Repository } from "./repository.js";
|
||||
import type { DbAdapter } from "./db-adapter.js";
|
||||
import type { DbConfig } from "./config.js";
|
||||
|
|
@ -23,133 +18,82 @@ export async function initDb(
|
|||
log: DbLog,
|
||||
notifyFn?: (table: string, op: string, id?: string | number) => void,
|
||||
): Promise<{ repo: Repository; close: () => Promise<void> }> {
|
||||
const driver = config.driver;
|
||||
const notify = notifyFn ?? (() => {});
|
||||
|
||||
if (driver === "postgres") {
|
||||
let pgUrl = config.url ?? "";
|
||||
if (!pgUrl) {
|
||||
const u = encodeURIComponent(config.user);
|
||||
const p = encodeURIComponent(config.password);
|
||||
pgUrl = `postgres://${u}:${p}@${config.host}:${config.port}/${config.database}`;
|
||||
}
|
||||
log.info(`connecting to postgres at ${pgUrl.replace(/:[^:@]+@/, ":***@")}`);
|
||||
let pgUrl = config.url ?? "";
|
||||
if (!pgUrl) {
|
||||
const u = encodeURIComponent(config.user);
|
||||
const p = encodeURIComponent(config.password);
|
||||
pgUrl = `postgres://${u}:${p}@${config.host}:${config.port}/${config.database}`;
|
||||
}
|
||||
log.info(`connecting to postgres at ${pgUrl.replace(/:[^:@]+@/, ":***@")}`);
|
||||
|
||||
const { PgAdapter } = await import("./pg-adapter.js");
|
||||
const adapter = new PgAdapter(pgUrl, config.poolMax);
|
||||
const { PgAdapter } = await import("./pg-adapter.js");
|
||||
const adapter = new PgAdapter(pgUrl, config.poolMax);
|
||||
|
||||
// Ensure schema_migrations exists (bootstrap).
|
||||
await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
schema_name TEXT NOT NULL, version INTEGER NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (schema_name, version)
|
||||
)`);
|
||||
await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
schema_name TEXT NOT NULL, version INTEGER NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (schema_name, version)
|
||||
)`);
|
||||
|
||||
// 1. Run PUBLIC_MIGRATIONS first (tenants + global_admins tables).
|
||||
const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
||||
const pubVersionRow = await adapter.get<{ version: number }>(
|
||||
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public_global'`,
|
||||
).catch(() => undefined);
|
||||
const pubCurrentVersion = pubVersionRow?.version ?? 0;
|
||||
if (pubCurrentVersion < PUBLIC_MIGRATIONS.length) {
|
||||
log.info(`running PUBLIC migrations from ${pubCurrentVersion} to ${PUBLIC_MIGRATIONS.length}`);
|
||||
for (let i = pubCurrentVersion; i < PUBLIC_MIGRATIONS.length; i++) {
|
||||
try {
|
||||
await adapter.exec(PUBLIC_MIGRATIONS[i]!);
|
||||
} catch (err) {
|
||||
log.warn(`PUBLIC migration ${i} failed: ${(err as Error).message}`);
|
||||
log.warn(`SQL: ${PUBLIC_MIGRATIONS[i]!.slice(0, 200)}`);
|
||||
throw err;
|
||||
}
|
||||
await adapter.run(
|
||||
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public_global', ?)`,
|
||||
[i + 1],
|
||||
);
|
||||
const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
||||
const pubVersionRow = await adapter.get<{ version: number }>(
|
||||
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public_global'`,
|
||||
).catch(() => undefined);
|
||||
const pubCurrentVersion = pubVersionRow?.version ?? 0;
|
||||
if (pubCurrentVersion < PUBLIC_MIGRATIONS.length) {
|
||||
log.info(`running PUBLIC migrations from ${pubCurrentVersion} to ${PUBLIC_MIGRATIONS.length}`);
|
||||
for (let i = pubCurrentVersion; i < PUBLIC_MIGRATIONS.length; i++) {
|
||||
try {
|
||||
await adapter.exec(PUBLIC_MIGRATIONS[i]!);
|
||||
} catch (err) {
|
||||
log.warn(`PUBLIC migration ${i} failed: ${(err as Error).message}`);
|
||||
log.warn(`SQL: ${PUBLIC_MIGRATIONS[i]!.slice(0, 200)}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
log.info(`PUBLIC schema up to date (version ${pubCurrentVersion})`);
|
||||
}
|
||||
|
||||
// 2. Run TENANT_MIGRATIONS in the public schema (default tenant).
|
||||
const versionRow = await adapter.get<{ version: number }>(
|
||||
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
|
||||
).catch(() => undefined);
|
||||
const currentVersion = versionRow?.version ?? 0;
|
||||
if (currentVersion < TENANT_MIGRATIONS.length) {
|
||||
log.info(`running PG tenant migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`);
|
||||
for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) {
|
||||
try {
|
||||
await adapter.exec(TENANT_MIGRATIONS[i]!);
|
||||
} catch (err) {
|
||||
log.warn(`PG migration ${i} failed: ${(err as Error).message}`);
|
||||
log.warn(`SQL: ${TENANT_MIGRATIONS[i]!.slice(0, 200)}`);
|
||||
throw err;
|
||||
}
|
||||
await adapter.run(
|
||||
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public', ?)`,
|
||||
[i + 1],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log.info(`PG schema up to date (version ${currentVersion})`);
|
||||
}
|
||||
|
||||
// 3. Ensure default tenant exists.
|
||||
const defaultTenant = await adapter.get(
|
||||
`SELECT id FROM public.tenants WHERE slug = 'default'`,
|
||||
);
|
||||
if (!defaultTenant) {
|
||||
log.info("creating default tenant");
|
||||
await adapter.run(
|
||||
`INSERT INTO public.tenants (name, slug, schema_name, is_active)
|
||||
VALUES ('Default', 'default', 'public', true)`,
|
||||
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public_global', ?)`,
|
||||
[i + 1],
|
||||
);
|
||||
}
|
||||
|
||||
const repo = new Repository(adapter, async (table, op, id) => {
|
||||
notify(table, op, id);
|
||||
});
|
||||
|
||||
return { repo, close: () => adapter.close() };
|
||||
}
|
||||
|
||||
// SQLite path (default).
|
||||
const path = config.sqlitePath;
|
||||
log.info(`opening sqlite at ${path}`);
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
} catch (err) {
|
||||
log.warn(`mkdir failed for ${dirname(path)}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA synchronous = NORMAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
db.exec("PRAGMA busy_timeout = 10000");
|
||||
|
||||
const row = db.prepare("PRAGMA user_version").get() as { user_version: number };
|
||||
const currentVersion = row.user_version;
|
||||
const targetVersion = MIGRATIONS.length;
|
||||
|
||||
if (currentVersion < targetVersion) {
|
||||
log.info(`running migrations from ${currentVersion} to ${targetVersion}`);
|
||||
for (let i = currentVersion; i < targetVersion; i++) {
|
||||
const entry = MIGRATIONS[i];
|
||||
if (typeof entry === "string") {
|
||||
db.exec(entry);
|
||||
} else if (typeof entry === "function") {
|
||||
entry(db);
|
||||
}
|
||||
}
|
||||
db.exec(`PRAGMA user_version = ${targetVersion}`);
|
||||
} else {
|
||||
log.info(`schema up to date (version ${currentVersion})`);
|
||||
log.info(`PUBLIC schema up to date (version ${pubCurrentVersion})`);
|
||||
}
|
||||
|
||||
const { SqliteAdapter } = await import("./sqlite-adapter.js");
|
||||
const adapter = SqliteAdapter.fromExisting(db);
|
||||
const versionRow = await adapter.get<{ version: number }>(
|
||||
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
|
||||
).catch(() => undefined);
|
||||
const currentVersion = versionRow?.version ?? 0;
|
||||
if (currentVersion < TENANT_MIGRATIONS.length) {
|
||||
log.info(`running PG tenant migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`);
|
||||
for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) {
|
||||
try {
|
||||
await adapter.exec(TENANT_MIGRATIONS[i]!);
|
||||
} catch (err) {
|
||||
log.warn(`PG migration ${i} failed: ${(err as Error).message}`);
|
||||
log.warn(`SQL: ${TENANT_MIGRATIONS[i]!.slice(0, 200)}`);
|
||||
throw err;
|
||||
}
|
||||
await adapter.run(
|
||||
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public', ?)`,
|
||||
[i + 1],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log.info(`PG schema up to date (version ${currentVersion})`);
|
||||
}
|
||||
|
||||
const defaultTenant = await adapter.get(
|
||||
`SELECT id FROM public.tenants WHERE slug = 'default'`,
|
||||
);
|
||||
if (!defaultTenant) {
|
||||
log.info("creating default tenant");
|
||||
await adapter.run(
|
||||
`INSERT INTO public.tenants (name, slug, schema_name, is_active)
|
||||
VALUES ('Default', 'default', 'public', true)`,
|
||||
);
|
||||
}
|
||||
|
||||
const repo = new Repository(adapter, async (table, op, id) => {
|
||||
notify(table, op, id);
|
||||
|
|
@ -160,40 +104,24 @@ export async function initDb(
|
|||
|
||||
/**
|
||||
* Create a new tenant schema and run all TENANT_MIGRATIONS inside it.
|
||||
* Called when a new tenant is created from the admin UI.
|
||||
*
|
||||
* @param adapter - the DB adapter (must be PG)
|
||||
* @param slug - tenant slug (used to derive schema name: `tenant_<slug>`)
|
||||
* @param log - logging callbacks
|
||||
*/
|
||||
export async function createTenantSchema(
|
||||
adapter: DbAdapter,
|
||||
slug: string,
|
||||
log: DbLog,
|
||||
): Promise<void> {
|
||||
if (adapter.dialect() !== "postgres") {
|
||||
// SQLite is single-tenant — no schema creation needed.
|
||||
return;
|
||||
}
|
||||
// Validate slug to prevent SQL injection.
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(slug)) {
|
||||
throw new Error(`invalid tenant slug: ${slug}`);
|
||||
}
|
||||
const schemaName = `tenant_${slug}`;
|
||||
log.info(`creating tenant schema: ${schemaName}`);
|
||||
|
||||
// Create the schema.
|
||||
await adapter.exec(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
|
||||
|
||||
// Set search_path to the new schema for running tenant migrations.
|
||||
await adapter.setSearchPath(schemaName);
|
||||
|
||||
try {
|
||||
// Run all TENANT_MIGRATIONS inside the new schema.
|
||||
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
||||
|
||||
// Ensure schema_migrations tracking for this schema.
|
||||
// Use the public schema_migrations table (always in public).
|
||||
const versionRow = await adapter.get<{ version: number }>(
|
||||
`SELECT COALESCE(MAX(version), 0) AS version FROM public.schema_migrations WHERE schema_name = ?`,
|
||||
[schemaName],
|
||||
|
|
@ -216,7 +144,6 @@ export async function createTenantSchema(
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
// Always reset search_path back to public.
|
||||
await adapter.setSearchPath("public");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,6 +258,8 @@ export function rowToEntity(r: Row): Entity {
|
|||
html_content: sn(r["html_content"]),
|
||||
web_url: sn(r["web_url"]),
|
||||
dashboard_id: sn(r["dashboard_id"]),
|
||||
ablesign_screen_id: sn(r["ablesign_screen_id"]),
|
||||
managed: !!r["managed"],
|
||||
created_at: s(r["created_at"]),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -485,4 +485,240 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
|||
`CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`,
|
||||
|
||||
`ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS partitions_json JSONB`,
|
||||
|
||||
// ---- UUIDv7 PK migration for existing databases ----
|
||||
// Databases created before UUIDv7 migration have INTEGER PKs.
|
||||
// This migration converts them to TEXT in-place. Safe to run on
|
||||
// databases that already have TEXT PKs (DO NOTHING on conflict).
|
||||
// gen_random_uuid() generates UUIDv4 — close enough for backfill.
|
||||
// New rows already use app-generated UUIDv7 from repository.ts.
|
||||
`CREATE OR REPLACE FUNCTION _bf_add_fk(
|
||||
src_table text, src_col text, ref_table text, ref_col text, on_del text
|
||||
) RETURNS void LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
col_exists boolean;
|
||||
ref_exists boolean;
|
||||
cname text;
|
||||
BEGIN
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = current_schema() AND table_name = src_table AND column_name = src_col
|
||||
) INTO col_exists;
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = current_schema() AND table_name = ref_table AND column_name = ref_col
|
||||
) INTO ref_exists;
|
||||
IF NOT col_exists OR NOT ref_exists THEN RETURN; END IF;
|
||||
cname := src_table || '_' || src_col || '_fkey';
|
||||
EXECUTE format(
|
||||
'ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I(%I) ON DELETE %s',
|
||||
src_table, cname, src_col, ref_table, ref_col, on_del
|
||||
);
|
||||
END $fn$`,
|
||||
|
||||
`DO $$
|
||||
DECLARE
|
||||
col_type text;
|
||||
r record;
|
||||
BEGIN
|
||||
SELECT data_type INTO col_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'users'
|
||||
AND column_name = 'id';
|
||||
IF col_type IS NULL OR col_type = 'text' THEN
|
||||
RAISE NOTICE 'UUIDv7 migration: already TEXT or table missing, skipping';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'UUIDv7 migration: converting INTEGER PKs to TEXT...';
|
||||
|
||||
-- 1. Drop ALL foreign key constraints in current schema dynamically.
|
||||
FOR r IN
|
||||
SELECT tc.constraint_name, tc.table_name
|
||||
FROM information_schema.table_constraints tc
|
||||
WHERE tc.table_schema = current_schema()
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I DROP CONSTRAINT IF EXISTS %I', r.table_name, r.constraint_name);
|
||||
END LOOP;
|
||||
|
||||
-- 2. Convert every integer/bigint column that is a PK or FK to TEXT.
|
||||
FOR r IN
|
||||
SELECT c.table_name, c.column_name
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = current_schema()
|
||||
AND c.data_type IN ('integer', 'bigint')
|
||||
AND (
|
||||
c.column_name = 'id'
|
||||
OR c.column_name LIKE '%_id'
|
||||
OR c.column_name LIKE '%_by'
|
||||
)
|
||||
AND c.table_name NOT IN ('schema_migrations', 'setup_state')
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ALTER COLUMN %I TYPE TEXT USING %I::TEXT',
|
||||
r.table_name, r.column_name, r.column_name);
|
||||
-- Drop any leftover default (sequences from old SERIAL columns).
|
||||
EXECUTE format('ALTER TABLE %I ALTER COLUMN %I DROP DEFAULT', r.table_name, r.column_name);
|
||||
END LOOP;
|
||||
|
||||
-- 3. Drop orphan sequences (leftover from SERIAL columns).
|
||||
FOR r IN
|
||||
SELECT sequence_name
|
||||
FROM information_schema.sequences
|
||||
WHERE sequence_schema = current_schema()
|
||||
AND sequence_name LIKE '%_id_seq'
|
||||
LOOP
|
||||
EXECUTE format('DROP SEQUENCE IF EXISTS %I CASCADE', r.sequence_name);
|
||||
END LOOP;
|
||||
|
||||
-- 4. Re-add FK constraints (only if both table and column exist).
|
||||
PERFORM _bf_add_fk('sessions', 'user_id', 'users', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('api_keys', 'user_id', 'users', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('camera_streams', 'camera_id', 'cameras', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('display_layouts','display_id', 'displays', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('display_layouts','layout_id', 'layouts', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('layout_cells', 'layout_id', 'layouts', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('layout_cells', 'camera_id', 'cameras', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('kiosks', 'display_id', 'displays', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('kiosk_labels', 'kiosk_id', 'kiosks', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('kiosk_labels', 'label_id', 'labels', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('camera_labels', 'camera_id', 'cameras', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('camera_labels', 'label_id', 'labels', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('layout_labels', 'layout_id', 'layouts', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('layout_labels', 'label_id', 'labels', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('event_log', 'source_kiosk_id', 'kiosks', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('event_log', 'source_camera_id', 'cameras', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('kiosk_gpio_bindings','kiosk_id', 'kiosks', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('kiosk_logs', 'kiosk_id', 'kiosks', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('camera_event_subscriptions','camera_id', 'cameras', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('camera_event_subscriptions','subscribed_by_kiosk_id','kiosks', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('displays', 'default_layout_id', 'layouts', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('pairing_codes', 'consumed_by_kiosk_id', 'kiosks', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('firmware_releases','uploaded_by', 'users', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('firmware_rollouts','release_id', 'firmware_releases', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('firmware_rollouts','created_by', 'users', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('os_update_releases','uploaded_by', 'users', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('os_update_rollouts','release_id', 'os_update_releases', 'id', 'CASCADE');
|
||||
PERFORM _bf_add_fk('os_update_rollouts','created_by', 'users', 'id', 'SET NULL');
|
||||
PERFORM _bf_add_fk('entities', 'camera_id', 'cameras', 'id', 'CASCADE');
|
||||
|
||||
RAISE NOTICE 'UUIDv7 migration: complete — all PKs and FKs are now TEXT';
|
||||
END $$`,
|
||||
|
||||
// ---- Backfill: replace bare-integer IDs with real UUIDv7 ----
|
||||
// Existing rows have IDs like "1", "2" from the type conversion.
|
||||
// This replaces them with proper UUIDv7-shaped UUIDs while updating
|
||||
// all FK references so nothing breaks.
|
||||
`DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
old_id text;
|
||||
new_id text;
|
||||
fk record;
|
||||
saved_fks jsonb := '[]'::jsonb;
|
||||
BEGIN
|
||||
-- 1. Save and drop ALL FK constraints so updates are unconstrained.
|
||||
FOR r IN
|
||||
SELECT tc.constraint_name, tc.table_name,
|
||||
kcu.column_name AS fk_col,
|
||||
ccu.table_name AS ref_table,
|
||||
ccu.column_name AS ref_col,
|
||||
rc.delete_rule
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON tc.constraint_name = rc.constraint_name AND tc.table_schema = rc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = current_schema()
|
||||
LOOP
|
||||
saved_fks := saved_fks || jsonb_build_object(
|
||||
'name', r.constraint_name, 'tbl', r.table_name,
|
||||
'col', r.fk_col, 'ref', r.ref_table, 'rcol', r.ref_col,
|
||||
'del', r.delete_rule
|
||||
);
|
||||
EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', r.table_name, r.constraint_name);
|
||||
END LOOP;
|
||||
|
||||
-- 2. Replace integer-looking IDs with UUIDs + cascade to FK columns.
|
||||
FOR r IN
|
||||
SELECT t.table_name
|
||||
FROM information_schema.columns t
|
||||
WHERE t.table_schema = current_schema()
|
||||
AND t.column_name = 'id'
|
||||
AND t.data_type = 'text'
|
||||
AND t.table_name NOT IN ('schema_migrations', 'setup_state', 'pairing_codes', 'sessions')
|
||||
ORDER BY t.table_name
|
||||
LOOP
|
||||
FOR old_id IN
|
||||
EXECUTE format('SELECT id FROM %I WHERE id ~ $1', r.table_name)
|
||||
USING '^[0-9]+$'
|
||||
LOOP
|
||||
new_id := gen_random_uuid()::text;
|
||||
|
||||
-- Update FK columns in other tables that point to this old_id.
|
||||
FOR fk IN
|
||||
SELECT e->>'tbl' AS fk_table, e->>'col' AS fk_col
|
||||
FROM jsonb_array_elements(saved_fks) e
|
||||
WHERE e->>'ref' = r.table_name AND e->>'rcol' = 'id'
|
||||
LOOP
|
||||
EXECUTE format('UPDATE %I SET %I = $1 WHERE %I = $2',
|
||||
fk.fk_table, fk.fk_col, fk.fk_col)
|
||||
USING new_id, old_id;
|
||||
END LOOP;
|
||||
|
||||
EXECUTE format('UPDATE %I SET id = $1 WHERE id = $2', r.table_name)
|
||||
USING new_id, old_id;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
-- 3. Re-add all FK constraints.
|
||||
FOR fk IN
|
||||
SELECT e->>'name' AS cname, e->>'tbl' AS tbl, e->>'col' AS col,
|
||||
e->>'ref' AS ref, e->>'rcol' AS rcol, e->>'del' AS del
|
||||
FROM jsonb_array_elements(saved_fks) e
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I(%I) ON DELETE %s',
|
||||
fk.tbl, fk.cname, fk.col, fk.ref, fk.rcol, fk.del
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'UUIDv7 backfill: all integer-looking IDs replaced with UUIDs';
|
||||
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`,
|
||||
];
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,9 @@
|
|||
/**
|
||||
* Postgres backend for the repository.
|
||||
*
|
||||
* Translates SQLite-style `?` placeholders to Postgres `$1, $2, ...` at
|
||||
* execute time so the Repository code can stay dialect-neutral. RETURNING
|
||||
* id captures lastInsertRowid (caller must add `RETURNING id` to INSERTs
|
||||
* that need it — same for SQLite path so the SQL strings are portable).
|
||||
* Translates `?` placeholders to Postgres `$1, $2, ...` at execute time
|
||||
* so Repository SQL stays clean. Rewrites `INSERT OR IGNORE` to
|
||||
* `INSERT ... ON CONFLICT DO NOTHING` for Postgres compatibility.
|
||||
*
|
||||
* Pool size: default 10 — configurable via pgPoolMax in sec-config.yaml.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Repository — typed accessor over the sqlite handle.
|
||||
* Repository — typed accessor over the DB adapter.
|
||||
*
|
||||
* Keeps prepared statements cached for the life of the connection. All
|
||||
* mutating methods invoke the `notify` callback with (table, op, id) so the
|
||||
|
|
@ -2307,11 +2307,13 @@ export class Repository {
|
|||
html_content?: string | null;
|
||||
web_url?: string | null;
|
||||
dashboard_id?: string | null;
|
||||
ablesign_screen_id?: string | null;
|
||||
managed?: boolean;
|
||||
}): Promise<Entity> {
|
||||
const id = uuidv7();
|
||||
await this._run(
|
||||
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id, ablesign_screen_id, managed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
input.name,
|
||||
|
|
@ -2319,8 +2321,10 @@ export class Repository {
|
|||
input.description ?? null,
|
||||
input.type === "camera" ? (input.camera_id ?? 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 === "ablesign" ? (input.ablesign_screen_id ?? null) : null,
|
||||
input.managed ?? false,
|
||||
],
|
||||
);
|
||||
void this.notify("entities", "create", id);
|
||||
|
|
@ -2405,7 +2409,7 @@ export class Repository {
|
|||
if (await this.getEntityByName(name)) {
|
||||
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> {
|
||||
|
|
@ -2628,4 +2632,123 @@ export class Repository {
|
|||
async deleteCloudAccount(id: string): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
/**
|
||||
* SQLite backend for the repository. Wraps node:sqlite (sync API) in
|
||||
* Promise-returning methods so the Repository can stay async-uniform across
|
||||
* both backends.
|
||||
*
|
||||
* Prepared statements are cached per-SQL for perf parity with the
|
||||
* old direct-DatabaseSync code path.
|
||||
*/
|
||||
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||
|
||||
import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js";
|
||||
|
||||
export class SqliteAdapter implements DbAdapter {
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly stmts = new Map<string, StatementSync>();
|
||||
private txDepth = 0;
|
||||
|
||||
constructor(path: string) {
|
||||
this.db = new DatabaseSync(path);
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
}
|
||||
|
||||
/** Wrap an already-opened DatabaseSync (e.g. after migrations ran). */
|
||||
static fromExisting(db: DatabaseSync): SqliteAdapter {
|
||||
const adapter = Object.create(SqliteAdapter.prototype) as SqliteAdapter;
|
||||
(adapter as any).db = db;
|
||||
(adapter as any).stmts = new Map();
|
||||
(adapter as any).txDepth = 0;
|
||||
return adapter;
|
||||
}
|
||||
|
||||
private prep(sql: string): StatementSync {
|
||||
let s = this.stmts.get(sql);
|
||||
if (!s) {
|
||||
s = this.db.prepare(sql);
|
||||
this.stmts.set(sql, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private coerce(params: ReadonlyArray<SqlValue>): any[] {
|
||||
return params.map((v) => (v === true ? 1 : v === false ? 0 : v));
|
||||
}
|
||||
|
||||
async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> {
|
||||
const stmt = this.prep(sql);
|
||||
const r = stmt.run(...this.coerce(params));
|
||||
return {
|
||||
lastInsertRowid:
|
||||
typeof r.lastInsertRowid === "bigint" ? r.lastInsertRowid : BigInt(r.lastInsertRowid),
|
||||
changes: Number(r.changes),
|
||||
};
|
||||
}
|
||||
|
||||
async get<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T | undefined> {
|
||||
const stmt = this.prep(sql);
|
||||
const r = (stmt.get as any)(...this.coerce(params));
|
||||
return r as T | undefined;
|
||||
}
|
||||
|
||||
async all<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T[]> {
|
||||
const stmt = this.prep(sql);
|
||||
return (stmt.all as any)(...this.coerce(params)) as T[];
|
||||
}
|
||||
|
||||
async exec(sql: string): Promise<void> {
|
||||
this.db.exec(sql);
|
||||
}
|
||||
|
||||
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.txDepth === 0) this.db.exec("BEGIN");
|
||||
this.txDepth += 1;
|
||||
try {
|
||||
const result = await fn();
|
||||
this.txDepth -= 1;
|
||||
if (this.txDepth === 0) this.db.exec("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.txDepth -= 1;
|
||||
if (this.txDepth === 0) {
|
||||
try { this.db.exec("ROLLBACK"); } catch { /* ignore */ }
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
dialect(): "sqlite" { return "sqlite"; }
|
||||
|
||||
/** No-op for SQLite — single-tenant only. */
|
||||
async setSearchPath(_schema: string): Promise<void> {
|
||||
// SQLite doesn't support schemas — single tenant only.
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
/** Expose raw DB for migrations that need fine control (idempotent
|
||||
* ALTER TABLE, PRAGMA inspection, etc). Sqlite-only. */
|
||||
rawSync(): DatabaseSync { return this.db; }
|
||||
}
|
||||
|
|
@ -15,8 +15,7 @@
|
|||
* 3. All queries run against tenant's schema
|
||||
* 4. Connection returned to pool with search_path reset
|
||||
*
|
||||
* SQLite mode: single-tenant, no schema switching. tenant_id is always
|
||||
* the static DEFAULT_TENANT_ID. The tenant table isn't created.
|
||||
* Default tenant uses the public schema directly (slug = "default").
|
||||
*/
|
||||
|
||||
export const DEFAULT_TENANT_ID = "default";
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ export type StreamRole = "main" | "sub" | "other";
|
|||
export type StreamSelector = "auto" | "main" | "sub";
|
||||
export type StreamPolicy = "auto" | "always_main" | "always_sub";
|
||||
export type LayoutPriority = "hot" | "normal" | "cold";
|
||||
export type CellContentType = "none" | "camera" | "web" | "html";
|
||||
export type EntityType = "camera" | "html" | "web" | "dashboard";
|
||||
export type CellContentType = "none" | "camera" | "web" | "html" | "ablesign";
|
||||
export type EntityType = "camera" | "html" | "web" | "dashboard" | "ablesign";
|
||||
|
||||
export interface Entity {
|
||||
id: string;
|
||||
|
|
@ -25,6 +25,10 @@ export interface Entity {
|
|||
web_url: string | null;
|
||||
/** Node-RED dashboard tab id; populated when type === "dashboard". */
|
||||
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;
|
||||
}
|
||||
export type DesiredPowerState = "follow_layout" | "on" | "standby";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
@ -4329,3 +4379,163 @@ export function TenantEditPage(props: TenantEditPageProps) {
|
|||
</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/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/ablesign" label="AbleSign" icon="▶" active={a === "ablesign"} />
|
||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue