Compare commits

...

20 commits

Author SHA1 Message Date
Mitchell R
a518fe17ea
fix: move AbleSign migrations to end of array (after UUIDv7 backfill)
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
Server already ran past the indices where AbleSign tables were inserted.
Moving to end ensures they get new, unrun version numbers.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:15:41 +02:00
Mitchell R
8381ed280e
fix: md5 crate v0.7 API (compute not Digest) + clone default_layout_id
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:03:29 +02:00
Mitchell R
5d23079086
feat: add anyvali input validation to all external API endpoints
Create shared/api-schemas.ts with av.object schemas for:
- pair/initiate, pair/claim (pairing flow)
- kiosk/heartbeat (telemetry with displays, partitions, hwmon)
- kiosk/event (ONVIF/system events)
- kiosk/logs (batched log entries)
- firmware/applied, os/applied (update reports)
- auth/login, auth/totp, setup (admin auth)

Each endpoint now calls validateBody(Schema, body) which returns 400
on schema violation. All string fields have maxLength, numeric fields
have min/max ranges, arrays strip unknown keys. Rejects malformed
input before it reaches DB or business logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 14:03:58 +02:00
Mitchell R
515f7088cc
fix: graceful FK violation on event insert + kiosk stale file cleanup
- Event insert: if source_camera_id FK fails (stale kiosk sending old
  integer IDs), retry with camera_id=NULL. Event still logs, just
  without camera association. Stops 500 spam until kiosk updates.
- Kiosk cleanup on first healthy boot: remove stale OS update staging
  files (>24h old) from /var/lib/betterframe/tmp/, and old firmware
  .prev binaries (>7 days) from /opt/betterframe/kiosk/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:56:24 +02:00
Mitchell R
b93e9484ff
fix: drop FKs before UUID backfill, re-add after
SET CONSTRAINTS ALL DEFERRED only works on DEFERRABLE constraints.
Ours aren't. Instead: save all FK definitions to jsonb array, drop
them all, do the id replacements unconstrained, re-add from saved
definitions. Same pattern as the type-conversion migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:42:35 +02:00
Mitchell R
420463afdc
feat: backfill bare-integer IDs with UUIDs in existing rows
Adds migration that finds all rows where id matches ^[0-9]+$ (legacy
integer IDs converted to text strings), generates a UUID for each,
updates all FK references dynamically via information_schema, then
updates the PK. Existing data (cameras, layouts, kiosks, etc.) gets
proper UUID IDs. New rows already use UUIDv7 from repository.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:38:55 +02:00
Mitchell R
9dc6119791
fix: also convert *_by columns (uploaded_by, created_by) to TEXT
Dynamic column detection only matched 'id' and '*_id' patterns.
firmware_releases.uploaded_by and similar FK columns use '_by' suffix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:34:27 +02:00
Mitchell R
02b69713c3
fix: dynamic FK re-add with column existence check
Previous migration hardcoded ALTER TABLE ADD CONSTRAINT for FK re-add,
but production DB may have different columns than CREATE TABLE schema
(api_keys had no user_id). Now uses _bf_add_fk() helper that checks
both source column and target column exist before adding FK. Skips
silently if either is missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:32:18 +02:00
Mitchell R
fe9c51d3f0
fix: exclude setup_state from UUIDv7 migration
setup_state is a singleton (INTEGER PK CHECK(id=1)), not an entity.
Converting its id to TEXT breaks the CHECK constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:28:53 +02:00
Mitchell R
108123fb86
fix: dynamic FK drop in UUIDv7 migration — handle all constraints
Previous migration hard-coded FK constraint names and missed
firmware_releases.uploaded_by, firmware_rollouts.created_by/release_id,
os_update_releases.uploaded_by, os_update_rollouts.created_by/release_id,
pairing_codes.consumed_by_kiosk_id, entities.camera_id.

