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
248 lines
8.4 KiB
Go
248 lines
8.4 KiB
Go
package cameras
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Running the checks head office asks for.
|
||
//
|
||
// Both answers come from the engine, which already knows how to give them and
|
||
// already phrases them for whoever is standing next to the camera. Nothing here
|
||
// re-words a verdict; it carries one.
|
||
|
||
// Job is one check the shop PC has been asked to run.
|
||
type Job struct {
|
||
CameraID string `json:"camera_id"`
|
||
Kind string `json:"kind"`
|
||
Seconds int `json:"seconds"`
|
||
}
|
||
|
||
// Result is what it found.
|
||
type Result struct {
|
||
CameraID string `json:"camera_id"`
|
||
OK bool `json:"ok"`
|
||
Verdict string `json:"verdict,omitempty"`
|
||
Headline string `json:"headline,omitempty"`
|
||
Advice []string `json:"advice,omitempty"`
|
||
Detail map[string]any `json:"detail,omitempty"`
|
||
ImageKey string `json:"image_key,omitempty"`
|
||
}
|
||
|
||
// Prober is the half of the engine that answers "does this camera work".
|
||
type Prober interface {
|
||
// Test opens the stream once and lets go, returning a frame. The engine's
|
||
// probe checks TCP reachability first, so a wrong address answers in
|
||
// milliseconds instead of the ~75 s an FFmpeg connect would take.
|
||
Test(ctx context.Context, cam Local) (TestResult, error)
|
||
// Placement watches for `seconds` and judges whether a person walking past
|
||
// produced a view worth enrolling.
|
||
Placement(ctx context.Context, cameraID string, seconds int) (map[string]any, error)
|
||
}
|
||
|
||
type TestResult struct {
|
||
OK bool `json:"ok"`
|
||
Error string `json:"error,omitempty"`
|
||
Width int `json:"width,omitempty"`
|
||
Height int `json:"height,omitempty"`
|
||
// Snapshot is base64 JPEG, the operator's proof that the camera is
|
||
// pointing where they think it is.
|
||
Snapshot string `json:"snapshot_jpeg_b64,omitempty"`
|
||
}
|
||
|
||
// Checks is the client for the job queue.
|
||
type Checks interface {
|
||
Pending(ctx context.Context) ([]Job, error)
|
||
Submit(ctx context.Context, res Result) error
|
||
}
|
||
|
||
// runChecks picks up whatever head office has asked for and answers it.
|
||
//
|
||
// Called from the same sync loop as configuration, so a check requested at head
|
||
// office is picked up on the next tick. Deliberately not its own faster poll: a
|
||
// placement check needs a human to walk about anyway, so shaving a minute off
|
||
// the request buys nothing an operator would notice.
|
||
func (s *Syncer) runChecks(ctx context.Context, desired []Desired) {
|
||
if s.Checks == nil || s.Prober == nil {
|
||
return
|
||
}
|
||
jobs, err := s.Checks.Pending(ctx)
|
||
if err != nil {
|
||
s.logf("camera checks: %v", err)
|
||
return
|
||
}
|
||
for _, job := range jobs {
|
||
res := s.runOne(ctx, job, desired)
|
||
if err := s.Checks.Submit(ctx, res); err != nil {
|
||
// Nothing to retry against: the server released the claim on a
|
||
// timeout, so the operator's next press starts a fresh one. Losing
|
||
// a result is better than a queue of stale verdicts.
|
||
s.logf("camera %s: could not report the check: %v", job.CameraID, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Syncer) runOne(ctx context.Context, job Job, desired []Desired) Result {
|
||
res := Result{CameraID: job.CameraID}
|
||
|
||
local, err := s.Engine.List(ctx)
|
||
if err != nil {
|
||
res.Headline = "the recognition software on this PC is not responding"
|
||
res.Advice = []string{"Open Behavision on the shop's PC and make sure it is started."}
|
||
return res
|
||
}
|
||
var cam Local
|
||
var found bool
|
||
for _, c := range local {
|
||
if c.ID == job.CameraID {
|
||
cam, found = c, true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
// The camera exists at head office but the PC has not applied it yet.
|
||
// Honest, and it tells the operator to wait rather than to go and look
|
||
// at the cabling.
|
||
res.Headline = "this PC has not set up that camera yet"
|
||
res.Advice = []string{"It is applied within a couple of minutes of being added. Try again shortly."}
|
||
return res
|
||
}
|
||
|
||
// The engine never returns a camera password - by design, it reports
|
||
// `has_password` and nothing else - so probing with what it hands back
|
||
// dials the camera with an empty credential. That failed, and reported
|
||
// "could not open stream - check the host, port, path and credentials"
|
||
// about a camera the same PC had been streaming for an hour, with advice
|
||
// sending the installer to check the very credential that was never sent.
|
||
//
|
||
// Head office has the real one, and this sync already fetched it.
|
||
if cam.Password == "" {
|
||
for _, d := range desired {
|
||
if d.CameraID == job.CameraID {
|
||
cam.Password = d.Password
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
switch job.Kind {
|
||
case "placement":
|
||
return s.runPlacement(ctx, job, cam)
|
||
default:
|
||
return s.runConnection(ctx, job, cam)
|
||
}
|
||
}
|
||
|
||
func (s *Syncer) runConnection(ctx context.Context, job Job, cam Local) Result {
|
||
res := Result{CameraID: job.CameraID}
|
||
|
||
out, err := s.Prober.Test(ctx, cam)
|
||
if err != nil {
|
||
res.Headline = "could not test the camera: " + err.Error()
|
||
return res
|
||
}
|
||
if !out.OK {
|
||
res.Verdict = "unreachable"
|
||
// The engine's own sentence. It distinguishes a refused connection from
|
||
// a wrong path from a stream that opens and never sends a frame, and
|
||
// those need three different things done about them.
|
||
res.Headline = out.Error
|
||
res.Advice = adviceFor(out.Error)
|
||
return res
|
||
}
|
||
|
||
res.OK = true
|
||
res.Verdict = "reachable"
|
||
res.Headline = fmt.Sprintf("connected — %d×%d", out.Width, out.Height)
|
||
res.Detail = map[string]any{"width": out.Width, "height": out.Height}
|
||
res.Advice = []string{
|
||
"Check the picture below is the view you expect.",
|
||
"Then run a walk-past check to prove faces here can actually be recognised.",
|
||
}
|
||
if out.Snapshot != "" {
|
||
if jpeg, err := base64.StdEncoding.DecodeString(out.Snapshot); err == nil {
|
||
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
|
||
res.ImageKey = key
|
||
} else {
|
||
// A missing picture does not invalidate the result: the camera
|
||
// still connected, which is what was asked.
|
||
s.logf("camera %s: check snapshot upload failed: %v", job.CameraID, err)
|
||
}
|
||
}
|
||
}
|
||
return res
|
||
}
|
||
|
||
func (s *Syncer) runPlacement(ctx context.Context, job Job, cam Local) Result {
|
||
res := Result{CameraID: job.CameraID}
|
||
|
||
seconds := job.Seconds
|
||
if seconds <= 0 {
|
||
seconds = 25
|
||
}
|
||
// Room for the watch itself plus the engine's own overhead. Without the
|
||
// margin the context dies at the exact moment the verdict is computed.
|
||
ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds+30)*time.Second)
|
||
defer cancel()
|
||
|
||
report, err := s.Prober.Placement(ctx, job.CameraID, seconds)
|
||
if err != nil {
|
||
res.Headline = "the walk-past check could not be run: " + err.Error()
|
||
return res
|
||
}
|
||
res.Detail = report
|
||
res.Verdict, _ = report["verdict"].(string)
|
||
res.Headline, _ = report["headline"].(string)
|
||
if adv, ok := report["advice"].([]any); ok {
|
||
for _, a := range adv {
|
||
if str, ok := a.(string); ok {
|
||
res.Advice = append(res.Advice, str)
|
||
}
|
||
}
|
||
}
|
||
// Only `good` is a pass. `marginal` means half the visitors are silently
|
||
// discarded, which is not a working camera - calling it one is how a site
|
||
// gets signed off and discovered three weeks later from a footfall report
|
||
// that was always zero.
|
||
res.OK = res.Verdict == "good"
|
||
return res
|
||
}
|
||
|
||
// adviceFor turns the engine's diagnosis into the next thing to do.
|
||
//
|
||
// Matched on the engine's own wording rather than an error code, because the
|
||
// engine returns prose - and prose that is already correct. This adds the
|
||
// action, it does not restate the problem.
|
||
func adviceFor(engineError string) []string {
|
||
msg := strings.ToLower(engineError)
|
||
switch {
|
||
case strings.Contains(msg, "refused"):
|
||
return []string{
|
||
"Something answered at that address but refused the connection.",
|
||
"The port is usually 554 for an RTSP camera. Check the port first.",
|
||
}
|
||
case strings.Contains(msg, "unreachable"), strings.Contains(msg, "timed out"),
|
||
strings.Contains(msg, "no route"):
|
||
return []string{
|
||
"Nothing answered at that address from the shop's PC.",
|
||
"Check the camera is powered on and plugged into the same network as the PC.",
|
||
"Confirm the address in the camera's own app or on its label.",
|
||
}
|
||
case strings.Contains(msg, "could not open"):
|
||
return []string{
|
||
"The address is reachable but the stream would not open.",
|
||
"This is usually the stream path or the camera's username and password.",
|
||
"Pick your camera's make above to fill in the usual path for it.",
|
||
}
|
||
case strings.Contains(msg, "no frame"):
|
||
return []string{
|
||
"The camera accepted the connection but sent no picture.",
|
||
"Some cameras only allow one viewer at a time — close any app watching it.",
|
||
}
|
||
}
|
||
return []string{"Check the address, port, stream path, username and password."}
|
||
}
|