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.
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.
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.
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
- bf-status: query kiosk state by ID via /api/admin/kiosks/:id
- bf-trigger-status: dedicated heartbeat-only topic kiosk.status
(skips connect/disconnect noise from kiosk.changed)
- bf-snapshot: GET /admin/entities/:id/snapshot as Buffer for
motion → email/telegram flows
- coordinator-ws now forwards both kiosk.changed (event=heartbeat)
AND kiosk.status on every status message
Action buttons that don't need a redirect now use hx-post:
- Kiosk power (wake/standby), fan (auto/off/50%/full)
- Kiosk switch-layout dropdown
- Kiosk GPIO delete (row swap-out)
- Camera labels add/remove (list re-render)
- Kiosk labels add/remove (list re-render)
- Display attach/detach layout (list re-render)
Server routes return HTML fragments via isHtmxRequest() check,
otherwise still 302 redirect for direct-URL access.
Forms that legitimately redirect (create/edit/delete, auth flows)
stay as standard form posts.
- Dockerfile.server: RUN npm run build during builder stage so the
image ships pre-compiled lib/ + bsb-plugin.json. Runtime image also
installs ffmpeg (for camera snapshot endpoint).
- DisplayEditPage Show buttons + Switch dropdown now use hx-post
with hx-swap=none — no page reload, just fires the command.
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
Bind native backend services and Node-RED to loopback so Angie remains the public auth boundary. Keep Docker on an internal compose network and stop kiosk fallback to a layout when display default is none.
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
- 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).
Rust kiosk:
- web cells now use webkit6 WebView (load_uri)
- html cells use WebView.load_html (full HTML rendering)
- query_displays() reads /sys/class/drm/ for connected HDMI/DP outputs
- Heartbeat reports display geometry every 60s
Server:
- /api/kiosk/heartbeat accepts displays array
- Syncs kiosk-reported displays to display records
- Updates dimensions when changed, creates new displays for new ports
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.
- 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.
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).
- 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
- 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
- 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
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.
h3 v2's setCookie modifies event response headers but doesn't
carry them when handler returns a raw Response object. Build
Set-Cookie header manually in redirect helpers instead.
h3 v2's html() is a tagged template literal, not a function that
accepts a string. JSX-rendered markup passed directly causes
"first.reduce is not a function". Created htmlPage() helper that
wraps markup in a proper Response with text/html content type.
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.