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, ok := s.resolveCamera(w, r, r.PathValue("id")) if !ok { 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, ok := s.resolveSite(w, r, r.PathValue("site")) if !ok { 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)) } }