Commit graph

150 commits

Author SHA1 Message Date
Mitchell R
69450de009 fix: fallback to user-provided title when AbleSign API omits it
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:42:32 +02:00
Mitchell R
d6a52df27a feat: global Settings page for AbleSign + Cloud Cam account config
- New /admin/settings page with AbleSign account setup (API key) and
  link to Cloud Cams config
- Settings nav item in sidebar (gear icon, before Account)
- Removed AbleSign Config from AbleSign dropdown (now in Settings)
- AbleSign account delete redirects to Settings
- Cloud Cams nav item kept for its own CRUD page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:47:54 +02:00
Mitchell R
01fcb66402 fix: surface AbleSign screen creation errors instead of swallowing
Previously caught and silently ignored. Now shows error message on
the screens page so we can debug the pairing flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:41:13 +02:00
Mitchell R
10f5cf7fac fix: per-connection search_path + sidebar tenant dropdown
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
PG adapter: setSearchPath now stores schema name, runner applies
SET search_path on every connection checkout. Eliminates cross-request
schema bleed (previous: setSearchPath mutated shared connection state).

Middleware: always set search_path (removed 'public' skip condition).

Sidebar: tenant switcher dropdown at bottom, loaded via htmx from
/admin/_tenant_switcher. Hidden when only one tenant. Auto-submits
on change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:15:00 +02:00
Mitchell R
65de42d495 refactor: AbleSign UI — single account, screen detail, no kiosk assign
- Remove Accounts from AbleSign nav (one account per tenant)
- Screens page: create button, no kiosk assignment
- Screen detail page with config form
- Internal/External badge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:09:40 +02:00
Mitchell R
e0941f533d
feat: AbleSign dropdown nav + screens/content/playlists pages
- Sidebar: NavGroup component (details/summary) for AbleSign dropdown
  with Accounts, Screens, Content, Playlists sub-items
- Global screens page (/admin/ablesign/screens) — all screens across
  accounts with Internal/External badge
- Content page — aggregates media files + web apps from all accounts
- Playlists page — shows per-screen playlist items
- Auto-sync screens on account creation
- Internal/External: Internal = created via "Create & Pair" (has
  screenToken, gets entity). External = synced from AbleSign (no token,
  no entity, management-only). Only internal screens become entities.
- Entity creation only on Create & Pair path — not on sync or assign

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 01:47:17 +02:00
Mitchell R
73dbd9b6bf
feat: managed entities (read-only) + AbleSign auto-creates entity
- Entity type: add 'ablesign' to EntityType + CellContentType
- Entity.managed boolean: true for auto-created entities (camera sync,
  cloud cams, AbleSign). UI blocks editing managed entities.
- Entity.ablesign_screen_id: links to ablesign_screens row
- ensureCameraEntity now sets managed=true
- AbleSign screen creation auto-creates managed entity with
  web_url=player.ablesign.tv and ablesign_screen_id FK
- PG migration: alter entities CHECK constraint + add columns
- Entity edit route rejects POST for managed entities

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:08:19 +02:00
Mitchell R
c3bdcbce4c
feat: AbleSign digital signage integration
- DB: ablesign_accounts (api_key_encrypted, workspace_id) +
  ablesign_screens (ablesign_screen_id, kiosk assignment, orientation)
- API client: shared/ablesign.ts — list/register/update/delete screens,
  playlist CRUD, headless pairing (initiate player registration →
  register via admin API key → no UI shown on kiosk)
- Admin routes: account CRUD, screen sync from AbleSign API, headless
  screen creation (Create & Pair), kiosk assignment, remote delete
- Admin UI: AbleSign nav item, accounts page (add/sync/delete),
  screens page (add/assign to kiosk/delete) with kiosk dropdown
- Follows cloud camera pattern: encrypted credentials, sync from
  vendor API, assign to kiosks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:03:42 +02:00
Mitchell R
5ce526eb33
feat: audio controls, reboot button, update lock, ONVIF refresh
- Audio: kiosk/src/audio.rs — PipeWire/ALSA volume, mute, output
  selection. WS commands volume-set/volume-mute/audio-output.
  Heartbeat reports audio state. Admin UI volume buttons + mute.
- Reboot: admin button with confirmation, WS reboot command,
  kiosk runs systemctl reboot.
- Firmware update now reboots (not exit) to clear state fully.
- Update lock: FIRMWARE_LOCK + OS_UPDATE_LOCK mutexes prevent
  concurrent update attempts from heartbeat + WS paths.
