Commit graph

87 commits

Author SHA1 Message Date
Mitchell R
3d5e27bdfb
fix(release): surface build versions 2026-05-21 08:51:41 +02:00
Mitchell R
3ffaf780e3
feat(kiosk): improve display controls and health 2026-05-21 02:03:12 +02:00
Mitchell R
96f5e6a330
feat(ota): add OS update release endpoints 2026-05-20 06:19:46 +02:00
Mitchell R
9942957bcf
feat(onvif): run discovery from selected kiosk 2026-05-20 06:16:27 +02:00
Mitchell R
dae5d0ce88
feat(managed-config): server-side scaffold for Pi-image device config
Kiosks running our pre-built image (managed_image=true at pairing) can
have their hostname, timezone, network (DHCP/static + VLAN), and Wi-Fi
configured from the admin UI. Pull-model: server stores desired-state
JSON, kiosk heartbeat returns pending_config when version exceeds
applied_version, kiosk echoes applied_version back. Wi-Fi PSK encrypted
with the cluster key so ciphertext at rest is shipped to the kiosk
without per-kiosk re-encryption.

Server side only — kiosk Rust applier (betterframe-apply-config helper
+ rollback timer) and pair-initiate marker file are next.

ci(pi-gen): use action's image-path output for asset upload

pi-gen writes the .img.xz into pi-gen-action's own working dir, not our
repo deploy/. Glob never matched. Use steps.pigen.outputs.image-path
directly — no glob needed.
2026-05-20 03:18:11 +02:00
Mitchell R
6473f0fc95
fix(firmware): diagnostic dump + smart-quote / BOM / multi-quote handling
Adds aggressive normalisation to tryParsePrivateKey:
- Strip UTF-8 BOM
- Replace smart quotes (" " ' ') with ASCII
- Strip multiple layers of wrapping quotes
- Combine escape-unfold with quote-strip (env vars that quote AND escape)
- Strip whitespace inside base64 candidate before decode

On parse failure, dumps length + head/tail samples + first-byte hex so
the operator can spot exactly what shape the env var arrived in.
2026-05-18 22:52:35 +02:00
Mitchell R
8082571b03
fix(firmware): tolerate mangled PEM in BF_FIRMWARE_SIGNING_KEY env
Coolify / docker compose env injection routinely strips real newlines or
wraps in quotes, causing createPrivateKey to throw ERR_OSSL_UNSUPPORTED
and crashing the server before it can even start.

tryParsePrivateKey now attempts: literal, \n→LF, CRLF→LF, quote-stripped,
base64-decoded, and single-line PEM re-wrapped to 64-col. On total
failure, logs a clear warning and falls back to on-disk / generated key
instead of crashing.
2026-05-18 22:47:07 +02:00
Mitchell R
17f8c7ce02 feat(server): generic MQTT telemetry bridge (off by default) 2026-05-14 07:46:56 +02:00
Mitchell R
aa4e91491b feat(server): backup + restore (AES-256-GCM, PBKDF2, admin UI) 2026-05-14 07:44:01 +02:00
Mitchell R
a6c1fb4d8d feat(server): rate limit (login + pair) + CSP/security headers 2026-05-14 07:40:22 +02:00
Mitchell R
3ec2f3bf85 feat(server): audit log — schema, helper, admin UI, hooks for login/pair/firmware 2026-05-14 07:38:18 +02:00
Mitchell R
d1fd128ea0 feat(server): env-var overrides for sec-config keys + docker healthchecks 2026-05-14 07:33:10 +02:00
Mitchell R
6a8f6d76af feat(kiosk): LAN-side local HTTP server (GET layout API + admin proxy)
Kiosk now exposes :18090 with two surfaces:

- GET /local/layout/:id?key=<kiosk_local_key>
  Bookmark-friendly layout switch on this kiosk. Auth = kiosk-generated
  local key (32 random bytes, hex, stored at <state_dir>/local.key).

