Compare commits

..

15 commits

Author SHA1 Message Date
Mitchell R
10f5cf7fac fix: per-connection search_path + sidebar tenant dropdown
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
PG adapter: setSearchPath now stores schema name, runner applies
SET search_path on every connection checkout. Eliminates cross-request
schema bleed (previous: setSearchPath mutated shared connection state).

Middleware: always set search_path (removed 'public' skip condition).

Sidebar: tenant switcher dropdown at bottom, loaded via htmx from
/admin/_tenant_switcher. Hidden when only one tenant. Auto-submits
on change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:15:00 +02:00
Mitchell R
1dbb56752c fix: tenant switch auto-copies global admin into tenant users
isSetupComplete() now checks public.global_admins — if a global admin
exists but tenant has no local users, copies admin into tenant's users
table and marks setup complete. Prevents setup wizard on tenant switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:10:56 +02:00
Mitchell R
65de42d495 refactor: AbleSign UI — single account, screen detail, no kiosk assign
- Remove Accounts from AbleSign nav (one account per tenant)
- Screens page: create button, no kiosk assignment
- Screen detail page with config form
- Internal/External badge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:09:40 +02:00
Mitchell R
e0941f533d
feat: AbleSign dropdown nav + screens/content/playlists pages
- Sidebar: NavGroup component (details/summary) for AbleSign dropdown
  with Accounts, Screens, Content, Playlists sub-items
- Global screens page (/admin/ablesign/screens) — all screens across
  accounts with Internal/External badge
- Content page — aggregates media files + web apps from all accounts
- Playlists page — shows per-screen playlist items
- Auto-sync screens on account creation
- Internal/External: Internal = created via "Create & Pair" (has
  screenToken, gets entity). External = synced from AbleSign (no token,
  no entity, management-only). Only internal screens become entities.
- Entity creation only on Create & Pair path — not on sync or assign

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 01:47:17 +02:00
Mitchell R
e1a3cd1d05
fix: ONVIF single-kiosk ownership + rate limiting
Server-side:
- Bundle gen: when camera event_source is "auto", first kiosk to fetch
  bundle claims ownership → writes "kiosk:<id>" to camera row. Other
  kiosks see assigned owner and skip ONVIF subscription.
- Kiosk deletion resets event_source back to "auto" so next kiosk
  takes over.
- repo.getActiveOnvifOwners() for future use.

Kiosk-side:
- Only subscribe when event_source is "auto" or "kiosk:<MY_ID>".
  Skips "kiosk:<other_id>", "server", "none", "disabled".
- Poll interval: 3s → 10s (cameras were getting overwhelmed)
- CreatePullPoint backoff: exponential 30s→60s→120s...→600s max
- Pull errors: exponential 15s→30s→45s...→300s, resubscribe after 5
  consecutive failures instead of immediately.
- load_kiosk_id() helper reads from cached bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 01:40:44 +02:00
Mitchell R
a518fe17ea
fix: move AbleSign migrations to end of array (after UUIDv7 backfill)
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
Server already ran past the indices where AbleSign tables were inserted.
Moving to end ensures they get new, unrun version numbers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:28:38 +02:00
Mitchell R
73dbd9b6bf
feat: managed entities (read-only) + AbleSign auto-creates entity
- Entity type: add 'ablesign' to EntityType + CellContentType
- Entity.managed boolean: true for auto-created entities (camera sync,
  cloud cams, AbleSign). UI blocks editing managed entities.
- Entity.ablesign_screen_id: links to ablesign_screens row
- ensureCameraEntity now sets managed=true
- AbleSign screen creation auto-creates managed entity with
  web_url=player.ablesign.tv and ablesign_screen_id FK
- PG migration: alter entities CHECK constraint + add columns
- Entity edit route rejects POST for managed entities

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:08:19 +02:00
Mitchell R
c3bdcbce4c
feat: AbleSign digital signage integration
- DB: ablesign_accounts (api_key_encrypted, workspace_id) +
  ablesign_screens (ablesign_screen_id, kiosk assignment, orientation)
- API client: shared/ablesign.ts — list/register/update/delete screens,
  playlist CRUD, headless pairing (initiate player registration →
  register via admin API key → no UI shown on kiosk)
- Admin routes: account CRUD, screen sync from AbleSign API, headless
  screen creation (Create & Pair), kiosk assignment, remote delete
- Admin UI: AbleSign nav item, accounts page (add/sync/delete),
  screens page (add/assign to kiosk/delete) with kiosk dropdown
- Follows cloud camera pattern: encrypted credentials, sync from
  vendor API, assign to kiosks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:03:42 +02:00
Mitchell R
5ce526eb33
feat: audio controls, reboot button, update lock, ONVIF refresh
- Audio: kiosk/src/audio.rs — PipeWire/ALSA volume, mute, output
  selection. WS commands volume-set/volume-mute/audio-output.
  Heartbeat reports audio state. Admin UI volume buttons + mute.
- Reboot: admin button with confirmation, WS reboot command,
  kiosk runs systemctl reboot.
- Firmware update now reboots (not exit) to clear state fully.
- Update lock: FIRMWARE_LOCK + OS_UPDATE_LOCK mutexes prevent
  concurrent update attempts from heartbeat + WS paths.
- ONVIF: auto-refresh stale/failed subs (>24h or failed state),
  mark_event_received with proper epoch timestamp, parse Key
  section for PlateNumber.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 16:57:41 +02:00
Mitchell R
55b11f2ffa
fix: Node-RED event forwarding + parse ONVIF Key section (PlateNumber)
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
Server bridge was forwarding to raw topic paths that no Node-RED node
listens on. Now forwards to fixed routes: camera.event, onvif.event,
onvif.motion, onvif.anpr — matching what trigger nodes register.

ONVIF XML parser now extracts Key section SimpleItems (PlateNumber,
etc.) into the data map alongside Data section items. Previously only
parsed Source and Data, missing Key-section fields like plate numbers.

Node-RED trigger nodes: camera_id filter changed from Number() to
String() comparison for UUIDv7 compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:38:30 +02:00
Mitchell R
eb8abbdff9
feat: ONVIF subscription auto-refresh on failure or 24h staleness
- Track subscribed_at timestamp per camera in SubStatus
- Fix mark_event_received to use epoch seconds (was OS version string)
- needs_refresh() returns true when any sub is failed/stopped or >24h old
- Heartbeat loop calls maybe_refresh_onvif() every 60s — reloads
  cameras from cached bundle and restarts onvif_events::start() which
  kills old generation threads and creates fresh PullPoint subscriptions
- mark_event_received called on each successful event forward

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:34:22 +02:00
Mitchell R
8c59bb6b02
fix: wrap nullable event fields with optional() for missing keys
anyvali nullable() accepts null but rejects undefined (absent field).
Kiosk log events omit camera_id/property_op entirely. Wrap with
optional() so missing fields default to null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:26:55 +02:00
Mitchell R
9eeddff680
fix: add /var/lib/betterframe/tmp to tmpfiles for bfkiosk write access
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
OS update staging dir on BF_DATA needs bfkiosk ownership.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:17:37 +02:00
Mitchell R
38c78c0bb5
fix: log validation errors with field detail + raw body on event reject
validateBody now extracts per-field error messages from anyvali issues.
Event endpoint logs the raw body (first 500 chars) on validation failure
so we can see exactly what the kiosk sends.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:15:41 +02:00
Mitchell R
8381ed280e
fix: md5 crate v0.7 API (compute not Digest) + clone default_layout_id
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:03:29 +02:00
28 changed files with 1497 additions and 68 deletions

View file

@ -1,2 +1,3 @@
d /run/betterframe 0755 bfkiosk bfkiosk - d /run/betterframe 0755 bfkiosk bfkiosk -
d /var/lib/betterframe/kiosk 0755 bfkiosk bfkiosk - d /var/lib/betterframe/kiosk 0755 bfkiosk bfkiosk -
d /var/lib/betterframe/tmp 0755 bfkiosk bfkiosk -

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

