Commit graph

304 commits

Author SHA1 Message Date
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
77b58c07fd feat(kiosk): track main/sub pipelines independently in warm pool
Pool was keyed by camera_id, so a cell flipping M→S tore down the old
pipeline and started fresh. With (camera_id, badge) keys the main and
sub variants live alongside each other: switching badge promotes the
new one to Warm and leaves the previous one to cool down via the normal
state machine, so flipping back inside the cooldown is instant.

ensure_warm no longer touches sibling badge entries. recompute_global_
state computes warm/hot sets as (cam, badge) pairs by calling
pick_stream per cell with its area fraction, so the planner sees what
ensure_warm will actually create.
2026-05-13 13:00:35 +02:00
Mitchell R
d5bd64d05c feat(deploy): self-update + auto-reboot on boot-file changes
Two ergonomics fixes so one invocation does the right thing:

  1. After git pull, re-exec the script if the installer itself changed
     in the pull. Previously you'd need a second run to pick up new
     logic. BF_REEXEC=1 guard prevents loops.

  2. Track REBOOT_NEEDED when cmdline.txt / config.txt get edited or
     /var/run/reboot-required appears (apt kernel/libc update). At end
     of run, auto-reboot after a 10s cancellable window. Override with
     BF_NO_REBOOT=1.
