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.8 KiB
Go
248 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|