Commit graph

34 commits

Author SHA1 Message Date
Mitchell R
28ff450d35
revert(bundle): restore type==='rtsp' gate on stream fallback
ONVIF cams legitimately have multiple streams (main+sub) stored in
camera_streams. Dropping the type gate synthesized a single "main"
row for ONVIF cams that lacked rows, hiding the multi-stream design
the kiosk's pick_stream relies on (area >= 0.2 → main, else sub).
The "(no stream)" symptom is a partial-import bug to chase separately;
the bundle fallback is for single-URL RTSP cams only. Also drop the
backfill migration that did the same insert at the DB layer.
2026-05-21 09:39:54 +02:00
Mitchell R
281c0adf44
fix(bundle): synthesize stream for any camera with rtsp_url
ONVIF-imported cameras with rtsp_url but no camera_streams rows showed
"(no stream)" in the kiosk because the bundle fallback was gated to
type=rtsp only. Drop the type check + backfill existing rows so old
imports get a main stream row created.

feat(kiosk-mgmt): report hostname + all network interfaces

Behind Docker/Angie the server only saw the proxy bridge IP (172.31.0.2).
Kiosk now shells `ip -j addr show`, reports every non-loopback IPv4/v6
with CIDR, MAC, and operstate. Plus `hostname` for verifying that
managed-config applies landed. Admin UI renders interface list with
LAN IPs preferred for the copy-paste local-LAN endpoint.

feat(managed-config): auto-sync hostname from kiosk name

When admin renames a managed-image kiosk, slugify the name → DNS-safe
hostname and bump managed_config_version so the kiosk applies it on
next heartbeat. Empty form hostname now falls back to slug too, so
DHCP shows the friendly name.

feat(events): forward firmware + OS update outcomes as kiosk.log

Kiosk POSTs `/api/kiosk/event` with topic=kiosk.log on firmware-apply
attempts. Server-side firmware/os-update endpoints also insert into
event_log so admins can audit upgrades without correlating per-source.
Wire schema heartbeat gains reported_hostname + network_interfaces for
Rust import parity.
2026-05-21 09:23:50 +02:00
Mitchell R
49e420dea5
feat(display): report and control power state 2026-05-21 09:10:30 +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
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
936e6170a6
feat(store): Postgres adapter foundation + BF_DB selector (phase 1)
Lays groundwork for sqlite|postgres backend selection without yet
converting Repository. Adds:

- db-adapter.ts: async DbAdapter interface (run/get/all/exec/transaction)
- sqlite-adapter.ts: wraps node:sqlite sync API in Promise-returning shape,
  caches prepared statements
- pg-adapter.ts: pg Pool + ? → $N placeholder rewrite + RETURNING-id
  capture + savepoint-nested transactions
- service-store config: driver (sqlite|postgres), pgUrl
- BF_DB env override, plumbed via envStr

Selecting BF_DB=postgres throws at init() until the Repository is
converted off DatabaseSync. This commit ships the foundation only.

Next phases (separate commits):
  2. Convert Repository methods sync → async via DbAdapter
  3. Update every caller to await
  4. Split MIGRATIONS into sqlite + portable / pg-specific sets
  5. UUIDv7 IDs for new tables on PG path

Adds deps: pg ^8.13.1, uuidv7 ^1.0.2, @types/pg ^8.20.0
2026-05-18 22:50:48 +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
69cd0391b5 feat(ota): phase 3 — rollouts + automated rollback
Rollouts (server side):
- /admin/firmware/rollouts page lists + creates campaigns. Pick release,
  target kiosk_ids (empty = whole channel), percentage (1-100).
- Active rollouts override channel-latest in /api/kiosk/firmware/check.
- Deterministic bucket via sha256(rollout_id:kiosk_id) % 100 — same kiosk
  consistently lands in the same bucket across re-checks.
- Pause / resume / complete state controls.

Rollback (kiosk side):
- Before swap, kiosk writes firmware-applying.json marker.
- After clean boot + first successful heartbeat, marker deleted.
- New ExecStartPre hook (/usr/local/sbin/betterframe-firmware-rollback.sh)
  runs every service start; stale marker (>120s) + .prev present →
  restore .prev. Pairs with systemd's StartLimit to catch crash loops.
2026-05-14 07:28:20 +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
7c88d7f733 fix(displays): use kiosk-local indices
Kiosk heartbeat reports local display positions so the server can sync physical outputs without consuming global display indices.

Migrate displays.index away from global uniqueness because display numbering is only meaningful within a kiosk.
2026-05-13 03:57:12 +02:00
Mitchell R
1b47911ce5 fix(store): delete displays with kiosk 2026-05-13 03:43:29 +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
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
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
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
722ddcfb12 feat: layout builder — resize cells + in-place htmx editing
UI improvements:
- Click cell → htmx swaps in edit form inside the cell (no page reload)
- Cancel re-fetches cell in read mode
- Save returns updated cell HTML, htmx swaps it
- Edit form includes Width/Height inputs for col_span/row_span
- Inline +W/-W/+H/-H buttons on each cell for quick resize
- Add (+) and delete (×) buttons also htmx — only the grid swaps

Routes:
- GET /admin/layouts/:id/cells/:cellId — cell fragment (read mode)
- GET /admin/layouts/:id/cells/:cellId/edit — cell fragment (edit mode)
- POST /admin/layouts/:id/cells/:cellId/resize — adjust span by delta
- All cell ops return fragment if hx-request header present, else 302

All mutations trigger notifyKiosks() — kiosks live-update via WS.
2026-05-10 22:31:37 +02:00
Mitchell R
374a2e091b fix: proper migration version tracking via user_version PRAGMA
- Migrations now run exactly once per DB lifetime, tracked via
  SQLite's user_version PRAGMA
- Re-runs become no-ops after schema reaches target version
- v0.2 also made defensive — skips if template_id already dropped

Fixes "no such column: layouts.template_id" on second startup after
v0.5 rebuild dropped the legacy columns.
2026-05-10 22:18:03 +02:00
Mitchell R
2398be6853 fix: drop legacy layouts.template_id/display_id columns via table rebuild
- Migration v0.5: rebuild layouts table without template_id, display_id,
  regions, grid_cols, grid_rows, is_default
- Migration v0.6: rebuild layout_cells table without region_name
- Migration v0.7: drop layout_templates table entirely (concept removed)
- createLayout simplified to clean column set (no sentinel values)
- createLayoutCell simplified (no region_name placeholder)
- Removed all layout_template repo methods (dead code)

Answers user question "why template_id/display_id in template": SQLite
can't drop columns without rebuilding the table. Now done properly.
2026-05-10 22:03:32 +02:00
Mitchell R
b8f934b2be fix: createLayout passes 0 for legacy NOT NULL template_id 2026-05-10 21:59:47 +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
e3bb5ae048 feat: RTSP field split, coordinator-ws stub, CLAUDE.md update
- Camera add/edit: split RTSP URL into host/port/path/user/pass fields
- Camera edit updates stream URI when RTSP URL changes
- service-coordinator-ws: HTTP health stub (WS upgrade deferred)
- Repository: added updateCameraStream
- CLAUDE.md: full rewrite reflecting current architecture
2026-05-10 15:35:47 +02:00
Mitchell R
bcaca8fdc7
fix: default stream_selector to 'auto' for non-camera cells 2026-05-10 03:48:23 +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
7b4a11c182
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
  attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
  with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
  deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
  updateLabel, cameraLabelIds
2026-05-10 03:24: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