Files
Behavision/server/internal/store/api_cameras.go
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

292 lines
10 KiB
Go

package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/api"
)
// ErrNoSecrets is the API package's sentinel, aliased rather than redeclared.
//
// Two variables with the same text would compare unequal under errors.Is, so
// the handler's check would silently fall through to a 500 - the failure this
// error exists to replace with a sentence an operator can act on.
var ErrNoSecrets = api.ErrNoSecrets
const cameraCols = `
c.id::text, c.site_id::text, si.name, c.camera_id, c.label,
c.host, c.port, c.path, c.username, (c.password_enc IS NOT NULL),
c.max_width, c.tuning, c.enabled, c.revision,
c.connected, c.last_seen_at, c.snapshot_key, c.snapshot_at,
c.check_kind, c.check_requested_at, c.check_started_at, c.check_finished_at,
c.check_seconds, c.check_result, c.check_image_key`
func scanCamera(row pgx.Row) (api.Camera, error) {
var c api.Camera
var lastSeen, snapAt *time.Time
var snapKey string
var checkKind *string
var reqAt, startAt, finAt *time.Time
var checkSeconds int
var checkResult []byte
var checkImage string
if err := row.Scan(&c.ID, &c.SiteID, &c.Site, &c.CameraID, &c.Label,
&c.Host, &c.Port, &c.Path, &c.Username, &c.HasPassword,
&c.MaxWidth, &c.Tuning, &c.Enabled, &c.Revision,
&c.Connected, &lastSeen, &snapKey, &snapAt,
&checkKind, &reqAt, &startAt, &finAt,
&checkSeconds, &checkResult, &checkImage); err != nil {
return c, err
}
kind := ""
if checkKind != nil {
kind = *checkKind
}
// Carried on the camera rather than fetched separately: "is this camera set
// up" and "has anyone proved it works" are the same question to the person
// asking, and two requests to answer it is two chances for the screen to
// show a camera and its verdict from different moments.
c.Check = checkOf(kind, reqAt, startAt, finAt, checkSeconds, checkResult, checkImage)
if lastSeen != nil {
c.LastSeenAt = lastSeen.UTC().Format(time.RFC3339)
}
if snapAt != nil {
c.SnapshotAt = snapAt.UTC().Format(time.RFC3339)
}
// The KEY travels in ImageKey, which is json:"-", and the handler swaps it
// for a signed link. Same rule as an arrival's face.
c.Snapshot.Key = snapKey
return c, nil
}
// Cameras lists a tenant's cameras, optionally for one site.
//
// Never returns a password, and structurally cannot: the column is not in the
// select list at all, only whether it is set.
func (s *Store) Cameras(ctx context.Context, clientID, siteID string) ([]api.Camera, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+cameraCols+`
FROM site_cameras c
JOIN sites si ON si.id = c.site_id
WHERE c.client_id = $1 AND c.deleted_at IS NULL
AND ($2 = '' OR c.site_id = $2::uuid)
ORDER BY si.name, c.camera_id`, clientID, siteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []api.Camera
for rows.Next() {
c, err := scanCamera(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// SaveCamera creates or updates one camera and bumps its revision.
//
// The revision bump is what makes the agent's reconcile cheap: it compares one
// integer instead of diffing every field, so a sync on an unchanged site costs
// a single query and no engine calls.
func (s *Store) SaveCamera(ctx context.Context, clientID, siteID, cameraID string,
in api.CameraInput) (api.Camera, error) {
var out api.Camera
if in.Password != nil && *in.Password != "" && s.secrets == nil {
return out, ErrNoSecrets
}
// Site must belong to this tenant. Checked in SQL rather than trusted from
// the path: a site id is caller-supplied and this would otherwise write a
// camera into somebody else's shop.
var owns bool
if err := s.pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM sites WHERE id = $1::uuid AND client_id = $2::uuid)`,
siteID, clientID).Scan(&owns); err != nil {
return out, err
}
if !owns {
return out, pgx.ErrNoRows
}
var sealed []byte
if in.Password != nil && *in.Password != "" {
// Sealed with the SITE id as additional data, so a row copied between
// sites in the database does not decrypt into a working credential.
b, err := s.secrets.SealString(*in.Password, siteID)
if err != nil {
return out, err
}
sealed = b
}
// COALESCE on every field: a nil pointer means "leave this alone". An
// operator editing a label must not blank the password, and the form does
// not send one because the API never gave it back.
row := s.pool.QueryRow(ctx, `
INSERT INTO site_cameras (client_id, site_id, camera_id, label, host, port,
path, username, password_enc, max_width, tuning, enabled)
VALUES ($1::uuid, $2::uuid, $3,
COALESCE($4, ''), COALESCE($5, ''), COALESCE($6, 554),
COALESCE($7, '/'), COALESCE($8, ''), $9,
COALESCE($10, 1280), COALESCE($11, '{}'::jsonb), COALESCE($12, true))
ON CONFLICT (site_id, camera_id) DO UPDATE SET
label = COALESCE($4, site_cameras.label),
host = COALESCE($5, site_cameras.host),
port = COALESCE($6, site_cameras.port),
path = COALESCE($7, site_cameras.path),
username = COALESCE($8, site_cameras.username),
password_enc = COALESCE($9, site_cameras.password_enc),
max_width = COALESCE($10, site_cameras.max_width),
tuning = COALESCE($11, site_cameras.tuning),
enabled = COALESCE($12, site_cameras.enabled),
revision = site_cameras.revision + 1,
updated_at = now(),
-- Re-saving a deleted camera revives it. An operator adding back a
-- camera they removed should get their camera, not a unique-key
-- error about a row they cannot see.
deleted_at = NULL
RETURNING id`, clientID, siteID, cameraID,
in.Label, in.Host, in.Port, in.Path, in.Username, sealed,
in.MaxWidth, in.Tuning, in.Enabled)
var id string
if err := row.Scan(&id); err != nil {
return out, err
}
return s.CameraByID(ctx, clientID, id)
}
func (s *Store) CameraByID(ctx context.Context, clientID, id string) (api.Camera, error) {
return scanCamera(s.pool.QueryRow(ctx, `
SELECT `+cameraCols+`
FROM site_cameras c
JOIN sites si ON si.id = c.site_id
WHERE c.client_id = $1 AND c.id = $2::uuid AND c.deleted_at IS NULL`,
clientID, id))
}
// DeleteCamera tombstones a camera.
//
// A tombstone rather than a DELETE, because the agent adopts cameras it finds
// configured on the shop PC. A hard delete here would be undone on the next
// sync by the very camera the operator just removed - and they would have no
// idea why it kept coming back.
func (s *Store) DeleteCamera(ctx context.Context, clientID, id string) (api.Camera, error) {
cam, err := s.CameraByID(ctx, clientID, id)
if err != nil {
return cam, err
}
_, err = s.pool.Exec(ctx, `
UPDATE site_cameras
SET deleted_at = now(), revision = revision + 1, updated_at = now()
WHERE client_id = $1 AND id = $2::uuid`, clientID, id)
return cam, err
}
// AgentCameras is the desired configuration for one site, WITH passwords.
//
// The only route that decrypts them, and it is reachable only with that site's
// own agent token. Deleted cameras are included, flagged: the agent cannot
// distinguish "head office removed this" from "head office has not seen this
// yet" by absence, and would re-adopt what was just deleted.
func (s *Store) AgentCameras(ctx context.Context, siteID string) ([]api.AgentCamera, error) {
rows, err := s.pool.Query(ctx, `
SELECT camera_id, label, host, port, path, username, password_enc,
max_width, tuning, enabled, revision, (deleted_at IS NOT NULL)
FROM site_cameras
WHERE site_id = $1::uuid
ORDER BY camera_id`, siteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []api.AgentCamera
for rows.Next() {
var c api.AgentCamera
var sealed []byte
if err := rows.Scan(&c.CameraID, &c.Label, &c.Host, &c.Port, &c.Path,
&c.Username, &sealed, &c.MaxWidth, &c.Tuning, &c.Enabled,
&c.Revision, &c.Deleted); err != nil {
return nil, err
}
if len(sealed) > 0 && s.secrets != nil {
// A password that will not decrypt is sent as empty rather than
// failing the whole sync: one unreadable camera must not stop the
// other three being configured. The agent reports the connection
// failure, which is the symptom an operator can actually act on.
if pw, err := s.secrets.OpenString(sealed, siteID); err == nil {
c.Password = pw
} else {
s.auditFailed("camera password decrypt", err)
}
}
out = append(out, c)
}
return out, rows.Err()
}
// ApplyAgentReport records what a shop PC observes, and adopts any camera it
// is running that head office does not know about.
//
// Adoption is what makes turning this on safe. Every existing site already has
// cameras configured locally - including the office camera this was tested with
// - and a reconcile that only pushed downwards would delete all of them on
// first sync.
func (s *Store) ApplyAgentReport(ctx context.Context, clientID, siteID string,
rep api.AgentCameraReport) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
for _, cam := range rep.Adopt {
var sealed []byte
if cam.Password != "" && s.secrets != nil {
if b, err := s.secrets.SealString(cam.Password, siteID); err == nil {
sealed = b
}
}
// DO NOTHING on conflict, deliberately. Adoption must never overwrite
// head office's configuration with what the shop PC happens to hold -
// that would make an edit here silently revert on the next sync. It
// only fills in cameras nobody has configured centrally, tombstones
// included, so a deleted camera stays deleted.
if _, err := tx.Exec(ctx, `
INSERT INTO site_cameras (client_id, site_id, camera_id, label, host,
port, path, username, password_enc,
max_width, tuning, enabled)
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10,
COALESCE($11, '{}'::jsonb), $12)
ON CONFLICT (site_id, camera_id) DO NOTHING`,
clientID, siteID, cam.CameraID, cam.Label, cam.Host, cam.Port,
cam.Path, cam.Username, sealed, cam.MaxWidth, cam.Tuning,
cam.Enabled); err != nil {
return fmt.Errorf("adopt %q: %w", cam.CameraID, err)
}
}
for _, st := range rep.State {
if _, err := tx.Exec(ctx, `
UPDATE site_cameras
SET connected = $3, last_seen_at = now(),
snapshot_key = CASE WHEN $4 = '' THEN snapshot_key ELSE $4 END,
snapshot_at = CASE WHEN $4 = '' THEN snapshot_at ELSE now() END
WHERE site_id = $1::uuid AND camera_id = $2`,
siteID, st.CameraID, st.Connected, st.SnapshotKey); err != nil {
return fmt.Errorf("state %q: %w", st.CameraID, err)
}
}
return tx.Commit(ctx)
}