New kiosk/src/onvif_events.rs: for each ONVIF camera in the bundle,
creates a PullPoint subscription, polls every 3s, parses
NotificationMessage XML into structured JSON (topic + source key/values
+ data key/values + timestamp), and POSTs to /api/kiosk/event with
source_type=onvif + camera_id.
Forwards ALL event topics: motion, ANPR (LicensePlateRecognition),
line crossing, intrusion, digital input, analytics, tamper — everything
the camera exposes. Node-RED sorts what matters.
Subscription lifecycle:
- CreatePullPointSubscription with 60s InitialTerminationTime
- Renew every 55s before timeout
- Unsubscribe on bundle change / shutdown
- Auto-resubscribe on pull/renew failure (30s backoff)
- Generation tracking via Weak<()> so old workers self-terminate
when start() is called with a new bundle
WSSE PasswordDigest auth for SOAP calls — same scheme the server's
onvif.ts uses. sha1 crate added.
BundleCamera extended with onvif_host/port/username/password_encrypted
fields (server already ships them; kiosk just wasn't deserializing).
Gated by BF_ENABLE_ONVIF_EVENTS=1. Enabled by default in the pi-gen
image env file.
TODO: cluster-key-based decryption of onvif_password_encrypted. For
now relies on the RTSP URI having plaintext credentials embedded (which
the ONVIF import path already ensures via rtspWithCredentials).
New module kiosk/src/at_rest.rs. Derives an AES-256-GCM key via HKDF
from a Pi-bound value:
1. /proc/device-tree/serial-number (Pi 5 firmware exposes it)
2. /proc/cpuinfo Serial line (older kernels)
3. /etc/machine-id (non-Pi dev fallback)
File format: "BFE1" magic || 12-byte random nonce || ciphertext+tag.
Atomic write via tempfile + rename so a crash mid-write can't leave a
half-encrypted file.
Wired into kiosk/src/server.rs at every file I/O touching sensitive
state:
- kiosk.key (bearer token to BF server)
- local.key (LAN-side API auth key)
- bundle.json (cached bundle with RTSP credentials in URL form)
Migration: read paths tolerate legacy plaintext (kiosks upgraded from a
pre-at_rest build) AND re-store as ciphertext on the first read. One-
shot upgrade — subsequent boots skip the migration write.
Threat model defended: SD card extraction. Attacker who pulls the card
can't decrypt without also having the same physical Pi (CPU serial is
hardware-bound). Doesn't defeat an attacker who has both — at that
point they ARE the kiosk. Bar is raised from "trivially extract every
camera password" to "must steal the device intact."
Not defended: TPM-style attestation, remote attestation, sealed boot.
Pi 5 has no TPM and we don't ship a secure-boot config.
Tests in-module: round-trip short bytes, round-trip JSON, legacy
plaintext passthrough.
Phase 3 of the OS OTA pipeline. New module kiosk/src/os_update.rs polls
/api/kiosk/os/check with the kiosk's compatibility string and current OS
version (read from /etc/betterframe/os-compatibility +
/etc/betterframe/os-version, both written by the image build), downloads
the bundle, sha256-verifies the transport, and hands off to
`rauc install`. RAUC takes it from there: CMS signature verify against
/etc/rauc/keyring.pem, copy into inactive A/B slot, arm tryboot via the
custom bootloader backend, return. We then post /api/kiosk/os/applied
and `systemctl reboot` into the new slot.
Wired into the existing 60s heartbeat loop in ui.rs, gated by
BF_ENABLE_OS_OTA=1 (default OFF so dev kiosks on non-A/B images don't
keep trying + failing). Runs BEFORE the kiosk-binary check on each tick
so an OS bundle that ships an updated kiosk binary doesn't race the
firmware path.
On clean-boot heartbeat success we now also call `rauc status
mark-good` so the boot-attempts counter resets — three bad boots in a
row will auto-roll back without us needing a separate rollback path.
What's NOT in this commit:
- A/B partition layout in the pi-gen image (task #6, blocks actual
deployment — bundles can be served + accepted but `rauc install`
will refuse without two valid slots).
- Admin UI for managing releases + rollouts (task #4).
When admin opens an entity preview, find a kiosk whose active layout
references the camera (new repo.listKiosksRenderingCamera). Probe each
candidate's LAN snapshot endpoint with a 4s timeout. On success, stream
the bytes back with x-bf-snapshot-source: kiosk:<id>. Falls through to
the existing server-direct ffmpeg/gst pull only when no kiosk is reachable
or has the camera in its active layout.
Kiosk side adds /local/snapshot/:camera_id?key=<local_key>. Spawns a
one-shot gst-launch (rtspsrc → decodebin → jpegenc ! filesink
num-buffers=1) on a blocking worker so axum's reactor stays free.
Prefers sub stream for snapshots to keep bandwidth low. Single-frame
pipeline tears down after the first JPEG.
LAN IP picking extracted to shared/kiosk-lan.ts so route handler and
KioskLocalPanel agree on which interface to talk to (the previously-
duplicated logic in admin-pages stays for now since it also renders the
interface list).
Why a parallel pipeline instead of teeing the warm one: cross-thread
gtk4paintablesink → appsink sample extraction is non-trivial. A 1-frame
parallel pull is cheap when the kiosk's RTSP session to that camera is
already known to work (precondition: it's in the active layout).
WebView showed a grey/black half-square in the top-left — that's GDK's
"none" cursor rendered by WebKit's own surface, which ignores the
GTK-window CSS we set elsewhere. Inject a WebKit UserStyleSheet that
applies cursor:none !important to every page + frame at User priority,
overriding page-author CSS.
For the boot gap (cage start → first kiosk frame), set XCURSOR_SIZE=1
and WLR_NO_HARDWARE_CURSORS=1 in the systemd unit. SW fallback honors
the 1-pixel size; HW cursors don't, which is why a default arrow leaks
through on some Pi GPUs.
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.
Reverts misdiagnosis. pi-gen defaults to trixie since the Debian 13
release, which has gtk4 4.14 + libwebkitgtk-6.0 stock — no backports
needed. Build container, kiosk gtk feature gate, and pi-gen target all
realigned to trixie.
Actual reason last image run failed: our custom stage was missing the
mandatory prerun.sh (pi-gen calls it to seed ROOTFS_DIR from the
previous stage) and the EXPORT_IMAGE marker file (signals 'bake an
image at the end of this stage'). Both added.
Asset upload now globs deploy/*.img.xz so any extra exports stage2
produces ship alongside our customised one.
- WorkerMsg made pub + re-exported at crate root so local_server can send
through the UI channel.
- ed25519_dalek::pkcs8::DecodePublicKey trait import — needed for
VerifyingKey::from_public_key_pem call site.
- Workflow: pushes to master now auto-trigger a dev-channel build (in
addition to tag-pushes for stable/beta). Concurrency group cancels
superseded master builds; tag builds never cancel each other.
Pi OS Bookworm + Debian bookworm both ship libgtk-4 4.8.3. No code in
the kiosk uses 4.12+ APIs (compute_bounds, WidgetPaintable, Picture,
add_tick_callback, Fixed, set_content_fit are all <= 4.8). Swap
gtk4 feature v4_12 → v4_8 and drop the bookworm-backports juggling
in CI.
- server Dockerfile installs wget — bookworm-slim doesn't include it
by default, so the healthcheck CMD silently failed → Coolify marked
the container unhealthy.
- nodered healthcheck swapped to /nrdp/ (always 200 when runtime up)
via wget --spider; previous /nrdp/auth/login returned non-2xx when
adminAuth disabled.
- start_period bumped to 90s for nodered's flow load on smaller hosts.
- Kiosk discovery: cloud fallback now frame-eu.betterportal.net per
the managed-fleet endpoint.
When the active layout switches, cells that exist in both old + new (same
camera, same URL, same HTML) now slide + scale from their old screen
position to the new one over 350ms (ease-out cubic). Fresh cells fade in;
removed cells fade out where they were.
Implementation:
- Each cell widget gets a stable widget_name (cam:<id>:<selector>,
web:<url>, html:<hash>) so old/new can be matched.
- Before swap, capture each cell's bounds + a WidgetPaintable snapshot.
- New grid wrapped in an Overlay; a Fixed ghost layer hosts the animated
Picture widgets driven by add_tick_callback + ease-out cubic.
- Once the window finishes the animation timer, the overlay is unwrapped
back to a plain grid so subsequent renders don't accumulate layers.
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.
Web and HTML cells were rebuilt + reloaded on every layout switch,
losing JS state and incurring a full page load each time. Mirror the
camera pool: hold WebViews in WARM_WEBVIEWS keyed by URL (or hash of
inline HTML), reuse on switch-back, unparent + cool on switch-away,
drop after the same cooling timer. Identical content in two layouts
shares one WebView.
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.
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.
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.
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.
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.
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
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.
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.
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