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

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

201
agent/pkg/config/config.go Normal file
View File

@@ -0,0 +1,201 @@
// Package config holds the agent's own settings: which tenant and site this
// install belongs to, how to reach the broker, and how to launch the engine.
//
// Kept separate from the engine's YAML on purpose. That file describes
// recognition — thresholds, cameras, gates — and is edited by whoever tunes a
// site. This one describes identity and connectivity, is written by the
// installer and the login flow, and holds a secret.
package config
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// protectedPrefix marks a value that went through DPAPI, so a config written
// on Windows is never mistaken for a plaintext dev one and vice versa.
const protectedPrefix = "dpapi:"
// Config is the agent's on-disk settings.
type Config struct {
// Tenant identity. The server keys everything on these.
ClientID string `json:"client_id"`
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
// Broker.
BrokerURL string `json:"broker_url"`
BrokerUsername string `json:"broker_username"`
BrokerPassword string `json:"broker_password"` // protected at rest
// Pins the broker's issuer. Empty uses the system roots, which is what a
// Let's Encrypt certificate needs; a private CA is pinned by path.
BrokerCAFile string `json:"broker_ca_file"`
// Engine process.
EngineExe string `json:"engine_exe"`
EngineArgs []string `json:"engine_args"`
APIBase string `json:"api_base"`
APIUser string `json:"api_user"`
APIPassword string `json:"api_password"` // protected at rest
// Session, so a shop PC that reboots overnight is not a login every
// morning. Protected at rest like every other secret here.
SessionToken string `json:"session_token"`
SessionRefresh string `json:"session_refresh"`
SessionEmail string `json:"session_email"`
// CloudBase is the server this site reports to; AgentToken is this PC's
// own credential there, issued once at enrolment.
//
// Deliberately not the same secret as BrokerPassword: they authenticate
// different things - one says this site may publish events, the other that
// it may ask the API for something - so rotating either must not break the
// other. Protected at rest like every other secret here.
CloudBase string `json:"cloud_base"`
AgentToken string `json:"agent_token"`
// Standalone marks a PC deliberately run on its own: cameras, recognition
// and the local gallery, with nothing reported to head office.
//
// It exists so that "not linked yet" and "not going to be linked" are
// different states. Without it every install was blocked on an enrolment
// code, so a shop with one PC and no head office could not add a camera at
// all - the software refused to do the thing it is for until a server it
// does not need had issued it a credential.
Standalone bool `json:"standalone"`
// Queue.
SpoolMax int `json:"spool_max"`
path string
}
// Defaults returns a config that runs a locally installed engine.
//
// EngineExe is relative to the install root - the directory holding this
// executable - and names the installed layout: the engine is a PyInstaller
// one-FOLDER build, so it brings its own DLLs and cannot simply sit beside the
// app. Windows filenames are case-insensitive too, so `Behavision.exe` (the
// app) and `behavision.exe` (the engine) could not share a directory even if
// it were tidy to.
func Defaults() Config {
exe := filepath.Join("engine", "behavision")
if runtime.GOOS == "windows" {
exe += ".exe"
}
return Config{
EngineExe: exe,
EngineArgs: []string{"run"},
APIBase: "http://127.0.0.1:8010",
SpoolMax: 50000,
}
}
// Load reads the config, decrypting secrets. A missing file is not an error:
// a fresh install has none until the operator logs in, and failing to start
// because of that would leave them with no UI to log in from.
func Load(path string) (Config, error) {
cfg := Defaults()
cfg.path = path
blob, err := os.ReadFile(path)
if os.IsNotExist(err) {
return cfg, nil
}
if err != nil {
return cfg, err
}
if err := json.Unmarshal(blob, &cfg); err != nil {
return cfg, fmt.Errorf("config %s: %w", path, err)
}
cfg.path = path
for _, field := range []*string{&cfg.BrokerPassword, &cfg.APIPassword,
&cfg.SessionToken, &cfg.SessionRefresh, &cfg.AgentToken} {
plain, err := reveal(*field)
if err != nil {
// A secret that cannot be decrypted usually means the config was
// copied from another machine - DPAPI is machine-scoped. Blank it
// rather than failing: the operator can log in again, but they
// cannot fix a process that will not start.
*field = ""
continue
}
*field = plain
}
return cfg, nil
}
// Save writes the config atomically, protecting secrets on the way out.
func (c Config) Save(path string) error {
if path == "" {
path = c.path
}
if path == "" {
return fmt.Errorf("config: no path to save to")
}
out := c
out.path = ""
for _, field := range []*string{&out.BrokerPassword, &out.APIPassword,
&out.SessionToken, &out.SessionRefresh, &out.AgentToken} {
hidden, err := conceal(*field)
if err != nil {
return err
}
*field = hidden
}
blob, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
// Temp-then-rename: a crash mid-write must not leave a config that parses
// as valid but is half old and half new.
tmp := path + ".tmp"
if err := os.WriteFile(tmp, blob, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// Configured reports whether this install has been claimed by a tenant yet.
// The UI shows a login screen until it has.
func (c Config) Configured() bool {
return c.ClientID != "" && c.SiteID != "" && c.BrokerURL != ""
}
// SecretsProtected is false on a dev machine, where secrets are stored as-is.
// Surfaced rather than hidden so nobody ships a build believing otherwise.
func SecretsProtected() bool { return protectionAvailable() }
func conceal(plain string) (string, error) {
if plain == "" || !protectionAvailable() {
return plain, nil
}
blob, err := protect([]byte(plain))
if err != nil {
return "", err
}
return protectedPrefix + base64.StdEncoding.EncodeToString(blob), nil
}
func reveal(stored string) (string, error) {
if !strings.HasPrefix(stored, protectedPrefix) {
return stored, nil
}
blob, err := base64.StdEncoding.DecodeString(
strings.TrimPrefix(stored, protectedPrefix))
if err != nil {
return "", err
}
plain, err := unprotect(blob)
if err != nil {
return "", err
}
return string(plain), nil
}

View File

@@ -0,0 +1,179 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestAFreshInstallLoadsDefaultsInsteadOfFailing(t *testing.T) {
// There is no config until the operator logs in, and refusing to start
// would leave them with no UI to log in from.
cfg, err := Load(filepath.Join(t.TempDir(), "nope.json"))
if err != nil {
t.Fatalf("missing config treated as an error: %v", err)
}
if cfg.Configured() {
t.Fatal("a blank install reported itself as configured")
}
if cfg.APIBase == "" || cfg.EngineExe == "" {
t.Fatal("defaults were not applied")
}
}
func TestRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.json")
cfg := Defaults()
cfg.ClientID, cfg.SiteID, cfg.BrokerURL = "acme", "store-1", "tls://b:8883"
cfg.BrokerPassword, cfg.APIPassword = "broker-secret", "api-secret"
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
back, err := Load(path)
if err != nil {
t.Fatal(err)
}
if back.BrokerPassword != "broker-secret" || back.APIPassword != "api-secret" {
t.Fatalf("secrets did not survive the round trip: %+v", back)
}
if !back.Configured() {
t.Fatal("a claimed install reported itself unconfigured")
}
}
func TestSaveIsAtomic(t *testing.T) {
// A crash mid-write must not leave a config that parses but is half old
// and half new.
dir := t.TempDir()
path := filepath.Join(dir, "agent.json")
cfg := Defaults()
cfg.ClientID = "acme"
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("temp file left behind: %s", e.Name())
}
}
}
func TestAnUndecryptableSecretBlanksRatherThanBlocksStartup(t *testing.T) {
// DPAPI is machine-scoped, so a config copied between PCs cannot be read.
// Refusing to start would be unrecoverable without a UI; blanking it means
// the operator just logs in again.
path := filepath.Join(t.TempDir(), "agent.json")
os.WriteFile(path, []byte(`{"client_id":"acme","site_id":"s1",
"broker_url":"tls://b","broker_password":"dpapi:!!!not-base64!!!"}`), 0o600)
cfg, err := Load(path)
if err != nil {
t.Fatalf("unreadable secret blocked startup: %v", err)
}
if cfg.BrokerPassword != "" {
t.Fatal("a secret that could not be decrypted was kept")
}
if cfg.ClientID != "acme" {
t.Fatal("the rest of the config was discarded too")
}
}
func TestPlaintextSecretsAreMarkedDifferentlyFromProtectedOnes(t *testing.T) {
// So a dev config is never mistaken for a protected one on inspection.
stored, err := conceal("secret")
if err != nil {
t.Fatal(err)
}
if SecretsProtected() && !strings.HasPrefix(stored, protectedPrefix) {
t.Fatal("protected value is not marked")
}
if !SecretsProtected() && strings.HasPrefix(stored, protectedPrefix) {
t.Fatal("plaintext value claims to be protected")
}
}
func TestSaveDoesNotLeakThePathFieldIntoJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.json")
Defaults().Save(path)
blob, _ := os.ReadFile(path)
if strings.Contains(string(blob), t.TempDir()) {
t.Fatal("internal path field was serialised")
}
}
func TestTheSessionSurvivesARestart(t *testing.T) {
// A shop PC reboots overnight. Without this someone logs in every morning
// before the store can record anything.
path := filepath.Join(t.TempDir(), "agent.json")
cfg := Defaults()
cfg.SessionToken, cfg.SessionRefresh = "access-tok", "refresh-tok"
cfg.SessionEmail = "manager@acme.test"
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
back, err := Load(path)
if err != nil {
t.Fatal(err)
}
if back.SessionToken != "access-tok" || back.SessionRefresh != "refresh-tok" {
t.Fatalf("session lost: %+v", back)
}
if back.SessionEmail != "manager@acme.test" {
t.Fatal("email not kept")
}
}
func TestSessionTokensAreProtectedLikeOtherSecrets(t *testing.T) {
// A bearer token in plaintext on disk is a credential anyone with the file
// can replay.
path := filepath.Join(t.TempDir(), "agent.json")
cfg := Defaults()
cfg.SessionToken = "super-secret-jwt"
cfg.Save(path)
raw, _ := os.ReadFile(path)
if SecretsProtected() && strings.Contains(string(raw), "super-secret-jwt") {
t.Fatal("session token written in plaintext")
}
}
// Standalone has to survive a restart. It is a setup choice made once at a
// counter, and a flag that only lives in memory would put the enrolment-code
// screen back in front of a shop that already answered "we have no head
// office" - which reads as the app forgetting the setup step was ever done.
func TestStandaloneSurvivesSaveAndLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.json")
cfg := Defaults()
cfg.Standalone = true
if err := cfg.Save(path); err != nil {
t.Fatalf("save: %v", err)
}
back, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if !back.Standalone {
t.Fatal("standalone was not persisted")
}
// Independent of being claimed: a standalone PC has no tenant, and a
// claimed one is not standalone even if the flag was once set.
if back.Configured() {
t.Fatal("a standalone config must not report itself as claimed")
}
}
// The engine is a PyInstaller one-FOLDER build living in its own subdirectory,
// and on Windows `Behavision.exe` (the app) could not share a directory with
// `behavision.exe` (the engine) anyway. Asserted here because the installer
// lays the tree out to match, and a rename would otherwise fail only inside
// the package - the one place nothing is tested.
func TestDefaultEngineExeIsInTheEngineFolder(t *testing.T) {
got := Defaults().EngineExe
if dir := filepath.Dir(got); dir != "engine" {
t.Fatalf("engine exe %q is not under engine/, got dir %q", got, dir)
}
if filepath.IsAbs(got) {
t.Fatalf("engine exe %q must be relative to the install root", got)
}
}

