Files
Behavision/server/internal/api/handlers_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

275 lines
9.0 KiB
Go

package api
import (
"errors"
"net/http"
"strings"
"time"
)
// Cameras, onboarded from head office instead of from the shop floor.
//
// The shop PC remains the thing that CONNECTS to a camera - it is on the same
// LAN and nothing else can be - so these routes write desired state that the
// agent pulls and applies. Two audiences, two shapes: a tenant never receives
// a camera password, and an agent receives one only for its own site.
// A snapshot is refreshed every minute or so, so a link outliving that is
// pointless; short enough that one in a screenshot is worthless by the time
// anyone reads it.
const snapshotTTL = 5 * time.Minute
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
siteID := trim(r.URL.Query().Get("site_id"))
if siteID != "" && !looksLikeUUID(siteID) {
badRequest(w, "site_id must be a site identifier")
return
}
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
if err != nil {
s.serverError(w, "cameras", err)
return
}
if cams == nil {
cams = []Camera{}
}
s.attachSnapshots(cams)
writeJSON(w, http.StatusOK, cams)
}
// attachSnapshots swaps each camera's object key for a signed link.
//
// A snapshot is a frame of a shop floor, so it gets the same treatment as a
// face: a short-lived signed URL, never a stored one. Absence is data - most
// deployments store no images at all, and a camera that is merely new has no
// frame yet.
func (s *Server) attachSnapshots(cams []Camera) {
for i := range cams {
key := cams[i].Snapshot.Key
cams[i].Snapshot.Key = ""
switch {
case s.Blob == nil:
cams[i].Snapshot.Reason = "This system is not storing images."
case key == "":
cams[i].Snapshot.Reason = "No picture from this camera yet."
default:
url, err := s.Blob.PresignGet(key, snapshotTTL)
if err != nil {
s.logf("ERROR presign snapshot: %v", err)
cams[i].Snapshot.Reason = "That picture could not be loaded."
continue
}
cams[i].Snapshot = Image{Available: true, URL: url,
ExpiresIn: int(snapshotTTL.Seconds())}
}
}
}
func (s *Server) handleCreateCamera(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change camera settings.")
return
}
siteID := r.PathValue("site")
if !looksLikeUUID(siteID) {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
var in CameraInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
id := ""
if in.CameraID != nil {
id = cameraSlug(*in.CameraID)
}
if id == "" && in.Label != nil {
id = cameraSlug(*in.Label)
}
if id == "" {
badRequest(w, "give the camera a name, such as Entrance")
return
}
if in.Label == nil || trim(*in.Label) == "" {
in.Label = &id
}
if msg, ok := cameraProblem(in); !ok {
badRequest(w, msg)
return
}
s.saveCamera(w, r, p.ClientID, siteID, id, in, http.StatusCreated)
}
func (s *Server) handleUpdateCamera(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change camera settings.")
return
}
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
existing, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
if err != nil {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
var in CameraInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
// The camera id is what visits are recorded against. Renaming it would
// orphan every visit already attributed to the old name, so the label is
// the thing an operator may change.
in.CameraID = nil
s.saveCamera(w, r, p.ClientID, existing.SiteID, existing.CameraID, in, http.StatusOK)
}
func (s *Server) saveCamera(w http.ResponseWriter, r *http.Request,
clientID, siteID, cameraID string, in CameraInput, code int) {
p := PrincipalFrom(r.Context())
cam, err := s.Store.SaveCamera(r.Context(), clientID, siteID, cameraID, in)
if err != nil {
if errors.Is(err, ErrNoSecrets) {
// A camera saved with its password silently dropped is a camera
// that will not connect, and the operator could not tell that from
// a wrong password.
writeErr(w, http.StatusServiceUnavailable, "no_secret_key", err.Error())
return
}
if strings.Contains(err.Error(), "no rows") {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
s.serverError(w, "save camera", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: clientID, ActorID: p.UserID, ActorKind: "user",
Action: "camera.save", Entity: "camera", EntityID: cam.ID,
Detail: map[string]any{"camera_id": cam.CameraID, "site_id": siteID},
})
// Through the slice, not around it. `attachSnapshots([]Camera{cam})` would
// decorate a COPY and then serialise the untouched original, so a created
// camera came back with an empty snapshot object and no reason - the one
// field whose whole job is to say why there is no picture.
cams := []Camera{cam}
s.attachSnapshots(cams)
writeJSON(w, code, cams[0])
}
func (s *Server) handleDeleteCamera(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change camera settings.")
return
}
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
cam, err := s.Store.DeleteCamera(r.Context(), p.ClientID, id)
if err != nil {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "camera.delete", Entity: "camera", EntityID: cam.ID,
Detail: map[string]any{"camera_id": cam.CameraID},
})
w.WriteHeader(http.StatusNoContent)
}
// ------------------------------------------------------------------ agent
// handleAgentCameras is the shop PC asking what it should be running.
//
// Authenticated by the agent's own token, and scoped to that agent's site by
// the credential rather than by anything in the request - a site id a caller
// could set would hand one shop another shop's camera passwords.
func (s *Server) handleAgentCameras(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
cams, err := s.Store.AgentCameras(r.Context(), ap.SiteID)
if err != nil {
s.serverError(w, "agent cameras", err)
return
}
if cams == nil {
cams = []AgentCamera{}
}
writeJSON(w, http.StatusOK, map[string]any{"cameras": cams})
}
// handleAgentCameraReport records what the shop PC observes and adopts any
// camera it is running that head office does not know about.
func (s *Server) handleAgentCameraReport(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
var rep AgentCameraReport
if err := decode(w, r, &rep); err != nil {
badRequest(w, err.Error())
return
}
for i := range rep.Adopt {
// Slugged here as well as on the operator path. An agent is trusted to
// report its own site, not to choose an identifier that could collide
// with a topic segment or a path.
rep.Adopt[i].CameraID = cameraSlug(rep.Adopt[i].CameraID)
}
// ClientID, not Client. AgentPrincipal carries both the tenant's uuid and
// its human slug, and the slug is the one that reads correctly in a log
// line - which is exactly why it gets used by mistake in a query that wants
// the uuid.
if err := s.Store.ApplyAgentReport(r.Context(), ap.ClientID, ap.SiteID, rep); err != nil {
s.serverError(w, "agent camera report", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------- helpers
// cameraSlug normalises the id the engine will know this camera by.
//
// It ends up in an object key, a URL path and a topic segment, so it is
// restricted to characters that cannot change what any of those mean.
func cameraSlug(s string) string {
var b strings.Builder
lastDash := true
for _, r := range strings.ToLower(trim(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case !lastDash:
b.WriteByte('-')
lastDash = true
}
}
return clip(strings.Trim(b.String(), "-"), 64)
}
// cameraProblem rejects a camera that cannot possibly connect, with the
// sentence an operator needs rather than a validation code.
func cameraProblem(in CameraInput) (string, bool) {
if in.Host == nil || trim(*in.Host) == "" {
return "the camera needs an address on the shop's network, such as 192.168.0.138", false
}
if in.Port != nil && (*in.Port < 1 || *in.Port > 65535) {
return "the port must be between 1 and 65535 - RTSP cameras are usually 554", false
}
if in.MaxWidth != nil && *in.MaxWidth < 320 {
return "frames narrower than 320 pixels are too small to recognise a face in", false
}
return "", true
}