- ONVIF: auto-refresh stale/failed subs (>24h or failed state),
  mark_event_received with proper epoch timestamp, parse Key
  section for PlateNumber.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 16:57:41 +02:00
Mitchell R
55b11f2ffa
fix: Node-RED event forwarding + parse ONVIF Key section (PlateNumber)
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
Server bridge was forwarding to raw topic paths that no Node-RED node
listens on. Now forwards to fixed routes: camera.event, onvif.event,
onvif.motion, onvif.anpr — matching what trigger nodes register.

ONVIF XML parser now extracts Key section SimpleItems (PlateNumber,
etc.) into the data map alongside Data section items. Previously only
parsed Source and Data, missing Key-section fields like plate numbers.

Node-RED trigger nodes: camera_id filter changed from Number() to
String() comparison for UUIDv7 compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:38:30 +02:00
Mitchell R
38c78c0bb5
fix: log validation errors with field detail + raw body on event reject
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>
2026-05-26 15:15:41 +02:00
Mitchell R
5d23079086
feat: add anyvali input validation to all external API endpoints
Create shared/api-schemas.ts with av.object schemas for:
- pair/initiate, pair/claim (pairing flow)
- kiosk/heartbeat (telemetry with displays, partitions, hwmon)
- kiosk/event (ONVIF/system events)
- kiosk/logs (batched log entries)
- firmware/applied, os/applied (update reports)
- auth/login, auth/totp, setup (admin auth)

Each endpoint now calls validateBody(Schema, body) which returns 400
on schema violation. All string fields have maxLength, numeric fields
have min/max ranges, arrays strip unknown keys. Rejects malformed
input before it reaches DB or business logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 14:03:58 +02:00
Mitchell R
515f7088cc
fix: graceful FK violation on event insert + kiosk stale file cleanup
- 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>
2026-05-26 13:56:24 +02:00
Mitchell R
9b4032ca8a
refactor: decommission SQLite + add UUIDv7 PK migration for existing PG
- 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>
2026-05-26 13:22:29 +02:00
Mitchell R
0c74e26e42
feat: expand BF_DATA on first boot + wire update progress banner + partition reporting
- 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>
2026-05-26 08:09:20 +02:00
Mitchell R
66653af360
feat: implement multi-tenant support with PG schema isolation
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>
2026-05-26 07:22:01 +02:00
Mitchell R
64f47a9a6b
refactor: migrate all auto-increment PKs to UUIDv7 text IDs
Replace SERIAL/AUTOINCREMENT integer primary keys with UUIDv7 text
IDs across all 15 entity tables (users, api_keys, displays, cameras,
camera_streams, layouts, layout_cells, entities, kiosks, labels,
kiosk_gpio_bindings, event_log, kiosk_logs, audit_log,
camera_event_subscriptions). SetupState keeps id=1 INTEGER singleton.

Changes:
- types.ts: all id fields number->string, all FK fields number->string
- mappers.ts: n(r["id"])->s(r["id"]) for PKs, nn()->sn() for nullable FKs
- repository.ts: import uuidv7, generate IDs before INSERT, remove
  RETURNING id, change all method signatures from number to string
- migrations-pg.ts: SERIAL->TEXT NOT NULL PRIMARY KEY, INTEGER FK->TEXT FK
- bundle.ts: all bundle interface IDs number->string
- pairing.ts, auth.ts: kioskId/userId types number->string
- coordinator-registry.ts: kioskId number->string
- audit.ts: actor_id number->string
- mqtt-bridge.ts: kioskId number->string in publish/subscribe
- All route handlers: Number(getRouterParam)->getRouterParam ?? ""
- admin-pages.tsx: template function params and Map types number->string
- kiosk/src/bundle.rs: flexible serde deserializer that accepts both
  u32 (old) and String (new) IDs for backward compatibility

