Compare commits

..

25 commits

Author SHA1 Message Date
Mitchell R
8b988d7a7d feat: localStorage injection for AbleSign WebView cells
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
Server bundle:
- BundleCell gains local_storage: Record<string,string> field
- AbleSign entities populate screenId + screenToken (decrypted)
  into local_storage for the cell

Kiosk:
- BundleCell.local_storage deserialized from bundle
- ensure_web() accepts local_storage param
- Injects UserScript at document-start that sets localStorage
  items before the page loads — AbleSign player reads these
  and skips the pairing screen

Also: getSetupState auto-creates row if missing (handles DELETE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 04:02:30 +02:00
Mitchell R
d58792524d fix: auto-create setup_state if missing + map ablesign→web in bundle
- getSetupState: INSERT if row missing instead of throwing. Handles
  manual DELETE or fresh tenant schema.
- Bundle generation: ablesign entities map to content_type='web' with
  web_url from entity. Kiosk renders as WebView — no kiosk update needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:57:42 +02:00
Mitchell R
4e282f503d fix: add 'ablesign' to layout_cells content_type CHECK constraint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:48:27 +02:00
Mitchell R
69450de009 fix: fallback to user-provided title when AbleSign API omits it
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:42:32 +02:00
Mitchell R
88bf4645f6 fix: probe for BSB entrypoint path (not hardcoded)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:36:59 +02:00
Mitchell R
5fa34d0568 fix: entrypoint wrapper chowns /var/lib/betterframe on startup
Named volume mounts as root-owned. BSB runs as uid 1000.
bf-entrypoint.sh fixes ownership before exec'ing BSB entrypoint.
Removed VOLUME directive (compose handles mount).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:33:29 +02:00
Mitchell R
6b2f56f092 fix: add betterframe-data volume to server in Coolify compose
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:20:21 +02:00
Mitchell R
58700be430 remove: root docker-compose.yml (Coolify manages its own compose)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:11:21 +02:00
Mitchell R
9db8d1d65b fix: clean up secret key generation log message
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:56:27 +02:00
Mitchell R
b739533ce1 fix: mount betterframe-data volume on server container
secret.key, firmware signing keys, and all encrypted data require
persistent storage at /var/lib/betterframe. Without this volume,
every redeploy regenerates keys and breaks all encrypted fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:53:36 +02:00
Mitchell R
d6a52df27a feat: global Settings page for AbleSign + Cloud Cam account config
- New /admin/settings page with AbleSign account setup (API key) and
  link to Cloud Cams config
- Settings nav item in sidebar (gear icon, before Account)
- Removed AbleSign Config from AbleSign dropdown (now in Settings)
- AbleSign account delete redirects to Settings
- Cloud Cams nav item kept for its own CRUD page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:47:54 +02:00
Mitchell R
f0088836e9 fix: add AbleSign Config link back to nav dropdown
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:45:17 +02:00
Mitchell R
01fcb66402 fix: surface AbleSign screen creation errors instead of swallowing
Previously caught and silently ignored. Now shows error message on
the screens page so we can debug the pairing flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:41:13 +02:00
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
31 changed files with 1635 additions and 197 deletions

View file

@ -46,7 +46,17 @@ ARG BF_MQTT_TOPIC_PREFIX=betterframe
RUN apk add --no-cache gettext ffmpeg
RUN mkdir -p /var/lib/betterframe && chown 1000:1000 /var/lib/betterframe
RUN mkdir -p /var/lib/betterframe \
&& { \
echo '#!/bin/sh'; \
echo 'chown -R 1000:1000 /var/lib/betterframe 2>/dev/null || true'; \
echo '# Find and exec the base image entrypoint or node directly'; \
echo 'if [ -f /root/entrypoint.sh ]; then exec /root/entrypoint.sh "$@"; fi'; \
echo 'if [ -f /usr/local/bin/entrypoint.sh ]; then exec /usr/local/bin/entrypoint.sh "$@"; fi'; \
echo 'if [ -f /home/bsb/entrypoint.sh ]; then exec /home/bsb/entrypoint.sh "$@"; fi'; \
echo 'exec node /home/bsb/lib/index.js "$@"'; \
} > /usr/local/bin/bf-entrypoint.sh \
&& chmod +x /usr/local/bin/bf-entrypoint.sh
# Install plugin into BSB's node_modules (searched at /home/bsb/node_modules)
# /mnt/bsb-plugins is a VOLUME in base image — writes lost at runtime
@ -67,6 +77,6 @@ RUN echo "$BF_SERVER_VERSION" > /home/bsb/.bf-version
ENV NODE_ENV=production
ENV BSB_LIVE=true
VOLUME /var/lib/betterframe
ENTRYPOINT ["/usr/local/bin/bf-entrypoint.sh"]
EXPOSE 18080 18081 18082

View file

@ -20,6 +20,8 @@ services:
restart: unless-stopped
environment:
- TZ=UTC
volumes:
- betterframe-data:/var/lib/betterframe
expose:
- "18080"
- "18081"
@ -94,6 +96,11 @@ services:
networks:
- betterframe
volumes:
betterframe-data:
nrdata:
pgdata:
networks:
betterframe:
driver: bridge

View file

@ -1,128 +0,0 @@
# BetterFrame stack: server + Angie proxy + Node-RED.
# Kiosk runs on the Pi natively (not in Docker, needs Wayland/HDMI).
#
# Lives at repo root by convention — Docker Compose + Coolify both default
# to looking here. All paths are repo-root-relative so they resolve
# identically whether compose is invoked with or without --project-directory.
#
# Usage:
# docker compose up -d --build # from repo root
#
# Volumes (override per-deployment via env — see Coolify "Environment"):
# BF_DATA_VOLUME_NAME default "betterframe-data"
# NODERED_DATA_VOLUME_NAME default "nodered-data"
# BF_HOST_PORT default 80 (host edge port mapped to angie)
#
# Coolify ops: set these env vars on the resource so each deployment owns its
# own named volumes (e.g. "bf-prod-data" vs "bf-staging-data"). For host bind
# mounts or NFS / S3-CSI volumes, use Coolify's per-service "Storage" UI
# rather than templating driver_opts here — JSON injection via env is brittle.
#
# Only ${BF_HOST_PORT}:80 is published on the host. Backend services and
# Node-RED are reachable only from within the Docker network.
version: "3.8"
services:
server:
build:
context: .
dockerfile: deploy/docker/Dockerfile.server
args:
BF_SERVER_VERSION: ${BF_SERVER_VERSION:-}
container_name: betterframe-server
restart: unless-stopped
environment:
- TZ=UTC
volumes:
- type: bind
source: ${SERVER_SEC_CONFIG:-./sec-config.yaml}
target: /home/bsb/sec-config.yaml
read_only: true
expose:
- "18080"
- "18081"
- "18082"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:18080/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- betterframe
angie:
build:
context: .
dockerfile: deploy/docker/Dockerfile.angie
container_name: betterframe-angie
restart: unless-stopped
depends_on:
- server
- nodered
ports:
- "${BF_HOST_PORT:-80}:80"
networks:
- betterframe
nodered:
build:
context: .
dockerfile: deploy/docker/Dockerfile.nodered
container_name: betterframe-nodered
restart: unless-stopped
environment:
- TZ=UTC
volumes:
- nodered-data:/data
expose:
- "1880"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:1880/nrdp/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
networks:
- betterframe
# Optional: uncomment to use PostgreSQL instead of SQLite.
# Set BF_DB=postgres and BF_PG_URL on the server service to activate.
postgres:
image: postgres:18-alpine
container_name: betterframe-postgres
restart: unless-stopped
environment:
- POSTGRES_DB=${BF_PG_DB:-betterframe}
- POSTGRES_USER=${BF_PG_USER:-betterframe}
- POSTGRES_PASSWORD=${BF_PG_PASSWORD:-betterframe}
volumes:
- postgres-data:/var/lib/postgresql
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${BF_PG_USER:-betterframe}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- betterframe
profiles:
- postgres
volumes:
# Top-level keys are the in-compose references used above. `name:` sets the
# actual docker volume name on the host so multiple Coolify deployments on
# the same host can share machine without name collisions. Default keeps
# backward compat with existing single-host deployments.
betterframe-data:
name: ${BF_DATA_VOLUME_NAME:-betterframe-data}
nodered-data:
name: ${NODERED_DATA_VOLUME_NAME:-nodered-data}
postgres-data:
name: ${BF_PG_VOLUME_NAME:-betterframe-postgres}
networks:
betterframe:
driver: bridge

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

@ -138,6 +138,8 @@ pub struct BundleCell {
pub fit: String,
#[serde(default)]
pub smart_url: Option<SmartUrlConfig>,
#[serde(default)]
pub local_storage: Option<std::collections::HashMap<String, String>>,
}
fn default_fit() -> String { "cover".to_string() }

View file

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

View file

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

View file

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

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.
pub fn discover_server(override_url: Option<&str>) -> String {
if let Some(url) = override_url {
@ -514,6 +518,7 @@ pub fn heartbeat(
"network_interfaces": network_interfaces,
"onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(),
"partitions": serde_json::to_value(&hw.partitions).unwrap_or_default(),
"audio": serde_json::to_value(crate::audio::get_state()).unwrap_or_default(),
}))
.timeout(Duration::from_secs(5))
.send()

View file

@ -1,10 +1,13 @@
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fs;
use std::sync::mpsc;
use std::sync::{mpsc, Mutex};
use std::time::{Duration, Instant};
use url::Url;
static FIRMWARE_LOCK: Mutex<()> = Mutex::new(());
static OS_UPDATE_LOCK: Mutex<()> = Mutex::new(());
use gtk4::prelude::*;
use gtk4::{
self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture,
@ -267,6 +270,18 @@ fn activate(app: &Application) {
}
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::VolumeSet(vol) => {
crate::audio::set_volume(vol);
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::VolumeMute(muted) => {
crate::audio::set_mute(muted);
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::AudioOutputSet(id) => {
crate::audio::set_output(&id);
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::SwitchLayout {
display_id,
layout_id,
@ -276,6 +291,10 @@ fn activate(app: &Application) {
layout_id,
});
}
ServerMsg::Reboot => {
info!("reboot requested by admin");
let _ = std::process::Command::new("systemctl").arg("reboot").status();
}
ServerMsg::FirmwareCheck => {
maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload);
}
@ -311,6 +330,7 @@ fn activate(app: &Application) {
}
maybe_apply_os_update(&server, &key, &tx_progress);
maybe_apply_firmware_update(&server, &key, &tx_progress);
maybe_refresh_onvif(&server, &key);
std::thread::sleep(std::time::Duration::from_secs(60));
}
});
@ -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") {
return;
}
let Ok(_lock) = OS_UPDATE_LOCK.try_lock() else {
info!("os-update: another update already in progress, skipping");
return;
};
let Some(info) = os_update::check(server_url, kiosk_key) else {
return;
};
@ -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") {
return;
}
let Ok(_lock) = FIRMWARE_LOCK.try_lock() else {
info!("firmware: another update already in progress, skipping");
return;
};
let current = option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
let Some(info) = firmware::check(server_url, kiosk_key, current) else {
return;
@ -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
/// per display. Safe to call multiple times — installs at most once.
fn install_idle_watchdog() {
@ -1141,7 +1193,7 @@ fn render_layout(display_id: &str, layout_id: &str) {
none_cell()
} else {
let key = html_key(html);
ensure_web(key, WebSource::Html(html), server_url, kiosk_key).upcast()
ensure_web(key, WebSource::Html(html), server_url, kiosk_key, None).upcast()
}
}
"web" => {
@ -1150,7 +1202,7 @@ fn render_layout(display_id: &str, layout_id: &str) {
none_cell()
} else {
let key = format!("web:{url}");
let wv = ensure_web(key, WebSource::Url(url), server_url, kiosk_key);
let wv = ensure_web(key, WebSource::Url(url), server_url, kiosk_key, cell.local_storage.as_ref());
// Smart URL: execute login/navigation steps after page loads.
if let Some(ref smart) = cell.smart_url {
let decrypt_key = server::load_encrypt_key()
@ -1872,6 +1924,7 @@ fn ensure_web(
source: WebSource<'_>,
server_url: &str,
kiosk_key: &str,
local_storage: Option<&std::collections::HashMap<String, String>>,
) -> webkit6::WebView {
let cached = WARM_WEBVIEWS.with(|m| m.borrow().get(&key).map(|e| e.webview.clone()));
if let Some(wv) = cached {
@ -1915,6 +1968,26 @@ fn ensure_web(
}
}
if let Some(ls) = local_storage {
if !ls.is_empty() {
let mut js = String::from("(function(){");
for (k, v) in ls {
js.push_str(&format!("localStorage.setItem({},{});", js_string_lit(k), js_string_lit(v)));
}
js.push_str("})();");
let script = webkit6::UserScript::new(
&js,
webkit6::UserContentInjectedFrames::TopFrame,
webkit6::UserScriptInjectionTime::Start,
&[],
&[],
);
if let Some(ucm) = webkit6::prelude::WebViewExt::user_content_manager(&wv) {
ucm.add_script(&script);
}
}
}
match source {
WebSource::Html(html) => {
webkit6::prelude::WebViewExt::load_html(&wv, html, None);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -65,12 +65,8 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
const tenant = await deps.repo.getTenantBySlug(tenantSlug);
if (tenant && tenant.is_active) {
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);
}
} else {
// Fall back to default tenant.
const defaultTenant = await deps.repo.getTenantBySlug("default");
if (defaultTenant) {
event.context.tenant = defaultTenant;

View file

@ -0,0 +1,288 @@
/**
* 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 screenTitle = screen.title || title;
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: screenTitle,
orientation: screen.orientation || "landscape",
});
await deps.repo.createEntity({
name: `AbleSign: ${screenTitle}`,
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 (err) {
const msg = (err as Error).message ?? "unknown error";
event.context.obs?.log.warn("ablesign screen creation failed: {msg}", { msg });
const screens = await deps.repo.listAbleSignScreens(accountId);
for (const s of screens) (s as any).has_entity = !!(await deps.repo.getEntityByAbleSignScreen(s.id));
return htmlPage(AbleSignScreensPage({ screens, accountId, error: `Screen creation failed: ${msg}` }));
}
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/settings" } });
});
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

@ -34,6 +34,7 @@ import {
renderKioskLabels,
renderDisplayLayouts,
renderDefaultLayoutSelect,
SettingsPage,
} from "../../web-templates/admin-pages.js";
import { discover as onvifDiscover, getEventProperties as onvifGetEventProperties } from "../../shared/onvif.js";
import { generateBundle } from "../../shared/bundle.js";
@ -790,6 +791,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const id = (getRouterParam(event, "id") ?? "");
const ent = await deps.repo.getEntityById(id);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
if ((ent as any).managed) {
return new Response(null, { status: 302, headers: { location: `/admin/entities/${String(id)}` } });
}
const body = await readBody<Record<string, string>>(event);
const patch: {
name?: string;
@ -2193,6 +2197,52 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
app.post("/admin/kiosks/:id/reboot", async (event) => {
const id = (getRouterParam(event, "id") ?? "");
getCoordinator().sendToKiosk(id, { type: "reboot" });
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
app.post("/admin/kiosks/:id/volume", async (event) => {
const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event);
const action = body?.["action"];
if (action === "mute") {
getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: true });
} else if (action === "unmute") {
getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: false });
} else if (action === "output") {
getCoordinator().sendToKiosk(id, { type: "audio-output", output_id: body?.["output_id"] ?? "" });
} else {
const vol = Math.max(0, Math.min(100, Number(body?.["volume"]) || 0));
getCoordinator().sendToKiosk(id, { type: "volume-set", volume: vol });
}
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
// ---- Settings page ----------------------------------------------------------
app.get("/admin/settings", async () => {
const cloudAccounts = await deps.repo.listCloudAccounts();
const ablesignAccounts = await deps.repo.listAbleSignAccounts();
return htmlPage(SettingsPage({ cloudAccounts, ablesignAccounts }));
});
// ---- 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 ---------------
//
// All payloads run through `stripSecrets` so credential-bearing fields

View file

@ -735,11 +735,11 @@ function registerKioskRoutes(
nodered.forward(body.topic, out, markForwarded);
mqtt.publishEvent(kiosk.id, body.topic, out);
// ONVIF events: also forward to the fixed onvif.event route so the
// bf-trigger-motion / bf-trigger-anpr / bf-trigger-event nodes
// receive them without needing per-topic route registration.
nodered.forward("camera.event", out);
if (body.source_type === "onvif") {
nodered.forward("onvif.event", out);
nodered.forward("onvif.motion", out);
nodered.forward("onvif.anpr", out);
}
}

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),
source_type: av.string().maxLength(32).default("system"),
camera_id: av.nullable(av.string().maxLength(64)).default(null),
property_op: av.nullable(av.string().maxLength(32)).default(null),
camera_id: av.optional(av.nullable(av.string().maxLength(64))).default(null),
property_op: av.optional(av.nullable(av.string().maxLength(32))).default(null),
payload: av.any().default({}),
},
{ unknownKeys: "strip" },

View file

@ -91,6 +91,8 @@ export interface BundleCell {
login_detect_url?: string;
session_check_interval_ms?: number;
};
/** Key→value pairs injected into WebView localStorage before page load. */
local_storage?: Record<string, string>;
}
export interface BundleLayout {
@ -220,6 +222,7 @@ export async function generateBundle(
// bundle still ships the legacy camera_id/web_url/html_content shape
// so the existing Rust kiosk consumes it unchanged.
let contentType = c.content_type;
let cellLocalStorage: Record<string, string> | undefined;
let cameraId = c.camera_id;
let webUrl = c.web_url;
let htmlContent = c.html_content;
@ -229,13 +232,29 @@ export async function generateBundle(
// Dashboard entities are surfaced to the kiosk as `web` cells
// pointing at /dash/<dashboard_id> — kiosk WebKit handles them
// identically to user-supplied web cells.
contentType = ent.type === "dashboard" ? "web" : ent.type;
contentType = (ent.type === "dashboard" || ent.type === "ablesign") ? "web" : ent.type;
cameraId = ent.type === "camera" ? ent.camera_id : null;
webUrl =
ent.type === "web" ? ent.web_url :
ent.type === "ablesign" ? ent.web_url :
ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` :
null;
htmlContent = ent.type === "html" ? ent.html_content : null;
// AbleSign: inject screenToken + screenId into localStorage
if (ent.type === "ablesign" && ent.ablesign_screen_id) {
const screen = await repo.getAbleSignScreen(ent.ablesign_screen_id);
if (screen) {
const ls: Record<string, string> = {
screenId: screen.ablesign_screen_id,
};
if (screen.ablesign_screen_token_encrypted) {
try {
ls["screenToken"] = secrets.decryptString(screen.ablesign_screen_token_encrypted, "ablesign-token");
} catch { /* token decrypt failed — player will show pairing */ }
}
cellLocalStorage = ls;
}
}
}
}
bundleCells.push({
@ -270,6 +289,7 @@ export async function generateBundle(
session_check_interval_ms: raw.session_check_interval_ms,
};
})() : undefined,
local_storage: cellLocalStorage,
});
}
result.push({
@ -302,6 +322,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[] = [];
for (const cam of cameras) {
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"]),
web_url: sn(r["web_url"]),
dashboard_id: sn(r["dashboard_id"]),
ablesign_screen_id: sn(r["ablesign_screen_id"]),
managed: !!r["managed"],
created_at: s(r["created_at"]),
};
}

View file

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

View file

@ -13,9 +13,9 @@ import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js";
export class PgAdapter implements DbAdapter {
private readonly pool: Pool;
/** Per-async-context client when inside transaction(). */
private currentTxClient: PoolClient | null = null;
private txDepth = 0;
private searchPath = "public";
constructor(connectionString: string, poolMax: number = 10) {
this.pool = new Pool({
@ -70,8 +70,12 @@ export class PgAdapter implements DbAdapter {
private async runner<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
if (this.currentTxClient) return fn(this.currentTxClient);
const client = await this.pool.connect();
try { return await fn(client); }
finally { client.release(); }
try {
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> {
@ -149,13 +153,10 @@ export class PgAdapter implements DbAdapter {
dialect(): "postgres" { return "postgres"; }
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)) {
throw new Error(`invalid schema name: ${schema}`);
}
await this.runner(async (c) => {
await c.query(`SET search_path TO ${schema}, public`);
});
this.searchPath = schema;
}
async close(): Promise<void> {

View file

@ -254,13 +254,36 @@ export class Repository {
// ===========================================================================
async getSetupState(): Promise<SetupState> {
const r = await this._get("SELECT * FROM setup_state WHERE id = 1");
if (!r) throw new Error("setup_state row missing");
let r = await this._get("SELECT * FROM setup_state WHERE id = 1");
if (!r) {
await this._run(
"INSERT INTO setup_state (id, is_complete, extras) VALUES (1, false, '{}') ON CONFLICT (id) DO NOTHING",
);
r = await this._get("SELECT * FROM setup_state WHERE id = 1");
if (!r) throw new Error("setup_state row could not be created");
}
return rowToSetupState(r as Record<string, unknown>);
}
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> {
@ -2307,11 +2330,13 @@ export class Repository {
html_content?: string | null;
web_url?: string | null;
dashboard_id?: string | null;
ablesign_screen_id?: string | null;
managed?: boolean;
}): Promise<Entity> {
const id = uuidv7();
await this._run(
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id, ablesign_screen_id, managed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
input.name,
@ -2319,8 +2344,10 @@ export class Repository {
input.description ?? null,
input.type === "camera" ? (input.camera_id ?? null) : null,
input.type === "html" ? (input.html_content ?? null) : null,
input.type === "web" ? (input.web_url ?? null) : null,
input.type === "web" || input.type === "ablesign" ? (input.web_url ?? null) : null,
input.type === "dashboard" ? (input.dashboard_id ?? null) : null,
input.type === "ablesign" ? (input.ablesign_screen_id ?? null) : null,
input.managed ?? false,
],
);
void this.notify("entities", "create", id);
@ -2398,6 +2425,14 @@ export class Repository {
* the camera's name is already taken by another entity, append the camera
* 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> {
const existing = await this.getEntityForCamera(camera.id);
if (existing) return existing;
@ -2405,7 +2440,7 @@ export class Repository {
if (await this.getEntityByName(name)) {
name = `${camera.name} (cam ${camera.id.slice(0, 8)})`;
}
return this.createEntity({ name, type: "camera", camera_id: camera.id });
return this.createEntity({ name, type: "camera", camera_id: camera.id, managed: true });
}
async updateKiosk(id: string, patch: Partial<Kiosk>): Promise<void> {
@ -2431,6 +2466,7 @@ export class Repository {
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_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]);
});
for (const display of displays) {
@ -2525,6 +2561,18 @@ export class Repository {
// 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[]> {
const rs = await this._all(
"SELECT * FROM camera_event_subscriptions WHERE camera_id = ? ORDER BY topic",
@ -2628,4 +2676,123 @@ export class Repository {
async deleteCloudAccount(id: string): Promise<void> {
await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]);
}
// ===========================================================================
// AbleSign accounts + screens
// ===========================================================================
async listAbleSignAccounts(): Promise<any[]> {
return this._all("SELECT * FROM ablesign_accounts ORDER BY created_at DESC");
}
async getAbleSignAccount(id: string): Promise<any | undefined> {
return this._get("SELECT * FROM ablesign_accounts WHERE id = ?", [id]);
}
async createAbleSignAccount(input: {
name: string;
api_key_encrypted: string;
workspace_id?: string;
}): Promise<string> {
const id = uuidv7();
await this._run(
`INSERT INTO ablesign_accounts (id, name, api_key_encrypted, workspace_id)
VALUES (?, ?, ?, ?)`,
[id, input.name, input.api_key_encrypted, input.workspace_id ?? null],
);
return id;
}
async updateAbleSignAccount(id: string, patch: Record<string, unknown>): Promise<void> {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "created_at") continue;
sets.push(`${k} = ?`);
vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
await this._run(`UPDATE ablesign_accounts SET ${sets.join(", ")} WHERE id = ?`, vals);
}
async deleteAbleSignAccount(id: string): Promise<void> {
await this._run("DELETE FROM ablesign_accounts WHERE id = ?", [id]);
}
async listAbleSignScreens(accountId?: string): Promise<any[]> {
if (accountId) {
return this._all("SELECT * FROM ablesign_screens WHERE account_id = ? ORDER BY title", [accountId]);
}
return this._all("SELECT * FROM ablesign_screens ORDER BY title");
}
async getAbleSignScreen(id: string): Promise<any | undefined> {
return this._get("SELECT * FROM ablesign_screens WHERE id = ?", [id]);
}
async getAbleSignScreenByKiosk(kioskId: string): Promise<any | undefined> {
return this._get("SELECT * FROM ablesign_screens WHERE kiosk_id = ?", [kioskId]);
}
async createAbleSignScreen(input: {
account_id: string;
ablesign_screen_id: string;
ablesign_screen_token_encrypted?: string;
kiosk_id?: string;
title: string;
orientation?: string;
}): Promise<string> {
const id = uuidv7();
await this._run(
`INSERT INTO ablesign_screens (id, account_id, ablesign_screen_id, ablesign_screen_token_encrypted, kiosk_id, title, orientation)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[id, input.account_id, input.ablesign_screen_id, input.ablesign_screen_token_encrypted ?? null, input.kiosk_id ?? null, input.title, input.orientation ?? "landscape"],
);
return id;
}
async updateAbleSignScreen(id: string, patch: Record<string, unknown>): Promise<void> {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "created_at") continue;
sets.push(`${k} = ?`);
vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
await this._run(`UPDATE ablesign_screens SET ${sets.join(", ")} WHERE id = ?`, vals);
}
async deleteAbleSignScreen(id: string): Promise<void> {
await this._run("DELETE FROM ablesign_screens WHERE id = ?", [id]);
}
async upsertAbleSignScreen(input: {
account_id: string;
ablesign_screen_id: string;
title: string;
online: boolean;
last_heartbeat_at?: string;
orientation?: string;
}): Promise<string> {
const existing = await this._get<{ id: string }>(
"SELECT id FROM ablesign_screens WHERE account_id = ? AND ablesign_screen_id = ?",
[input.account_id, input.ablesign_screen_id],
);
if (existing) {
await this._run(
`UPDATE ablesign_screens SET title = ?, online = ?, last_heartbeat_at = COALESCE(?, last_heartbeat_at), orientation = COALESCE(?, orientation) WHERE id = ?`,
[input.title, input.online, input.last_heartbeat_at ?? null, input.orientation ?? null, existing.id],
);
return existing.id;
}
return this.createAbleSignScreen({
account_id: input.account_id,
ablesign_screen_id: input.ablesign_screen_id,
title: input.title,
orientation: input.orientation,
});
}
}

View file

@ -130,9 +130,9 @@ function loadServerKey(config: SecretsConfig, log: SecretsLog): Buffer {
}
}
// 3. Generate new dev key
log.warn(
`GENERATING DEV SERVER KEY at ${keyPath} — production should use systemd-creds`,
// 3. No key found — generate one and persist.
log.info(
`encryption key not found, generating new key at ${keyPath}`,
);
try {
mkdirSync(dirname(keyPath), { recursive: true });

View file

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

View file

@ -1915,6 +1915,56 @@ export function KioskEditPage(props: KioskEditProps) {
}}
>Full</button>
</div>
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px solid #f0f0f0; display:flex; gap:0.5rem; align-items:center">
<div style="font-size:0.8rem; font-weight:600">Power</div>
<button type="button" class="btn btn-sm btn-ghost" style="color:#c00" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/reboot`,
"hx-swap": "none",
"hx-confirm": "Reboot this kiosk? It will be offline for ~30 seconds.",
}}>Reboot</button>
</div>
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px solid #f0f0f0">
<div style="font-size:0.8rem; font-weight:600; margin-bottom:0.5rem">Audio</div>
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap">
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "0" }),
"hx-swap": "none",
}}>0%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "25" }),
"hx-swap": "none",
}}>25%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "50" }),
"hx-swap": "none",
}}>50%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "75" }),
"hx-swap": "none",
}}>75%</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ volume: "100" }),
"hx-swap": "none",
}}>100%</button>
<span style="color:#999">|</span>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ action: "mute" }),
"hx-swap": "none",
}}>Mute</button>
<button type="button" class="btn btn-sm btn-ghost" {...{
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
"hx-vals": JSON.stringify({ action: "unmute" }),
"hx-swap": "none",
}}>Unmute</button>
</div>
</div>
</div>
</div>
@ -4329,3 +4379,343 @@ export function TenantEditPage(props: TenantEditPageProps) {
</Layout>
);
}
// ---- Settings Page ----------------------------------------------------------
interface SettingsPageProps {
cloudAccounts: any[];
ablesignAccounts: any[];
error?: string;
}
export function SettingsPage(props: SettingsPageProps) {
return (
<Layout title="Settings" activeNav="settings">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">Settings</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">AbleSign Account</h2>
{props.ablesignAccounts.length > 0 ? (
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Screens</th><th>Last Sync</th><th>Actions</th></tr></thead>
<tbody>
{props.ablesignAccounts.map((a: any) => (
<tr>
<td>{a.name}</td>
<td>{String(a.screen_count ?? 0)}</td>
<td style="font-size:0.85rem">{a.last_sync_at ? formatTime(a.last_sync_at) : "Never"}</td>
<td>
<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">Remove</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<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:10rem" placeholder="My AbleSign" />
</label>
<label style="font-size:0.85rem">
{"API Key"}<br/>
<input type="password" name="api_key" required style="width:14rem" placeholder="ak_..." />
</label>
<label style="font-size:0.85rem">
{"Workspace ID"}<br/>
<input type="text" name="workspace_id" style="width:6rem" />
</label>
<button type="submit" class="btn btn-sm">Connect</button>
</form>
)}
</div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1.1rem; margin:0 0 1rem">Cloud Camera Accounts</h2>
<p style="font-size:0.85rem; color:#999">
{"Manage cloud camera integrations at "}
<a href="/admin/cloud-accounts">Cloud Cams</a>.
</p>
</div>
</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 }) {
const a = props.activeNav;
return (
@ -57,14 +71,20 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/kiosks" label="Kiosks" icon="&#9672;" active={a === "kiosks"} />
<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/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/audit" label="Audit" icon="&#9678;" active={a === "audit"} />
<NavItem href="/admin/backup" label="Backup" icon="&#9788;" active={a === "backup"} />
<NavItem href="/admin/tenants" label="Tenants" icon="&#9783;" active={a === "tenants"} />
<hr />
<NavItem href="/admin/settings" label="Settings" icon="&#9881;" active={a === "settings"} />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />
<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>
</aside>
);
@ -199,6 +219,12 @@ const baseStyles = {
".nav-item:hover": { backgroundColor: "#2a2a4e", color: "#fff", textDecoration: "none" },
".nav-item.active": { backgroundColor: "#2563eb", color: "#fff" },
".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" },
".topbar": {
display: "flex",