Files
Behavision/agent/pkg/cameras/cameras.go
Suriyakumarvijayanayagam e0ceb14589 Camera pictures without an object-storage bucket
Head office shows a camera's latest frame rather than live video, for a
reason that has not changed: the engine serves MJPEG on 127.0.0.1 on a PC
behind a shop's router with no inbound route, and relaying it needs
WebRTC/TURN. Pointing a browser straight at the shop PC is not the escape
either - the engine's API is Basic-authenticated with a credential it
generates locally and never sends anywhere, and shipping that to the
cloud so a web page could use it would put the key to the biometric API
and the live face feed in the server's database.

But that picture only worked if you had an S3 bucket. Without one,
attachSnapshots reported "This system is not storing images" for every
camera forever - on the two screens whose whole job is to show the
camera. Making them picture-led turned a missing feature into a wall of
empty tiles, on every local install and any self-hosted customer who does
not want a bucket.

migrations/009 adds camera_snapshots and the agent falls back to
PUT /api/agent/cameras/{camera}/snapshot when the presigned route answers
images_disabled - chosen by sentinel, never by matching the message, since
it picks between two routes. One row per camera is what makes this safe in
the database when face images are not: the key IS the camera, so storage
is (cameras x ~100 KB) and does not grow with footfall.

The read is session-authenticated rather than a signed link, which an
<img> cannot use - hence Shot.jsx and useAuthedImage, keyed on the URL
string rather than the snapshot object so a poll does not re-fetch 90 KB
per camera every few seconds, and revoking the object URL on cleanup.

Verified against the real office camera with no bucket configured: 90,587
bytes stored in Postgres, served as image/jpeg to a signed-in user, 401
without a session, rendered on both the Cameras and Shops cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 12:53:18 +05:30

301 lines
9.8 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 puts a JPEG in object storage and names it. Used for the
// placement check's proof picture, which is transient.
UploadSnapshot(ctx context.Context, jpeg []byte) (key string, err error)
// PutSnapshot gets a camera's latest frame to head office by whichever
// route this deployment has - the bucket, or the server itself when there
// is none. An empty key means the server already stored it, so the state
// report has nothing to carry.
PutSnapshot(ctx context.Context, cameraID string, 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 {
// An empty key is not a failure: it means this deployment has
// no object storage and the server stored the picture itself.
if key, err := s.Cloud.PutSnapshot(ctx, c.ID, 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...)
}
}