Compare commits
2 Commits
v0.5.0-dem
...
v0.5.2-dem
| Author | SHA1 | Date | |
|---|---|---|---|
| 48a30d97db | |||
| ecc8bbba6f |
129
CLAUDE.md
129
CLAUDE.md
@@ -3307,3 +3307,132 @@ 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.
|
||||
|
||||
## 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 the answer
|
||||
— `LiveHub` plus `cameras.Live`, where the shop PC asks outbound whether
|
||||
anybody is watching and pushes JPEG frames up for exactly 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**, which is the whole 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, and no screen has to know which.
|
||||
|
||||
- **Reconnecting happens in the relay, not the page.** The server caps one push
|
||||
at five minutes so a tab left open for a week cannot leave a shop uploading
|
||||
for a week. 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 — status line included —
|
||||
waits for the shop PC to start pushing. Measured against production: thirty
|
||||
seconds and not even a `Content-Type`, which surfaces as the *request* timing
|
||||
out rather than a stream that has not painted yet.
|
||||
- **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. `Watch live` is per tile and toggles the previous one
|
||||
off.
|
||||
- **`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. It is a live view
|
||||
of a shop floor; the relay being on loopback is not on its own a control.
|
||||
- **`CameraLive` uses its own HTTP client.** The shared one has a 30-second
|
||||
timeout that 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 camera read "Connected" for 34 minutes after the shop PC went blind
|
||||
|
||||
Found while verifying the live view against production, and it is the reason
|
||||
that verification looked like a failure: head office registered the viewer and
|
||||
no frame ever came.
|
||||
|
||||
`reportWith` returns early when the engine is unreachable — correctly, because
|
||||
it has nothing to say — so the last state it sent **stays in the database
|
||||
looking current**. Measured on the live estate: `cam2` and `entrance` both
|
||||
reading **Connected**, in green, with `last_seen_at` thirty-four minutes old,
|
||||
while the heartbeat from the same PC said `cameras_up: 0, cameras_total: 0`.
|
||||
Two surfaces reading two stored fields and disagreeing about one fact.
|
||||
|
||||
`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, not
|
||||
three, and `api.CameraState` is the one function that decides them:
|
||||
|
||||
| state | meaning | what to do |
|
||||
|---|---|---|
|
||||
| `connected` | reported within `CameraStaleAfter`, and working | — |
|
||||
| `not_connecting` | reported recently, and the stream will not open | check the address, password, cabling |
|
||||
| `waiting` | no shop PC has ever reported this camera | it has not reached the PC yet |
|
||||
| `stale` | reported once, and not lately | check the PC is on and Behavision is running |
|
||||
|
||||
- **`Connected` is CLEARED when the state is `stale` or `waiting`.** Leaving a
|
||||
stale `true` in place keeps the lie available to every client that reads the
|
||||
field directly — a mobile app, a script, an older desktop build — and leaves
|
||||
two fields on one object disagreeing, which is exactly how the shops screen
|
||||
once came out labelled **Working**, in green, above *"2 of 3 cameras not
|
||||
connecting"*.
|
||||
- **It is computed in `scanCamera`**, so every camera anybody reads passes
|
||||
through it. A state computed per handler is a state one handler forgets, and
|
||||
this one had already reached three screens.
|
||||
- **`CameraStaleAfter` is 5 minutes — five missed reports, not one.** The agent
|
||||
reports on a 60-second tick, so one miss is a dropped packet. Same reasoning
|
||||
as a site being offline after three missed heartbeats: an indicator that
|
||||
cries wolf is one people learn to ignore.
|
||||
- **An unparseable `last_seen_at` is stale**, not connected. It should be
|
||||
impossible, which is precisely why it must not fall through to the state that
|
||||
says everything is fine.
|
||||
|
||||
138
desktop/app.go
138
desktop/app.go
@@ -82,6 +82,14 @@ func (a *App) startup(ctx context.Context) {
|
||||
if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil {
|
||||
log.Printf("camera relay unavailable, tiles will not load: %v", err)
|
||||
}
|
||||
// And the other direction: watching a camera in another building, through
|
||||
// head office's relay. Enabled unconditionally rather than only when a
|
||||
// session already exists, because signing in is a thing that happens
|
||||
// while the app is open - and CameraLive refuses without a session
|
||||
// anyway, so there is nothing to gate.
|
||||
if err := a.proxy.watchRemote(a.cloud.CameraLive); err != nil {
|
||||
log.Printf("remote camera view unavailable: %v", err)
|
||||
}
|
||||
|
||||
// A saved session means a shop PC that rebooted overnight comes back
|
||||
// working instead of waiting for someone to log in.
|
||||
@@ -616,10 +624,44 @@ 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,
|
||||
"state": c.State, "state_note": c.StateNote,
|
||||
"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
|
||||
@@ -686,25 +728,115 @@ func (a *App) StreamURL(cameraID string) string {
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
}
|
||||
|
||||
// RemoteStreamURL is the live view of a camera in another building.
|
||||
//
|
||||
// The picture comes from head office's relay - the shop PC pushes frames
|
||||
// outbound because nothing can reach in - and this app re-emits them as MJPEG
|
||||
// on its own loopback, so a tile is an ordinary <img> either way. A screen
|
||||
// therefore never has to know which building it is looking at.
|
||||
//
|
||||
// Empty when the relay is not running, and the caller shows the last snapshot
|
||||
// instead. There is no useful fallback URL: the head-office endpoint needs
|
||||
// this session's bearer, which an <img> cannot send.
|
||||
func (a *App) RemoteStreamURL(cameraID string) string {
|
||||
if !a.cloud.LoggedIn() {
|
||||
return ""
|
||||
}
|
||||
return a.proxy.urlFor(cameraID, "live.mjpeg")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- live --
|
||||
|
||||
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 --
|
||||
|
||||
40
desktop/frontend/dist/assets/index-6LYbNlbD.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-6LYbNlbD.js
vendored
Normal file
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-Be_Iv2Nz.js
vendored
40
desktop/frontend/dist/assets/index-Be_Iv2Nz.js
vendored
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-6LYbNlbD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -38,6 +38,9 @@ export const api = {
|
||||
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
|
||||
placementResult: (id) => call('PlacementResult', id),
|
||||
streamURL: (id) => call('StreamURL', id),
|
||||
// The live view of a camera in another building, relayed through head
|
||||
// office. Empty when nobody is signed in.
|
||||
remoteStreamURL: (id) => call('RemoteStreamURL', id),
|
||||
|
||||
live: () => call('Live'),
|
||||
pipelineStatus: () => call('PipelineStatus'),
|
||||
|
||||
@@ -634,3 +634,32 @@ 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); }
|
||||
|
||||
/* Watch live sits over the picture, opposite the connection pill. It is on
|
||||
the tile rather than in the button row because it is about the picture, and
|
||||
because the row it would otherwise join is hidden on a remote camera. */
|
||||
.camview .btn.watch {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
border-color: rgba(255, 255, 255, .25);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.camview .btn.watch:hover { background: rgba(0, 0, 0, .72); }
|
||||
.camview .btn.watch.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
@@ -19,7 +19,27 @@ 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)
|
||||
// ONE camera at a time, and that is a cost decision rather than a layout
|
||||
// one. A remote view makes the shop computer upload frames for as long as
|
||||
// somebody is watching, so a grid that all went live at once would put an
|
||||
// estate's worth of cameras on the wire because somebody opened a page.
|
||||
const [watching, setWatching] = useState(null)
|
||||
const [watchURL, setWatchURL] = useState('')
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
if (!watching) { setWatchURL(''); return }
|
||||
api.remoteStreamURL(watching).then(u => { if (alive) setWatchURL(u || '') })
|
||||
.catch(() => { if (alive) setWatchURL('') })
|
||||
return () => { alive = false }
|
||||
}, [watching])
|
||||
|
||||
async function remove(cam) {
|
||||
if (!confirm(`Remove ${cam.id}? Recognition from it stops immediately.`)) return
|
||||
@@ -31,13 +51,22 @@ 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. Each tile shows that
|
||||
camera's most recent frame; <b>Watch live</b> asks the shop computer to
|
||||
send video for as long as you are looking.
|
||||
</div>}
|
||||
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{cams.length === 0
|
||||
@@ -51,7 +80,10 @@ export default function Cameras() {
|
||||
</div>
|
||||
: <div className="camgrid">
|
||||
{cams.map(c => (
|
||||
<CameraCard key={c.id} cam={c} stream={streams[c.id]}
|
||||
<CameraCard key={c.id} cam={c}
|
||||
stream={watching === c.id ? watchURL : streams[c.id]}
|
||||
watching={watching === c.id}
|
||||
onWatch={() => setWatching(watching === c.id ? null : c.id)}
|
||||
onEdit={() => setEditing(c)} onCheck={() => setCheck(c.id)} onRemove={() => remove(c)} />
|
||||
))}
|
||||
</div>}
|
||||
@@ -63,41 +95,74 @@ export default function Cameras() {
|
||||
)
|
||||
}
|
||||
|
||||
function CameraCard({ cam, stream, onEdit, onCheck, onRemove }) {
|
||||
const conn = cam.connected === undefined ? { tone: 'idle', label: 'Engine stopped' }
|
||||
: cam.connected ? { tone: 'ok', label: 'Connected' } : { tone: 'bad', label: 'Not connecting' }
|
||||
function CameraCard({ cam, stream, watching, onWatch, onEdit, onCheck, onRemove }) {
|
||||
// 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.remote
|
||||
// Four states, decided once by the server. `stale` is the one that was
|
||||
// missing: the shop computer reports nothing when it cannot reach its own
|
||||
// engine, so its last report used to sit there reading Connected -
|
||||
// measured at 34 minutes on the live estate.
|
||||
? ({ connected: { tone: 'ok', label: 'Connected' },
|
||||
not_connecting: { tone: 'bad', label: 'Not connecting' },
|
||||
stale: { tone: 'warn', label: 'Not reporting' } }[cam.state]
|
||||
|| { tone: 'idle', label: 'Waiting for the shop computer' })
|
||||
: cam.connected === undefined || cam.connected === null
|
||||
? { 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>
|
||||
{cam.remote && <button className={`btn sm watch ${watching ? 'on' : ''}`} onClick={onWatch}>
|
||||
<Icon.Play size={13} />{watching ? 'Stop watching' : 'Watch live'}
|
||||
</button>}
|
||||
</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.state_note
|
||||
? cam.state_note
|
||||
: watching
|
||||
? 'Live from the shop computer. It uploads only while you watch.'
|
||||
: cam.snapshot?.available
|
||||
? 'Last picture from the shop computer. Watch live to see it now.'
|
||||
: 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">
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -39,6 +40,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 +643,182 @@ 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"`
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ package main
|
||||
// session and the bytes are fetched and handed over as an object URL.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
@@ -64,6 +65,12 @@ type streamProxy struct {
|
||||
target string // engine origin, e.g. http://127.0.0.1:8010
|
||||
user string
|
||||
pass string
|
||||
|
||||
// Opens head office's live relay for one camera. Set on a computer that
|
||||
// is signed in, whether or not an engine runs here - which is the whole
|
||||
// point: watching a camera in another building is precisely the case
|
||||
// where there is no engine on this machine to ask.
|
||||
live func(ctx context.Context, cameraID string) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||
@@ -71,18 +78,49 @@ func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||
// start binds a loopback listener and begins relaying. Calling it again while
|
||||
// running is a no-op, so a restarted engine cannot leave two listeners behind.
|
||||
func (p *streamProxy) start(base, user, pass string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.srv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
base = "http://" + base
|
||||
}
|
||||
if _, err := url.Parse(base); err != nil {
|
||||
return fmt.Errorf("engine base %q: %w", base, err)
|
||||
}
|
||||
if err := p.bind(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.target = strings.TrimRight(base, "/")
|
||||
p.user, p.pass = user, pass
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchRemote makes the relay able to serve head office's live view, and
|
||||
// binds it if nothing else has.
|
||||
//
|
||||
// Separate from start() because the two are independent: a shop PC has both
|
||||
// an engine and a session, an owner's laptop has only a session, and a PC
|
||||
// still being set up has only an engine. Folding them together would mean a
|
||||
// computer with no engine could not watch a camera at all - which is the one
|
||||
// computer most likely to be trying to.
|
||||
func (p *streamProxy) watchRemote(fn func(context.Context, string) (*http.Response, error)) error {
|
||||
if err := p.bind(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.live = fn
|
||||
return nil
|
||||
}
|
||||
|
||||
// bind starts the loopback listener once. Calling it again while running is a
|
||||
// no-op, so neither a restarted engine nor a second sign-in can leave two
|
||||
// listeners behind.
|
||||
func (p *streamProxy) bind() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.srv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The engine's own credential exists precisely so that the live face feed
|
||||
// is never served open - CLAUDE.md is explicit that an unauthenticated
|
||||
@@ -105,8 +143,6 @@ func (p *streamProxy) start(base, user, pass string) error {
|
||||
|
||||
p.ln = ln
|
||||
p.token = hex.EncodeToString(raw)
|
||||
p.target = strings.TrimRight(base, "/")
|
||||
p.user, p.pass = user, pass
|
||||
// No client timeout: an MJPEG stream is endless by design and any deadline
|
||||
// would cut the picture off mid-shift. The request context ends it when
|
||||
// the webview navigates away or the tile is replaced.
|
||||
@@ -130,7 +166,7 @@ func (p *streamProxy) start(base, user, pass string) error {
|
||||
func (p *streamProxy) stop() {
|
||||
p.mu.Lock()
|
||||
srv, ln := p.srv, p.ln
|
||||
p.srv, p.ln, p.token = nil, nil, ""
|
||||
p.srv, p.ln, p.token, p.live = nil, nil, "", nil
|
||||
p.mu.Unlock()
|
||||
if srv != nil {
|
||||
_ = srv.Close()
|
||||
@@ -155,6 +191,7 @@ func (p *streamProxy) urlFor(cameraID, file string) string {
|
||||
func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.RLock()
|
||||
token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client
|
||||
liveFn := p.live
|
||||
p.mu.RUnlock()
|
||||
if token == "" || client == nil {
|
||||
http.NotFound(w, r)
|
||||
@@ -190,6 +227,17 @@ func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||
// calls; it is here so that adding a still later is a change to a screen
|
||||
// rather than a change to the one file where a mistake is a credentialed
|
||||
// proxy onto the biometric API.
|
||||
// Head office's relay, not the engine. The two are different machines and
|
||||
// different credentials, so this returns rather than falling through.
|
||||
if parts[3] == "live.mjpeg" {
|
||||
if liveFn == nil {
|
||||
http.Error(w, "not signed in to head office", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
p.relayRemote(w, r, cameraID, liveFn)
|
||||
return
|
||||
}
|
||||
|
||||
var enginePath string
|
||||
switch parts[3] {
|
||||
case "stream.mjpeg":
|
||||
|
||||
149
desktop/stream_remote.go
Normal file
149
desktop/stream_remote.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
// Watching a camera in another building, from the app.
|
||||
//
|
||||
// The shop PC sits behind a router with no inbound route, so nothing here can
|
||||
// pull its MJPEG stream - that stream is served on the shop PC's own loopback
|
||||
// and always will be. Head office's LiveHub is the way round it: the agent
|
||||
// asks outbound whether anyone is watching and pushes JPEG frames up for
|
||||
// exactly as long as somebody is. The head-office web app already consumes
|
||||
// that; this is the same feed, for the app.
|
||||
//
|
||||
// It arrives as base64 frames over SSE, which an <img> cannot render, so this
|
||||
// re-emits them as multipart MJPEG - which an <img> renders natively, through
|
||||
// the relay that already exists for the local engine. That is what keeps ONE
|
||||
// code path in the screens: a tile points at a loopback URL and does not know
|
||||
// or care which building the picture came from.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The boundary is ours to choose; it only has to be a string the JPEG bytes
|
||||
// cannot contain, and a marker line never appears inside JPEG data.
|
||||
const mjpegBoundary = "behavisionframe"
|
||||
|
||||
// A frame is base64, so ~1.33 bytes on the wire per byte of picture. The
|
||||
// engine re-encodes to 640 px for the relay and those measure ~20 KB, so this
|
||||
// is roughly a hundredfold headroom - large enough never to clip a real frame
|
||||
// and small enough that a broken or hostile stream cannot grow this process's
|
||||
// memory without bound.
|
||||
const maxFrameLine = 8 << 20
|
||||
|
||||
func (p *streamProxy) relayRemote(w http.ResponseWriter, r *http.Request,
|
||||
cameraID string, open func(context.Context, string) (*http.Response, error)) {
|
||||
|
||||
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary="+mjpegBoundary)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
// Send the headers NOW, before any frame exists. Go writes them on the
|
||||
// first body write, so without this the whole response - status line
|
||||
// included - waits for the shop computer to start pushing, and a viewer
|
||||
// whose camera is slow to answer sees the REQUEST time out rather than a
|
||||
// stream that has not painted yet. Measured against production: 30
|
||||
// seconds and not even a Content-Type.
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Reconnecting is normal, not an error. The server caps one push at five
|
||||
// minutes so that a tab left open for a week cannot leave a shop
|
||||
// uploading for a week - so a viewer who IS still there simply asks
|
||||
// again. Doing it here rather than in the page is what lets the <img>
|
||||
// survive the cap: it never sees the stream end.
|
||||
sent := 0
|
||||
for {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
n, err := p.pumpRemote(w, flusher, r.Context(), cameraID, open)
|
||||
sent += n
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
// Nothing was written and the attempt failed. Writing an error body
|
||||
// now would be writing it into a multipart stream the <img> is
|
||||
// already parsing, so the picture simply stays on whatever it last
|
||||
// showed and the screen's own "not connecting" state is the report.
|
||||
if err != nil && sent == 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pumpRemote runs one SSE connection to exhaustion and returns how many
|
||||
// frames it forwarded.
|
||||
func (p *streamProxy) pumpRemote(w http.ResponseWriter, flusher http.Flusher,
|
||||
ctx context.Context, cameraID string,
|
||||
open func(context.Context, string) (*http.Response, error)) (int, error) {
|
||||
|
||||
resp, err := open(ctx, cameraID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), maxFrameLine)
|
||||
|
||||
var event, data string
|
||||
frames := 0
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event: "):
|
||||
event = strings.TrimSpace(line[7:])
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
data = line[6:]
|
||||
case line == "":
|
||||
// End of one SSE event. `waiting` means head office has us
|
||||
// registered and the shop PC has not started pushing yet - a real
|
||||
// second or two while the agent is asked, and nothing to draw.
|
||||
if event == "frame" && data != "" {
|
||||
if err := writeMJPEGFrame(w, flusher, data); err != nil {
|
||||
return frames, err // the webview went away
|
||||
}
|
||||
frames++
|
||||
}
|
||||
event, data = "", ""
|
||||
}
|
||||
}
|
||||
return frames, sc.Err()
|
||||
}
|
||||
|
||||
func writeMJPEGFrame(w http.ResponseWriter, flusher http.Flusher, b64 string) error {
|
||||
jpg, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(jpg) == 0 {
|
||||
// One malformed frame is not a reason to tear down a working view.
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintf(w,
|
||||
"--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
|
||||
mjpegBoundary, len(jpg)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(jpg); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte("\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
// Flushed per frame. Anything held waiting for a full buffer is a tile
|
||||
// that stays blank, which is indistinguishable from the view not working.
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
103
desktop/stream_remote_live_test.go
Normal file
103
desktop/stream_remote_live_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-desktop/internal/cloud"
|
||||
)
|
||||
|
||||
// The whole chain against the real head office and a real shop computer:
|
||||
//
|
||||
// TEST_CLOUD_EMAIL=... TEST_CLOUD_PASSWORD=... \
|
||||
// go test ./desktop/ -run RemoteLive -v
|
||||
//
|
||||
// Everything in stream_remote_test.go proves the relay against a fake that
|
||||
// agrees with me. Only this proves the part that cannot be faked: that a shop
|
||||
// computer behind a router with no inbound route actually pushes frames when
|
||||
// asked, that they survive base64 and SSE, and that what comes out of the
|
||||
// loopback relay is a multipart stream an <img> will paint.
|
||||
//
|
||||
// It also costs something to run, which is why it is opt-in: watching makes
|
||||
// the shop computer upload for as long as the test reads.
|
||||
func TestRemoteLiveFromProduction(t *testing.T) {
|
||||
email, pass := os.Getenv("TEST_CLOUD_EMAIL"), os.Getenv("TEST_CLOUD_PASSWORD")
|
||||
if email == "" || pass == "" {
|
||||
t.Skip("set TEST_CLOUD_EMAIL and TEST_CLOUD_PASSWORD to run against production")
|
||||
}
|
||||
base := os.Getenv("TEST_CLOUD_URL")
|
||||
if base == "" {
|
||||
base = "https://mcp.loyaly.ai"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
c := cloud.New(base)
|
||||
if _, err := c.Login(ctx, email, pass); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
|
||||
cams, err := c.RemoteCameras(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("cameras: %v", err)
|
||||
}
|
||||
t.Logf("%d cameras", len(cams))
|
||||
target := os.Getenv("TEST_CLOUD_CAMERA")
|
||||
for _, cam := range cams {
|
||||
conn := "waiting"
|
||||
if cam.Connected != nil {
|
||||
conn = map[bool]string{true: "connected", false: "not connecting"}[*cam.Connected]
|
||||
}
|
||||
t.Logf(" %-10s %-16s %-15s snapshot=%v", cam.CameraID, cam.Site, conn, cam.Snapshot.Available)
|
||||
if target == "" && cam.Connected != nil && *cam.Connected {
|
||||
target = cam.CameraID
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
t.Skip("no connected camera to watch")
|
||||
}
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(c.CameraLive); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
rctx, rcancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer rcancel()
|
||||
req, _ := http.NewRequestWithContext(rctx, http.MethodGet, p.urlFor(target, "live.mjpeg"), nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
start := time.Now()
|
||||
acc, buf, frames := make([]byte, 0, 1<<20), make([]byte, 32*1024), 0
|
||||
for frames < 10 {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
acc = append(acc, buf[:n]...)
|
||||
frames = bytes.Count(acc, []byte("--"+mjpegBoundary))
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
el := time.Since(start)
|
||||
t.Logf("watching %q: %d frames, %d bytes, %.1fs (%.1f fps, %.0f KB/s)",
|
||||
target, frames, len(acc), el.Seconds(),
|
||||
float64(frames)/el.Seconds(), float64(len(acc))/el.Seconds()/1024)
|
||||
|
||||
if frames < 3 {
|
||||
t.Fatalf("got %d frames from a connected camera - the shop computer is "+
|
||||
"not answering head office's request to push", frames)
|
||||
}
|
||||
// Bytes that are actually a picture, not a framing header that happens to
|
||||
// be well formed. A JPEG begins FFD8.
|
||||
if !bytes.Contains(acc, []byte{0xFF, 0xD8, 0xFF}) {
|
||||
t.Error("no JPEG start marker anywhere in the stream")
|
||||
}
|
||||
}
|
||||
209
desktop/stream_remote_test.go
Normal file
209
desktop/stream_remote_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jpg is a byte sequence that is not valid JPEG and does not need to be: what
|
||||
// is under test is that the bytes arrive intact and framed, not that a decoder
|
||||
// likes them.
|
||||
var jpg = []byte{0xFF, 0xD8, 'h', 'e', 'l', 'l', 'o', 0xFF, 0xD9}
|
||||
|
||||
// sseServer answers head office's live endpoint with `pushes` frames and then
|
||||
// ends the response, which is what the server's five-minute cap does.
|
||||
func sseServer(t *testing.T, frames int, hits *int32) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(hits, 1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fl, _ := w.(http.Flusher)
|
||||
// Registered, nothing being pushed yet. Nothing may be drawn for it.
|
||||
fmt.Fprint(w, "event: waiting\ndata: \n\n")
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
for i := 0; i < frames; i++ {
|
||||
fmt.Fprintf(w, "event: frame\ndata: %s\n\n",
|
||||
base64.StdEncoding.EncodeToString(jpg))
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func openerFor(srv *httptest.Server) func(context.Context, string) (*http.Response, error) {
|
||||
return func(ctx context.Context, cam string) (*http.Response, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/live/"+cam, nil)
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point: base64 frames over SSE are not something an <img> can show,
|
||||
// and a multipart MJPEG stream is. Without this the app could only ever show a
|
||||
// still, on exactly the computers that cannot reach the camera any other way.
|
||||
func TestRemoteFramesReachTheWebviewAsMJPEG(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 3, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
u := p.urlFor("cam2", "live.mjpeg")
|
||||
if u == "" {
|
||||
t.Fatal("no relay url; the proxy did not bind")
|
||||
}
|
||||
|
||||
// The relay reconnects for as long as the viewer is there, so the read is
|
||||
// bounded by us rather than by the stream ending - exactly as an <img>
|
||||
// would behave.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/x-mixed-replace") {
|
||||
t.Fatalf("Content-Type = %q, an <img> will not treat that as a stream", ct)
|
||||
}
|
||||
|
||||
// Read the first three frames' worth and stop; the relay would otherwise
|
||||
// go on reconnecting forever, which is the behaviour being relied on.
|
||||
want := append([]byte(fmt.Sprintf("--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
|
||||
mjpegBoundary, len(jpg))), jpg...)
|
||||
got := make([]byte, 0, 4096)
|
||||
buf := make([]byte, 512)
|
||||
for len(got) < 3*len(want) {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
got = append(got, buf[:n]...)
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if n := bytes.Count(got, []byte("--"+mjpegBoundary)); n < 3 {
|
||||
t.Fatalf("got %d frames in %d bytes, want at least 3", n, len(got))
|
||||
}
|
||||
if !bytes.Contains(got, want) {
|
||||
t.Errorf("a frame was not framed as expected:\n%q", got[:min(len(got), 300)])
|
||||
}
|
||||
// `waiting` is a real state - head office has us registered and the shop
|
||||
// computer has not started pushing - and there is nothing to draw for it.
|
||||
// Emitting an empty part would blank a tile that already had a picture.
|
||||
if bytes.Contains(got, []byte("Content-Length: 0")) {
|
||||
t.Error("an empty frame was written for a waiting event")
|
||||
}
|
||||
}
|
||||
|
||||
// The server caps one push at five minutes so a tab left open for a week
|
||||
// cannot leave a shop uploading for a week. Reconnecting is therefore a normal
|
||||
// event, and doing it here rather than in the page is what lets the <img>
|
||||
// survive the cap - it never sees the stream end.
|
||||
func TestTheRelayReconnectsWhenHeadOfficeEndsAPush(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 1, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.urlFor("cam2", "live.mjpeg"), nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Two frames means two pushes, because each push carries exactly one.
|
||||
seen, buf := 0, make([]byte, 256)
|
||||
acc := make([]byte, 0, 2048)
|
||||
for seen < 2 {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
acc = append(acc, buf[:n]...)
|
||||
seen = bytes.Count(acc, []byte("--"+mjpegBoundary))
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen < 2 {
|
||||
t.Fatalf("got %d frames across reconnects, want 2", seen)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got < 2 {
|
||||
t.Errorf("head office was asked %d times, want at least 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Signed out, the relay must not pretend. There is no fallback URL to offer
|
||||
// either: the head-office endpoint needs this session's bearer, which an <img>
|
||||
// cannot send - so a tile that silently failed would be the only alternative.
|
||||
func TestTheRelayRefusesWhenNobodyIsSignedIn(t *testing.T) {
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(nil); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
resp, err := http.Get(p.urlFor("cam2", "live.mjpeg"))
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The relay is credentialed - it is a path to a live view of a shop floor -
|
||||
// and the token is the only thing standing between another local process and
|
||||
// it. live.mjpeg must be behind exactly the same door as the engine routes.
|
||||
func TestTheRemoteRouteIsBehindTheSameToken(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 1, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
// The right shape, the wrong value.
|
||||
parts := strings.Split(p.urlFor("cam2", "live.mjpeg"), "/")
|
||||
parts[4] = strings.Repeat("0", len(parts[4]))
|
||||
bad := strings.Join(parts, "/")
|
||||
|
||||
resp, err := http.Get(bad)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404 - and 404 rather than 403, because there is nothing here to tell an unwelcome caller they found the right door", resp.StatusCode)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) != 0 {
|
||||
t.Error("a request with the wrong token still made the shop computer upload")
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
96
server/internal/api/camera_state_test.go
Normal file
96
server/internal/api/camera_state_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ptr(b bool) *bool { return &b }
|
||||
|
||||
// The state this was written for. Two cameras read "Connected", in green, on
|
||||
// the live estate thirty-four minutes after the shop computer had stopped
|
||||
// being able to see either of them - because the agent correctly reports
|
||||
// nothing when it cannot reach the engine, and the last value it sent stays
|
||||
// in the database looking current.
|
||||
func TestAStaleReportIsNotAConnectedCamera(t *testing.T) {
|
||||
now := time.Date(2026, 9, 30, 11, 13, 0, 0, time.UTC)
|
||||
c := Camera{Connected: ptr(true),
|
||||
LastSeenAt: now.Add(-34 * time.Minute).Format(time.RFC3339)}
|
||||
c.CameraState(now)
|
||||
|
||||
if c.State != CameraStale {
|
||||
t.Errorf("state = %q, want %q", c.State, CameraStale)
|
||||
}
|
||||
// Cleared, not merely overruled. A stale true left in place stays
|
||||
// available to every client that reads the field directly, and leaves two
|
||||
// fields on one object disagreeing.
|
||||
if c.Connected != nil {
|
||||
t.Errorf("connected = %v, want null - nobody currently knows", *c.Connected)
|
||||
}
|
||||
if c.StateNote == "" {
|
||||
t.Error("a stale camera said nothing about what to do")
|
||||
}
|
||||
}
|
||||
|
||||
// One missed report is a dropped packet. Warning on it would put an alarm on a
|
||||
// healthy estate every few minutes, and an indicator that cries wolf is one
|
||||
// people learn to ignore.
|
||||
func TestOneMissedReportIsStillConnected(t *testing.T) {
|
||||
now := time.Now()
|
||||
c := Camera{Connected: ptr(true),
|
||||
LastSeenAt: now.Add(-90 * time.Second).Format(time.RFC3339)}
|
||||
c.CameraState(now)
|
||||
if c.State != CameraConnected {
|
||||
t.Errorf("state = %q after 90s, want %q", c.State, CameraConnected)
|
||||
}
|
||||
if c.Connected == nil || !*c.Connected {
|
||||
t.Error("a fresh report lost its connected flag")
|
||||
}
|
||||
}
|
||||
|
||||
// "Nobody has ever told us" and "nobody has told us lately" need different
|
||||
// sentences: the first is a camera head office added a minute ago and the
|
||||
// shop computer has not picked up, the second is a shop computer that has
|
||||
// stopped. Sending an installer to the wrong one wastes a journey.
|
||||
func TestNeverReportedIsNotTheSameAsStopped(t *testing.T) {
|
||||
now := time.Now()
|
||||
var never Camera
|
||||
never.CameraState(now)
|
||||
if never.State != CameraWaiting {
|
||||
t.Errorf("state = %q, want %q", never.State, CameraWaiting)
|
||||
}
|
||||
|
||||
stopped := Camera{LastSeenAt: now.Add(-time.Hour).Format(time.RFC3339)}
|
||||
stopped.CameraState(now)
|
||||
if stopped.State == never.State {
|
||||
t.Fatal("a camera nobody has reported and one that stopped read the same")
|
||||
}
|
||||
if stopped.StateNote == never.StateNote {
|
||||
t.Error("two states that need different actions gave the same advice")
|
||||
}
|
||||
}
|
||||
|
||||
// A camera the shop computer CAN see and cannot open is the one case where
|
||||
// "check the cabling" is the right advice, and it must stay distinguishable
|
||||
// from the three where it is not.
|
||||
func TestAFreshFailureSaysCheckTheCamera(t *testing.T) {
|
||||
now := time.Now()
|
||||
c := Camera{Connected: ptr(false), LastSeenAt: now.Format(time.RFC3339)}
|
||||
c.CameraState(now)
|
||||
if c.State != CameraNotConnect {
|
||||
t.Errorf("state = %q, want %q", c.State, CameraNotConnect)
|
||||
}
|
||||
if c.Connected == nil || *c.Connected {
|
||||
t.Error("a reported failure must stay false, not become unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// An unparseable timestamp is not a working camera. It should not be possible,
|
||||
// which is exactly why it must not fall through to "connected".
|
||||
func TestAnUnreadableTimestampIsStale(t *testing.T) {
|
||||
c := Camera{Connected: ptr(true), LastSeenAt: "not a time"}
|
||||
c.CameraState(time.Now())
|
||||
if c.State != CameraStale || c.Connected != nil {
|
||||
t.Errorf("state = %q connected = %v, want stale and unknown", c.State, c.Connected)
|
||||
}
|
||||
}
|
||||
@@ -655,6 +655,11 @@ type Camera struct {
|
||||
Snapshot Image `json:"snapshot"`
|
||||
SnapshotAt string `json:"snapshot_at,omitempty"`
|
||||
|
||||
// State is the ONE answer a screen should render, because there are four
|
||||
// of them and only three were ever expressed. See CameraState.
|
||||
State string `json:"state"`
|
||||
StateNote string `json:"state_note,omitempty"`
|
||||
|
||||
// Check is the last attempt to prove this camera works. Always present so
|
||||
// a client can tell "never checked" from "checked and failed" without
|
||||
// guessing from an absent field.
|
||||
@@ -962,3 +967,70 @@ type DeviceSession struct {
|
||||
// hand.
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// Camera states, and why a fourth one had to exist.
|
||||
//
|
||||
// A shop computer reports each camera's state about once a minute. When it
|
||||
// cannot reach the recognition engine it reports NOTHING - correctly, because
|
||||
// it has nothing to say - and the last value it sent stays in the database
|
||||
// unchanged. Measured on the live estate: two cameras reading **Connected**,
|
||||
// in green, thirty-four minutes after the shop computer had stopped being
|
||||
// able to see either of them, while the heartbeat from the same PC said
|
||||
// 0 of 0 cameras. Both surfaces were reading 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. The honest statement is that
|
||||
// nobody currently knows - and that is a different sentence from "nobody has
|
||||
// ever told us", which is what an unreported camera needs. Two states that
|
||||
// need different actions must never share a word; the same rule that keeps
|
||||
// `artifact` apart from `no_faces` in the commissioning verdicts.
|
||||
const (
|
||||
CameraConnected = "connected" // reported recently, and working
|
||||
CameraNotConnect = "not_connecting" // reported recently, and not
|
||||
CameraWaiting = "waiting" // no shop computer has ever reported
|
||||
CameraStale = "stale" // reported once, and not lately
|
||||
)
|
||||
|
||||
// CameraStaleAfter is five missed reports, not one.
|
||||
//
|
||||
// The agent reports on a 60 s tick, so one miss is a dropped packet or a slow
|
||||
// upload. Calling that stale would put a warning on a healthy estate every few
|
||||
// minutes, and an indicator that cries wolf is one people learn to ignore -
|
||||
// which is the same reasoning that makes a site offline after three missed
|
||||
// heartbeats rather than one.
|
||||
const CameraStaleAfter = 5 * time.Minute
|
||||
|
||||
// CameraState decides the four states, and nulls Connected when it is not
|
||||
// entitled to an opinion.
|
||||
//
|
||||
// Connected is CLEARED rather than left alone on purpose. Leaving a stale true
|
||||
// in place would keep the lie available to every client that reads the field
|
||||
// directly - a mobile app, a script, an older build of our own desktop app -
|
||||
// and would leave two fields on one object disagreeing, which is precisely how
|
||||
// the shops screen once came out labelled Working in green above "2 of 3
|
||||
// cameras not connecting".
|
||||
func (c *Camera) CameraState(now time.Time) {
|
||||
switch {
|
||||
case c.LastSeenAt == "":
|
||||
c.State = CameraWaiting
|
||||
c.StateNote = "No shop computer has reported on this camera yet."
|
||||
c.Connected = nil
|
||||
return
|
||||
}
|
||||
seen, err := time.Parse(time.RFC3339, c.LastSeenAt)
|
||||
if err != nil || now.Sub(seen) > CameraStaleAfter {
|
||||
c.State = CameraStale
|
||||
c.StateNote = "The shop computer has stopped reporting this camera. " +
|
||||
"Check that the computer is on and Behavision is running on it."
|
||||
c.Connected = nil
|
||||
return
|
||||
}
|
||||
if c.Connected != nil && *c.Connected {
|
||||
c.State = CameraConnected
|
||||
return
|
||||
}
|
||||
c.State = CameraNotConnect
|
||||
c.StateNote = "The shop computer cannot open this camera's stream. " +
|
||||
"Check the address, the password and the cabling."
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ func scanCamera(row pgx.Row) (api.Camera, error) {
|
||||
// The KEY travels in ImageKey, which is json:"-", and the handler swaps it
|
||||
// for a signed link. Same rule as an arrival's face.
|
||||
c.Snapshot.Key = snapKey
|
||||
// Here rather than in a handler, because every camera anybody reads comes
|
||||
// through this function and a state computed per caller is a state one
|
||||
// caller forgets - which is how a stale `connected` reached three screens
|
||||
// at once.
|
||||
c.CameraState(time.Now())
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
server/internal/web/dist/index.html
vendored
2
server/internal/web/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-Ckr5hGZd.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BuVkBNdU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -47,9 +47,13 @@ export default function Cameras({ user }) {
|
||||
|
||||
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
|
||||
const list = cams || []
|
||||
const up = list.filter(c => c.connected).length
|
||||
const down = list.filter(c => c.connected === false).length
|
||||
const waiting = list.filter(c => c.connected == null).length
|
||||
// Counted off `state`, the one field the server computes, never off
|
||||
// `connected`. Two places deciding the same fact is how a shop came out
|
||||
// labelled Working, in green, above "2 of 3 cameras not connecting".
|
||||
const up = list.filter(c => c.state === 'connected').length
|
||||
const down = list.filter(c => c.state === 'not_connecting').length
|
||||
const waiting = list.filter(c => c.state === 'waiting').length
|
||||
const stale = list.filter(c => c.state === 'stale').length
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -58,6 +62,7 @@ export default function Cameras({ user }) {
|
||||
<p className="sub">
|
||||
{list.length} {list.length === 1 ? 'camera' : 'cameras'}
|
||||
{up > 0 && <> · <b className="ok">{up} connected</b></>}
|
||||
{stale > 0 && <> · <b className="warn">{stale} not reporting</b></>}
|
||||
{down > 0 && <> · <b className="bad">{down} down</b></>}
|
||||
{waiting > 0 && <> · {waiting} waiting for the shop PC</>}
|
||||
</p>
|
||||
@@ -108,10 +113,13 @@ function CameraCard({ cam, canEdit, onEdit, onWatch }) {
|
||||
// Three states, not two. A camera nobody has tried yet is not a camera that
|
||||
// is down, and telling an operator to check the cabling on a camera the shop
|
||||
// PC has not even seen sends them to the wrong building.
|
||||
const state = cam.connected == null ? 'idle'
|
||||
: cam.connected ? 'ok' : 'bad'
|
||||
const words = cam.connected == null ? 'Waiting for the shop PC'
|
||||
: cam.connected ? 'Connected' : 'Not connecting'
|
||||
// Four states, and the fourth is the one that was missing: a camera whose
|
||||
// shop PC has stopped reporting it. The agent correctly says nothing when it
|
||||
// cannot reach the engine, so the last value it sent used to sit in the
|
||||
// database reading Connected - measured at 34 minutes on the live estate.
|
||||
const state = { connected: 'ok', not_connecting: 'bad', stale: 'warn' }[cam.state] || 'idle'
|
||||
const words = { connected: 'Connected', not_connecting: 'Not connecting',
|
||||
stale: 'Not reporting' }[cam.state] || 'Waiting for the shop PC'
|
||||
const verified = verification(cam)
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user