Files
Behavision/server/migrations/005_cameras.sql
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

77 lines
3.6 KiB
PL/PgSQL

BEGIN;
-- Cameras, owned by head office rather than by the PC they run on.
--
-- Until now a camera existed only in `cameras.json` on one shop's disk, added
-- through the desktop app by somebody standing in that shop. That is fine for
-- the shop and impossible for the tenant: an owner onboarding a new store, or
-- fixing a camera in a branch they are not standing in, had no way to do it.
--
-- The shop PC stays the thing that CONNECTS to the camera - it is on the same
-- LAN, and nothing else can be - so this table is desired state that the agent
-- pulls and applies. The engine's own store remains the running config; these
-- two are reconciled, not merged.
CREATE TABLE site_cameras (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
-- The id the ENGINE knows this camera by, and the one that lands in
-- visits.camera_id. Stable for the life of the camera: renaming it would
-- orphan every visit already recorded against the old name.
camera_id text NOT NULL,
label text NOT NULL DEFAULT '',
-- Connection, split into parts rather than stored as one URL. The engine
-- builds the RTSP URL itself with percent-encoded credentials, because a
-- password containing '@' in a hand-assembled URL is the exact bug the
-- original project shipped.
host text NOT NULL DEFAULT '',
port integer NOT NULL DEFAULT 554,
path text NOT NULL DEFAULT '/',
username text NOT NULL DEFAULT '',
-- Encrypted, never hashed: the agent has to be able to USE it. Same
-- treatment as a site's broker password, and for the same reason.
--
-- This is a real widening of what the server holds. An RTSP credential is
-- a live path into the camera itself, and until now it lived only on the
-- shop PC under DPAPI. Putting it here is the price of onboarding a camera
-- from head office, and it is sealed with the site id as additional data so
-- a row copied between sites will not decrypt.
password_enc bytea,
max_width integer NOT NULL DEFAULT 1280,
tuning jsonb NOT NULL DEFAULT '{}'::jsonb,
enabled boolean NOT NULL DEFAULT true,
-- Bumped on every edit. The agent compares it to what it last applied, so
-- a reconcile is cheap when nothing has changed - which is almost always.
revision bigint NOT NULL DEFAULT 1,
-- Observed state, written by the agent. Kept beside desired state on
-- purpose: "this camera is configured" and "this camera is working" are
-- the two halves of the only question anyone asks about a camera, and
-- splitting them across tables makes the screen that answers it a join
-- nobody remembers to write.
connected boolean,
last_seen_at timestamptz,
-- The most recent frame, as an object key. NOT a URL, for the same reason
-- face images are keys: a stored URL is permanent and unrevocable.
snapshot_key text NOT NULL DEFAULT '',
snapshot_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- A tombstone, not a delete. The agent ADOPTS cameras it finds configured
-- locally, so a hard delete would be undone on the next sync by the very
-- camera the operator just removed.
deleted_at timestamptz,
UNIQUE (site_id, camera_id)
);
CREATE INDEX site_cameras_site_idx ON site_cameras (site_id) WHERE deleted_at IS NULL;
CREATE INDEX site_cameras_client_idx ON site_cameras (client_id) WHERE deleted_at IS NULL;
COMMIT;