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
275 lines
9.0 KiB
Go
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
|
|
}
|