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

279
agent/pkg/bridge/bridge.go Normal file
View 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...)
}
}

View 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))
}
}

View 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
View 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
}

View 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")
}
}