@ -0,0 +1,165 @@
//! Audio output control — volume, mute, output selection.
//!
//! Tries PipeWire (`wpctl`) first, falls back to ALSA (`amixer`).
//! Pi 5 with Debian Bookworm uses PipeWire by default under cage.
use std::process::Command;
use tracing::{info, warn};
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct AudioState {
pub volume_percent: u32,
pub muted: bool,
pub output_name: String,
pub available_outputs: Vec<AudioOutput>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AudioOutput {
pub id: String,
pub name: String,
pub is_default: bool,
}
pub fn get_state() -> AudioState {
if has_wpctl() {
get_state_pipewire()
} else {
get_state_alsa()
}
}
pub fn set_volume(percent: u32) -> bool {
let pct = percent.min(100);
info!("audio: set volume {pct}%");
if has_wpctl() {
run_ok("wpctl", &["set-volume", "@DEFAULT_AUDIO_SINK@", &format!("{:.2}", pct as f32 / 100.0)])
} else {
run_ok("amixer", &["sset", "Master", &format!("{pct}%")])
}
}
pub fn set_mute(muted: bool) -> bool {
info!("audio: set mute={muted}");
if has_wpctl() {
let val = if muted { "1" } else { "0" };
run_ok("wpctl", &["set-mute", "@DEFAULT_AUDIO_SINK@", val])
} else {
let val = if muted { "mute" } else { "unmute" };
run_ok("amixer", &["sset", "Master", val])
}
}
pub fn set_output(id: &str) -> bool {
info!("audio: set output={id}");
if has_wpctl() {
run_ok("wpctl", &["set-default", id])
} else {
false
}
}
fn has_wpctl() -> bool {
Command::new("wpctl").arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_ok(cmd: &str, args: &[&str]) -> bool {
match Command::new(cmd).args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(s) => s.success(),
Err(e) => { warn!("audio: {cmd} failed: {e}"); false }
}
}
fn get_state_pipewire() -> AudioState {
let mut state = AudioState::default();
if let Ok(out) = Command::new("wpctl").args(["get-volume", "@DEFAULT_AUDIO_SINK@"]).output() {
let text = String::from_utf8_lossy(&out.stdout);
// "Volume: 0.75" or "Volume: 0.75 [MUTED]"
state.muted = text.contains("[MUTED]");
if let Some(vol_str) = text.split_whitespace().nth(1) {
if let Ok(v) = vol_str.parse::<f32>() {
state.volume_percent = (v * 100.0).round() as u32;
}
}
}
if let Ok(out) = Command::new("wpctl").args(["status"]).output() {
let text = String::from_utf8_lossy(&out.stdout);
let mut in_sinks = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.contains("Audio/Sink") || trimmed.contains("Sinks:") {
in_sinks = true;
continue;
}
if in_sinks && trimmed.is_empty() {
break;
}
if in_sinks {
let is_default = trimmed.contains('*');
let clean = trimmed.trim_start_matches(['│', '├', '└', '─', ' ', '*', '·']);
let parts: Vec<&str> = clean.splitn(2, '.').collect();
if parts.len() == 2 {
let id = parts[0].trim().to_string();
let name = parts[1].trim().trim_start_matches(' ').to_string();
if is_default {
state.output_name = name.clone();
}
state.available_outputs.push(AudioOutput { id, name, is_default });
}
}
}
}
state
}
fn get_state_alsa() -> AudioState {
let mut state = AudioState::default();
state.output_name = "Master".to_string();
if let Ok(out) = Command::new("amixer").args(["sget", "Master"]).output() {
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
let trimmed = line.trim();
// "Mono: Playback 32768 [50%] [on]" or "[off]"
if trimmed.contains('[') && trimmed.contains('%') {
if let Some(pct_str) = trimmed.split('[').nth(1) {
if let Some(pct) = pct_str.strip_suffix("%]") {
state.volume_percent = pct.parse().unwrap_or(0);
}
}
state.muted = trimmed.contains("[off]");
break;
}
}
}
if let Ok(out) = Command::new("aplay").args(["-l"]).output() {
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
if line.starts_with("card ") {
let name = line.split(':').nth(1).unwrap_or("").trim().to_string();
let id = line.split_whitespace().nth(1).unwrap_or("0")
.trim_end_matches(':').to_string();
state.available_outputs.push(AudioOutput {
id,
name,
is_default: state.available_outputs.is_empty(),
});
}
}
}
state
}

View file

@ -69,7 +69,7 @@ impl KioskBundle {
height_px: d.height_px, height_px: d.height_px,
idle_timeout_seconds: d.idle_timeout_seconds, idle_timeout_seconds: d.idle_timeout_seconds,
sleep_timeout_seconds: d.sleep_timeout_seconds, sleep_timeout_seconds: d.sleep_timeout_seconds,
default_layout_id: d.default_layout_id, default_layout_id: d.default_layout_id.clone(),
layouts: self.layouts.clone(), layouts: self.layouts.clone(),
}]; }];
} }

View file

