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:
279
agent/pkg/bridge/bridge.go
Normal file
279
agent/pkg/bridge/bridge.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Package bridge turns engine detections into queued MQTT messages.
|
||||
//
|
||||
// This is the link that was missing: the engine detects a person and fires an
|
||||
// event onto its own bus; nothing turned that into something the server would
|
||||
// ever see. The engine already has a webhook sink, so the agent listens on
|
||||
// loopback and points `events.webhook_url` at itself.
|
||||
//
|
||||
// A webhook rather than the agent polling the engine: polling would either miss
|
||||
// events between polls or need cursor state the engine does not keep, and the
|
||||
// sink already exists and already runs off the hot path.
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Queue is the durable spool, reduced to what the bridge needs.
|
||||
type Queue interface {
|
||||
Append(topic string, payload any) error
|
||||
}
|
||||
|
||||
// Embeddings fetches an identity's template from the engine.
|
||||
//
|
||||
// The event bus deliberately does not carry embeddings — a 512-float template
|
||||
// on the bus would reach the log sink and the email sink too — so the bridge
|
||||
// asks for it separately, once per identity.
|
||||
type Embeddings interface {
|
||||
Embedding(ctx context.Context, identityID int64) (vector []float32, model string, err error)
|
||||
}
|
||||
|
||||
// Event is the engine's wire shape (behavision/events.py).
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
CameraID string `json:"camera_id"`
|
||||
TS float64 `json:"ts"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
type Bridge struct {
|
||||
Queue Queue
|
||||
Embeddings Embeddings
|
||||
// TopicPrefix is "bv/<client>.<site>". The broker enforces that a site can
|
||||
// only publish under its own, so an empty one means this PC is not claimed
|
||||
// yet and events stay on disk rather than being addressed to nowhere.
|
||||
TopicPrefix string
|
||||
Log *log.Logger
|
||||
// Uploader sends face images to object storage. Nil when the engine is not
|
||||
// writing them, which is the default.
|
||||
Uploader Uploader
|
||||
// Wake, when set, is rung after a visit reaches the queue so the pump
|
||||
// drains it now instead of on its next idle tick. That tick is two seconds,
|
||||
// and it sits squarely on the path between a person walking in and their
|
||||
// face appearing on a screen - the one delay in this chain that costs
|
||||
// nothing to remove.
|
||||
//
|
||||
// Must not block: it runs on the engine's webhook request, so a slow pump
|
||||
// would apply backpressure all the way into the recognition loop.
|
||||
Wake func()
|
||||
|
||||
mu sync.Mutex
|
||||
// Templates are fetched once per identity, not once per sighting. A
|
||||
// returning customer seen forty times a day would otherwise pull the same
|
||||
// 512 floats out of SQLite forty times.
|
||||
seen map[int64]cached
|
||||
|
||||
Accepted uint64
|
||||
Skipped uint64
|
||||
Failed uint64
|
||||
}
|
||||
|
||||
type cached struct {
|
||||
vector []float32
|
||||
model string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const cacheTTL = 30 * time.Minute
|
||||
|
||||
// Handler is the HTTP endpoint the engine posts to.
|
||||
func (b *Bridge) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "post only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var ev Event
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&ev); err != nil {
|
||||
// 400, not 500: the engine must not retry a payload that will
|
||||
// never parse, and its sink logs failures without blocking.
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := b.Handle(r.Context(), ev); err != nil {
|
||||
b.logf("event %s: %v", ev.Type, err)
|
||||
http.Error(w, "queue failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// Handle converts one engine event and queues it.
|
||||
func (b *Bridge) Handle(ctx context.Context, ev Event) error {
|
||||
switch ev.Type {
|
||||
case "person.new", "person.seen":
|
||||
default:
|
||||
// camera.up/down and person.missed are local diagnostics. They belong
|
||||
// in the heartbeat, not in the footfall stream, where they would be
|
||||
// counted as visits.
|
||||
b.bump(&b.Skipped)
|
||||
return nil
|
||||
}
|
||||
if b.TopicPrefix == "" {
|
||||
b.bump(&b.Skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
identityID := asInt(ev.Data["identity_id"])
|
||||
visit := map[string]any{
|
||||
// Deterministic from what identifies the sighting, so the SAME event
|
||||
// redelivered after a crash carries the SAME id and the server's
|
||||
// idempotency check catches it. A random uuid here would defeat the
|
||||
// entire at-least-once design.
|
||||
"event_id": eventID(b.TopicPrefix, ev.CameraID, identityID, ev.TS),
|
||||
"occurred_at": time.Unix(0, int64(ev.TS*float64(time.Second))).UTC(),
|
||||
"camera_id": ev.CameraID,
|
||||
"is_new": ev.Type == "person.new",
|
||||
"similarity": asFloat(ev.Data["similarity"]),
|
||||
"quality": asFloat(ev.Data["quality"]),
|
||||
"local_visitor_id": identityID,
|
||||
"attributes": attributes(ev.Data),
|
||||
}
|
||||
|
||||
if identityID > 0 && b.Embeddings != nil {
|
||||
vec, model, err := b.embedding(ctx, identityID)
|
||||
if err != nil {
|
||||
// Queue the visit anyway. A footfall count without a template is
|
||||
// still a real visit; dropping it would lose the one number the
|
||||
// customer is paying for over an optional field.
|
||||
b.logf("no embedding for identity %d, sending counts only: %v",
|
||||
identityID, err)
|
||||
} else {
|
||||
visit["embedding"] = vec
|
||||
visit["model"] = model
|
||||
}
|
||||
}
|
||||
|
||||
// After the embedding, before the queue: the key has to be on the event
|
||||
// that gets queued, and the local file is removed either way so a failed
|
||||
// upload cannot leave a picture of a customer on a shop PC forever.
|
||||
if path, _ := ev.Data["image_path"].(string); path != "" {
|
||||
b.attachImage(ctx, visit, path)
|
||||
}
|
||||
|
||||
if err := b.Queue.Append(b.TopicPrefix+"/visit", visit); err != nil {
|
||||
b.bump(&b.Failed)
|
||||
return fmt.Errorf("queue visit: %w", err)
|
||||
}
|
||||
b.bump(&b.Accepted)
|
||||
// After the append, never before: waking a pump for an event that is not
|
||||
// on disk yet is a drain that finds nothing and an event that then waits
|
||||
// out the full idle interval anyway.
|
||||
if b.Wake != nil {
|
||||
b.Wake()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) embedding(ctx context.Context, id int64) ([]float32, string, error) {
|
||||
b.mu.Lock()
|
||||
if c, ok := b.seen[id]; ok && time.Since(c.at) < cacheTTL {
|
||||
b.mu.Unlock()
|
||||
return c.vector, c.model, nil
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
vec, model, err := b.Embeddings.Embedding(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.seen == nil {
|
||||
b.seen = map[int64]cached{}
|
||||
}
|
||||
b.seen[id] = cached{vector: vec, model: model, at: time.Now()}
|
||||
b.mu.Unlock()
|
||||
return vec, model, nil
|
||||
}
|
||||
|
||||
// Listen serves the webhook on loopback and returns the URL to configure in
|
||||
// the engine. Port 0 so two instances on one machine cannot collide.
|
||||
func (b *Bridge) Listen(ctx context.Context) (url string, stop func(), err error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: b.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
b.logf("bridge server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
return fmt.Sprintf("http://%s/events", ln.Addr().String()), func() {
|
||||
c, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(c)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// eventID is stable for one sighting: same camera, same identity, same second.
|
||||
//
|
||||
// The engine's sighting cooldown is 30 s, so two genuinely different visits by
|
||||
// one person at one camera cannot share a second. Truncating to the second
|
||||
// rather than using the raw float also survives the engine re-sending after a
|
||||
// restart with a marginally different timestamp.
|
||||
func eventID(prefix, camera string, identity int64, ts float64) string {
|
||||
return fmt.Sprintf("%s|%s|%d|%d",
|
||||
strings.TrimPrefix(prefix, "bv/"), camera, identity, int64(ts))
|
||||
}
|
||||
|
||||
// attributes keeps the estimator output and drops the bookkeeping fields the
|
||||
// server already has as columns.
|
||||
func attributes(data map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range data {
|
||||
switch k {
|
||||
case "identity_id", "label", "similarity", "quality", "frame_quality":
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asInt(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func asFloat(v any) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (b *Bridge) bump(p *uint64) {
|
||||
b.mu.Lock()
|
||||
*p++
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bridge) logf(format string, args ...any) {
|
||||
if b.Log != nil {
|
||||
b.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
288
agent/pkg/bridge/bridge_test.go
Normal file
288
agent/pkg/bridge/bridge_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeQueue struct {
|
||||
topics []string
|
||||
payloads []map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
func (q *fakeQueue) Append(topic string, payload any) error {
|
||||
if q.err != nil {
|
||||
return q.err
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
var m map[string]any
|
||||
json.Unmarshal(b, &m)
|
||||
q.topics = append(q.topics, topic)
|
||||
q.payloads = append(q.payloads, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEmb struct {
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEmb) Embedding(_ context.Context, id int64) ([]float32, string, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, "", f.err
|
||||
}
|
||||
return make([]float32, 512), "w600k_r50.onnx", nil
|
||||
}
|
||||
|
||||
func newBridge() (*Bridge, *fakeQueue, *fakeEmb) {
|
||||
q, e := &fakeQueue{}, &fakeEmb{}
|
||||
return &Bridge{Queue: q, Embeddings: e, TopicPrefix: "bv/acme.store1"}, q, e
|
||||
}
|
||||
|
||||
func seen(id int64, ts float64) Event {
|
||||
return Event{Type: "person.seen", CameraID: "entrance", TS: ts,
|
||||
Data: map[string]any{"identity_id": float64(id), "similarity": 0.58,
|
||||
"quality": 0.74, "gender": "Male", "age": float64(34)}}
|
||||
}
|
||||
|
||||
func TestADetectionIsQueuedForTheRightTopic(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
if err := b.Handle(context.Background(), seen(7, 1787996491)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.topics) != 1 || q.topics[0] != "bv/acme.store1/visit" {
|
||||
t.Fatalf("topics=%v", q.topics)
|
||||
}
|
||||
p := q.payloads[0]
|
||||
if p["camera_id"] != "entrance" || p["is_new"] != false {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
if p["quality"] != 0.74 || p["similarity"] != 0.58 {
|
||||
t.Fatalf("measurements lost: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheEventIDIsStableForTheSameSighting(t *testing.T) {
|
||||
// This is what makes at-least-once delivery safe. A random id here would
|
||||
// defeat the server's idempotency check and double the store's footfall
|
||||
// after every reconnect.
|
||||
b, q, _ := newBridge()
|
||||
ctx := context.Background()
|
||||
b.Handle(ctx, seen(7, 1787996491.20))
|
||||
b.Handle(ctx, seen(7, 1787996491.86)) // same second, redelivered
|
||||
|
||||
if q.payloads[0]["event_id"] != q.payloads[1]["event_id"] {
|
||||
t.Fatalf("ids differ: %v vs %v",
|
||||
q.payloads[0]["event_id"], q.payloads[1]["event_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentPeopleAndCamerasGetDifferentIDs(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
ctx := context.Background()
|
||||
b.Handle(ctx, seen(7, 1787996491))
|
||||
b.Handle(ctx, seen(8, 1787996491)) // different person, same instant
|
||||
other := seen(7, 1787996491)
|
||||
other.CameraID = "till"
|
||||
b.Handle(ctx, other) // same person, different camera
|
||||
|
||||
ids := map[any]bool{}
|
||||
for _, p := range q.payloads {
|
||||
ids[p["event_id"]] = true
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("collided: %d distinct ids from 3 sightings", len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiagnosticsAreNotCountedAsVisits(t *testing.T) {
|
||||
// person.missed and camera.up are real events, but sending them down the
|
||||
// footfall stream would inflate the headcount with things that are not
|
||||
// people.
|
||||
b, q, _ := newBridge()
|
||||
for _, typ := range []string{"person.missed", "camera.up", "camera.down",
|
||||
"identity.merged"} {
|
||||
b.Handle(context.Background(), Event{Type: typ, CameraID: "entrance"})
|
||||
}
|
||||
if len(q.payloads) != 0 {
|
||||
t.Fatalf("queued %d non-visits", len(q.payloads))
|
||||
}
|
||||
if b.Skipped != 4 {
|
||||
t.Fatalf("skipped=%d", b.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnclaimedPCQueuesNothing(t *testing.T) {
|
||||
// Without a tenant prefix an event would be addressed to nowhere, and the
|
||||
// broker would refuse it anyway.
|
||||
b, q, _ := newBridge()
|
||||
b.TopicPrefix = ""
|
||||
b.Handle(context.Background(), seen(7, 1))
|
||||
if len(q.payloads) != 0 {
|
||||
t.Fatal("queued an event with no tenant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTemplateIsFetchedOncePerIdentity(t *testing.T) {
|
||||
// A returning customer seen forty times a day would otherwise pull the
|
||||
// same 512 floats out of SQLite forty times.
|
||||
b, _, e := newBridge()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
b.Handle(ctx, seen(7, float64(1787996491+i*60)))
|
||||
}
|
||||
if e.calls != 1 {
|
||||
t.Fatalf("fetched the template %d times", e.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingTemplateStillQueuesTheVisit(t *testing.T) {
|
||||
// A footfall count without a template is still a real visit. Dropping it
|
||||
// would lose the number the customer is paying for over an optional field.
|
||||
b, q, e := newBridge()
|
||||
e.err = errors.New("no embedding")
|
||||
if err := b.Handle(context.Background(), seen(7, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatal("visit dropped because the template was missing")
|
||||
}
|
||||
if _, has := q.payloads[0]["embedding"]; has {
|
||||
t.Fatal("queued an embedding key with no embedding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributesSurviveButBookkeepingIsDropped(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
b.Handle(context.Background(), seen(7, 1))
|
||||
attrs := q.payloads[0]["attributes"].(map[string]any)
|
||||
if attrs["gender"] != "Male" || attrs["age"] != float64(34) {
|
||||
t.Fatalf("attributes lost: %+v", attrs)
|
||||
}
|
||||
for _, k := range []string{"identity_id", "similarity", "quality"} {
|
||||
if _, dup := attrs[k]; dup {
|
||||
t.Errorf("%q duplicated into attributes; it is already a column", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedJSONIsRejectedNotRetried(t *testing.T) {
|
||||
b, _, _ := newBridge()
|
||||
srv := httptest.NewServer(b.Handler())
|
||||
defer srv.Close()
|
||||
resp, err := http.Post(srv.URL+"/events", "application/json",
|
||||
strings.NewReader("{ truncated"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status %d — the engine would retry this forever", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWebhookQueuesARealPost(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
srv := httptest.NewServer(b.Handler())
|
||||
defer srv.Close()
|
||||
body, _ := json.Marshal(seen(7, 1787996491))
|
||||
resp, err := http.Post(srv.URL+"/events", "application/json",
|
||||
strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatal("nothing queued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenBindsLoopbackOnly(t *testing.T) {
|
||||
// The webhook accepts unauthenticated posts that become footfall rows;
|
||||
// it must not be reachable from the network.
|
||||
b, _, _ := newBridge()
|
||||
url, stop, err := b.Listen(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stop()
|
||||
if !strings.HasPrefix(url, "http://127.0.0.1:") {
|
||||
t.Fatalf("bridge listening on %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- waking
|
||||
|
||||
// Waking BEFORE the append would send the pump to look at a queue the event
|
||||
// has not reached yet: it finds nothing, goes back to sleep, and the visit then
|
||||
// waits out the full idle interval anyway - the exact delay the wake exists to
|
||||
// remove, with an extra wasted drain on top.
|
||||
func TestTheQueueIsWokenOnlyAfterTheVisitIsOnDisk(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
var depthWhenWoken = -1
|
||||
b.Wake = func() { depthWhenWoken = len(q.payloads) }
|
||||
|
||||
if err := b.Handle(context.Background(), seen(7, 1_700_000_000)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if depthWhenWoken != 1 {
|
||||
t.Fatalf("woken with %d events queued, want 1 - the event must be durable first",
|
||||
depthWhenWoken)
|
||||
}
|
||||
}
|
||||
|
||||
// A visit that never reached the queue must not wake anything: there is nothing
|
||||
// to drain, and the pump would spin on an empty spool.
|
||||
func TestAFailedAppendDoesNotWakeThePump(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
q.err = errors.New("disk full")
|
||||
woken := 0
|
||||
b.Wake = func() { woken++ }
|
||||
|
||||
if err := b.Handle(context.Background(), seen(7, 1_700_000_000)); err == nil {
|
||||
t.Fatal("a failed append should surface as an error")
|
||||
}
|
||||
if woken != 0 {
|
||||
t.Fatalf("woke the pump %d times for an event that was never queued", woken)
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics are not visits. They are skipped before the queue, so they must
|
||||
// not wake a pump either.
|
||||
func TestASkippedEventDoesNotWakeThePump(t *testing.T) {
|
||||
b, _, _ := newBridge()
|
||||
woken := 0
|
||||
b.Wake = func() { woken++ }
|
||||
|
||||
ev := seen(7, 1_700_000_000)
|
||||
ev.Type = "person.missed"
|
||||
if err := b.Handle(context.Background(), ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if woken != 0 {
|
||||
t.Fatalf("a diagnostic event woke the pump %d times", woken)
|
||||
}
|
||||
}
|
||||
|
||||
// The bridge must run unchanged with no waker wired, which is what an agent
|
||||
// built before this existed looks like.
|
||||
func TestNoWakerIsFine(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
b.Wake = nil
|
||||
if err := b.Handle(context.Background(), seen(7, 1_700_000_000)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatalf("queued %d visits, want 1", len(q.payloads))
|
||||
}
|
||||
}
|
||||
56
agent/pkg/bridge/engine_client.go
Normal file
56
agent/pkg/bridge/engine_client.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EngineEmbeddings reads templates from the local engine's API.
|
||||
type EngineEmbeddings struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewEngineEmbeddings(base, user, password string) *EngineEmbeddings {
|
||||
return &EngineEmbeddings{
|
||||
Base: strings.TrimRight(base, "/"), User: user, Password: password,
|
||||
Client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EngineEmbeddings) Embedding(ctx context.Context, id int64) ([]float32, string, error) {
|
||||
url := fmt.Sprintf("%s/api/identities/%d/embedding", e.Base, id)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, 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 returned %s", resp.Status)
|
||||
}
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Embedding []float32 `json:"embedding"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(body.Embedding) == 0 {
|
||||
return nil, "", fmt.Errorf("engine returned an empty embedding")
|
||||
}
|
||||
return body.Embedding, body.Model, nil
|
||||
}
|
||||
205
agent/pkg/bridge/images.go
Normal file
205
agent/pkg/bridge/images.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Uploader sends one face image to object storage.
|
||||
//
|
||||
// It is an interface because the bridge must work identically when images are
|
||||
// off, when the PC is not enrolled yet, and when the server has no bucket
|
||||
// configured - three states that are normal rather than exceptional.
|
||||
type Uploader interface {
|
||||
// Upload returns the object key the server assigned.
|
||||
Upload(ctx context.Context, path string) (key string, err error)
|
||||
}
|
||||
|
||||
// SpacesUploader uploads through a URL the server mints.
|
||||
//
|
||||
// The shop PC holds no bucket credentials, only its own agent token. That is
|
||||
// the point: the bucket is shared with other applications and a counter-top PC
|
||||
// is the least trustworthy machine in the estate, so a stolen one gives up a
|
||||
// few minutes of write access to one key rather than a bucket password.
|
||||
type SpacesUploader struct {
|
||||
// BaseURL is the server, e.g. https://mcp.loyaly.ai
|
||||
BaseURL string
|
||||
// Token is the agent's own API credential, issued at enrolment. Separate
|
||||
// from the broker password so rotating either does not break the other.
|
||||
Token string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// ErrImagesOff means the server stores no images. Distinct from a failure: the
|
||||
// agent should stop trying and carry on sending visits, not retry forever.
|
||||
var ErrImagesOff = errors.New("server does not store images")
|
||||
|
||||
// maxImageBytes bounds what will be read off disk and sent. The engine writes
|
||||
// ~20 KB crops; anything near this is a bug or a different file that landed in
|
||||
// the outbox, and a shop uplink should not spend minutes discovering that.
|
||||
const maxImageBytes = 2 << 20
|
||||
|
||||
func (u *SpacesUploader) httpClient() *http.Client {
|
||||
if u.Client != nil {
|
||||
return u.Client
|
||||
}
|
||||
// Long enough for a slow shop uplink, bounded so a half-open connection
|
||||
// cannot stall the queue behind it.
|
||||
return &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
|
||||
type uploadTarget struct {
|
||||
Key string `json:"key"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (u *SpacesUploader) Upload(ctx context.Context, path string) (string, error) {
|
||||
if u.BaseURL == "" || u.Token == "" {
|
||||
// Not claimed yet. The visit still queues; it simply has no photo.
|
||||
return "", ErrImagesOff
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return "", errors.New("image file is empty")
|
||||
}
|
||||
if info.Size() > maxImageBytes {
|
||||
return "", fmt.Errorf("image is %d bytes, over the %d limit",
|
||||
info.Size(), maxImageBytes)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.UploadBytes(ctx, body)
|
||||
}
|
||||
|
||||
// UploadBytes puts an image already in memory.
|
||||
//
|
||||
// Split out for camera snapshots, which the engine hands over as bytes. The
|
||||
// alternative - writing each frame to a temp file so Upload could read it back
|
||||
// - would put a picture of a shop floor on disk once a minute per camera, on
|
||||
// the one machine in the estate least worth trusting with it.
|
||||
func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string, error) {
|
||||
if u.BaseURL == "" || u.Token == "" {
|
||||
return "", ErrImagesOff
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return "", errors.New("image is empty")
|
||||
}
|
||||
if int64(len(body)) > maxImageBytes {
|
||||
return "", fmt.Errorf("image is %d bytes, over the %d limit",
|
||||
len(body), maxImageBytes)
|
||||
}
|
||||
|
||||
target, err := u.target(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, target.URL,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Sent exactly as handed back. The ACL is inside the server's signature, so
|
||||
// changing or dropping it does not publish the image - it fails the upload,
|
||||
// which is the safe direction.
|
||||
for k, v := range target.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.ContentLength = int64(len(body))
|
||||
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("upload returned %s: %s",
|
||||
resp.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return target.Key, nil
|
||||
}
|
||||
|
||||
func (u *SpacesUploader) target(ctx context.Context) (uploadTarget, error) {
|
||||
var out uploadTarget
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(u.BaseURL, "/")+"/api/agent/upload-url", nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+u.Token)
|
||||
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("ask for an upload url: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotImplemented {
|
||||
return out, ErrImagesOff
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return out, fmt.Errorf("upload url returned %s: %s",
|
||||
resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.URL == "" || out.Key == "" {
|
||||
return out, errors.New("server returned an incomplete upload target")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachImage uploads the engine's face image and returns the object key.
|
||||
//
|
||||
// Every failure is non-fatal and the local file is removed regardless. A visit
|
||||
// without a photo is a real visit and the number the customer pays for; a
|
||||
// visit stuck behind a failed upload is lost footfall. Keeping the file for a
|
||||
// retry would also mean an outbox that grows for as long as the failure lasts,
|
||||
// full of pictures of customers.
|
||||
func (b *Bridge) attachImage(ctx context.Context, visit map[string]any, path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
b.logf("could not remove %s after upload: %v", filepath.Base(path), err)
|
||||
}
|
||||
}()
|
||||
if b.Uploader == nil {
|
||||
return
|
||||
}
|
||||
// Bounded separately from the caller: an upload that hangs must not hold
|
||||
// up the visit it belongs to.
|
||||
uctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key, err := b.Uploader.Upload(uctx, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrImagesOff) {
|
||||
// Normal for a deployment that stores no images, and for a PC that
|
||||
// has not been claimed yet. Not worth a line per visitor.
|
||||
return
|
||||
}
|
||||
b.logf("image upload failed for %s, sending the visit without it: %v",
|
||||
filepath.Base(path), err)
|
||||
return
|
||||
}
|
||||
visit["image_key"] = key
|
||||
}
|
||||
202
agent/pkg/bridge/images_test.go
Normal file
202
agent/pkg/bridge/images_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeImage(t *testing.T, dir, name string, body []byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// fakeServer plays both halves: the API that mints an upload URL and the
|
||||
// bucket that receives the PUT.
|
||||
func fakeServer(t *testing.T, uploaded *[]byte, sentACL *string) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
mux.HandleFunc("/api/agent/upload-url", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer agent-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(uploadTarget{ //nolint:errcheck
|
||||
Key: "behavision/acme/store1/2026/08/31/abc.jpg",
|
||||
URL: srv.URL + "/bucket/abc.jpg",
|
||||
Headers: map[string]string{
|
||||
"x-amz-acl": "private", "content-type": "image/jpeg",
|
||||
},
|
||||
ExpiresIn: 600,
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/bucket/", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
*uploaded = body
|
||||
*sentACL = r.Header.Get("x-amz-acl")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestUploadSendsTheFileAndTheSignedACL(t *testing.T) {
|
||||
var got []byte
|
||||
var acl string
|
||||
srv := fakeServer(t, &got, &acl)
|
||||
dir := t.TempDir()
|
||||
path := writeImage(t, dir, "face.jpg", []byte("jpeg bytes"))
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"}
|
||||
key, err := u.Upload(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key != "behavision/acme/store1/2026/08/31/abc.jpg" {
|
||||
t.Fatalf("key = %q", key)
|
||||
}
|
||||
if string(got) != "jpeg bytes" {
|
||||
t.Fatalf("uploaded %q", got)
|
||||
}
|
||||
// The ACL is inside the server's signature. Sending it exactly as handed
|
||||
// back is what keeps the shop PC from deciding to publish the image.
|
||||
if acl != "private" {
|
||||
t.Fatalf("x-amz-acl = %q, want private", acl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnclaimedPCReportsImagesOffRatherThanFailing(t *testing.T) {
|
||||
u := &SpacesUploader{} // no base url, no token: not enrolled yet
|
||||
_, err := u.Upload(context.Background(), "/nonexistent")
|
||||
if !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("got %v, want ErrImagesOff", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerWithoutABucketIsNotARetryableFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}))
|
||||
defer srv.Close()
|
||||
dir := t.TempDir()
|
||||
path := writeImage(t, dir, "face.jpg", []byte("x"))
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"}
|
||||
// 501 means "this deployment stores no images". The agent must stop trying
|
||||
// rather than retry every visitor forever.
|
||||
if _, err := u.Upload(context.Background(), path); !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("got %v, want ErrImagesOff", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedFilesAreRefusedBeforeTheUplink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeImage(t, dir, "huge.jpg", make([]byte, maxImageBytes+1))
|
||||
u := &SpacesUploader{BaseURL: "http://example.invalid", Token: "t"}
|
||||
// A shop uplink should not spend minutes discovering that something other
|
||||
// than a face crop landed in the outbox.
|
||||
if _, err := u.Upload(context.Background(), path); err == nil ||
|
||||
!strings.Contains(err.Error(), "limit") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -- the bridge's use of it -------------------------------------------------
|
||||
|
||||
type stubUploader struct {
|
||||
key string
|
||||
err error
|
||||
sent []string
|
||||
}
|
||||
|
||||
func (s *stubUploader) Upload(_ context.Context, path string) (string, error) {
|
||||
s.sent = append(s.sent, path)
|
||||
return s.key, s.err
|
||||
}
|
||||
|
||||
func TestVisitCarriesTheImageKeyAndTheLocalFileIsRemoved(t *testing.T) {
|
||||
q := &fakeQueue{}
|
||||
up := &stubUploader{key: "behavision/acme/store1/2026/08/31/abc.jpg"}
|
||||
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up}
|
||||
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
|
||||
|
||||
err := b.Handle(context.Background(), Event{
|
||||
Type: "person.new", CameraID: "entrance", TS: 1756_000_000,
|
||||
Data: map[string]any{"identity_id": float64(7), "image_path": path},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatalf("expected one queued visit, got %d", len(q.payloads))
|
||||
}
|
||||
visit := q.payloads[0]
|
||||
if visit["image_key"] != up.key {
|
||||
t.Fatalf("image_key = %v", visit["image_key"])
|
||||
}
|
||||
// The outbox is transient. Leaving files behind means a shop PC slowly
|
||||
// filling with pictures of its customers.
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("the local image was not removed after upload")
|
||||
}
|
||||
}
|
||||
|
||||
// A footfall count without a photo is a real visit and the number the customer
|
||||
// pays for. Losing it over an optional field would be the wrong trade - the
|
||||
// same rule the bridge already follows for a missing embedding.
|
||||
func TestAFailedUploadStillQueuesTheVisit(t *testing.T) {
|
||||
q := &fakeQueue{}
|
||||
up := &stubUploader{err: errors.New("bucket unreachable")}
|
||||
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up}
|
||||
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
|
||||
|
||||
if err := b.Handle(context.Background(), Event{
|
||||
Type: "person.seen", CameraID: "entrance", TS: 1756_000_001,
|
||||
Data: map[string]any{"identity_id": float64(7), "image_path": path},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatalf("the visit was dropped because its photo failed")
|
||||
}
|
||||
if _, ok := q.payloads[0]["image_key"]; ok {
|
||||
t.Fatal("a key was attached despite the upload failing")
|
||||
}
|
||||
// Removed anyway: keeping it for a retry means an outbox that grows for as
|
||||
// long as the failure lasts.
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("the local image survived a failed upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) {
|
||||
q := &fakeQueue{}
|
||||
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1"} // images off
|
||||
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
|
||||
|
||||
if err := b.Handle(context.Background(), Event{
|
||||
Type: "person.new", CameraID: "entrance", TS: 1756_000_002,
|
||||
Data: map[string]any{"identity_id": float64(7), "image_path": path},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := q.payloads[0]["image_key"]; ok {
|
||||
t.Fatal("an image key appeared with no uploader configured")
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("the local image was left on disk")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user