RED.httpNode.post registers a raw express route with no body parser, so
req.body was undefined and trigger payloads showed all fields null. Add
a zero-dep readJsonBody helper that streams + parses req body.
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.
Add recompute_pool_states + expire_cooling_pipelines + recompute_global_state
and PipelineEntry struct so warm pool entries carry warmth state + cooling
deadline. Drop the incomplete tuple-shape from the previous push.
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
Node-RED only scans userDir/node_modules by default. Setting
nodesDir explicitly tells it to also scan our baked-in path,
which survives the /data volume mount.
- New deploy/docker/Dockerfile.nodered extends nodered/node-red,
npm-installs the workspace nodered/ package into
/usr/src/node-red/node_modules so bf-* nodes auto-load on boot.
- docker-compose nodered service switched from public image to
this build context. Rebuilding (--build) picks up node changes.
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
Kiosk pick_stream() implements CLAUDE.md heuristic:
- cell area >= 20% of grid → main stream
- cell area < 20% → sub stream
- explicit "main"/"sub" selector still honored
Badge overlay shows which stream is rendering:
- 'M' when camera has multi-stream and we picked main
- 'S' when we picked sub
- nothing when single-stream
Small label, top-left corner, semi-transparent black background.
Reduces buffer drops on multi-camera grids — small cells now use
low-res sub streams instead of all decoding 4K main.
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
Previously every reload-bundle killed and restarted all pipelines.
Now:
- WARM_CAMERAS map: camera_id → (pipeline, paintable)
- On reload: stop only pipelines for cameras no longer needed
- Needed = cells with content_type=camera + layout.preload_camera_ids
- Reuse existing pipeline+paintable, attach to new Picture widget
- Preloaded cameras keep decoding even when not visible
Achieves the "zero perceived latency" layout swap goal from CLAUDE.md
when cameras overlap between layouts.
- 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