Files
Behavision/desktop/internal/cloud/client.go
Suriyakumarvijayanayagam dad04e8cda 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
2026-09-04 11:14:18 +05:30

505 lines
16 KiB
Go

// Package cloud talks to the Behavision server at mcp.loyaly.ai.
//
// Everything a store PC sends to head office goes over MQTT; this is the
// request/response half — logging in, reading reports, saving the customer
// form. A store PC never holds database credentials, so every one of these is
// a call the server authorises against the session token.
package cloud
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// ErrUnauthorized means the session is gone. The UI shows the login sheet
// again rather than an error dialog - an expired token is an ordinary event,
// not a fault.
var ErrUnauthorized = errors.New("session expired")
type Client struct {
Base string
http *http.Client
mu sync.RWMutex
token string
refresh string
user User
// Held across a whole refresh so concurrent screens cannot each spend the
// single-use refresh token.
refreshMu sync.Mutex
onRefresh func(Session)
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
ClientID string `json:"client_id"`
Client string `json:"client_name"`
}
type Session struct {
Token string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User User `json:"user"`
}
func New(base string) *Client {
return &Client{
Base: strings.TrimRight(base, "/"),
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) SetSession(s Session) {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = s.Token, s.RefreshToken, s.User
}
func (c *Client) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = "", "", User{}
}
func (c *Client) User() User {
c.mu.RLock()
defer c.mu.RUnlock()
return c.user
}
func (c *Client) LoggedIn() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token != ""
}
// do sends a request, refreshing the session once if the access token has
// expired.
//
// The body is marshalled up front and kept, because a retry has to send it
// again and an io.Reader is spent after the first attempt - a bug that only
// shows up twelve hours after a shop PC was last touched, which is the worst
// possible time to find it.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
return err
}
}
err := c.send(ctx, method, path, raw, out)
if !errors.Is(err, errTokenExpired) {
return err
}
if rerr := c.Refresh(ctx); rerr != nil {
// The refresh token is gone too, so this really is a sign-in, not a
// transient failure. Report it as such so the UI shows the login sheet
// rather than an error dialog.
return ErrUnauthorized
}
return c.send(ctx, method, path, raw, out)
}
// APIError carries the server's machine-readable code alongside the prose.
//
// Some codes are not failures at all: a customer with no photo is the default
// configuration of this product, not a fault, and a caller cannot tell that
// from the message text. Error() still returns the server's own words, so
// anything that only prints the error is unaffected.
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string { return e.Message }
// codeOf reports the server's error code, or "" for anything else.
func codeOf(err error) string {
var ae *APIError
if errors.As(err, &ae) {
return ae.Code
}
return ""
}
// errTokenExpired is internal: callers see either success or ErrUnauthorized.
// An expiring access token is an ordinary event that the client handles on its
// own, not something every screen should have to know about.
var errTokenExpired = errors.New("access token expired")
func (c *Client) send(ctx context.Context, method, path string, raw []byte, out any) error {
var rdr io.Reader
if raw != nil {
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
if err != nil {
return err
}
if raw != nil {
req.Header.Set("Content-Type", "application/json")
}
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("cannot reach %s: %w", c.Base, err)
}
defer resp.Body.Close()
// The server returns {error, message, detail}; showing `message` puts the
// server's own words in front of the user instead of a status code.
var e struct {
Message string `json:"message"`
Error string `json:"error"`
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
_ = json.Unmarshal(body, &e)
}
switch {
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
return errTokenExpired
case resp.StatusCode == http.StatusUnauthorized:
return ErrUnauthorized
case resp.StatusCode >= 400:
msg := e.Message
if msg == "" {
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
}
return &APIError{Status: resp.StatusCode, Code: e.Error, Message: msg}
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
}
// Refresh swaps the refresh token for a new pair.
//
// Serialised behind refreshMu so a screen that fires four polls at once does
// not spend the refresh token four times - the server rotates it on use, so
// three of those four would race and lose, logging the shop out at random.
func (c *Client) Refresh(ctx context.Context) error {
c.refreshMu.Lock()
defer c.refreshMu.Unlock()
c.mu.RLock()
before, refresh := c.token, c.refresh
c.mu.RUnlock()
if refresh == "" {
return ErrUnauthorized
}
var s Session
if err := c.send(ctx, http.MethodPost, "/api/auth/refresh",
mustJSON(map[string]string{"refresh_token": refresh}), &s); err != nil {
return err
}
c.mu.Lock()
// Another goroutine may have refreshed while this one waited on the lock;
// its tokens are the live ones and must not be overwritten by ours.
if c.token == before {
c.token, c.refresh = s.Token, s.RefreshToken
if s.User.Email != "" {
c.user = s.User
}
}
c.mu.Unlock()
if c.onRefresh != nil {
// So the caller can persist the rotated tokens. Without this a PC that
// refreshes and then reboots comes back holding a refresh token the
// server already invalidated.
c.onRefresh(s)
}
return nil
}
// OnRefresh registers a callback fired whenever the session rotates.
func (c *Client) OnRefresh(fn func(Session)) { c.onRefresh = fn }
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err) // a map of strings cannot fail to marshal
}
return b
}
func (c *Client) Login(ctx context.Context, email, password string) (Session, error) {
var s Session
err := c.do(ctx, http.MethodPost, "/api/auth/login",
map[string]string{"email": email, "password": password}, &s)
if err != nil {
return Session{}, err
}
c.SetSession(s)
return s, nil
}
func (c *Client) Me(ctx context.Context) (User, error) {
var u User
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
if err == nil {
c.mu.Lock()
c.user = u
c.mu.Unlock()
}
return u, err
}
// Logout revokes the session server-side as well as forgetting it here.
// Clearing only the local copy leaves a live token on a machine somebody is
// about to hand back.
func (c *Client) Logout(ctx context.Context) error {
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
c.Clear()
return err
}
// Session returns the current tokens so the caller can persist them.
func (c *Client) Session() Session {
c.mu.RLock()
defer c.mu.RUnlock()
return Session{Token: c.token, RefreshToken: c.refresh, User: c.user}
}
// Bootstrap is what a freshly installed PC asks for after the operator logs
// in: which models to fetch, and the broker credentials for this site. The
// installer ships none of this, so a leaked build hands out nothing.
type Bootstrap struct {
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
SiteSlug string `json:"site_slug"`
// ClientSlug and SiteSlug are what the agent's topic prefix is built from,
// and the server derives ClientSlug from the broker username so the two
// cannot disagree with the broker's ACL.
ClientSlug string `json:"client_slug"`
MQTTURL string `json:"mqtt_url"`
MQTTUser string `json:"mqtt_username"`
MQTTPass string `json:"mqtt_password"`
// AgentToken is this PC's own credential for the HTTPS API - asking for an
// image upload URL, pulling its camera list. Not the broker password: they
// authenticate different things, so rotating one must not break the other.
AgentToken string `json:"agent_token"`
CACert string `json:"ca_cert"`
Models []Model `json:"models"`
}
type Model struct {
Name string `json:"name"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
Bytes int64 `json:"bytes"`
}
func (c *Client) Bootstrap(ctx context.Context, siteToken string) (Bootstrap, error) {
var b Bootstrap
return b, c.do(ctx, http.MethodPost, "/api/agent/enrol",
map[string]string{"site_token": siteToken}, &b)
}
type FootfallPoint struct {
Bucket string `json:"bucket"`
Visitors int `json:"visitors"`
New int `json:"new"`
Returning int `json:"returning"`
}
type FootfallReport struct {
From string `json:"from"`
To string `json:"to"`
Bucket string `json:"bucket"`
TZ string `json:"timezone"`
Points []FootfallPoint `json:"points"`
// Total is unique people over the whole window; Visits counts every
// appearance. Summing Points gives neither - a customer who came on Monday
// and Thursday is one Total and two bucket-visitors - so both ship rather
// than letting a screen add up the chart and call it a headcount.
Total int `json:"total"`
Visits int `json:"visits"`
// Share of faces the cameras saw that fell below the enrolment gate. A
// footfall figure from a badly placed camera is wrong in a way nobody can
// see, so the number ships with its own confidence.
FractionBelowGate float64 `json:"fraction_below_gate"`
WorstSite string `json:"worst_site,omitempty"`
}
// SiteHealth distinguishes "no customers" from "this shop's PC has been
// unplugged for a week" - two identical rows of zeroes with completely
// different responses.
type SiteHealth struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Timezone string `json:"timezone"`
Online bool `json:"online"`
LastHeartbeatAt string `json:"last_heartbeat_at"`
LastEventAt string `json:"last_event_at"`
RecognitionModel string `json:"recognition_model"`
AgentVersion string `json:"agent_version"`
CamerasUp int `json:"cameras_up"`
CamerasTotal int `json:"cameras_total"`
FractionBelowGate float64 `json:"fraction_below_gate"`
Queued int `json:"queued"`
Dropped int64 `json:"dropped"`
}
func (c *Client) Sites(ctx context.Context) ([]SiteHealth, error) {
var out []SiteHealth
return out, c.do(ctx, http.MethodGet, "/api/sites", nil, &out)
}
// Visit is one appearance in a customer's timeline.
type Visit struct {
ID string `json:"id"`
OccurredAt string `json:"occurred_at"`
Site string `json:"site"`
CameraID string `json:"camera_id"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity"`
Quality float64 `json:"quality"`
Attributes map[string]any `json:"attributes"`
}
// Photo is a customer's face image, or a plain statement that there isn't one.
//
// Absence is modelled as data rather than as an error because it is the
// ordinary case: images are off by default, so most deployments answer
// "no photo" for every customer forever. Returning an error there would put a
// red failure box on screen for a system working exactly as configured, and a
// UI that cries wolf is a UI whose real errors get ignored.
type Photo struct {
URL string `json:"url"`
ExpiresIn int `json:"expires_in"`
Available bool `json:"available"`
Reason string `json:"reason"`
}
// VisitorImage fetches a short-lived signed link to this customer's photo.
//
// The link expires (the server decides how soon, and says so), so it is
// fetched when a screen opens rather than cached alongside the customer.
func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
var out Photo
err := c.do(ctx, http.MethodGet,
"/api/visitors/"+url.PathEscape(id)+"/image", nil, &out)
if err != nil {
switch codeOf(err) {
case "no_image":
return Photo{Reason: "No photo of this customer has been captured."}, nil
case "images_disabled":
return Photo{Reason: "This system is not storing customer photos."}, nil
}
return Photo{}, err
}
out.Available = out.URL != ""
return out, nil
}
// ForgetVisitor erases a customer: face template, photo and profile.
//
// Irreversible by design — a soft-deleted face template is a retained
// photograph by another name, because template inversion reconstructs a
// recognisable face from it. The server refuses the whole request rather than
// report a partial erasure, so an error here means nothing was deleted.
func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete,
"/api/visitors/"+url.PathEscape(id), nil, nil)
}
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
var out []Visit
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors/%s/history?limit=%d", url.PathEscape(id), limit),
nil, &out)
}
func (c *Client) Footfall(ctx context.Context, from, to, bucket string) (FootfallReport, error) {
var r FootfallReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/footfall?from=%s&to=%s&bucket=%s", from, to, bucket),
nil, &r)
}
type SalesReport struct {
Visitors int `json:"visitors"`
Purchasers int `json:"purchasers"`
Conversion float64 `json:"conversion"`
Revenue float64 `json:"revenue"`
AvgBasket float64 `json:"average_basket"`
Currency string `json:"currency"`
}
func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error) {
var r SalesReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/conversion?from=%s&to=%s", from, to), nil, &r)
}
type Customer struct {
ID string `json:"id"`
Label string `json:"label"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
VisitCount int `json:"visit_count"`
FirstSeenAt string `json:"first_seen_at"`
LastSeenAt string `json:"last_seen_at"`
HasProfile bool `json:"has_profile"`
HasConsent bool `json:"has_consent"`
}
func (c *Client) Customers(ctx context.Context, query string, limit int) ([]Customer, error) {
var out []Customer
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors?q=%s&limit=%d",
url.QueryEscape(query), limit), nil, &out)
}
// Profile is the in-store form. PUT rather than POST: a staff member
// resubmitting on a bad connection must not create a second record for the
// same person.
type Profile struct {
VisitorID string `json:"visitor_id"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
Gender string `json:"gender"`
DateOfBirth string `json:"date_of_birth"`
Notes string `json:"notes"`
Consent bool `json:"consent"`
}
func (c *Client) SaveProfile(ctx context.Context, p Profile) error {
return c.do(ctx, http.MethodPut,
"/api/visitors/"+url.PathEscape(p.VisitorID)+"/profile", p, nil)
}
func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
amount float64, items []string, notes string) error {
return c.do(ctx, http.MethodPost, "/api/purchases", map[string]any{
"visitor_id": visitorID, "amount": amount,
"items": items, "source": "manual", "notes": notes,
}, nil)
}