- ANY /proxy/* — forwards to BF server with the request's Authorization
  header preserved. Lets LAN clients reach a cloud-hosted BF server via
  the kiosk's local socket; kiosk adds no auth of its own.

Heartbeat reports {local_key, local_port}; kiosks table grows
local_key/local_port/local_last_ip columns. Admin kiosk edit page now
shows the local URLs as a copy-paste block.

Override port: BF_KIOSK_LOCAL_PORT. Disable: BF_KIOSK_LOCAL_DISABLE=1.
2026-05-14 07:24:21 +02:00
Mitchell R
e5009fdd14 feat(ota): replacement pairing + firmware OTA (admin UI, kiosk client, CI) 2026-05-13 20:56:42 +02:00
Mitchell R
b10958def7 fix(nodered): kiosk-side layout.changed events + provisioning retries
Three related fixes:

1. Idle reverts (and any other kiosk-initiated layout switch) now POST
   layout.changed to /api/kiosk/event. Previously the server only emitted
   on admin-initiated switches, so Node-RED never saw the idle revert.

2. Server's /api/kiosk/event splays the payload to the top level when
   the topic has a dedicated trigger node (layout.changed, kiosk.changed,
   kiosk.status, display.power.changed, camera.changed). The trigger
   nodes expect flat shapes matching the admin emit; the old wrapped
   shape left every field undefined.

3. Auto-provisioning of bf-server-config in Node-RED: extend retry
   window to ~5 min, log per attempt, force v2 API + full-deploy header
   so credentials inline get accepted, surface response body on failure.
2026-05-13 13:03:51 +02:00
Mitchell R
122509de0d feat(nodered): auto-provision bf-server-config on boot
Server mints a dedicated admin API key on first boot (persisted plaintext
encrypted in setup_state.extras) and POSTs a bf-server-config node into
Node-RED's flow graph via /nrdp/flows. Idempotent — skips if any
bf-server-config already exists, so user-owned configs win.

New admin-http config 'selfUrl' (defaults to http://127.0.0.1:18080)
tells Node-RED how to reach the BF server. Docker compose sets it to
http://server:18080 so requests stay inside the compose network.
2026-05-13 03:09:25 +02:00
Mitchell R
faaa2cef39 feat(display): admin enable/disable toggle
is_enabled column on displays (default 1). Disabled displays are filtered
from the kiosk bundle so the kiosk never opens a window on them. Admin
edit page exposes a checkbox; list page shows a "disabled" badge.
2026-05-13 02:59:28 +02:00
Mitchell R
887db013ef fix: trigger nodes self-register + move to angie-blocked path
Trigger nodes now self-contained inputs (inputs:0):
- Each registers POST /api/internal/<topic> on RED.httpNode
- Angie returns 404 for any /api/* not whitelisted (kiosk/pair/admin)
  so external requests cannot trigger BF nodes
- Server bridge POSTs direct to nodered container (bypasses Angie)
- nodered-bridge.ts updated to use /api/internal/<topic>
- 6 trigger nodes converted: display-power, layout-changed,
  kiosk-changed, camera-changed, status, kiosk-camera-event
- Optional per-node filters (display_id, kiosk_id, camera_id)
- close handler removes only this node's route layer
2026-05-13 02:42:37 +02:00
Mitchell R
bd48c853e6 feat: restructure Node-RED nodes + server event emission
Renames:
- bf-config → bf-server-config (config node clarity)
- bf-event-in → bf-kiosk-camera-event (specific camera trigger)

New trigger nodes (input-only, under "BetterFrame Triggers"):
- bf-trigger-display-power, bf-trigger-layout-changed,
  bf-trigger-kiosk-changed, bf-trigger-camera-changed

New flow nodes:
- bf-config-get: query state by type (displays/kiosks/cameras/layouts/
  entities, or by-id)
- bf-config-set: mutate via typed setters (default-layout, enabled,
  priority, name)

Server-side event emission:
- shared/strip-secrets.ts: recursive password scrub
- New JSON admin endpoints: GET/POST /api/admin/{displays,kiosks,
  layouts,entities}[/:id]
- Coordinator-ws fires kiosk.changed on connect/disconnect/heartbeat
- Layout/power/camera routes call nodered.forward() on state change
2026-05-13 02:26:08 +02:00
Mitchell R
b83782b8e0 feat: Node-RED custom nodes + dashboard entity type
Node-RED nodes (nodered/):
- bf-config: shared server URL + admin API key
- bf-event-in: filter kiosk events by topic glob
- bf-layout-switch: POST display layout-switch
- bf-power: kiosk wake/standby
- bf-fan: kiosk fan control
- bf-cameras: query camera list
- Drag-droppable from Node-RED palette

Server:
- Admin Bearer API key auth on /admin/* (NodeRED can call admin API)
- GET /api/admin/cameras for bf-cameras node
- Dashboard entity type:
  - entities.type CHECK adds 'dashboard'
  - entities.dashboard_id column
  - shared/nodered-bridge.ts listDashboards() polls /nrdp/flows
  - Bundle resolves dashboard entity → web cell at /dash/<id>
  - POST /admin/entities/sync-dashboards mirrors Node-RED tabs
  - EntitiesPage shows Dashboards section + Sync button
  - EntityEditPage for dashboard: read-only + "Open in Node-RED"
  - No create/delete from BF UI — managed in Node-RED
- sec-config: noderedUrl on admin-http (was already on api-http)
2026-05-13 01:47:53 +02:00
Mitchell R
975cc184b3 feat: multi-display + snapshot + health + GPIO + nodered embed
Multi-display:
- Bundle ships displays[] each with own layouts + idle/sleep
- Rust kiosk creates one ApplicationWindow per gdk monitor
- Per-display state (layout, idle, sleep) via HashMap
- WARM_CAMERAS pool shared across displays
- Backward-compat top-level display/layouts still emitted

System Health (/admin/health):
- Online status, CPU temp (color-coded), fan RPM/PWM
- Bundle version mismatch detection
- 30s auto-refresh

Camera snapshot/test:
- shared/snapshot.ts: ffmpeg/gst-launch fallback, 5s timeout
- /admin/entities/:id/snapshot returns JPEG
- EntityEditPage shows live preview with Refresh

GPIO (Pi buttons/sensors):
- kiosk_gpio_bindings table + CRUD admin UI
- Bundle ships gpio_bindings[]
- kiosk/src/gpio.rs with gpiod crate, worker thread per pin
- Edge events POST to /api/kiosk/event with source_type=gpio

Layout switch fixes:
- GET aliases added so direct URL hits work
- New /admin/displays/:displayId/layout/:layoutId for multi-display
- DisplayEditPage gets "Switch Layout Now" section

Node-RED embed:
- /admin/nodered renders iframe at /nrdp/
- Sandbox attrs allow scripts/forms/popups
- Sidebar link now opens embedded view
2026-05-13 01:18:22 +02:00
Mitchell R
1e09582379 feat: per-cell content fit (cover|contain|fill), default cover
- Migration adds layout_cells.fit column (default 'cover')
- LayoutCell type + mapper + repo accept/persist fit
- Bundle ships fit per cell
- Admin cell edit form: Fit dropdown with industry-default Cover
- Rust kiosk applies ContentFit::Cover|Contain|Fill per cell.fit

Cover = fill cell, crop overflow (industry default — Nx Witness etc)
Contain = letterbox, no crop
Fill = stretch, distort
2026-05-11 13:52:22 +02:00
Mitchell R
51c58e7abf feat: Pi fan control + temp monitoring + stream swap on layout change
Kiosk:
- hwmon.rs reads /sys/class/thermal + /sys/class/hwmon for CPU temp,
  fan RPM, fan PWM
- Heartbeat reports cpu_temp_c, fan_rpm, fan_pwm
- WS message "fan" with {pwm: N} or {mode: "auto"} sets pwm1_enable+pwm1
- Picture content_fit Cover → Contain (no more cropping/overlay cuts)
- ensure_warm tears down + rebuilds pipeline when desired stream
  changes (M↔S swap on layout change)

Server:
- Migration v0.8: add cpu_temp_c, fan_rpm, fan_pwm to kiosks
- Heartbeat persists hwmon fields
- KioskEditPage shows CPU/fan/PWM + Auto/Off/50%/Full buttons
- POST /admin/kiosks/:id/fan dispatches via coordinator WS
2026-05-11 11:47:07 +02:00
Mitchell R
820e0a5945
fix(proxy): split Node-RED route surfaces
Route backend, kiosk ingest, kiosk dashboards, and public Node-RED HTTP-in separately. Keep Node-RED editor under admin auth and attach kiosk auth when kiosk loads protected dashboard URLs.
2026-05-11 10:44:45 +02:00
Mitchell R
026325ccd0
feat(layout): add branded none cells
Migrate empty layout cells to an explicit none state so kiosk renders the BetterFrame placeholder instead of blank HTML.
2026-05-11 09:38:50 +02:00
Mitchell R
0d9451ae95
feat(onvif): batch import discovered cameras 2026-05-11 08:40:25 +02:00
Mitchell R
02e57a5d54
fix(onvif): import profiles as streams 2026-05-11 00:20:48 +02:00
Mitchell R
3be1a9a624 feat: entities (unified content pool) + ONVIF discovery flow
Entities:
- New entities table — id, name, type (camera|html|web), camera_id,
  html_content, web_url
- Auto-create entity per camera on createCamera
- Layout cells reference entity_id (replaces inline content_type/
  camera_id/html_content/web_url)
- Bundle resolves entities back to legacy cell fields for kiosk compat
  (Rust kiosk unchanged)
- Full CRUD: /admin/entities, /admin/entities/new, /admin/entities/:id
- Cell editor: single entity dropdown with type badges

ONVIF discovery:
- /admin/cameras/discover — host/port/user/pass form
- Server queries ONVIF device, lists profiles with name/resolution/
  encoding/framerate
- "Add" creates camera + main stream from chosen profile
- shared/onvif.ts: minimal SOAP+UsernameToken+PasswordDigest client
  (no external dep)
- Camera new form simplified to RTSP-only with discover link
2026-05-10 23:18:44 +02:00
Mitchell R
f61c3db0e8 feat: Node-RED outbound bridge — forward kiosk events to Node-RED
- shared/nodered-bridge.ts: fire-and-forget POST to Node-RED HTTP-in
- api-http: kiosk event endpoint now forwards to Node-RED at /in/<topic>
- Best-effort, never blocks. 3s timeout, warn on failure.
- sec-config: noderedUrl on api-http (defaults to http://127.0.0.1:1880)

Node-RED flows can attach http-in nodes at /in/<topic> to receive
camera motion, GPIO events, etc. Inbound commands (Node-RED → server)
go through the admin API with admin Bearer token (no new endpoints
needed for v0.1).
2026-05-10 22:49:59 +02:00
Mitchell R
16ab165b06 feat: live updates via WebSocket — server pushes, kiosk reloads
Server side:
- service-coordinator-ws: full WS implementation using ws package
- Auth via ?token=<kiosk_key> query param
- Coordinator registry for cross-plugin notification
- Admin mutations call notifyKiosks() → server pushes reload-bundle
- 30s ping/pong heartbeat

Kiosk side:
- Rust ws_client with tokio runtime + tokio-tungstenite
- Auto-reconnect with exponential backoff (1s → 60s cap)
- On reload-bundle: re-fetches bundle, re-renders layout
- Pong replies to server pings

Also fix: auto-suffix kiosk name on UNIQUE collision (re-pair with
same hostname no longer fails).
2026-05-10 22:15:58 +02:00
Mitchell R
533412a826 refactor: Nx-Witness layout builder + drop regions/is_default
- Cells own position directly (row/col/row_span/col_span)
- Drop regions JSON from layouts (cells ARE the regions)
- Drop is_default from layouts (display.default_layout_id owns)
- Drop grid_cols/grid_rows from layouts (computed from cells)
- Layout new form: name, description, priority, resets_idle_timer only
- Layout edit: visual grid builder, + buttons on cell edges,
  click cell to assign content
- Bundle cells now carry position directly
- Rust kiosk attaches widgets using cell position
- Migration v0.4: backfills cell positions from old region map
2026-05-10 21:55:19 +02:00
Mitchell R
7fbda3c2b3 refactor: merge templates into layouts, displays from kiosks
- Eliminated layout_templates as separate entity — regions/grid now
  live directly on layouts
- Displays created from kiosk pairing (not standalone), each display
  has kiosk_id FK
- Removed Templates from sidebar nav and all template routes/pages
- Layout creation uses preset buttons (fullscreen, 2x2, 1+3, 3x3)
  that set regions directly on the layout
- Setup no longer creates default display/layout (deferred to pairing)
- Pairing creates HDMI-0 display for new kiosk
- Bundle reads regions from layout directly, no template lookup
- Rust kiosk updated to match new bundle format
- DB migration adds regions/grid_cols/grid_rows to layouts, kiosk_id
  to displays, copies existing template data
2026-05-10 21:39:09 +02:00
Mitchell R
cc306cec57
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
  (no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
  BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
  to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
  updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
  updateDisplay, and more
2026-05-10 03:45:53 +02:00
Mitchell R
94e316a207
feat: implement kiosk API, pairing flow, and bundle generation
- service-api-http: h3 on :18081 with pairing, bundle, heartbeat,
  and event endpoints
- shared/pairing.ts: 8-char code state machine (initiate → claim →
  confirm)
- shared/bundle.ts: label-scoped bundle with cluster-encrypted ONVIF
  passwords
- Admin kiosks page: POST /admin/kiosks/pair wired to confirmPairing
- sec-config: api-http bound to 0.0.0.0 with auth config
2026-05-10 03:12:07 +02:00
Mitchell R
bacf1d2fcb
fix: use deterministic HKDF key for cookie signing
cookieMac was using encryptString which generates a random IV per
call, making the HMAC key non-deterministic. Cookie signed at login
could never verify on subsequent requests. Now uses deriveKey(info)
which uses HKDF — deterministic for same server key.
2026-05-10 02:59:04 +02:00
Mitchell R
a8b0fbb2bc
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.

4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.

service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.

sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 02:29:25 +02:00
Mitchell R
2fd2502b85
adding initial project 2026-05-10 01:09:13 +02:00