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
77 lines
3.6 KiB
PL/PgSQL
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;
|