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
This commit is contained in:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

View File

@@ -0,0 +1,291 @@
// Package cameras keeps a shop PC's cameras in step with head office.
//
// The split is forced by the network, not by taste: only this PC is on the
// camera's LAN, so only this PC can connect to it — but the person onboarding a
// camera is often in an office somewhere else. So head office holds the DESIRED
// configuration and the agent pulls it.
//
// Pull, never push. A shop PC sits behind a router with no inbound route, so it
// has to ask; and asking makes the whole thing idempotent — a sync that fails
// halfway is fixed by the next one rather than leaving two systems disagreeing.
package cameras
import (
"context"
"log"
"time"
)
// Engine is the local recognition engine's camera API.
type Engine interface {
List(ctx context.Context) ([]Local, error)
Add(ctx context.Context, cam Local) error
Update(ctx context.Context, id string, cam Local) error
Remove(ctx context.Context, id string) error
Snapshot(ctx context.Context, id string) ([]byte, error)
}
// Cloud is head office.
type Cloud interface {
Desired(ctx context.Context) ([]Desired, error)
Report(ctx context.Context, rep Report) error
UploadSnapshot(ctx context.Context, jpeg []byte) (key string, err error)
}
// Local is a camera as the engine holds it.
type Local struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Path string `json:"path,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
MaxWidth int `json:"max_width,omitempty"`
Tuning map[string]any `json:"tuning,omitempty"`
Connected bool `json:"connected"`
}
// Desired is a camera as head office holds it.
type Desired struct {
CameraID string `json:"camera_id"`
Label string `json:"label"`
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
Username string `json:"username"`
Password string `json:"password"`
MaxWidth int `json:"max_width"`
Tuning map[string]any `json:"tuning"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
Deleted bool `json:"deleted"`
}
type State struct {
CameraID string `json:"camera_id"`
Connected bool `json:"connected"`
SnapshotKey string `json:"snapshot_key,omitempty"`
}
type Report struct {
State []State `json:"state,omitempty"`
Adopt []Desired `json:"adopt,omitempty"`
}
// Syncer reconciles the two, on a timer.
type Syncer struct {
Engine Engine
Cloud Cloud
Log *log.Logger
// Every how often to reconcile configuration. Cameras change rarely, and
// each sync is a database read on the server for every site in the estate,
// so this is minutes rather than seconds.
Interval time.Duration
// How often to send a fresh picture of each camera. A shop floor does not
// change much, and each frame is a few tens of kilobytes uploaded over the
// same connection the visits have to travel on.
SnapshotEvery time.Duration
// Checks and Prober are the "prove this camera works" half. Both nil on a
// PC that has never been claimed, and the syncer simply skips that work
// rather than treating it as a failure.
Checks Checks
Prober Prober
// applied remembers the revision last pushed into the engine, so an
// unchanged site costs one request and no engine calls at all.
applied map[string]int64
}
const (
DefaultInterval = 2 * time.Minute
DefaultSnapshotEvery = 60 * time.Second
)
// New builds a fully wired Syncer from the two clients every caller already
// has.
//
// It exists because the four fields were assembled by hand at each call site
// and both of them - the headless agent and the desktop app - set Engine and
// Cloud and forgot Checks and Prober. runChecks returns silently when either
// is nil (correct: an unclaimed PC has neither), so pressing "Test connection"
// at head office left the camera saying "checking..." until the five-minute
// stale release, and then said nothing at all. No error, on either side.
//
// The same two objects satisfy all four interfaces, so there was never a
// reason for a caller to choose.
func New(eng *EngineClient, cloud *CloudClient, log *log.Logger) *Syncer {
return &Syncer{Engine: eng, Cloud: cloud, Checks: cloud, Prober: eng, Log: log}
}
// Run reconciles until ctx is cancelled.
func (s *Syncer) Run(ctx context.Context) {
interval, snapEvery := s.Interval, s.SnapshotEvery
if interval <= 0 {
interval = DefaultInterval
}
if snapEvery <= 0 {
snapEvery = DefaultSnapshotEvery
}
// Immediately on start, so a PC that has just been claimed picks up its
// cameras now rather than in two minutes.
s.Once(ctx)
config := time.NewTicker(interval)
defer config.Stop()
snaps := time.NewTicker(snapEvery)
defer snaps.Stop()
for {
select {
case <-ctx.Done():
return
case <-config.C:
s.Once(ctx)
case <-snaps.C:
s.report(ctx)
}
}
}
// Once performs one full reconcile: pull desired, apply, then report back.
func (s *Syncer) Once(ctx context.Context) {
if s.applied == nil {
s.applied = map[string]int64{}
}
desired, err := s.Cloud.Desired(ctx)
if err != nil {
// Not fatal and not even unusual: an unclaimed PC has no credentials
// and a disconnected one has no network. The engine keeps running the
// cameras it already has, which is the whole point of the local store.
s.logf("camera sync: %v", err)
return
}
local, err := s.Engine.List(ctx)
if err != nil {
s.logf("camera sync: engine unavailable: %v", err)
return
}
have := map[string]Local{}
for _, c := range local {
have[c.ID] = c
}
known := map[string]bool{}
for _, d := range desired {
known[d.CameraID] = true
_, exists := have[d.CameraID]
switch {
case d.Deleted || !d.Enabled:
if exists {
if err := s.Engine.Remove(ctx, d.CameraID); err != nil {
s.logf("camera %s: remove failed: %v", d.CameraID, err)
continue
}
s.logf("camera %s removed (head office)", d.CameraID)
}
delete(s.applied, d.CameraID)
case !exists:
if err := s.Engine.Add(ctx, toLocal(d)); err != nil {
s.logf("camera %s: add failed: %v", d.CameraID, err)
continue
}
s.applied[d.CameraID] = d.Revision
s.logf("camera %s added from head office", d.CameraID)
case s.applied[d.CameraID] != d.Revision:
// The revision is what keeps this cheap. Without it every sync
// would PATCH every camera, and a PATCH restarts the connection —
// so a healthy site would drop its own video every two minutes.
if err := s.Engine.Update(ctx, d.CameraID, toLocal(d)); err != nil {
s.logf("camera %s: update failed: %v", d.CameraID, err)
continue
}
s.applied[d.CameraID] = d.Revision
s.logf("camera %s updated to revision %d", d.CameraID, d.Revision)
}
}
// Anything running here that head office has never heard of gets offered
// up. Without this, switching the feature on would delete every camera an
// existing site is already running — including the one it was commissioned
// with. The server refuses to overwrite its own config with these, and
// keeps tombstones, so a deleted camera is not resurrected.
var adopt []Desired
for id, c := range have {
if known[id] {
continue
}
adopt = append(adopt, Desired{
CameraID: id, Label: orElse(c.Label, id), Host: c.Host,
Port: c.Port, Path: c.Path, Username: c.Username,
Password: c.Password, MaxWidth: c.MaxWidth, Tuning: c.Tuning,
Enabled: true,
})
}
s.reportWith(ctx, adopt)
// Last, so a check requested against a camera added in the same breath
// finds it already applied. Inside Once() rather than beside it in the
// loop, so it also runs on startup and cannot be called twice a tick.
s.runChecks(ctx, desired)
}
func (s *Syncer) report(ctx context.Context) { s.reportWith(ctx, nil) }
// reportWith sends observed state, and a fresh picture from each camera.
func (s *Syncer) reportWith(ctx context.Context, adopt []Desired) {
local, err := s.Engine.List(ctx)
if err != nil {
s.logf("camera report: engine unavailable: %v", err)
return
}
rep := Report{Adopt: adopt}
for _, c := range local {
st := State{CameraID: c.ID, Connected: c.Connected}
if c.Connected {
// A snapshot failure never blocks the state report. Knowing a
// camera is down matters far more than having a picture of it,
// and the picture is the part most likely to fail.
if jpeg, err := s.Engine.Snapshot(ctx, c.ID); err == nil && len(jpeg) > 0 {
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
st.SnapshotKey = key
} else {
s.logf("camera %s: snapshot upload failed: %v", c.ID, err)
}
}
}
rep.State = append(rep.State, st)
}
if len(rep.State) == 0 && len(rep.Adopt) == 0 {
return
}
if err := s.Cloud.Report(ctx, rep); err != nil {
s.logf("camera report: %v", err)
}
}
func toLocal(d Desired) Local {
return Local{
ID: d.CameraID, Label: d.Label, Host: d.Host, Port: d.Port,
Path: d.Path, Username: d.Username, Password: d.Password,
MaxWidth: d.MaxWidth, Tuning: d.Tuning,
}
}
func orElse(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
func (s *Syncer) logf(format string, args ...any) {
if s.Log != nil {
s.Log.Printf(format, args...)
}
}

View File

@@ -0,0 +1,241 @@
package cameras
import (
"context"
"errors"
"testing"
)
type fakeEngine struct {
cams map[string]Local
added []string
updated []string
removed []string
listErr error
snapshot []byte
}
func newEngine(cams ...Local) *fakeEngine {
m := map[string]Local{}
for _, c := range cams {
m[c.ID] = c
}
return &fakeEngine{cams: m, snapshot: []byte("\xff\xd8jpeg")}
}
func (f *fakeEngine) List(context.Context) ([]Local, error) {
if f.listErr != nil {
return nil, f.listErr
}
var out []Local
for _, c := range f.cams {
out = append(out, c)
}
return out, nil
}
func (f *fakeEngine) Add(_ context.Context, c Local) error {
f.cams[c.ID] = c
f.added = append(f.added, c.ID)
return nil
}
func (f *fakeEngine) Update(_ context.Context, id string, c Local) error {
c.ID = id
f.cams[id] = c
f.updated = append(f.updated, id)
return nil
}
func (f *fakeEngine) Remove(_ context.Context, id string) error {
delete(f.cams, id)
f.removed = append(f.removed, id)
return nil
}
func (f *fakeEngine) Snapshot(context.Context, string) ([]byte, error) {
return f.snapshot, nil
}
type fakeCloud struct {
desired []Desired
desiredErr error
reports []Report
uploads int
uploadErr error
}
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
return f.desired, f.desiredErr
}
func (f *fakeCloud) Report(_ context.Context, r Report) error {
f.reports = append(f.reports, r)
return nil
}
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
if f.uploadErr != nil {
return "", f.uploadErr
}
f.uploads++
return "snap/key.jpg", nil
}
func syncer(e *fakeEngine, c *fakeCloud) *Syncer {
return &Syncer{Engine: e, Cloud: c}
}
func TestACameraAddedAtHeadOfficeAppearsOnTheShopPC(t *testing.T) {
e, c := newEngine(), &fakeCloud{desired: []Desired{{
CameraID: "entrance", Label: "Entrance", Host: "192.168.0.138",
Port: 554, Path: "/ch0_0.264", Username: "admin", Password: "s3cret",
MaxWidth: 1280, Enabled: true, Revision: 1,
}}}
syncer(e, c).Once(context.Background())
got, ok := e.cams["entrance"]
if !ok {
t.Fatal("the camera was never created on the PC")
}
if got.Host != "192.168.0.138" || got.Password != "s3cret" {
t.Fatalf("connection details did not travel: %+v", got)
}
}
// The whole reason adoption exists. Every existing site is already running
// cameras configured locally - including the office camera this was tested with
// - and a reconcile that only pushed downwards would delete all of them the
// first time it ran.
func TestACameraAlreadyRunningLocallyIsOfferedToHeadOfficeNotDeleted(t *testing.T) {
e := newEngine(Local{ID: "office", Label: "Office", Host: "192.168.0.138",
Port: 554, Username: "admin", Password: "s3cret", Connected: true})
c := &fakeCloud{}
syncer(e, c).Once(context.Background())
if _, ok := e.cams["office"]; !ok {
t.Fatal("an existing camera was deleted by the first sync")
}
if len(c.reports) == 0 || len(c.reports[0].Adopt) != 1 {
t.Fatalf("the camera was not offered for adoption: %+v", c.reports)
}
if got := c.reports[0].Adopt[0]; got.CameraID != "office" || got.Password != "s3cret" {
t.Fatalf("adoption dropped details the camera needs: %+v", got)
}
}
// A tombstone must win over adoption, or a deleted camera comes straight back
// on the next sync and the operator cannot work out why.
func TestADeletedCameraIsRemovedAndNotReadopted(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
c := &fakeCloud{desired: []Desired{
{CameraID: "entrance", Enabled: true, Revision: 3, Deleted: true},
}}
s := syncer(e, c)
s.Once(context.Background())
if _, ok := e.cams["entrance"]; ok {
t.Fatal("a camera deleted at head office is still running")
}
for _, r := range c.reports {
for _, a := range r.Adopt {
if a.CameraID == "entrance" {
t.Fatal("the deleted camera was offered back for adoption")
}
}
}
}
// A PATCH restarts the camera connection, so re-applying unchanged config every
// two minutes would make a healthy site drop its own video permanently.
func TestUnchangedConfigurationTouchesTheEngineOnce(t *testing.T) {
e := newEngine()
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
Enabled: true, Revision: 7}}}
s := syncer(e, c)
s.Once(context.Background())
s.Once(context.Background())
s.Once(context.Background())
if len(e.added) != 1 {
t.Fatalf("added %d times, want 1", len(e.added))
}
if len(e.updated) != 0 {
t.Fatalf("updated %d times with no change - every one restarts the stream", len(e.updated))
}
}
func TestANewRevisionIsApplied(t *testing.T) {
e := newEngine()
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
Enabled: true, Revision: 1}}}
s := syncer(e, c)
s.Once(context.Background())
c.desired[0].Host = "10.0.0.9"
c.desired[0].Revision = 2
s.Once(context.Background())
if len(e.updated) != 1 {
t.Fatalf("updated %d times, want 1", len(e.updated))
}
if e.cams["entrance"].Host != "10.0.0.9" {
t.Fatalf("the new address was not applied: %+v", e.cams["entrance"])
}
}
// An unclaimed PC, or one with no internet, must keep running the cameras it
// already has. Wiping local config because head office is unreachable would
// stop a shop recognising anybody for the duration of an outage.
func TestAnUnreachableHeadOfficeChangesNothingLocally(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
c := &fakeCloud{desiredErr: errors.New("this PC is not claimed by a company yet")}
syncer(e, c).Once(context.Background())
if _, ok := e.cams["entrance"]; !ok {
t.Fatal("local cameras were removed because the cloud was unreachable")
}
if len(e.removed) != 0 {
t.Fatalf("removed %v", e.removed)
}
}
// Knowing a camera is down matters far more than having a picture of it, and
// the picture is the part most likely to fail.
func TestAFailedSnapshotStillReportsWhetherTheCameraIsUp(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: true})
c := &fakeCloud{uploadErr: errors.New("bucket unreachable")}
syncer(e, c).Once(context.Background())
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
t.Fatalf("no state was reported: %+v", c.reports)
}
st := c.reports[0].State[0]
if !st.Connected {
t.Error("connected state was lost with the snapshot")
}
if st.SnapshotKey != "" {
t.Error("a failed upload reported a key anyway")
}
}
// No point photographing a camera that is not producing frames, and the attempt
// costs a request per sync per dead camera.
func TestADisconnectedCameraIsNotPhotographed(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: false})
c := &fakeCloud{}
syncer(e, c).Once(context.Background())
if c.uploads != 0 {
t.Fatalf("uploaded %d snapshots of a disconnected camera", c.uploads)
}
if c.reports[0].State[0].Connected {
t.Error("a disconnected camera was reported as up")
}
}
func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
e := newEngine()
e.listErr = errors.New("connection refused")
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Enabled: true, Revision: 1}}}
syncer(e, c).Once(context.Background()) // must not panic
if len(c.reports) != 0 {
t.Fatal("reported state it could not have observed")
}
}