View File

@@ -0,0 +1,62 @@
package config
import (
"bufio"
"os"
"strings"
)
// EngineCredentials reads the Basic credentials the engine generated for
// itself, from the file it writes them to.
//
// The engine only invents a credential when none is configured and its API
// listens on a routable address - which is the DEFAULT configuration, so this
// is the ordinary case and not an edge one. `paths.APICredentials` has existed
// since the agent was written, with a comment saying the agent reads the file
// "rather than storing a second copy, so a regenerated credential does not
// silently break the tray". Nothing read it. On a stock install the agent's
// api_user was therefore empty and every call it makes to the engine - health,
// stats, camera sync, embeddings for a visit - came back 401: the tray red, the
// cameras never reconciled, and no error anywhere saying why.
//
// A missing or unreadable file is not an error. A PC where the operator set
// BEHAVISION_API_USER has no such file and needs none.
func EngineCredentials(path string) (user, password string) {
f, err := os.Open(path)
if err != nil {
return "", ""
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
// `key=value`, and `key: value` too: the file is also read by people,
// and which separator the engine used is not worth a support call.
line := strings.TrimSpace(sc.Text())
k, v, ok := strings.Cut(line, "=")
if !ok {
k, v, ok = strings.Cut(line, ":")
}
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "username":
user = strings.TrimSpace(v)
case "password":
password = strings.TrimSpace(v)
}
}
return user, password
}
// WithEngineCredentials fills in the engine's Basic credentials from the file
// when the config carries none. Configured values always win: an operator who
// set BEHAVISION_API_USER means it.
func (c Config) WithEngineCredentials(path string) Config {
if c.APIUser != "" || c.APIPassword != "" {
return c
}
c.APIUser, c.APIPassword = EngineCredentials(path)
return c
}

View File

@@ -0,0 +1,58 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeCreds(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "api_credentials.txt")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
// The shape the engine actually writes. This is the whole point of the file:
// on a stock install it is the ONLY place the credential exists.
func TestItReadsWhatTheEngineWrites(t *testing.T) {
p := writeCreds(t, "username=behavision\npassword=qQTGFpetJ5Py613XwcbARQ\n")
u, pw := EngineCredentials(p)
if u != "behavision" || pw != "qQTGFpetJ5Py613XwcbARQ" {
t.Fatalf("got %q / %q", u, pw)
}
}
func TestColonSeparatedIsReadToo(t *testing.T) {
p := writeCreds(t, " username: behavision\n password: hunter2\n")
if u, pw := EngineCredentials(p); u != "behavision" || pw != "hunter2" {
t.Fatalf("got %q / %q", u, pw)
}
}
// A missing file is normal - an operator who set BEHAVISION_API_USER has none.
func TestAMissingFileIsNotAnError(t *testing.T) {
if u, pw := EngineCredentials("/nope/nothing.txt"); u != "" || pw != "" {
t.Fatalf("got %q / %q", u, pw)
}
}
// Configured values win. Reading the file over an operator's own credential
// would silently ignore what they set.
func TestAConfiguredCredentialIsNotOverwritten(t *testing.T) {
p := writeCreds(t, "username=generated\npassword=generated\n")
c := Config{APIUser: "mine", APIPassword: "secret"}.WithEngineCredentials(p)
if c.APIUser != "mine" || c.APIPassword != "secret" {
t.Fatalf("configured credential was replaced: %q / %q", c.APIUser, c.APIPassword)
}
}
func TestAnEmptyCredentialIsFilledIn(t *testing.T) {
p := writeCreds(t, "username=behavision\npassword=abc\n")
c := Config{}.WithEngineCredentials(p)
if c.APIUser != "behavision" || c.APIPassword != "abc" {
t.Fatalf("not filled in: %q / %q", c.APIUser, c.APIPassword)
}
}

View File

