Compare commits
1 Commits
v0.5.0-dem
...
v0.5.1-dem
| Author | SHA1 | Date | |
|---|---|---|---|
| ecc8bbba6f |
52
CLAUDE.md
52
CLAUDE.md
@@ -3307,3 +3307,55 @@ Against real Postgres, on the demo tenant:
|
||||
- HTML, PDF, GIF and empty bodies are all refused as face images: the check is
|
||||
on the magic bytes, never the `Content-Type` header, because this endpoint
|
||||
stores what it is handed and serves it back to a browser.
|
||||
|
||||
## Viewer mode: the app on a computer that is not watching anything
|
||||
|
||||
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. `App.Live()`
|
||||
and `App.Cameras()` read **only** `a.local`, 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 merely stale.
|
||||
|
||||
That is the wrong model of what this application is. A shop PC watches
|
||||
cameras; an owner's laptop, a manager's machine, a second till being set up do
|
||||
not, and all three are signed in to the same estate. **Having no engine is a
|
||||
normal state, not a failure**, and the app now says what it can see from where
|
||||
it is standing instead of reporting the absence of something it does not need.
|
||||
|
||||
Both methods try loopback first and fall back to head office when it fails and
|
||||
somebody is signed in. The order matters: a real shop PC must never be shown
|
||||
head office's minute-old summary when the engine two milliseconds away has the
|
||||
live one.
|
||||
|
||||
- **`Viewing` is on the snapshot, not inferred in the browser.** Three surfaces
|
||||
read it — the banner, the camera tally, the getting-started panel — and a
|
||||
screen that computed it separately is how the shops screen once came out
|
||||
labelled **Working**, in green, directly above *"2 of 3 cameras not
|
||||
connecting"*. One fact, one place, the same rule as the tray being a client
|
||||
of `EngineStatus()`.
|
||||
- **`fraction_below_gate` is the WORST shop, never an average.** 0.10 against
|
||||
0.73 averages to 0.42 and hides the only shop anyone needs to visit. Same
|
||||
rule the heartbeat already follows with `worst_site`.
|
||||
- **A remote camera is flagged `remote: true`, and the screen withholds Edit,
|
||||
Remove and Check placement.** Those talk to a camera on a LAN this computer
|
||||
cannot reach, and an Edit button that cannot work is worse than one that is
|
||||
absent. The tenant response structurally cannot carry `host`, `username` or
|
||||
`has_password`, so nothing here can invent them either — a test asserts that.
|
||||
- **`connected` is three states.** `null` is "no shop computer has reported on
|
||||
this 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.
|
||||
- **The picture is the last snapshot, and it says so.** There is no live video
|
||||
here: the engine's MJPEG stream is on the shop PC's loopback behind a router
|
||||
with no inbound route. Head office's `LiveHub` relay is the answer to that
|
||||
and is a further step for this client; the banner does not imply otherwise.
|
||||
- **Snapshots are fetched in Go and passed as `data:` URIs, cached by
|
||||
`snapshot_at`.** A webview `<img>` resolves a relative src against `wails://`
|
||||
and cannot send the session's bearer — the same problem `VisitorImage`
|
||||
already solved — and this screen polls every 8 seconds at ~90 KB a camera, so
|
||||
re-fetching an unchanged frame is megabytes an hour to redraw the same
|
||||
picture. Keyed on the server's `snapshot_at`, because a new timestamp is the
|
||||
only thing that means a new photograph.
|
||||
- **With no engine AND nobody signed in, the engine error is still the answer.**
|
||||
There is nothing else to show and the person is most likely setting this PC
|
||||
up; naming head office there points them at a step they have not reached.
|
||||
|
||||
112
desktop/app.go
112
desktop/app.go
@@ -616,10 +616,43 @@ func (a *App) StopEngine() EngineStatus {
|
||||
|
||||
// ---------------------------------------------------------------- cameras --
|
||||
|
||||
// Cameras lists this PC's cameras, or the company's if this PC has none of
|
||||
// its own.
|
||||
//
|
||||
// The distinction is load-bearing and the UI is told which it got. A camera
|
||||
// from the local engine is one THIS machine can reach, edit and stream. One
|
||||
// from head office is a camera at a shop somewhere else: it has a snapshot
|
||||
// and a connection state, and it cannot be edited from here because the shop
|
||||
// PC on that LAN is the only thing that can reach it. Offering an Edit button
|
||||
// that could not work would be worse than not showing the camera at all.
|
||||
func (a *App) Cameras() ([]map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return a.local.Cameras(ctx)
|
||||
|
||||
cams, err := a.local.Cameras(ctx)
|
||||
if err == nil {
|
||||
return cams, nil
|
||||
}
|
||||
if !a.cloud.LoggedIn() {
|
||||
return nil, err
|
||||
}
|
||||
remote, rerr := a.cloud.RemoteCameras(ctx)
|
||||
if rerr != nil {
|
||||
return nil, err // the local failure is the one worth reporting
|
||||
}
|
||||
out := make([]map[string]any, 0, len(remote))
|
||||
for _, c := range remote {
|
||||
out = append(out, map[string]any{
|
||||
"id": c.ID, "camera_id": c.CameraID, "label": c.Label,
|
||||
"site": c.Site, "enabled": c.Enabled,
|
||||
"connected": c.Connected, "last_seen_at": c.LastSeenAt,
|
||||
"snapshot": c.Snapshot, "snapshot_at": c.SnapshotAt,
|
||||
// What the screen keys off to hide Edit, Test and Check: this
|
||||
// camera is on a network this PC cannot reach.
|
||||
"remote": true,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiscoverCameras lists the cameras on this PC's network, so the add-camera
|
||||
@@ -691,20 +724,93 @@ func (a *App) StreamURL(cameraID string) string {
|
||||
type LiveSnapshot struct {
|
||||
Stats map[string]any `json:"stats"`
|
||||
Events []map[string]any `json:"events"`
|
||||
// Viewing is true when none of this came from an engine on THIS PC. The
|
||||
// screen must say so: the numbers are the company's, not this machine's,
|
||||
// and a laptop in a hotel showing "2 cameras live" without that word
|
||||
// would be claiming to be watching a shop it cannot see.
|
||||
Viewing bool `json:"viewing"`
|
||||
}
|
||||
|
||||
// Live is what the shop PC sees, and falls back to what HEAD OFFICE sees.
|
||||
//
|
||||
// A PC with no engine is not necessarily broken - it is somebody signed in on
|
||||
// a laptop away from the shop, which is the ordinary way an owner looks at
|
||||
// their estate. Until now that produced "engine not reachable at
|
||||
// 127.0.0.1:8010", an accurate sentence and a useless one when the reader was
|
||||
// never expecting an engine on that machine.
|
||||
//
|
||||
// The local engine always wins when it is there: it is this shop's own
|
||||
// ground truth and it is live rather than a heartbeat old.
|
||||
func (a *App) Live() (LiveSnapshot, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats, err := a.local.Stats(ctx)
|
||||
if err == nil {
|
||||
events, eerr := a.local.Events(ctx, 40)
|
||||
if eerr == nil {
|
||||
return LiveSnapshot{Stats: stats, Events: events}, nil
|
||||
}
|
||||
}
|
||||
// No engine here. If nobody is signed in either, the honest answer is
|
||||
// still the local error - there is nothing else to show and the person
|
||||
// is most likely setting this PC up.
|
||||
if !a.cloud.LoggedIn() {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
return a.liveFromCloud(ctx)
|
||||
}
|
||||
|
||||
// liveFromCloud builds the same shape the Live screen already renders, out of
|
||||
// the estate's own feed, so the view needs no second code path.
|
||||
func (a *App) liveFromCloud(ctx context.Context) (LiveSnapshot, error) {
|
||||
sites, err := a.cloud.Sites(ctx)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
events, err := a.local.Events(ctx, 40)
|
||||
arrivals, err := a.cloud.Arrivals(ctx, 40)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
return LiveSnapshot{Stats: stats, Events: events}, nil
|
||||
|
||||
// The counters are summed across the estate, and fraction_below_gate
|
||||
// takes the WORST site rather than an average - one badly placed camera
|
||||
// is a hole in the numbers, and averaging it against three good ones
|
||||
// hides the only site anyone needs to visit. Same rule the heartbeat
|
||||
// already follows.
|
||||
var up, total int
|
||||
worst := 0.0
|
||||
people := map[string]struct{}{}
|
||||
for _, s := range sites {
|
||||
up, total = up+s.CamerasUp, total+s.CamerasTotal
|
||||
if s.FractionBelowGate > worst {
|
||||
worst = s.FractionBelowGate
|
||||
}
|
||||
}
|
||||
events := make([]map[string]any, 0, len(arrivals))
|
||||
for _, v := range arrivals {
|
||||
if v.VisitorID != "" {
|
||||
people[v.VisitorID] = struct{}{}
|
||||
}
|
||||
events = append(events, map[string]any{
|
||||
"type": map[bool]string{true: "person.new", false: "person.seen"}[v.IsNew],
|
||||
"ts": v.OccurredAt, "camera_id": v.CameraID,
|
||||
"data": map[string]any{
|
||||
"label": v.Label, "ref": v.Ref, "site": v.Site,
|
||||
"similarity": v.Similarity, "attributes": v.Attributes,
|
||||
},
|
||||
})
|
||||
}
|
||||
return LiveSnapshot{
|
||||
Viewing: true,
|
||||
Events: events,
|
||||
Stats: map[string]any{
|
||||
"cameras": []map[string]any{},
|
||||
"gallery": map[string]any{"identities": len(people), "sightings": len(arrivals)},
|
||||
"cameras_up": up, "cameras_total": total,
|
||||
"fraction_below_gate": worst,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reports --
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
desktop/frontend/dist/index.html
vendored
4
desktop/frontend/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="./assets/index-Be_Iv2Nz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-lhDNZRcC.css">
|
||||
<script type="module" crossorigin src="./assets/index-vAtlvw9l.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-xWw5ie4A.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -634,3 +634,17 @@ tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
|
||||
.starter .note { padding: var(--sp-3) var(--sp-4); font-size: 12px; }
|
||||
.btn.ghost { background: none; border-color: transparent; color: var(--ink-3); }
|
||||
.btn.ghost:hover { color: var(--ink); }
|
||||
|
||||
/* Viewer mode: this PC has no engine, so the screens show the company's own
|
||||
data from head office. Informational, not an error - it is the ordinary
|
||||
state of a laptop away from a shop, and styling it red would train people
|
||||
to ignore the red that means something. */
|
||||
.viewing {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 12px 14px; margin-bottom: 14px;
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
color: var(--ink-2); font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
.viewing b { color: var(--ink); font-weight: 600; }
|
||||
.viewing svg { flex: none; margin-top: 2px; color: var(--accent); }
|
||||
|
||||
@@ -19,7 +19,14 @@ export default function Cameras() {
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [check, setCheck] = useState(null)
|
||||
const cams = data ?? []
|
||||
const streams = useStreamURLs(cams)
|
||||
// Every camera is remote or none is: this list comes from the engine on
|
||||
// loopback, and when that is unreachable the whole list comes from head
|
||||
// office instead. A remote camera is on a network this computer cannot
|
||||
// reach, so the picture is the shop PC's last snapshot and the buttons that
|
||||
// would talk to the camera are not offered - one that cannot work is worse
|
||||
// than one that is absent.
|
||||
const remote = cams.some(c => c.remote)
|
||||
const streams = useStreamURLs(remote ? [] : cams)
|
||||
|
||||
async function remove(cam) {
|
||||
if (!confirm(`Remove ${cam.id}? Recognition from it stops immediately.`)) return
|
||||
@@ -31,13 +38,21 @@ export default function Cameras() {
|
||||
<header className="pagehead">
|
||||
<div>
|
||||
<h2>Cameras</h2>
|
||||
<p>Add a camera, then prove it can see faces with a walk-past. Only then is it working.</p>
|
||||
<p>{remote
|
||||
? 'The cameras across your shops, as the shop computers last reported them.'
|
||||
: 'Add a camera, then prove it can see faces with a walk-past. Only then is it working.'}</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
{!remote && <button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
<Icon.Plus size={15} />Add camera
|
||||
</button>
|
||||
</button>}
|
||||
</header>
|
||||
|
||||
{remote && <div className="viewing">
|
||||
<b>Viewing your shops from here.</b> These cameras are wired to the shop
|
||||
computers, so they are set up and checked there. The picture is each
|
||||
camera's most recent frame, not live video.
|
||||
</div>}
|
||||
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{cams.length === 0
|
||||
@@ -64,40 +79,57 @@ export default function Cameras() {
|
||||
}
|
||||
|
||||
function CameraCard({ cam, stream, onEdit, onCheck, onRemove }) {
|
||||
const conn = cam.connected === undefined ? { tone: 'idle', label: 'Engine stopped' }
|
||||
// Three states, not two, and the third is why `connected` is a pointer on
|
||||
// the wire: null means no shop computer has reported on this camera yet,
|
||||
// which reads as waiting rather than as a fault to go and investigate.
|
||||
const conn = cam.connected === undefined || cam.connected === null
|
||||
? (cam.remote ? { tone: 'idle', label: 'Waiting for the shop computer' }
|
||||
: { tone: 'idle', label: 'Engine stopped' })
|
||||
: cam.connected ? { tone: 'ok', label: 'Connected' } : { tone: 'bad', label: 'Not connecting' }
|
||||
// The last placement verdict, so "proven" survives closing the sheet. Only
|
||||
// `good` is a pass: marginal means half the visitors are silently discarded.
|
||||
const { data: last } = usePolled(() => api.placementResult(cam.id), 15000, [cam.id])
|
||||
// Never asked for a remote camera: that answer lives on the shop computer,
|
||||
// and polling loopback for it here only produces an error every 15 seconds.
|
||||
const { data: last } = usePolled(
|
||||
() => cam.remote ? Promise.resolve(null) : api.placementResult(cam.id), 15000, [cam.id])
|
||||
const proof = !last || last.running || !last.verdict || last.verdict === 'starting'
|
||||
? { tone: 'miss', label: 'Not yet proven', text: 'Walk past it once and Behavision will tell you if the placement works.' }
|
||||
: last.verdict === 'good'
|
||||
? { tone: 'seen', label: 'Proven', text: last.headline || 'Faces recognised on a walk-past.' }
|
||||
: { tone: 'miss', label: 'Not proven', text: last.headline || 'Move the camera and check again.' }
|
||||
const shot = cam.snapshot?.available ? cam.snapshot.url : ''
|
||||
return (
|
||||
<article className="camcard">
|
||||
<div className="camview">
|
||||
{stream
|
||||
? <img src={stream} alt={cam.id} />
|
||||
{stream || shot
|
||||
? <img src={stream || shot} alt={cam.id} />
|
||||
: <div className="placeholder"><Icon.NoCamera size={34} /></div>}
|
||||
<span className={`pill ${conn.tone === 'idle' ? '' : conn.tone} over`}><i className={`dot ${conn.tone}`} />{conn.label}</span>
|
||||
</div>
|
||||
<div className="cambody">
|
||||
<div className="camtitle">
|
||||
<div>
|
||||
<h3>{cam.id}</h3>
|
||||
<span className="mono note">{cam.host || cam.url}{cam.path ? ` · ${cam.path}` : ''}</span>
|
||||
<h3>{cam.label || cam.camera_id || cam.id}</h3>
|
||||
<span className="mono note">{cam.remote
|
||||
? cam.site || cam.site_slug || ''
|
||||
: `${cam.host || cam.url}${cam.path ? ` · ${cam.path}` : ''}`}</span>
|
||||
</div>
|
||||
<div className="camactions">
|
||||
{!cam.remote && <div className="camactions">
|
||||
<button className="btn sm" onClick={onEdit}>Edit</button>
|
||||
<button className="btn sm danger" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="camproof">
|
||||
<span className={`tag ${proof.tone}`}>{proof.label}</span>
|
||||
<span className="note">{proof.text}</span>
|
||||
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
|
||||
</div>}
|
||||
</div>
|
||||
{cam.remote
|
||||
? <div className="camproof">
|
||||
<span className="note">{cam.snapshot?.available
|
||||
? 'Last picture from the shop computer.'
|
||||
: cam.snapshot?.reason || 'No picture yet from the shop computer.'}</span>
|
||||
</div>
|
||||
: <div className="camproof">
|
||||
<span className={`tag ${proof.tone}`}>{proof.label}</span>
|
||||
<span className="note">{proof.text}</span>
|
||||
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
|
||||
</div>}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -23,15 +23,22 @@ export default function Live({ onNavigate }) {
|
||||
const cameras = data?.stats?.cameras ?? []
|
||||
const gallery = data?.stats?.gallery ?? {}
|
||||
const events = data?.events ?? []
|
||||
// No engine on THIS PC, so everything below came from head office. It has to
|
||||
// be said rather than implied: a laptop in a hotel showing "2 cameras live"
|
||||
// without this line is claiming to watch a shop it cannot see.
|
||||
const viewing = data?.viewing === true
|
||||
|
||||
// fraction_below_gate is the number that decides a site: what share of the
|
||||
// faces this camera saw were too poor to enrol. Surfaced rather than buried,
|
||||
// because a high value looks exactly like "a quiet day".
|
||||
const worst = cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
const up = cameras.filter(c => c.connected).length
|
||||
const worst = viewing
|
||||
? (data?.stats?.fraction_below_gate ?? 0)
|
||||
: cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
// Viewing: the server already summed these across the estate.
|
||||
const up = viewing ? (data?.stats?.cameras_up ?? 0) : cameras.filter(c => c.connected).length
|
||||
|
||||
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
|
||||
const freshest = useFreshest(arrivals[0])
|
||||
@@ -40,14 +47,29 @@ export default function Live({ onNavigate }) {
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Live</h2>
|
||||
<p>Who is in the shop, and whether it is reaching head office.</p>
|
||||
<p>{viewing
|
||||
? 'Your shops, as head office sees them.'
|
||||
: 'Who is in the shop, and whether it is reaching head office.'}</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{viewing && (
|
||||
<div className="viewing">
|
||||
<Icon.Cloud size={15} />
|
||||
<span><b>Viewing your shops from here.</b> This computer is not watching
|
||||
any cameras itself — everything below is what your shop PCs reported.
|
||||
To recognise people on this machine, it has to be on the same network
|
||||
as a camera.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
|
||||
|
||||
<GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />
|
||||
{/* Getting Started walks somebody through setting up a camera on THIS
|
||||
PC — not what a viewer is doing, and not something they could finish
|
||||
from here. */}
|
||||
{!viewing && <GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />}
|
||||
|
||||
<div className="panel arrivals-panel">
|
||||
<div className="panelhead">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
166
desktop/viewing_test.go
Normal file
166
desktop/viewing_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-desktop/internal/cloud"
|
||||
"github.com/loyaly/behavision-desktop/internal/local"
|
||||
)
|
||||
|
||||
// Viewer mode: what the app shows on a computer that is signed in and is not
|
||||
// itself watching any cameras.
|
||||
//
|
||||
// This is the friend's-Mac case, and before it existed the app was honest and
|
||||
// useless: Live() and Cameras() read ONLY the engine on 127.0.0.1, so a laptop
|
||||
// with no engine got "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. Signing in is what the person did; the app answered
|
||||
// as if they had not.
|
||||
//
|
||||
// The engine here is a port nothing listens on, which is precisely what a PC
|
||||
// with no engine is.
|
||||
const noEngine = "http://127.0.0.1:1" // reserved, refuses immediately
|
||||
|
||||
func viewerApp(t *testing.T, srv *httptest.Server) *App {
|
||||
t.Helper()
|
||||
c := cloud.New(srv.URL)
|
||||
c.SetSession(cloud.Session{Token: "test-token"})
|
||||
return &App{
|
||||
ctx: context.Background(),
|
||||
cloud: c,
|
||||
local: local.New(noEngine, "", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveFallsBackToHeadOfficeWhenThereIsNoEngine(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/api/sites":
|
||||
// Two shops. One is fine, one is the Office1 case.
|
||||
w.Write([]byte(`[
|
||||
{"slug":"a","name":"A","cameras_up":2,"cameras_total":2,"fraction_below_gate":0.10},
|
||||
{"slug":"b","name":"B","cameras_up":1,"cameras_total":3,"fraction_below_gate":0.73}
|
||||
]`))
|
||||
case strings.HasPrefix(r.URL.Path, "/api/visits"):
|
||||
w.Write([]byte(`{"arrivals":[
|
||||
{"visit_id":"v1","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2","is_new_visitor":true},
|
||||
{"visit_id":"v2","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2"},
|
||||
{"visit_id":"v3","camera_id":"entrance"}
|
||||
]}`))
|
||||
default:
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
snap, err := viewerApp(t, srv).Live()
|
||||
if err != nil {
|
||||
t.Fatalf("Live: %v", err)
|
||||
}
|
||||
if !snap.Viewing {
|
||||
t.Fatal("the snapshot did not say it was a view of somewhere else")
|
||||
}
|
||||
if got := snap.Stats["cameras_up"]; got != 3 {
|
||||
t.Errorf("cameras_up = %v, want 3 summed across both shops", got)
|
||||
}
|
||||
if got := snap.Stats["cameras_total"]; got != 5 {
|
||||
t.Errorf("cameras_total = %v, want 5", got)
|
||||
}
|
||||
// The WORST site, never an average. Averaging 0.10 against 0.73 reports
|
||||
// 0.42 and hides the only shop anyone needs to go and fix - the same rule
|
||||
// the heartbeat already follows with worst_site.
|
||||
if got := snap.Stats["fraction_below_gate"]; got != 0.73 {
|
||||
t.Errorf("fraction_below_gate = %v, want the worst shop's 0.73", got)
|
||||
}
|
||||
// Three arrivals, two of them the same person, one unidentified. A visit
|
||||
// with no visitor_id is real footfall and an unknown person, so it counts
|
||||
// as a sighting and not as somebody known.
|
||||
g := snap.Stats["gallery"].(map[string]any)
|
||||
if g["identities"] != 1 || g["sightings"] != 3 {
|
||||
t.Errorf("gallery = %v, want 1 identity over 3 sightings", g)
|
||||
}
|
||||
if len(snap.Events) != 3 {
|
||||
t.Fatalf("got %d events, want 3", len(snap.Events))
|
||||
}
|
||||
if snap.Events[0]["type"] != "person.new" || snap.Events[1]["type"] != "person.seen" {
|
||||
t.Errorf("arrival types wrong: %v", snap.Events)
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody signed in: the local failure is the honest answer. There is nothing
|
||||
// else to show, and the person is most likely setting this PC up - telling
|
||||
// them about head office would be telling them about something they have not
|
||||
// got to yet.
|
||||
func TestLiveWithNoEngineAndNoSessionReportsTheEngine(t *testing.T) {
|
||||
a := &App{ctx: context.Background(), cloud: cloud.New("https://example.invalid"),
|
||||
local: local.New(noEngine, "", "")}
|
||||
if _, err := a.Live(); err == nil {
|
||||
t.Fatal("want the engine error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// A remote camera is flagged, because the screen has to withhold every button
|
||||
// that would talk to a camera on a network this computer cannot reach. An Edit
|
||||
// button that cannot work is worse than one that is absent.
|
||||
func TestRemoteCamerasAreFlaggedAndCarryNoCredentials(t *testing.T) {
|
||||
jpeg := base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xD9})
|
||||
var shots int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/camera-snapshots/") {
|
||||
shots++
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
b, _ := base64.StdEncoding.DecodeString(jpeg)
|
||||
w.Write(b)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`[
|
||||
{"id":"c1","camera_id":"cam2","label":"Open office","site":"Coimbatore",
|
||||
"connected":true,"snapshot_at":"2026-09-30T10:00:00Z",
|
||||
"snapshot":{"available":true,"url":"/api/camera-snapshots/c1.jpg","auth":true}}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := viewerApp(t, srv)
|
||||
cams, err := a.Cameras()
|
||||
if err != nil {
|
||||
t.Fatalf("Cameras: %v", err)
|
||||
}
|
||||
if len(cams) != 1 {
|
||||
t.Fatalf("got %d cameras, want 1", len(cams))
|
||||
}
|
||||
if cams[0]["remote"] != true {
|
||||
t.Error("the camera was not flagged remote")
|
||||
}
|
||||
// The RTSP details are a live path into the camera itself and the server
|
||||
// does not send them to a tenant at all. Nothing here may invent them.
|
||||
for _, k := range []string{"host", "port", "path", "username", "password"} {
|
||||
if _, ok := cams[0][k]; ok {
|
||||
t.Errorf("a remote camera carried %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
// The picture has to be fetched here: a webview <img> resolves a relative
|
||||
// src against wails:// and cannot send the session's bearer.
|
||||
shot := cams[0]["snapshot"].(cloud.Photo)
|
||||
if !strings.HasPrefix(shot.URL, "data:image/jpeg;base64,") || shot.Auth {
|
||||
t.Errorf("snapshot url = %q auth=%v, want an inline data URI", shot.URL, shot.Auth)
|
||||
}
|
||||
|
||||
// And fetched ONCE. This screen polls every 8 seconds and a real snapshot
|
||||
// is ~90 KB, so re-fetching an unchanged picture is megabytes an hour to
|
||||
// redraw the same frame.
|
||||
if _, err := a.Cameras(); err != nil {
|
||||
t.Fatalf("second poll: %v", err)
|
||||
}
|
||||
if shots != 1 {
|
||||
t.Errorf("fetched the same snapshot %d times across two polls", shots)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user