Now uses information_schema to dynamically drop ALL FK constraints
before type conversion, and dynamically finds ALL integer id/*_id
columns to convert. No more missed FKs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:26:46 +02:00
Mitchell R
9b4032ca8a
refactor: decommission SQLite + add UUIDv7 PK migration for existing PG
- Delete sqlite-adapter.ts and migrations.ts (SQLite path removed)
- Remove driver/sqlitePath from all config schemas + sec-config template
- init.ts now PG-only, no SQLite branch
- db-adapter.ts dialect narrowed to "postgres" only
- Add in-place UUIDv7 migration: detects INTEGER PKs in existing PG
  databases, drops FK constraints, ALTER COLUMN TYPE to TEXT for all
  15 entity tables + their FK columns, re-adds FK constraints. Idempotent
  (skips if already TEXT). Existing integer IDs become string "1", "2"
  etc — new inserts use proper UUIDv7 from repository.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:22:29 +02:00
Mitchell R
908fd417c0
refactor(kiosk): migrate all bundle IDs to String for UUIDv7 + ONVIF image proxy
- All bundle struct ID fields (kiosk_id, display_id, layout_id,
  camera_id, stream_id, gpio_id) now String with de_flexible_id
  deserializer accepting both JSON numbers and strings.
- PoolKey, DisplayState hashmap, WorkerMsg, ServerMsg all use String
  IDs throughout. Zero u32 ID references remain.
- ONVIF event image proxy: kiosk detects PictureUri in event data,
  downloads image from camera (basic/digest auth), base64 encodes,
  attaches to event payload before forwarding to server.
- Add md5 crate for HTTP Digest auth on camera image fetch.
- ws_client: flexible_id_from_value helper for WS message ID parsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:09:32 +02:00
39 changed files with 2188 additions and 1712 deletions

View file

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

138
kiosk/Cargo.lock generated
View file

@ -2,6 +2,41 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -138,6 +173,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
name = "betterframe-kiosk"
version = "0.1.0"
dependencies = [
"aes-gcm",
"axum",
"base64",
"dirs",
@ -149,11 +185,14 @@ dependencies = [
"gstreamer-video",
"gtk4",
"hex",
"hkdf",
"hostname",
"md5",
"rand",
"reqwest",
"serde",
"serde_json",
"sha1",
"sha2",
"tokio",
"tokio-tungstenite",
@ -161,6 +200,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"url",
"urlencoding",
"webkit6",
]
@ -263,6 +303,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -326,9 +376,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@ -381,6 +441,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -743,6 +804,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gio"
version = "0.20.12"
@ -1151,6 +1222,24 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "hostname"
version = "0.4.2"
@ -1430,6 +1519,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -1546,6 +1644,12 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "memchr"
version = "2.8.0"
@ -1655,6 +1759,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.79"
@ -1786,6 +1896,18 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -2696,6 +2818,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@ -2714,6 +2846,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"

View file

@ -44,6 +44,8 @@ urlencoding = "2"
# ONVIF WSSE PasswordDigest auth
sha1 = "0.10"
# HTTP Digest auth for camera image fetch
md5 = "0.7"
# Hardware-bound at-rest encryption of state files (kiosk_key + bundle cache
# contain camera RTSP credentials in URL form). Keys derived via HKDF from

165
kiosk/src/audio.rs Normal file
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

@ -1,5 +1,35 @@
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
fn de_flexible_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
let v = serde_json::Value::deserialize(deserializer)?;
match v {
serde_json::Value::String(s) => Ok(s),
serde_json::Value::Number(n) => Ok(n.to_string()),
_ => Err(serde::de::Error::custom("expected string or number for id")),
}
}
fn de_flexible_id_opt<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
let v = Option::<serde_json::Value>::deserialize(deserializer)?;
match v {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(s)) if s.is_empty() => Ok(None),
Some(serde_json::Value::String(s)) => Ok(Some(s)),
Some(serde_json::Value::Number(n)) => Ok(Some(n.to_string())),
_ => Err(serde::de::Error::custom("expected string or number for id")),
}
}
fn de_flexible_id_vec<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
let v = Vec::<serde_json::Value>::deserialize(deserializer)?;
v.into_iter()
.map(|item| match item {
serde_json::Value::String(s) => Ok(s),
serde_json::Value::Number(n) => Ok(n.to_string()),
_ => Err(serde::de::Error::custom("expected string or number in id array")),
})
.collect()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KioskBundle {
@ -39,7 +69,7 @@ impl KioskBundle {
height_px: d.height_px,
idle_timeout_seconds: d.idle_timeout_seconds,
sleep_timeout_seconds: d.sleep_timeout_seconds,
default_layout_id: d.default_layout_id,
default_layout_id: d.default_layout_id.clone(),
layouts: self.layouts.clone(),
}];
}
@ -49,37 +79,43 @@ impl KioskBundle {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleDisplay {
pub id: u32,
#[serde(deserialize_with = "de_flexible_id")]
pub id: String,
pub name: String,
pub width_px: u32,
pub height_px: u32,
pub idle_timeout_seconds: u32,
pub sleep_timeout_seconds: u32,
pub default_layout_id: Option<u32>,
#[serde(default, deserialize_with = "de_flexible_id_opt")]
pub default_layout_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleDisplayWithLayouts {
pub id: u32,
#[serde(deserialize_with = "de_flexible_id")]
pub id: String,
pub name: String,
pub width_px: u32,
pub height_px: u32,
pub idle_timeout_seconds: u32,
pub sleep_timeout_seconds: u32,
pub default_layout_id: Option<u32>,
#[serde(default, deserialize_with = "de_flexible_id_opt")]
pub default_layout_id: Option<String>,
#[serde(default)]
pub layouts: Vec<BundleLayout>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleLayout {
pub id: u32,
#[serde(deserialize_with = "de_flexible_id")]
pub id: String,
pub name: String,
pub grid_cols: u32,
pub grid_rows: u32,
pub priority: String,
pub cooling_timeout_seconds: Option<u32>,
pub preload_camera_ids: Vec<u32>,
#[serde(default, deserialize_with = "de_flexible_id_vec")]
pub preload_camera_ids: Vec<String>,
pub is_default: bool,
pub resets_idle_timer: bool,
pub cells: Vec<BundleCell>,
@ -137,7 +173,8 @@ pub struct SmartUrlStep {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleCamera {
pub id: u32,
#[serde(deserialize_with = "de_flexible_id")]
pub id: String,
pub name: String,
#[serde(rename = "type")]
pub cam_type: String,
@ -162,7 +199,8 @@ pub struct BundleCamera {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleStream {
pub id: u32,
#[serde(deserialize_with = "de_flexible_id")]
pub id: String,
pub role: String,
pub name: String,
pub rtsp_uri: String,
@ -174,7 +212,8 @@ pub struct BundleStream {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleGpioBinding {
pub id: u32,
#[serde(deserialize_with = "de_flexible_id")]
pub id: String,
pub chip: String,
pub pin: u32,
pub direction: String,

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

@ -115,7 +115,7 @@ async fn local_info_handler(
async fn local_layout_handler(
State(state): State<LocalServerState>,
Path(id): Path<u32>,
Path(id): Path<String>,
Query(auth): Query<LocalAuth>,
) -> Response {
if !constant_time_eq(&auth.key, &state.local_key) {
@ -127,7 +127,7 @@ async fn local_layout_handler(
};
if let Err(e) = tx.send(WorkerMsg::SwitchLayout {
display_id: None,
layout_id: id,
layout_id: id.clone(),
}) {
warn!("local-server: send SwitchLayout failed: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "send failed").into_response();
@ -152,7 +152,7 @@ async fn local_layout_handler(
/// "good enough" and isolated.
async fn local_snapshot_handler(
State(state): State<LocalServerState>,
Path(camera_id): Path<u32>,
Path(camera_id): Path<String>,
Query(auth): Query<LocalAuth>,
) -> Response {
if !constant_time_eq(&auth.key, &state.local_key) {

View file

@ -1,4 +1,5 @@
mod at_rest;
mod audio;
mod axiom;
mod bundle;
mod cec;
@ -18,15 +19,20 @@ pub use ui::WorkerMsg;
pub enum ServerMsg {
ReloadBundle,
Standby(Option<u32>),
Wake(Option<u32>),
Standby(Option<String>),
Wake(Option<String>),
/// Some(0..=255) = manual PWM. None = restore auto.
Fan(Option<u32>),
/// Switch to a specific layout by ID, optionally scoped to one display.
SwitchLayout {
display_id: Option<u32>,
layout_id: u32,
display_id: Option<String>,
layout_id: String,
},
/// Audio controls from admin.
VolumeSet(u32),
VolumeMute(bool),
AudioOutputSet(String),
Reboot,
/// Server-pushed "go check for a firmware update now".
FirmwareCheck,
/// Server-pushed "go check for an OS update now".

View file

@ -25,7 +25,7 @@ use crate::bundle::BundleCamera;
/// Active subscriptions keyed by camera id. Worker threads check this
/// to know when to stop (camera removed from bundle / bundle changed).
static ACTIVE: Mutex<Option<HashMap<u32, ()>>> = Mutex::new(None);
static ACTIVE: Mutex<Option<HashMap<String, ()>>> = Mutex::new(None);
/// Holds the current generation Arc. When start() replaces it, the old
/// Arc drops → old threads' Weak::upgrade() returns None → they exit.
@ -34,38 +34,79 @@ static ACTIVE: Mutex<Option<HashMap<u32, ()>>> = Mutex::new(None);
static GENERATION: Mutex<Option<Arc<()>>> = Mutex::new(None);
/// Subscription status per camera — reported in heartbeat for admin visibility.
static STATUS: Mutex<Option<HashMap<u32, SubStatus>>> = Mutex::new(None);
static STATUS: Mutex<Option<HashMap<String, SubStatus>>> = Mutex::new(None);
#[derive(Clone, serde::Serialize)]
pub struct SubStatus {
pub state: &'static str, // "subscribing", "active", "failed", "stopped"
pub state: &'static str,
pub last_event_at: Option<String>,
pub subscribed_at: Option<String>,
pub error: Option<String>,
}
fn set_status(cam_id: u32, state: &'static str, error: Option<String>) {
fn epoch_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs}")
}
fn epoch_now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn set_status(cam_id: &str, state: &'static str, error: Option<String>) {
let mut map = STATUS.lock().unwrap();
let map = map.get_or_insert_with(HashMap::new);
let entry = map.entry(cam_id).or_insert_with(|| SubStatus {
let entry = map.entry(cam_id.to_string()).or_insert_with(|| SubStatus {
state: "subscribing",
last_event_at: None,
subscribed_at: None,
error: None,
});
entry.state = state;
entry.error = error;
if state == "active" {
entry.subscribed_at = Some(epoch_now());
}
}
fn mark_event_received(cam_id: u32) {
fn mark_event_received(cam_id: &str) {
let mut map = STATUS.lock().unwrap();
if let Some(map) = map.as_mut() {
if let Some(entry) = map.get_mut(&cam_id) {
entry.last_event_at = Some(crate::os_update::current_os_version_public()); // reuse timestamp helper... actually just use epoch
if let Some(entry) = map.get_mut(cam_id) {
entry.last_event_at = Some(epoch_now());
}
}
}
/// Check if any subscription needs a forced refresh (>24h since subscribe,
/// or currently in failed/stopped state).
pub fn needs_refresh() -> bool {
let map = STATUS.lock().unwrap();
let Some(map) = map.as_ref() else { return false };
let now = epoch_now_secs();
for status in map.values() {
if status.state == "failed" || status.state == "stopped" {
return true;
}
if let Some(ref sub_at) = status.subscribed_at {
if let Ok(ts) = sub_at.parse::<u64>() {
if now.saturating_sub(ts) > 24 * 3600 {
return true;
}
}
}
}
false
}
/// Get current subscription statuses for all cameras. Used by heartbeat.
pub fn get_statuses() -> HashMap<u32, SubStatus> {
pub fn get_statuses() -> HashMap<String, SubStatus> {
STATUS.lock().unwrap().clone().unwrap_or_default()
}
@ -99,7 +140,7 @@ pub fn start(
// Signal old workers to stop.
let mut active = ACTIVE.lock().unwrap();
let new_map: HashMap<u32, ()> = onvif_cams.iter().map(|c| (c.id, ())).collect();
let new_map: HashMap<String, ()> = onvif_cams.iter().map(|c| (c.id.clone(), ())).collect();
*active = Some(new_map);
drop(active);
@ -146,18 +187,18 @@ fn run_subscription(
}
// 1. CreatePullPointSubscription
set_status(cam.id, "subscribing", None);
set_status(&cam.id, "subscribing", None);
let sub = match create_pullpoint(&event_url, user, pass) {
Ok(s) => s,
Err(e) => {
warn!("onvif-events: cam {} CreatePullPoint failed: {e}", cam.id);
set_status(cam.id, "failed", Some(e));
set_status(&cam.id, "failed", Some(e));
std::thread::sleep(Duration::from_secs(30));
continue;
}
};
info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address);
set_status(cam.id, "active", None);
set_status(&cam.id, "active", None);
// 2. Poll loop
let poll_interval = Duration::from_secs(3);
@ -184,12 +225,13 @@ fn run_subscription(
match pull_messages(&sub.address, user, pass) {
Ok(events) => {
for evt in events {
forward_event(server, kiosk_key, cam.id, &evt);
forward_event(server, kiosk_key, &cam.id, &evt, user, pass);
mark_event_received(&cam.id);
}
}
Err(e) => {
warn!("onvif-events: cam {} pull failed: {e}", cam.id);
set_status(cam.id, "failed", Some(e));
set_status(&cam.id, "failed", Some(e));
std::thread::sleep(Duration::from_secs(15));
break; // resubscribe after backoff
}
@ -406,6 +448,11 @@ fn parse_notification_messages(xml: &str) -> Vec<OnvifEvent> {
source.insert(name, value);
}
}
if let Some(key_block) = extract_section(block, "Key") {
for (name, value) in parse_simple_items(&key_block) {
data.insert(name, value);
}
}
if let Some(data_block) = extract_section(block, "Data") {
for (name, value) in parse_simple_items(&data_block) {
data.insert(name, value);
@ -542,12 +589,23 @@ fn extract_attr_inline(xml: &str, attr: &str) -> Option<String> {
// ---- Forward to BF server --------------------------------------------------
fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent) {
let payload = serde_json::json!({
fn forward_event(
server: &str,
kiosk_key: &str,
camera_id: &str,
evt: &OnvifEvent,
cam_user: &str,
cam_pass: &str,
) {
let attachments = fetch_image_attachments(&evt.data, cam_user, cam_pass);
let mut payload = serde_json::json!({
"source": evt.source,
"data": evt.data,
"timestamp": evt.timestamp,
});
if !attachments.is_empty() {
payload["attachments"] = serde_json::json!(attachments);
}
let body = serde_json::json!({
"topic": evt.topic,
"source_type": "onvif",
@ -559,10 +617,144 @@ fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent
.post(format!("{server}/api/kiosk/event"))
.header("Authorization", format!("Bearer {kiosk_key}"))
.json(&body)
.timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
.send();
}
fn fetch_image_attachments(
data: &HashMap<String, String>,
user: &str,
pass: &str,
) -> HashMap<String, String> {
let mut attachments = HashMap::new();
let image_exts = [".jpg", ".jpeg", ".png", ".bmp"];
for (key, value) in data {
if !value.starts_with("http://") && !value.starts_with("https://") {
continue;
}
let lower = value.to_lowercase();
if !image_exts.iter().any(|ext| lower.contains(ext)) {
continue;
}
match fetch_image_b64(value, user, pass) {
Some(b64) => {
let mime = if lower.contains(".png") {
"image/png"
} else {
"image/jpeg"
};
attachments.insert(key.clone(), format!("data:{mime};base64,{b64}"));
}
None => {
warn!("onvif-events: failed to fetch image for {key}: {value}");
}
}
}
attachments
}
fn fetch_image_b64(url: &str, user: &str, pass: &str) -> Option<String> {
use base64::Engine;
let client = reqwest::blocking::Client::new();
let resp = client
.get(url)
.basic_auth(user, Some(pass))
.timeout(Duration::from_secs(5))
.send()
.ok()?;
if !resp.status().is_success() {
let status = resp.status();
// Retry with digest auth if basic auth returned 401.
if status.as_u16() == 401 {
return fetch_image_b64_digest(url, user, pass);
}
warn!("onvif-events: image fetch HTTP {status} for {url}");
return None;
}
let bytes = resp.bytes().ok()?;
if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
return None;
}
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
}
fn fetch_image_b64_digest(url: &str, user: &str, pass: &str) -> Option<String> {
use base64::Engine;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.ok()?;
let resp = client
.get(url)
.header("Authorization", digest_auth_header(url, user, pass)?)
.send()
.ok()?;
if !resp.status().is_success() {
return None;
}
let bytes = resp.bytes().ok()?;
if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
return None;
}
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
}
fn digest_auth_header(url: &str, user: &str, pass: &str) -> Option<String> {
let client = reqwest::blocking::Client::new();
let resp = client.get(url).timeout(Duration::from_secs(3)).send().ok()?;
if resp.status().as_u16() != 401 {
return None;
}
let www_auth = resp.headers().get("www-authenticate")?.to_str().ok()?;
if !www_auth.to_lowercase().starts_with("digest ") {
return None;
}
let realm = extract_digest_field(www_auth, "realm")?;
let nonce = extract_digest_field(www_auth, "nonce")?;
let qop = extract_digest_field(www_auth, "qop").unwrap_or_default();
let uri = url::Url::parse(url).ok().map(|u| u.path().to_string()).unwrap_or_else(|| "/".to_string());
let ha1 = md5_hex(&format!("{user}:{realm}:{pass}"));
let ha2 = md5_hex(&format!("GET:{uri}"));
let cnonce = format!("{:08x}", rand::random::<u32>());
let nc = "00000001";
let response = if qop.contains("auth") {
md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"))
} else {
md5_hex(&format!("{ha1}:{nonce}:{ha2}"))
};
if qop.contains("auth") {
Some(format!(
r#"Digest username="{user}", realm="{realm}", nonce="{nonce}", uri="{uri}", response="{response}", qop=auth, nc={nc}, cnonce="{cnonce}""#
))
} else {
Some(format!(
r#"Digest username="{user}", realm="{realm}", nonce="{nonce}", uri="{uri}", response="{response}""#
))
}
}
fn extract_digest_field(header: &str, field: &str) -> Option<String> {
let pat = format!("{field}=\"");
let start = header.find(&pat)? + pat.len();
let end = header[start..].find('"')?;
Some(header[start..start + end].to_string())
}
fn md5_hex(input: &str) -> String {
let digest = md5::compute(input.as_bytes());
hex_lower_bytes(&digest.0)
}
fn hex_lower_bytes(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0x0f) as usize] as char);
}
s
}
// ---- Cluster key decryption ------------------------------------------------
/// Decrypt a value encrypted with secrets.encryptForCluster on the server.

View file

@ -428,8 +428,8 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option<KioskBundle> {
pub fn report_layout_change(
server: &str,
key: &str,
display_id: u32,
layout_id: u32,
display_id: &str,
layout_id: &str,
layout_name: &str,
) {
let client = reqwest::blocking::Client::new();
@ -514,6 +514,7 @@ pub fn heartbeat(
"network_interfaces": network_interfaces,
"onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(),
"partitions": serde_json::to_value(&hw.partitions).unwrap_or_default(),
"audio": serde_json::to_value(crate::audio::get_state()).unwrap_or_default(),
}))
.timeout(Duration::from_secs(5))
.send()

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,
@ -30,7 +33,7 @@ use crate::ws_client;
/// even though the GTK main loop is shared.
struct DisplayState {
window: ApplicationWindow,
current_layout_id: Option<u32>,
current_layout_id: Option<String>,
last_activity: Instant,
is_asleep: bool,
}
@ -58,7 +61,7 @@ struct PipelineEntry {
/// per (main, sub, other) stream — each with independent warmth state. When a
/// cell switches M↔S we promote the new variant to Warm/Hot but leave the old
/// one to cool down naturally so a quick swap back is instant.
type PoolKey = (u32, char);
type PoolKey = (String, char);
/// WebView pool entry. Same Hot/Warm/Cooling/Cold lifecycle as cameras —
/// switching to a layout that doesn't reference a previously-loaded URL/HTML
@ -95,7 +98,7 @@ thread_local! {
static CURRENT_SYNC_LABEL: RefCell<String> = RefCell::new(String::from("unknown"));
/// Per-display state, keyed by bundle display id.
static DISPLAYS: RefCell<HashMap<u32, DisplayState>> = RefCell::new(HashMap::new());
static DISPLAYS: RefCell<HashMap<String, DisplayState>> = RefCell::new(HashMap::new());
/// Has the idle-watchdog already been installed on the main loop?
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
@ -171,7 +174,7 @@ fn activate(app: &Application) {
// cached on-disk bundle and keep retrying every 30s in the background.
let initial = match server::fetch_bundle(&server, &key) {
Some(b) => {
crate::axiom::set_kiosk_id(b.kiosk_id.to_string());
crate::axiom::set_kiosk_id(b.kiosk_id.clone());
info!(
"bundle: {} cameras, {} display(s)",
b.cameras.len(),
@ -267,6 +270,18 @@ fn activate(app: &Application) {
}
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::VolumeSet(vol) => {
crate::audio::set_volume(vol);
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::VolumeMute(muted) => {
crate::audio::set_mute(muted);
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::AudioOutputSet(id) => {
crate::audio::set_output(&id);
send_heartbeat_now(&server_for_reload, &key_for_reload);
}
ServerMsg::SwitchLayout {
display_id,
layout_id,
@ -276,6 +291,10 @@ fn activate(app: &Application) {
layout_id,
});
}
ServerMsg::Reboot => {
info!("reboot requested by admin");
let _ = std::process::Command::new("systemctl").arg("reboot").status();
}
ServerMsg::FirmwareCheck => {
maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload);
}
@ -306,10 +325,12 @@ fn activate(app: &Application) {
firmware::mark_firmware_applied();
mark_kiosk_healthy();
mark_rauc_slot_good();
cleanup_stale_files();
first_iter = false;
}
maybe_apply_os_update(&server, &key, &tx_progress);
maybe_apply_firmware_update(&server, &key, &tx_progress);
maybe_refresh_onvif(&server, &key);
std::thread::sleep(std::time::Duration::from_secs(60));
}
});
@ -329,14 +350,14 @@ fn activate(app: &Application) {
display_id,
layout_id,
} => {
if let Some(display_id) = display_id {
render_layout(display_id, layout_id);
if let Some(display_id) = &display_id {
render_layout(display_id, &layout_id);
} else {
switch_layout_anywhere(layout_id);
switch_layout_anywhere(&layout_id);
}
}
WorkerMsg::Standby(display_id) => standby_display(display_id),
WorkerMsg::Wake(display_id) => wake_display(display_id),
WorkerMsg::Standby(display_id) => standby_display(display_id.as_deref()),
WorkerMsg::Wake(display_id) => wake_display(display_id.as_deref()),
WorkerMsg::ShowTerminalCode(code) => show_terminal_code_overlay(&code),
WorkerMsg::DismissTerminalCode => dismiss_terminal_code_overlay(),
WorkerMsg::UpdateProgress(progress) => show_update_banner(progress),
@ -350,11 +371,11 @@ pub enum WorkerMsg {
ShowPairingCode(String),
RenderBundle(KioskBundle, String, String),
SwitchLayout {
display_id: Option<u32>,
layout_id: u32,
display_id: Option<String>,
layout_id: String,
},
Standby(Option<u32>),
Wake(Option<u32>),
Standby(Option<String>),
Wake(Option<String>),
ShowTerminalCode(String),
DismissTerminalCode,
/// Update progress banner — shown as overlay on all displays.
@ -362,7 +383,7 @@ pub enum WorkerMsg {
UpdateProgress(Option<(String, u8)>),
}
fn output_name_for_display(display_id: u32) -> Option<String> {
fn output_name_for_display(display_id: &str) -> Option<String> {
CURRENT_BUNDLE.with(|b| {
b.borrow()
.as_ref()
@ -376,7 +397,7 @@ fn output_name_for_display(display_id: u32) -> Option<String> {
})
}
fn standby_display(display_id: Option<u32>) {
fn standby_display(display_id: Option<&str>) {
if let Some(display_id) = display_id {
if let Some(output_name) = output_name_for_display(display_id) {
cec::standby_output(&output_name);
@ -384,7 +405,7 @@ fn standby_display(display_id: Option<u32>) {
cec::standby();
}
DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
st.is_asleep = true;
}
});
@ -398,7 +419,7 @@ fn standby_display(display_id: Option<u32>) {
}
}
fn wake_display(display_id: Option<u32>) {
fn wake_display(display_id: Option<&str>) {
if let Some(display_id) = display_id {
if let Some(output_name) = output_name_for_display(display_id) {
cec::wake_output(&output_name);
@ -406,7 +427,7 @@ fn wake_display(display_id: Option<u32>) {
cec::wake();
}
DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
st.is_asleep = false;
st.last_activity = Instant::now();
}
@ -423,9 +444,9 @@ fn wake_display(display_id: Option<u32>) {
}
/// Reset activity timer for one display. If asleep, wake it.
fn mark_activity(display_id: u32) {
fn mark_activity(display_id: &str) {
DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
st.last_activity = Instant::now();
if st.is_asleep {
info!("activity while asleep → waking display {display_id}");
@ -451,11 +472,12 @@ fn send_heartbeat_now(server_url: &str, kiosk_key: &str) -> bool {
.map(|(index, (name, width_px, height_px))| {
let bundle_id = bundle_displays
.get(index)
.map(|d| d.id)
.or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id));
.map(|d| d.id.clone())
.or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id.clone()));
let power_state = bundle_id
.as_deref()
.and_then(|id| {
DISPLAYS.with(|ds| ds.borrow().get(&id).map(|st| st.is_asleep))
DISPLAYS.with(|ds| ds.borrow().get(id).map(|st| st.is_asleep))
})
.map(|is_asleep| if is_asleep { "standby" } else { "awake" })
.unwrap_or("unknown")
@ -495,14 +517,45 @@ fn mark_rauc_slot_good() {
.status();
}
fn cleanup_stale_files() {
// Stale OS update downloads in staging dir.
let staging = std::path::Path::new("/var/lib/betterframe/tmp");
if staging.is_dir() {
if let Ok(entries) = fs::read_dir(staging) {
let cutoff = std::time::SystemTime::now() - Duration::from_secs(24 * 3600);
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else { continue };
let old = meta.modified().map(|m| m < cutoff).unwrap_or(false);
if old {
info!("cleanup: removing stale staging file {}", entry.path().display());
let _ = fs::remove_file(entry.path());
}
}
}
}
// Old firmware .prev binary (only keep if < 7 days old as rollback safety).
let prev = std::path::Path::new("/opt/betterframe/kiosk/betterframe-kiosk.prev");
if prev.exists() {
let cutoff = std::time::SystemTime::now() - Duration::from_secs(7 * 24 * 3600);
if let Ok(meta) = prev.metadata() {
if meta.modified().map(|m| m < cutoff).unwrap_or(false) {
info!("cleanup: removing old firmware .prev");
let _ = fs::remove_file(prev);
}
}
}
}
/// Ask the server whether a full-OS RAUC bundle is available for this
/// kiosk. On hit, download + sha256 + `rauc install` + reboot. On miss or
/// error: log + keep running. Gated by BF_ENABLE_OS_OTA=1 (default OFF
/// for dev kiosks running a non-A/B image).
/// kiosk.
fn maybe_apply_os_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sender<WorkerMsg>) {
if std::env::var("BF_ENABLE_OS_OTA").as_deref() != Ok("1") {
return;
}
let Ok(_lock) = OS_UPDATE_LOCK.try_lock() else {
info!("os-update: another update already in progress, skipping");
return;
};
let Some(info) = os_update::check(server_url, kiosk_key) else {
return;
};
@ -549,6 +602,10 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sen
if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") {
return;
}
let Ok(_lock) = FIRMWARE_LOCK.try_lock() else {
info!("firmware: another update already in progress, skipping");
return;
};
let current = option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
let Some(info) = firmware::check(server_url, kiosk_key, current) else {
return;
@ -595,6 +652,30 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sen
}
}
fn maybe_refresh_onvif(server_url: &str, kiosk_key: &str) {
if !onvif_events::needs_refresh() {
return;
}
info!("onvif: refreshing stale/failed subscriptions");
let bundle = match server::load_cached_bundle() {
Some(b) => b,
None => return,
};
let displays = bundle.normalized_displays();
let layout_cam_ids: std::collections::HashSet<String> = displays
.iter()
.flat_map(|d| d.layouts.iter())
.flat_map(|l| l.cells.iter())
.filter_map(|c| c.camera_id.clone())
.collect();
let layout_cameras: Vec<_> = bundle.cameras.iter()
.filter(|c| layout_cam_ids.contains(&c.id))
.cloned()
.collect();
let decrypt_key = server::load_encrypt_key().or_else(|| server::load_cluster_key());
onvif_events::start(&layout_cameras, decrypt_key.as_deref(), server_url, kiosk_key);
}
/// Install the once-per-second watchdog that enforces idle/sleep timeouts
/// per display. Safe to call multiple times — installs at most once.
fn install_idle_watchdog() {
@ -614,8 +695,8 @@ fn install_idle_watchdog() {
// Snapshot per-display timing decisions so we can act outside the borrow.
struct Action {
display_id: u32,
revert_to: Option<u32>,
display_id: String,
revert_to: Option<String>,
sleep: bool,
}
let mut actions: Vec<Action> = Vec::new();
@ -632,10 +713,10 @@ fn install_idle_watchdog() {
let idle_to = d.idle_timeout_seconds as u64;
let sleep_to = d.sleep_timeout_seconds as u64;
let elapsed = st.last_activity.elapsed();
let default_id = d.default_layout_id;
let default_id = d.default_layout_id.clone();
let mut act = Action {
display_id: *display_id,
display_id: display_id.clone(),
revert_to: None,
sleep: false,
};
@ -643,12 +724,13 @@ fn install_idle_watchdog() {
if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
let cur_resets_idle = st
.current_layout_id
.and_then(|cur_id| d.layouts.iter().find(|l| l.id == cur_id))
.as_ref()
.and_then(|cur_id| d.layouts.iter().find(|l| l.id == *cur_id))
.map(|l| l.resets_idle_timer)
.unwrap_or(false);
if let (Some(cur_id), Some(def_id)) = (st.current_layout_id, default_id) {
if let (Some(cur_id), Some(def_id)) = (&st.current_layout_id, &default_id) {
if cur_id != def_id && cur_resets_idle {
act.revert_to = Some(def_id);
act.revert_to = Some(def_id.clone());
}
}
}
@ -667,7 +749,7 @@ fn install_idle_watchdog() {
"idle timeout reached → reverting display {} to default",
a.display_id
);
render_layout(a.display_id, layout_id);
render_layout(&a.display_id, &layout_id);
}
if a.sleep {
info!(
@ -790,11 +872,11 @@ fn render_bundle(
// Collect camera IDs actually referenced in layout cells.
let displays = bundle.normalized_displays();
let layout_cam_ids: std::collections::HashSet<u32> = displays
let layout_cam_ids: std::collections::HashSet<String> = displays
.iter()
.flat_map(|d| d.layouts.iter())
.flat_map(|l| l.cells.iter())
.filter_map(|c| c.camera_id)
.filter_map(|c| c.camera_id.clone())
.collect();
// Only subscribe to ONVIF events for cameras in layouts (not all bundle cameras).
@ -825,12 +907,12 @@ fn render_bundle(
.collect();
// Tear down any previous per-display windows we no longer need.
let keep_ids: std::collections::HashSet<u32> = displays.iter().map(|d| d.id).collect();
let to_remove: Vec<u32> = DISPLAYS.with(|ds| {
let keep_ids: std::collections::HashSet<&str> = displays.iter().map(|d| d.id.as_str()).collect();
let to_remove: Vec<String> = DISPLAYS.with(|ds| {
ds.borrow()
.keys()
.filter(|id| !keep_ids.contains(id))
.copied()
.filter(|id| !keep_ids.contains(id.as_str()))
.cloned()
.collect()
});
for id in to_remove {
@ -845,7 +927,7 @@ fn render_bundle(
// displays is correct once the loop finishes.
// Build/reuse window per bundle display, then render its initial layout.
let mut new_state: HashMap<u32, DisplayState> = HashMap::new();
let mut new_state: HashMap<String, DisplayState> = HashMap::new();
for (i, bd) in displays.iter().enumerate() {
let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id));
let (window, was_asleep) = match existing {
@ -872,7 +954,7 @@ fn render_bundle(
}
};
new_state.insert(
bd.id,
bd.id.clone(),
DisplayState {
window,
current_layout_id: None,
@ -892,7 +974,7 @@ fn render_bundle(
for bd in &displays {
let target = pick_initial_layout(bd);
if let Some(layout_id) = target {
render_layout(bd.id, layout_id);
render_layout(&bd.id, &layout_id);
} else {
warn!("display {} has no default layout", bd.id);
DISPLAYS.with(|ds| {
@ -905,19 +987,19 @@ fn render_bundle(
}
}
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<u32> {
bd.default_layout_id
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id))
.or_else(|| bd.layouts.first().map(|l| l.id))
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<String> {
bd.default_layout_id.clone()
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id.clone()))
.or_else(|| bd.layouts.first().map(|l| l.id.clone()))
}
/// Find which display owns a given layout_id and render it there.
fn switch_layout_anywhere(layout_id: u32) {
fn switch_layout_anywhere(layout_id: &str) {
let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
let Some(bundle) = bundle else { return };
for bd in bundle.normalized_displays() {
if bd.layouts.iter().any(|l| l.id == layout_id) {
render_layout(bd.id, layout_id);
render_layout(&bd.id, layout_id);
return;
}
}
@ -925,7 +1007,7 @@ fn switch_layout_anywhere(layout_id: u32) {
}
/// Render a specific layout id on a specific display.
fn render_layout(display_id: u32, layout_id: u32) {
fn render_layout(display_id: &str, layout_id: &str) {
mark_activity(display_id);
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
@ -950,7 +1032,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
warn!(
"render_layout: layout {layout_id} not on display {display_id}, falling back to default"
);
bd.default_layout_id
bd.default_layout_id.as_deref()
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
});
@ -958,7 +1040,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
let Some(layout) = layout else {
warn!("render_layout: no usable layout on display {display_id}");
DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
show_empty_display_reference(&st.window, &bundle, bd);
st.current_layout_id = None;
}
@ -971,10 +1053,10 @@ fn render_layout(display_id: u32, layout_id: u32) {
let previous_layout_id = DISPLAYS.with(|ds| {
let prev = ds
.borrow()
.get(&display_id)
.and_then(|s| s.current_layout_id);
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
st.current_layout_id = Some(layout.id);
.get(display_id)
.and_then(|s| s.current_layout_id.clone());
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
st.current_layout_id = Some(layout.id.clone());
}
prev
});
@ -992,17 +1074,18 @@ fn render_layout(display_id: u32, layout_id: u32) {
// Notify the server when the active layout actually changes so Node-RED
// sees idle reverts + any other kiosk-initiated switch. Skip when the
// layout id is unchanged (re-render of the same layout).
if previous_layout_id != Some(layout.id) {
if previous_layout_id.as_deref() != Some(layout.id.as_str()) {
let layout_name = layout.name.clone();
let layout_id_for_report = layout.id;
let layout_id_for_report = layout.id.clone();
let display_id_for_report = display_id.to_string();
let server = server_url.clone();
let key = kiosk_key.clone();
std::thread::spawn(move || {
server::report_layout_change(
&server,
&key,
display_id,
layout_id_for_report,
&display_id_for_report,
&layout_id_for_report,
&layout_name,
);
});
@ -1012,7 +1095,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
warn!("layout has no cells");
recompute_global_state();
DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
show_logo(&st.window);
}
});
@ -1033,21 +1116,21 @@ fn render_layout(display_id: u32, layout_id: u32) {
grid.set_vexpand(true);
grid.set_hexpand(true);
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
bundle.cameras.iter().map(|c| (c.id, c)).collect();
let cam_map: HashMap<&str, &crate::bundle::BundleCamera> =
bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect();
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
// Ensure preloaded cameras have pipelines even if not visible.
for cam_id in &layout.preload_camera_ids {
if let Some(cam) = cam_map.get(cam_id) {
ensure_warm(*cam_id, cam, None, 0.0);
if let Some(cam) = cam_map.get(cam_id.as_str()) {
ensure_warm(cam_id, cam, None, 0.0);
}
}
for cell in &layout.cells {
let cell_key: Option<String> = match cell.content_type.as_str() {
"camera" => cell.camera_id.map(|id| {
"camera" => cell.camera_id.as_ref().map(|id| {
format!(
"cam:{id}:{}",
cell.stream_selector.as_deref().unwrap_or("auto")
@ -1063,8 +1146,8 @@ fn render_layout(display_id: u32, layout_id: u32) {
};
let widget: gtk::Widget = match cell.content_type.as_str() {
"camera" => {
if let Some(cam_id) = cell.camera_id {
if let Some(cam) = cam_map.get(&cam_id) {
if let Some(cam_id) = cell.camera_id.as_ref() {
if let Some(cam) = cam_map.get(cam_id.as_str()) {
let area = (cell.col_span * cell.row_span) as f32 / total_area;
if let Some((paintable, badge)) =
ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area)
@ -1149,7 +1232,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
}
DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
animate_layout_swap(&st.window, &grid);
}
});
@ -1401,14 +1484,14 @@ fn recompute_global_state() {
let mut hot_set: std::collections::HashSet<PoolKey> = std::collections::HashSet::new();
let mut max_cooling_secs: u32 = 0;
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
bundle.cameras.iter().map(|c| (c.id, c)).collect();
let cam_map: HashMap<&str, &crate::bundle::BundleCamera> =
bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect();
// Snapshot per-display active layout id outside any borrow of WARM_CAMERAS.
let active: Vec<(u32, Option<u32>)> = DISPLAYS.with(|ds| {
let active: Vec<(String, Option<String>)> = DISPLAYS.with(|ds| {
ds.borrow()
.iter()
.map(|(id, st)| (*id, st.current_layout_id))
.map(|(id, st)| (id.clone(), st.current_layout_id.clone()))
.collect()
});
@ -1417,7 +1500,7 @@ fn recompute_global_state() {
// missing or no streams).
fn cell_keys(
layout: &crate::bundle::BundleLayout,
cam_map: &HashMap<u32, &crate::bundle::BundleCamera>,
cam_map: &HashMap<&str, &crate::bundle::BundleCamera>,
out: &mut std::collections::HashSet<PoolKey>,
) {
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
@ -1425,24 +1508,24 @@ fn recompute_global_state() {
if cell.content_type != "camera" {
continue;
}
let Some(cam_id) = cell.camera_id else {
let Some(cam_id) = cell.camera_id.as_ref() else {
continue;
};
let Some(cam) = cam_map.get(&cam_id) else {
let Some(cam) = cam_map.get(cam_id.as_str()) else {
continue;
};
let area = (cell.col_span * cell.row_span) as f32 / total_area;
if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) {
out.insert((cam_id, badge));
out.insert((cam_id.clone(), badge));
}
}
// Preload cameras have no cell context — let pick_stream choose
// (typically sub). Different layouts that actually render them will
// promote whichever badge they end up using.
for cam_id in &layout.preload_camera_ids {
if let Some(cam) = cam_map.get(cam_id) {
if let Some(cam) = cam_map.get(cam_id.as_str()) {
if let Some((_, badge)) = cam.pick_stream(None, 0.0) {
out.insert((*cam_id, badge));
out.insert((cam_id.clone(), badge));
}
}
}
@ -1452,7 +1535,7 @@ fn recompute_global_state() {
let active_id = active
.iter()
.find(|(id, _)| *id == bd.id)
.and_then(|(_, l)| *l);
.and_then(|(_, l)| l.clone());
if let Some(cur_id) = active_id {
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
cell_keys(layout, &cam_map, &mut warm_set);
@ -1475,7 +1558,7 @@ fn recompute_global_state() {
let active_id = active
.iter()
.find(|(id, _)| *id == bd.id)
.and_then(|(_, l)| *l);
.and_then(|(_, l)| l.clone());
if let Some(cur_id) = active_id {
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
web_keys_for_layout(layout, &mut warm_webs);
@ -1525,7 +1608,7 @@ fn recompute_pool_states(
continue;
}
if max_cooling_secs == 0 {
to_remove.push(*key);
to_remove.push(key.clone());
to_stop.push(entry.pipeline.clone());
} else {
entry.state = WarmthState::Cooling;
@ -1551,15 +1634,15 @@ fn recompute_pool_states(
/// Remove warm camera entries for cameras no longer in the bundle.
/// Immediately stops pipelines — no cooling period.
fn purge_removed_cameras(bundle_cameras: &[crate::bundle::BundleCamera]) {
let valid_ids: std::collections::HashSet<u32> = bundle_cameras.iter().map(|c| c.id).collect();
let valid_ids: std::collections::HashSet<&str> = bundle_cameras.iter().map(|c| c.id.as_str()).collect();
let mut to_remove: Vec<PoolKey> = Vec::new();
let mut to_stop: Vec<gstreamer::Pipeline> = Vec::new();
WARM_CAMERAS.with(|w| {
let mut warm = w.borrow_mut();
for (key, entry) in warm.iter() {
if !valid_ids.contains(&key.0) {
to_remove.push(*key);
if !valid_ids.contains(key.0.as_str()) {
to_remove.push(key.clone());
to_stop.push(entry.pipeline.clone());
}
}
@ -1588,7 +1671,7 @@ fn expire_cooling_pipelines() {
.filter(|(_, e)| {
e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t)
})
.map(|(k, _)| *k)
.map(|(k, _)| k.clone())
.collect();
for k in keys {
if let Some(e) = warm.remove(&k) {
@ -1771,13 +1854,13 @@ fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool {
/// that sibling entry alone — recompute_pool_states will demote it to Cooling
/// so it can be reused if the cell flips back before the cooldown elapses.
fn ensure_warm(
cam_id: u32,
cam_id: &str,
cam: &crate::bundle::BundleCamera,
selector: Option<&str>,
area_fraction: f32,
) -> Option<(gtk::gdk::Paintable, char)> {
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
let key: PoolKey = (cam_id, desired_badge);
let key: PoolKey = (cam_id.to_string(), desired_badge);
let cached = WARM_CAMERAS.with(|w| {
w.borrow()
@ -2179,7 +2262,7 @@ fn add_css(widget: &impl IsA<gtk::Widget>, css: &str) {
thread_local! {
static TERMINAL_CODE_WIDGET: RefCell<Option<gtk::Widget>> = const { RefCell::new(None) };
static TERMINAL_CODE_SAVED_CHILD: RefCell<Option<(u32, gtk::Widget)>> = const { RefCell::new(None) };
static TERMINAL_CODE_SAVED_CHILD: RefCell<Option<(String, gtk::Widget)>> = const { RefCell::new(None) };
}
fn show_terminal_code_overlay(code: &str) {
@ -2189,7 +2272,7 @@ fn show_terminal_code_overlay(code: &str) {
// Instead, replace the first display window's child with the code
// overlay and restore it when dismissed.
let display_id = DISPLAYS.with(|ds| {
ds.borrow().keys().next().copied()
ds.borrow().keys().next().cloned()
});
let Some(display_id) = display_id else { return };
@ -2201,7 +2284,7 @@ fn show_terminal_code_overlay(code: &str) {
// Save current child for restore.
let old_child = win.child();
if let Some(ref c) = old_child {
TERMINAL_CODE_SAVED_CHILD.with(|s| *s.borrow_mut() = Some((display_id, c.clone())));
TERMINAL_CODE_SAVED_CHILD.with(|s| *s.borrow_mut() = Some((display_id.clone(), c.clone())));
}
// Match the pairing screen layout but with red warning theme.

View file

@ -151,26 +151,26 @@ async fn handle_message(
} else if text.contains("\"type\":\"standby\"") {
let display_id = serde_json::from_str::<serde_json::Value>(text)
.ok()
.and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32));
.and_then(|m| m.get("display_id").and_then(flexible_id_from_value));
let _ = tx.send(ServerMsg::Standby(display_id));
} else if text.contains("\"type\":\"wake\"") {
let display_id = serde_json::from_str::<serde_json::Value>(text)
.ok()
.and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32));
.and_then(|m| m.get("display_id").and_then(flexible_id_from_value));
let _ = tx.send(ServerMsg::Wake(display_id));
} else if text.contains("\"type\":\"layout-switch\"") {
let msg = serde_json::from_str::<serde_json::Value>(text).ok();
let layout_id = msg.as_ref()
.and_then(|m| m.get("layout_id"))
.and_then(|v| v.as_u64())
.map(|v| v as u32);
.and_then(flexible_id_from_value);
let display_id = msg.as_ref()
.and_then(|m| m.get("display_id"))
.and_then(|v| v.as_u64())
.map(|v| v as u32);
.and_then(flexible_id_from_value);
if let Some(layout_id) = layout_id {
let _ = tx.send(ServerMsg::SwitchLayout { display_id, layout_id });
}
} else if text.contains("\"type\":\"reboot\"") {
let _ = tx.send(ServerMsg::Reboot);
} else if text.contains("\"type\":\"firmware_check\"") {
let _ = tx.send(ServerMsg::FirmwareCheck);
} else if text.contains("\"type\":\"os_check\"") {
@ -185,6 +185,20 @@ async fn handle_message(
return;
};
let _ = tx.send(ServerMsg::Fan(pwm));
} else if text.contains("\"type\":\"volume-set\"") {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
if let Some(vol) = msg.get("volume").and_then(|v| v.as_u64()) {
let _ = tx.send(ServerMsg::VolumeSet(vol.min(100) as u32));
}
} else if text.contains("\"type\":\"volume-mute\"") {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
let muted = msg.get("muted").and_then(|v| v.as_bool()).unwrap_or(true);
let _ = tx.send(ServerMsg::VolumeMute(muted));
} else if text.contains("\"type\":\"audio-output\"") {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
if let Some(id) = msg.get("output_id").and_then(|v| v.as_str()) {
let _ = tx.send(ServerMsg::AudioOutputSet(id.to_string()));
}
// ---- Journal streaming --------------------------------------------------
} else if text.contains("\"type\":\"journal-start\"") {
@ -366,6 +380,16 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
}
}
/// Extract an ID from a JSON value that may be a string or a number.
/// Mirrors the flexible ID deserialization in bundle.rs.
fn flexible_id_from_value(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn build_ws_url(http_url: &str, token: &str) -> String {
let base = if let Some(rest) = http_url.strip_prefix("https://") {
format!("wss://{}", rest.split('/').next().unwrap_or(rest))

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

@ -24,8 +24,6 @@ default:
enabled: true
config:
db:
driver: ${BF_DB_DRIVER}
sqlitePath: /var/lib/betterframe/betterframe.db
host: ${BF_PG_HOST}
port: ${BF_PG_PORT}
database: ${BF_PG_DB}
@ -57,8 +55,6 @@ default:
enabled: true
config:
db:
driver: ${BF_DB_DRIVER}
sqlitePath: /var/lib/betterframe/betterframe.db
host: ${BF_PG_HOST}
port: ${BF_PG_PORT}
database: ${BF_PG_DB}
@ -86,8 +82,6 @@ default:
enabled: true
config:
db:
driver: ${BF_DB_DRIVER}
sqlitePath: /var/lib/betterframe/betterframe.db
host: ${BF_PG_HOST}
port: ${BF_PG_PORT}
database: ${BF_PG_DB}

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 -----------------------------------------------------------------
@ -42,8 +43,6 @@ const ConfigSchema = av.object(
{
db: av.object(
{
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
url: av.string().default(""),
host: av.string().default("postgres"),
port: av.int().min(1).max(65535).default(5432),
@ -240,6 +239,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
registerOsUpdateRoutes(app, deps);
registerCloudRoutes(app, deps);
registerTenantRoutes(app, deps);
registerAbleSignRoutes(app, deps);
// Auth-check endpoint for Angie auth_request subrequest.
// Returns 200 if session cookie is valid + admin role, 401 otherwise.

View file

@ -0,0 +1,173 @@
/**
* AbleSign digital signage routes.
*/
import { type H3, getRouterParam, readBody, createError } from "h3";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js";
import * as ablesign from "../../shared/ablesign.js";
import { AbleSignPage, AbleSignScreensPage } from "../../web-templates/admin-pages.js";
export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/ablesign", async () => {
const accounts = await deps.repo.listAbleSignAccounts();
return htmlPage(AbleSignPage({ accounts }));
});
app.post("/admin/ablesign/add", async (event) => {
const body = await readBody<Record<string, string>>(event);
const name = (body?.name ?? "").trim();
const apiKey = (body?.api_key ?? "").trim();
const workspaceId = (body?.workspace_id ?? "").trim() || undefined;
if (!name || !apiKey) {
const accounts = await deps.repo.listAbleSignAccounts();
return htmlPage(AbleSignPage({ accounts, error: "Name and API key required." }));
}
const test = await ablesign.testApiKey(apiKey, workspaceId);
if (!test.ok) {
const accounts = await deps.repo.listAbleSignAccounts();
return htmlPage(AbleSignPage({ accounts, error: `API key test failed: ${test.error}` }));
}
const encrypted = deps.secrets.encryptString(apiKey, "ablesign-key");
await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId });
return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } });
});
app.get("/admin/ablesign/:id/screens", async (event) => {
const id = getRouterParam(event, "id") ?? "";
const account = await deps.repo.getAbleSignAccount(id);
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
const screens = await deps.repo.listAbleSignScreens(id);
const kiosks = await deps.repo.listKiosks();
return htmlPage(AbleSignScreensPage({ account, screens, kiosks }));
});
app.post("/admin/ablesign/:id/sync", async (event) => {
const id = getRouterParam(event, "id") ?? "";
const account = await deps.repo.getAbleSignAccount(id);
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
try {
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
const opts = { apiKey, workspaceId: account.workspace_id || undefined };
const result = await ablesign.listScreens(opts);
for (const s of result.data) {
await deps.repo.upsertAbleSignScreen({
account_id: id,
ablesign_screen_id: String(s.id),
title: s.title,
online: !!s.heartbeatTime,
last_heartbeat_at: s.heartbeatTime || undefined,
orientation: s.orientation,
});
}
await deps.repo.updateAbleSignAccount(id, {
screen_count: result.data.length,
last_sync_at: new Date().toISOString(),
last_sync_error: null,
});
} catch (err) {
await deps.repo.updateAbleSignAccount(id, {
last_sync_at: new Date().toISOString(),
last_sync_error: (err as Error).message,
});
}
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${id}/screens` } });
});
app.post("/admin/ablesign/:id/screens/add", async (event) => {
const accountId = getRouterParam(event, "id") ?? "";
const account = await deps.repo.getAbleSignAccount(accountId);
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
const body = await readBody<Record<string, string>>(event);
const title = (body?.title ?? "").trim();
if (!title) {
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
}
try {
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
const opts = { apiKey, workspaceId: account.workspace_id || undefined };
const { screen, registrationCode } = await ablesign.headlessPairScreen(opts, title);
// Poll once for token (may not be available immediately).
let screenToken: string | undefined;
try {
const poll = await ablesign.pollRegistration(registrationCode);
screenToken = poll.screenToken;
} catch { /* token may not be ready yet — kiosk can work without it initially */ }
const screenRowId = await deps.repo.createAbleSignScreen({
account_id: accountId,
ablesign_screen_id: String(screen.id),
ablesign_screen_token_encrypted: screenToken
? deps.secrets.encryptString(screenToken, "ablesign-token")
: undefined,
title: screen.title,
orientation: screen.orientation,
});
await deps.repo.createEntity({
name: `AbleSign: ${screen.title}`,
type: "ablesign",
description: `AbleSign screen (ID: ${String(screen.id)})`,
web_url: "https://player.ablesign.tv",
ablesign_screen_id: screenRowId,
managed: true,
});
await deps.repo.updateAbleSignAccount(accountId, {
screen_count: (account.screen_count ?? 0) + 1,
});
} catch {
// redirect back — error handling TODO
}
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
});
app.post("/admin/ablesign/screens/:sid/assign", async (event) => {
const sid = getRouterParam(event, "sid") ?? "";
const body = await readBody<Record<string, string>>(event);
const kioskId = (body?.kiosk_id ?? "").trim() || null;
await deps.repo.updateAbleSignScreen(sid, { kiosk_id: kioskId });
const screen = await deps.repo.getAbleSignScreen(sid);
const accountId = screen?.account_id ?? "";
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
});
app.post("/admin/ablesign/:id/delete", async (event) => {
const id = getRouterParam(event, "id") ?? "";
await deps.repo.deleteAbleSignAccount(id);
return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } });
});
app.post("/admin/ablesign/screens/:sid/delete", async (event) => {
const sid = getRouterParam(event, "sid") ?? "";
const screen = await deps.repo.getAbleSignScreen(sid);
if (screen) {
try {
const account = await deps.repo.getAbleSignAccount(screen.account_id);
if (account) {
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
await ablesign.deleteScreen(
{ apiKey, workspaceId: account.workspace_id || undefined },
Number(screen.ablesign_screen_id),
);
}
} catch { /* best-effort remote delete */ }
await deps.repo.deleteAbleSignScreen(sid);
}
const accountId = screen?.account_id ?? "";
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
});
}

View file

@ -790,6 +790,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const id = (getRouterParam(event, "id") ?? "");
const ent = await deps.repo.getEntityById(id);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
if ((ent as any).managed) {
return new Response(null, { status: 302, headers: { location: `/admin/entities/${String(id)}` } });
}
const body = await readBody<Record<string, string>>(event);
const patch: {
name?: string;
@ -2193,6 +2196,29 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
app.post("/admin/kiosks/:id/reboot", async (event) => {
const id = (getRouterParam(event, "id") ?? "");
getCoordinator().sendToKiosk(id, { type: "reboot" });
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
app.post("/admin/kiosks/:id/volume", async (event) => {
const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event);
const action = body?.["action"];
if (action === "mute") {
getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: true });
} else if (action === "unmute") {
getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: false });
} else if (action === "output") {
getCoordinator().sendToKiosk(id, { type: "audio-output", output_id: body?.["output_id"] ?? "" });
} else {
const vol = Math.max(0, Math.min(100, Number(body?.["volume"]) || 0));
getCoordinator().sendToKiosk(id, { type: "volume-set", volume: vol });
}
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
// ---- JSON API (admin scope) — used by Node-RED bf-* nodes ---------------
//
// All payloads run through `stripSecrets` so credential-bearing fields

View file

@ -7,6 +7,7 @@ import type { AdminDeps } from "./index.js";
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
import { audit } from "../../shared/audit.js";
import { createRateLimiter } from "../../shared/rate-limit.js";
import { LoginBody, TotpBody, validateBody } from "../../shared/api-schemas.js";
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
@ -37,13 +38,14 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
});
}
const body = await readBody<{ username?: string; password?: string }>(event);
const username = (body?.username ?? "").trim();
const password = body?.password ?? "";
if (!username || !password) {
return htmlPage(LoginPage({ error: "Username and password required.", username }));
let body: { username: string; password: string };
try {
body = validateBody(LoginBody, await readBody(event));
} catch {
return htmlPage(LoginPage({ error: "Username and password required.", username: "" }));
}
const username = body.username.trim();
const password = body.password;
const user = await deps.repo.getUserByUsername(username);
if (!user || !user.is_active) {
@ -128,10 +130,15 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
const body = await readBody<{ code?: string }>(event);
const code = (body?.code ?? "").trim().replace(/\s/g, "");
let totpBody: { code: string };
try {
totpBody = validateBody(TotpBody, await readBody(event));
} catch {
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
}
const code = totpBody.code.trim().replace(/\s/g, "");
if (!code || code.length !== 6) {
if (code.length !== 6) {
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
}

View file

@ -5,6 +5,7 @@ import { type H3, readBody } from "h3";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js";
import { SetupPage } from "../../web-templates/auth-pages.js";
import { SetupBody, validateBody } from "../../shared/api-schemas.js";
export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
app.get("/setup", async () => {
@ -19,19 +20,19 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
const body = await readBody<{ username?: string; password?: string }>(event);
const username = (body?.username ?? "").trim();
const password = body?.password ?? "";
let body: { username: string; password: string };
try {
body = validateBody(SetupBody, await readBody(event));
} catch {
return htmlPage(SetupPage({ error: "Username (3-64 chars) and password (12+ chars) required.", username: "" }));
}
const username = body.username.trim();
const password = body.password;
const errors: string[] = [];
if (!username || username.length < 3 || username.length > 64) {
errors.push("Username must be 364 characters.");
} else if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
errors.push("Username may only contain letters, digits, underscore, or hyphen.");
}
if (password.length < 12) {
errors.push("Password must be at least 12 characters.");
}
if (errors.length > 0) {
return htmlPage(SetupPage({ error: errors.join(" "), username }));

View file

@ -31,6 +31,10 @@ import { createHash } from "node:crypto";
import type { AuthApi } from "../../shared/auth.js";
import type { SecretsApi } from "../../shared/secrets.js";
import type { FirmwareChannel } from "../../shared/types.js";
import {
PairInitiateBody, PairClaimBody, HeartbeatBody, EventBody,
KioskLogsBody, FirmwareAppliedBody, OsAppliedBody, validateBody,
} from "../../shared/api-schemas.js";
// ---- Config -----------------------------------------------------------------
@ -38,8 +42,6 @@ const ConfigSchema = av.object(
{
db: av.object(
{
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
url: av.string().default(""),
host: av.string().default("postgres"),
port: av.int().min(1).max(65535).default(5432),
@ -294,18 +296,13 @@ function registerPairingRoutes(
throw createError({ statusCode: 429, statusMessage: "rate limited" });
}
const body = await readBody<{
proposed_name?: string;
hardware_model?: string;
capabilities?: string[];
managed_image?: boolean;
}>(event);
const body = validateBody(PairInitiateBody, await readBody(event));
const result = await initiatePairing(repo, {
proposedName: body?.proposed_name ?? null,
hardwareModel: body?.hardware_model ?? null,
capabilities: body?.capabilities ?? [],
managedImage: body?.managed_image === true,
proposedName: body.proposed_name || null,
hardwareModel: body.hardware_model || null,
capabilities: body.capabilities,
managedImage: body.managed_image,
codeTtlSeconds: codeTtl,
});
@ -321,9 +318,8 @@ function registerPairingRoutes(
throw createError({ statusCode: 429, statusMessage: "rate limited" });
}
const body = await readBody<{ code?: string }>(event);
const code = (body?.code ?? "").trim().toUpperCase();
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });
const body = validateBody(PairClaimBody, await readBody(event));
const code = body.code.trim().toUpperCase();
const reqObs = event.context.obs!;
const result = await claimPairing(repo, code, reqObs);
@ -461,44 +457,7 @@ function registerKioskRoutes(
if (!kiosk) return { bf_kiosk_deleted: true };
event.context.obs?.log.info("heartbeat from kiosk {id}", { id: String(kiosk.id) });
const body = await readBody<{
bundle_version?: string;
kiosk_app_version?: string;
os_version?: string;
displays?: Array<{
index?: number;
name: string;
width_px: number;
height_px: number;
power_state?: "awake" | "standby" | "unknown";
}>;
cpu_temp_c?: number | null;
cpu_load_percent?: number | null;
fan_rpm?: number | null;
fan_pwm?: number | null;
memory_total_mb?: number | null;
memory_used_mb?: number | null;
disk_total_mb?: number | null;
disk_free_mb?: number | null;
disk_used_percent?: number | null;
local_key?: string | null;
local_port?: number | null;
reported_hostname?: string | null;
network_interfaces?: Array<Record<string, unknown>>;
partitions?: Array<{
device: string;
mountpoint: string;
total_mb: number;
used_mb: number;
free_mb: number;
used_percent: number;
}>;
// Managed-image kiosk echoes back the version it last applied, and the
// last apply error (if any). Server uses these to decide whether to
// include pending_config in the response.
managed_config_applied_version?: number;
managed_config_error?: string | null;
}>(event);
const body = validateBody(HeartbeatBody, await readBody(event));
// Capture the kiosk's LAN-side IP from the heartbeat connection so admin
// can render a copy-paste URL even when the kiosk has no DNS name.
@ -507,26 +466,26 @@ function registerKioskRoutes(
?? null;
await repo.touchKiosk(kiosk.id, {
bundle_version: body?.bundle_version ?? null,
kiosk_app_version: body?.kiosk_app_version ?? null,
os_version: body?.os_version ?? null,
cpu_temp_c: body?.cpu_temp_c ?? null,
cpu_load_percent: body?.cpu_load_percent ?? null,
fan_rpm: body?.fan_rpm ?? null,
fan_pwm: body?.fan_pwm ?? null,
memory_total_mb: body?.memory_total_mb ?? null,
memory_used_mb: body?.memory_used_mb ?? null,
disk_total_mb: body?.disk_total_mb ?? null,
disk_free_mb: body?.disk_free_mb ?? null,
disk_used_percent: body?.disk_used_percent ?? null,
local_key: body?.local_key ?? null,
local_port: body?.local_port ?? null,
bundle_version: body.bundle_version ?? null,
kiosk_app_version: body.kiosk_app_version ?? null,
os_version: body.os_version ?? null,
cpu_temp_c: body.cpu_temp_c ?? null,
cpu_load_percent: body.cpu_load_percent ?? null,
fan_rpm: body.fan_rpm ?? null,
fan_pwm: body.fan_pwm ?? null,
memory_total_mb: body.memory_total_mb ?? null,
memory_used_mb: body.memory_used_mb ?? null,
disk_total_mb: body.disk_total_mb ?? null,
disk_free_mb: body.disk_free_mb ?? null,
disk_used_percent: body.disk_used_percent ?? null,
local_key: body.local_key ?? null,
local_port: body.local_port ?? null,
local_last_ip: remoteIp,
reported_hostname: body?.reported_hostname ?? null,
network_interfaces_json: Array.isArray(body?.network_interfaces)
reported_hostname: body.reported_hostname ?? null,
network_interfaces_json: Array.isArray(body.network_interfaces)
? JSON.stringify(body.network_interfaces)
: null,
partitions_json: Array.isArray(body?.partitions)
partitions_json: Array.isArray(body.partitions)
? JSON.stringify(body.partitions)
: null,
});
@ -536,7 +495,7 @@ function registerKioskRoutes(
// successful apply (kiosk omits it). verifyKioskKey returns just {id};
// re-read the full row to check the managed_image flag.
const kioskFull = await repo.getKioskById(kiosk.id);
if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") {
if (kioskFull?.managed_image && typeof body.managed_config_applied_version === "number") {
const patch: Record<string, unknown> = {
managed_config_applied_version: body.managed_config_applied_version,
managed_config_applied_at: new Date().toISOString(),
@ -549,24 +508,24 @@ function registerKioskRoutes(
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
mqtt.publishTelemetry(kiosk.id, {
kiosk_app_version: body?.kiosk_app_version,
bundle_version: body?.bundle_version,
cpu_temp_c: body?.cpu_temp_c,
cpu_load_percent: body?.cpu_load_percent,
fan_rpm: body?.fan_rpm,
fan_pwm: body?.fan_pwm,
memory_total_mb: body?.memory_total_mb,
memory_used_mb: body?.memory_used_mb,
disk_total_mb: body?.disk_total_mb,
disk_free_mb: body?.disk_free_mb,
disk_used_percent: body?.disk_used_percent,
kiosk_app_version: body.kiosk_app_version,
bundle_version: body.bundle_version,
cpu_temp_c: body.cpu_temp_c,
cpu_load_percent: body.cpu_load_percent,
fan_rpm: body.fan_rpm,
fan_pwm: body.fan_pwm,
memory_total_mb: body.memory_total_mb,
memory_used_mb: body.memory_used_mb,
disk_total_mb: body.disk_total_mb,
disk_free_mb: body.disk_free_mb,
disk_used_percent: body.disk_used_percent,
ip: remoteIp,
reported_hostname: body?.reported_hostname,
network_interfaces: body?.network_interfaces,
reported_hostname: body.reported_hostname,
network_interfaces: body.network_interfaces,
});
// Sync displays reported by the kiosk
if (Array.isArray(body?.displays)) {
if (Array.isArray(body.displays)) {
const existing = await repo.listDisplaysForKiosk(kiosk.id);
const seenDisplayIds = new Set<string>();
for (const [position, reported] of body.displays.entries()) {
@ -667,20 +626,21 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const body = await readBody<{
topic: string;
source_type?: string;
camera_id?: string;
property_op?: string;
payload?: Record<string, unknown>;
}>(event);
if (!body?.topic) throw createError({ statusCode: 400, statusMessage: "topic required" });
const raw = await readBody(event);
let body: ReturnType<typeof EventBody["parse"]>;
try {
body = validateBody(EventBody, raw);
} catch (err: any) {
event.context.obs?.log.warn("event validation failed: {msg} body={raw}", {
msg: err.message ?? "unknown",
raw: JSON.stringify(raw).slice(0, 500),
});
throw err;
}
const payload = (body.payload ?? {}) as Record<string, unknown>;
event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic });
// Dedup: Hikvision cameras send duplicate ONVIF events within ~1s.
// Key = kiosk_id:camera_id:topic:source_keys_hash. Window = 2s.
const dedupKey = `${kiosk.id}:${body.camera_id ?? 0}:${body.topic}:${JSON.stringify(body.payload?.["source"] ?? "")}`;
const dedupKey = `${kiosk.id}:${body.camera_id ?? 0}:${body.topic}:${JSON.stringify(payload["source"] ?? "")}`;
const now = Date.now();
if (eventDedupCache.has(dedupKey)) {
const lastSeen = eventDedupCache.get(dedupKey)!;
@ -697,21 +657,38 @@ function registerKioskRoutes(
}
}
const eventId = await repo.insertEvent({
let eventId: string;
try {
eventId = await repo.insertEvent({
source_kiosk_id: kiosk.id,
source_camera_id: body.camera_id ?? null,
source_type: (body.source_type as any) ?? "system",
topic: body.topic,
property_op: body.property_op ?? null,
payload: body.payload ?? {},
payload,
forwarded_to_nodered: false,
});
} catch (err: any) {
if (err?.code === "23503") {
eventId = await repo.insertEvent({
source_kiosk_id: kiosk.id,
source_camera_id: null,
source_type: (body.source_type as any) ?? "system",
topic: body.topic,
property_op: body.property_op ?? null,
payload,
forwarded_to_nodered: false,
});
} else {
throw err;
}
}
// Side-effect: persist active layout per display so the admin UI can
// surface "currently showing X" without having to query event_log.
if (body.topic === "layout.changed") {
const displayId = String(body.payload?.["display_id"] ?? "");
const layoutId = String(body.payload?.["layout_id"] ?? "");
const displayId = String(payload["display_id"] ?? "");
const layoutId = String(payload["layout_id"] ?? "");
if (displayId && layoutId) {
try {
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
@ -758,11 +735,11 @@ function registerKioskRoutes(
nodered.forward(body.topic, out, markForwarded);
mqtt.publishEvent(kiosk.id, body.topic, out);
// ONVIF events: also forward to the fixed onvif.event route so the
// bf-trigger-motion / bf-trigger-anpr / bf-trigger-event nodes
// receive them without needing per-topic route registration.
nodered.forward("camera.event", out);
if (body.source_type === "onvif") {
nodered.forward("onvif.event", out);
nodered.forward("onvif.motion", out);
nodered.forward("onvif.anpr", out);
}
}
@ -777,26 +754,19 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const body = await readBody<{
entries?: Array<{ level?: string; message?: string; context?: Record<string, unknown>; logged_at?: string }>;
}>(event);
const raw = body?.entries;
if (!Array.isArray(raw) || raw.length === 0) {
const body = validateBody(KioskLogsBody, await readBody(event));
if (body.entries.length === 0) {
throw createError({ statusCode: 400, statusMessage: "entries array required" });
}
if (raw.length > 100) {
throw createError({ statusCode: 400, statusMessage: "max 100 entries per batch" });
}
const validLevels = new Set(["debug", "info", "warn", "error"]);
const entries = raw
.filter((e) => e.message && typeof e.message === "string")
.map((e) => ({
level: (validLevels.has(e.level ?? "") ? e.level! : "info") as "debug" | "info" | "warn" | "error",
message: e.message!,
context: e.context ?? {},
logged_at: e.logged_at,
const entries = body.entries
.filter((e: any) => e.message.length > 0)
.map((e: any) => ({
level: (validLevels.has(e.level) ? e.level : "info") as "debug" | "info" | "warn" | "error",
message: String(e.message),
context: (e.context ?? {}) as Record<string, unknown>,
logged_at: e.logged_at as string | undefined,
}));
const count = await repo.insertKioskLogs(kiosk.id, entries);
@ -920,10 +890,7 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const body = await readBody<{ version: string; error?: string }>(event);
if (!body?.version) {
throw createError({ statusCode: 400, statusMessage: "version required" });
}
const body = validateBody(FirmwareAppliedBody, await readBody(event));
await repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
await repo.insertEvent({
source_kiosk_id: kiosk.id,
@ -1064,10 +1031,7 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const body = await readBody<{ version: string; error?: string }>(event);
if (!body?.version) {
throw createError({ statusCode: 400, statusMessage: "version required" });
}
const body = validateBody(OsAppliedBody, await readBody(event));
await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
await repo.insertEvent({
source_kiosk_id: kiosk.id,

View file

@ -36,8 +36,6 @@ const ConfigSchema = av.object(
{
db: av.object(
{
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
url: av.string().default(""),
host: av.string().default("postgres"),
port: av.int().min(1).max(65535).default(5432),

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

@ -0,0 +1,168 @@
/**
* Anyvali input schemas for all external-facing API endpoints.
* Applied via validateBody() / validateQuery() helpers.
*/
import * as av from "@anyvali/js";
// ---- Kiosk API (service-api-http) -------------------------------------------
export const PairInitiateBody = av.object(
{
proposed_name: av.string().maxLength(128).default(""),
hardware_model: av.string().maxLength(128).default(""),
capabilities: av.array(av.string().maxLength(64)).default([]),
managed_image: av.bool().default(false),
},
{ unknownKeys: "strip" },
);
export const PairClaimBody = av.object(
{
code: av.string().minLength(1).maxLength(16),
},
{ unknownKeys: "strip" },
);
const HeartbeatDisplay = av.object(
{
index: av.int().min(0).max(32).default(0),
name: av.string().maxLength(128).default(""),
width_px: av.int().min(0).max(16384).default(0),
height_px: av.int().min(0).max(16384).default(0),
power_state: av.string().maxLength(16).default("unknown"),
},
{ unknownKeys: "strip" },
);
const HeartbeatPartition = av.object(
{
device: av.string().maxLength(128).default(""),
mountpoint: av.string().maxLength(256).default(""),
total_mb: av.int().min(0).default(0),
used_mb: av.int().min(0).default(0),
free_mb: av.int().min(0).default(0),
used_percent: av.number().min(0).max(100).default(0),
},
{ unknownKeys: "strip" },
);
export const HeartbeatBody = av.object(
{
bundle_version: av.string().maxLength(128).default(""),
kiosk_app_version: av.string().maxLength(64).default(""),
os_version: av.string().maxLength(64).default(""),
displays: av.array(HeartbeatDisplay).default([]),
cpu_temp_c: av.nullable(av.number().min(-40).max(150)).default(null),
cpu_load_percent: av.nullable(av.number().min(0).max(100)).default(null),
fan_rpm: av.nullable(av.int().min(0).max(50000)).default(null),
fan_pwm: av.nullable(av.int().min(0).max(255)).default(null),
memory_total_mb: av.nullable(av.int().min(0)).default(null),
memory_used_mb: av.nullable(av.int().min(0)).default(null),
disk_total_mb: av.nullable(av.int().min(0)).default(null),
disk_free_mb: av.nullable(av.int().min(0)).default(null),
disk_used_percent: av.nullable(av.number().min(0).max(100)).default(null),
local_key: av.nullable(av.string().maxLength(256)).default(null),
local_port: av.nullable(av.int().min(1).max(65535)).default(null),
reported_hostname: av.nullable(av.string().maxLength(256)).default(null),
network_interfaces: av.array(av.any()).default([]),
partitions: av.array(HeartbeatPartition).default([]),
managed_config_applied_version: av.optional(av.int().min(0)),
managed_config_error: av.optional(av.nullable(av.string().maxLength(4096))),
onvif_subscriptions: av.optional(av.any()),
},
{ unknownKeys: "strip" },
);
export const EventBody = av.object(
{
topic: av.string().minLength(1).maxLength(512),
source_type: av.string().maxLength(32).default("system"),
camera_id: av.optional(av.nullable(av.string().maxLength(64))).default(null),
property_op: av.optional(av.nullable(av.string().maxLength(32))).default(null),
payload: av.any().default({}),
},
{ unknownKeys: "strip" },
);
const KioskLogEntry = av.object(
{
level: av.string().maxLength(16).default("info"),
message: av.string().maxLength(4096).default(""),
context: av.any().default({}),
logged_at: av.optional(av.string().maxLength(64)),
},
{ unknownKeys: "strip" },
);
export const KioskLogsBody = av.object(
{
entries: av.array(KioskLogEntry).default([]),
},
{ unknownKeys: "strip" },
);
export const FirmwareAppliedBody = av.object(
{
version: av.string().minLength(1).maxLength(64),
error: av.optional(av.string().maxLength(4096)),
},
{ unknownKeys: "strip" },
);
export const OsAppliedBody = av.object(
{
version: av.string().minLength(1).maxLength(64),
error: av.optional(av.string().maxLength(4096)),
},
{ unknownKeys: "strip" },
);
// ---- Auth (routes-auth, routes-setup) ----------------------------------------
export const LoginBody = av.object(
{
username: av.string().minLength(1).maxLength(128),
password: av.string().minLength(1).maxLength(1024),
},
{ unknownKeys: "strip" },
);
export const TotpBody = av.object(
{
code: av.string().minLength(1).maxLength(16),
},
{ unknownKeys: "strip" },
);
export const SetupBody = av.object(
{
username: av.string().minLength(3).maxLength(64),
password: av.string().minLength(12).maxLength(1024),
},
{ unknownKeys: "strip" },
);
export const PasswordChangeBody = av.object(
{
current_password: av.string().minLength(1).maxLength(1024),
new_password: av.string().minLength(12).maxLength(1024),
},
{ unknownKeys: "strip" },
);
// ---- Helper -----------------------------------------------------------------
export function validateBody<T>(schema: { safeParse(input: unknown): { success: boolean; data?: T; error?: unknown } }, raw: unknown): T {
const result = schema.safeParse(raw);
if (!result.success) {
let msg = "invalid request body";
const err = result.error as any;
if (err?.issues) {
msg = err.issues.map((i: any) => `${i.path?.join?.(".") ?? "?"}: ${i.message}`).join("; ");
} else if (err?.message) {
msg = String(err.message);
}
throw Object.assign(new Error(msg), { status: 400, statusText: "Bad Request" });
}
return result.data as T;
}

View file

@ -2,8 +2,6 @@ import * as av from "@anyvali/js";
export const dbConfigSchema = av.object(
{
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
url: av.string().default(""),
host: av.string().default("postgres"),
port: av.int().min(1).max(65535).default(5432),
@ -16,8 +14,6 @@ export const dbConfigSchema = av.object(
);
export type DbConfig = {
driver: "sqlite" | "postgres";
sqlitePath: string;
url: string;
host: string;
port: number;

View file

@ -1,58 +1,28 @@
/**
* Backend-agnostic DB adapter. Repository talks to this; concrete adapters
* (sqlite, postgres) implement it.
* Backend-agnostic DB adapter. Repository talks to this; PG adapter implements it.
*
* Design choices:
* - All methods return Promises so the Postgres path can use real async I/O.
* The SQLite adapter wraps node:sqlite's synchronous calls in
* Promise.resolve to keep the same interface.
* - `?` is the canonical placeholder in SQL strings. The Postgres adapter
* - All methods return Promises (real async I/O with PG pool).
* - `?` is the canonical placeholder in SQL strings. The PG adapter
* rewrites them to `$1, $2, ...` at execute time so repository code stays
* dialect-neutral.
* - INSERTs that need to return the new row id must use `... RETURNING id`
* explicitly. Both SQLite (3.35+) and Postgres support it.
*
* Migrations and DDL fragments still differ between dialects (AUTOINCREMENT
* vs SERIAL, STRICT vs nothing, strftime vs now()), so each backend ships
* its own migration set rather than trying to abstract DDL.
*/
export type SqlValue = string | number | bigint | boolean | null | Uint8Array;
export type Row = Record<string, unknown>;
export interface RunResult {
/** New row id when the statement used `RETURNING id`, else 0n. */
lastInsertRowid: bigint;
/** Rows affected (approximate for some Postgres queries). */
changes: number;
}
export interface DbAdapter {
/** Execute a write statement (INSERT / UPDATE / DELETE). */
run(sql: string, params?: ReadonlyArray<SqlValue>): Promise<RunResult>;
/** Single-row query. Undefined if no row. */
get<T = Row>(sql: string, params?: ReadonlyArray<SqlValue>): Promise<T | undefined>;
/** Multi-row query. */
all<T = Row>(sql: string, params?: ReadonlyArray<SqlValue>): Promise<T[]>;
/** Execute multi-statement DDL (no params, no result). */
exec(sql: string): Promise<void>;
/** Run a callback inside a transaction. Rolls back on throw. */
transaction<T>(fn: () => Promise<T>): Promise<T>;
/** Identifies the backend. */
dialect(): "sqlite" | "postgres";
/**
* Set the schema search_path for multi-tenant isolation (PG only).
* SQLite adapter implements this as a no-op.
*/
dialect(): "postgres";
setSearchPath(schema: string): Promise<void>;
/** Release the connection / pool. */
close(): Promise<void>;
}
export interface DbAdapterConfig {
driver: "sqlite" | "postgres";
/** SQLite-only: filesystem path. */
sqlitePath?: string;
/** Postgres-only: connection string (postgres://user:pass@host:port/db). */
pgUrl?: string;
}

View file

@ -1,14 +1,9 @@
/**
* initDb initialize the database from config (shared module).
* initDb initialize the PostgreSQL database from config.
*
* Replaces the init logic that was in service-store/index.ts.
* Each service plugin calls this independently with its own config.
* Runs PUBLIC_MIGRATIONS (global tables) then TENANT_MIGRATIONS
* (per-tenant schema). Creates default tenant if missing.
*/
import { DatabaseSync } from "node:sqlite";
import { dirname } from "node:path";
import { mkdirSync } from "node:fs";
import { MIGRATIONS } from "./migrations.js";
import { Repository } from "./repository.js";
import type { DbAdapter } from "./db-adapter.js";
import type { DbConfig } from "./config.js";
@ -23,10 +18,8 @@ export async function initDb(
log: DbLog,
notifyFn?: (table: string, op: string, id?: string | number) => void,
): Promise<{ repo: Repository; close: () => Promise<void> }> {
const driver = config.driver;
const notify = notifyFn ?? (() => {});
if (driver === "postgres") {
let pgUrl = config.url ?? "";
if (!pgUrl) {
const u = encodeURIComponent(config.user);
@ -38,14 +31,12 @@ export async function initDb(
const { PgAdapter } = await import("./pg-adapter.js");
const adapter = new PgAdapter(pgUrl, config.poolMax);
// Ensure schema_migrations exists (bootstrap).
await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
schema_name TEXT NOT NULL, version INTEGER NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (schema_name, version)
)`);
// 1. Run PUBLIC_MIGRATIONS first (tenants + global_admins tables).
const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js");
const pubVersionRow = await adapter.get<{ version: number }>(
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public_global'`,
@ -70,7 +61,6 @@ export async function initDb(
log.info(`PUBLIC schema up to date (version ${pubCurrentVersion})`);
}
// 2. Run TENANT_MIGRATIONS in the public schema (default tenant).
const versionRow = await adapter.get<{ version: number }>(
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
).catch(() => undefined);
@ -94,7 +84,6 @@ export async function initDb(
log.info(`PG schema up to date (version ${currentVersion})`);
}
// 3. Ensure default tenant exists.
const defaultTenant = await adapter.get(
`SELECT id FROM public.tenants WHERE slug = 'default'`,
);
@ -110,90 +99,29 @@ export async function initDb(
notify(table, op, id);
});
return { repo, close: () => adapter.close() };
}
// SQLite path (default).
const path = config.sqlitePath;
log.info(`opening sqlite at ${path}`);
try {
mkdirSync(dirname(path), { recursive: true });
} catch (err) {
log.warn(`mkdir failed for ${dirname(path)}: ${(err as Error).message}`);
}
const db = new DatabaseSync(path);
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA synchronous = NORMAL");
db.exec("PRAGMA foreign_keys = ON");
db.exec("PRAGMA busy_timeout = 10000");
const row = db.prepare("PRAGMA user_version").get() as { user_version: number };
const currentVersion = row.user_version;
const targetVersion = MIGRATIONS.length;
if (currentVersion < targetVersion) {
log.info(`running migrations from ${currentVersion} to ${targetVersion}`);
for (let i = currentVersion; i < targetVersion; i++) {
const entry = MIGRATIONS[i];
if (typeof entry === "string") {
db.exec(entry);
} else if (typeof entry === "function") {
entry(db);
}
}
db.exec(`PRAGMA user_version = ${targetVersion}`);
} else {
log.info(`schema up to date (version ${currentVersion})`);
}
const { SqliteAdapter } = await import("./sqlite-adapter.js");
const adapter = SqliteAdapter.fromExisting(db);
const repo = new Repository(adapter, async (table, op, id) => {
notify(table, op, id);
});
return { repo, close: () => adapter.close() };
}
/**
* Create a new tenant schema and run all TENANT_MIGRATIONS inside it.
* Called when a new tenant is created from the admin UI.
*
* @param adapter - the DB adapter (must be PG)
* @param slug - tenant slug (used to derive schema name: `tenant_<slug>`)
* @param log - logging callbacks
*/
export async function createTenantSchema(
adapter: DbAdapter,
slug: string,
log: DbLog,
): Promise<void> {
if (adapter.dialect() !== "postgres") {
// SQLite is single-tenant — no schema creation needed.
return;
}
// Validate slug to prevent SQL injection.
if (!/^[a-z0-9][a-z0-9_-]*$/.test(slug)) {
throw new Error(`invalid tenant slug: ${slug}`);
}
const schemaName = `tenant_${slug}`;
log.info(`creating tenant schema: ${schemaName}`);
// Create the schema.
await adapter.exec(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
// Set search_path to the new schema for running tenant migrations.
await adapter.setSearchPath(schemaName);
try {
// Run all TENANT_MIGRATIONS inside the new schema.
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
// Ensure schema_migrations tracking for this schema.
// Use the public schema_migrations table (always in public).
const versionRow = await adapter.get<{ version: number }>(
`SELECT COALESCE(MAX(version), 0) AS version FROM public.schema_migrations WHERE schema_name = ?`,
[schemaName],
@ -216,7 +144,6 @@ export async function createTenantSchema(
}
}
} finally {
// Always reset search_path back to public.
await adapter.setSearchPath("public");
}
}

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

@ -485,4 +485,240 @@ export const TENANT_MIGRATIONS: readonly string[] = [
`CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`,
`ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS partitions_json JSONB`,
// ---- UUIDv7 PK migration for existing databases ----
// Databases created before UUIDv7 migration have INTEGER PKs.
// This migration converts them to TEXT in-place. Safe to run on
// databases that already have TEXT PKs (DO NOTHING on conflict).
// gen_random_uuid() generates UUIDv4 — close enough for backfill.
// New rows already use app-generated UUIDv7 from repository.ts.
`CREATE OR REPLACE FUNCTION _bf_add_fk(
src_table text, src_col text, ref_table text, ref_col text, on_del text
) RETURNS void LANGUAGE plpgsql AS $fn$
DECLARE
col_exists boolean;
ref_exists boolean;
cname text;
BEGIN
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = src_table AND column_name = src_col
) INTO col_exists;
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ref_table AND column_name = ref_col
) INTO ref_exists;
IF NOT col_exists OR NOT ref_exists THEN RETURN; END IF;
cname := src_table || '_' || src_col || '_fkey';
EXECUTE format(
'ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I(%I) ON DELETE %s',
src_table, cname, src_col, ref_table, ref_col, on_del
);
END $fn$`,
`DO $$
DECLARE
col_type text;
r record;
BEGIN
SELECT data_type INTO col_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'users'
AND column_name = 'id';
IF col_type IS NULL OR col_type = 'text' THEN
RAISE NOTICE 'UUIDv7 migration: already TEXT or table missing, skipping';
RETURN;
END IF;
RAISE NOTICE 'UUIDv7 migration: converting INTEGER PKs to TEXT...';
-- 1. Drop ALL foreign key constraints in current schema dynamically.
FOR r IN
SELECT tc.constraint_name, tc.table_name
FROM information_schema.table_constraints tc
WHERE tc.table_schema = current_schema()
AND tc.constraint_type = 'FOREIGN KEY'
LOOP
EXECUTE format('ALTER TABLE %I DROP CONSTRAINT IF EXISTS %I', r.table_name, r.constraint_name);
END LOOP;
-- 2. Convert every integer/bigint column that is a PK or FK to TEXT.
FOR r IN
SELECT c.table_name, c.column_name
FROM information_schema.columns c
WHERE c.table_schema = current_schema()
AND c.data_type IN ('integer', 'bigint')
AND (
c.column_name = 'id'
OR c.column_name LIKE '%_id'
OR c.column_name LIKE '%_by'
)
AND c.table_name NOT IN ('schema_migrations', 'setup_state')
LOOP
EXECUTE format('ALTER TABLE %I ALTER COLUMN %I TYPE TEXT USING %I::TEXT',
r.table_name, r.column_name, r.column_name);
-- Drop any leftover default (sequences from old SERIAL columns).
EXECUTE format('ALTER TABLE %I ALTER COLUMN %I DROP DEFAULT', r.table_name, r.column_name);
END LOOP;
-- 3. Drop orphan sequences (leftover from SERIAL columns).
FOR r IN
SELECT sequence_name
FROM information_schema.sequences
WHERE sequence_schema = current_schema()
AND sequence_name LIKE '%_id_seq'
LOOP
EXECUTE format('DROP SEQUENCE IF EXISTS %I CASCADE', r.sequence_name);
END LOOP;
-- 4. Re-add FK constraints (only if both table and column exist).
PERFORM _bf_add_fk('sessions', 'user_id', 'users', 'id', 'CASCADE');
PERFORM _bf_add_fk('api_keys', 'user_id', 'users', 'id', 'CASCADE');
PERFORM _bf_add_fk('camera_streams', 'camera_id', 'cameras', 'id', 'CASCADE');
PERFORM _bf_add_fk('display_layouts','display_id', 'displays', 'id', 'CASCADE');
PERFORM _bf_add_fk('display_layouts','layout_id', 'layouts', 'id', 'CASCADE');
PERFORM _bf_add_fk('layout_cells', 'layout_id', 'layouts', 'id', 'CASCADE');
PERFORM _bf_add_fk('layout_cells', 'camera_id', 'cameras', 'id', 'SET NULL');
PERFORM _bf_add_fk('kiosks', 'display_id', 'displays', 'id', 'SET NULL');
PERFORM _bf_add_fk('kiosk_labels', 'kiosk_id', 'kiosks', 'id', 'CASCADE');
PERFORM _bf_add_fk('kiosk_labels', 'label_id', 'labels', 'id', 'CASCADE');
PERFORM _bf_add_fk('camera_labels', 'camera_id', 'cameras', 'id', 'CASCADE');
PERFORM _bf_add_fk('camera_labels', 'label_id', 'labels', 'id', 'CASCADE');
PERFORM _bf_add_fk('layout_labels', 'layout_id', 'layouts', 'id', 'CASCADE');
PERFORM _bf_add_fk('layout_labels', 'label_id', 'labels', 'id', 'CASCADE');
PERFORM _bf_add_fk('event_log', 'source_kiosk_id', 'kiosks', 'id', 'SET NULL');
PERFORM _bf_add_fk('event_log', 'source_camera_id', 'cameras', 'id', 'SET NULL');
PERFORM _bf_add_fk('kiosk_gpio_bindings','kiosk_id', 'kiosks', 'id', 'CASCADE');
PERFORM _bf_add_fk('kiosk_logs', 'kiosk_id', 'kiosks', 'id', 'CASCADE');
PERFORM _bf_add_fk('camera_event_subscriptions','camera_id', 'cameras', 'id', 'CASCADE');
PERFORM _bf_add_fk('camera_event_subscriptions','subscribed_by_kiosk_id','kiosks', 'id', 'SET NULL');
PERFORM _bf_add_fk('displays', 'default_layout_id', 'layouts', 'id', 'SET NULL');
PERFORM _bf_add_fk('pairing_codes', 'consumed_by_kiosk_id', 'kiosks', 'id', 'SET NULL');
PERFORM _bf_add_fk('firmware_releases','uploaded_by', 'users', 'id', 'SET NULL');
PERFORM _bf_add_fk('firmware_rollouts','release_id', 'firmware_releases', 'id', 'CASCADE');
PERFORM _bf_add_fk('firmware_rollouts','created_by', 'users', 'id', 'SET NULL');
PERFORM _bf_add_fk('os_update_releases','uploaded_by', 'users', 'id', 'SET NULL');
PERFORM _bf_add_fk('os_update_rollouts','release_id', 'os_update_releases', 'id', 'CASCADE');
PERFORM _bf_add_fk('os_update_rollouts','created_by', 'users', 'id', 'SET NULL');
PERFORM _bf_add_fk('entities', 'camera_id', 'cameras', 'id', 'CASCADE');
RAISE NOTICE 'UUIDv7 migration: complete — all PKs and FKs are now TEXT';
END $$`,
// ---- Backfill: replace bare-integer IDs with real UUIDv7 ----
// Existing rows have IDs like "1", "2" from the type conversion.
// This replaces them with proper UUIDv7-shaped UUIDs while updating
// all FK references so nothing breaks.
`DO $$
DECLARE
r record;
old_id text;
new_id text;
fk record;
saved_fks jsonb := '[]'::jsonb;
BEGIN
-- 1. Save and drop ALL FK constraints so updates are unconstrained.
FOR r IN
SELECT tc.constraint_name, tc.table_name,
kcu.column_name AS fk_col,
ccu.table_name AS ref_table,
ccu.column_name AS ref_col,
rc.delete_rule
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name AND tc.table_schema = rc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = current_schema()
LOOP
saved_fks := saved_fks || jsonb_build_object(
'name', r.constraint_name, 'tbl', r.table_name,
'col', r.fk_col, 'ref', r.ref_table, 'rcol', r.ref_col,
'del', r.delete_rule
);
EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', r.table_name, r.constraint_name);
END LOOP;
-- 2. Replace integer-looking IDs with UUIDs + cascade to FK columns.
FOR r IN
SELECT t.table_name
FROM information_schema.columns t
WHERE t.table_schema = current_schema()
AND t.column_name = 'id'
AND t.data_type = 'text'
AND t.table_name NOT IN ('schema_migrations', 'setup_state', 'pairing_codes', 'sessions')
ORDER BY t.table_name
LOOP
FOR old_id IN
EXECUTE format('SELECT id FROM %I WHERE id ~ $1', r.table_name)
USING '^[0-9]+$'
LOOP
new_id := gen_random_uuid()::text;
-- Update FK columns in other tables that point to this old_id.
FOR fk IN
SELECT e->>'tbl' AS fk_table, e->>'col' AS fk_col
FROM jsonb_array_elements(saved_fks) e
WHERE e->>'ref' = r.table_name AND e->>'rcol' = 'id'
LOOP
EXECUTE format('UPDATE %I SET %I = $1 WHERE %I = $2',
fk.fk_table, fk.fk_col, fk.fk_col)
USING new_id, old_id;
END LOOP;
EXECUTE format('UPDATE %I SET id = $1 WHERE id = $2', r.table_name)
USING new_id, old_id;
END LOOP;
END LOOP;
-- 3. Re-add all FK constraints.
FOR fk IN
SELECT e->>'name' AS cname, e->>'tbl' AS tbl, e->>'col' AS col,
e->>'ref' AS ref, e->>'rcol' AS rcol, e->>'del' AS del
FROM jsonb_array_elements(saved_fks) e
LOOP
EXECUTE format(
'ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I(%I) ON DELETE %s',
fk.tbl, fk.cname, fk.col, fk.ref, fk.rcol, fk.del
);
END LOOP;
RAISE NOTICE 'UUIDv7 backfill: all integer-looking IDs replaced with UUIDs';
END $$`,
// ---- AbleSign digital signage integration -----------------------------------
`CREATE TABLE IF NOT EXISTS ablesign_accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
api_key_encrypted TEXT NOT NULL,
workspace_id TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
screen_count INTEGER NOT NULL DEFAULT 0,
last_sync_at TIMESTAMPTZ,
last_sync_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE TABLE IF NOT EXISTS ablesign_screens (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES ablesign_accounts(id) ON DELETE CASCADE,
ablesign_screen_id TEXT NOT NULL,
ablesign_screen_token_encrypted TEXT,
kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
orientation TEXT NOT NULL DEFAULT 'landscape',
online BOOLEAN NOT NULL DEFAULT false,
last_heartbeat_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(account_id, ablesign_screen_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_account ON ablesign_screens(account_id)`,
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_kiosk ON ablesign_screens(kiosk_id)`,
`ALTER TABLE entities DROP CONSTRAINT IF EXISTS entities_type_check`,
`ALTER TABLE entities ADD CONSTRAINT entities_type_check CHECK(type IN ('camera', 'html', 'web', 'dashboard', 'ablesign'))`,
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS ablesign_screen_id TEXT REFERENCES ablesign_screens(id) ON DELETE CASCADE`,
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false`,
];

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,9 @@
/**
* Postgres backend for the repository.
*
* Translates SQLite-style `?` placeholders to Postgres `$1, $2, ...` at
* execute time so the Repository code can stay dialect-neutral. RETURNING
* id captures lastInsertRowid (caller must add `RETURNING id` to INSERTs
* that need it same for SQLite path so the SQL strings are portable).
* Translates `?` placeholders to Postgres `$1, $2, ...` at execute time
* so Repository SQL stays clean. Rewrites `INSERT OR IGNORE` to
* `INSERT ... ON CONFLICT DO NOTHING` for Postgres compatibility.
*
* Pool size: default 10 configurable via pgPoolMax in sec-config.yaml.
*/

View file

@ -1,5 +1,5 @@
/**
* Repository typed accessor over the sqlite handle.
* Repository typed accessor over the DB adapter.
*
* Keeps prepared statements cached for the life of the connection. All
* mutating methods invoke the `notify` callback with (table, op, id) so the
@ -2307,11 +2307,13 @@ export class Repository {
html_content?: string | null;
web_url?: string | null;
dashboard_id?: string | null;
ablesign_screen_id?: string | null;
managed?: boolean;
}): Promise<Entity> {
const id = uuidv7();
await this._run(
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id, ablesign_screen_id, managed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
input.name,
@ -2319,8 +2321,10 @@ export class Repository {
input.description ?? null,
input.type === "camera" ? (input.camera_id ?? null) : null,
input.type === "html" ? (input.html_content ?? null) : null,
input.type === "web" ? (input.web_url ?? null) : null,
input.type === "web" || input.type === "ablesign" ? (input.web_url ?? null) : null,
input.type === "dashboard" ? (input.dashboard_id ?? null) : null,
input.type === "ablesign" ? (input.ablesign_screen_id ?? null) : null,
input.managed ?? false,
],
);
void this.notify("entities", "create", id);
@ -2405,7 +2409,7 @@ export class Repository {
if (await this.getEntityByName(name)) {
name = `${camera.name} (cam ${camera.id.slice(0, 8)})`;
}
return this.createEntity({ name, type: "camera", camera_id: camera.id });
return this.createEntity({ name, type: "camera", camera_id: camera.id, managed: true });
}
async updateKiosk(id: string, patch: Partial<Kiosk>): Promise<void> {
@ -2628,4 +2632,123 @@ export class Repository {
async deleteCloudAccount(id: string): Promise<void> {
await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]);
}
// ===========================================================================
// AbleSign accounts + screens
// ===========================================================================
async listAbleSignAccounts(): Promise<any[]> {
return this._all("SELECT * FROM ablesign_accounts ORDER BY created_at DESC");
}
async getAbleSignAccount(id: string): Promise<any | undefined> {
return this._get("SELECT * FROM ablesign_accounts WHERE id = ?", [id]);
}
async createAbleSignAccount(input: {
name: string;
api_key_encrypted: string;
workspace_id?: string;
}): Promise<string> {
const id = uuidv7();
await this._run(
`INSERT INTO ablesign_accounts (id, name, api_key_encrypted, workspace_id)
VALUES (?, ?, ?, ?)`,
[id, input.name, input.api_key_encrypted, input.workspace_id ?? null],
);
return id;
}
async updateAbleSignAccount(id: string, patch: Record<string, unknown>): Promise<void> {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "created_at") continue;
sets.push(`${k} = ?`);
vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
await this._run(`UPDATE ablesign_accounts SET ${sets.join(", ")} WHERE id = ?`, vals);
}
async deleteAbleSignAccount(id: string): Promise<void> {
await this._run("DELETE FROM ablesign_accounts WHERE id = ?", [id]);
}
async listAbleSignScreens(accountId?: string): Promise<any[]> {
if (accountId) {
return this._all("SELECT * FROM ablesign_screens WHERE account_id = ? ORDER BY title", [accountId]);
}
return this._all("SELECT * FROM ablesign_screens ORDER BY title");
}
async getAbleSignScreen(id: string): Promise<any | undefined> {
return this._get("SELECT * FROM ablesign_screens WHERE id = ?", [id]);
}
async getAbleSignScreenByKiosk(kioskId: string): Promise<any | undefined> {
return this._get("SELECT * FROM ablesign_screens WHERE kiosk_id = ?", [kioskId]);
}
async createAbleSignScreen(input: {
account_id: string;
ablesign_screen_id: string;
ablesign_screen_token_encrypted?: string;
kiosk_id?: string;
title: string;
orientation?: string;
}): Promise<string> {
const id = uuidv7();
await this._run(
`INSERT INTO ablesign_screens (id, account_id, ablesign_screen_id, ablesign_screen_token_encrypted, kiosk_id, title, orientation)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[id, input.account_id, input.ablesign_screen_id, input.ablesign_screen_token_encrypted ?? null, input.kiosk_id ?? null, input.title, input.orientation ?? "landscape"],
);
return id;
}
async updateAbleSignScreen(id: string, patch: Record<string, unknown>): Promise<void> {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "created_at") continue;
sets.push(`${k} = ?`);
vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
await this._run(`UPDATE ablesign_screens SET ${sets.join(", ")} WHERE id = ?`, vals);
}
async deleteAbleSignScreen(id: string): Promise<void> {
await this._run("DELETE FROM ablesign_screens WHERE id = ?", [id]);
}
async upsertAbleSignScreen(input: {
account_id: string;
ablesign_screen_id: string;
title: string;
online: boolean;
last_heartbeat_at?: string;
orientation?: string;
}): Promise<string> {
const existing = await this._get<{ id: string }>(
"SELECT id FROM ablesign_screens WHERE account_id = ? AND ablesign_screen_id = ?",
[input.account_id, input.ablesign_screen_id],
);
if (existing) {
await this._run(
`UPDATE ablesign_screens SET title = ?, online = ?, last_heartbeat_at = COALESCE(?, last_heartbeat_at), orientation = COALESCE(?, orientation) WHERE id = ?`,
[input.title, input.online, input.last_heartbeat_at ?? null, input.orientation ?? null, existing.id],
);
return existing.id;
}
return this.createAbleSignScreen({
account_id: input.account_id,
ablesign_screen_id: input.ablesign_screen_id,
title: input.title,
orientation: input.orientation,
});
}
}

