Two changes, and the second was found by verifying the first. ## Watching a camera from the app, in another building Snapshots answer "is that camera working". They do not answer "what is happening in my shop right now", which is what somebody who opens the app away from the counter is asking. Head office's browser already had that answer - LiveHub plus cameras.Live, where the shop PC asks outbound whether anybody is watching and pushes JPEG frames for as long as somebody is - and the app could not reach it. cloud.CameraLive opens that feed and the app's own loopback relay re-emits it as multipart MJPEG. That is the trick: frames arrive base64 over SSE, an <img> cannot render that, and an <img> renders MJPEG natively - so a tile is an ordinary <img> pointed at loopback whether the camera is in this room or another city. - Reconnecting happens in the relay, not the page. The server caps one push at five minutes, so doing it here means the <img> never sees the stream end. - The headers are flushed before the first frame. Go writes them on the first body write, so without that the whole response waits for the shop PC to start pushing. Measured against production: 30 seconds and not even a Content-Type, which surfaces as the request timing out. - One camera at a time. Watching makes a shop PC upload, so a grid that went live at once would put an estate's worth of cameras on the wire because somebody opened a page. - live.mjpeg is behind the same per-run token as the engine routes, and a wrong token is a 404 that never reaches head office at all. - CameraLive uses its own HTTP client: the shared one's 30s timeout covers the whole response and would sever a working view every thirty seconds - the trap that made the server set WriteTimeout to zero for its own SSE endpoint. ## A camera read "Connected" for 34 minutes after the shop PC went blind Which is why the verification above looked like a failure: head office registered the viewer and no frame ever came. reportWith returns early when the engine is unreachable - correctly, it has nothing to say - so the last state it sent stays in the database looking current. Measured live: cam2 and entrance both reading Connected, in green, with last_seen_at 34 minutes old, while the heartbeat from the same PC said cameras_up 0 of 0. Two surfaces reading two stored fields and disagreeing. false could not be the answer. It means "this camera is not connecting", which sends an installer to check cabling on a camera that was working perfectly the last time anybody could ask it. So there are four states and one function: connected reported recently, and working not_connecting reported recently, and the stream will not open waiting no shop PC has ever reported this camera stale reported once, and not lately - Connected is CLEARED when stale or waiting. A stale true left in place stays available to every client reading the field directly, and leaves two fields on one object disagreeing - how the shops screen once came out labelled Working, in green, above "2 of 3 cameras not connecting". - Computed in scanCamera, so every camera anybody reads passes through it. A state computed per handler is one a handler forgets, and this had already reached three screens. - CameraStaleAfter is 5 minutes: five missed reports, not one. Same reasoning as three missed heartbeats - an indicator that cries wolf gets ignored. - An unparseable last_seen_at is stale. It should be impossible, which is why it must not fall through to the state that says everything is fine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
825 lines
28 KiB
Go
825 lines
28 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/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"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)
|
|
|
|
// Camera snapshots already fetched, keyed by camera id. The Cameras screen
|
|
// polls every 8 seconds and a snapshot is ~90 KB, so re-fetching one that
|
|
// has not changed would put megabytes an hour on the wire to redraw the
|
|
// same picture - the same trap the web app's useAuthedImage avoids by
|
|
// keying on the url rather than the object around it.
|
|
shotMu sync.Mutex
|
|
shots map[string]cachedShot
|
|
}
|
|
|
|
type cachedShot struct {
|
|
at string // the server's snapshot_at; a new one is a new picture
|
|
uri string
|
|
}
|
|
|
|
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. Forget the session - in memory AND on disk, through
|
|
// the same callback that persists rotations - so the app goes back to
|
|
// Login instead of showing "session expired" on every screen until
|
|
// somebody finds Sign out. Seen on a PC that had been claimed against a
|
|
// demo head office and then re-claimed against the real one: the old
|
|
// login sat there, dead, for the whole session.
|
|
c.Clear()
|
|
if c.onRefresh != nil {
|
|
c.onRefresh(Session{})
|
|
}
|
|
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 && tok != "":
|
|
// A 401 on a call we sent a session with: the session is the problem.
|
|
return ErrUnauthorized
|
|
case resp.StatusCode >= 400:
|
|
// Every other 4xx/5xx - including a 401 on a call that carried NO
|
|
// session, such as redeeming an installation code - is about the
|
|
// request, and the server wrote its message for exactly this moment.
|
|
// Mapping those to "session expired" told an installer their session
|
|
// had lapsed on a screen where they had never signed in, and hid
|
|
// "That installation code is not valid" behind it.
|
|
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"`
|
|
// Auth is set by the server when the URL is one of its own endpoints and
|
|
// needs this session's bearer, rather than a presigned object-store link
|
|
// that carries its own signature. It never reaches the front end - see
|
|
// VisitorImage, which resolves it here.
|
|
Auth bool `json:"auth"`
|
|
}
|
|
|
|
// 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 != ""
|
|
|
|
// A deployment with no object storage serves the photo from the API itself,
|
|
// which means a RELATIVE url that needs this session's bearer. Neither
|
|
// works in the window: a webview <img> resolves a relative src against
|
|
// wails://, not against the cloud, and it cannot send an Authorization
|
|
// header at all - so handing it straight through renders a broken picture
|
|
// on exactly the deployments that have just started storing photos.
|
|
//
|
|
// Fetched here and passed as a data: URI. The alternative is a local proxy
|
|
// inside this process holding the session, which is a second authenticated
|
|
// surface on the shop PC to get wrong. One photo per sheet, ~90 KB, and the
|
|
// server already records the read where the link was handed out.
|
|
if out.Available && out.Auth {
|
|
data, err := c.fetchImage(ctx, out.URL)
|
|
if err != nil {
|
|
// The record itself is worth far more than the picture, so this is
|
|
// an absence with a reason rather than a failure that blanks the
|
|
// customer - the same rule the whole image path follows.
|
|
return Photo{Reason: "That photo could not be loaded."}, nil
|
|
}
|
|
out.URL = data
|
|
out.Auth = false
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// fetchImage reads an image this server holds itself and returns a data: URI.
|
|
//
|
|
// Deliberately not routed through send(): that decodes JSON into `out`, and
|
|
// these are bytes. It shares the token and the expiry retry, because a sheet
|
|
// opened twelve hours after the last one must not show a broken photo.
|
|
func (c *Client) fetchImage(ctx context.Context, path string) (string, error) {
|
|
body, err := c.imageBytes(ctx, path)
|
|
if errors.Is(err, errTokenExpired) {
|
|
if rerr := c.Refresh(ctx); rerr != nil {
|
|
return "", rerr
|
|
}
|
|
body, err = c.imageBytes(ctx, path)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body), nil
|
|
}
|
|
|
|
// maxPhotoBytes bounds what will be pulled into memory and then base64'd into
|
|
// the window. Face crops are ~20 KB and a camera still ~100 KB; anything near
|
|
// this is a different file or a fault, and a shop PC should not spend its
|
|
// memory finding that out.
|
|
const maxPhotoBytes = 4 << 20
|
|
|
|
func (c *Client) imageBytes(ctx context.Context, path string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
var e struct {
|
|
Error string `json:"error"`
|
|
}
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
|
_ = json.Unmarshal(body, &e)
|
|
if e.Error == "token_expired" {
|
|
return nil, errTokenExpired
|
|
}
|
|
return nil, ErrUnauthorized
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("photo: %s", resp.Status)
|
|
}
|
|
return io.ReadAll(io.LimitReader(resp.Body, maxPhotoBytes))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// AssistantTurn is one message in the help conversation. The browser holds
|
|
// the history and resends it; nothing is stored server-side.
|
|
type AssistantTurn struct {
|
|
Role string `json:"role"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// AssistantAnswer is the reply, and the names of what it looked at - shown to
|
|
// the user, because an assistant that silently ran a camera check would be
|
|
// alarming and naming what it consulted makes a wrong answer traceable.
|
|
type AssistantAnswer struct {
|
|
Text string `json:"text"`
|
|
Used []string `json:"used,omitempty"`
|
|
}
|
|
|
|
// Ask puts a question to the head-office assistant as this signed-in user.
|
|
func (c *Client) Ask(ctx context.Context, history []AssistantTurn) (AssistantAnswer, error) {
|
|
var out AssistantAnswer
|
|
return out, c.do(ctx, http.MethodPost, "/api/assistant", map[string]any{"history": history}, &out)
|
|
}
|
|
|
|
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"`
|
|
// Ref is the customer number - "V-42" - and is what staff say to each
|
|
// other. It is accepted anywhere this customer's id is.
|
|
Ref string `json:"ref"`
|
|
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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- viewing --
|
|
//
|
|
// A PC with no engine of its own is not broken, it is a VIEWER: somebody
|
|
// signed in on a laptop away from the shop. Everything below reads head
|
|
// office so those screens have something true to show instead of "engine not
|
|
// reachable", which is an accurate sentence and a useless one when the reader
|
|
// was never expecting an engine on that machine.
|
|
|
|
// Arrival is one visit as the estate's feed reports it, across every shop -
|
|
// not just this PC's. `GET /api/visits`.
|
|
type Arrival struct {
|
|
VisitID string `json:"visit_id"`
|
|
VisitRef string `json:"visit_ref"`
|
|
OccurredAt string `json:"occurred_at"`
|
|
Site string `json:"site"`
|
|
SiteSlug string `json:"site_slug"`
|
|
CameraID string `json:"camera_id"`
|
|
VisitorID string `json:"visitor_id"`
|
|
Ref string `json:"ref"`
|
|
Label string `json:"label"`
|
|
IsNew bool `json:"is_new_visitor"`
|
|
Similarity float64 `json:"similarity"`
|
|
Attributes map[string]any `json:"attributes"`
|
|
Image Photo `json:"image"`
|
|
}
|
|
|
|
// RemoteCamera is a camera as HEAD OFFICE knows it. Deliberately not the same
|
|
// type the local engine returns: this one can never be edited from here (the
|
|
// shop PC on that LAN is the only thing that can reach it) and it carries a
|
|
// snapshot rather than a stream.
|
|
type RemoteCamera struct {
|
|
ID string `json:"id"`
|
|
CameraID string `json:"camera_id"`
|
|
Label string `json:"label"`
|
|
Site string `json:"site"`
|
|
SiteSlug string `json:"site_slug"`
|
|
Enabled bool `json:"enabled"`
|
|
Connected *bool `json:"connected"`
|
|
LastSeenAt string `json:"last_seen_at"`
|
|
// State is the server's single answer - connected / not_connecting /
|
|
// waiting / stale - and the screen renders that rather than deciding
|
|
// again from Connected. Two places deciding one fact is how a shop came
|
|
// out labelled Working, in green, above "2 of 3 cameras not connecting".
|
|
State string `json:"state"`
|
|
StateNote string `json:"state_note"`
|
|
Snapshot Photo `json:"snapshot"`
|
|
SnapshotAt string `json:"snapshot_at"`
|
|
}
|
|
|
|
// Arrivals reads the estate's recent visits, newest last.
|
|
func (c *Client) Arrivals(ctx context.Context, limit int) ([]Arrival, error) {
|
|
var out struct {
|
|
Arrivals []Arrival `json:"arrivals"`
|
|
}
|
|
if err := c.send(ctx, http.MethodGet,
|
|
fmt.Sprintf("/api/visits?limit=%d", limit), nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Arrivals, nil
|
|
}
|
|
|
|
// RemoteCameras lists every camera head office knows about for this company.
|
|
func (c *Client) RemoteCameras(ctx context.Context) ([]RemoteCamera, error) {
|
|
var out []RemoteCamera
|
|
if err := c.send(ctx, http.MethodGet, "/api/cameras", nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out {
|
|
out[i].Snapshot = c.resolveShot(ctx, out[i].ID, out[i].SnapshotAt, out[i].Snapshot)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// resolveShot turns a camera snapshot into something the window can render.
|
|
//
|
|
// Same problem VisitorImage has and the same answer: a deployment with no
|
|
// object storage serves the picture from the API itself, so the url is
|
|
// relative and needs this session's bearer. A webview <img> can supply
|
|
// neither - it resolves a relative src against wails:// and cannot set a
|
|
// header - so the bytes are fetched here and passed as a data: URI.
|
|
//
|
|
// A failure is an absence with a reason, never an error. Whether the camera is
|
|
// CONNECTED is the answer this screen exists to give; the photograph is
|
|
// decoration, and blanking the card because a picture would not load would
|
|
// hide the part that matters.
|
|
func (c *Client) resolveShot(ctx context.Context, camID, at string, p Photo) Photo {
|
|
if !p.Available || !p.Auth || p.URL == "" {
|
|
return p
|
|
}
|
|
c.shotMu.Lock()
|
|
hit, ok := c.shots[camID]
|
|
c.shotMu.Unlock()
|
|
if ok && hit.at == at && at != "" {
|
|
p.URL, p.Auth = hit.uri, false
|
|
return p
|
|
}
|
|
uri, err := c.fetchImage(ctx, p.URL)
|
|
if err != nil {
|
|
return Photo{Reason: "That camera's picture could not be loaded."}
|
|
}
|
|
c.shotMu.Lock()
|
|
if c.shots == nil {
|
|
c.shots = map[string]cachedShot{}
|
|
}
|
|
c.shots[camID] = cachedShot{at: at, uri: uri}
|
|
c.shotMu.Unlock()
|
|
p.URL, p.Auth = uri, false
|
|
return p
|
|
}
|
|
|
|
// CameraLive opens head office's live relay for one camera and returns the
|
|
// live SSE response for the caller to read and close.
|
|
//
|
|
// A response rather than frames, because the consumer is the app's own
|
|
// loopback relay: it re-emits these frames as MJPEG so an <img> can show them,
|
|
// and buffering the stream through a channel here would only add a place for
|
|
// frames to queue. A stale frame is worthless - the only one worth having is
|
|
// the newest - which is the whole reason LiveHub drops rather than queues.
|
|
//
|
|
// There is no client timeout on this request. A live view is endless by
|
|
// design and any deadline would cut the picture off mid-shift; the context is
|
|
// what ends it, when the viewer navigates away.
|
|
func (c *Client) CameraLive(ctx context.Context, cameraID string) (*http.Response, error) {
|
|
resp, err := c.liveOnce(ctx, cameraID)
|
|
if errors.Is(err, errTokenExpired) {
|
|
if rerr := c.Refresh(ctx); rerr != nil {
|
|
return nil, rerr
|
|
}
|
|
resp, err = c.liveOnce(ctx, cameraID)
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
func (c *Client) liveOnce(ctx context.Context, cameraID string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
|
c.Base+"/api/cameras/"+url.PathEscape(cameraID)+"/live", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
c.mu.RLock()
|
|
tok := c.token
|
|
c.mu.RUnlock()
|
|
if tok == "" {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
|
|
// c.http has a 30 s timeout, which covers the whole response and would
|
|
// therefore sever a working live view every thirty seconds - the same
|
|
// trap that made the server set WriteTimeout to zero for its own SSE
|
|
// endpoint. A dedicated client, with the dial bounded instead.
|
|
hc := &http.Client{Transport: &http.Transport{
|
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
}}
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
var e struct {
|
|
Error string `json:"error"`
|
|
}
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
|
resp.Body.Close()
|
|
_ = json.Unmarshal(body, &e)
|
|
if e.Error == "token_expired" {
|
|
return nil, errTokenExpired
|
|
}
|
|
return nil, ErrUnauthorized
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("live view: %s", resp.Status)
|
|
}
|
|
return resp, nil
|
|
}
|