- Track subscribed_at timestamp per camera in SubStatus
- Fix mark_event_received to use epoch seconds (was OS version string)
- needs_refresh() returns true when any sub is failed/stopped or >24h old
- Heartbeat loop calls maybe_refresh_onvif() every 60s — reloads
cameras from cached bundle and restarts onvif_events::start() which
kills old generation threads and creates fresh PullPoint subscriptions
- mark_event_received called on each successful event forward
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
validateBody now extracts per-field error messages from anyvali issues.
Event endpoint logs the raw body (first 500 chars) on validation failure
so we can see exactly what the kiosk sends.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Event insert: if source_camera_id FK fails (stale kiosk sending old
integer IDs), retry with camera_id=NULL. Event still logs, just
without camera association. Stops 500 spam until kiosk updates.
- Kiosk cleanup on first healthy boot: remove stale OS update staging
files (>24h old) from /var/lib/betterframe/tmp/, and old firmware
.prev binaries (>7 days) from /opt/betterframe/kiosk/.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SET CONSTRAINTS ALL DEFERRED only works on DEFERRABLE constraints.
Ours aren't. Instead: save all FK definitions to jsonb array, drop
them all, do the id replacements unconstrained, re-add from saved
definitions. Same pattern as the type-conversion migration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds migration that finds all rows where id matches ^[0-9]+$ (legacy
integer IDs converted to text strings), generates a UUID for each,
updates all FK references dynamically via information_schema, then
updates the PK. Existing data (cameras, layouts, kiosks, etc.) gets
proper UUID IDs. New rows already use UUIDv7 from repository.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dynamic column detection only matched 'id' and '*_id' patterns.
firmware_releases.uploaded_by and similar FK columns use '_by' suffix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous migration hardcoded ALTER TABLE ADD CONSTRAINT for FK re-add,
but production DB may have different columns than CREATE TABLE schema
(api_keys had no user_id). Now uses _bf_add_fk() helper that checks
both source column and target column exist before adding FK. Skips
silently if either is missing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
setup_state is a singleton (INTEGER PK CHECK(id=1)), not an entity.
Converting its id to TEXT breaks the CHECK constraint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous migration hard-coded FK constraint names and missed
firmware_releases.uploaded_by, firmware_rollouts.created_by/release_id,
os_update_releases.uploaded_by, os_update_rollouts.created_by/release_id,
pairing_codes.consumed_by_kiosk_id, entities.camera_id.
Now uses information_schema to dynamically drop ALL FK constraints
before type conversion, and dynamically finds ALL integer id/*_id
columns to convert. No more missed FKs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Delete sqlite-adapter.ts and migrations.ts (SQLite path removed)
- Remove driver/sqlitePath from all config schemas + sec-config template
- init.ts now PG-only, no SQLite branch
- db-adapter.ts dialect narrowed to "postgres" only
- Add in-place UUIDv7 migration: detects INTEGER PKs in existing PG
databases, drops FK constraints, ALTER COLUMN TYPE to TEXT for all
15 entity tables + their FK columns, re-adds FK constraints. Idempotent
(skips if already TEXT). Existing integer IDs become string "1", "2"
etc — new inserts use proper UUIDv7 from repository.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- All bundle struct ID fields (kiosk_id, display_id, layout_id,
camera_id, stream_id, gpio_id) now String with de_flexible_id
deserializer accepting both JSON numbers and strings.
- PoolKey, DisplayState hashmap, WorkerMsg, ServerMsg all use String
IDs throughout. Zero u32 ID references remain.
- ONVIF event image proxy: kiosk detects PictureUri in event data,
downloads image from camera (basic/digest auth), base64 encodes,
attaches to event payload before forwarding to server.
- Add md5 crate for HTTP Digest auth on camera image fetch.
- ws_client: flexible_id_from_value helper for WS message ID parsing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add betterframe-expand-data systemd service: growpart + resize2fs on
BF_DATA (last partition) so it fills the full SD card on first boot.
Solves the "No space left on device" issue with OS update downloads.
- Change OS update staging dir from /var/tmp/betterframe to
/var/lib/betterframe/tmp (on BF_DATA partition, not rootfs).
- Wire firmware and OS update progress callbacks into the GTK overlay
banner — shows "OS Update v1.2.3: Downloading — 45%" etc.
- Add per-partition disk reporting in heartbeat (/, /boot/firmware,
/var/lib/betterframe) with total/used/free/percent.
- Display partition table on kiosk detail page in admin UI.
- PG + SQLite migrations for partitions_json column on kiosks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added WorkerMsg::UpdateProgress(Option<(label, percent)>) for
showing firmware/OS update progress as an overlay banner on the
display. Handler + label management in place. Actual progress
reporting from firmware.rs/os_update.rs to be wired next.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Version label was only on pairing screen. Now also shown on the
idle/awaiting-layout logo screen (bottom-left overlay).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SOAP errors now extract fault Reason/Text/Code from XML instead of
dumping raw envelope. Logs whether ONVIF password was decrypted
(has_pass=true/false). Added NTP config to pi-gen (pool.ntp.org +
Google/Cloudflare fallback) — WSSE PasswordDigest fails with clock
skew.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds tenant management for PostgreSQL deployments. Each tenant gets its
own PG schema (tenant_<slug>) with a full set of BetterFrame tables.
SQLite deployments stay single-tenant with no behavior change.
Key changes:
- Run PUBLIC_MIGRATIONS (tenants + global_admins tables) during PG init
- Auto-create "default" tenant (schema=public) on first boot
- createTenantSchema() runs TENANT_MIGRATIONS in a new PG schema
- DbAdapter.setSearchPath() for per-request schema switching (PG)
- Tenant CRUD in Repository (listTenants, create, update, delete)
- Middleware resolves bf_tenant cookie and sets search_path per request
- Admin UI: /admin/tenants with CRUD + tenant switching via cookie
- Tenant dropdown in topbar (Layout) when >1 tenant exists
- Tenant nav item in sidebar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ONVIF-discovered camera streams now store rtsp_host, rtsp_port, and
rtsp_path as separate columns instead of baking credentials into a
pre-built URL. This fixes XML entity issues (&), special character
password breakage, and credential duplication across streams.
Bundle generation builds the final playable URL at delivery time using
components + camera row credentials with proper URL encoding. Existing
RTSP-type cameras with only rtsp_uri continue to work unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ONVIF returns XML with & in URIs. GStreamer rtspsrc cant parse
these. Now decoded before storing in camera_streams. Fixes RTSP
Unauthorized for ONVIF-discovered cameras with query params.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Event subscriptions table now shows Source and Sink columns.
Camera detail page uses full width instead of max-width 700px.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ONVIF: only subscribe to cameras actually in layout cells, not all
bundle cameras. Purge warm camera pool entries for cameras removed
from the bundle entirely — immediate stop, no cooling period.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Credentials embedded in RTSP URL can skip digest negotiation on
some cameras. Now extract user:pass from URL, set as user-id/user-pw
properties on rtspsrc, pass clean URL as location.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Version label at bottom-left of pairing screen shows firmware
version (compile-time) and OS version (from /etc/betterframe/
os-version). Spinner removed from pairing screen per request.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After _check confirms key still valid, code fell through to parse
the bf_kiosk_deleted JSON as a KioskBundle causing parse error.
Now returns None to skip bundle processing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claimPairing returned kioskKey + clusterKey but NOT encryptKey.
Without it, kiosk cant decrypt ONVIF passwords in the bundle,
causing WSSE auth failure and HTTP 400 on all PullPoint
subscriptions. Now included in claim response and API output.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
HTTP 400 from camera gave no detail. Now includes first 500 chars
of response body in error message so Axiom shows the actual SOAP
fault reason.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mapper referenced these fields but type interface was missing them.
Caused tsc failure in Docker build.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kiosk_id only set during claim. After restart, loaded from disk,
kiosk_id stayed empty. Now set from bundle.kiosk_id after fetch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When /api/kiosk/event receives an ONVIF event, call
markEventReceived(camera_id, topic) to flip subscription
status from pending → active (orange → green in admin UI).
Also added event_source/event_sink fields to subscription mapper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Secrets dont auto-propagate to reusable workflows. Added
BF_AXIOM_KEY + BF_AXIOM_DATASET to both release.yml secrets
block and build.yml workflow_call.secrets declaration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Kiosk checks for stable firmware update before pairing. If available,
downloads + verifies + swaps binary and restarts. No auth needed.
Server: GET /api/firmware/public/check (stable channel, no auth)
GET /api/firmware/public/download/:id (rate-limited, no auth)
Kiosk: check_public() + apply_public() in firmware.rs. Called from
ui.rs worker thread before entering pairing loop. kiosk_app_version
made pub for access from ui.rs.
Also includes kiosk_id deserialization fix (Value instead of String).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server returns kiosk_id as integer (not yet migrated to UUIDv7).
ClaimResp.kiosk_id changed from Option<String> to Option<Value>
to handle both integer and string. This was causing a panic on
deserialization after successful pairing.
Also simplified Coolify version arg.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Nested dollar-brace defaults dont resolve in Docker Compose.
Use COOLIFY_GIT_COMMIT directly which Coolify always provides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
25% was too tight — firmware OTA writes to /opt/betterframe/kiosk/
on the rootfs and fills it. 50% headroom gives enough space for
the kiosk binary download + swap.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
routes-os-updates.ts used require() which fails in ESM. Changed to
dynamic import(). Also includes persistent event topic subscriptions
with status tracking (inactive/pending/active/failed), merge-only
refresh, and colored status dots in camera detail UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add camera_event_subscriptions table to track per-camera per-topic
subscription state (inactive/pending/active/failed). Refresh-events
handler now merges discovered topics instead of replacing, so topics
are never lost when a camera goes temporarily offline. Admin UI shows
colored status dots and last-event timestamps per topic, with a
"subscribe all inactive" button to queue subscriptions for kiosk pickup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server returns {bf_kiosk_deleted: true} (200) instead of 401 when
kiosk key not found on bundle/heartbeat. Kiosk then confirms via
GET /api/kiosk/_check — only wipes config if _check also returns
401. Prevents proxy glitches from nuking valid kiosks.
Flow: bf_kiosk_deleted signal → confirm via _check → 401 = wipe,
200 = ignore (false alarm).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PG BOOLEAN columns cant compare with integer literals. Five
queries used enabled = 1 in WHERE, causing boolean = integer
operator error on kiosk auth, bundle fetch, and heartbeat.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PG returns JSONB columns as native JS objects, not strings.
j() helper only handled strings via JSON.parse, returning the
fallback for objects. Now passes through objects/arrays directly.
Fixes pairing extras (kiosk_key_plaintext), capabilities, scopes,
and all other JSONB fields.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PG driver returns TIMESTAMPTZ as JS Date objects. The s() and sn()
mapper helpers only checked for typeof string, returning null/empty
for Date objects. This broke consumed_at check in pairing (always
null), expires_at comparisons (Invalid Date), and all other
timestamp fields.
Now: Date instances are converted to ISO strings via toISOString().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Repository _run/_get/_all now create child spans with db.statement
when an Observable is set via withObs(). Bundle generation and pairing
confirmation accept optional obs for span-based tracing. Key admin
route handlers (camera/layout/kiosk CRUD, cloud sync) log structured
info lines with actor and resource id. Kiosk API routes (heartbeat,
bundle, event, firmware check, OS check) log kiosk_id on entry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
onError: always log.error regardless of status code.
onResponse: log info with response status + duration in ms.
claimPairing: debug changed to info (debug not working in BSB).
Timestamps tracked via _startMs on event context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
If event.context.obs not set, fall back to init-level obs and
flag no request trace in error message.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claimPairing now receives the request Observable and logs the
specific reason for pending (not_found/expired/not_consumed/
missing_key). Success logged at info level with kiosk_id.
All logs correlated via request trace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>