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
242 lines
7.1 KiB
Go
242 lines
7.1 KiB
Go
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")
|
|
}
|
|
}
|