247
agent/pkg/cameras/checks.go Normal file
View File

@@ -0,0 +1,247 @@
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."}
}

View File

@@ -0,0 +1,247 @@
package cameras
import (
"context"
"errors"
"strings"
"testing"
)
type fakeProber struct {
test TestResult
testErr error
placement map[string]any
placeErr error
placedFor int
// testedWith records the camera the probe was actually handed, which is
// where the credential either arrives or does not.
testedWith Local
}
func (f *fakeProber) Test(_ context.Context, cam Local) (TestResult, error) {
f.testedWith = cam
return f.test, f.testErr
}
func (f *fakeProber) Placement(_ context.Context, _ string, seconds int) (map[string]any, error) {
f.placedFor = seconds
return f.placement, f.placeErr
}
type fakeChecks struct {
jobs []Job
submitted []Result
pendErr error
}
func (f *fakeChecks) Pending(context.Context) ([]Job, error) { return f.jobs, f.pendErr }
func (f *fakeChecks) Submit(_ context.Context, r Result) error {
f.submitted = append(f.submitted, r)
return nil
}
func checker(e *fakeEngine, p *fakeProber, c *fakeChecks) *Syncer {
return &Syncer{Engine: e, Cloud: &fakeCloud{}, Prober: p, Checks: c}
}
func TestAReachableCameraReportsItsResolutionAndAPicture(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Connected: true})
p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080,
Snapshot: "/9j/4AAQSkZJRg=="}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(), nil)
if len(c.submitted) != 1 {
t.Fatalf("submitted %d results", len(c.submitted))
}
got := c.submitted[0]
if !got.OK {
t.Fatalf("a working camera reported as failing: %+v", got)
}
if !strings.Contains(got.Headline, "1920") {
t.Errorf("headline does not say what was found: %q", got.Headline)
}
if got.ImageKey == "" {
t.Error("no picture uploaded, so the operator cannot see what it is pointing at")
}
}
// The engine already tells three different failures apart, and each needs a
// different thing done about it. Carrying its sentence and adding the action is
// the whole design; re-wording it here would be a fourth description of the
// same fault.
func TestEachConnectionFailureGetsItsOwnAdvice(t *testing.T) {
cases := map[string]string{
"connection refused": "port",
"host unreachable": "powered on",
"could not open stream - check the host, port, path and credentials": "stream path",
"connected but no frame arrived within 12s": "one viewer at a time",
}
for engineErr, want := range cases {
e := newEngine(Local{ID: "entrance", Connected: true})
p := &fakeProber{test: TestResult{OK: false, Error: engineErr}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(), nil)
got := c.submitted[0]
if got.OK {
t.Errorf("%q reported as a pass", engineErr)
}
// The engine's own sentence must survive intact.
if got.Headline != engineErr {
t.Errorf("headline %q, want the engine's own words %q", got.Headline, engineErr)
}
joined := strings.ToLower(strings.Join(got.Advice, " "))
if !strings.Contains(joined, want) {
t.Errorf("%q -> advice %q, expected it to mention %q", engineErr, joined, want)
}
}
}
// Only `good` is a pass. `marginal` means half the visitors are silently
// discarded, and signing that off as working is exactly how a site runs for
// weeks recognising almost nobody.
func TestOnlyAGoodPlacementCounts(t *testing.T) {
for verdict, wantOK := range map[string]bool{
"good": true, "marginal": false, "poor": false,
"no_faces": false, "artifact": false, "inconclusive": false,
"no_completed_passes": false,
} {
e := newEngine(Local{ID: "entrance", Connected: true})
p := &fakeProber{placement: map[string]any{
"verdict": verdict, "headline": "h",
"advice": []any{"do the thing"},
}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}}
checker(e, p, c).runChecks(context.Background(), nil)
if got := c.submitted[0].OK; got != wantOK {
t.Errorf("verdict %q -> ok=%v, want %v", verdict, got, wantOK)
}
}
}
// The engine's advice is written for the person standing next to the camera.
// It must reach them.
func TestThePlacementAdviceIsCarriedThroughVerbatim(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: true})
p := &fakeProber{placement: map[string]any{
"verdict": "poor",
"headline": "most visitors here cannot be recognised",
"advice": []any{
"Face the camera the way people walk in, at about head height.",
"Re-run this check after moving it.",
},
"faces": float64(11),
}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}}
checker(e, p, c).runChecks(context.Background(), nil)
got := c.submitted[0]
if got.Headline != "most visitors here cannot be recognised" {
t.Errorf("headline changed: %q", got.Headline)
}
if len(got.Advice) != 2 || !strings.Contains(got.Advice[0], "head height") {
t.Errorf("advice did not survive: %+v", got.Advice)
}
// Everything else the engine said travels too, so a new field reaches the
// UI without a schema change on the way.
if got.Detail["faces"] != float64(11) {
t.Errorf("detail was dropped: %+v", got.Detail)
}
if p.placedFor != 25 {
t.Errorf("watched for %ds, want 25", p.placedFor)
}
}
// A camera added at head office 30 seconds ago has not reached the PC yet.
// Telling the operator to check the cabling would send them to the wrong place.
func TestACameraTheShopPCHasNotAppliedYetSaysSo(t *testing.T) {
e := newEngine() // engine knows nothing about it
p := &fakeProber{}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(), nil)
got := c.submitted[0]
if got.OK {
t.Fatal("reported a pass for a camera that does not exist here")
}
if !strings.Contains(got.Headline, "not set up that camera yet") {
t.Errorf("headline blames the wrong thing: %q", got.Headline)
}
if !strings.Contains(strings.Join(got.Advice, " "), "Try again shortly") {
t.Errorf("advice does not tell them to wait: %+v", got.Advice)
}
}
// "The engine is not running" and "the camera is broken" need opposite actions.
func TestAStoppedEngineIsNotReportedAsABrokenCamera(t *testing.T) {
e := newEngine()
e.listErr = errors.New("connection refused")
p := &fakeProber{}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(), nil)
got := c.submitted[0]
if !strings.Contains(got.Headline, "not responding") {
t.Fatalf("headline blames the camera: %q", got.Headline)
}
if !strings.Contains(strings.Join(got.Advice, " "), "make sure it is started") {
t.Errorf("advice: %+v", got.Advice)
}
}
// An unclaimed PC has neither, and must not treat that as a fault.
func TestASyncerWithNoCheckSupportSkipsQuietly(t *testing.T) {
s := &Syncer{Engine: newEngine(), Cloud: &fakeCloud{}}
s.runChecks(context.Background(), nil) // must not panic
}
func TestNoPendingChecksSubmitsNothing(t *testing.T) {
c := &fakeChecks{}
checker(newEngine(), &fakeProber{}, c).runChecks(context.Background(), nil)
if len(c.submitted) != 0 {
t.Fatalf("submitted %d results with no jobs", len(c.submitted))
}
}
// The engine deliberately never returns a camera password - it reports
// has_password and nothing else - so probing with what the engine hands back
// dials the camera with an empty credential. That reported "could not open
// stream - check the host, port, path and credentials" about a camera the very
// same PC had been streaming for an hour, and sent the installer to check the
// one thing that had never been sent.
func TestTheProbeIsGivenThePasswordHeadOfficeHolds(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Username: "admin",
Connected: true}) // no Password: the engine does not return one
p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
desired := []Desired{{CameraID: "entrance", Username: "admin", Password: "hunter2"}}
checker(e, p, c).runChecks(context.Background(), desired)
if p.testedWith.Password != "hunter2" {
t.Fatalf("the probe was handed password %q - a working camera would be "+
"reported unreachable", p.testedWith.Password)
}
}
// A password the engine DOES have is not overwritten by head office's copy:
// the local one is what the camera is actually being streamed with.
func TestALocalPasswordWins(t *testing.T) {
e := newEngine(Local{ID: "entrance", Password: "local", Connected: true})
p := &fakeProber{test: TestResult{OK: true}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(),
[]Desired{{CameraID: "entrance", Password: "remote"}})
if p.testedWith.Password != "local" {
t.Fatalf("probe used %q, want the engine's own", p.testedWith.Password)
}
}