@@ -0,0 +1,13 @@
//go:build !windows
package config
// On non-Windows hosts secrets are stored as-is. This exists so the rest of
// the agent compiles and tests on a developer machine; the shipping platform
// is Windows, where protect.go's DPAPI implementation is used instead.
//
// It is a passthrough, NOT encryption, and Save() marks such values plainly so
// nobody can mistake a dev config for a protected one.
func protect(plain []byte) ([]byte, error) { return plain, nil }
func unprotect(blob []byte) ([]byte, error) { return blob, nil }
func protectionAvailable() bool { return false }

View File

@@ -0,0 +1,68 @@
//go:build windows
package config
import (
"fmt"
"syscall"
"unsafe"
)
// Windows DPAPI, reached through crypt32.dll directly rather than pulling in
// golang.org/x/sys. Machine scope, matching how the Python side already
// protects camera passwords: the agent and the engine may run as different
// users on the same PC, and a user-scoped blob written by one cannot be read
// by the other.
var (
crypt32 = syscall.NewLazyDLL("crypt32.dll")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procProtectData = crypt32.NewProc("CryptProtectData")
procUnprotectData = crypt32.NewProc("CryptUnprotectData")
procLocalFree = kernel32.NewProc("LocalFree")
)
const cryptprotectLocalMachine = 0x4
type dataBlob struct {
cbData uint32
pbData *byte
}
func newBlob(d []byte) dataBlob {
if len(d) == 0 {
return dataBlob{}
}
return dataBlob{cbData: uint32(len(d)), pbData: &d[0]}
}
func (b *dataBlob) bytes() []byte {
out := make([]byte, b.cbData)
copy(out, unsafe.Slice(b.pbData, b.cbData))
return out
}
func protect(plain []byte) ([]byte, error) {
in, out := newBlob(plain), dataBlob{}
r, _, err := procProtectData.Call(
uintptr(unsafe.Pointer(&in)), 0, 0, 0, 0,
cryptprotectLocalMachine, uintptr(unsafe.Pointer(&out)))
if r == 0 {
return nil, fmt.Errorf("CryptProtectData: %w", err)
}
defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData)))
return out.bytes(), nil
}
func unprotect(blob []byte) ([]byte, error) {
in, out := newBlob(blob), dataBlob{}
r, _, err := procUnprotectData.Call(
uintptr(unsafe.Pointer(&in)), 0, 0, 0, 0,
cryptprotectLocalMachine, uintptr(unsafe.Pointer(&out)))
if r == 0 {
return nil, fmt.Errorf("CryptUnprotectData: %w", err)
}
defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData)))
return out.bytes(), nil
}
func protectionAvailable() bool { return true }

View File

@@ -0,0 +1,44 @@
package engine
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// The engine's REAL reply, copied from a running instance. The point of this
// test is the `"frozen": false` inside `paths`: Health.Paths was
// map[string]string, so decoding failed on that one bool, and because a failed
// decode fails the whole document a working engine was reported unreachable -
// red tray, and a heartbeat carrying neither the model nor the cameras, so head
// office showed "0 of 0 cameras" for a site that was watching one.
const realHealthBody = `{"status":"ok","recognition_model":"w600k_r50",
"paths":{"frozen":false,"install_root":"/opt/behavision","state_root":"/var/behavision",
"config":"/var/behavision/config/default.yaml","data_dir":"/var/behavision/data",
"models_dir":"/var/behavision/models"},"cameras":{"cam1":true},"uptime_seconds":42.5}`
func TestHealthDecodesWhatTheEngineActuallySends(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(realHealthBody))
}))
defer srv.Close()
s := New(Options{HealthURL: srv.URL})
h, err := s.Health(context.Background())
if err != nil {
t.Fatalf("the engine's own reply did not decode: %v", err)
}
if h.RecognitionModel != "w600k_r50" {
t.Errorf("model = %q", h.RecognitionModel)
}
// The two fields the heartbeat carries. Losing these is what made a
// working site look empty at head office.
if up, ok := h.Cameras["cam1"]; !ok || !up {
t.Errorf("cameras = %v, want cam1 connected", h.Cameras)
}
if h.Paths.StateRoot != "/var/behavision" || h.Paths.Frozen {
t.Errorf("paths = %+v", h.Paths)
}
}

View File

