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
292 lines
9.2 KiB
Go
292 lines
9.2 KiB
Go
// 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...)
|
|
}
|
|
}
|