View file

@ -1,103 +0,0 @@
/**
* SQLite backend for the repository. Wraps node:sqlite (sync API) in
* Promise-returning methods so the Repository can stay async-uniform across
* both backends.
*
* Prepared statements are cached per-SQL for perf parity with the
* old direct-DatabaseSync code path.
*/
import { DatabaseSync, type StatementSync } from "node:sqlite";
import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js";
export class SqliteAdapter implements DbAdapter {
private readonly db: DatabaseSync;
private readonly stmts = new Map<string, StatementSync>();
private txDepth = 0;
constructor(path: string) {
this.db = new DatabaseSync(path);
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA foreign_keys = ON");
this.db.exec("PRAGMA synchronous = NORMAL");
}
/** Wrap an already-opened DatabaseSync (e.g. after migrations ran). */
static fromExisting(db: DatabaseSync): SqliteAdapter {
const adapter = Object.create(SqliteAdapter.prototype) as SqliteAdapter;
(adapter as any).db = db;
(adapter as any).stmts = new Map();
(adapter as any).txDepth = 0;
return adapter;
}
private prep(sql: string): StatementSync {
let s = this.stmts.get(sql);
if (!s) {
s = this.db.prepare(sql);
this.stmts.set(sql, s);
}
return s;
}
private coerce(params: ReadonlyArray<SqlValue>): any[] {
return params.map((v) => (v === true ? 1 : v === false ? 0 : v));
}
async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> {
const stmt = this.prep(sql);
const r = stmt.run(...this.coerce(params));
return {
lastInsertRowid:
typeof r.lastInsertRowid === "bigint" ? r.lastInsertRowid : BigInt(r.lastInsertRowid),
changes: Number(r.changes),
};
}
async get<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T | undefined> {
const stmt = this.prep(sql);
const r = (stmt.get as any)(...this.coerce(params));
return r as T | undefined;
}
async all<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T[]> {
const stmt = this.prep(sql);
return (stmt.all as any)(...this.coerce(params)) as T[];
}
async exec(sql: string): Promise<void> {
this.db.exec(sql);
}
async transaction<T>(fn: () => Promise<T>): Promise<T> {
if (this.txDepth === 0) this.db.exec("BEGIN");
this.txDepth += 1;
try {
const result = await fn();
this.txDepth -= 1;
if (this.txDepth === 0) this.db.exec("COMMIT");
return result;
} catch (err) {
this.txDepth -= 1;
if (this.txDepth === 0) {
try { this.db.exec("ROLLBACK"); } catch { /* ignore */ }
}
throw err;
}
}
dialect(): "sqlite" { return "sqlite"; }
/** No-op for SQLite — single-tenant only. */
async setSearchPath(_schema: string): Promise<void> {
// SQLite doesn't support schemas — single tenant only.
}
async close(): Promise<void> {
this.db.close();
}
/** Expose raw DB for migrations that need fine control (idempotent
* ALTER TABLE, PRAGMA inspection, etc). Sqlite-only. */
rawSync(): DatabaseSync { return this.db; }
}

