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
321 lines
11 KiB
Go
321 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// Proving a camera works, and proving a site works.
|
|
//
|
|
// The engine already answers both questions and already phrases its answers for
|
|
// whoever is standing next to the camera. Nothing here re-words them; this is
|
|
// the channel that was missing, plus the one judgement head office can make on
|
|
// its own - whether the shop PC is even talking to us.
|
|
|
|
const (
|
|
// A placement check asks somebody to walk through the frame and out of it.
|
|
// Under ~15 s and an installer has no time to do that; over ~60 s and they
|
|
// have wandered off.
|
|
minCheckSeconds = 15
|
|
defaultCheckSeconds = 25
|
|
maxCheckSeconds = 60
|
|
// A shop PC that claimed a check and never reported is assumed to have been
|
|
// restarted mid-check. Long enough that a slow placement run is not
|
|
// stolen from itself.
|
|
checkStaleAfter = 5 * time.Minute
|
|
)
|
|
|
|
func (s *Server) handleRequestCheck(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
if !p.CanManageSites() {
|
|
writeErr(w, http.StatusForbidden, "forbidden",
|
|
"Your account cannot run camera checks.")
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if !looksLikeUUID(id) {
|
|
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
|
return
|
|
}
|
|
var req CheckRequest
|
|
if err := decode(w, r, &req); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
switch req.Kind {
|
|
case "connection", "placement":
|
|
case "":
|
|
req.Kind = "connection"
|
|
default:
|
|
badRequest(w, `kind must be "connection" or "placement"`)
|
|
return
|
|
}
|
|
if req.Seconds == 0 {
|
|
req.Seconds = defaultCheckSeconds
|
|
}
|
|
if req.Seconds < minCheckSeconds || req.Seconds > maxCheckSeconds {
|
|
badRequest(w, fmt.Sprintf(
|
|
"a placement check runs for between %d and %d seconds - long enough "+
|
|
"to walk through the frame, short enough that nobody wanders off",
|
|
minCheckSeconds, maxCheckSeconds))
|
|
return
|
|
}
|
|
|
|
if err := s.Store.RequestCheck(r.Context(), p.ClientID, id, req.Kind, req.Seconds); err != nil {
|
|
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
|
return
|
|
}
|
|
cam, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
|
|
if err != nil {
|
|
s.serverError(w, "camera after check request", err)
|
|
return
|
|
}
|
|
cams := []Camera{cam}
|
|
s.attachSnapshots(cams)
|
|
// 202: the shop PC has not run it yet, and saying 200 would invite a client
|
|
// to read the (empty) result as the answer.
|
|
writeJSON(w, http.StatusAccepted, cams[0])
|
|
}
|
|
|
|
// ------------------------------------------------------------------ agent
|
|
|
|
func (s *Server) handleAgentChecks(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
|
// Release anything a previous run claimed and abandoned before handing out
|
|
// work, so a PC restarted mid-check picks its own job back up rather than
|
|
// leaving the camera showing "checking..." for ever.
|
|
if err := s.Store.ReleaseStaleChecks(r.Context(), checkStaleAfter); err != nil {
|
|
s.logf("ERROR releasing stale checks: %v", err)
|
|
}
|
|
jobs, err := s.Store.ClaimChecks(r.Context(), ap.SiteID)
|
|
if err != nil {
|
|
s.serverError(w, "claim checks", err)
|
|
return
|
|
}
|
|
if jobs == nil {
|
|
jobs = []AgentCheckJob{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"checks": jobs})
|
|
}
|
|
|
|
func (s *Server) handleAgentCheckResult(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
|
var res AgentCheckResult
|
|
if err := decode(w, r, &res); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
res.CameraID = cameraSlug(res.CameraID)
|
|
if res.CameraID == "" {
|
|
badRequest(w, "camera_id is required")
|
|
return
|
|
}
|
|
if err := s.Store.RecordCheckResult(r.Context(), ap.SiteID, res); err != nil {
|
|
s.serverError(w, "record check result", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ------------------------------------------------------- the site smoke test
|
|
|
|
// handleSiteCheck answers "is this shop working", end to end.
|
|
//
|
|
// Assembled entirely from what head office already knows, so it costs no round
|
|
// trip to the shop and works when the PC is off - which is itself one of the
|
|
// answers, and the one a footfall report cannot give.
|
|
//
|
|
// Ordered, and it stops judging once something fails: asking whether cameras
|
|
// see faces on a PC that is switched off produces an answer that means nothing,
|
|
// and printing it next to a real failure buries the real failure.
|
|
func (s *Server) handleSiteCheck(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
siteID := r.PathValue("site")
|
|
if !looksLikeUUID(siteID) {
|
|
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
|
|
return
|
|
}
|
|
sites, err := s.Store.SiteHealth(r.Context(), p.ClientID)
|
|
if err != nil {
|
|
s.serverError(w, "site check", err)
|
|
return
|
|
}
|
|
var site *SiteHealth
|
|
for i := range sites {
|
|
if sites[i].SiteID == siteID {
|
|
site = &sites[i]
|
|
break
|
|
}
|
|
}
|
|
if site == nil {
|
|
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
|
|
return
|
|
}
|
|
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
|
|
if err != nil {
|
|
s.serverError(w, "site check cameras", err)
|
|
return
|
|
}
|
|
|
|
out := SiteCheck{SiteID: site.SiteID, Site: site.Name,
|
|
Steps: BuildSiteSteps(site, cams, s.now())}
|
|
out.OK = true
|
|
for _, st := range out.Steps {
|
|
if st.Status != "pass" {
|
|
out.OK = false
|
|
break
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// buildSiteSteps is the whole judgement, in one place and with no I/O, so it
|
|
// can be tested against every combination without a database.
|
|
func BuildSiteSteps(site *SiteHealth, cams []Camera, now time.Time) []CheckStep {
|
|
steps := make([]CheckStep, 0, 5)
|
|
|
|
// 1. Is the shop PC talking to us at all? Everything below is unknowable
|
|
// until this passes, so a failure here stops the rest being judged.
|
|
pc := CheckStep{Name: "The shop's PC is online"}
|
|
switch {
|
|
case site.LastHeartbeatAt == "":
|
|
pc.Status, pc.Detail = "fail", "This PC has never reported in."
|
|
pc.Advice = "Install Behavision on the shop's PC and claim it with the enrolment code for this shop."
|
|
case !site.Online:
|
|
pc.Status = "fail"
|
|
pc.Detail = "Last heard from " + humanAgo(site.LastHeartbeatAt, now) + "."
|
|
pc.Advice = "Check the PC is switched on, and that it has internet."
|
|
default:
|
|
pc.Status, pc.Detail = "pass", "Reported in "+humanAgo(site.LastHeartbeatAt, now)+"."
|
|
}
|
|
steps = append(steps, pc)
|
|
if pc.Status == "fail" {
|
|
return append(steps, unknownStep("Cameras are connected"),
|
|
unknownStep("Cameras can recognise faces"),
|
|
unknownStep("Visits are reaching head office"))
|
|
}
|
|
|
|
// 2. Recognition running, and WHICH model - on a memory-starved box the
|
|
// large model loses the fallback chain and the process stays up anyway.
|
|
rec := CheckStep{Name: "Recognition is running"}
|
|
if site.RecognitionModel == "" {
|
|
rec.Status = "warn"
|
|
rec.Detail = "The PC is online but has not said which recognition model it loaded."
|
|
rec.Advice = "Open Behavision on the shop's PC and check it is started."
|
|
} else {
|
|
rec.Status, rec.Detail = "pass", "Using "+site.RecognitionModel+"."
|
|
}
|
|
steps = append(steps, rec)
|
|
|
|
// 3. Cameras connected.
|
|
cam := CheckStep{Name: "Cameras are connected"}
|
|
switch {
|
|
case len(cams) == 0:
|
|
cam.Status, cam.Detail = "fail", "No cameras have been set up for this shop."
|
|
cam.Advice = "Add a camera, then run this check again."
|
|
default:
|
|
var up, down, untested int
|
|
for _, c := range cams {
|
|
switch {
|
|
case c.Connected == nil:
|
|
untested++
|
|
case *c.Connected:
|
|
up++
|
|
default:
|
|
down++
|
|
}
|
|
}
|
|
switch {
|
|
case down > 0:
|
|
cam.Status = "fail"
|
|
cam.Detail = fmt.Sprintf("%d of %d cameras are not connecting.", down, len(cams))
|
|
cam.Advice = "Open the camera and use Check connection to see why."
|
|
case untested > 0 && up == 0:
|
|
cam.Status = "warn"
|
|
cam.Detail = fmt.Sprintf("%d camera(s) set up, none tried yet by the shop's PC.", untested)
|
|
cam.Advice = "Wait a couple of minutes, or use Check connection on a camera."
|
|
default:
|
|
cam.Status = "pass"
|
|
cam.Detail = fmt.Sprintf("%d of %d connected.", up, len(cams))
|
|
}
|
|
}
|
|
steps = append(steps, cam)
|
|
|
|
// 4. The question the whole product turns on, and the one Office1 failed
|
|
// silently for weeks: not "is a camera plugged in" but "does a person
|
|
// walking past produce a view good enough to recognise".
|
|
faces := CheckStep{Name: "Cameras can recognise faces"}
|
|
switch {
|
|
case site.FractionBelowGate > 0.5:
|
|
faces.Status = "fail"
|
|
faces.Detail = fmt.Sprintf(
|
|
"%.0f%% of the faces seen were too poor to recognise.",
|
|
site.FractionBelowGate*100)
|
|
faces.Advice = "The camera needs moving: face the way people walk in, at about head height."
|
|
case site.FractionBelowGate > 0.2:
|
|
faces.Status = "warn"
|
|
faces.Detail = fmt.Sprintf("%.0f%% of faces seen were too poor to recognise.",
|
|
site.FractionBelowGate*100)
|
|
faces.Advice = "Some visitors are being missed. Run a walk-past check on each camera."
|
|
case site.LastEventAt == "":
|
|
// Not a failure. A shop that has just been set up has seen nobody yet,
|
|
// and calling that broken sends an installer looking for a fault that
|
|
// does not exist.
|
|
faces.Status = "unknown"
|
|
faces.Detail = "Nobody has walked past yet."
|
|
faces.Advice = "Run a walk-past check on a camera to prove it before the shop opens."
|
|
default:
|
|
faces.Status = "pass"
|
|
faces.Detail = "Faces seen are good enough to recognise."
|
|
}
|
|
steps = append(steps, faces)
|
|
|
|
// 5. Do visits actually arrive? A shop can be recognising people perfectly
|
|
// and reporting none of it.
|
|
send := CheckStep{Name: "Visits are reaching head office"}
|
|
switch {
|
|
case site.Dropped > 0:
|
|
send.Status = "fail"
|
|
send.Detail = fmt.Sprintf("%d visits were lost - this PC was offline too long.", site.Dropped)
|
|
send.Advice = "Check this shop's internet. Those visits cannot be recovered."
|
|
case site.Queued > 20:
|
|
send.Status = "warn"
|
|
send.Detail = fmt.Sprintf("%d visits are waiting to be sent.", site.Queued)
|
|
send.Advice = "The PC is recording but not sending. Check its internet connection."
|
|
case site.LastEventAt == "":
|
|
send.Status = "unknown"
|
|
send.Detail = "No visits recorded yet."
|
|
default:
|
|
send.Status = "pass"
|
|
send.Detail = "Last visit received " + humanAgo(site.LastEventAt, now) + "."
|
|
}
|
|
steps = append(steps, send)
|
|
|
|
return steps
|
|
}
|
|
|
|
func unknownStep(name string) CheckStep {
|
|
return CheckStep{Name: name, Status: "unknown",
|
|
Detail: "Cannot be checked until the shop's PC is online."}
|
|
}
|
|
|
|
// humanAgo phrases a timestamp the way somebody reading a status page would say
|
|
// it. Deliberately vague at the top end: "3 days ago" is as actionable as
|
|
// "3 days and 4 hours ago" and far easier to scan.
|
|
func humanAgo(iso string, now time.Time) string {
|
|
t, err := time.Parse(time.RFC3339, iso)
|
|
if err != nil {
|
|
return "at an unknown time"
|
|
}
|
|
d := now.Sub(t)
|
|
switch {
|
|
case d < 90*time.Second:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
|
case d < 48*time.Hour:
|
|
return fmt.Sprintf("%d h ago", int(d.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
|
}
|
|
}
|