View File

@@ -0,0 +1,263 @@
package cameras
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// EngineClient talks to the recognition engine on this PC's loopback.
type EngineClient struct {
Base string
User string
Password string
Client *http.Client
}
func NewEngineClient(base, user, password string) *EngineClient {
return &EngineClient{
Base: strings.TrimRight(base, "/"), User: user, Password: password,
// Generous, because adding a camera makes the engine dial it, and a
// wrong address takes the full RTSP timeout to fail. Shorter than that
// and every genuinely-bad camera looks like an engine fault instead.
Client: &http.Client{Timeout: 30 * time.Second},
}
}
func (e *EngineClient) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, e.Base+path, rdr)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if e.User != "" {
req.SetBasicAuth(e.User, e.Password)
}
resp, err := e.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The engine's message, not just a status. "camera stored but failed to
// start: connection refused" is something an operator can act on;
// "500" is not, and this string ends up in the agent log a support
// engineer reads.
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("engine %s: %s", resp.Status, strings.TrimSpace(string(msg)))
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
}
func (e *EngineClient) List(ctx context.Context) ([]Local, error) {
var out []Local
return out, e.do(ctx, http.MethodGet, "/api/cameras", nil, &out)
}
func (e *EngineClient) Add(ctx context.Context, cam Local) error {
return e.do(ctx, http.MethodPost, "/api/cameras", cam, nil)
}
func (e *EngineClient) Update(ctx context.Context, id string, cam Local) error {
// The engine takes the id from the path on PATCH and refuses it in the
// body, so it is cleared here rather than at the call site.
cam.ID = ""
return e.do(ctx, http.MethodPatch, "/api/cameras/"+url.PathEscape(id), cam, nil)
}
func (e *EngineClient) Remove(ctx context.Context, id string) error {
return e.do(ctx, http.MethodDelete, "/api/cameras/"+url.PathEscape(id), nil, nil)
}
// Snapshot fetches the most recent frame the engine holds.
//
// Not a fresh capture: the engine already keeps the latest frame in memory for
// its own MJPEG stream, so this costs a memory copy rather than a camera round
// trip. A camera that has not produced a frame yet answers 503, which is a
// normal state on a just-added camera and not an error worth logging loudly.
func (e *EngineClient) Snapshot(ctx context.Context, id string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
e.Base+"/api/cameras/"+url.PathEscape(id)+"/frame.jpg", nil)
if err != nil {
return nil, err
}
if e.User != "" {
req.SetBasicAuth(e.User, e.Password)
}
resp, err := e.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("engine %s", resp.Status)
}
// Bounded. A frame is tens of kilobytes; anything near this cap means
// something other than a JPEG is on the other end.
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}
// CloudClient talks to head office with this agent's own token.
type CloudClient struct {
Base string
Token string
Client *http.Client
// Upload is the existing image path: the server mints a presigned URL and
// the agent PUTs to it. Reused rather than reimplemented, so a shop PC
// still never holds bucket credentials — the reason that path exists.
Upload func(ctx context.Context, jpeg []byte) (string, error)
}
func NewCloudClient(base, token string) *CloudClient {
return &CloudClient{
Base: strings.TrimRight(base, "/"), Token: token,
Client: &http.Client{Timeout: 20 * time.Second},
}
}
func (c *CloudClient) do(ctx context.Context, method, path string, body, out any) error {
if c.Token == "" {
// An unclaimed PC. Said plainly, because this is the normal state
// between installing the software and typing an enrolment code, and it
// must not read as a fault in the log.
return fmt.Errorf("this PC is not claimed by a company yet")
}
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("head office: %s", resp.Status)
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out)
}
func (c *CloudClient) Desired(ctx context.Context) ([]Desired, error) {
var body struct {
Cameras []Desired `json:"cameras"`
}
if err := c.do(ctx, http.MethodGet, "/api/agent/cameras", nil, &body); err != nil {
return nil, err
}
return body.Cameras, nil
}
func (c *CloudClient) Report(ctx context.Context, rep Report) error {
return c.do(ctx, http.MethodPost, "/api/agent/cameras", rep, nil)
}
func (c *CloudClient) UploadSnapshot(ctx context.Context, jpeg []byte) (string, error) {
if c.Upload == nil {
return "", fmt.Errorf("images are not enabled for this deployment")
}
return c.Upload(ctx, jpeg)
}
// ---------------------------------------------------------------- probing
// Test opens the candidate stream once, without saving it.
//
// The engine does this as a sync handler in its threadpool because
// cv2.VideoCapture blocks hard, and it checks TCP reachability first - so a
// wrong address, which is the single most likely thing anybody types, answers
// in milliseconds rather than the ~75 s an FFmpeg connect takes to give up.
func (e *EngineClient) Test(ctx context.Context, cam Local) (TestResult, error) {
var out TestResult
// A generous ceiling: the engine's own deadline is 12 s for the frame plus
// 3 s to connect, and cutting it off earlier would report a timeout of our
// own making as if it were the camera's.
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
return out, e.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)
}
// Placement runs the engine's commissioning watch and returns its report whole.
//
// Polled rather than awaited: the engine starts the watch and answers
// immediately, so the run survives this request being retried, and the report
// arrives with `running: true` until it does not.
func (e *EngineClient) Placement(ctx context.Context, cameraID string, seconds int) (
map[string]any, error) {
path := "/api/cameras/" + url.PathEscape(cameraID) + "/commission"
var report map[string]any
if err := e.do(ctx, http.MethodPost, path,
map[string]any{"seconds": seconds}, &report); err != nil {
return nil, err
}
deadline := time.Now().Add(time.Duration(seconds+20) * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return report, ctx.Err()
case <-time.After(2 * time.Second):
}
var latest map[string]any
if err := e.do(ctx, http.MethodGet, path, nil, &latest); err != nil {
// Keep the last good report rather than losing the whole run to
// one failed poll - the engine may simply have been busy.
continue
}
report = latest
if running, _ := latest["running"].(bool); !running {
return report, nil
}
}
return report, nil
}
// ---------------------------------------------------------------- check jobs
func (c *CloudClient) Pending(ctx context.Context) ([]Job, error) {
var body struct {
Checks []Job `json:"checks"`
}
if err := c.do(ctx, http.MethodGet, "/api/agent/checks", nil, &body); err != nil {
return nil, err
}
return body.Checks, nil
}
func (c *CloudClient) Submit(ctx context.Context, res Result) error {
return c.do(ctx, http.MethodPost, "/api/agent/checks", res, nil)
}

View File

@@ -0,0 +1,29 @@
package cameras
import (
"log"
"testing"
)
// The bug this guards: both callers built the Syncer as a struct literal, set
// Engine and Cloud, and left Checks and Prober nil. runChecks returns silently
// when either is nil - correct for an unclaimed PC - so a camera check
// requested at head office was claimed by nobody and sat at "checking..." until
// the server released it five minutes later. Nothing logged, on either side.
func TestNewWiresEveryHalfOfTheSyncer(t *testing.T) {
eng := NewEngineClient("http://127.0.0.1:8010", "u", "p")
cloud := NewCloudClient("https://example.invalid", "token")
s := New(eng, cloud, log.Default())
if s.Engine == nil || s.Cloud == nil {
t.Fatal("configuration half not wired")
}
// The half that proves a camera works. A Syncer without these is not a
// broken Syncer, which is exactly why the omission was invisible.
if s.Checks == nil {
t.Error("Checks is nil: head office's camera checks would never be claimed")
}
if s.Prober == nil {
t.Error("Prober is nil: a claimed check could never be answered")
}
}