View file

@ -15,8 +15,7 @@
* 3. All queries run against tenant's schema
* 4. Connection returned to pool with search_path reset
*
* SQLite mode: single-tenant, no schema switching. tenant_id is always
* the static DEFAULT_TENANT_ID. The tenant table isn't created.
* Default tenant uses the public schema directly (slug = "default").
*/
export const DEFAULT_TENANT_ID = "default";

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,163 @@ export function TenantEditPage(props: TenantEditPageProps) {
</Layout>
);
}
// ---- AbleSign Pages ---------------------------------------------------------
interface AbleSignPageProps {
accounts: any[];
error?: string;
}
export function AbleSignPage(props: AbleSignPageProps) {
return (
<Layout title="AbleSign" activeNav="ablesign">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Accounts</h1>
{props.error ? <div class="alert alert-error" style="margin-bottom:1rem">{props.error}</div> : ""}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1.1rem; margin:0 0 1rem">Add Account</h2>
<form method="POST" action="/admin/ablesign/add" style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:end">
<label style="font-size:0.85rem">
{"Name"}<br/>
<input type="text" name="name" required style="width:12rem" placeholder="My AbleSign" />
</label>
<label style="font-size:0.85rem">
{"API Key"}<br/>
<input type="password" name="api_key" required style="width:16rem" placeholder="ak_..." />
</label>
<label style="font-size:0.85rem">
{"Workspace ID (optional)"}<br/>
<input type="text" name="workspace_id" style="width:8rem" />
</label>
<button type="submit" class="btn btn-sm">Add</button>
</form>
</div>
{props.accounts.length > 0 ? (
<div class="card">
<div class="table-wrap">
<table>
<thead><tr>
<th>Name</th>
<th>Screens</th>
<th>Last Sync</th>
<th>Actions</th>
</tr></thead>
<tbody>
{props.accounts.map((a: any) => (
<tr>
<td><a href={`/admin/ablesign/${String(a.id)}/screens`}>{a.name}</a></td>
<td>{String(a.screen_count ?? 0)}</td>
<td style="font-size:0.85rem">
{a.last_sync_at ? formatTime(a.last_sync_at) : "Never"}
{a.last_sync_error && <span style="color:red" title={a.last_sync_error}>{" (error)"}</span>}
</td>
<td style="display:flex; gap:0.25rem">
<a href={`/admin/ablesign/${String(a.id)}/screens`} class="btn btn-sm btn-ghost">Screens</a>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`} style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost">Sync</button>
</form>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/delete`} style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : ""}
</Layout>
);
}
interface AbleSignScreensPageProps {
account: any;
screens: any[];
kiosks: any[];
}
export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
const a = props.account;
return (
<Layout title={`AbleSign — ${String(a.name)}`} activeNav="ablesign">
<h1 style="font-size:1.5rem; margin:0 0 0.5rem">{a.name} Screens</h1>
<p style="color:#999; margin:0 0 1.5rem; font-size:0.85rem">
{String(a.screen_count ?? 0)} screens
{a.last_sync_at && ` · synced ${formatTime(a.last_sync_at)}`}
</p>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.75rem">Add Screen</h2>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/screens/add`} style="display:flex; gap:0.5rem; align-items:end">
<label style="font-size:0.85rem">
{"Screen Name"}<br/>
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
</label>
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
</form>
<p style="font-size:0.8rem; color:#999; margin:0.5rem 0 0">
Creates a new screen in AbleSign and pairs it automatically.
</p>
</div>
<div class="card" style="margin-bottom:1rem">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem">
<h2 style="font-size:1rem; margin:0">Screens</h2>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`}>
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
</form>
</div>
{props.screens.length === 0 ? (
<p style="color:#999; font-size:0.85rem">No screens yet. Add one above or sync from AbleSign.</p>
) : (
<div class="table-wrap">
<table>
<thead><tr>
<th>Title</th>
<th>Orientation</th>
<th>Status</th>
<th>Assigned Kiosk</th>
<th>Actions</th>
</tr></thead>
<tbody>
{props.screens.map((s: any) => (
<tr>
<td>{s.title}</td>
<td style="font-size:0.85rem">{s.orientation}</td>
<td>
{s.online
? <span class="badge badge-green">Online</span>
: <span class="badge badge-gray">Offline</span>}
</td>
<td>
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/assign`}
style="display:flex; gap:0.25rem; align-items:center">
<select name="kiosk_id" style="font-size:0.85rem; max-width:14rem">
<option value=""> None </option>
{props.kiosks.map((k: any) => (
<option value={String(k.id)} selected={k.id === s.kiosk_id}>{k.name}</option>
))}
</select>
<button type="submit" class="btn btn-sm btn-ghost">Assign</button>
</form>
</td>
<td>
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/delete`} style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Layout>
);
}

View file

@ -58,6 +58,7 @@ function Sidebar(props: { activeNav?: string }) {
<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"} />
<NavItem href="/admin/ablesign" label="AbleSign" icon="&#9654;" active={a === "ablesign"} />
<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"} />