2026-05-13 12:58:20 +02:00
Mitchell R
786febbb9b fix(kiosk): strip caps so WebKit's bwrap sandbox can start
WebKitGTK launches bubblewrap for its web-content process; bwrap refuses
to run when the parent process still carries unexpected CAP_* bits ("but
not setuid, old file caps config?"). Setting CapabilityBoundingSet= +
AmbientCapabilities= empty and NoNewPrivileges=yes gives bwrap a clean
caps slate to drop from, so the sandbox initialises and web/dashboard
cells render instead of crashing the kiosk.
2026-05-13 12:53:31 +02:00
Mitchell R
f2dd5b9386 feat(kiosk): show empty display reference 2026-05-13 04:04:03 +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
54d4dfefa8 Fix kiosk fan control state updates 2026-05-13 03:47:34 +02:00
Mitchell R
d018b34955 fix(displays): sync layout attachment UI 2026-05-13 03:46:58 +02:00
Mitchell R
1b47911ce5 fix(store): delete displays with kiosk 2026-05-13 03:43:29 +02:00
Mitchell R
46b66fd62b feat(kiosk): immediate first heartbeat + loading spinner
Heartbeat slept 60s before first send, so admin Hardware panel showed
"—" right after pairing/boot. Reorder: fire once, then sleep.

Add a GTK spinner under the logo on the idle/pairing screens so users
see the kiosk is alive and working rather than staring at a static
splash.
2026-05-13 03:39:03 +02:00
Mitchell R
70fd4ff7f6 fix(kiosk): hide the mouse cursor on every window
Cage shows the pointer mid-screen by default — there's no input the
user should see on a kiosk. Set GDK's "none" cursor on the pairing
window and each per-display window.
2026-05-13 03:37:32 +02:00
Mitchell R
bb67c26a1c fix(deploy): mark setup-pi-kiosk.sh executable in git index + add .gitattributes
Windows chmod doesn't propagate to git's mode bits, so the script
landed as 100644 (non-exec) and `./deploy/scripts/setup-pi-kiosk.sh`
gave "command not found" on the Pi. Update index to 100755 and add
.gitattributes to force LF on shell scripts / systemd units to head off
the related CRLF-shebang trap.
2026-05-13 03:33:41 +02:00
Mitchell R
93cf261f07 fix(deploy): purge Pi welcome wizard + mask display managers
systemctl disable lets apt upgrades re-enable a DM. Mask everything
that could put a desktop on tty1. Purge piwiz + userconf-pi (the
"Welcome to Raspberry Pi" first-run wizard) and wipe /etc/motd +
/etc/update-motd.d so even on console nothing identifies as Pi.
2026-05-13 03:31:47 +02:00
Mitchell R
50fd9046bc fix(systemd): move StartLimit* keys to [Unit] section
systemd ignored them in [Service] and warned at load. Moving to [Unit]
makes the 10-burst / 60s rate limit actually take effect.
2026-05-13 03:25:54 +02:00
Mitchell R
ad909e9c93 fix(deploy): drop nonexistent 'seat' supplementary group
systemd refuses to spawn the unit with code=216/GROUP when any group in
SupplementaryGroups= doesn't exist. Debian's seatd uses -g video — there
is no 'seat' group on the system. Removing it lets cage start; the video
group already covers seatd access.
2026-05-13 03:23:49 +02:00
Mitchell R
85a0bcae87 feat(deploy): BetterFrame plymouth boot splash
Replace Pi rainbow + kernel boot text with a black BG + centered BF logo
during boot. Installer renders logo.png from the existing SVG asset via
rsvg-convert, drops a script-based plymouth theme, and appends the
quiet/splash flags to cmdline.txt + disable_splash=1 in config.txt.

cmdline.txt edits are idempotent: each flag only added if missing.
2026-05-13 03:21:37 +02:00
Mitchell R
b42e972fcf feat(deploy): client|server|both positional arg for installer
Pick install mode at the command line instead of multiple SKIP_* env
flags. Defaults to 'both' so existing single-host usage is unchanged.
2026-05-13 03:17:55 +02:00
Mitchell R
bfbaa72022 feat(deploy): single-file end-to-end installer/updater
Expand setup-pi-kiosk.sh to be the one-and-only entry point: clones (or
git-pulls) the repo into the invoking user's home, installs Docker +
compose plugin + GTK/GStreamer/WebKit/cage/seatd + rustup (if missing),
brings up the docker-compose stack, builds the kiosk binary, and
provisions the bfkiosk user + cage PAM + systemd unit.

Every step is idempotent so re-running pulls latest, rebuilds, and
redeploys. SKIP_DOCKER / SKIP_KIOSK / SKIP_BUILD env flags let an
operator partition the work for kiosk-only or server-only hosts.
2026-05-13 03:16:40 +02:00
Mitchell R
5656d430ff fix(deploy): run cargo build under a login shell
sudo -u <user> cargo fails when cargo lives in ~/.cargo/bin and root's
PATH doesn't carry it. Switch to sudo -u <user> -i sh -c so the user's
.profile / cargo env is sourced.
2026-05-13 03:13:39 +02:00
Mitchell R
f320b680a1 feat(deploy): setup-pi-kiosk.sh builds + installs the kiosk binary
Script now installs GTK/GStreamer/webkit dev libs, runs cargo build
--release as the invoking user, then drops the binary at
/opt/betterframe/kiosk/betterframe-kiosk where the systemd unit
expects it. Set SKIP_BUILD=1 to bypass when iterating.
2026-05-13 03:13:15 +02:00
Mitchell R
81a64766ae feat(deploy): Pi kiosk bring-up via cage + low-priv bfkiosk user
Replace the user-mode kiosk service with a system unit that runs cage
(single-app Wayland compositor) on tty1 as a dedicated unprivileged
user. No desktop, no display manager, auto-restart on crash via
Restart=always.

setup-pi-kiosk.sh provisions the user, installs cage + seatd, disables
any display manager, points default.target at multi-user, drops the
PAM stack, and enables the service. Idempotent.

Screen wake "auto-login": with no DM and no lockscreen, DPMS-driven
sleep just turns the panel back on — the kiosk process is already
running.
2026-05-13 03:11:06 +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
5b380d4694 fix(nodered): parse JSON body in trigger nodes
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.
2026-05-13 03:07:22 +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
bfb5028001 fix(kiosk): complete hot/warm/cooling/cold state machine
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.
2026-05-13 02:59:22 +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
acb4a353f9 feat: bf-status (action + trigger) + bf-snapshot (action) nodes
- 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
2026-05-13 02:29:12 +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
44b0268def fix: nodered settings.js nodesDir → /usr/src/betterframe-nodes
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.
2026-05-13 02:00:40 +02:00
Mitchell R
896934ae84 feat: bake BF Node-RED nodes into nodered Docker image
- 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.
2026-05-13 01:57:26 +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
f40b730fe9 refactor: htmx audit — convert kiosk/display/label actions
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.
2026-05-13 01:37:15 +02:00
Mitchell R
766db445c4 fix: Dockerfile npm run build + htmx for layout switch buttons
- 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.
2026-05-13 01:32:25 +02:00
Mitchell R
5669222f48 feat: add 'Show' button per attached layout on display edit 2026-05-13 01:24:24 +02:00
Mitchell R
dc96e6c08c fix: gpio lines binding must be mut for read_event 2026-05-13 01:23:09 +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
1c0fe02fcf feat: layout switch push + idle/sleep timer + offline bundle cache
Layout switch push:
- POST /admin/kiosks/:id/layout/:layoutId — coordinator sends
  {type:"layout-switch", layout_id} via WS
- Kiosk renders specified layout from cached bundle
- KioskEditPage adds Switch Layout dropdown + button

Idle/sleep timer:
- thread_local LAST_ACTIVITY + IS_ASLEEP + CURRENT_LAYOUT_ID
- mark_activity() on render/switch/wake; wakes if asleep
- glib timeout_add_local every 1s checks elapsed:
  - elapsed >= idle_timeout AND not on default + resets_idle_timer
    → switch to default layout
  - elapsed >= sleep_timeout AND !asleep → cec::standby()
- Display idle/sleep timeouts from bundle.display

Offline cache:
- server::save_bundle → ~/.betterframe-kiosk/bundle.json
- server::load_cached_bundle on offline boot
- fetch_bundle no longer panics; returns Option
- 30s retry loop until server reachable
- Reload-bundle gracefully handles fetch failures
2026-05-13 01:00:11 +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
9679ae7eb1 ui: default kiosk background to black 2026-05-11 11:50:45 +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
5a67c80caa fix: fire CEC AND DPMS unconditionally for power commands
Pi5 cec-ctl returns ok even when monitor ignores CEC. Doing both
covers TVs (CEC) and monitors (DPMS) without detection logic.
Both idempotent.
2026-05-11 11:18:31 +02:00
Mitchell R
0cd508a2ec chore: remove unused legacy stream_uri method 2026-05-11 11:06:24 +02:00
Mitchell R
29b7e30844 feat: auto stream selection + M/S badge overlay
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.
2026-05-11 11:05:38 +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
346ddfa3a4
fix(proxy): strip Node-RED ingress bases
Keep the two external ingress flows at /in/public and /in/kiosk while allowing Node-RED routes to stay path-local, such as /test1.
2026-05-11 10:38:32 +02:00
Mitchell R
02412169a0
fix(deploy): make Docker the service runtime
Remove host daemon deployment for server, proxy, and Node-RED so Node-RED is only reachable through the Compose proxy boundary.
2026-05-11 10:08:33 +02:00
Mitchell R
96d7cc45ba
fix(deploy): require proxied local services
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.
2026-05-11 09:51:00 +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
b3c17a9d53
fix(deploy): gate proxied runtime routes 2026-05-11 08:57:55 +02:00
Mitchell R
e38c92f753
fix(power): add monitor fallback checks 2026-05-11 08:55:42 +02:00