@ -128,7 +128,9 @@ pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> {
let _ = fs::rename(&bin, &prev_path); let _ = fs::rename(&bin, &prev_path);
} }
fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?; fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?;
info!("preboot firmware: updated to {}, exiting for restart", info.version); info!("preboot firmware: updated to {}, rebooting", info.version);
let _ = std::process::Command::new("systemctl").arg("reboot").status();
std::thread::sleep(Duration::from_secs(30));
std::process::exit(0); std::process::exit(0);
} }
@ -267,10 +269,18 @@ pub fn apply(
.timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(5))
.send(); .send();
on_progress("Restarting", 100); on_progress("Rebooting", 100);
info!("firmware: swap complete → exiting for systemd to relaunch"); info!("firmware: swap complete → rebooting to pick up new binary");
// systemd Restart=always picks up the new binary on next start. match std::process::Command::new("systemctl").arg("reboot").status() {
Ok(_) => {
std::thread::sleep(Duration::from_secs(30));
std::process::exit(0); std::process::exit(0);
}
Err(e) => {
info!("systemctl reboot failed: {e}, falling back to exit");
std::process::exit(0);
}
}
} }
fn verify_signature(public_key_pem: &str, sha256_hex: &str, sig_b64url: &str) -> Result<(), String> { fn verify_signature(public_key_pem: &str, sha256_hex: &str, sig_b64url: &str) -> Result<(), String> {

View file

@ -1,4 +1,5 @@
mod at_rest; mod at_rest;
mod audio;
mod axiom; mod axiom;
mod bundle; mod bundle;
mod cec; mod cec;
@ -27,6 +28,11 @@ pub enum ServerMsg {
display_id: Option<String>, display_id: Option<String>,
layout_id: String, layout_id: String,
}, },
/// Audio controls from admin.
VolumeSet(u32),
VolumeMute(bool),
AudioOutputSet(String),
Reboot,
/// Server-pushed "go check for a firmware update now". /// Server-pushed "go check for a firmware update now".
FirmwareCheck, FirmwareCheck,
/// Server-pushed "go check for an OS update now". /// Server-pushed "go check for an OS update now".

View file

@ -38,56 +38,99 @@ static STATUS: Mutex<Option<HashMap<String, SubStatus>>> = Mutex::new(None);
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
pub struct SubStatus { pub struct SubStatus {
pub state: &'static str, // "subscribing", "active", "failed", "stopped" pub state: &'static str,
pub last_event_at: Option<String>, pub last_event_at: Option<String>,
pub subscribed_at: Option<String>,
pub error: Option<String>, pub error: Option<String>,
} }
fn epoch_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs}")
}
fn epoch_now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn set_status(cam_id: &str, state: &'static str, error: Option<String>) { fn set_status(cam_id: &str, state: &'static str, error: Option<String>) {
let mut map = STATUS.lock().unwrap(); let mut map = STATUS.lock().unwrap();
let map = map.get_or_insert_with(HashMap::new); let map = map.get_or_insert_with(HashMap::new);
let entry = map.entry(cam_id.to_string()).or_insert_with(|| SubStatus { let entry = map.entry(cam_id.to_string()).or_insert_with(|| SubStatus {
state: "subscribing", state: "subscribing",
last_event_at: None, last_event_at: None,
subscribed_at: None,
error: None, error: None,
}); });
entry.state = state; entry.state = state;
entry.error = error; entry.error = error;
if state == "active" {
entry.subscribed_at = Some(epoch_now());
}
} }
fn mark_event_received(cam_id: &str) { fn mark_event_received(cam_id: &str) {
let mut map = STATUS.lock().unwrap(); let mut map = STATUS.lock().unwrap();
if let Some(map) = map.as_mut() { if let Some(map) = map.as_mut() {
if let Some(entry) = map.get_mut(cam_id) { if let Some(entry) = map.get_mut(cam_id) {
entry.last_event_at = Some(crate::os_update::current_os_version_public()); // reuse timestamp helper... actually just use epoch entry.last_event_at = Some(epoch_now());
} }
} }
} }
/// Check if any subscription needs a forced refresh (>24h since subscribe,
/// or currently in failed/stopped state).
pub fn needs_refresh() -> bool {
let map = STATUS.lock().unwrap();
let Some(map) = map.as_ref() else { return false };
let now = epoch_now_secs();
for status in map.values() {
if status.state == "failed" || status.state == "stopped" {
return true;
}
if let Some(ref sub_at) = status.subscribed_at {
if let Ok(ts) = sub_at.parse::<u64>() {
if now.saturating_sub(ts) > 24 * 3600 {
return true;
}
}
}
}
false
}
/// Get current subscription statuses for all cameras. Used by heartbeat. /// Get current subscription statuses for all cameras. Used by heartbeat.
pub fn get_statuses() -> HashMap<String, SubStatus> { pub fn get_statuses() -> HashMap<String, SubStatus> {
STATUS.lock().unwrap().clone().unwrap_or_default() STATUS.lock().unwrap().clone().unwrap_or_default()
} }
/// Start event subscription workers for all ONVIF cameras in the bundle. /// Start event subscription workers for ONVIF cameras assigned to this kiosk.
/// Idempotent — stops old workers (via ACTIVE flag) before starting new. /// Only subscribes when event_source is "auto" or "kiosk:<this_kiosk_id>".
pub fn start( pub fn start(
cameras: &[BundleCamera], cameras: &[BundleCamera],
cluster_key: Option<&str>, cluster_key: Option<&str>,
server_url: &str, server_url: &str,
kiosk_key: &str, kiosk_key: &str,
) { ) {
// Only subscribe to cameras where event_source is "auto" or "kiosk:<this_id>" let my_kiosk_id = crate::server::load_kiosk_id();
// (not "server" or another kiosk). For "auto", this kiosk subscribes because
// the server put the camera in this kiosk's bundle — meaning it's reachable.
let onvif_cams: Vec<_> = cameras let onvif_cams: Vec<_> = cameras
.iter() .iter()
.filter(|c| { .filter(|c| {
if c.cam_type != "onvif" || c.onvif_host.is_none() { return false; } if c.cam_type != "onvif" || c.onvif_host.is_none() { return false; }
match c.event_source.as_deref() { match c.event_source.as_deref() {
Some("server") => false, // server handles this one Some("server") => false,
Some(s) if s.starts_with("kiosk:") => true, // pinned to a kiosk (might be us) Some("none") | Some("disabled") => false,
_ => true, // "auto" or missing → this kiosk subscribes Some(s) if s.starts_with("kiosk:") => {
let assigned = &s[6..];
my_kiosk_id.as_deref() == Some(assigned)
}
_ => true, // "auto" or missing
} }
}) })
.cloned() .cloned()
@ -139,30 +182,32 @@ fn run_subscription(
let has_pass = !pass.is_empty(); let has_pass = !pass.is_empty();
info!("onvif-events: cam {} ({}) subscribing at {event_url} user={user} has_pass={has_pass}", cam.id, cam.name); info!("onvif-events: cam {} ({}) subscribing at {event_url} user={user} has_pass={has_pass}", cam.id, cam.name);
let mut backoff_secs: u64 = 30;
loop { loop {
if generation.upgrade().is_none() { if generation.upgrade().is_none() {
info!("onvif-events: cam {} generation expired, exiting", cam.id); info!("onvif-events: cam {} generation expired, exiting", cam.id);
return; return;
} }
// 1. CreatePullPointSubscription
set_status(&cam.id, "subscribing", None); set_status(&cam.id, "subscribing", None);
let sub = match create_pullpoint(&event_url, user, pass) { let sub = match create_pullpoint(&event_url, user, pass) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
warn!("onvif-events: cam {} CreatePullPoint failed: {e}", cam.id); warn!("onvif-events: cam {} CreatePullPoint failed: {e} (backoff {backoff_secs}s)", cam.id);
set_status(&cam.id, "failed", Some(e)); set_status(&cam.id, "failed", Some(e));
std::thread::sleep(Duration::from_secs(30)); std::thread::sleep(Duration::from_secs(backoff_secs));
backoff_secs = (backoff_secs * 2).min(600);
continue; continue;
} }
}; };
backoff_secs = 30;
info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address); 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(10);
let poll_interval = Duration::from_secs(3); let renew_interval = Duration::from_secs(55);
let renew_interval = Duration::from_secs(55); // renew before 60s timeout
let mut since_renew = std::time::Instant::now(); let mut since_renew = std::time::Instant::now();
let mut consecutive_errors: u32 = 0;
loop { loop {
if generation.upgrade().is_none() { if generation.upgrade().is_none() {
@ -183,15 +228,22 @@ fn run_subscription(
match pull_messages(&sub.address, user, pass) { match pull_messages(&sub.address, user, pass) {
Ok(events) => { Ok(events) => {
consecutive_errors = 0;
for evt in events { for evt in events {
forward_event(server, kiosk_key, &cam.id, &evt, user, pass); forward_event(server, kiosk_key, &cam.id, &evt, user, pass);
mark_event_received(&cam.id);
} }
} }
Err(e) => { Err(e) => {
warn!("onvif-events: cam {} pull failed: {e}", cam.id); consecutive_errors += 1;
let error_backoff = (15 * consecutive_errors as u64).min(300);
warn!("onvif-events: cam {} pull failed ({consecutive_errors}x): {e}, backoff {error_backoff}s", cam.id);
set_status(&cam.id, "failed", Some(e)); set_status(&cam.id, "failed", Some(e));
std::thread::sleep(Duration::from_secs(15)); if consecutive_errors >= 5 {
break; // resubscribe after backoff break; // resubscribe from scratch
}
std::thread::sleep(Duration::from_secs(error_backoff));
continue;
} }
} }
@ -406,6 +458,11 @@ fn parse_notification_messages(xml: &str) -> Vec<OnvifEvent> {
source.insert(name, value); source.insert(name, value);
} }
} }
if let Some(key_block) = extract_section(block, "Key") {
for (name, value) in parse_simple_items(&key_block) {
data.insert(name, value);
}
}
if let Some(data_block) = extract_section(block, "Data") { if let Some(data_block) = extract_section(block, "Data") {
for (name, value) in parse_simple_items(&data_block) { for (name, value) in parse_simple_items(&data_block) {
data.insert(name, value); data.insert(name, value);
@ -694,11 +751,8 @@ fn extract_digest_field(header: &str, field: &str) -> Option<String> {
} }
fn md5_hex(input: &str) -> String { fn md5_hex(input: &str) -> String {
use md5::{Digest, Md5}; let digest = md5::compute(input.as_bytes());
let mut hasher = Md5::new(); hex_lower_bytes(&digest.0)
hasher.update(input.as_bytes());
let result = hasher.finalize();
hex_lower_bytes(&result)
} }
fn hex_lower_bytes(bytes: &[u8]) -> String { fn hex_lower_bytes(bytes: &[u8]) -> String {

View file

@ -162,6 +162,10 @@ pub fn load_cached_bundle() -> Option<KioskBundle> {
} }
} }
pub fn load_kiosk_id() -> Option<String> {
load_cached_bundle().map(|b| b.kiosk_id)
}
/// Discover the BetterFrame server. /// Discover the BetterFrame server.
pub fn discover_server(override_url: Option<&str>) -> String { pub fn discover_server(override_url: Option<&str>) -> String {
if let Some(url) = override_url { if let Some(url) = override_url {
@ -514,6 +518,7 @@ pub fn heartbeat(
"network_interfaces": network_interfaces, "network_interfaces": network_interfaces,
"onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(), "onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(),
"partitions": serde_json::to_value(&hw.partitions).unwrap_or_default(), "partitions": serde_json::to_value(&hw.partitions).unwrap_or_default(),
"audio": serde_json::to_value(crate::audio::get_state()).unwrap_or_default(),
})) }))
.timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(5))
.send() .send()

View file

@ -1,10 +1,13 @@
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::sync::mpsc; use std::sync::{mpsc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use url::Url; use url::Url;
static FIRMWARE_LOCK: Mutex<()> = Mutex::new(());
static OS_UPDATE_LOCK: Mutex<()> = Mutex::new(());
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{ use gtk4::{
self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture, self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture,
@ -267,6 +270,18 @@ fn activate(app: &Application) {
} }
send_heartbeat_now(&server_for_reload, &key_for_reload); send_heartbeat_now(&server_for_reload, &key_for_reload);
} }
ServerMsg::VolumeSet(vol) => {
crate::audio::set_volume(vol);
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::VolumeMute(muted) => {
crate::audio::set_mute(muted);
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::AudioOutputSet(id) => {
crate::audio::set_output(&id);
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::SwitchLayout { ServerMsg::SwitchLayout {
display_id, display_id,
layout_id, layout_id,
@ -276,6 +291,10 @@ fn activate(app: &Application) {
layout_id, layout_id,
}); });
} }
ServerMsg::Reboot => {
info!("reboot requested by admin");
let _ = std::process::Command::new("systemctl").arg("reboot").status();
}
ServerMsg::FirmwareCheck => { ServerMsg::FirmwareCheck => {
maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload); maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload);
} }
@ -311,6 +330,7 @@ fn activate(app: &Application) {
} }
maybe_apply_os_update(&server, &key, &tx_progress); maybe_apply_os_update(&server, &key, &tx_progress);
maybe_apply_firmware_update(&server, &key, &tx_progress); maybe_apply_firmware_update(&server, &key, &tx_progress);
maybe_refresh_onvif(&server, &key);
std::thread::sleep(std::time::Duration::from_secs(60)); std::thread::sleep(std::time::Duration::from_secs(60));
} }
}); });
@ -532,6 +552,10 @@ fn maybe_apply_os_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sender<Wo
if std::env::var("BF_ENABLE_OS_OTA").as_deref() != Ok("1") { if std::env::var("BF_ENABLE_OS_OTA").as_deref() != Ok("1") {
return; return;
} }
let Ok(_lock) = OS_UPDATE_LOCK.try_lock() else {
info!("os-update: another update already in progress, skipping");
return;
};
let Some(info) = os_update::check(server_url, kiosk_key) else { let Some(info) = os_update::check(server_url, kiosk_key) else {
return; return;
}; };
@ -578,6 +602,10 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sen
if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") { if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") {
return; return;
} }
let Ok(_lock) = FIRMWARE_LOCK.try_lock() else {
info!("firmware: another update already in progress, skipping");
return;
};
let current = option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")); let current = option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
let Some(info) = firmware::check(server_url, kiosk_key, current) else { let Some(info) = firmware::check(server_url, kiosk_key, current) else {
return; return;
@ -624,6 +652,30 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sen
} }
} }
fn maybe_refresh_onvif(server_url: &str, kiosk_key: &str) {
if !onvif_events::needs_refresh() {
return;
}
info!("onvif: refreshing stale/failed subscriptions");
let bundle = match server::load_cached_bundle() {
Some(b) => b,
None => return,
};
let displays = bundle.normalized_displays();
let layout_cam_ids: std::collections::HashSet<String> = displays
.iter()
.flat_map(|d| d.layouts.iter())
.flat_map(|l| l.cells.iter())
.filter_map(|c| c.camera_id.clone())
.collect();
let layout_cameras: Vec<_> = bundle.cameras.iter()
.filter(|c| layout_cam_ids.contains(&c.id))
.cloned()
.collect();
let decrypt_key = server::load_encrypt_key().or_else(|| server::load_cluster_key());
onvif_events::start(&layout_cameras, decrypt_key.as_deref(), server_url, kiosk_key);
}
/// Install the once-per-second watchdog that enforces idle/sleep timeouts /// Install the once-per-second watchdog that enforces idle/sleep timeouts
/// per display. Safe to call multiple times — installs at most once. /// per display. Safe to call multiple times — installs at most once.
fn install_idle_watchdog() { fn install_idle_watchdog() {

View file

@ -169,6 +169,8 @@ async fn handle_message(
if let Some(layout_id) = layout_id { if let Some(layout_id) = layout_id {
let _ = tx.send(ServerMsg::SwitchLayout { display_id, layout_id }); let _ = tx.send(ServerMsg::SwitchLayout { display_id, layout_id });
} }
} else if text.contains("\"type\":\"reboot\"") {
let _ = tx.send(ServerMsg::Reboot);
} else if text.contains("\"type\":\"firmware_check\"") { } else if text.contains("\"type\":\"firmware_check\"") {
let _ = tx.send(ServerMsg::FirmwareCheck); let _ = tx.send(ServerMsg::FirmwareCheck);
} else if text.contains("\"type\":\"os_check\"") { } else if text.contains("\"type\":\"os_check\"") {
@ -183,6 +185,20 @@ async fn handle_message(
return; return;
}; };
let _ = tx.send(ServerMsg::Fan(pwm)); let _ = tx.send(ServerMsg::Fan(pwm));
} else if text.contains("\"type\":\"volume-set\"") {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
if let Some(vol) = msg.get("volume").and_then(|v| v.as_u64()) {
let _ = tx.send(ServerMsg::VolumeSet(vol.min(100) as u32));
}
} else if text.contains("\"type\":\"volume-mute\"") {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
let muted = msg.get("muted").and_then(|v| v.as_bool()).unwrap_or(true);
let _ = tx.send(ServerMsg::VolumeMute(muted));
} else if text.contains("\"type\":\"audio-output\"") {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
if let Some(id) = msg.get("output_id").and_then(|v| v.as_str()) {
let _ = tx.send(ServerMsg::AudioOutputSet(id.to_string()));
}
// ---- Journal streaming -------------------------------------------------- // ---- Journal streaming --------------------------------------------------
} else if text.contains("\"type\":\"journal-start\"") { } else if text.contains("\"type\":\"journal-start\"") {

View file

@ -26,8 +26,7 @@ module.exports = function (RED) {
function BfKioskCameraEventNode(config) { function BfKioskCameraEventNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; const node = this;
const filterIdRaw = (config.camera_id || "").toString().trim(); const filterId = (config.camera_id || "").toString().trim() || null;
const filterId = filterIdRaw && !isNaN(Number(filterIdRaw)) ? Number(filterIdRaw) : null;
async function handler(req, res) { async function handler(req, res) {
const body = await readJsonBody(req); const body = await readJsonBody(req);
@ -37,7 +36,7 @@ module.exports = function (RED) {
const cameraId = body.camera_id !== undefined ? body.camera_id const cameraId = body.camera_id !== undefined ? body.camera_id
: body.source_camera_id !== undefined ? body.source_camera_id : body.source_camera_id !== undefined ? body.source_camera_id
: null; : null;
if (filterId !== null && Number(cameraId) !== filterId) { if (filterId !== null && String(cameraId) !== filterId) {
return res.status(200).end(); return res.status(200).end();
} }
const out = { const out = {

View file

@ -22,7 +22,7 @@ module.exports = function (RED) {
function BfTriggerAnprNode(config) { function BfTriggerAnprNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; const node = this;
const filterCam = config.camera_id ? Number(config.camera_id) : null; const filterCam = config.camera_id ? String(config.camera_id).trim() : null;
async function handler(req, res) { async function handler(req, res) {
const body = await readJsonBody(req); const body = await readJsonBody(req);
@ -33,7 +33,7 @@ module.exports = function (RED) {
} }
const cameraId = body.camera_id ?? body.source_camera_id ?? null; const cameraId = body.camera_id ?? body.source_camera_id ?? null;
if (filterCam !== null && Number(cameraId) !== filterCam) { if (filterCam !== null && String(cameraId) !== filterCam) {
return res.status(200).end(); return res.status(200).end();
} }

View file

@ -15,7 +15,7 @@ module.exports = function (RED) {
function BfTriggerEventNode(config) { function BfTriggerEventNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; const node = this;
const filterCam = config.camera_id ? Number(config.camera_id) : null; const filterCam = config.camera_id ? String(config.camera_id).trim() : null;
const filterTopic = (config.topic_filter || "").trim(); const filterTopic = (config.topic_filter || "").trim();
async function handler(req, res) { async function handler(req, res) {
@ -27,7 +27,7 @@ module.exports = function (RED) {
} }
const cameraId = body.camera_id ?? body.source_camera_id ?? null; const cameraId = body.camera_id ?? body.source_camera_id ?? null;
if (filterCam !== null && Number(cameraId) !== filterCam) { if (filterCam !== null && String(cameraId) !== filterCam) {
return res.status(200).end(); return res.status(200).end();
} }

View file

@ -22,7 +22,7 @@ module.exports = function (RED) {
function BfTriggerMotionNode(config) { function BfTriggerMotionNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; const node = this;
const filterCam = config.camera_id ? Number(config.camera_id) : null; const filterCam = config.camera_id ? String(config.camera_id).trim() : null;
async function handler(req, res) { async function handler(req, res) {
const body = await readJsonBody(req); const body = await readJsonBody(req);
@ -34,7 +34,7 @@ module.exports = function (RED) {
} }
const cameraId = body.camera_id ?? body.source_camera_id ?? null; const cameraId = body.camera_id ?? body.source_camera_id ?? null;
if (filterCam !== null && Number(cameraId) !== filterCam) { if (filterCam !== null && String(cameraId) !== filterCam) {
return res.status(200).end(); return res.status(200).end();
} }

View file

@ -35,6 +35,7 @@ import { registerOsUpdateRoutes } from "./routes-os-updates.js";
import { registerStaticRoutes } from "./routes-static.js"; import { registerStaticRoutes } from "./routes-static.js";
import { registerCloudRoutes } from "./routes-cloud.js"; import { registerCloudRoutes } from "./routes-cloud.js";
import { registerTenantRoutes } from "./routes-tenants.js"; import { registerTenantRoutes } from "./routes-tenants.js";
import { registerAbleSignRoutes } from "./routes-ablesign.js";
// ---- Config ----------------------------------------------------------------- // ---- Config -----------------------------------------------------------------
@ -238,6 +239,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
registerOsUpdateRoutes(app, deps); registerOsUpdateRoutes(app, deps);
registerCloudRoutes(app, deps); registerCloudRoutes(app, deps);
registerTenantRoutes(app, deps); registerTenantRoutes(app, deps);
registerAbleSignRoutes(app, deps);
// Auth-check endpoint for Angie auth_request subrequest. // Auth-check endpoint for Angie auth_request subrequest.
// Returns 200 if session cookie is valid + admin role, 401 otherwise. // Returns 200 if session cookie is valid + admin role, 401 otherwise.

View file

@ -65,12 +65,8 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
const tenant = await deps.repo.getTenantBySlug(tenantSlug); const tenant = await deps.repo.getTenantBySlug(tenantSlug);
if (tenant && tenant.is_active) { if (tenant && tenant.is_active) {
event.context.tenant = tenant; event.context.tenant = tenant;
// Set PG search_path to the tenant's schema.
if (tenant.schema_name !== "public") {
await deps.repo.adapter.setSearchPath(tenant.schema_name); await deps.repo.adapter.setSearchPath(tenant.schema_name);
}
} else { } else {
// Fall back to default tenant.
const defaultTenant = await deps.repo.getTenantBySlug("default"); const defaultTenant = await deps.repo.getTenantBySlug("default");
if (defaultTenant) { if (defaultTenant) {
event.context.tenant = defaultTenant; event.context.tenant = defaultTenant;

View file

@ -0,0 +1,283 @@
/**
* 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, AbleSignScreenDetailPage, AbleSignContentPage, AbleSignPlaylistsPage } 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");
const accountId = await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId });
// Auto-sync screens on account creation.
try {
const opts = { apiKey, workspaceId };
const result = await ablesign.listScreens(opts);
for (const s of result.data) {
await deps.repo.upsertAbleSignScreen({
account_id: accountId,
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(accountId, {
screen_count: result.data.length,
last_sync_at: new Date().toISOString(),
});
} catch { /* sync failure is non-fatal */ }
return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
});
// Redirect old per-account route to global screens page.
app.get("/admin/ablesign/:id/screens", async () => {
return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
});
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) {
const screenRowId = 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/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/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/screens" } });
});
// ---- Screen detail + config -------------------------------------------------
app.get("/admin/ablesign/screens/:sid", async (event) => {
const sid = getRouterParam(event, "sid") ?? "";
const screen = await deps.repo.getAbleSignScreen(sid);
if (!screen) throw createError({ statusCode: 404, statusMessage: "Screen not found" });
const account = await deps.repo.getAbleSignAccount(screen.account_id);
let remoteScreen: any = null;
if (account) {
try {
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
remoteScreen = await ablesign.getScreen(
{ apiKey, workspaceId: account.workspace_id || undefined },
Number(screen.ablesign_screen_id),
);
} catch { /* remote fetch failed */ }
}
const entity = await deps.repo.getEntityByAbleSignScreen(sid);
return htmlPage(AbleSignScreenDetailPage({ screen, remoteScreen, entity }));
});
app.post("/admin/ablesign/screens/:sid", async (event) => {
const sid = getRouterParam(event, "sid") ?? "";
const screen = await deps.repo.getAbleSignScreen(sid);
if (!screen) throw createError({ statusCode: 404, statusMessage: "Screen not found" });
const account = await deps.repo.getAbleSignAccount(screen.account_id);
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
const body = await readBody<Record<string, string>>(event);
const title = (body?.title ?? "").trim();
const orientation = body?.orientation ?? "landscape";
const description = (body?.description ?? "").trim();
try {
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
await ablesign.updateScreen(
{ apiKey, workspaceId: account.workspace_id || undefined },
Number(screen.ablesign_screen_id),
{ title: title || undefined, orientation, description: description || undefined },
);
if (title) {
await deps.repo.updateAbleSignScreen(sid, { title, orientation });
}
} catch { /* update failed */ }
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/screens/${sid}` } });
});
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/screens" } });
});
// ---- Global views (all accounts aggregated) --------------------------------
app.get("/admin/ablesign/screens", async () => {
const accounts = await deps.repo.listAbleSignAccounts();
const account = accounts[0] ?? null;
const screens = account ? await deps.repo.listAbleSignScreens(account.id) : [];
for (const s of screens) {
(s as any).has_entity = !!(await deps.repo.getEntityByAbleSignScreen(s.id));
}
return htmlPage(AbleSignScreensPage({ screens, accountId: account?.id ?? null }));
});
app.get("/admin/ablesign/content", async () => {
const accounts = await deps.repo.listAbleSignAccounts();
const content: any[] = [];
for (const acct of accounts) {
try {
const apiKey = deps.secrets.decryptString(acct.api_key_encrypted, "ablesign-key");
const opts = { apiKey, workspaceId: acct.workspace_id || undefined };
const media = await ablesign.listMediaFiles(opts);
const webApps = await ablesign.listWebApps(opts);
for (const m of media.data) content.push({ ...m, account_name: acct.name, kind: "media" });
for (const w of webApps.data) content.push({ ...w, account_name: acct.name, kind: "webapp" });
} catch { /* skip failed accounts */ }
}
return htmlPage(AbleSignContentPage({ content, accounts }));
});
app.get("/admin/ablesign/playlists", async () => {
const accounts = await deps.repo.listAbleSignAccounts();
const screens = await deps.repo.listAbleSignScreens();
const playlists: any[] = [];
for (const s of screens) {
const acct = accounts.find((a: any) => a.id === s.account_id);
if (!acct) continue;
try {
const apiKey = deps.secrets.decryptString(acct.api_key_encrypted, "ablesign-key");
const opts = { apiKey, workspaceId: acct.workspace_id || undefined };
const pl = await ablesign.getPlaylist(opts, Number(s.ablesign_screen_id));
playlists.push({ screen_title: s.title, account_name: acct.name, ...pl });
} catch { /* skip */ }
}
return htmlPage(AbleSignPlaylistsPage({ playlists }));
});
}

View file

@ -790,6 +790,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const id = (getRouterParam(event, "id") ?? ""); const id = (getRouterParam(event, "id") ?? "");
const ent = await deps.repo.getEntityById(id); const ent = await deps.repo.getEntityById(id);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
if ((ent as any).managed) {
return new Response(null, { status: 302, headers: { location: `/admin/entities/${String(id)}` } });
}
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const patch: { const patch: {
name?: string; name?: string;
@ -2193,6 +2196,44 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
}); });
app.post("/admin/kiosks/:id/reboot", async (event) => {
const id = (getRouterParam(event, "id") ?? "");
getCoordinator().sendToKiosk(id, { type: "reboot" });
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
app.post("/admin/kiosks/:id/volume", async (event) => {
const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event);
const action = body?.["action"];
if (action === "mute") {
getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: true });
} else if (action === "unmute") {
getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: false });
} else if (action === "output") {
getCoordinator().sendToKiosk(id, { type: "audio-output", output_id: body?.["output_id"] ?? "" });
} else {
const vol = Math.max(0, Math.min(100, Number(body?.["volume"]) || 0));
getCoordinator().sendToKiosk(id, { type: "volume-set", volume: vol });
}
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
// ---- Tenant switcher fragment (htmx) ----------------------------------------
app.get("/admin/_tenant_switcher", async (event) => {
const tenants = await deps.repo.listTenants();
if (tenants.length <= 1) return new Response("", { headers: { "content-type": "text/html" } });
const current = (event.context as any).tenant?.slug ?? "default";
const options = tenants.map((t: any) =>
`<option value="${t.slug as string}"${t.slug === current ? " selected" : ""}>${t.name as string}</option>`
).join("");
const html = `<form method="post" action="/admin/tenants/switch" style="padding:0.5rem 1rem">
<label style="font-size:0.75rem; color:#888; display:block; margin-bottom:0.25rem">Tenant</label>
<select name="tenant_slug" style="width:100%; font-size:0.8rem; padding:0.25rem" onchange="this.form.submit()">${options}</select>
</form>`;
return new Response(html, { headers: { "content-type": "text/html" } });
});
// ---- JSON API (admin scope) — used by Node-RED bf-* nodes --------------- // ---- JSON API (admin scope) — used by Node-RED bf-* nodes ---------------
// //
// All payloads run through `stripSecrets` so credential-bearing fields // All payloads run through `stripSecrets` so credential-bearing fields

View file

@ -626,7 +626,17 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token); const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const body = validateBody(EventBody, await readBody(event)); const raw = await readBody(event);
let body: ReturnType<typeof EventBody["parse"]>;
try {
body = validateBody(EventBody, raw);
} catch (err: any) {
event.context.obs?.log.warn("event validation failed: {msg} body={raw}", {
msg: err.message ?? "unknown",
raw: JSON.stringify(raw).slice(0, 500),
});
throw err;
}
const payload = (body.payload ?? {}) as Record<string, unknown>; const payload = (body.payload ?? {}) as Record<string, unknown>;
event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic }); event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic });
@ -725,11 +735,11 @@ function registerKioskRoutes(
nodered.forward(body.topic, out, markForwarded); nodered.forward(body.topic, out, markForwarded);
mqtt.publishEvent(kiosk.id, body.topic, out); mqtt.publishEvent(kiosk.id, body.topic, out);
// ONVIF events: also forward to the fixed onvif.event route so the nodered.forward("camera.event", out);
// bf-trigger-motion / bf-trigger-anpr / bf-trigger-event nodes
// receive them without needing per-topic route registration.
if (body.source_type === "onvif") { if (body.source_type === "onvif") {
nodered.forward("onvif.event", out); nodered.forward("onvif.event", out);
nodered.forward("onvif.motion", out);
nodered.forward("onvif.anpr", out);
} }
} }

View 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 };
}
}

View file

@ -77,8 +77,8 @@ export const EventBody = av.object(
{ {
topic: av.string().minLength(1).maxLength(512), topic: av.string().minLength(1).maxLength(512),
source_type: av.string().maxLength(32).default("system"), source_type: av.string().maxLength(32).default("system"),
camera_id: av.nullable(av.string().maxLength(64)).default(null), camera_id: av.optional(av.nullable(av.string().maxLength(64))).default(null),
property_op: av.nullable(av.string().maxLength(32)).default(null), property_op: av.optional(av.nullable(av.string().maxLength(32))).default(null),
payload: av.any().default({}), payload: av.any().default({}),
}, },
{ unknownKeys: "strip" }, { unknownKeys: "strip" },
@ -155,9 +155,13 @@ export const PasswordChangeBody = av.object(
export function validateBody<T>(schema: { safeParse(input: unknown): { success: boolean; data?: T; error?: unknown } }, raw: unknown): T { export function validateBody<T>(schema: { safeParse(input: unknown): { success: boolean; data?: T; error?: unknown } }, raw: unknown): T {
const result = schema.safeParse(raw); const result = schema.safeParse(raw);
if (!result.success) { if (!result.success) {
const msg = typeof result.error === "object" && result.error && "message" in result.error let msg = "invalid request body";
? String((result.error as any).message) const err = result.error as any;
: "invalid request body"; if (err?.issues) {
msg = err.issues.map((i: any) => `${i.path?.join?.(".") ?? "?"}: ${i.message}`).join("; ");
} else if (err?.message) {
msg = String(err.message);
}
throw Object.assign(new Error(msg), { status: 400, statusText: "Bad Request" }); throw Object.assign(new Error(msg), { status: 400, statusText: "Bad Request" });
} }
return result.data as T; return result.data as T;

View file

@ -302,6 +302,16 @@ export async function generateBundle(
}); });
} }
// ONVIF event ownership: for "auto" cameras, first kiosk to fetch bundle
// takes ownership. Server writes "kiosk:<id>" into event_source so
// subsequent kiosks see it's taken and skip.
for (const cam of cameras) {
if (cam.type === "onvif" && cam.event_source === "auto") {
await repo.updateCamera(cam.id, { event_source: `kiosk:${kioskId}` } as any);
cam.event_source = `kiosk:${kioskId}`;
}
}
const bundleCameras: BundleCamera[] = []; const bundleCameras: BundleCamera[] = [];
for (const cam of cameras) { for (const cam of cameras) {
const streams = await repo.listCameraStreams(cam.id); const streams = await repo.listCameraStreams(cam.id);

View file

@ -258,6 +258,8 @@ export function rowToEntity(r: Row): Entity {
html_content: sn(r["html_content"]), html_content: sn(r["html_content"]),
web_url: sn(r["web_url"]), web_url: sn(r["web_url"]),
dashboard_id: sn(r["dashboard_id"]), dashboard_id: sn(r["dashboard_id"]),
ablesign_screen_id: sn(r["ablesign_screen_id"]),
managed: !!r["managed"],
created_at: s(r["created_at"]), created_at: s(r["created_at"]),
}; };
} }

View file

@ -689,4 +689,36 @@ export const TENANT_MIGRATIONS: readonly string[] = [
RAISE NOTICE 'UUIDv7 backfill: all integer-looking IDs replaced with UUIDs'; RAISE NOTICE 'UUIDv7 backfill: all integer-looking IDs replaced with UUIDs';
END $$`, END $$`,
// ---- AbleSign digital signage integration -----------------------------------
`CREATE TABLE IF NOT EXISTS ablesign_accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
api_key_encrypted TEXT NOT NULL,
workspace_id TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
screen_count INTEGER NOT NULL DEFAULT 0,
last_sync_at TIMESTAMPTZ,
last_sync_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE TABLE IF NOT EXISTS ablesign_screens (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES ablesign_accounts(id) ON DELETE CASCADE,
ablesign_screen_id TEXT NOT NULL,
ablesign_screen_token_encrypted TEXT,
kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
orientation TEXT NOT NULL DEFAULT 'landscape',
online BOOLEAN NOT NULL DEFAULT false,
last_heartbeat_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(account_id, ablesign_screen_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_account ON ablesign_screens(account_id)`,
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_kiosk ON ablesign_screens(kiosk_id)`,
`ALTER TABLE entities DROP CONSTRAINT IF EXISTS entities_type_check`,
`ALTER TABLE entities ADD CONSTRAINT entities_type_check CHECK(type IN ('camera', 'html', 'web', 'dashboard', 'ablesign'))`,
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS ablesign_screen_id TEXT REFERENCES ablesign_screens(id) ON DELETE CASCADE`,
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false`,
]; ];

View file

@ -13,9 +13,9 @@ import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js";
export class PgAdapter implements DbAdapter { export class PgAdapter implements DbAdapter {
private readonly pool: Pool; private readonly pool: Pool;
/** Per-async-context client when inside transaction(). */
private currentTxClient: PoolClient | null = null; private currentTxClient: PoolClient | null = null;
private txDepth = 0; private txDepth = 0;
private searchPath = "public";
constructor(connectionString: string, poolMax: number = 10) { constructor(connectionString: string, poolMax: number = 10) {
this.pool = new Pool({ this.pool = new Pool({
@ -70,8 +70,12 @@ export class PgAdapter implements DbAdapter {
private async runner<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> { private async runner<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
if (this.currentTxClient) return fn(this.currentTxClient); if (this.currentTxClient) return fn(this.currentTxClient);
const client = await this.pool.connect(); const client = await this.pool.connect();
try { return await fn(client); } try {
finally { client.release(); } await client.query(`SET search_path TO ${this.searchPath}, public`);
return await fn(client);
} finally {
client.release();
}
} }
async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> { async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> {
@ -149,13 +153,10 @@ export class PgAdapter implements DbAdapter {
dialect(): "postgres" { return "postgres"; } dialect(): "postgres" { return "postgres"; }
async setSearchPath(schema: string): Promise<void> { async setSearchPath(schema: string): Promise<void> {
// Validate schema name to prevent SQL injection (only allow alphanumeric + underscore).
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) { if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) {
throw new Error(`invalid schema name: ${schema}`); throw new Error(`invalid schema name: ${schema}`);
} }
await this.runner(async (c) => { this.searchPath = schema;
await c.query(`SET search_path TO ${schema}, public`);
});
} }
async close(): Promise<void> { async close(): Promise<void> {

View file

@ -260,7 +260,24 @@ export class Repository {
} }
async isSetupComplete(): Promise<boolean> { async isSetupComplete(): Promise<boolean> {
return (await this.getSetupState()).is_complete && (await this.countUsers()) > 0; const state = await this.getSetupState();
if (state.is_complete) return true;
if ((await this.countUsers()) > 0) return true;
// No local users — copy global admin into tenant if one exists.
const ga = await this._get<{ id: string; username: string; password_hash: string }>(
"SELECT id, username, password_hash FROM public.global_admins WHERE is_active = true LIMIT 1",
).catch(() => undefined);
if (ga) {
await this._run(
`INSERT INTO users (id, username, password_hash, role, is_active)
VALUES (?, ?, ?, 'admin', true)
ON CONFLICT (id) DO NOTHING`,
[ga.id, ga.username, ga.password_hash],
);
await this.markSetupComplete();
return true;
}
return false;
} }
async markSetupComplete(): Promise<void> { async markSetupComplete(): Promise<void> {
@ -2307,11 +2324,13 @@ export class Repository {
html_content?: string | null; html_content?: string | null;
web_url?: string | null; web_url?: string | null;
dashboard_id?: string | null; dashboard_id?: string | null;
ablesign_screen_id?: string | null;
managed?: boolean;
}): Promise<Entity> { }): Promise<Entity> {
const id = uuidv7(); const id = uuidv7();
await this._run( await this._run(
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id) `INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id, ablesign_screen_id, managed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
id, id,
input.name, input.name,
@ -2319,8 +2338,10 @@ export class Repository {
input.description ?? null, input.description ?? null,
input.type === "camera" ? (input.camera_id ?? null) : null, input.type === "camera" ? (input.camera_id ?? null) : null,
input.type === "html" ? (input.html_content ?? null) : null, input.type === "html" ? (input.html_content ?? null) : null,
input.type === "web" ? (input.web_url ?? null) : null, input.type === "web" || input.type === "ablesign" ? (input.web_url ?? null) : null,
input.type === "dashboard" ? (input.dashboard_id ?? null) : null, input.type === "dashboard" ? (input.dashboard_id ?? null) : null,
input.type === "ablesign" ? (input.ablesign_screen_id ?? null) : null,
input.managed ?? false,
], ],
); );
void this.notify("entities", "create", id); void this.notify("entities", "create", id);
@ -2398,6 +2419,14 @@ export class Repository {
* the camera's name is already taken by another entity, append the camera * the camera's name is already taken by another entity, append the camera
* id to keep the name unique. * id to keep the name unique.
*/ */
async getEntityByAbleSignScreen(screenId: string): Promise<Entity | null> {
const r = await this._get(
`SELECT * FROM entities WHERE type = 'ablesign' AND ablesign_screen_id = ? LIMIT 1`,
[screenId],
);
return r ? rowToEntity(r as Record<string, unknown>) : null;
}
async ensureCameraEntity(camera: Camera): Promise<Entity> { async ensureCameraEntity(camera: Camera): Promise<Entity> {
const existing = await this.getEntityForCamera(camera.id); const existing = await this.getEntityForCamera(camera.id);
if (existing) return existing; if (existing) return existing;
@ -2405,7 +2434,7 @@ export class Repository {
if (await this.getEntityByName(name)) { if (await this.getEntityByName(name)) {
name = `${camera.name} (cam ${camera.id.slice(0, 8)})`; name = `${camera.name} (cam ${camera.id.slice(0, 8)})`;
} }
return this.createEntity({ name, type: "camera", camera_id: camera.id }); return this.createEntity({ name, type: "camera", camera_id: camera.id, managed: true });
} }
async updateKiosk(id: string, patch: Partial<Kiosk>): Promise<void> { async updateKiosk(id: string, patch: Partial<Kiosk>): Promise<void> {
@ -2431,6 +2460,7 @@ export class Repository {
await this._run(`DELETE FROM displays WHERE kiosk_id = ?`, [id]); await this._run(`DELETE FROM displays WHERE kiosk_id = ?`, [id]);
await this._run(`DELETE FROM kiosk_labels WHERE kiosk_id = ?`, [id]); await this._run(`DELETE FROM kiosk_labels WHERE kiosk_id = ?`, [id]);
await this._run(`DELETE FROM kiosk_gpio_bindings WHERE kiosk_id = ?`, [id]); await this._run(`DELETE FROM kiosk_gpio_bindings WHERE kiosk_id = ?`, [id]);
await this._run(`UPDATE cameras SET event_source = 'auto' WHERE event_source = ?`, [`kiosk:${id}`]);
await this._run(`DELETE FROM kiosks WHERE id = ?`, [id]); await this._run(`DELETE FROM kiosks WHERE id = ?`, [id]);
}); });
for (const display of displays) { for (const display of displays) {
@ -2525,6 +2555,18 @@ export class Repository {
// camera_event_subscriptions // camera_event_subscriptions
// =========================================================================== // ===========================================================================
async getActiveOnvifOwners(): Promise<Map<string, string>> {
const rs = await this._all<{ camera_id: string; subscribed_by_kiosk_id: string }>(
`SELECT DISTINCT camera_id, subscribed_by_kiosk_id FROM camera_event_subscriptions
WHERE subscribed_by_kiosk_id IS NOT NULL AND status = 'active'`,
);
const map = new Map<string, string>();
for (const r of rs) {
map.set(r.camera_id, r.subscribed_by_kiosk_id);
}
return map;
}
async listEventSubscriptions(cameraId: string): Promise<CameraEventSubscription[]> { async listEventSubscriptions(cameraId: string): Promise<CameraEventSubscription[]> {
const rs = await this._all( const rs = await this._all(
"SELECT * FROM camera_event_subscriptions WHERE camera_id = ? ORDER BY topic", "SELECT * FROM camera_event_subscriptions WHERE camera_id = ? ORDER BY topic",
@ -2628,4 +2670,123 @@ export class Repository {
async deleteCloudAccount(id: string): Promise<void> { async deleteCloudAccount(id: string): Promise<void> {
await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]); await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]);
} }
// ===========================================================================
// AbleSign accounts + screens
// ===========================================================================
async listAbleSignAccounts(): Promise<any[]> {
return this._all("SELECT * FROM ablesign_accounts ORDER BY created_at DESC");
}
async getAbleSignAccount(id: string): Promise<any | undefined> {
return this._get("SELECT * FROM ablesign_accounts WHERE id = ?", [id]);
}
async createAbleSignAccount(input: {
name: string;
api_key_encrypted: string;
workspace_id?: string;
}): Promise<string> {
const id = uuidv7();
await this._run(
`INSERT INTO ablesign_accounts (id, name, api_key_encrypted, workspace_id)
VALUES (?, ?, ?, ?)`,
[id, input.name, input.api_key_encrypted, input.workspace_id ?? null],
);
return id;
}
async updateAbleSignAccount(id: string, patch: Record<string, unknown>): Promise<void> {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "created_at") continue;
sets.push(`${k} = ?`);
vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
await this._run(`UPDATE ablesign_accounts SET ${sets.join(", ")} WHERE id = ?`, vals);
}
async deleteAbleSignAccount(id: string): Promise<void> {
await this._run("DELETE FROM ablesign_accounts WHERE id = ?", [id]);
}
async listAbleSignScreens(accountId?: string): Promise<any[]> {
if (accountId) {
return this._all("SELECT * FROM ablesign_screens WHERE account_id = ? ORDER BY title", [accountId]);
}
return this._all("SELECT * FROM ablesign_screens ORDER BY title");
}
async getAbleSignScreen(id: string): Promise<any | undefined> {
return this._get("SELECT * FROM ablesign_screens WHERE id = ?", [id]);
}
async getAbleSignScreenByKiosk(kioskId: string): Promise<any | undefined> {
return this._get("SELECT * FROM ablesign_screens WHERE kiosk_id = ?", [kioskId]);
}
async createAbleSignScreen(input: {
account_id: string;
ablesign_screen_id: string;
ablesign_screen_token_encrypted?: string;
kiosk_id?: string;
title: string;
orientation?: string;
}): Promise<string> {
const id = uuidv7();
await this._run(
`INSERT INTO ablesign_screens (id, account_id, ablesign_screen_id, ablesign_screen_token_encrypted, kiosk_id, title, orientation)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[id, input.account_id, input.ablesign_screen_id, input.ablesign_screen_token_encrypted ?? null, input.kiosk_id ?? null, input.title, input.orientation ?? "landscape"],
);
return id;
}
async updateAbleSignScreen(id: string, patch: Record<string, unknown>): Promise<void> {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "created_at") continue;
sets.push(`${k} = ?`);
vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
await this._run(`UPDATE ablesign_screens SET ${sets.join(", ")} WHERE id = ?`, vals);
}
async deleteAbleSignScreen(id: string): Promise<void> {
await this._run("DELETE FROM ablesign_screens WHERE id = ?", [id]);
}
async upsertAbleSignScreen(input: {
account_id: string;
ablesign_screen_id: string;
title: string;
online: boolean;
last_heartbeat_at?: string;
orientation?: string;
}): Promise<string> {
const existing = await this._get<{ id: string }>(
"SELECT id FROM ablesign_screens WHERE account_id = ? AND ablesign_screen_id = ?",
[input.account_id, input.ablesign_screen_id],
);
if (existing) {
await this._run(
`UPDATE ablesign_screens SET title = ?, online = ?, last_heartbeat_at = COALESCE(?, last_heartbeat_at), orientation = COALESCE(?, orientation) WHERE id = ?`,
[input.title, input.online, input.last_heartbeat_at ?? null, input.orientation ?? null, existing.id],
);
return existing.id;
}
return this.createAbleSignScreen({
account_id: input.account_id,
ablesign_screen_id: input.ablesign_screen_id,
title: input.title,
orientation: input.orientation,
});
}
} }

View file

@ -12,8 +12,8 @@ export type StreamRole = "main" | "sub" | "other";
export type StreamSelector = "auto" | "main" | "sub"; export type StreamSelector = "auto" | "main" | "sub";
export type StreamPolicy = "auto" | "always_main" | "always_sub"; export type StreamPolicy = "auto" | "always_main" | "always_sub";
export type LayoutPriority = "hot" | "normal" | "cold"; export type LayoutPriority = "hot" | "normal" | "cold";
export type CellContentType = "none" | "camera" | "web" | "html"; export type CellContentType = "none" | "camera" | "web" | "html" | "ablesign";
export type EntityType = "camera" | "html" | "web" | "dashboard"; export type EntityType = "camera" | "html" | "web" | "dashboard" | "ablesign";
export interface Entity { export interface Entity {
id: string; id: string;
@ -25,6 +25,10 @@ export interface Entity {
web_url: string | null; web_url: string | null;
/** Node-RED dashboard tab id; populated when type === "dashboard". */ /** Node-RED dashboard tab id; populated when type === "dashboard". */
dashboard_id: string | null; dashboard_id: string | null;
/** AbleSign screen row id; populated when type === "ablesign". */
ablesign_screen_id: string | null;
/** True for entities auto-created by camera sync, cloud cams, AbleSign. Read-only in UI. */
managed: boolean;
created_at: string; created_at: string;
} }
export type DesiredPowerState = "follow_layout" | "on" | "standby"; export type DesiredPowerState = "follow_layout" | "on" | "standby";

View file

@ -1915,6 +1915,56 @@ export function KioskEditPage(props: KioskEditProps) {
}} }}
>Full</button> >Full</button>
</div> </div>
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px solid #f0f0f0; display:flex; gap:0.5rem; align-items:center">
<div style="font-size:0.8rem; font-weight:600">Power</div>
<button type="button" class="btn btn-sm btn-ghost" style="color:#c00" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/reboot`,
"hx-swap": "none",
"hx-confirm": "Reboot this kiosk? It will be offline for ~30 seconds.",
}}>Reboot</button>
</div>
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px solid #f0f0f0">
<div style="font-size:0.8rem; font-weight:600; margin-bottom:0.5rem">Audio</div>
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap">
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "0" }),
"hx-swap": "none",
}}>0%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "25" }),
"hx-swap": "none",
}}>25%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "50" }),
"hx-swap": "none",
}}>50%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "75" }),
"hx-swap": "none",
}}>75%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "100" }),
"hx-swap": "none",
}}>100%</button>
<span style="color:#999">|</span>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ action: "mute" }),
"hx-swap": "none",
}}>Mute</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ action: "unmute" }),
"hx-swap": "none",
}}>Unmute</button>
</div>
</div>
</div> </div>
</div> </div>
@ -4329,3 +4379,276 @@ export function TenantEditPage(props: TenantEditPageProps) {
</Layout> </Layout>
); );
} }
// ---- AbleSign Pages ---------------------------------------------------------
interface AbleSignPageProps {
accounts: any[];
error?: string;
}
export function AbleSignPage(props: AbleSignPageProps) {
return (
<Layout title="AbleSign" activeNav="ablesign">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Accounts</h1>
{props.error ? <div class="alert alert-error" style="margin-bottom:1rem">{props.error}</div> : ""}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1.1rem; margin:0 0 1rem">Add Account</h2>
<form method="POST" action="/admin/ablesign/add" style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:end">
<label style="font-size:0.85rem">
{"Name"}<br/>
<input type="text" name="name" required style="width:12rem" placeholder="My AbleSign" />
</label>
<label style="font-size:0.85rem">
{"API Key"}<br/>
<input type="password" name="api_key" required style="width:16rem" placeholder="ak_..." />
</label>
<label style="font-size:0.85rem">
{"Workspace ID (optional)"}<br/>
<input type="text" name="workspace_id" style="width:8rem" />
</label>
<button type="submit" class="btn btn-sm">Add</button>
</form>
</div>
{props.accounts.length > 0 ? (
<div class="card">
<div class="table-wrap">
<table>
<thead><tr>
<th>Name</th>
<th>Screens</th>
<th>Last Sync</th>
<th>Actions</th>
</tr></thead>
<tbody>
{props.accounts.map((a: any) => (
<tr>
<td><a href={`/admin/ablesign/${String(a.id)}/screens`}>{a.name}</a></td>
<td>{String(a.screen_count ?? 0)}</td>
<td style="font-size:0.85rem">
{a.last_sync_at ? formatTime(a.last_sync_at) : "Never"}
{a.last_sync_error && <span style="color:red" title={a.last_sync_error}>{" (error)"}</span>}
</td>
<td style="display:flex; gap:0.25rem">
<a href={`/admin/ablesign/${String(a.id)}/screens`} class="btn btn-sm btn-ghost">Screens</a>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`} style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost">Sync</button>
</form>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/delete`} style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : ""}
</Layout>
);
}
interface AbleSignScreensPageProps {
screens: any[];
accountId: string | null;
error?: string;
}
export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
const aid = props.accountId;
return (
<Layout title="AbleSign — Screens" activeNav="ablesign-screens">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Screens</h1>
{props.error ? <div class="alert alert-error" style="margin-bottom:1rem">{props.error}</div> : ""}
{!aid ? (
<div class="card" style="margin-bottom:1.5rem">
<p style="color:#999; font-size:0.85rem">No AbleSign account configured. Add one under Account settings first.</p>
</div>
) : (
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.75rem">Create Screen</h2>
<form method="POST" action={`/admin/ablesign/${aid}/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">
Registers a new screen in AbleSign headlessly and creates a linked entity for use in layouts.
</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>
{aid ? (
<form method="POST" action={`/admin/ablesign/${aid}/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. Create one above or sync from AbleSign.</p>
) : (
<div class="table-wrap">
<table>
<thead><tr>
<th>Title</th>
<th>Orientation</th>
<th>Status</th>
<th>Source</th>
<th>Actions</th>
</tr></thead>
<tbody>
{props.screens.map((s: any) => (
<tr>
<td><a href={`/admin/ablesign/screens/${String(s.id)}`}>{s.title}</a></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>
{s.has_entity
? <span class="badge badge-blue">Internal</span>
: <span class="badge badge-gray">External</span>}
</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>
);
}
// ---- AbleSign Screen Detail Page ---------------------------------------------
interface AbleSignScreenDetailPageProps {
screen: any;
remoteScreen: any | null;
entity: any | null;
}
export function AbleSignScreenDetailPage(props: AbleSignScreenDetailPageProps) {
const s = props.screen;
const r = props.remoteScreen;
return (
<Layout title={`AbleSign — ${String(s.title)}`} activeNav="ablesign-screens">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">{s.title}</h1>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.75rem">Screen Configuration</h2>
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}`}>
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin-bottom:1rem">
<label style="font-size:0.85rem">
{"Title"}<br/>
<input type="text" name="title" value={s.title} style="width:16rem" />
</label>
<label style="font-size:0.85rem">
{"Orientation"}<br/>
<select name="orientation" style="font-size:0.85rem">
<option value="landscape" selected={s.orientation === "landscape"}>Landscape</option>
<option value="portrait" selected={s.orientation === "portrait"}>Portrait</option>
</select>
</label>
<label style="font-size:0.85rem">
{"Description"}<br/>
<input type="text" name="description" value={r?.description ?? ""} style="width:20rem" placeholder="Optional" />
</label>
</div>
<button type="submit" class="btn btn-sm">Save</button>
</form>
</div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.5rem">Status</h2>
<div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666">
<div>{"AbleSign ID: "}{String(s.ablesign_screen_id)}</div>
<div>{"Status: "}{s.online ? "Online" : "Offline"}</div>
<div>{"Source: "}{props.entity ? "Internal" : "External"}</div>
{props.entity ? <div>{"Entity: "}<a href={`/admin/entities/${String(props.entity.id)}`}>{props.entity.name}</a></div> : ""}
{r?.heartbeatTime ? <div>{"Last heartbeat: "}{formatTime(r.heartbeatTime)}</div> : ""}
{r?.timezone ? <div>{"Timezone: "}{String(r.timezone)}</div> : ""}
</div>
</div>
<div style="display:flex; gap:0.5rem">
<a href="/admin/ablesign/screens" class="btn btn-sm btn-ghost">Back to Screens</a>
</div>
</Layout>
);
}
interface AbleSignContentPageProps { content: any[]; accounts: any[]; }
export function AbleSignContentPage(props: AbleSignContentPageProps) {
return (
<Layout title="AbleSign — Content" activeNav="ablesign-content">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Content</h1>
<div class="card">
{props.content.length === 0
? <p style="color:#999; font-size:0.85rem">No content found. Add media or web apps in AbleSign CMS.</p>
: <div class="table-wrap">
<table>
<thead><tr><th>Title</th><th>Type</th><th>Account</th></tr></thead>
<tbody>
{props.content.map((c: any) => (
<tr>
<td>{c.title}</td>
<td style="font-size:0.85rem">{c.kind === "media" ? String(c.fileType || "media") : "web app"}</td>
<td style="font-size:0.85rem; color:#999">{c.account_name}</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
</Layout>
);
}
interface AbleSignPlaylistsPageProps { playlists: any[]; }
export function AbleSignPlaylistsPage(props: AbleSignPlaylistsPageProps) {
const cards = props.playlists.map((pl: any) =>
`<div class="card" style="margin-bottom:1rem">
<h2 style="font-size:1rem; margin:0 0 0.5rem">${pl.screen_title as string}</h2>
<p style="font-size:0.8rem; color:#999; margin:0 0 0.5rem">
Account: ${pl.account_name as string} · ${String(pl.items?.length ?? 0)} items${pl.shufflePlay ? " · Shuffle" : ""}
</p>
${Array.isArray(pl.items) && pl.items.length > 0
? `<table style="font-size:0.85rem; width:100%"><thead><tr><th>#</th><th>Type</th><th>Duration</th></tr></thead><tbody>${
(pl.items as any[]).map((item: any, idx: number) =>
`<tr><td>${String(idx + 1)}</td><td>${item.mediafileId ? "Media" : item.webAppId ? "Web App" : "Unknown"}</td><td>${item.displayDuration ? `${String(item.displayDuration)}s` : "—"}</td></tr>`
).join("")
}</tbody></table>`
: ""}
</div>`
).join("");
return (
<Layout title="AbleSign — Playlists" activeNav="ablesign-playlists">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Playlists</h1>
{props.playlists.length === 0
? <div class="card"><p style="color:#999; font-size:0.85rem">No playlists found.</p></div>
: cards}
</Layout>
);
}

View file

@ -39,6 +39,20 @@ function NavItem(props: { href: string; label: string; icon: string; active?: bo
); );
} }
function NavGroup(props: { label: string; icon: string; active?: boolean; children: string | string[] }) {
return (
<details class="nav-group" open={props.active}>
<summary class={`nav-item${props.active ? " active" : ""}`}>
<span class="nav-icon">{props.icon}</span>
{props.label}
</summary>
<div class="nav-group-items">
{props.children}
</div>
</details>
);
}
function Sidebar(props: { activeNav?: string }) { function Sidebar(props: { activeNav?: string }) {
const a = props.activeNav; const a = props.activeNav;
return ( return (
@ -58,6 +72,11 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/firmware" label="Firmware" icon="&#9650;" active={a === "firmware"} /> <NavItem href="/admin/firmware" label="Firmware" icon="&#9650;" active={a === "firmware"} />
<NavItem href="/admin/os-updates" label="OS Updates" icon="&#9679;" active={a === "os-updates"} /> <NavItem href="/admin/os-updates" label="OS Updates" icon="&#9679;" active={a === "os-updates"} />
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="&#9729;" active={a === "cloud"} /> <NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="&#9729;" active={a === "cloud"} />
<NavGroup label="AbleSign" icon="&#9654;" active={a?.startsWith("ablesign")}>
<NavItem href="/admin/ablesign/screens" label="Screens" icon=" " active={a === "ablesign-screens"} />
<NavItem href="/admin/ablesign/content" label="Content" icon=" " active={a === "ablesign-content"} />
<NavItem href="/admin/ablesign/playlists" label="Playlists" icon=" " active={a === "ablesign-playlists"} />
</NavGroup>
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} /> <NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<NavItem href="/admin/audit" label="Audit" icon="&#9678;" active={a === "audit"} /> <NavItem href="/admin/audit" label="Audit" icon="&#9678;" active={a === "audit"} />
<NavItem href="/admin/backup" label="Backup" icon="&#9788;" active={a === "backup"} /> <NavItem href="/admin/backup" label="Backup" icon="&#9788;" active={a === "backup"} />
@ -65,6 +84,7 @@ function Sidebar(props: { activeNav?: string }) {
<hr /> <hr />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} /> <NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />
<NavItem href="/admin/nodered" label="Node-RED" icon="&#8594;" active={a === "nodered"} /> <NavItem href="/admin/nodered" label="Node-RED" icon="&#8594;" active={a === "nodered"} />
<div class="tenant-switcher" {...{"hx-get": "/admin/_tenant_switcher", "hx-trigger": "load", "hx-swap": "innerHTML"}}></div>
</nav> </nav>
</aside> </aside>
); );
@ -199,6 +219,12 @@ const baseStyles = {
".nav-item:hover": { backgroundColor: "#2a2a4e", color: "#fff", textDecoration: "none" }, ".nav-item:hover": { backgroundColor: "#2a2a4e", color: "#fff", textDecoration: "none" },
".nav-item.active": { backgroundColor: "#2563eb", color: "#fff" }, ".nav-item.active": { backgroundColor: "#2563eb", color: "#fff" },
".nav-icon": { fontSize: "0.75rem", width: "1.25rem", textAlign: "center" as const }, ".nav-icon": { fontSize: "0.75rem", width: "1.25rem", textAlign: "center" as const },
".nav-group": { margin: 0, padding: 0 },
".nav-group summary": { cursor: "pointer", listStyle: "none" },
".nav-group summary::-webkit-details-marker": { display: "none" },
".nav-group-items": { paddingLeft: "1.25rem" },
".nav-group-items .nav-item": { fontSize: "0.8rem", padding: "0.35rem 1rem" },
".tenant-switcher": { marginTop: "auto", borderTop: "1px solid #2a2a4e", paddingTop: "0.25rem" },
".sidebar hr": { border: "none", borderTop: "1px solid #2a2a4e", margin: "0.5rem 0" }, ".sidebar hr": { border: "none", borderTop: "1px solid #2a2a4e", margin: "0.5rem 0" },
".topbar": { ".topbar": {
display: "flex", display: "flex",