@@ -0,0 +1,355 @@
// Package engine starts, watches and stops the Python recognition engine.
//
// Go cannot run ONNX, OpenCV or FAISS, so the engine stays Python and ships
// frozen. What Go owns is its lifecycle: start it, keep it up, capture its
// output, and stop it when the user asks — which is what the tray's start/stop
// buttons actually drive.
//
// Deliberately not a Windows service. A service runs in session 0 and cannot
// draw a tray icon, and spawning a child process needs no elevation while
// controlling a service does. A service wrapper can be layered on later
// without touching anything here.
package engine
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"sync"
"time"
)
// State is what the tray icon colours itself from.
type State string
const (
Stopped State = "stopped" // not running, and not meant to be
Starting State = "starting" // process spawned, not yet answering
Running State = "running" // answering /api/health
Backoff State = "backoff" // crashed, waiting to retry
Failed State = "failed" // gave up
)
const (
minBackoff = 1 * time.Second
maxBackoff = 30 * time.Second
// A run that lasted this long counts as healthy, so the next crash starts
// its backoff from the bottom again. Without this a process that runs fine
// for hours and then crashes once waits the full 30s to come back.
stableRun = 60 * time.Second
// How long a stopping process gets to exit on its own before it is killed.
stopGrace = 10 * time.Second
)
// Options configures a Supervisor.
type Options struct {
// Command builds the process to run. Injected rather than hardcoded so
// tests can supervise /bin/sh instead of a 200 MB frozen engine.
Command func(ctx context.Context) *exec.Cmd
// LogWriter receives the engine's stdout and stderr. A crashed engine with
// no captured output is undiagnosable, which on a customer site means a
// site visit.
LogWriter io.Writer
// HealthURL, StatsURL, User, Password address the engine's own API.
HealthURL string
StatsURL string
User string
Password string
// MaxRestarts of 0 means never give up. Non-zero is for tests.
MaxRestarts int
now func() time.Time
}
// Supervisor keeps one engine process running. Safe for concurrent use.
type Supervisor struct {
opts Options
mu sync.Mutex
state State
lastErr error
restarts int
cancel context.CancelFunc
done chan struct{}
}
func New(opts Options) *Supervisor {
if opts.LogWriter == nil {
opts.LogWriter = io.Discard
}
if opts.now == nil {
opts.now = time.Now
}
return &Supervisor{opts: opts, state: Stopped}
}
// Start launches the engine and keeps it running until Stop. Calling it while
// already running is a no-op rather than a second process — two engines on one
// SQLite WAL and one camera is exactly the failure this package exists to
// avoid.
func (s *Supervisor) Start() {
s.mu.Lock()
if s.cancel != nil {
s.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.done = make(chan struct{})
s.state = Starting
s.restarts = 0
done := s.done
s.mu.Unlock()
go s.supervise(ctx, done)
}
// Stop asks the engine to exit and waits for it.
func (s *Supervisor) Stop() {
s.mu.Lock()
cancel, done := s.cancel, s.done
s.cancel = nil
s.mu.Unlock()
if cancel == nil {
return
}
cancel()
if done != nil {
<-done
}
s.setState(Stopped, nil)
}
// State reports what the supervisor is doing, plus the last error if any.
func (s *Supervisor) State() (State, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.state, s.lastErr
}
// Restarts counts crash-restarts since Start.
func (s *Supervisor) Restarts() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.restarts
}
// -- the loop --------------------------------------------------------------
func (s *Supervisor) supervise(ctx context.Context, done chan struct{}) {
defer close(done)
backoff := minBackoff
for {
if ctx.Err() != nil {
return
}
s.setState(Starting, nil)
started := s.opts.now()
err := s.runOnce(ctx)
ran := s.opts.now().Sub(started)
// A cancelled context means the user pressed Stop. Exiting then is
// success, not a crash, and restarting would be the single most
// annoying bug a tray app can have.
if ctx.Err() != nil {
return
}
s.mu.Lock()
s.restarts++
restarts := s.restarts
s.mu.Unlock()
if s.opts.MaxRestarts > 0 && restarts >= s.opts.MaxRestarts {
s.setState(Failed, err)
return
}
if ran >= stableRun {
backoff = minBackoff
}
s.setState(Backoff, err)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
func (s *Supervisor) runOnce(ctx context.Context) error {
cmd := s.opts.Command(ctx)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
return fmt.Errorf("engine failed to start: %w", err)
}
pumped := make(chan struct{})
go func() {
defer close(pumped)
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
fmt.Fprintln(s.opts.LogWriter, sc.Text())
}
}()
s.setState(Running, nil)
waitErr := cmd.Wait()
<-pumped
// A context cancel terminates the child through exec's own handling; the
// resulting error is expected, not a fault.
if ctx.Err() != nil {
return nil
}
if waitErr != nil {
return fmt.Errorf("engine exited: %w", waitErr)
}
return errors.New("engine exited unexpectedly with status 0")
}
func (s *Supervisor) setState(st State, err error) {
s.mu.Lock()
s.state = st
if err != nil {
s.lastErr = err
}
s.mu.Unlock()
}
// -- health ----------------------------------------------------------------
// Health is the subset of /api/health the tray and the server care about.
type Health struct {
Status string `json:"status"`
RecognitionModel string `json:"recognition_model"`
Cameras map[string]bool `json:"cameras"`
// Paths is a STRUCT, not map[string]string, because `frozen` is a bool.
// It was a map of strings, so decoding the engine's real reply failed with
// "cannot unmarshal bool into Go struct field Health.paths" - and because
// one bad field fails the whole document, a perfectly healthy engine was
// reported unreachable: red tray, and a heartbeat carrying neither the
// model nor the camera list, so head office showed 0 of 0 cameras for a
// site that was watching one.
Paths EnginePaths `json:"paths"`
}
// EnginePaths mirrors what `behavision paths` and /api/health report. Unknown
// fields are ignored by encoding/json, so the engine can add to it freely.
type EnginePaths struct {
Frozen bool `json:"frozen"`
InstallRoot string `json:"install_root"`
StateRoot string `json:"state_root"`
Config string `json:"config"`
DataDir string `json:"data_dir"`
ModelsDir string `json:"models_dir"`
}
// Health polls the engine's own API. A running process is not the same as a
// working engine: the model can fail to load and the process stays up.
func (s *Supervisor) Health(ctx context.Context) (*Health, error) {
if s.opts.HealthURL == "" {
return nil, errors.New("no health url configured")
}
var h Health
if err := s.getJSON(ctx, s.opts.HealthURL, &h); err != nil {
return nil, err
}
return &h, nil
}
// Stats is the slice of /api/stats the heartbeat carries.
//
// Only fraction_below_gate, because that is the number that decides whether a
// site's footfall can be believed at all - the share of faces its cameras saw
// and discarded before they ever became a visit. Everything else in /api/stats
// is a local diagnostic and belongs on the local dashboard, not on the wire
// every thirty seconds.
type Stats struct {
Cameras []struct {
CameraID string `json:"camera_id"`
Pipeline struct {
BestQuality struct {
N int `json:"n"`
FractionBelowGate float64 `json:"fraction_below_gate"`
} `json:"best_quality"`
} `json:"pipeline"`
} `json:"cameras"`
}
// WorstBelowGate returns the worst camera's figure, and whether any camera has
// measured enough faces to have an opinion.
//
// Worst rather than average: one badly placed camera is a hole in the report,
// and averaging it against three good ones hides the only camera anyone needs
// to move. The sample floor is there because three faces is an anecdote -
// reporting 1.00 from a single below-gate track would raise an alarm about a
// camera nobody has walked past yet.
func (s *Stats) WorstBelowGate() (float64, bool) {
const minSamples = 10
worst, found := 0.0, false
for _, c := range s.Cameras {
if c.Pipeline.BestQuality.N < minSamples {
continue
}
if !found || c.Pipeline.BestQuality.FractionBelowGate > worst {
worst, found = c.Pipeline.BestQuality.FractionBelowGate, true
}
}
return worst, found
}
// Stats polls the engine's pipeline counters.
func (s *Supervisor) Stats(ctx context.Context) (*Stats, error) {
if s.opts.StatsURL == "" {
return nil, errors.New("no stats url configured")
}
var out Stats
if err := s.getJSON(ctx, s.opts.StatsURL, &out); err != nil {
return nil, err
}
return &out, nil
}
func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
if s.opts.User != "" {
req.SetBasicAuth(s.opts.User, s.opts.Password)
}
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s returned %s", url, resp.Status)
}
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out)
}
// LogFile opens the engine log, rotating aside anything already there so one
// run's output cannot be mistaken for another's.
func LogFile(path string) (*os.File, error) {
if _, err := os.Stat(path); err == nil {
os.Rename(path, path+".1")
}
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
}

View File

