The app on a laptop reported an engine that was never meant to be there

Signing in on a second Mac showed "engine not reachable at
http://127.0.0.1:8010" and 0 of 0 cameras, on an account whose shops were
running and recognising people the whole time. Nothing was broken: Live() and
Cameras() read only the engine on loopback, so the app answered as though the
person had never signed in - and camera sync goes through the engine, which is
why the count was zero rather than stale.

Having no engine is a normal state. A shop PC watches cameras; an owner's
laptop, a manager's machine and a second till being set up do not, and all
three are signed in to the same estate. Both methods now fall back to head
office when loopback fails and somebody is signed in. Loopback is still tried
first: a real shop PC must never be shown a minute-old summary when the engine
two milliseconds away has the live one.

Decisions worth keeping:

- Viewing is on the snapshot, not inferred per screen. Three surfaces read it,
  and a screen that computed it separately is how the shops screen once came
  out labelled Working, in green, above "2 of 3 cameras not connecting".
- fraction_below_gate takes the WORST shop, never an average. 0.10 against
  0.73 averages to 0.42 and hides the only shop anyone needs to visit.
- A remote camera is flagged, and Edit, Remove and Check placement are
  withheld. They talk to a camera on a LAN this computer cannot reach, and a
  button that cannot work is worse than one that is absent.
- connected is three states. null is "no shop computer has reported yet" and
  reads as waiting; false is "Not connecting". A bare false sends somebody to
  check cabling on a camera nobody has tried to reach.
- Snapshots are fetched in Go as data: URIs and cached by snapshot_at. A
  webview <img> resolves a relative src against wails:// and cannot send the
  bearer - the problem VisitorImage already solved - and this screen polls
  every 8 seconds at ~90 KB a camera.
- With no engine AND no session, the engine error is still the answer. The
  person is most likely setting this PC up.

The picture is the last snapshot and the banner says so: there is no live
video from here, because the engine's MJPEG stream is on the shop PC's
loopback behind a router with no inbound route. The LiveHub relay head office
uses is the answer to that and is a further step for this client.

Verified against production: five arrivals and two cameras parsed from the
real API. viewing_test.go covers the fallback, the worst-shop rule, the
withheld credentials and that an unchanged snapshot is fetched once across
two polls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-30 16:33:32 +05:30
parent 97a8ecc03a
commit ecc8bbba6f
10 changed files with 548 additions and 39 deletions

View File

@@ -39,6 +39,19 @@ type Client struct {
// 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 {
@@ -629,3 +642,107 @@ func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
"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"`
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
}