Fresh PG database -- no data migration needed, just schema changes.
SQLite migrations unchanged (dev-only, recreate DB for UUIDv7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 07:11:45 +02:00
Mitchell R
69e51197bf
refactor(streams): store RTSP components separately for ONVIF cameras
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 (&amp;), 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>
2026-05-26 06:51:33 +02:00
Mitchell R
b6e929d2ad
fix: decode XML entities in ONVIF RTSP URIs
ONVIF returns XML with &amp; 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>
2026-05-26 06:44:25 +02:00
Mitchell R
1cf77f55c9
fix: deliver encrypt_key in claim response
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>
2026-05-26 05:36:41 +02:00
Mitchell R
24f9532adf
feat(events): mark subscriptions active on event receipt
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>
2026-05-26 05:04:11 +02:00
Mitchell R
b0f42d29c2
feat: pre-boot firmware self-update + public endpoints
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>
2026-05-26 04:16:17 +02:00
Mitchell R
dba06b63db
fix: require() to dynamic import() in ESM + event subscriptions
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>
2026-05-26 02:40:46 +02:00
Mitchell R
cce9b51887
feat(events): add persistent ONVIF event topic subscriptions with status tracking
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>
2026-05-26 02:38:43 +02:00
Mitchell R
aa068a32f1
feat(kiosk): double-verified auto-wipe on server-side deletion
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>
2026-05-26 02:17:50 +02:00
Mitchell R
c91f9cb450
feat(obs): add observability tracing throughout server
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>
2026-05-26 01:47:24 +02:00
Mitchell R
4880dc32fc
feat: onError always logs, onResponse logs status+time, fix debug
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>
2026-05-26 01:41:16 +02:00
Mitchell R
85f8456cf0
fix: onError uses init obs when request trace missing
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>
2026-05-26 01:34:14 +02:00
Mitchell R
925d9fd6dc
feat: pass request obs into claimPairing for traced logging
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>
2026-05-26 01:32:28 +02:00
Mitchell R
35d184a6dd
fix: use pluginCwd for static files + info log on request
Static file path now uses BSB pluginCwd instead of import.meta.dirname.
Added info log with method+path on every request via per-request trace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:20:17 +02:00
Mitchell R
57348b14ab
feat: per-request tracing via BSB createTrace
Each HTTP request gets a fresh BSB trace (not a child span of init).
onRequest creates trace, stores on event.context.obs. onError logs
with trace context. onResponse ends the trace. 4xx logged as warn,
5xx as error. H3EventContext typed with obs field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:02:26 +02:00
Mitchell R
0113e4e54a
feat: log 5xx errors to BSB observable via h3 onError
Both admin-http and api-http now log HTTP 500+ errors with status,
path, and error message to BSB observable (warn level). Makes
server-side errors visible in Coolify/container logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 00:55:17 +02:00
Mitchell R
2e88d891e1
fix(db): clean config field names under db: object
Removed redundant pg prefix — fields already nested under db:.
pgHost→host, pgPort→port, pgDatabase→database, pgUser→user,
pgPassword→password, pgPoolMax→poolMax, pgUrl→url.

Updated all 3 plugin schemas, shared DbConfig type, init.ts,
and sec-config template.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 05:12:53 +02:00
Mitchell R
664e6e1548
fix(build): inline db config schema for BSB schema extractor
BSB bsb-plugin-cli build extracts schemas statically and cannot
resolve cross-file imports. Inlined the anyvali db config schema
in each plugin's ConfigSchema. Shared DbConfig type stays in
shared/db/config.ts (type-only imports work fine).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 03:22:49 +02:00
Mitchell R
0479cb7b4b
refactor(db): move service-store from BSB plugin to shared/db library
Each service plugin now independently initializes its own DB connection
via shared/db/init.ts instead of depending on a central service-store
plugin. This removes the inter-plugin dependency ordering and the
plugin-registry singleton, making each service self-contained.

- Move db-adapter, repository, mappers, migrations, adapters to shared/db/
- Create shared/db/config.ts (reusable dbConfigSchema) and init.ts
- Delete service-store plugin and plugin-registry
- Add db config block to each service's ConfigSchema + sec-config template
- Move event_log purge timer into service-admin-http
- Update all import paths across shared modules and plugins

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 02:48:32 +02:00
Mitchell R
4062b8bb6f
fix(db): SQLite adapter coerce true/false to 1/0 in params
node:sqlite rejects JS booleans as bind params. SQLite adapter now
converts true→1, false→0 before binding. Mirrors the PG compat
approach from the other direction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 02:29:46 +02:00
Mitchell R
49d730cf7f
refactor: remove all process.env and envStr() from server code
All runtime config now flows exclusively through BSB plugin config
(this.config.*) or shared module parameters. No more env var overrides.

Changes:
- Delete shared/env-overrides.ts (envStr/envBool/envInt helpers)
- version.ts: remove env var chain, keep only .bf-version file + "dev"
- firmware.ts: replace BF_FIRMWARE_SIGNING_KEY env with config.signingKeyPem
  parameter, remove tryParsePrivateKey helper
- secrets.ts: replace process.env.CREDENTIALS_DIRECTORY with
  config.systemdCredsDir
- mqtt-bridge.ts: accept MqttConfig object instead of reading process.env
- service-store: replace envStr calls with this.config.*, build pgUrl from
  config fields, add pgPoolMax config
- pg-adapter.ts: accept poolMax constructor param instead of env var
- service-admin-http: add firmwareSigningKey, firmwareImportApiKey,
  otaImportApiKey, systemdCredsDir config fields; pass to shared modules
- middleware.ts: replace tokenMatchesEnv with tokenMatchesExpected using
  deps.firmwareImportApiKey/otaImportApiKey
- service-api-http: add mqttUrl/mqttUsername/mqttPassword/mqttTopicPrefix
  config fields; pass to initMqttBridge
- service-coordinator-ws: replace envStr calls with this.config.*
- sec-config.yaml: add all new config fields with sensible defaults
- docker-compose.coolify.yml: remove all BF_* env vars from server service

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 13:22:44 +02:00
Mitchell R
bab194a184
fix(db): add RETURNING id to all INSERTs for PG compat
PG doesn't populate lastInsertRowid without RETURNING clause.
SQLite 3.35+ also supports RETURNING. Added to all 14 INSERT
statements that use auto-generated integer IDs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 13:00:18 +02:00
Mitchell R
6e3a893421
fix(db): use native booleans instead of B() integer coercion
PG rejects integer 0/1 for BOOLEAN columns. Replaced all B() calls
with native JS booleans — works for both SQLite (coerces true→1,
false→0) and PG (native BOOLEAN). Removed B() import and PG adapter
coercion hack.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 12:55:04 +02:00
Mitchell R
5cefa04a45
fix(db): PG adapter coerce 0/1 to boolean for PG strict typing
PG rejects integer values for BOOLEAN columns. B() helper returns 0/1
for SQLite compat. PG adapter now converts 0→false, 1→true in params
before sending — safe for both INTEGER and BOOLEAN column types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 12:50:07 +02:00
Mitchell R
a9484d1dd7
feat(cloud-cameras): type=cloud + bidirectional sync + PG default
Cloud cameras are now a distinct type ('cloud') managed entirely by
sync. Bidirectional: cameras added in vendor cloud appear automatically,
removed cameras get deleted. Cloud cameras and their entities are
read-only in admin UI — no manual editing.

- Camera type CHECK widened to include 'cloud'
- New columns: cloud_account_id, cloud_vendor_camera_id,
  cloud_stream_url, cloud_stream_type
- Repo: upsertCloudCamera, deleteCloudCamerasNotIn,
  listCloudCamerasByAccount
- Sync replaces import: full reconciliation per account
- Hik-Connect: fetch HLS preview URLs via previewURLs endpoint
- Tuya: fetch stream URLs during sync (not just on demand)
- Kiosk API: GET /api/kiosk/cameras/:id/stream returns fresh
  relay URL from vendor (session-based URLs expire)
- Cloud cameras show read-only detail page with cloud badge
- Coolify compose: postgres 18 as default, BF_DB=postgres,
  server depends_on postgres healthy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 11:36:49 +02:00
Mitchell R
48a9e99eb2
fix(db): rewrite PG migrations to match final SQLite schema
PG migrations still had the original table structure (layouts with
template_id/display_id, layout_cells with region_name) that SQLite
dropped in v0.5. PG deploy would fail because repo code expects the
final schema.

Fixes: layouts table (removed template_id/display_id/is_default),
layout_cells (removed region_name), added display_layouts join table,
kiosks.encrypt_key_encrypted, entities.name UNIQUE, all missing
indexes (sessions active, event_log received, audit_log actor,
firmware version/arch unique), foreign keys on pairing_codes/
event_log/firmware/rollouts, kiosk_gpio_bindings.created_at +
CHECK constraints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 03:03:44 +02:00
Mitchell R
851274d05d
fix: PG cloud_accounts migration + rollout-safe cleanup + setup cursor
- Add cloud_accounts table to PostgreSQL tenant migrations (was only
  in SQLite).
- Artifact cleanup now skips releases referenced by active/queued/paused
  rollouts (CASCADE would delete the rollout).
- Add invisible cursor theme install to setup-pi-kiosk.sh (was only
  in pi-gen image build).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 02:59:27 +02:00
Mitchell R
1a87c97479
fix(kiosk): piwiz + cursor + migration backfill + artifact cleanup
Cursor: install theme as index.theme (XCursor spec) not just
cursor.theme. Add WLR_XCURSOR_THEME env var for wlroots compat.

Piwiz: broader purge (rpi-first-boot-wizard, raspi-config triggers,
profile.d scripts, firstrun.sh). Mark first-boot done via userconf
marker file.

Migration: add encrypt_key_encrypted, cloud_accounts, and ONVIF event
columns to catch-all backfill so PRAGMA user_version skips can't miss
them.

Artifact cleanup: delete yanked firmware/OS files + prune to 5 most
recent per channel. Runs every 6h. Stops disk from filling up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 02:56:56 +02:00
Mitchell R
1c16a1da07
fix(cloud-accounts): use Layout component + postgres 18 + npm bumps
Cloud accounts page was raw HTML with no sidebar/topbar. Converted to
jsx-htmx CloudAccountsPage component matching all other admin pages.
Docker postgres bumped 16→18. npm: @types/node, tsx, ws updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 02:46:33 +02:00
Mitchell R
565cd01ca6
feat(smart-url): step builder form in cell editor (add/remove/configure steps inline) 2026-05-23 02:35:57 +02:00
Mitchell R
af639b4d46
feat(cloud-accounts): admin page with add/test/sync/import/delete 2026-05-23 02:34:03 +02:00
Mitchell R
7206847c97
feat(layout-editor): visual drag-resize grid editor for layout cells
Browser-side layout editor (no build step, vanilla JS):
  - Click to select cells
  - Drag edges (right/bottom/corner handles) to resize col_span/row_span
  - Drag cells to reposition (row/col) with grid-aware snap
  - Visual feedback: selection outline, resize handle highlights, drag opacity

Server: POST /admin/layouts/:id/cells/:cellId/move route for drag-drop
repositioning. Existing /resize route handles span changes.

CSS: inline resize handle styles + selection state. Handles appear on
hover (6px edge bars + 12px corner square).

layout-editor.js loaded via /static/. Activates on any grid with
data-layout-editor="<layoutId>" attribute. Compatible with htmx —
re-initializes after swap via htmx:afterSettle listener.

data-cell-id attribute added to each .layout-cell div for JS targeting.
2026-05-23 02:28:42 +02:00
Mitchell R
f728b0002c
feat(cloud-cameras): Hik-Connect + Dahua + Tuya + Uniview + TP-Link integrations
Cloud camera platform integrations with provider interface pattern:

Framework (cloud-cameras/types.ts):
  - CloudCameraProvider interface: testCredentials, listCameras,
    getStreamUrl, credentialFields
  - CloudAccount model + vendor registry
  - Multiple accounts per vendor per tenant supported
  - All auth on server — kiosk only gets streaming URLs

Vendors:
  - Hik-Connect: token auth, device list via OpenAPI, local RTSP
    (cloud P2P relay requires native SDK — not supported yet)
  - Dahua: HTTP Basic/Digest against device ISAPI, channel enumeration,
    RTSP URL construction per channel
  - Tuya: OAuth2 + HMAC-SHA256, device list + stream allocation via
    IoT Cloud API, RTSP/HLS URL from allocate endpoint
  - Uniview: HTTP Basic against LightAPI, channel enumeration via
    /LAPI/V1.0/Channels, RTSP per channel
  - TP-Link: no cloud API, direct RTSP + TCP port probe for testing

DB: cloud_accounts table (SQLite migration) for storing encrypted
credentials per vendor per tenant.

Admin UI for account management TODO — provider framework + DB ready.
2026-05-23 02:25:44 +02:00
Mitchell R
82ef29a23d
feat(nodered): motion + ANPR + generic ONVIF event trigger nodes
Three new Node-RED trigger nodes in BetterFrame Triggers palette:

bf-trigger-motion (red) — fires on MotionAlarm, CellMotionDetector,
VideoAnalytics/Motion, FieldDetector topics. Outputs msg.active
(true/false) for motion start/stop. Camera ID filter optional.

bf-trigger-anpr (blue) — fires on LicensePlateRecognition, Plate,
ANPR, LPR, NumberPlate topics. Extracts msg.plate (string) and
msg.confidence (number) from vendor-specific payload fields
(Hikvision PlateNumber, Dahua plateNumber, etc.). Camera ID filter.

bf-trigger-event (green) — generic catch-all. Topic substring filter
+ camera ID filter. Outputs msg.source + msg.data as key-value objects
parsed from ONVIF SimpleItems. Use for line crossing, intrusion,
digital input, tamper, audio detection, or any unknown topic.

Server side: ONVIF events (source_type=onvif) now additionally forward
to the fixed onvif.event route so all three nodes receive events without
needing per-topic Node-RED route registration.
2026-05-23 02:17:05 +02:00