@@ -0,0 +1,240 @@
package engine
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os/exec"
"sync"
"testing"
"time"
)
// sh supervises /bin/sh instead of a 200 MB frozen engine. The Command hook
// exists for exactly this.
func sh(script string) func(context.Context) *exec.Cmd {
return func(ctx context.Context) *exec.Cmd {
return exec.CommandContext(ctx, "/bin/sh", "-c", script)
}
}
func waitFor(t *testing.T, s *Supervisor, want State, within time.Duration) {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if got, _ := s.State(); got == want {
return
}
time.Sleep(5 * time.Millisecond)
}
got, err := s.State()
t.Fatalf("state %q (err %v), want %q within %s", got, err, want, within)
}
func TestItRunsAndReportsRunning(t *testing.T) {
s := New(Options{Command: sh("sleep 5")})
s.Start()
defer s.Stop()
waitFor(t, s, Running, 2*time.Second)
}
func TestStopDoesNotTriggerARestart(t *testing.T) {
// The classic supervisor bug: the user presses Stop, the child exits, the
// loop reads that as a crash and starts it again.
s := New(Options{Command: sh("sleep 30")})
s.Start()
waitFor(t, s, Running, 2*time.Second)
s.Stop()
if got, _ := s.State(); got != Stopped {
t.Fatalf("state after Stop is %q", got)
}
if n := s.Restarts(); n != 0 {
t.Fatalf("Stop counted as %d crash-restarts", n)
}
time.Sleep(200 * time.Millisecond)
if got, _ := s.State(); got != Stopped {
t.Fatalf("it restarted itself after Stop: %q", got)
}
}
func TestStopIsSynchronous(t *testing.T) {
// Stop must not return while the child still holds the SQLite WAL, or the
// next Start races the previous process.
s := New(Options{Command: sh("sleep 30")})
s.Start()
waitFor(t, s, Running, 2*time.Second)
done := make(chan struct{})
go func() { s.Stop(); close(done) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("Stop did not return")
}
}
func TestACrashIsRestarted(t *testing.T) {
s := New(Options{Command: sh("exit 1")})
s.Start()
defer s.Stop()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if s.Restarts() >= 2 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("only %d restarts - is it backing off correctly?", s.Restarts())
}
func TestItDoesNotSpinOnAProcessThatCannotStart(t *testing.T) {
// A tight restart loop on a broken install pins a core and fills the disk
// with log lines. Backoff must space the attempts out.
s := New(Options{Command: sh("exit 1")})
s.Start()
defer s.Stop()
time.Sleep(1500 * time.Millisecond)
// 1s + 2s backoff means at most ~2 attempts in 1.5s; a spin would be
// thousands.
if n := s.Restarts(); n > 4 {
t.Fatalf("%d restarts in 1.5s - not backing off", n)
}
}
func TestItGivesUpAfterMaxRestarts(t *testing.T) {
s := New(Options{Command: sh("exit 1"), MaxRestarts: 2})
s.Start()
defer s.Stop()
waitFor(t, s, Failed, 5*time.Second)
if _, err := s.State(); err == nil {
t.Fatal("Failed state carries no reason")
}
}
func TestEngineOutputIsCaptured(t *testing.T) {
// A crashed engine with no captured output means a site visit to diagnose.
var mu sync.Mutex
buf := &lockedBuf{mu: &mu}
s := New(Options{Command: sh("echo model-load-failed; exit 1"),
LogWriter: buf, MaxRestarts: 1})
s.Start()
defer s.Stop()
waitFor(t, s, Failed, 5*time.Second)
if got := buf.String(); !bytes.Contains([]byte(got), []byte("model-load-failed")) {
t.Fatalf("engine output not captured, got %q", got)
}
}
func TestStartTwiceDoesNotRunTwoEngines(t *testing.T) {
// Two engines on one SQLite WAL and one camera is the failure this whole
// package exists to prevent.
s := New(Options{Command: sh("sleep 5")})
s.Start()
s.Start()
defer s.Stop()
waitFor(t, s, Running, 2*time.Second)
if n := s.Restarts(); n != 0 {
t.Fatalf("second Start disturbed the first: %d restarts", n)
}
}
func TestHealthReportsTheModelThatActuallyLoaded(t *testing.T) {
// A running process is not a working engine: on a memory-starved box the
// big model loses the fallback chain and the process stays up regardless.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "u" || pass != "p" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Write([]byte(`{"status":"ok","recognition_model":"w600k_mbf.onnx",
"cameras":{"entrance":true}}`))
}))
defer srv.Close()
s := New(Options{Command: sh("sleep 1"), HealthURL: srv.URL,
User: "u", Password: "p"})
h, err := s.Health(context.Background())
if err != nil {
t.Fatal(err)
}
if h.RecognitionModel != "w600k_mbf.onnx" || !h.Cameras["entrance"] {
t.Fatalf("bad health: %+v", h)
}
}
func TestHealthFailsClosedOnBadCredentials(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
s := New(Options{Command: sh("true"), HealthURL: srv.URL, User: "u", Password: "wrong"})
if _, err := s.Health(context.Background()); err == nil {
t.Fatal("401 reported as healthy")
}
}
type lockedBuf struct {
mu *sync.Mutex
buf bytes.Buffer
}
func (l *lockedBuf) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.buf.Write(p)
}
func (l *lockedBuf) String() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.buf.String()
}
// The engine has to be TOLD where to post detections, and the only place that
// can happen is when the child is launched: the bridge picks a random loopback
// port after the supervisor is built, and a restarted engine has to be told
// again. This pins that the Command hook is consulted per launch rather than
// captured once - the wiring that was missing while the bridge's own doc
// comment claimed it existed.
func TestTheChildIsBuiltFreshOnEveryLaunch(t *testing.T) {
var mu sync.Mutex
url := "http://127.0.0.1:1111/e"
var seen []string
s := New(Options{Command: func(ctx context.Context) *exec.Cmd {
mu.Lock()
seen = append(seen, url)
mu.Unlock()
return exec.CommandContext(ctx, "/bin/sh", "-c", "exit 1")
}})
s.Start()
waitFor(t, s, Backoff, 2*time.Second)
// The port changes, exactly as it does when the bridge restarts.
mu.Lock()
url = "http://127.0.0.1:2222/e"
mu.Unlock()
// One backoff (1s) plus room for the relaunch.
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
n := len(seen)
mu.Unlock()
if n >= 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
s.Stop()
mu.Lock()
defer mu.Unlock()
if len(seen) < 2 {
t.Fatalf("the command hook ran %d times, so a restart could not be told a new URL", len(seen))
}
if seen[len(seen)-1] != "http://127.0.0.1:2222/e" {
t.Fatalf("the last launch used %q - the hook captured a stale value", seen[len(seen)-1])
}
}

217
agent/pkg/mqtt/client.go Normal file
View File

@@ -0,0 +1,217 @@
// Broker client: the thin adapter behind the Publisher interface.
//
// Everything that decides *what to send and when* is in pump.go and is tested
// without a broker. This file only knows how to put bytes on a topic, which is
// why it is the one part that needs a real connection to exercise.
//
// Targets Mosquitto. No clustering, no shared subscriptions, no broker-side
// rules — a store publishes its own events under its own prefix and that is
// the whole interaction.
package mqtt
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log"
neturl "net/url"
"os"
"strings"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
// ClientOptions configures a broker connection.
type ClientOptions struct {
// BrokerURL is tls://host:8883 in production, tcp://host:1883 for local
// testing only. Credentials and footfall must never cross the internet in
// the clear, so Connect refuses tcp:// to a non-loopback host.
BrokerURL string
ClientID string
Username string
Password string
// CAFile pins a private CA. Empty uses the system roots, which is what a
// Let's Encrypt certificate on the broker needs.
CAFile string
// InsecureSkipVerify disables certificate checking. Only ever for a
// self-signed staging box, and it is logged loudly when set, because a
// forgotten one silently removes the protection TLS was added for.
InsecureSkipVerify bool
// PublishTimeout bounds a single publish. Without it a half-open
// connection blocks the pump indefinitely and the queue grows behind it.
PublishTimeout time.Duration
Log *log.Logger
}
// Client implements Publisher.
type Client struct {
opts ClientOptions
client paho.Client
}
// NewClient dials the broker. It returns as soon as the connection is
// established; reconnection afterwards is automatic and the pump reads
// Connected() to decide whether to try.
func NewClient(opts ClientOptions) (*Client, error) {
if opts.BrokerURL == "" {
return nil, errors.New("mqtt: no broker url")
}
if opts.PublishTimeout <= 0 {
opts.PublishTimeout = 10 * time.Second
}
if err := checkTransport(opts.BrokerURL); err != nil {
return nil, err
}
po := paho.NewClientOptions().
AddBroker(opts.BrokerURL).
SetClientID(opts.ClientID).
SetUsername(opts.Username).
SetPassword(opts.Password).
// The broker holds no state for us: every event is already durable on
// our own disk, so a clean session avoids the broker queueing a
// second copy we would then have to de-duplicate.
SetCleanSession(true).
SetAutoReconnect(true).
SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second).
SetMaxReconnectInterval(2 * time.Minute).
SetKeepAlive(30 * time.Second).
SetConnectTimeout(15 * time.Second).
// Publishes must fail fast rather than pile up in memory while the
// link is down; the spool is what holds them, not the client.
SetMessageChannelDepth(1).
SetOrderMatters(true)
if strings.HasPrefix(opts.BrokerURL, "tls://") ||
strings.HasPrefix(opts.BrokerURL, "ssl://") {
cfg, err := tlsConfig(opts)
if err != nil {
return nil, err
}
po.SetTLSConfig(cfg)
}
c := &Client{opts: opts}
po.OnConnect = func(paho.Client) { c.logf("broker connected: %s", opts.BrokerURL) }
po.OnConnectionLost = func(_ paho.Client, err error) {
c.logf("broker connection lost: %v", err)
}
c.client = paho.NewClient(po)
tok := c.client.Connect()
if !tok.WaitTimeout(20 * time.Second) {
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL)
}
if err := tok.Error(); err != nil {
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
}
return c, nil
}
// Publish sends one message at QoS 1 and waits for the broker's PUBACK.
//
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack
// and delete an event that was dropped on the wire. QoS 2 costs two extra
// round trips to remove a duplicate the server can drop itself from the event
// id — at-least-once with idempotent consumers is the cheaper contract.
func (c *Client) Publish(ctx context.Context, topic string, payload []byte) error {
if c.client == nil {
return errors.New("mqtt: no client")
}
if !c.client.IsConnected() {
return errors.New("mqtt: not connected")
}
tok := c.client.Publish(topic, 1, false, payload)
// Honour both the caller's context and a hard timeout: a half-open TCP
// connection can leave a token that never completes, which would stall the
// pump forever with the queue growing behind it.
done := make(chan struct{})
go func() { tok.Wait(); close(done) }()
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(c.opts.PublishTimeout):
return fmt.Errorf("mqtt: publish to %s timed out", topic)
case <-done:
return tok.Error()
}
}
// Connected reports whether the broker link is up.
func (c *Client) Connected() bool {
return c.client != nil && c.client.IsConnected()
}
// Close disconnects cleanly, giving in-flight publishes a moment to land.
func (c *Client) Close() {
if c.client != nil && c.client.IsConnected() {
c.client.Disconnect(1000)
}
}
func (c *Client) logf(format string, args ...any) {
if c.opts.Log != nil {
c.opts.Log.Printf(format, args...)
}
}
// checkTransport refuses plaintext MQTT to anywhere but the local machine.
//
// The payloads carry customer visit records and the connection carries the
// tenant's broker password. A tcp:// URL to a public host is not a
// configuration choice, it is a mistake, and it is one that works — which is
// exactly why it has to be rejected here rather than noticed later.
func checkTransport(raw string) error {
if !strings.HasPrefix(raw, "tcp://") && !strings.HasPrefix(raw, "mqtt://") {
return nil
}
if os.Getenv("BEHAVISION_ALLOW_PLAINTEXT_MQTT") == "1" {
return nil
}
// url.Parse, not hand-rolled splitting: an IPv6 literal is bracketed and
// full of colons, so scanning for the first ":" turns "[::1]:1883" into
// "[" and refuses a perfectly good loopback address.
u, err := neturl.Parse(raw)
if err != nil {
return fmt.Errorf("mqtt: cannot parse broker url %q: %w", raw, err)
}
switch u.Hostname() {
case "localhost", "127.0.0.1", "::1", "":
return nil
}
return fmt.Errorf("mqtt: refusing plaintext connection to %q - use tls:// "+
"(set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1 only for local testing)",
u.Hostname())
}
func tlsConfig(opts ClientOptions) (*tls.Config, error) {
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
if opts.InsecureSkipVerify {
cfg.InsecureSkipVerify = true
if opts.Log != nil {
opts.Log.Print("WARNING: MQTT certificate verification is DISABLED")
}
return cfg, nil
}
if opts.CAFile == "" {
return cfg, nil // system roots
}
pem, err := os.ReadFile(opts.CAFile)
if err != nil {
return nil, fmt.Errorf("mqtt: ca file: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("mqtt: no certificates found in %s", opts.CAFile)
}
cfg.RootCAs = pool
return cfg, nil
}
// osWriteFile is indirected so tests can build without importing os twice.
var osWriteFile = os.WriteFile

View File

@@ -0,0 +1,104 @@
package mqtt
import (
"strings"
"testing"
)
func TestPlaintextToAPublicHostIsRefused(t *testing.T) {
// The payloads carry customer visit records and the connection carries the
// tenant's broker password. A tcp:// URL to a public host is not a config
// choice, it is a mistake — and one that WORKS, which is exactly why it
// has to fail here rather than be noticed after a year of traffic.
for _, url := range []string{
"tcp://broker.example.com:1883",
"mqtt://66.116.226.234:1883",
"tcp://10.0.0.5:1883",
"tcp://[2001:db8::1]:1883",
} {
if _, err := NewClient(ClientOptions{BrokerURL: url}); err == nil ||
!strings.Contains(err.Error(), "refusing plaintext") {
t.Errorf("%s was not refused (err=%v)", url, err)
}
}
}
func TestPlaintextToLocalhostIsAllowed(t *testing.T) {
// Local testing against a Mosquitto on the same box crosses no network.
// Checked at the transport gate rather than through NewClient: dialling a
// port nothing is listening on burns the full 20s connect timeout, and a
// slow test is a test people start skipping.
for _, url := range []string{"tcp://127.0.0.1:1883", "tcp://localhost:1883",
"mqtt://[::1]:1883"} {
if err := checkTransport(url); err != nil {
t.Errorf("loopback %s was refused: %v", url, err)
}
}
}
func TestPlaintextEscapeHatchIsExplicit(t *testing.T) {
// An override must exist for a lab, but it has to be a deliberate act,
// not a config field someone leaves set.
t.Setenv("BEHAVISION_ALLOW_PLAINTEXT_MQTT", "1")
if err := checkTransport("tcp://broker.example.com:1883"); err != nil {
t.Fatalf("escape hatch did not apply: %v", err)
}
}
func TestTLSUrlsSkipTheTransportCheck(t *testing.T) {
for _, url := range []string{"tls://b:8883", "ssl://b:8883", "wss://b:443"} {
if err := checkTransport(url); err != nil {
t.Errorf("%s rejected: %v", url, err)
}
}
}
func TestAnEmptyBrokerUrlIsAnError(t *testing.T) {
if _, err := NewClient(ClientOptions{}); err == nil {
t.Fatal("empty broker url accepted")
}
}
func TestTLSConfigRejectsAnUnreadableCA(t *testing.T) {
// Silently falling back to system roots when a pinned CA is missing would
// quietly undo the pinning.
if _, err := tlsConfig(ClientOptions{CAFile: "/nonexistent/ca.pem"}); err == nil {
t.Fatal("missing CA file accepted")
}
}
func TestTLSConfigRejectsAFileWithNoCertificates(t *testing.T) {
f := t.TempDir() + "/not-a-cert.pem"
if err := writeFile(f, "hello"); err != nil {
t.Fatal(err)
}
if _, err := tlsConfig(ClientOptions{CAFile: f}); err == nil {
t.Fatal("a file with no PEM certificates was accepted as a CA")
}
}
func TestTLSFloorIsTLS12(t *testing.T) {
cfg, err := tlsConfig(ClientOptions{})
if err != nil {
t.Fatal(err)
}
if cfg.MinVersion < 0x0303 {
t.Fatalf("MinVersion %#x allows TLS below 1.2", cfg.MinVersion)
}
}
func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
// The pump calls this on every tick; a nil-client panic would take the
// whole agent down instead of backing off.
c := &Client{}
if err := c.Publish(nil, "t", []byte("{}")); err == nil { //nolint:staticcheck
t.Fatal("publish on an unconnected client reported success")
}
if c.Connected() {
t.Fatal("an unconnected client reported Connected")
}
}
func writeFile(path, content string) error {
return osWriteFile(path, []byte(content), 0o600)
}

218
agent/pkg/mqtt/pump.go Normal file
View File

@@ -0,0 +1,218 @@
// Package mqtt moves queued events to the broker.
//
// Split from the broker client on purpose: everything that decides *what to
// send and when* lives here and is testable without a broker, while the paho
// binding is a thin adapter that only knows how to put bytes on a topic.
package mqtt
import (
"context"
"errors"
"log"
"time"
"github.com/loyaly/behavision-agent/pkg/spool"
)
// Publisher is the broker, reduced to what the pump needs.
type Publisher interface {
// Publish must return nil only once the broker has confirmed receipt.
// Returning early would let the pump ack an event that never arrived.
Publish(ctx context.Context, topic string, payload []byte) error
Connected() bool
}
// Queue is the durable side, reduced likewise.
type Queue interface {
Peek(n int) ([]spool.Entry, error)
Ack(seqs ...uint64) error
Len() int
Dropped() uint64
}
const (
batchSize = 32
idleInterval = 2 * time.Second
minRetry = 1 * time.Second
maxRetry = 30 * time.Second
defaultHeartbe = 60 * time.Second
)
// Pump drains the queue into the broker and emits a heartbeat.
type Pump struct {
Queue Queue
Publisher Publisher
// Heartbeat topic. Without it "the site is offline" and "nobody visited"
// are indistinguishable on the server, which for a footfall product is a
// silent hole in the customer's report.
HeartbeatTopic string
HeartbeatPayload func() []byte
HeartbeatInterval time.Duration
Log *log.Logger
// Wake, when set, makes the pump drain immediately instead of waiting out
// idleInterval. Without it a visit that lands one millisecond after a drain
// sits on disk for two seconds before anyone is told - and that delay is on
// the path a shop screen or a mobile app sees as "how long after someone
// walks in does their face appear".
//
// A doorbell, not a queue: it carries nothing, because the pump re-reads
// the spool either way. Buffered by one and written non-blockingly, so a
// burst of arrivals cannot stall the recognition pipeline behind a pump
// that is mid-publish.
Wake <-chan struct{}
}
// Waker is the writing end of the Wake channel, held by whatever appends to the
// queue. NewWaker returns both halves so a caller cannot accidentally build one
// that blocks its own producer.
type Waker struct{ ch chan struct{} }
func NewWaker() *Waker { return &Waker{ch: make(chan struct{}, 1)} }
// Wake rings the pump. Never blocks: a full slot already means "there is work",
// which is the entire message, so a second ring adds nothing.
func (w *Waker) Wake() {
if w == nil {
return
}
select {
case w.ch <- struct{}{}:
default:
}
}
// C is the channel to hand the pump.
func (w *Waker) C() <-chan struct{} {
if w == nil {
return nil
}
return w.ch
}
// Run drains until ctx is cancelled.
func (p *Pump) Run(ctx context.Context) {
interval := p.HeartbeatInterval
if interval <= 0 {
interval = defaultHeartbe
}
beat := time.NewTicker(interval)
defer beat.Stop()
retry := minRetry
for {
if ctx.Err() != nil {
return
}
// Non-blocking, for the case where there is a backlog and the loop
// never reaches the waiting select below.
select {
case <-beat.C:
p.heartbeat(ctx)
default:
}
sent, err := p.drainOnce(ctx)
if ctx.Err() != nil {
return
}
var wait time.Duration
switch {
case err != nil:
// The broker is down or refusing. Back off rather than spinning:
// a store with no internet would otherwise burn a core all night.
p.logf("publish failed, retrying in %s: %v", retry, err)
wait = retry
if retry < maxRetry {
retry *= 2
if retry > maxRetry {
retry = maxRetry
}
}
case sent == 0:
retry = minRetry
wait = idleInterval
default:
// Something went through; there may be more waiting, so loop
// immediately rather than sleeping through a backlog.
retry = minRetry
}
if wait == 0 {
continue
}
// The heartbeat must be able to interrupt this wait. Sleeping through
// it would delay every beat by the idle interval, and on a quiet site
// the pump is idle essentially always.
// A nil Wake channel blocks forever in a select, which is exactly the
// right behaviour: an agent with no waker falls back to the timer.
select {
case <-ctx.Done():
return
case <-beat.C:
p.heartbeat(ctx)
case <-p.Wake:
// Something was queued. Loop straight round and drain it rather
// than sleeping out the rest of the idle interval.
case <-time.After(wait):
}
}
}
// drainOnce sends at most one batch and returns how many were acked.
func (p *Pump) drainOnce(ctx context.Context) (int, error) {
if !p.Publisher.Connected() {
return 0, errors.New("broker not connected")
}
entries, err := p.Queue.Peek(batchSize)
if err != nil || len(entries) == 0 {
return 0, err
}
sent := 0
for _, e := range entries {
if err := p.Publisher.Publish(ctx, e.Topic, e.Payload); err != nil {
// Stop at the first failure instead of skipping past it. Events
// are a per-visitor timeline and the server reads them in order;
// publishing around a stuck one would reorder a customer's visits.
return sent, err
}
// Acked one at a time, immediately after its own confirmation. A batch
// ack would re-send everything before a mid-batch failure on restart.
if err := p.Queue.Ack(e.Seq); err != nil {
return sent, err
}
sent++
}
return sent, nil
}
func (p *Pump) heartbeat(ctx context.Context) {
if p.HeartbeatTopic == "" || p.HeartbeatPayload == nil {
return
}
if !p.Publisher.Connected() {
return
}
// Not queued: a heartbeat is only meaningful now. Spooling them would
// replay a week of "I am alive" the moment a site reconnects.
if err := p.Publisher.Publish(ctx, p.HeartbeatTopic, p.HeartbeatPayload()); err != nil {
p.logf("heartbeat failed: %v", err)
}
}
func (p *Pump) logf(format string, args ...any) {
if p.Log != nil {
p.Log.Printf(format, args...)
}
}
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}

288
agent/pkg/mqtt/pump_test.go Normal file
View File

@@ -0,0 +1,288 @@
package mqtt
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/loyaly/behavision-agent/pkg/spool"
)
type fakeBroker struct {
mu sync.Mutex
connected bool
sent []string
failAfter int // fail every publish once this many have succeeded
err error
}
func (f *fakeBroker) Publish(ctx context.Context, topic string, payload []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failAfter > 0 && len(f.sent) >= f.failAfter {
if f.err != nil {
return f.err
}
return errors.New("broker refused")
}
f.sent = append(f.sent, string(payload))
return nil
}
func (f *fakeBroker) Connected() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.connected
}
func (f *fakeBroker) delivered() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.sent...)
}
func queue(t *testing.T, payloads ...string) *spool.Spool {
t.Helper()
s, err := spool.Open(t.TempDir(), 100)
if err != nil {
t.Fatal(err)
}
for _, p := range payloads {
if err := s.Append("visit", p); err != nil {
t.Fatal(err)
}
}
return s
}
func TestItDrainsInOrderAndAcks(t *testing.T) {
q := queue(t, "a", "b", "c")
b := &fakeBroker{connected: true}
p := &Pump{Queue: q, Publisher: b}
sent, err := p.drainOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if sent != 3 || q.Len() != 0 {
t.Fatalf("sent %d, %d left in queue", sent, q.Len())
}
got := b.delivered()
if len(got) != 3 || got[0] != `"a"` || got[2] != `"c"` {
t.Fatalf("wrong order: %v", got)
}
}
func TestNothingIsAckedWhileTheBrokerIsDown(t *testing.T) {
// Acking an event the broker never took is how footfall disappears.
q := queue(t, "a", "b")
b := &fakeBroker{connected: false}
p := &Pump{Queue: q, Publisher: b}
if _, err := p.drainOnce(context.Background()); err == nil {
t.Fatal("a disconnected broker was treated as success")
}
if q.Len() != 2 {
t.Fatalf("events were dropped while offline: %d left", q.Len())
}
}
func TestAFailureStopsTheBatchInsteadOfSkippingPast(t *testing.T) {
// Events are a per-visitor timeline read in order; publishing around a
// stuck one would reorder a customer's visits on the server.
q := queue(t, "a", "b", "c")
b := &fakeBroker{connected: true, failAfter: 1}
p := &Pump{Queue: q, Publisher: b}
sent, err := p.drainOnce(context.Background())
if err == nil {
t.Fatal("failure not reported")
}
if sent != 1 {
t.Fatalf("sent %d, want 1 before stopping", sent)
}
if q.Len() != 2 {
t.Fatalf("%d left in queue, want the 2 unsent", q.Len())
}
// And the survivors are the RIGHT two, still in order.
rest, _ := q.Peek(10)
if string(rest[0].Payload) != `"b"` {
t.Fatalf("queue head is %s, want b", rest[0].Payload)
}
}
func TestConfirmedEventsSurviveAMidBatchFailure(t *testing.T) {
// Acking per-event rather than per-batch: a batch ack would re-send
// everything before the failure after a restart, duplicating footfall.
q := queue(t, "a", "b", "c")
b := &fakeBroker{connected: true, failAfter: 2}
p := &Pump{Queue: q, Publisher: b}
p.drainOnce(context.Background())
if q.Len() != 1 {
t.Fatalf("%d left, want only the unsent one", q.Len())
}
b.failAfter = 0
sent, err := p.drainOnce(context.Background())
if err != nil || sent != 1 {
t.Fatalf("recovery sent %d (%v)", sent, err)
}
got := b.delivered()
if len(got) != 3 {
t.Fatalf("delivered %v - duplicates or losses", got)
}
}
func TestRunRecoversWhenTheBrokerComesBack(t *testing.T) {
q := queue(t, "a")
b := &fakeBroker{connected: false}
p := &Pump{Queue: q, Publisher: b}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Run(ctx)
time.Sleep(100 * time.Millisecond)
if len(b.delivered()) != 0 {
t.Fatal("published while disconnected")
}
b.mu.Lock()
b.connected = true
b.mu.Unlock()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if len(b.delivered()) == 1 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("queue never drained after the broker returned")
}
func TestHeartbeatIsSentSeparatelyFromTheQueue(t *testing.T) {
// "Site offline" and "nobody visited" must be distinguishable on the
// server. And a heartbeat is only meaningful now, so it is never spooled -
// otherwise a reconnecting site replays a week of "I am alive".
q := queue(t)
b := &fakeBroker{connected: true}
p := &Pump{Queue: q, Publisher: b,
HeartbeatTopic: "site/alive",
HeartbeatPayload: func() []byte { return []byte(`{"up":true}`) },
HeartbeatInterval: 20 * time.Millisecond}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Run(ctx)
time.Sleep(200 * time.Millisecond)
if len(b.delivered()) == 0 {
t.Fatal("no heartbeat was sent")
}
if q.Len() != 0 {
t.Fatal("heartbeats were written to the durable queue")
}
}
func TestRunStopsPromptlyOnCancel(t *testing.T) {
q := queue(t)
b := &fakeBroker{connected: true}
p := &Pump{Queue: q, Publisher: b}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { p.Run(ctx); close(done) }()
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("Run ignored cancellation")
}
}
// ---------------------------------------------------------------- waking
// The delay this removes is on the path between a person walking in and their
// face reaching a screen, so the test asserts a real wall-clock bound rather
// than that a channel was read.
func TestAWakeDrainsWithoutWaitingOutTheIdleInterval(t *testing.T) {
q := queue(t)
pub := &fakeBroker{connected: true}
waker := NewWaker()
p := &Pump{Queue: q, Publisher: pub, Wake: waker.C(),
HeartbeatInterval: time.Hour}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Run(ctx)
// Let it reach the idle wait with an empty queue first, so what follows is
// genuinely the wake path and not the drain it does on startup.
waitUntil(t, func() bool { return len(pub.delivered()) == 0 }, time.Second)
time.Sleep(50 * time.Millisecond)
start := time.Now()
if err := q.Append("visit", "e1"); err != nil {
t.Fatal(err)
}
waker.Wake()
waitUntil(t, func() bool { return len(pub.delivered()) == 1 }, 2*time.Second)
if took := time.Since(start); took >= idleInterval {
t.Fatalf("took %s - the wake did not beat the %s idle tick", took, idleInterval)
}
}
// A pump with no waker must behave exactly as it did before: a nil channel
// blocks forever in a select, which is the correct fallback, not a hang.
func TestAPumpWithNoWakerStillDrainsOnItsTimer(t *testing.T) {
q := queue(t)
pub := &fakeBroker{connected: true}
p := &Pump{Queue: q, Publisher: pub, HeartbeatInterval: time.Hour}
if err := q.Append("visit", "e1"); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Run(ctx)
waitUntil(t, func() bool { return len(pub.delivered()) == 1 }, 3*time.Second)
}
// The waker runs on the engine's webhook request. If it could ever block, a
// burst of arrivals would apply backpressure into the recognition loop.
func TestWakingNeverBlocksEvenWithNobodyListening(t *testing.T) {
waker := NewWaker()
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 10000; i++ {
waker.Wake()
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Wake blocked with no pump reading - this would stall recognition")
}
}
func TestANilWakerIsSafe(t *testing.T) {
var w *Waker
w.Wake() // an agent assembled without one must still run
if w.C() != nil {
t.Fatal("a nil waker handed out a channel")
}
}
func waitUntil(t *testing.T, cond func() bool, within time.Duration) {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("condition not met within %s", within)
}

79
agent/pkg/paths/paths.go Normal file
View File

@@ -0,0 +1,79 @@
// Package paths mirrors behavision/paths.py.
//
// The two processes must agree on where state lives or they will quietly use
// different databases: the engine would write footfall into one file while the
// agent reads another and reports an empty store. The rule is the same on both
// sides — BEHAVISION_DATA_DIR wins, then %PROGRAMDATA%\Behavision on Windows —
// and `behavision paths` prints the engine's answer so the two can be compared
// on a real machine rather than assumed equal.
package paths
import (
"os"
"path/filepath"
"runtime"
)
const AppName = "Behavision"
// StateRoot is the writable root: database, logs, spool, agent config.
func StateRoot() string {
if v := os.Getenv("BEHAVISION_DATA_DIR"); v != "" {
if abs, err := filepath.Abs(v); err == nil {
return abs
}
return v
}
if runtime.GOOS == "windows" {
base := os.Getenv("PROGRAMDATA")
if base == "" {
base = `C:\ProgramData`
}
return filepath.Join(base, AppName)
}
home, err := os.UserHomeDir()
if err != nil {
return "."
}
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support", AppName)
}
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
return filepath.Join(v, "behavision")
}
return filepath.Join(home, ".local", "share", "behavision")
}
// InstallRoot is the directory holding this executable.
func InstallRoot() string {
exe, err := os.Executable()
if err != nil {
return "."
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return filepath.Dir(exe)
}
func AgentConfig() string { return filepath.Join(StateRoot(), "agent.json") }
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
// APICredentials is the file the engine writes when it generates its own
// Basic credentials. The agent reads it rather than storing a second copy,
// so a regenerated credential does not silently break the tray.
func APICredentials() string {
return filepath.Join(StateRoot(), "data", "api_credentials.txt")
}
// EnsureState creates the writable tree. Called before anything opens a file
// under it, so a first run on a fresh machine does not fail on a missing dir.
func EnsureState() error {
for _, d := range []string{StateRoot(), SpoolDir()} {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,43 @@
package paths
import (
"path/filepath"
"strings"
"testing"
)
func TestDataDirEnvWins(t *testing.T) {
// The override is what lets one machine run two instances, and what makes
// the installed layout testable from a checkout - on both sides.
dir := t.TempDir()
t.Setenv("BEHAVISION_DATA_DIR", dir)
if got := StateRoot(); got != dir {
t.Fatalf("StateRoot() = %q, want %q", got, dir)
}
}
func TestEverythingLivesUnderTheStateRoot(t *testing.T) {
dir := t.TempDir()
t.Setenv("BEHAVISION_DATA_DIR", dir)
for name, got := range map[string]string{
"agent config": AgentConfig(),
"spool": SpoolDir(),
"engine log": EngineLog(),
"credentials": APICredentials(),
} {
if !strings.HasPrefix(got, dir) {
t.Errorf("%s resolved outside the state root: %s", name, got)
}
}
}
func TestEnsureStateIsIdempotent(t *testing.T) {
dir := filepath.Join(t.TempDir(), "fresh")
t.Setenv("BEHAVISION_DATA_DIR", dir)
if err := EnsureState(); err != nil {
t.Fatal(err)
}
if err := EnsureState(); err != nil {
t.Fatalf("second call failed: %v", err)
}
}