82 Commits

Author SHA1 Message Date
248025cdf9 The Python ceiling was defeated by the venv the failure left behind
makeVenv reused any environment already on disk, whatever Python built it.
The machine that found the version bug already had a runtime built by 3.14,
left there by the run that failed - so with the ceiling in place setup would
choose a good interpreter, reach makeVenv, find the 3.14 environment, keep it,
and die in the same clang error as before.

A fix a user cannot reach because the bug's own debris is in the way is not a
fix, and it would have read as the release not working.

It now asks the interpreter inside an existing environment what it is and
rebuilds when the answer is unsupported, saying so. Rebuilding costs a
re-download of the libraries and nothing else - the models live in the state
root. An environment that cannot be asked counts as unusable too: a
half-created one answers nothing, and reusing it fails later in pip with an
error about a package rather than about the environment.

Tested against real environments rather than a fake, because what is under
test is what an interpreter on disk reports about itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 17:11:47 +05:30
ff4f95c3b0 Four silent failures a demo on somebody else's Mac walked straight into
Three reported from a colleague's machine, plus one the fixing uncovered.
Every one produced a message that was true and useless.

## behavision-setup chose the Python least likely to work

findPython walked 3.14, 3.13, 3.12, 3.11, 3.10 and took the first hit - a
floor with NO ceiling, which is exactly backwards. The newest Python on a
machine is the one least likely to have binary wheels. It picked 3.14, pip
found no numpy wheel for cp314, fell back to building numpy from source and
produced "Unknown compiler(s)"; once the operator had installed Xcode's
command line tools to get past that, ten minutes of compiling ended in
"<arm_neon.h> is intended only for ARM and AArch64 targets".

maxMinor refuses in one line before anything is downloaded, and "too new" is
a different message from "too old" - telling somebody holding Python 3.14
that no Python was found sends them to install a newer one, which is the
direction that just failed.

## numpy<2.0 was the cap; OpenCV was the hazard

Widening it needed proof, and the proof found something else. Nine runs of
the detector guard per combination, one machine, one sitting:

  numpy 1.26 / cv2 4.11    9 passed, 0 crashed
  numpy 2.0  / cv2 4.11    8 passed, 1 crashed
  numpy 1.26 / cv2 4.14    3 passed, 6 crashed
  numpy 2.0  / cv2 4.14    2 passed, 7 crashed

numpy is not the variable; OpenCV is - the third row is numpy 1.26. The crash
was test_a_shared_detector_really_does_race, which races a shared
cv2.FaceDetectorYN on purpose. That is undefined behaviour in C++: 4.11
usually turned it into an exception, 4.14 usually turns it into a segfault,
and 4.11 crashing once says the hazard was always there.

It never reached the product - Engine._build_worker builds a detector per
camera. It reached the suite: two runs in three died with no failing
assertion in them. The race runs in a subprocess now, and one clean attempt
proves nothing, so the premise holds if any of several attempts misbehaves.
226 passed / 2 skipped on numpy 2.0.2, five runs of five.

opencv stays capped below 5: everything above was measured on 4.x, and an
uncapped >=4.8.1 gives every NEW install a major release this project has
never run a real camera through.

## One MQTT client id for a whole shop, so two PCs fought over it

behavision-<client>-<site> is the same string on every computer claimed to one
site. MQTT requires unique client ids and a broker enforces it by
disconnecting the older session, so the colleague's Mac and the shop's own
till took turns kicking each other off:

  broker connected / broker connection lost: EOF / broker connected / EOF ...

The damage is not confined to the new machine. The till is the other half of
that loop, so signing in on a laptop to look at the product stops a live shop
delivering visits - and from each end it reads as an unstable network.

MQTTClientID() appends a per-installation id, minted on first load and written
back so an existing install gets one without anybody doing anything. The site
stays in the name because that is what a broker log is read by. An unwritable
config falls back to a per-run id rather than a shared one.

## "no such file or directory" for an engine nobody had installed

Pressing Start went straight to the supervisor, which reported what exec
reported: a 200-character path ending in "no such file or directory". Every
word true, none of it saying "run the setup tool" - the startup path had that
sentence, in a log file nobody on a shop counter opens.

engineMissing() is the one function the startup path, the Start button and the
status panel all consult. It also names App Translocation, which was in that
path and is unguessable: macOS runs a downloaded unsigned app from a random
read-only copy, so relative paths resolve inside it and an install there would
not survive a restart. The product is unsigned, so that is the normal
first-run state on every Mac, not an edge case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 17:09:25 +05:30
48a30d97db Live camera view in the app, and the green light that was lying about it
Two changes, and the second was found by verifying the first.

## Watching a camera from the app, in another building

Snapshots answer "is that camera working". They do not answer "what is
happening in my shop right now", which is what somebody who opens the app away
from the counter is asking. Head office's browser already had that answer -
LiveHub plus cameras.Live, where the shop PC asks outbound whether anybody is
watching and pushes JPEG frames for as long as somebody is - and the app could
not reach it.

cloud.CameraLive opens that feed and the app's own loopback relay re-emits it
as multipart MJPEG. That is the trick: frames arrive base64 over SSE, an <img>
cannot render that, and an <img> renders MJPEG natively - so a tile is an
ordinary <img> pointed at loopback whether the camera is in this room or
another city.

- Reconnecting happens in the relay, not the page. The server caps one push at
  five minutes, so doing it here means the <img> never sees the stream end.
- The headers are flushed before the first frame. Go writes them on the first
  body write, so without that the whole response waits for the shop PC to
  start pushing. Measured against production: 30 seconds and not even a
  Content-Type, which surfaces as the request timing out.
- One camera at a time. Watching makes a shop PC upload, so a grid that went
  live at once would put an estate's worth of cameras on the wire because
  somebody opened a page.
- live.mjpeg is behind the same per-run token as the engine routes, and a
  wrong token is a 404 that never reaches head office at all.
- CameraLive uses its own HTTP client: the shared one's 30s timeout covers the
  whole response and would sever a working view every thirty seconds - the
  trap that made the server set WriteTimeout to zero for its own SSE endpoint.

## A camera read "Connected" for 34 minutes after the shop PC went blind

Which is why the verification above looked like a failure: head office
registered the viewer and no frame ever came.

reportWith returns early when the engine is unreachable - correctly, it has
nothing to say - so the last state it sent stays in the database looking
current. Measured live: cam2 and entrance both reading Connected, in green,
with last_seen_at 34 minutes old, while the heartbeat from the same PC said
cameras_up 0 of 0. Two surfaces reading two stored fields and disagreeing.

false could not be the answer. It means "this camera is not connecting", which
sends an installer to check cabling on a camera that was working perfectly the
last time anybody could ask it. So there are four states and one function:

  connected       reported recently, and working
  not_connecting  reported recently, and the stream will not open
  waiting         no shop PC has ever reported this camera
  stale           reported once, and not lately

- Connected is CLEARED when stale or waiting. A stale true left in place stays
  available to every client reading the field directly, and leaves two fields
  on one object disagreeing - how the shops screen once came out labelled
  Working, in green, above "2 of 3 cameras not connecting".
- Computed in scanCamera, so every camera anybody reads passes through it. A
  state computed per handler is one a handler forgets, and this had already
  reached three screens.
- CameraStaleAfter is 5 minutes: five missed reports, not one. Same reasoning
  as three missed heartbeats - an indicator that cries wolf gets ignored.
- An unparseable last_seen_at is stale. It should be impossible, which is why
  it must not fall through to the state that says everything is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 16:48:43 +05:30
ecc8bbba6f The app on a laptop reported an engine that was never meant to be there
Signing in on a second Mac showed "engine not reachable at
http://127.0.0.1:8010" and 0 of 0 cameras, on an account whose shops were
running and recognising people the whole time. Nothing was broken: Live() and
Cameras() read only the engine on loopback, so the app answered as though the
person had never signed in - and camera sync goes through the engine, which is
why the count was zero rather than stale.

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

Decisions worth keeping:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 16:33:32 +05:30
97a8ecc03a Setup told a Mac with Python 3.12 on it to go and install Python
Found by running behavision-setup against a clean state directory the
way a second machine will, which had never been done. It failed at the
first step:

    Setup did not finish: no Python 3.10 or newer was found
      Found, but too old: python3 3.9

on a machine that has 3.12. The search was `python3` then `python`, and
on macOS `/usr/bin/python3` is ALWAYS the Command Line Tools build -
3.9 on current macOS, below the 3.10 floor. Anything newer installs as
`python3.12`, under Homebrew, as a framework, or somewhere a GUI
application's minimal PATH never sees.

So it now tries versioned names newest-first, then the plain ones, then
the four directories macOS actually uses - and absolute candidates are
stat'd rather than passed to LookPath, which only searches PATH. It
found /Users/tenext/.local/opt/python3.12/bin/python3.12, which is
exactly the interpreter it had been ignoring.

With that, the whole install completes on a Mac for the first time:
venv, engine and dependencies, models, agent.json, and "Engine starts
and answers - verified". EXIT=0, a 298 MB runtime.

Also the last thing it prints, which is the first thing an operator
acts on. It said "Start Behavision from the Start menu" and "it appears
in the system tray; right-click there to stop it". On macOS there is no
Start menu and, deliberately, no tray at all - so the finishing message
was describing a machine the user was not sitting at, on the one step
where setup had otherwise succeeded. It now says to right-click the app
the first time because the build is not notarised, and that closing the
window stops recognition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 15:44:25 +05:30
50d122e5f0 An unused constant was a Stop() that could hang forever
Ran staticcheck across all three Go modules for the first time. server
(23k lines) and desktop came back clean. agent had seven findings, and
one of them was not tidiness.

`stopGrace = 10 * time.Second` was declared and wired to nothing.
Stop() cancels the context, cmd.Cancel kills the process tree, and then
Stop() blocks on cmd.Wait() - which, with no WaitDelay set, waits not
just for the process but for every writer of its stdout pipe to close.
One grandchild still holding that pipe hangs Wait, hangs Stop, and on the
desktop app that is the tray's Quit never returning. The constant named
the intent and nothing read it. cmd.WaitDelay = stopGrace is the line
that was missing.

The rest were real but small: an unused field in the live relay, an
unused sleep helper in the pump, and "net/url" imported twice under two
names - both genuinely used, in two functions doing the same job for the
same reason, so they are unified rather than one deleted. My first pass
deleted the wrong one on a bad grep and the build caught it immediately.

Three findings are suppressed rather than fixed, with the reason stated:

- Two "error strings should not end with punctuation". Both are
  multi-line messages a shop operator reads at a counter, not errors
  anything wraps. ST1005 exists because wrapped errors concatenate
  mid-sentence; stripping the full stops would run three sentences
  together to satisfy a rule that does not apply.
- A deliberately nil context in a pump test - the point of the test is
  that an unconnected client does not panic. It already carried
  //nolint:staticcheck, which is golangci-lint's directive and
  staticcheck ignores, which is why it kept being reported.

Also tidied agent/go.mod, which had paho and x/sys marked indirect while
being imported directly.

All three modules clean, all suites pass: 21 Go packages, 226 engine
tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 15:14:09 +05:30
dd3331ee9d Commit the technical dossier, marked as the snapshot it is
511 lines extracted from the code at release 0.4.1 / schema 013, and
accurate for that point. The repository is nine releases and a schema
past it.

A stale document that states its own version reads as current to anyone
skimming, which is the same failure this project keeps catching
elsewhere: wrong in a way nobody can detect. So the top now lists what
it predates by name - the motion gate, Gallery.health, tenantOnly, the
password endpoint, customers and merge, the admin drill-down, sales and
dashboard, migration 014, the macOS build - and points at API.md and
CLAUDE.md, which are kept current.

No credential values in it; the matches for password/secret/token are
environment variable NAMES and package paths describing where secrets
live, which is what a dossier should say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 15:06:19 +05:30
68a50d10b1 Record the desktop work: the tray, macOS, and the release
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 15:01:55 +05:30
8137480877 release.sh can build the macOS package too
Cheap for a reason worth stating: the Windows package is already a SOURCE
install - a pure-Python wheel plus a setup tool that builds a venv on the
target machine, because PyInstaller cannot cross-compile. macOS needs
nothing different. Same wheel, same setup tool, a natively built .app in
place of the .exe. No frozen engine, no 200 MB, no second packaging story.

behavision-setup already handled both platforms (Scripts vs bin, the
tasklist check guarded) and cross-compiled for darwin without a change.
The one thing that did not was its advice when Python is missing: it told
everyone to tick 'Add python.exe to PATH' on a Windows installer page.
Software that does not know which machine it is running on is software
somebody stops trusting for the rest of the session.

MAC=1 opts in, so the ordinary Windows release is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 14:50:45 +05:30
f5eb2bb124 The green button was disabled by an options block that was not there
Reported from the Mac build: the window cannot be maximised. It is not a
Wails limitation or a WebView quirk, it is an omission with a very
specific consequence.

Wails computes zoomable INSIDE `if frontendOptions.Mac != nil`:

    var fullSizeContent, hideTitleBar, zoomable, ... C.int   // 0
    if frontendOptions.Mac != nil {
        zoomable = bool2Cint(!frontendOptions.Mac.DisableZoom)
    }

and the native side then acts on the zero:

    if (!zoomable && resizable) {
        NSButton *button = [self.mainWindow
                              standardWindowButton:NSWindowZoomButton];
        [button setEnabled: NO];
    }

So leaving Mac unset does not mean "take the defaults" - it means the
green button is created and then explicitly disabled. There was a Windows
options block and no Mac one, which is how this survived: the platform
that was configured behaved, and the platform that was not looked broken.

Fixed by the block existing. The fields are written out rather than left
as an empty struct so it reads as a decision rather than something half
typed.

Verified at runtime rather than by reasoning about the source alone: all
three title-bar buttons report enabled=true through the accessibility
API, and the window resizes to 1440x900, the full display.

One correction to my own first check, recorded because it nearly sent me
the wrong way: querying AXFullScreenButton as an ATTRIBUTE of the window
returns "missing value" whether or not the button exists. It has to be
found by subrole among the window's buttons. The button was fine; the
question was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 13:22:04 +05:30
0b29dd4a50 The engine inherited whatever directory launched the app
Nothing ever set the child's working directory, so it took the parent's -
and an app started by double-clicking its bundle is handed "/", not
anywhere useful. On macOS the symptom was
`python: No module named behavision` repeating forever, because the dev
engine is invoked as `-m behavision` and that resolves against the
working directory.

The same app launched from a terminal inside the repo worked perfectly,
which is exactly the shape of a bug that survives every test a developer
runs. It only appeared when the app was started the way a user starts
one.

Config.EngineDir, empty meaning the install root, set by both launchers -
the desktop app and the headless agent, which had identical code and the
identical omission. It matters beyond this case: the shipped Windows
engine is a one-folder PyInstaller build whose relative paths should
resolve beside itself rather than beside Explorer's idea of a current
directory.

Verified by double-clicking the bundle with nothing in the environment:
engine up on 8010 (401, gated), w600k_r50 on CoreML, gallery 5/5
embeddings usable and none stranded, both office cameras connected and
streaming, and head office reporting cameras 2/2 one heartbeat later.

Two things that showed up while proving it, both the product being
honest rather than faults:

- The camera at .121 was genuinely unreachable for several minutes, and
  last_error said so in words an installer can act on - "cannot reach
  192.168.1.121:554 - No route" - rather than `connected: false`. That
  field was added yesterday for precisely this.
- Head office briefly showed cameras 0/1 against a local 2/2. That is a
  60-second heartbeat, not a disagreement; the next one read 2/2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 12:42:55 +05:30
e33761d6d0 A Mac build, and the two ways it crashed first
Chosen deliberately as a DEVELOPER build, not a product. Indian retail
counters are Windows; shipping a Mac product means an Apple Developer
account, notarisation, a second installer format, a second frozen engine
and DPAPI having no macOS equivalent - a permanent second platform for
customers who do not have Macs. What a Mac build is worth is demoing the
desktop app on the machine it is written on, without needing the Windows
box.

It built after one missing framework (previous commit) and then crashed
within a second, twice, both times in the tray:

  systray.Run              SIGTRAP inside cgo. nativeLoop() takes the
                           macOS main run loop for itself and Wails
                           already has it. macOS has exactly one.
  RunWithExternalLoop      "NSWindow should only be instantiated on the
                           main thread!" - it registers in the existing
                           NSApplication rather than starting a second,
                           but still builds AppKit objects, and Wails'
                           OnStartup is not the main thread.

Making it work needs the status item created through a main-queue
dispatch inside Wails' lifecycle. That is real work for a build whose
purpose is a demo, so macOS has no tray and the file says so at length
rather than leaving the next person to rediscover both crashes.

The consequence is handled rather than left lying. With no tray there is
no way back from a hidden window and no way to quit, so hiding on close
would strand a running engine behind no window, no tray and no control -
force-quit or nothing. On macOS closing the window therefore quits, and
OnShutdown stops the engine. Same rule the tray's Quit already follows:
never leave it watching with no visible control. Windows is untouched,
where hiding is correct because the tray is how it comes back.

The runner is split by build tag rather than branched at runtime because
the two platforms need different systray ENTRY POINTS, not different
arguments.

Verified: 18 seconds up, zero crash markers, 88 MB resident, and an
honest "engine not installed yet" instead of a crash - against a
throwaway data dir so it claimed nothing and touched no camera. The
frozen Mac engine is deliberately not built; the app takes an engine
command from config, which is how the dev setup already points at the
venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 11:44:49 +05:30
02b2c5bc39 "Open dashboard" in the tray did nothing reliable, and there is a Mac build
Reported from the shipped Windows app. Three faults in one call, and the
first is why it failed rather than merely misbehaved.

runtime.Show is implemented by Wails as a bare mainWindow.Show(), while
runtime.WindowShow wraps the identical work in runtime.LockOSThread. Win32
window operations have to run on the thread owning the window's message
pump, and the tray's handler runs on the SYSTRAY's goroutine, which is
never that thread. An unlocked Win32 call from an arbitrary goroutine is
the bug.

Two more that would each have been enough on their own:

- Showing is not un-minimising. Hidden and minimised are different states
  and Show only fixes the first, so a window the user minimised stayed
  minimised.
- Windows refuses the foreground to a process that does not already hold
  it, so the window came back BEHIND whatever was being looked at.
  Clicking a tray icon is by definition a moment when this app is not in
  front, so that is not an edge case here - it is every time. The
  always-on-top flip is the ordinary way to ask, and it is why this now
  runs in a goroutine rather than on the menu loop, which must not sleep.

The same four calls fix OnSecondInstanceLaunch, which had the same shape
and is reached far more often: double-clicking the desktop icon while the
app is already running.

OnBeforeClose used runtime.Hide against a reopen that used WindowShow -
different calls on Windows, one thread-locked and one not. Paired now.

And a Mac build, because the question came up and the answer turned out
to be yes. Wails' darwin frontend references UTType without linking
UniformTypeIdentifiers, so the build failed at the LINK step after
compiling everything - which reads like a broken toolchain rather than
one missing flag. There was no Mac version because of that, not because
of a design limit. darwin_link.go declares the framework in source rather
than leaving it as a CGO_LDFLAGS incantation, for the same reason
deploy.sh now finds Go itself. Verified: plain `go build` produces a
16 MB arm64 binary on this Mac, and the Windows build is unchanged.

Worth knowing for whoever edits that file: the comment directly above
`import "C"` is cgo's C preamble, not documentation. The first attempt put
the explanation there and the prose was compiled as C.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 17:42:39 +05:30
e5a63cc412 Document the customer create and merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 16:08:52 +05:30
e27eb8e927 A discarded phone number was kept and could not be found
The merge records what it had to discard in the survivor's notes, and
search did not look there - so the value was retained and unfindable,
which answers the letter of "nothing is lost" and not the point of it. A
customer reached by their old number is exactly who somebody is looking
for when they type it.

Caught in the same patch: I wrote ESCAPE with two backslashes where the
four clauses beside it use one. In a Go raw string that is two literal
backslashes, and Postgres requires the escape to be a single character -
it would have failed the whole customer search at runtime, on a query no
in-memory test executes. All five clauses are identical now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 16:07:43 +05:30
7c564aca3c A merge lost a phone number on its first live run
Found by walking the scenario against production rather than by a test.
Two records, each with a phone; the survivor kept its own, and the
source's simply stopped existing. Searching for it returned nothing.

The first version's rule was "fill the survivor's blanks, never overwrite
what it has", which is right about which value WINS and said nothing
about the one that loses. One person can have two numbers, two spellings
of a name, a work address and a personal one - and a merge that quietly
deletes one is exactly the data loss this file already refuses elsewhere:
"silently turning Alice back into Visitor 3 is data loss the operator
cannot see happen."

The profile is now reconciled field by field in Go rather than in one
clever upsert, because the interesting case was never the winner. Blanks
are still filled and the survivor still keeps its own values, but every
losing value is returned in `discarded` AND appended to the survivor's
notes - the response is read once and the record is read forever.

Notes themselves are additive rather than a winner: two people writing
about one customer wrote two different true things.

mergeProfiles is pure, so the rule is asserted directly - four cases
including the ordinary one, a typed record joining a camera record with
no profile at all, which must add no noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 16:02:06 +05:30
7dda6ab508 A customer nobody has photographed, and the way back when they are seen
POST /api/customers and POST /api/visitors/{id}/merge. They ship together
because the first creates the need for the second: a customer typed in at
a counter has no face template, so when a camera sees that person later
the matcher has nothing to compare against and enrols them as somebody
new. That is the design working, not failing - and it means every
hand-created customer is a duplicate waiting to happen. Shipping the
create alone would manufacture duplicates into the state CLAUDE.md
already flags: "there is no merge endpoint server-side, so its
duplicates would be unrecoverable."

The number comes from clients.visitor_seq, taken exactly as RecordVisit
takes it. Two sources of visitor numbers that could disagree would be
worse than none: V-42 has to mean one person whichever way they arrived.
The label is the typed name, or "Visitor N" when they gave none - the
same string the engine writes, so a record created by hand is
indistinguishable from an enrolled one afterwards.

The merge is one transaction over FIVE tables, and the count is the
point. visits, purchases, visitor_embeddings, consents and
visitor_profiles all reference visitors ON DELETE CASCADE, so a table
this forgets to re-point is not an error - those rows are destroyed with
the source and nobody finds out until a customer's history is short.

visitor_profiles is UNIQUE on visitor_id, so the two cannot simply both
move and something has to win. Blanks on the survivor are filled from the
source and nothing it already holds is overwritten, which is exactly
right for the case this exists for: a hand-typed name and phone joining
the face that was recognised a week later.

Policies carried over from the edge gallery's merge, which had to settle
all of this once already: a human-assigned name outranks an auto
"Visitor N" whichever direction the operator merged; visit_count is
recomputed with COUNT(*) and never summed, because the stored counter may
be stale and the row count cannot be; first_seen_at takes the earlier of
the two, since it is one person and always was.

Two things that are this side's own:

- The source is deleted for real, not soft-deleted. A tombstone would
  leave its number resolving to a record holding nothing, which reads as
  "this customer exists and has never been here" - a worse answer than
  "no such customer".
- The response names the RETIRED reference. Staff write V-42 on cards and
  read it aloud; a merge that does not say which one stopped working
  leaves somebody to discover it at a counter.

Manager and above, not staff. Apart from erasure this is the only
irreversible operation on a customer: two people welded together cannot
be separated, because nothing records which visit came from whom. It logs
at WARNING and writes an audit row for the same reason.

Also fixed while here: two s.Log.Printf calls - one of them mine, from
the password endpoint - that would panic on a nil logger. The package has
a nil-guarded s.logf and those were the only two not using it. The
password one sat in an error path no test reaches, which is exactly where
that bug waits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 15:59:51 +05:30
e0bd764e44 Document the self-service password change
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 15:31:21 +05:30
6068b2c3c7 Nobody could change their own password
POST /api/auth/password. The cost of its absence was measured today
rather than argued: rotating three production accounts took a shell on
the host, three round trips, and briefly left a PLATFORM ADMIN - the
account that reads every company on the estate - with the password
PASTE_IT_HERE, because a placeholder in a pasted command was taken
literally and there was no way to correct it from the product.

A manager could always reset somebody ELSE's password. A platform admin
could be reset by nobody: they have no client, so the team routes are
not theirs, and `provision user` on the host was the only route. For
software that puts accounts on shop-floor PCs and staff phones, this is
not a feature - it is what makes every other credential decision
recoverable.

Three decisions:

- **authed, not tenantOnly.** A session is not a company's data, and the
  account with no company is precisely the one that had no route. Scoping
  this by client would have reproduced the hole it exists to close, which
  is also why SetUserPassword is not scoped by client the way
  ResetMemberPassword beside it is. The user id comes from the verified
  session, never the request, so there is nothing to point at anyone else.

- **The current password is required.** An access token lives twelve
  hours and travels on devices that get lost and shared; without this a
  stolen one owns the account permanently instead of until it expires.

- **Every OTHER session is revoked, and the caller's is kept.** Somebody
  changing their password because they believe it is known must not have
  to wonder whether the device that already had it is still signed in -
  and must not be signed out of the one in their hand while dealing with
  it. A failure there is logged, not returned: the password IS changed by
  then, and reporting an error would send them to retry with a current
  password that no longer exists.

The suite's login() helper fatals on anything but 200, which is right
everywhere else and useless here - half of what these tests assert is
that a password has STOPPED working. loginCode() returns the status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 15:30:08 +05:30
dae18d651b A customer's history now says whether they bought anything
"When was this customer last in" and "did they buy" are one question
staff ask in one breath, and answering it meant two calls and a join in
the client. Each visit row carries purchases, spend and currency.

LATERAL, not a join onto purchases. A plain join returns the visit TWICE
when it holds two sales, which would make a customer look like they came
more often than they did - a wrong number of exactly the kind this
product is otherwise careful about, arrived at by adding a feature.

Mixed currencies on one visit report the count and NO figure. Adding
rupees to dollars produces something that looks like money and is not,
and the sales still happened, so the count is the honest part to keep.

A purchase with no visit_id is deliberately absent: it belongs to the
customer rather than to a moment, and GET /api/sales?customer=V-42 lists
it. The two surfaces together cover every sale exactly once.

Both properties are asserted in the LIVE store tests, because both live
in the SQL. An in-memory fake asserting that a LATERAL does not duplicate
a row would only be checking the fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 14:49:23 +05:30
c7312d31b4 build.ps1 would have shipped last month's UI without saying so
Audited before its first run, because deploy.sh taught us what a script
nobody has executed contains.

PowerShell's $ErrorActionPreference = "Stop" governs PowerShell errors. A
native .exe returning non-zero is not one, so `npm ci` and `npm run build`
were unchecked and the script sailed past them. That matters here more
than anywhere else: a built frontend/dist is COMMITTED to this repository
so `go build` type-checks without npm, which means a silently failed npm
build leaves the old one in place and it embeds perfectly. The output is
an installer that builds, installs, opens and shows a stale UI, with
nothing anywhere saying so - the silent-wrong outcome, reached through
the single most likely failure on a fresh Windows box.

A Run() helper now throws on any non-zero native exit, across nine call
sites: venv, both pip installs, pytest, pyinstaller, npm ci, npm build,
both go builds, and the frozen engine's own smoke test.

The pip installs were also piped to Out-Null, so a failure there produced
no output AND no stop. run-local.sh has already been caught making
exactly that mistake, where it "exited at step 5 with no output at all -
the single hardest failure to diagnose, and it took three runs to find".
Not worth repeating in a script that runs on a machine nobody is sitting
at.

Two smaller ones from the same read:

- frontend\dist\index.html is deleted before npm runs, and its absence
  afterwards is an error. Checking the exit code is not enough when the
  artefact it was meant to produce is already sitting there from git.
- `go build -o dist\...` does not create its target directory, and dist\
  is gitignored. It exists on a fresh clone only because PyInstaller ran
  first and made it - an ordering dependency nothing stated. Stated now,
  and created explicitly.

None of this has been run on Windows. It cannot be from here - PyInstaller
freezes the interpreter and native wheels of the machine it runs on. What
this buys is that the first Windows run fails for a real reason rather
than for a bug in the script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 12:50:13 +05:30
f4102443ea API.md: the nine routes that shipped, and the boundary they exposed
The admin drill-down, the sales reads and the dashboard summary, each
with the shape production actually returns - copied from live responses
rather than written from the structs, because that is the difference
between documentation and a guess.

Three things stated because a client would otherwise get them wrong:
admin camera rows are a DIFFERENT shape from GET /api/cameras and carry
no host, port, path, username or has_password; a sale with no visitor is
listed rather than joined away, so this agrees with the conversion report
over the same rows; and the sales list has no cursor, with the reason,
because purchases has no monotonic column and a cursor would imply a
delivery guarantee it cannot make.

Also the boundary the work exposed: 'authed' meant any signed-in user,
and a platform admin is signed in with no company at all. That now has a
sentence and a code (403 not_a_tenant_account) instead of being a 500
nobody had called.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:28:28 +05:30
359d48e1c4 Record the admin API, and the two 500s only production found
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:19:56 +05:30
830c1c1573 Five live endpoints answered 500 to a platform admin
/api/visits, /api/cameras, /api/sites, /api/visitors and
/api/reports/footfall, all in production, all before today's work. A
platform admin is defined by having NO client, and every tenant query
scopes on client_id = $1::uuid - so the empty string reaches Postgres as
''::uuid, which is a cast ERROR rather than an empty result. Found by
calling them while verifying the new routes, which have the same shape
and were failing the same way.

tenantOnly is the guard, beside adminOnly and for the opposite audience.
Per-query casts would have been the wrong fix twice over: it is a fix the
next query forgets, and the next query would then 500 in production
exactly as these did.

403, not adminOnly's 404, because the two hide opposite things. A tenant
must not learn a platform surface exists. A platform admin already knows
the tenant surface does - they are reading its data through /api/admin -
so nothing is concealed by pretending otherwise, and the refusal names
the route to use instead. "Forbidden" alone sends somebody hunting a
permissions problem that does not exist.

/api/auth/* stays on plain authed: a session is not a company's data, and
signing out or revoking a lost device must keep working for an account
with no tenant.

The fake could not have caught this either - it compares client ids as
strings and is perfectly content with "". The test asserts the contract
(403 and a message naming /api/admin) and a third case that matters more
than either: an ordinary tenant user still reaches all of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:18:41 +05:30
4db71e9381 A wrong URL answered 500, and only the real database said so
/api/admin/clients/not-a-uuid/sites returned 500. `c.id = $1::uuid` makes
Postgres cast the path segment, and casting a malformed string - or the
empty one the shape check handed back in its place - is an ERROR, not a
miss. `c.id::text = $1` cannot fail: an id that is not a uuid matches
nothing, which is the 404 a wrong URL should get.

The two sibling resolvers were already written this way and correctly
404'd the same input. I applied the rule to two of three places, which is
the shape of a rule that holds until somebody adds the next write path.
The shape check is gone with it - it existed only to produce the empty
string that then broke the cast.

The in-memory fake could not have caught this and did not: it resolves a
merchant with a map lookup, so every handler test passed, including the
one named for the case. That test stays, because 404-not-500 is still the
contract, but the property belongs to Postgres - so
api_admin_monitor_live_test.go asserts it where it lives, over every
free-text identifier these queries take. It skips without
TEST_DATABASE_URL, like the rest of the live store tests.

Also in deploy.sh, found by reading its own output: step 3 reported the
WRONG backup. `ls | tail -1` sorts alphabetically, so pre-...-demo-12
sorts before pre-...-demo-6 and it printed a dump from four days earlier.
A deploy that names the wrong safety net is worse than one that names
none, because that is the file somebody reaches for at the worst possible
moment. It echoes the filename it just wrote, and refuses to continue on
an empty one - pipefail catches a failing pg_dump, but a zero-byte gzip
would still have satisfied it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:14:26 +05:30
6fafecd6e4 deploy.sh died at step 1 on the machine it was written on
"go: command not found". Go sits in a directory the operator's .zprofile
adds and a script does not inherit, so the very first step of the deploy
failed for a reason having nothing to do with the deploy. Found the only
way it could be - by somebody running it - and a deploy that needs the
operator to fix their environment before it works is a deploy that gets
skipped, which is the failure this script exists to end.

It now looks in the three places Go actually lands and says so plainly if
it finds none.

Step 7 also verified five routes and none of them were the nine that
shipped in the last two commits. It checks all of them now, and treats
401 as a PASS on purpose: an unauthenticated call to a route that exists
is refused, while a route the binary never registered is a 404. That
makes this step prove the ROUTING rather than the auth - which is
precisely what a deploy gets wrong, and what otherwise surfaces weeks
later as a console reporting "Backend integration required" against an
API that had already shipped. A missing route now fails the deploy loudly
instead of printing a number nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 18:08:48 +05:30
0e4cb1274e Sales you can read, not only sum, and a home screen in one call
Three routes over data the server already stores.

GET /api/sales and /api/sales/{id}. The purchases table has existed
since the conversion report did, and nothing could read a row of it - so
"revenue was 41,000 last week" was a number that could not be checked
against a till. The list carries the customer reference the product
actually shows people (V-42) beside the uuid, and a sale with NO
customer is listed rather than joined away: an unidentified walk-in is
still revenue, and an inner join would make this disagree with the
conversion report computed over the same rows.

No cursor, deliberately. A keyset cursor needs a monotonic
server-assigned column and purchases has none; ordering by
(occurred_at, id) with a random uuid tie-break is exactly the shape that
silently dropped four of six simultaneous visits from the arrivals feed
before visits.seq existed. Offering one here would imply a delivery
guarantee this table cannot make, so the list is bounded by the date
window and a limit - which is how a sales list is browsed anyway.

GET /api/dashboard/summary. Four calls a client had to make and then
combine, which is how the desktop Footfall screen once produced its
headline by adding the daily bars up: silently too high, because a
customer who came twice is one person and two bucket-visitors. The
combining happens here, against Footfall and SiteHealth rather than new
SQL - a second definition of "unique visitor" or of "online" drifts, and
a home screen that disagrees with the report it links to is the one
nobody trusts afterwards. fraction_below_gate travels with the count for
the same reason it does everywhere else: it is what says whether the
headcount is a number or a floor.

Today is cut in the shop's timezone. In the one market this ships to,
UTC is five and a half hours wrong.

An unknown shop filter is a 400, not an ignored parameter. This API has
already been bitten once by a silently ignored filter handing back the
whole estate, which is a wrong number nobody would question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 16:25:10 +05:30
abcf6aa012 The admin console could list merchants and see nothing inside them
Six read-only routes: merchant detail, its shops, one shop, its cameras,
one camera, and the platform totals. The console drills down
merchant -> store -> camera and every level below the first showed
'Backend integration required'.

They cannot be the tenant routes, and the reason is structural rather
than incidental. Every tenant handler derives the client from the
SESSION - that is what makes cross-tenant access impossible rather than
merely disallowed - and a platform admin has no client at all. The three
workarounds each make it worse: passing a company id to a tenant route
puts a caller-chosen tenant back in the one place this system refuses to
take one, filtering the estate in the browser ships every merchant's
data to render one, and signing in as the owner audits the wrong person.
So the tenant STORE functions are reused with an explicit client id -
they already take one - and the scoping the tenant handlers get from the
session is done in the handler instead.

AdminCamera is a separate type from Camera, for the same reason
AgentCamera is. It cannot carry host, port, path, username or
has_password. A tenant seeing those for their own camera is correct; a
platform admin browsing another company's estate is a different
question, and an RTSP host with a username beside it is most of a live
path into a customer's camera. Blanking fields on a shared struct leaves
'remember to redact, on every path, forever' as the only thing
preventing a leak. The test asserts on the raw JSON, because decoding
into the struct would discard exactly what it is looking for.

An unowned site is 404, never an empty list. The tenant resolver returns
a uuid untouched and lets client_id =  downstream scope it, which is
sound only because that id comes from a session; here the caller names
both halves, so an unowned uuid would reach a query that quietly returns
nothing - 'this shop has no cameras' when the truth is 'not your shop'.
Both resolvers check the whole chain in one statement.

Two things the in-memory fake could not have caught, so neither was left
to it. The fake ignored clientID in SiteHealth and Cameras, which would
have made every cross-merchant test pass while returning another
company's shops; it is client-aware now for these paths. And the SQL was
written to make the documented $2-deduced-as-two-types bug impossible
rather than to be caught by a database later: id::text = $2 in place
of id = $2::uuid, one type per parameter, which also turns a malformed
path segment into the 404 it should be instead of a cast error.

Every read below the merchant list writes an audit row naming the admin
and the merchant - an admin is the one account for which nothing else
here leaves a trace. The counts-only summary does not: a console
refreshes it on a timer, and logging that buries the reads worth
finding. A suspended merchant stays readable, because that is precisely
what an admin opens the console to look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 16:01:22 +05:30
f61da2eeed Three states that looked like health from outside
Audited the engine for what it does when something goes wrong rather than
when it goes right. Each of these left the process healthy, the dashboard
green and the product not working.

A gallery the running encoder cannot read. Embeddings are model-tagged, so
when the fallback chain fires every vector the previous encoder wrote goes
invisible: the shop keeps its customer list and recognises nobody on it,
enrolling each regular a second time. Footfall stays correct, which is why
nothing looks wrong. The only evidence was an INFO line reading 'gallery
ready: 0 embeddings (model w600k_mbf) across 21 identities' - a sentence
that states the disaster and calls it ready. Gallery.health now warns with
the count of PEOPLE lost, not vectors, and carries the same numbers to
/api/stats and /api/health, because a log line on a shop PC is read by
nobody. Proved against the real 87-embedding gallery.

Connected, and sending nothing. 'connected' meant the socket opened, so a
stream that went quiet kept it true while last_frame_age_s climbed and the
heartbeat told head office the camera was up. OpenCV breaks a blocked read
at 30s, but a camera trickling a frame every 20s never trips that and never
recovers. streaming/stalled are reported beside connected and the dashboard
says live/stalled/offline - three states because offline sends you to the
network and stalled says the camera is answering and sending nothing.

The 5-second RTSP timeout that never existed. stimeout;5000000 carried a
comment claiming it bounded a dead camera. Measured on OpenCV 4.11 /
FFmpeg 7.1 against a socket that accepts and then says nothing: 30.0s with
stimeout, 30.0s with timeout, 30.3s with no option at all - identical, so
it was never honoured. stimeout became timeout in FFmpeg 5.0 and neither
reaches the RTSP protocol through this path; the real bound is OpenCV's own
interrupt constant. Replaced by the _tcp_reachable pre-flight probe_source
already used, in code we own: 30.3s -> 0.00-2.02s, each naming its cause.
That matters beyond speed - the VideoCapture constructor is not
interruptible, so stop() could not cut it short and a camera removed from
head office left a daemon thread holding a socket for half a minute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 14:15:54 +05:30
2e60fbb57a The engine was searching an empty room fifteen times a second
Measured rather than guessed, and the first guess was wrong. Wall clock
said H.265 decode cost 58 ms a frame; cap.read() blocks until the next
frame arrives, so that was the frame interval, not work. As CPU time:
decode 3.7 ms, detection 31.0 ms - and detection ran on every frame
whether or not anything was in front of the camera, 6,649 of 8,634
frames with faces_seen 0 and active_tracks 0 throughout.

detect_threads: OpenCV spreads a small repeated job over eight threads,
costing 31.0 ms of CPU for 8.9 ms of wall. One thread costs 15.3 ms for
15.3 ms, against a 66 ms budget at 15 fps. Half the CPU for latency
nothing can notice.

motion_gate: a 160x90 greyscale absdiff, 0.1 ms against detection's 15.
Consulted only while no track is open; forced to look every
motion_max_skip frames; compared against the last frame SEARCHED so a
slow drift cannot creep under the threshold; and a threshold above this
camera's measured noise and far below a person, so anything ambiguous
detects. tests/test_motion_gate.py pins each of those rather than the
saving, including asserting the longest run of skips rather than the
total - counting the total would pass a gate that slept forty frames
and then looked forty times.

Together 80% -> 16% of a core, detection skipped on 92% of frames.
faces_seen is still 0 and the gate is not why: run directly over the
same frames the detector finds nothing at threshold 0.50 either. The
placement is the limit, as recorded; the CPU was being spent to
rediscover that fifteen times a second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:58:05 +05:30
f97ffc913a demo console: polled frames and the real stats field names
The camera used an MJPEG stream through the proxy and the engine's
stats under names it does not use (frames/faces rather than
frames_processed/faces_seen), so the picture was blank and the counter
read zero. The engine re-serves its latest frame until the pipeline
produces a new one, so a polled still is the same picture with none of
the multipart fragility - which matters when the audience is in the
room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:50:08 +05:30
62c2cc8a7b A visit is #1042, not 4cc216ca-dad3-4958-bb96-5f5a82022cf8
Every other thing in this product a person refers to already had a
readable reference: a shop is chennai, a camera cam1, a customer V-42, a
person their email. An audit of every list response found exactly one
gap, and it was the row people look at most - the arrivals feed showed a
visit as 36 hex characters.

012 argued no route takes a visit id so none was needed. That is true of
routing and false of everything else: it is what the feed shows, what a
support conversation quotes, and what somebody reading an API response
judges the product by.

Migration 014 mirrors the visitor scheme exactly - per client, so it
discloses no platform-wide volume, and beside the uuid rather than
instead of it. A stored counter is affordable on the busiest table
because visits from one tenant are already serialised by the consumer's
SetOrderMatters(true), so it adds no contention that was not already
there. A derived reference was the alternative and does not work:
several people through one door share occurred_at to the microsecond,
which is the collision 004 exists to handle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:44:51 +05:30
16f0e69cec A fresh shop PC could never authenticate to its own engine
The agent read the engine's generated credential file once, at startup.
On a brand new install that file does not exist yet: the agent starts the
engine, and the engine writes its credential seconds later. So the agent
held an empty credential for the life of the process and every call it
makes - health, stats, camera sync, the embedding for a visit - came back
401, with a tray showing a red engine that was running perfectly.

Measured on a fresh state directory today: three 401s, no camera ever
reconciled, and the engine left running the YAML-seeded main stream
instead of the sub-stream head office holds. The install script hid this
on Windows because setup runs the engine once before the app starts.

config.Creds resolves lazily and re-reads on a rejection; the camera
client, the supervisor and the desktop app's engine client all retry once
when it changes. A configured BEHAVISION_API_USER is never re-read - an
operator who set one means it. Tests pin the actual first-run ordering.

Also adds demo/, a one-screen live console for showing the whole chain:
camera, the six steps with a measured camera-to-cloud latency, the
customer editable in place, and the raw JSON a phone and a dashboard
receive from production side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:40:28 +05:30
9062d2fc51 A shop filter that did not filter handed back the whole tenant
GET /api/cameras read only site_id, while every other filtered endpoint
takes both spellings through siteParam. So ?site=chennai was not a
filter at all but an unknown query parameter, silently ignored, and the
caller got every camera in the tenant believing it had one shop's.

Found by using it: a setup script saw another shop's cameras, concluded
three shops already had theirs and created none; then a delete aimed at
a test shop removed the live Coimbatore entrance camera, which had to be
restored. This is exactly the hazard already recorded for site vs
site_id - the note existed, the handler was simply missed.

One line to fix, and a test that asserts the whole class rather than
this one route: both spellings must narrow, and only an absent filter
may return more than one shop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:28:02 +05:30
177584e812 Record what the live system actually does, measured not assumed
Production had 1,211 events accepted and six recognised customers from
the office cameras - the first time the whole chain has carried a real
person, and the project had never been able to claim it. Repeat
sightings score 0.44-0.72, a distribution the match threshold sits
clearly below, on the head-height camera this file has recommended since
August. fraction_below_gate is still 0.59, so the visit count is a floor
and the report says so beside it.

The face-image chain was exercised on production as a shop PC does it -
upload URL, PUT to object storage, anonymous read refused 403. Every
server link holds; the only reason a customer has no photo is
app.store_faces being false by default, which is a data-protection
decision rather than a gap.

Sixteen mobile-API checks pass as a staff account. Three apparent bugs
were test errors and are written down so nobody re-files them, along
with the one field name a caller could guess wrong (site_token).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:14:14 +05:30
81e2c605b9 The first five minutes, as the product and not as a developer's first run
The first launch was a code box with a link under it, then an empty
Live screen with 'No cameras' in amber in a far corner, then a form
asking for an IP address, and for the first few minutes of all of it
the engine silently downloading 275 MB with nothing on screen but a
stopped-looking status. Walked in a browser with the new mock; nobody
who was not an installer would have got through it.

Now: a welcome that asks the one question a shop owner can answer -
managed from a head office, or on this PC only - with each path in a
sentence; a Getting Started checklist on Live that reads its three steps
from the engine and ticks them itself (recognition ready, camera added
and connected, camera proven by a walk-past), with the one button for
the next step, and that disappears the moment somebody is recognised;
and the model download reported as a percentage in the tray, the
sidebar and the checklist, parsed by the supervisor from the engine's
own progress lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-21 12:32:19 +05:30
1607f4ce74 Find the camera on the network instead of asking for its address
The add-camera form asked for an IP address, and a shop owner does not
know their camera's IP address - it is on a sticker under the camera or
in a menu that differs by make. That field is where onboarding stopped
for anyone who was not an installer.

behavision/discover.py: one ONVIF WS-Discovery multicast (names the
camera and often its make) merged with a TCP sweep of port 554 across
the local /24 (misses nothing that streams). Stdlib only, ~4 s on the
office network, both cameras found. The add-camera sheet leads with
'Find cameras on this network'; picking a row fills the address and,
when the make is recognisable, the stream path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-21 12:27:47 +05:30
f88d441bbf A shop can be renamed and, while empty, removed - from head office
The display name was always meant to be editable and the slug frozen;
until now neither had a way in. PATCH /api/sites/{site} takes a name
and a timezone (manager and above), DELETE removes an empty shop
(owner). The shop drawer in head office gets both, with the short name
shown read-only and the reason beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 16:11:07 +05:30
4ac08e5a85 The tray says why the engine is not running, and setup will not run under a live app
Seen on the demo PC: Start did nothing and Stop stayed grey. The
supervisor's engine had failed because a second engine already held
port 8010, and the tray reported that as nothing at all. The supervisor
now keeps the engine's last lines and turns the known ones into a
sentence - 'port 8010 is already in use - another Behavision or its
engine is still running', 'run behavision-setup again' - which the tray
and the window show. Tray clicks no longer run on the menu loop, so a
stop that waits for the process cannot make the menu look dead.

Two ways that second process came to exist are closed: setup refuses to
run while Behavision.exe or the agent is up, and the app watches
agent.json so a claim made underneath it - which rotates the API token
- is picked up instead of leaving camera sync refused until a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 15:50:26 +05:30
7c74431fcf Loya says 'sign in again' instead of 'session expired' 2026-09-19 15:29:15 +05:30
01f1c17c7f A claimed PC forgets the old login and the old cameras
Seen on the first claimed demo install: 'session expired' on every
screen, signed in as a user from the previous demo's head office, and
'Watching 3 cameras' for a shop with one - the PC had offered its two
leftover cameras up to head office, without their passwords, so the
same lens was listed twice and one copy could never be pushed anywhere.

Claiming now clears any stored session (a new head office is a new
world), a session whose refresh fails is forgotten on disk as well as
in memory so the app returns to Login by itself, and the demo setup
removes cameras left from an earlier install before it joins the shop,
because head office is the source of truth from then on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 15:26:23 +05:30
448fba8770 Build the desktop app with the Wails build tags
A plain go build of a Wails app starts, shows 'Wails applications will
not build without the correct build tags' and exits. That is what the
first Windows install of v0.4.4-demo saw. -tags desktop,production is
what wails build passes; both build paths pass it now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 15:13:43 +05:30
4bd1718491 A demo build can claim a real shop instead of running on its own
The first demo sealed the office cameras into the package and ran the
PC standalone - a copy of the product with no head office. The bundle
can now carry an installation code instead: setup redeems it exactly as
the app's Setup screen does, the PC joins the shop, and its cameras
arrive from head office on the first sync. The demo then IS the product
- login, Loya, head office - not a local imitation of it. The code is
single-use, so one bundle is one install. release.sh ships the bundle
with DEMO_PACK=.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 13:51:01 +05:30
51b9cb9743 The assistant is Loya, and she lives in the top-right corner
A name, a voice and a door. The prompt now asks for a colleague on the
shop floor - answer first, one to three sentences, the shop's name and
the person's name, the one thing to do next - instead of a report with
headings. Both apps put her behind the Loyaly mark in the top-right
corner of every screen, because a buddy you have to find in a sidebar
is not around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 13:42:35 +05:30
2835252bb3 assistant: recognise the API's other wording for a missing workspace id
The key-needs-a-workspace error arrived as 'must include the
anthropic-workspace-id header' and was reported as a bare 500 instead of
503 assistant_misconfigured naming the variable. Match the header name,
not the sentence around it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 13:36:45 +05:30
7021d5d2f5 The shop app gets its help panel, and its last two old screens catch up
Ask Behavision: a panel beside any screen that talks to the head-office
assistant as the signed-in user - setup questions and 'is my shop
working' answered by the same thing, without leaving the app. The
assistant's prompt now knows how the product is set up (installation
codes, adding a camera, what a placement verdict means, the model
download on first run), so it is the help and not only the analyst. A
PC running on its own has nobody to ask and gets the essentials as text.

Cameras and Customers were still on the pre-redesign markup - the add
camera drawer ran off the right edge of the window because it used a
class the new stylesheet never sized. Both are rebuilt: cameras as
picture-led cards with connection and 'proven' as two separate claims
and a placement check laid out as the two steps it is; the customer
record as a proper sheet.

mock.js renders the app in a browser with fake bindings
(?mock=fresh|standalone|claimed, dev server only), so a screen can be
put in front of somebody without a Windows build. It is how these were
reviewed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 13:22:16 +05:30
4c62fc0ef3 The Loyaly mark everywhere a person sees the product
Brand assets in brand/ (the 512px mark, sizes for each surface, a
multi-size .ico). Windows executables carry it as a compiled-in
resource (rsrc_windows_amd64.syso from go-winres) so Explorer, the
taskbar and the installer show it; installer/build.ps1 therefore uses a
plain go build rather than wails build, which would add a second copy
and fail the link. The tray icon is the mark with a state dot over its
corner - a plain coloured circle read as a generic status light among
other icons - rendered from the embedded PNG at 32px so it survives
150% scaling. The desktop app's login, setup and sidebar marks, the
head-office web app's mark and favicon, and the engine dashboard's
favicon are the same file.

Also found while packaging: no wheel so far shipped static/, so the
engine's own dashboard at :8010 on a Windows source install would have
failed with a missing file. package-data now includes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:54:19 +05:30
effa4f3d62 release.sh: build the wheel in an isolated env; the checkout's interpreter is 3.9 2026-09-19 12:44:51 +05:30
6c210f792f release.sh: the shop-PC package, built the same way every time
The previous releases were assembled by hand. This builds the Windows
zip from a clean tree - desktop app, agent, setup tool cross-compiled
here, the engine as a pure-Python wheel with its source beside it - tags,
and publishes to Gitea with notes from a reviewed file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:44:29 +05:30
8786a5b0b4 The platform admin's last shell-only jobs are endpoints
Suspend or reinstate a company (PATCH /api/admin/clients/{id}), reset
its owner's password (shown once), and delete it - and an owner can
remove a shop opened by mistake (DELETE /api/sites/{site}, empty only).

Suspension ends every session the company holds in the same
transaction: login and ingest already refused an inactive client, but a
live access token would have kept reading for up to twelve hours, so
'suspend' would have meant 'suspend some time tomorrow'. Deletion is
deliberately two steps - the company must already be suspended and the
request repeats the slug - because the data under it is biometric.
Face images go first (a storage failure aborts with nothing touched),
then the broker logins, then the rows by cascade.

Exercised against the local Postgres and broker: create, open a shop,
remove it (two plugin commands), refuse delete while active, suspend
(owner's token 401 immediately), reset, delete, zero rows left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:36:21 +05:30
c93fbff31f server/broker-cutover.sh: passwd/acl to dynamic security, with rollback
One reviewed step instead of a hand-typed sequence on the host: back up
the config, convert the passwd file into the plugin's store with every
hash intact, rewrite mosquitto.conf, restart, and prove the server and
the health probe reconnect. ROLLBACK=1 restores the previous config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 11:56:38 +05:30
4c750cb2ac Opening a shop is an API call; the broker learns of it in the same request
The last step of onboarding that needed a shell: provision site printed
a broker password and a person typed it into Mosquitto's passwd file on
the host - mounted read-only in the container, so the first attempt
failed silently and the password was re-rolled. No tenant could open a
second branch without us.

The server now drives Mosquitto's dynamic-security plugin over its own
broker login: POST /api/sites (owner) writes the row and the sealed
password, registers the login and a per-site role with literal topics
(the 2.0 plugin does not substitute %u - measured), and removes the row
again if the broker refuses, so a shop cannot exist in the database and
not on the broker. provision site goes through the same path. The
head-office Shops screen gets 'Open a new shop'.

broker-init converts the existing passwd file into the plugin's store
with every hash intact - PBKDF2-SHA512 both sides - so the cutover
re-claims no shop PC. Rehearsed locally: old logins keep working,
isolation holds, the health probe works, and a PC claiming a shop opened
through the API connects as that shop. run-local.sh now brings the
broker up the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 11:55:26 +05:30
5f83a1077d Enrolment hands out the broker CA, and now the PC keeps it
The server has always sent the broker's CA certificate in the enrolment
response, precisely so it never has to ship in an installer. Nothing on
the receiving end wrote it anywhere: the agent read the field under the
wrong name (ca_pem, the server says ca_cert) and the desktop app read it
correctly and dropped it. Every claimed PC therefore dialled
tls://mcp.loyaly.ai:8883 with the system trust store, the private CA
failed verification, and the agent reported 'the broker did not accept
this PC' - a TLS failure is indistinguishable from a refusal at that
layer. No real site could ever have published a visit.

Found by claiming this Mac as a real shop against production; fixed by
writing the CA to broker-ca.crt beside agent.json on both claim paths.
Verified: broker connected over TLS, camera pushed from head office,
engine streaming it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-18 12:16:43 +05:30
a74cb899b4 server/deploy.sh: build here, back up, migrate, switch, verify
Production ran code from 31 August and answered 404 to most of the API
the merchant and mobile clients are written against. The script builds
the web app into a static linux binary on the developer machine (the
host has 3.6 GB shared with other services and must not compile), backs
the database up, runs the migrations with the new binary while the old
server still serves so a failure stops with nothing changed, switches,
and proves the routes over the public URL. API.md now names the API host
correctly: mcp.loyaly.ai, not the console's platform.loyaly.ai.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-18 11:37:47 +05:30
8c88aad06e Stopping the engine on Windows stops the whole engine
The installer runs the engine as <venv>\Scripts\python.exe, and since
Python 3.7.2 that file is a redirector that spawns the real interpreter
as a child. Stop() terminated the redirector and left the interpreter -
the process holding the cameras and the SQLite WAL - running with no
parent and nothing able to stop it. Seen on a Windows install: Quit from
the tray, and recognition still running.

The child is now started suspended, placed in a job object with
KILL_ON_JOB_CLOSE, and resumed. Cancel terminates the job, so the whole
tree goes; and the job dies with this process, so it goes even if the app
crashes. CREATE_NO_WINDOW while here: python.exe is a console program
and a GUI parent otherwise opens a black console on the shop counter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-18 11:06:49 +05:30
50a843ce46 The shop app starts once, and a second launch just shows the window
The window hides to the tray on close, so the natural next step for a
shop assistant is to double-click the shortcut again. That started a
second full copy of the app: a second tray icon, a second engine
supervisor on the same SQLite WAL and the same port - the start-twice
failure the agent package was built to prevent, on the one binary that
never had the guard. Seen on a Windows install as a row of tray icons.
Wails' SingleInstanceLock now hands the second launch to the first
process, which brings its window to the front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-18 11:01:19 +05:30
979aa77cda The shop screen no longer shows camera video
The Live screen led with a camera tile beside the arrivals. Nobody at a
counter is watching CCTV; they are looking up at a customer and need the
name. The tile also cost CPU the recognition pipeline needs and pulled a
stream relay into the app for a picture that was decoration. Arrivals now
take the whole screen. The camera picture stays on the Cameras screen,
where it is a setup tool and not a feed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-18 10:53:32 +05:30
3d3775c8be The shop app looks like a product now, not a prototype
The window a shop assistant stares at all day was the weakest surface in
this system, and it looked improvised because it was: navigation drawn
with text characters (◉ ☺ ▢) that sit on the text baseline and cannot
take a stroke weight, margins set inline per screen, and four large stat
boxes dominating the page while the product's entire reason for existing
- WHO JUST WALKED IN - was a list of "person.seen" rows in the corner.

Rebuilt around the person in front of it: a counter, a cheap monitor,
somebody mid-conversation with a customer.

  - ui/icons.jsx: one drawn icon set, 24-unit grid, 1.6 stroke,
    currentColor, so one icon works on every surface and in every state.
  - styles.css: a real system. Four-step ground→raised palette biased
    blue-green (this product lives in the world of lenses), one spacing
    scale, one type scale, tabular figures wherever digits are compared
    or refreshed in place, and the scrollbars restyled - the default
    light scrollbar on a dark panel is the loudest "web page in a frame"
    tell there is.
  - Live: a status strip that answers "is this working" in one line,
    cameras as pictures with the caption over the image, and arrivals as
    cards big enough to match against the person standing there. The
    four stat boxes became a slim strip at the foot, where numbers that
    nobody acts on belong.
  - State is carried by shape AND colour everywhere - a pill, a dot and
    an edge stripe - because this gets read from two metres away and
    some operators do not see red and green apart.
  - Motion only where it means something: a live camera pulses, a fresh
    arrival slides in once. Nothing loops for decoration; this process
    shares a CPU with recognition.

Two things fixed because the screen showed them, not because a test did:

  - The sidebar read "Stopped" beside a live camera feed and a counter
    ticking up, whenever the engine was running but not started BY the
    app. That is the two-surfaces-disagreeing bug the tray exists to
    avoid. It now reads "Running outside the app" in amber, and Start is
    disabled rather than offering to launch a second engine onto one
    SQLite WAL.
  - The arrivals panel shrank to fit its content and left a hole beside
    a tall camera tile - so the layout looked broken exactly when the
    shop was quiet, which is most of the time. Both panels stretch and
    scroll their own content now.

Every existing class name still resolves, so the screens not rewritten
here pick the system up unchanged. Windows and darwin build; tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-15 11:06:57 +05:30
a1fe0942e2 docs: the architecture overview, as a file
Nine diagrams, one HTML file, no dependencies beyond web fonts that
fall back to system faces offline. The same document is published as
an artifact; this is the copy that ships with the repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 17:12:50 +05:30
e262fc8482 The live picture was chained to the recognition pipeline
Reported from the first Windows install: the camera feed lags. It did,
and not because of the network, the proxy or the webview.

The MJPEG stream served _annotated_jpeg - the frame the pipeline had
most recently FINISHED with, encoded after detection, quality scoring,
tracking and identification had all run on it. On a modest shop PC that
is a few frames a second, and every picture was already as old as that
processing. It looked like lag because it was lag. On the fast machine
it was developed on the pipeline kept up with the stream's own 10 fps
cap, which is why nobody here ever saw it.

Two more things compounded it. Every processed frame was JPEG-encoded
whether or not a viewer existed - CPU spent on precisely the machine
short of it. And ffmpeg ran its RTSP demuxer with default buffering,
which holds a comfortable queue of frames before handing over the first:
half a second to two seconds a live view can never recover.

Now the picture and the boxes are decoupled. latest_jpeg_since takes the
capture thread's freshest frame at the camera's own rate and draws the
boxes from the last processed frame over it - encoded on demand, per
request, so a camera nobody watches costs no encode at all. The stream
sends a frame only when the camera has a newer one, capped at 15 fps;
nothing is sent twice. Boxes older than a second are not drawn, so a
stalled pipeline cannot leave one floating over an empty spot.
_publish_annotated becomes _remember_tracks: a handful of tuples under
the lock, no copy, no encode. ffmpeg gets nobuffer / low_delay /
max_delay.

Measured on cam2's sub-stream, same machine, ten seconds each:

  before   99 frames sent,  98 distinct    9.8 new pictures/s
  after   141 frames sent, 141 distinct   14.0 new pictures/s

against a 15 fps camera, with the pipeline still processing 166 of 181
captured frames alongside - and engine CPU DOWN from 90% with no viewer
to 62% with one attached.

Engine version 1.0.0 -> 1.1.0 so a re-run of setup reinstalls it rather
than pip deciding the requirement is already satisfied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 16:28:05 +05:30
b59e667a68 A demo release with the office cameras sealed inside it
Wanted: install it and the two office cameras are already there - but
without the release carrying their admin password where anyone with the
zip can read it. "Encode it" does not achieve that; anything the
installer can decode, anyone holding the installer can decode.

pkg/demo seals the camera list with AES-256-GCM under a key that is NOT
in the package: a 120-bit unlock code minted when the bundle is sealed,
given to whoever runs setup by voice or message, typed once. The code
is random, so it is key material directly through SHA-256; a human-
chosen passphrase would need a KDF and a dependency, 120 random bits do
not. The sealed file contains the format marker and noise. Tested: the
password and the host do not appear in it, a wrong code and a flipped
byte are both refused as ErrWrongCode, every seal differs.

behavision-demo-pack seals; it runs on the build machine and is never
shipped. The code is printed once and stored nowhere.

behavision-setup, on finding demo-cameras.enc beside the engine source,
asks for the code BEFORE the ten-minute download so a mistyped one costs
seconds, and adds the cameras at the end - through the running engine's
own Add Camera endpoint, not by writing its file. The store's save() is
what applies DPAPI to the password on Windows, so this is how the
credential ends up encrypted and machine-bound on the demo PC rather
than in cameras.json for anyone who can read ProgramData. It then marks
the PC standalone, so the app opens on Live instead of asking for an
installation code it will never get.

Which found the gap that DPAPI only works if pywin32 is importable, and
nothing had ever pulled it in - every Windows install to date would have
logged the warning and written camera passwords in the clear. Added as
a Windows-only dependency.

Verified in a clean container: a wrong code refused, the right one
unlocks two cameras, every install step passes, both cameras added
through the API, standalone set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 16:07:49 +05:30
70c447873d "Session expired" on a screen where nobody had signed in
The first Windows install reached the setup screen, typed an
installation code, and was told the session had expired. There was no
session. The code had been minted on a different head office, and the
server said so - 401 bad_token, "That installation code is not valid.
Ask for a new one." - and the client threw the message away, because it
mapped every 401 to the string "session expired".

A 401 on a call that carried a session is a session problem. A 401 on a
call that carried none is about the request, and the server's message is
the answer. The client now tells them apart by whether it sent a token.
Two tests, one for each side of the rule.

Also: a launcher for pointing a Windows PC at a head office on the LAN,
with the two settings that needs and a comment saying why neither is
acceptable outside a demo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 15:31:55 +05:30
719ba2c7f5 Recognition starts with the app, not with a button
The engine only ever started when somebody pressed Start. So a till
that rebooted overnight came back with the window open, the tray icon
showing, the session restored - and recognition off until a shop
assistant noticed. That is the failure the tray colours exist to catch,
and it should not be the default state every morning.

Guarded on the interpreter actually existing: on a PC where setup has
not run yet, the supervisor would loop on a missing executable with
nothing useful to say. Start and Stop remain for the case where somebody
has deliberately stopped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:49:56 +05:30
5e1dcf7050 INSTALL.txt lives in the repo, not only inside a zip
The v0.3.0 release carried it and the repository did not, so rebuilding
the release from a clean state produced an empty file where the shop
operator's instructions should be. Caught by checking the byte count
before uploading, which is not a process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:27:54 +05:30
92573e9067 The installer, run on a clean machine, found two bugs in itself
Ran behavision-setup in a fresh Linux container: Python 3.12, nothing
else, the release contents mounted read-only the way Program Files or a
shared drive would be. It failed, and then it failed differently, and
both failures would have been the client's first experience.

1. `pip install <folder>` makes setuptools write behavision.egg-info
   INTO the folder. The folder is read-only wherever a release is
   sensibly unzipped, so: "could not create 'behavision.egg-info':
   Read-only file system". The release now ships a wheel - pure Python,
   buildable anywhere, nothing to build on the shop PC, and pip never
   touches the unzipped folder. Source stays as a fallback and is copied
   somewhere writable first.

2. The engine's paths.py knows two worlds - frozen (ProgramData) and a
   checkout (the repo root) - and a pip-installed engine is neither. It
   resolved its state root to site-packages: database there, camera
   list there, and its generated API credential in a folder the app
   never reads, while the app looked in ProgramData. Every call would be
   401 on a stock install, with nothing in either log saying why. The
   same disease as the Mac checkout two days ago, now in production
   shape.

   engine.ChildEnv is the one place the engine's environment is built,
   used by the desktop app, the headless agent and the installer's own
   smoke test. It passes BEHAVISION_DATA_DIR = this process's state
   root, which paths.py honours ahead of every other rule, so the two
   halves agree by construction however the engine was installed.

   It also seeds config/default.yaml into the state root: a package in
   site-packages has no config beside it to seed from.

Re-run on the same clean container: seven steps, all pass, models
downloaded, engine started and answered, and its data/ landed beside
agent.json - not in site-packages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:26:54 +05:30
92b12bcb1c A merchant can create a salesperson's login and hand it over
The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.

POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.

POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, because the other reason a manager resets a
password is a lost phone, and a reset that left that phone signed in
would look complete while fixing nothing. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a floor account often has no
mailbox anyone checks, and the person who can vouch for the salesperson
standing in front of them is their manager.

RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.

Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.

API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:12:54 +05:30
c50a74de47 The onboarding chain, as a chain
Admin creates the merchant, merchant invites the staff, staff redeem
the code on a phone. Every endpoint for it already existed and was
already documented - scattered across four sections in the order the
server groups them, not the order a person meets them.

Now one section, in tier order, each step with the request that makes
it and the response it hands to the next tier: the owner password shown
once, the invitation code shown once, the session returned by register
so a new salesperson is never sent to a login form. The status codes
were checked against the handlers: all three creations are 201.

Three absences named rather than left to be found: a merchant cannot
create a staff login directly (invitation only, on purpose); there is
no mobile app in this repository, only the API it will call; and an
admin cannot reset an owner's password or suspend a merchant over HTTP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 11:54:47 +05:30
0a423ed8cc API.md documented 27 routes; the server has 48
A mobile developer builds against this file, so a gap in it is a gap in
the app. Checked route by route against the mux: nineteen routes had no
entry at all, including the ENTIRE platform-admin surface, adding and
checking cameras, issuing shop-PC installation codes, the assistant, and
the face bytes endpoint. Most of what was documented had no response
shape - a client had to guess the field names for shops, cameras, team,
customers, history and both reports.

Every shape here is now taken from the server's own types, and the
uncertain claims were checked against the handlers rather than written
from memory: check requests return 202, history is newest first, the
visitor list is most-recently-seen first and excludes the erased, an
admin slug is derived from the company name when omitted.

Restructured by audience, because "who may call this" was scattered:

  - three callers named up front - merchant, platform admin, shop PC -
    and what each one signs in with and sees
  - the three merchant roles and what each adds, taken from
    CanWriteProfiles / CanManageSites rather than paraphrased
  - a permission matrix: every route and the least role that may call it
  - quick starts for the three clients that will actually be written:
    a floor app for staff, a console for owners, and admin
  - /api/agent/* listed once as "not for you", so nobody wonders

The prose that explained WHY - refresh rules, the cursor, photos as data
not errors, the report arithmetic - is kept; that is the part a client
developer cannot get from the code.

Also recorded plainly: the admin API is two endpoints. There is no way
to suspend a company, delete one, or reset an owner's password over
HTTP. Written down rather than left for someone to discover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 11:31:44 +05:30
22196ab9ba Ship the engine from source, so a release can be built anywhere
The Go halves of this product cross-compile to Windows from any machine.
The engine does not: PyInstaller bundles the interpreter and the native
wheels of the machine it runs on, so a frozen engine can only be built on
Windows. That one fact was the entire reason no release had ever been
cut - two of the three binaries were ready for weeks.

behavision-setup installs the engine from source instead. It finds a
Python, builds a private virtual environment beside the database,
installs the engine into it, downloads the models, records how to start
it in the same agent.json the app reads, and then starts it and waits
for its API to answer.

That last step is the point. An installer that reports success and
leaves a shop with an engine that will not run has done worse than
failing: the failure surfaces later, to somebody who did not install it.

The trade, since whoever runs this is standing in a shop: it needs
Python and internet at install time and takes minutes, where a frozen
build needs neither. What it buys is a release that exists.

Details that are not incidental:

  - `py -3` is tried before `python` on Windows. The launcher is what the
    official installer puts on PATH; `python` there is often the Store
    stub that prints an advert and exits 9009.
  - a virtual environment, not the system Python. A shop PC may have
    Python for something else, and the engine pins numpy below 2.0 -
    installing that into a shared interpreter breaks the other thing
    months later and silently.
  - EngineExe is written absolute. The app resolves a relative one
    against its install root under Program Files, where no interpreter
    lives.
  - pip's output is shown, not swallowed. When it fails on a proxy or a
    missing build tool it says exactly what is wrong, and hiding that
    leaves the operator with "setup failed" and nothing to act on.
  - the console pauses before closing. Double-clicked from Explorer, a
    program that finishes closes instantly and success and failure look
    identical.

Verified as far as a Mac can: `pip install .` builds the wheel and
resolves every dependency, and `python -m behavision` then runs from
site-packages rather than the working directory - which is the mechanism
this depends on and had never been exercised, because the project has
only ever been run out of its own checkout.

NOT verified: any of it on Windows. Nothing here has run on the target
platform, and the `py -3` path and the ProgramData layout are exactly
where that will show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcn9asw19WGBfCEaHvNug6
2026-09-10 20:21:32 +05:30
5e544eee3d The camera tiles put a password in the page, and loaded nothing
StreamURL built http://user:pass@127.0.0.1:8010/api/cameras/<id>/
stream.mjpeg and handed it to an <img>, with a comment saying the
credentials were inline "so an <img> tag can load it".

It cannot. Chromium strips credentials from subresource URLs and has
since M59, and WebView2 is Chromium - so on the one platform this
product ships to, every camera tile on a shop counter was a broken
image. Measured against a running engine: the app's Go-side calls
returned stats and people while an <img> on that very URL failed, and
curl proved the URL answered 200. The engine was never the problem.

The password now stays on this side of the process boundary. A loopback
relay attaches Basic auth and streams the engine's bytes back
unchanged - the same reasoning Shot.jsx already follows at head office,
where an <img> equally cannot carry a session.

What the relay is careful about, since it is a door onto the biometric
API with a credential attached:

  - loopback only, on a port the OS picks; a fixed one would collide
    with whatever else a shop PC runs and read as "the cameras broke"
  - a per-run random token in the path. The engine's own credential
    exists so the live face feed is never served open; an
    unauthenticated relay would hand that feed to any other process on
    the PC. Compared in constant time, and a wrong one is 404, not 403
  - an allow-list of stream.mjpeg and frame.jpg. Holding the token does
    not reach the identity list, the gallery, or erasure
  - camera ids validated, not interpolated
  - every chunk flushed; a buffered MJPEG stream is a tile that never
    paints, which looks identical to the bug being fixed

Two of those were written after a test failed, not before:

  - `..` MATCHES the id pattern, because real camera ids contain dots.
    `/api/cameras/../stream.mjpeg` is not the endpoint anyone intended.
    The id can never hold a slash, so `.` and `..` are the whole
    remaining traversal surface and are now refused by name.
  - the serve goroutine read p.srv off the struct while stop() was
    nilling it, so a quick start/stop dereferenced nil and took the
    process down. Captured before launching now.

FrameURL is deliberately not added. No screen asks for a still, and a
bound method nothing calls is the same defect as a capability the UI
cannot reach, only pointing the other way.

Verified: nine unit tests, plus a live test against the real engine and
the real office camera - two MJPEG frames, 90,793 bytes, no credential
in the URL. Windows and darwin both build; vet clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcn9asw19WGBfCEaHvNug6
2026-09-10 19:53:28 +05:30
9521cb986b The shop PC's UI had never once been run
`wails build` had never been executed against this project - CLAUDE.md
says so plainly - so every screen the shop floor actually touches was
unreviewed. Running it found why nobody had.

fyne.io/systray's nativeLoop must own the main thread on macOS, a Cocoa
requirement, and Wails already holds it. Starting both kills the process
with a SIGTRAP inside cgo before a single pixel is drawn. On Windows,
which is what ships, a tray on its own goroutine is fine - so the one
platform the whole team develops on was the one platform that could not
open the app, and the UI went unlooked-at as a result.

BEHAVISION_NO_TRAY runs the window without the tray, the same escape
hatch BEHAVISION_ALLOW_PLAINTEXT_MQTT already is for the broker.
Deliberately an environment variable and NOT a GOOS check: a build that
quietly drops the tray is how a shop PC ends up with no control surface
at all, and it would fail where nobody is watching. The guard is on stop()
as well, because systray.Quit() on a systray that never started is not a
no-op in v1.12.2 - it would turn closing the window into a crash on exit,
the failure most likely to be shrugged off as "it closed, fine".

go.mod gains the indirect dependencies the darwin build pulls in. No
version moved: the committed list was written by a windows-only build,
which never resolves that part of the Wails tree.

Verified: GOOS=windows build, go vet, and the agent suite all still pass,
and the packaged .app runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qiy5iKfz4L8S4vRaYPBdaU
2026-09-09 13:06:51 +05:30
30e01765ae The live tests seeded a tenant per run and never took it back
Each live store test makes its own client - deliberately, so they can
run in any order and so the isolation assertions have a real neighbour
to be isolated from - and none of them removed it afterwards. The dev
database had reached 242 abandoned tenants against the one real
company.

That is not untidy, it is a broken screen. The platform admin's
Companies view lists every client, so the real company sat under pages
of `walk1788761685056287000`, which is the first thing anyone opening
tenant administration would see.

dropTenant registers the cleanup against the CLIENT rather than each
table: every foreign key onto clients is ON DELETE CASCADE, so one
delete takes the sites, visitors, visits, face images, embeddings,
cameras and agents with it. A per-table list would rot the first time a
migration adds a table, and it would rot silently - the same shape as
the leak it replaces.

A failed cleanup calls t.Errorf rather than being ignored. A tenant
left behind is precisely what this exists to prevent, and swallowing
the error would let the leak come back with nothing to show for it.

Verified against the live database: three consecutive runs of the store
suite leave clients, sites and visits unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qiy5iKfz4L8S4vRaYPBdaU
2026-09-09 12:43:21 +05:30
ee9e8b80b7 A backup of .env is still a copy of the camera password
Editing .env leaves .env.bak-<timestamp> beside it, and only the
anchored /.env pattern was ignored - so the backup showed up as an
untracked file holding the RTSP password in plaintext, one `git add -A`
away from being committed. The pattern that protects the original has to
protect its copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qiy5iKfz4L8S4vRaYPBdaU
2026-09-09 11:56:15 +05:30
3598d8e9c0 The shop PC's avatar had the same V1 collision
Fixed on the web arrivals feed and not here, which is the failure this
codebase already warns about: two surfaces disagreeing about one fact.
Taking the first letter of each word of "Visitor 13" gives "V1" - and so
do "Visitor 10" and "Visitor 15", so three different customers wear the
same badge and it reads as the V-1 reference for a fourth.

Shows the number itself, same rule as the web app. customerRef, not
ref: React reserves that prop name and it would never arrive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-07 12:45:38 +05:30
ce0223006b References are immutable, because clients now store them
012 turned three descriptive columns into identifiers other systems
keep: in agent.json on a shop counter, in a saved URL, in a scheduled
report. All three were already treated as stable and none of it was
enforced.

- clients.slug is an MQTT topic segment the broker ACL is written
  against. Rename one and that tenant's whole estate is silently refused
  by the broker, with no way to tell the agents.
- sites.slug is what a shop PC calls itself - agent.json holds
  "site_id": "chennai", never the uuid. A rename orphans the PC from the
  shop it is standing in.
- site_cameras.camera_id lands in visits.camera_id, which is text and
  not a foreign key. A rename orphans every visit already attributed to
  the old name: the footfall is still there and no longer joins to a
  camera. This was half-enforced in handleUpdateCamera and nowhere else,
  which is the shape of a rule that holds until somebody adds a second
  write path.
- visitors.number is assigned once from the tenant's counter and read
  back as V-42.

A trigger, not a CHECK: a CHECK cannot see the old row and the rule is
about the transition. The DISPLAY name is deliberately not frozen -
"TeNext Chennai", "Front door" - it is what a person reads, nothing keys
on it, and a system that cannot fix a typo in a shop's name has confused
the two.

Also records why the uuid stays where a slug would do. The length was
never the problem; needing it was, and that is fixed. Replacing it would
touch eight foreign keys on a live database to shorten a field clients
are already told not to use, and a sequential id would make any future
tenancy hole walkable by counting. It is NOT because ids must be minted
offline - sites, visitors and visits are all created server-side with a
database in hand, and claiming otherwise would defend the status quo
rather than explain it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-07 12:17:49 +05:30
08873f4a67 Three uuids on one arrival, three different answers
Asked of the row the feed actually returns.

site_id had a reference all along and the feed was not sending it. A
client could read the shop's NAME off an arrival and still had no way to
ask for that shop except by uuid - the exact gap the reference scheme
exists to close. site_slug now travels with it.

visit_id stays a uuid and needs no reference: no route takes it, it is a
key a client de-duplicates on because delivery is at-least-once, and
nobody says a visit id out loud.

The uuid in a face URL must STAY random. visit_faces.id is
gen_random_uuid() and a derived or sequential one would let somebody
walk a shop's customers by date - the same reason bucket keys are random
rather than derived from the event id. A readable identifier is right
for a customer and wrong for the thing that points at their photograph.

And seq is now json:"-". visits.seq is a plain bigserial, so it counts
every visit on the PLATFORM, and shipping it put the total footfall of
every customer we have on every row of every tenant's feed - the same
German-tank estimate that decided visitors.number had to be per client.
It was a convenience for "have I fallen behind", nothing ever read it,
and the cursor answers that without disclosing a number. The SSE event
id was never the raw value; it has always been the opaque cursor.

The one test that broke was reading seq back off the wire to assert the
cursor pointed at the last row of a burst. It asserts against the seeded
position now: the property is unchanged, and the test can no longer see
what a client cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-07 12:12:48 +05:30
9182f70442 A customer number people can say out loud
Every id in the schema is a uuid and stays one. What was wrong was
putting one in front of a person: RecordVisit named every new customer
'Visitor ' || left(id::text, 8), so the arrivals feed, the shop PC and
the mobile app all read "Visitor 3446ec35" - the string a shop assistant
reads to a colleague and types into a search box. label is a stored
column staff can overwrite and SearchVisitors matches on, so formatting
around it in a front end would have left the data wrong on three
surfaces.

Migration 012 adds a per-client visitors.number, taken from a counter on
clients with UPDATE ... RETURNING inside the visit transaction. Per
client rather than global: a global sequence would tell any customer who
signs up how many people the whole platform has ever seen, from their
own first visitor number. The backfill numbers existing rows by
first_seen_at and relabels only the eight-hex pattern the old statement
produced, so a human-typed name is never overwritten.

Three of the four things anyone addresses by URL already had a human
name and the API simply refused it - a site has a slug, a camera has the
id the engine knows it by. refs.go accepts either form anywhere an id is
taken; a uuid resolves with no lookup, so every URL a client already
stored keeps working.

- An ambiguous camera name resolves to nothing, never to a guess: two
  shops may each have an "Office1" and acting on the first row would
  edit the wrong shop's camera.
- 404 on a path, 400 on a query filter. /api/visits answered fine and it
  was the filter that was wrong.
- site and site_id are both accepted everywhere now. They differed per
  endpoint, and an unknown query parameter is silently ignored, so
  getting it the wrong way round returned the whole estate.
- The search matches V-13, which is what the product now shows.

Two bugs found by running it rather than testing it:

- 'Visitor ' || $2::text beside number = $2 makes Postgres deduce two
  types for one parameter and refuse the insert. It compiled and passed
  every in-memory test; the first real database rejected it, along with
  the existing face tests that share the path.
- The fallback avatar said "V1" for Visitor 13, Visitor 10 and Visitor
  15 alike, and read as the V-1 reference for a fourth person. It shows
  the number now. The prop is customerRef, not ref - React reserves
  that name and it would never have arrived.

Verified on the live database and through the running API: 13 hex labels
became Visitor 1-13 in first-seen order, two typed names left alone, and
the same customer reachable by uuid, V-13 and 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-07 11:52:32 +05:30
3f9fb33b24 Accounts people can create, and photos on a server with no bucket
A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.

Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.

Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.

Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.

Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.

Found by running it, not by tests:

  * UPDATE ... RETURNING gives the value AFTER the update, so the prune
    read back empty keys, deleted nothing, and the table grew with
    footfall exactly as if it were not there. The fake agreed with either
    version; only the live Postgres test caught it.
  * Trusting only the auth flag broke every shop card, because Sites.jsx
    rebuilt a partial snapshot object and dropped it. A relative URL is
    now sufficient on its own.
  * ago() renders a future time as "just now", so a code valid for a week
    read "expires just now".

Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-05 11:45:42 +05:30
ffae7e45d5 Live view runs at the camera's real rate, and reports why it is MJPEG
4 fps was not "live", and it was a number I picked rather than measured.
The engine actually produces ~12 distinct frames a second, so most of it
was being left on the floor.

Now: poll a little ahead of the engine and drop frames identical to the
last one by hash. Measured end to end - 131 frames in 10 s, 13.1 fps,
20.3 KB each, 259 KB/s, zero duplicates. Every byte on the wire is a
picture the viewer has not seen, and the rate follows the camera instead
of a constant.

Also records why this is MJPEG rather than passing the camera's own
compressed video through, which would be smoother, cheaper and use no
CPU. Probed the office camera: main 2304x1296@15, sub 800x448@15 - and
BOTH are H.265, despite stream paths ending in ".264". Browsers play
H.264 everywhere and H.265 only on some platforms, so passthrough cannot
rely on it, and transcoding HEVC on the shop PC would put a video encoder
on the machine already doing the recognition.

So probe_source now reports `codec`. It decides what is possible, an
installer can usually change it, and otherwise the only way to learn it is
to read RTSP by hand - which is how this was found.

The RTSP libraries used to establish that are NOT kept: they were only
ever imported by a spike test, and two large dependencies in a shipped
binary to answer a question OpenCV already knows is a bad trade. Their
`go get` had also silently bumped the agent to go 1.25 and broken the
desktop build, which is its own argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 16:59:27 +05:30
18686cbceb Live view at head office, relayed through the agent's outbound connection
I got this wrong first time. "Head office cannot show live video cheaply"
conflated TRUE VIDEO with SEEING THE CAMERA NOW, and only the first needs
WebRTC and a TURN server.

The shop PC is behind a router with no inbound route, so head office
cannot pull the engine's MJPEG. It can answer the agent's outbound
requests, which is the shape of everything else here: the server holds a
poll open, the agent asks "is anyone watching?", and pushes JPEGs up for
exactly as long as somebody is.

Measured on the office camera: 98 KB full frame, 20.8 KB re-encoded at
640/q60, so one watcher costs ~83 KB/s. 47 frames arrived in 12 seconds -
4 fps, as configured. The UI says "about 4 frames a second" rather than
letting anyone conclude the camera stutters.

Nothing is uploaded when nobody is looking, which is the whole cost
argument: Publish returns false once the last viewer goes, interest lapses
on a timer each viewer refreshes as it reads (so a closed tab stops the
upload within seconds), one push is capped at five minutes, and the UI
streams one camera at a time.

LiveHub is deliberately the opposite of the arrivals Hub. There a doorbell
pushes nothing because nothing may be lost; here a dropped frame is the
correct outcome, so each viewer has a one-slot buffer that is overwritten -
the only frame worth having is the newest, and a queue would show an
ever-growing delay behind the shop instead of dropping back to live.

Ownership is proved once, before anything streams: the relay is keyed on a
camera id, a hub does not know whose camera it holds, and a camera id is
not a secret. Verified: another tenant gets 404, no session gets 401, and
an agent cannot push into another site's camera.

Also fixes a bug I introduced with it - the Live button was gated on
`connected`, which is head office's last report and up to two minutes
stale, so it hid itself during every reconnect. "Is that camera really
down?" is exactly when somebody wants to look, and a hidden control says
"you cannot" where the honest answer is "here is why".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 16:46:23 +05:30
2cd7a78ddc A headless PC can be claimed, and a refused broker says so
Both found by operating the stack rather than writing it: the local
processes were OOM-killed and bringing them back hit two gaps.

The headless agent had no way to be claimed at all. Bootstrap lived only
in desktop/internal/cloud, so the one configuration the agent binary
exists for - a back-office PC with no window - could only be onboarded by
hand-editing agent.json, which is the state the desktop's Setup screen was
built to end. `behavision-agent claim <code>` closes it; the CLI joins its
arguments because the code is printed in groups for reading aloud and an
operator pasting it will paste the spaces too.

Second: after the site's broker password was re-rolled, mosquitto logged
"not authorised" while the agent logged "timed out". Those need opposite
actions - re-link this PC, or go and look at the network - and paho's
SetConnectRetry collapses them, because it retries internally and the
connect token never completes. describeStall asks whether a TCP socket
opens at all, and says what is known rather than guessing at a reason the
broker never gives.

Verified end to end: minted a code from the platform as the owner,
claimed with the new command, broker connected, and the shop went to
online: true with 1/1 cameras on w600k_r50.

Also corrects this machine's memory in CLAUDE.md from 16 GB to 8 GB. It
feeds the model-fallback reasoning, and the local gallery already holds
17 embeddings tagged w600k_mbf beside 19 tagged w600k_r50 - the fallback
has silently fired before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 13:32:38 +05:30
e0ceb14589 Camera pictures without an object-storage bucket
Head office shows a camera's latest frame rather than live video, for a
reason that has not changed: the engine serves MJPEG on 127.0.0.1 on a PC
behind a shop's router with no inbound route, and relaying it needs
WebRTC/TURN. Pointing a browser straight at the shop PC is not the escape
either - the engine's API is Basic-authenticated with a credential it
generates locally and never sends anywhere, and shipping that to the
cloud so a web page could use it would put the key to the biometric API
and the live face feed in the server's database.

But that picture only worked if you had an S3 bucket. Without one,
attachSnapshots reported "This system is not storing images" for every
camera forever - on the two screens whose whole job is to show the
camera. Making them picture-led turned a missing feature into a wall of
empty tiles, on every local install and any self-hosted customer who does
not want a bucket.

migrations/009 adds camera_snapshots and the agent falls back to
PUT /api/agent/cameras/{camera}/snapshot when the presigned route answers
images_disabled - chosen by sentinel, never by matching the message, since
it picks between two routes. One row per camera is what makes this safe in
the database when face images are not: the key IS the camera, so storage
is (cameras x ~100 KB) and does not grow with footfall.

The read is session-authenticated rather than a signed link, which an
<img> cannot use - hence Shot.jsx and useAuthedImage, keyed on the URL
string rather than the snapshot object so a poll does not re-fetch 90 KB
per camera every few seconds, and revoking the object URL on cleanup.

Verified against the real office camera with no bucket configured: 90,587
bytes stored in Postgres, served as image/jpeg to a signed-in user, 401
without a session, rendered on both the Cameras and Shops cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 12:53:18 +05:30
227 changed files with 24983 additions and 1136 deletions

15
.gitignore vendored
View File

@@ -52,3 +52,18 @@ node_modules/
# in the tree `go build ./...` fails on a fresh checkout - on a machine that may
# have no npm at all. They are ~200 KB and regenerating them is one command; a
# repository that does not compile is the more expensive problem.
# Backups of .env made when editing camera credentials.
/.env.bak-*
# Generated by the wails CLI on every dev run and build, not source.
# NOT /desktop/build/ as a whole: appicon.png, darwin/ and windows/ under it
# are the Wails project scaffolding (icon, Info.plist, manifest) that a
# reproducible Windows build needs. Only the compiled output is ignored.
/desktop/frontend/wailsjs/
/desktop/frontend/package.json.md5
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
/behavision.egg-info/
/.prod/
/.demo/

1321
API.md Normal file

File diff suppressed because it is too large Load Diff

1414
CLAUDE.md

File diff suppressed because it is too large Load Diff

2
RUN.md
View File

@@ -251,7 +251,7 @@ the frozen engine once to prove it runs, and compiles the installer.
3. Launch from the Start menu. **Set this PC up on its own** — no code needed.
4. Cameras → Add camera → pick the make → Test connection → Save. The feed must
appear with no restart.
5. Check `/api/health` reports `recognition_model`. On a 16 GB machine the
5. Check `/api/health` reports `recognition_model`. On a small machine the
166 MB r50 can lose the fallback chain to the 13 MB mbf, and embeddings are
model-tagged, so which one wins decides whether a gallery carries over.
6. Sign out of the tray (Quit) — recognition must stop with it. Reboot; the app

View File

@@ -0,0 +1,93 @@
// Command behavision-demo-pack seals a camera list into demo-cameras.enc for a
// demo release. It runs on the machine that builds the release and is never
// shipped.
//
// behavision-demo-pack -cameras cameras.json -out demo-cameras.enc
//
// Prints the unlock code exactly once. It is not stored anywhere; a code you
// can look up later is a code anyone with access to the build machine holds.
// Lose it and seal again.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/loyaly/behavision-agent/pkg/demo"
)
func main() {
in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password) - the PC then runs on its own")
enrolCode := flag.String("enrol-code", "", "an installation code from head office - the PC then claims that shop and gets its cameras from there")
cloud := flag.String("cloud", "https://mcp.loyaly.ai", "head office, with -enrol-code")
out := flag.String("out", "demo-cameras.enc", "sealed bundle to write")
flag.Parse()
if *in == "" && *enrolCode == "" {
fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack (-cameras cameras.json | -enrol-code CODE [-cloud URL]) [-out demo-cameras.enc]")
os.Exit(2)
}
var payload demo.Payload
if *in != "" {
raw, err := os.ReadFile(*in)
if err != nil {
die("read cameras: %v", err)
}
if err := json.Unmarshal(raw, &payload.Cameras); err != nil {
die("cameras.json: %v", err)
}
if len(payload.Cameras) == 0 {
die("no cameras in %s", *in)
}
}
payload.EnrolCode = *enrolCode
if *enrolCode != "" {
payload.CloudBase = *cloud
}
cams := payload.Cameras
for i, c := range cams {
switch {
case c.ID == "":
die("camera %d has no id", i)
case c.Host == "":
die("camera %q has no host", c.ID)
case c.Path == "":
die("camera %q has no path - the stream path is the field nobody can guess", c.ID)
}
}
// Re-marshal so only the fields the engine accepts travel, in a stable
// shape, whatever extra keys the input happened to carry.
plain, err := json.Marshal(payload)
if err != nil {
die("marshal: %v", err)
}
code, err := demo.NewCode()
if err != nil {
die("code: %v", err)
}
sealed, err := demo.Seal(code, plain)
if err != nil {
die("seal: %v", err)
}
if err := os.WriteFile(*out, sealed, 0o644); err != nil {
die("write: %v", err)
}
if payload.EnrolCode != "" {
fmt.Printf("\n sealed an installation code for %s into %s (%d bytes)\n\n", payload.CloudBase, *out, len(sealed))
} else {
fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed))
}
fmt.Printf(" unlock code: %s\n\n", code)
fmt.Println(" Shown once. Give it to whoever runs behavision-setup, by voice")
fmt.Println(" or message - not in the same place as the zip.")
fmt.Println()
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, " "+format+"\n", args...)
os.Exit(1)
}

View File

@@ -0,0 +1,838 @@
// Command behavision-setup prepares a shop PC to run the recognition engine.
//
// It exists because the engine is Python and the rest of the product is Go.
// The Go halves cross-compile to Windows from any machine; the engine, frozen
// with PyInstaller, does not - PyInstaller bundles the interpreter and native
// wheels of the machine it runs on, so a frozen engine can only be built on
// Windows. That single fact was the whole reason a release could not be cut.
//
// So this installs the engine from source instead of shipping it frozen: find
// a Python, build a private virtual environment beside the database, install
// the engine into it, fetch the models, and record how to start it. Everything
// in the release can then be built anywhere.
//
// The trade, stated plainly because whoever runs this is standing in a shop:
// it needs Python and a working internet connection at install time, and it
// takes minutes rather than seconds. A frozen build needs neither. What it
// buys is a release that exists.
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"bytes"
"encoding/json"
"github.com/loyaly/behavision-agent/pkg/config"
"github.com/loyaly/behavision-agent/pkg/demo"
"github.com/loyaly/behavision-agent/pkg/engine"
"github.com/loyaly/behavision-agent/pkg/enrol"
"github.com/loyaly/behavision-agent/pkg/paths"
)
// The engine needs 3.10; nothing here works below it and the failure would
// otherwise arrive as a syntax error deep inside a dependency.
const minMinor = 10
// maxMinor is a WHEEL-availability ceiling, not a language one, and it is the
// reason this constant exists at all.
//
// findPython used to take the newest interpreter it could find, with a floor
// and no ceiling - which is precisely backwards, because the newest Python is
// the one least likely to have binary wheels for anything. Measured on a
// second Mac: it chose Python 3.14, pip found no numpy wheel for cp314, fell
// back to building numpy from source, and produced
//
// ERROR: Unknown compiler(s): [['cc'], ['gcc'], ['clang'], ...]
//
// then, once the operator installed Xcode's command line tools to get past
// that, ten minutes of compiling ending in
//
// arm_neon.h:28:2: error: "<arm_neon.h> is intended only for ARM and
// AArch64 targets"
//
// Two screens of C compiler output, on a shop counter, for a version choice
// made silently by this program. Refusing in one line, before anything is
// downloaded, is the whole of the fix.
//
// Raise it when the dependency set has wheels for the next version. Today
// onnxruntime is the binding one (cp314 is its newest); numpy publishes
// further ahead, and opencv-python ships a stable-ABI wheel that covers
// everything. `pip download --only-binary=:all: -r requirements.txt` against
// a candidate interpreter is the check.
const maxMinor = 14
// The three answers a candidate interpreter can get. Three, not two: a
// version that is too new and one that is too old need opposite actions from
// the operator, and collapsing them tells somebody holding Python 3.14 to go
// and install a newer Python.
const (
verdictOK = "ok"
verdictTooOld = "old"
verdictTooNew = "new"
verdictUnknown = "unparseable"
)
func pythonVerdict(major, minor int, parsed bool) string {
switch {
case !parsed:
return verdictUnknown
case major != 3:
// Python 4 is not a version this has been tried against, and 2 is
// long gone. Neither is a thing to guess about.
return verdictTooNew
case minor < minMinor:
return verdictTooOld
case minor > maxMinor:
return verdictTooNew
}
return verdictOK
}
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
pause()
os.Exit(1)
}
pause()
}
func run() error {
fmt.Println()
fmt.Println(" Behavision setup")
fmt.Println(" ----------------")
fmt.Println()
state := paths.StateRoot()
src, err := engineSource()
if err != nil {
return err
}
fmt.Printf(" engine source %s\n", src)
fmt.Printf(" install into %s\n", state)
fmt.Println()
if err := paths.EnsureState(); err != nil {
return fmt.Errorf("could not create %s: %w", state, err)
}
// A demo release ships its cameras sealed. Ask for the code NOW, before
// the ten-minute download, so a mistyped one costs seconds; the cameras
// are actually added at the end, through the running engine.
bundle, err := unlockDemo(src)
if err != nil {
return err
}
var demoCams []demo.Camera
if bundle != nil {
demoCams = bundle.Cameras
switch {
case bundle.EnrolCode != "":
step("Demo", "unlocked - this PC will join a shop at head office")
default:
step("Demo cameras", fmt.Sprintf("%d unlocked", len(demoCams)))
}
}
if running := behavisionRunning(); running != "" {
// lint:ignore ST1005 — this is not a wrapped error, it is the whole
// message an operator reads at a shop counter. ST1005 forbids
// trailing punctuation because errors get concatenated mid-sentence;
// nothing wraps this one, and stripping the full stops would make
// three sentences run together.
//lint:ignore ST1005 operator-facing prose, never wrapped
return fmt.Errorf("%s is running. Quit Behavision from the tray icon first, then run setup again.\n\n"+
"Setting up underneath a running copy starts a second engine on the same port and, in a demo,\n"+
"re-claims the shop while the open app still holds the old credentials.", running)
}
py, ver, err := findPython()
if err != nil {
return err
}
step("Python", fmt.Sprintf("%s (%s)", ver, py))
venv := filepath.Join(state, "runtime")
if err := makeVenv(py, venv); err != nil {
return err
}
vpy := venvPython(venv)
step("Virtual environment", venv)
// The engine reads its settings from <state>/config/default.yaml and will
// seed that from beside its own code on first run - which works when its
// code is a checkout or a frozen folder and not when it is a package in
// site-packages, where there is no config beside it. Seeded here, from the
// copy the release ships. Never overwritten: an upgrade must not revert an
// operator's thresholds.
if err := seedConfig(src, state); err != nil {
return err
}
step("Settings", filepath.Join(state, "config", "default.yaml"))
// --upgrade so re-running after a new release replaces the engine rather
// than leaving the old one in place and reporting success.
if err := pipInstall(vpy, src); err != nil {
return err
}
step("Engine and dependencies", "installed")
if err := runEngine(vpy, "setup-models"); err != nil {
return fmt.Errorf("downloading the recognition models: %w", err)
}
step("Recognition models", "downloaded")
if err := writeConfig(vpy); err != nil {
return err
}
step("Startup settings", filepath.Join(state, "agent.json"))
// Proving it starts is the point. An installer that reports success and
// leaves a shop with an engine that will not run has done worse than
// failing: the failure surfaces later, to someone who did not install it.
// Joining a shop: head office supplies the cameras, so any left on this PC
// from an earlier install go first. Otherwise the reconciler offers them UP
// to head office - without their passwords, which the engine never returns
// - and the shop ends up with the same lens listed twice, one copy of which
// can never be pushed to another PC. Measured on the first claimed demo.
if bundle != nil && bundle.EnrolCode != "" {
if err := os.Remove(paths.CamerasFile()); err == nil {
step("Earlier cameras", "removed - head office supplies them now")
}
}
if err := smokeTest(vpy, demoCams); err != nil {
return fmt.Errorf("the engine installed but would not start: %w", err)
}
step("Engine starts and answers", "verified")
if len(demoCams) > 0 {
step("Demo cameras", "added to the engine")
}
switch {
case bundle != nil && bundle.EnrolCode != "":
// The demo that IS the product: this PC claims a real shop, exactly
// as a customer install does, and its cameras arrive from head office
// on the first sync. The app then opens on Login.
siteName, err := claimShop(bundle.EnrolCode, bundle.CloudBase)
if err != nil {
return fmt.Errorf("could not join the shop at head office: %w", err)
}
step("Head office", "linked to "+siteName)
case bundle != nil:
// No head office in this demo. Without this the app opens on "type an
// installation code" and sits there; with it, it opens on Live.
if err := markStandalone(); err != nil {
return err
}
step("Head office", "none - running on this PC only")
}
fmt.Println()
// The last thing setup says is the first thing the operator does, so it
// has to describe THEIR machine. On macOS there is no Start menu and,
// deliberately, no tray at all - telling somebody to right-click a tray
// icon that does not exist is how software loses their trust on the step
// where it was otherwise finished.
if runtime.GOOS == "windows" {
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
fmt.Println(" It appears in the system tray; right-click there to stop it.")
} else {
fmt.Println(" Done. Open Behavision.app - right-click it and choose Open the")
fmt.Println(" first time, because this build is not notarised.")
fmt.Println(" There is no tray on macOS: closing the window stops recognition.")
}
fmt.Println()
return nil
}
func step(label, detail string) {
fmt.Printf(" [ok] %-24s %s\n", label, detail)
}
// engineSource finds the Python source shipped beside this executable. Beside,
// not downloaded: the engine and the app must be the same release, and a
// version skew between them is the class of bug nobody can reproduce.
func engineSource() (string, error) {
candidates := []string{
filepath.Join(paths.InstallRoot(), "engine-src"),
filepath.Join(paths.InstallRoot(), "..", "engine-src"),
}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(wd, "engine-src"), wd)
}
for _, c := range candidates {
if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil {
abs, _ := filepath.Abs(c)
return abs, nil
}
}
return "", errors.New("could not find the engine source (expected an " +
"engine-src folder with pyproject.toml beside this program). " +
"Unzip the whole release together rather than moving this file out of it")
}
// findPython returns the first interpreter that is new enough.
//
// `py -3` first on Windows: the launcher is what the official installer puts
// on PATH, and `python` there is often the Microsoft Store stub that prints an
// advert and exits 9009 instead of running anything.
// behavisionRunning names a Behavision process if one is up. Windows only -
// that is the platform setup ships on - and by image name via tasklist, which
// needs no extra privilege.
func behavisionRunning() string {
if runtime.GOOS != "windows" {
return ""
}
for _, name := range []string{"Behavision.exe", "behavision-agent.exe"} {
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name, "/NH").Output()
if err == nil && strings.Contains(strings.ToLower(string(out)), strings.ToLower(name)) {
return name
}
}
return ""
}
func findPython() (string, string, error) {
type cand struct {
exe string
args []string
}
var cands []cand
if runtime.GOOS == "windows" {
cands = append(cands, cand{"py", []string{"-3"}})
}
// Versioned names FIRST, newest first, and this is not belt-and-braces on
// macOS - it is the only thing that works. `/usr/bin/python3` there is
// always the Command Line Tools build, 3.9 on current macOS, which is
// below the 3.10 floor. Anything newer installs as `python3.12` or into a
// directory that is not on a GUI application's PATH. Searching only
// `python3` therefore told a Mac with Python 3.12 sitting on it to go and
// install Python - measured on this machine, which has 3.12 under
// ~/.local/opt and reported "Found, but too old: python3 3.9".
// Newest first WITHIN the supported range. Newest overall is what broke
// this; a version nobody has built wheels for is not a better choice than
// one that works.
var versions []string
for v := maxMinor; v >= minMinor; v-- {
versions = append(versions, fmt.Sprintf("3.%d", v))
}
for _, v := range versions {
cands = append(cands, cand{"python" + v, nil})
}
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
// And the places a Mac puts an interpreter that LookPath will not find,
// because a double-clicked app inherits a minimal PATH rather than the
// one a shell profile builds.
if runtime.GOOS != "windows" {
home, _ := os.UserHomeDir()
for _, v := range versions {
for _, dir := range []string{
"/opt/homebrew/bin",
"/usr/local/bin",
"/Library/Frameworks/Python.framework/Versions/" + v + "/bin",
filepath.Join(home, ".local", "opt", "python"+v, "bin"),
} {
cands = append(cands, cand{filepath.Join(dir, "python"+v), nil})
}
}
}
var tried, tooNew []string
for _, c := range cands {
exe := c.exe
if filepath.IsAbs(exe) {
// An absolute candidate is a guess about where an interpreter
// might be; most will not exist, and that is not an error.
if fi, err := os.Stat(exe); err != nil || fi.IsDir() {
continue
}
} else {
found, err := exec.LookPath(exe)
if err != nil {
continue
}
exe = found
}
args := append(append([]string{}, c.args...), "-c",
"import sys;print('%d.%d'%sys.version_info[:2])")
out, err := exec.Command(exe, args...).Output()
if err != nil {
continue
}
ver := strings.TrimSpace(string(out))
tried = append(tried, c.exe+" "+ver)
major, minor, parsed := parseVer(ver)
switch verdict := pythonVerdict(major, minor, parsed); verdict {
case verdictTooNew:
// Recorded separately: "too new" and "too old" need opposite
// actions, and a single "found, but unsuitable" list sends
// somebody to upgrade a Python that is already past the problem.
tooNew = append(tooNew, c.exe+" "+ver)
continue
case verdictTooOld, verdictUnknown:
continue
}
{
full := exe
if len(c.args) > 0 {
full = exe + " " + strings.Join(c.args, " ")
}
return full, "Python " + ver, nil
}
}
// The advice has to match the machine. Telling a Mac user to tick "Add
// python.exe to PATH" on a Windows installer page reads as software that
// does not know where it is running, which is exactly the moment somebody
// stops trusting the rest of what it says.
// Only a too-new Python is a different problem with a different fix, and
// saying "no Python was found" to somebody looking at Python 3.14 is the
// kind of message that makes people stop believing the next one.
if len(tooNew) > 0 && len(tried) == 0 {
// Built as a value and wrapped, not written as an fmt.Errorf literal:
// this is a paragraph shown to an operator, and a linter that wants
// error strings to be lower-case fragments is right about errors
// programs read and wrong about the ones people do.
tooNewMsg := fmt.Sprintf(
"this computer has %s, which is newer than Behavision supports.\n\n"+
" Some of the libraries the engine needs have no build for it\n"+
" yet, so installing would fail part-way through.\n\n"+
" Install Python 3.%d and run this again:\n"+
" macOS: brew install python@3.%d\n"+
" or https://www.python.org/downloads/macos/\n"+
" Windows: https://www.python.org/downloads/windows/\n\n"+
" Both versions can sit on the machine together; this picks\n"+
" the one it can use.",
strings.Join(tooNew, ", "), maxMinor, maxMinor)
return "", "", errors.New(tooNewMsg)
}
msg := fmt.Sprintf("no Python between 3.%d and 3.%d was found on this computer.\n\n",
minMinor, maxMinor)
if runtime.GOOS == "windows" {
msg += " Install it from https://www.python.org/downloads/windows/\n" +
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
" then run this again."
} else {
msg += " Install it with `brew install python@3.12`, or from\n" +
" https://www.python.org/downloads/macos/, then run this again."
}
if len(tried) > 0 {
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
}
if len(tooNew) > 0 {
msg += "\n\n Found, but too new: " + strings.Join(tooNew, ", ")
}
return "", "", errors.New(msg)
}
func parseVer(s string) (int, int, bool) {
parts := strings.Split(s, ".")
if len(parts) < 2 {
return 0, 0, false
}
major, err1 := strconv.Atoi(parts[0])
minor, err2 := strconv.Atoi(parts[1])
return major, minor, err1 == nil && err2 == nil
}
// splitLauncher turns `py -3` back into a command and its arguments.
func splitLauncher(s string) (string, []string) {
f := strings.Fields(s)
if len(f) == 0 {
return s, nil
}
return f[0], f[1:]
}
func venvPython(venv string) string {
if runtime.GOOS == "windows" {
return filepath.Join(venv, "Scripts", "python.exe")
}
return filepath.Join(venv, "bin", "python")
}
// makeVenv builds the engine's own interpreter under the writable state root.
//
// A virtual environment rather than the system Python: a shop PC may have
// Python there for something else, and pinning numpy below 2.0 - which the
// engine requires - inside a shared interpreter is how you break the other
// thing months later, silently.
func makeVenv(py, venv string) error {
// An existing environment is reused - but only if the Python inside it is
// one this build supports.
//
// It used to be reused unconditionally, and that would have made the
// version ceiling above look like it did not work. The machine this was
// all found on already had a runtime built by Python 3.14, from the run
// that failed: with the ceiling in place setup would choose a good
// interpreter, reach here, find the 3.14 environment, keep it, and die in
// the same clang error as before. A fix that is defeated by the wreckage
// of the bug it fixes is not one.
//
// Rebuilding costs a re-download of the libraries and nothing else. The
// models are in the state root, not in here, so they survive.
if _, err := os.Stat(venvPython(venv)); err == nil {
ok, ver := venvUsable(venv)
if ok {
return nil // pip below brings it up to date
}
fmt.Printf(" [..] %-24s %s\n", "Rebuilding environment",
"the existing one uses "+ver+", which is not supported")
if err := os.RemoveAll(venv); err != nil {
return fmt.Errorf("removing the old environment at %s: %w", venv, err)
}
}
exe, args := splitLauncher(py)
args = append(args, "-m", "venv", venv)
return stream(exec.Command(exe, args...), "creating the virtual environment")
}
// venvUsable reports whether the interpreter already inside an environment is
// one this build supports, and what it is when it is not.
//
// An environment that cannot be asked counts as unusable: a half-created or
// truncated one answers nothing, and reusing it fails later in pip with an
// error about a package rather than about the environment.
func venvUsable(venv string) (bool, string) {
out, err := exec.Command(venvPython(venv), "-c",
"import sys;print('%d.%d'%sys.version_info[:2])").Output()
if err != nil {
return false, "an interpreter that will not run"
}
ver := strings.TrimSpace(string(out))
major, minor, parsed := parseVer(ver)
return pythonVerdict(major, minor, parsed) == verdictOK, "Python " + ver
}
func pipInstall(vpy, src string) error {
fmt.Println(" Installing the engine and its libraries. This downloads a few")
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
fmt.Println()
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade",
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
return err
}
// A wheel if the release ships one - nothing to build on the shop PC, and
// pip never has to touch the folder the release was unzipped into.
//
// That matters more than it sounds: `pip install <folder>` makes setuptools
// write behavision.egg-info INTO that folder, and the folder is read-only
// whenever the release was unzipped somewhere sensible - Program Files, or
// the shared drive INSTALL.txt says is fine. Found by running this in a
// container with the source mounted read-only: "could not create
// 'behavision.egg-info': Read-only file system". Falling back to source
// copies it somewhere writable first, for the same reason.
if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 {
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
"installing the engine")
}
tmp, err := os.MkdirTemp("", "behavision-src-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
if err := copyTree(src, tmp); err != nil {
return fmt.Errorf("staging the engine source: %w", err)
}
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp),
"installing the engine")
}
// seedConfig puts the shipped default.yaml where the engine will look for it,
// and leaves an existing one alone.
func seedConfig(src, state string) error {
dst := filepath.Join(state, "config", "default.yaml")
if _, err := os.Stat(dst); err == nil {
return nil
}
from := filepath.Join(src, "config", "default.yaml")
b, err := os.ReadFile(from)
if err != nil {
return fmt.Errorf("the release is missing config/default.yaml: %w", err)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, b, 0o644)
}
// copyTree copies a source tree, skipping the caches a checkout accumulates.
func copyTree(from, to string) error {
return filepath.WalkDir(from, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(from, path)
if d.IsDir() {
if d.Name() == "__pycache__" || strings.HasSuffix(d.Name(), ".egg-info") {
return filepath.SkipDir
}
return os.MkdirAll(filepath.Join(to, rel), 0o755)
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(to, rel), b, 0o644)
})
}
// runEngine runs the engine exactly as the app will later: same interpreter,
// same environment. In particular ChildEnv sets BEHAVISION_DATA_DIR, without
// which a pip-installed engine decides its state lives in site-packages and
// downloads the models to a place the app never looks.
func runEngine(vpy string, args ...string) error {
full := append([]string{"-m", "behavision"}, args...)
cmd := exec.Command(vpy, full...)
cmd.Env = engine.ChildEnv("")
return stream(cmd, "running the engine")
}
// writeConfig records how to start the engine, in the same file and through
// the same type the app reads, so the two cannot disagree about it.
func writeConfig(vpy string) error {
path := paths.AgentConfig()
cfg, err := config.Load(path)
if err != nil {
return fmt.Errorf("reading %s: %w", path, err)
}
// An absolute path: the app resolves a relative EngineExe against its own
// install root under Program Files, and the interpreter is not there.
cfg.EngineExe = vpy
cfg.EngineArgs = []string{"-m", "behavision", "run"}
if cfg.APIBase == "" {
cfg.APIBase = "http://127.0.0.1:8010"
}
return cfg.Save(path)
}
// smokeTest starts the engine exactly as the app will and waits for its API to
// answer. Any reply counts, including 401: the engine invents its own
// credential when none is configured, and a refusal proves it is serving.
func smokeTest(vpy string, demoCams []demo.Camera) error {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
cmd.Env = engine.ChildEnv("")
var log strings.Builder
cmd.Stdout, cmd.Stderr = &log, &log
if err := cmd.Start(); err != nil {
return err
}
defer func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}()
client := &http.Client{Timeout: 3 * time.Second}
deadline := time.Now().Add(75 * time.Second)
for time.Now().Before(deadline) {
resp, err := client.Get("http://127.0.0.1:8010/api/health")
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if demoCams == nil {
return nil
}
// Through the engine's own Add Camera, not written to its file:
// the store is what applies DPAPI to the password on Windows, so
// this is how the credential ends up encrypted on disk rather
// than sitting in cameras.json for anyone who can read
// ProgramData.
return addCameras(demoCams)
}
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
break
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
tail(log.String(), 15))
}
func tail(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return " " + strings.Join(lines, "\n ")
}
// stream runs a command and shows its output. Shown, not swallowed: pip failing
// on a missing build tool prints exactly what is wrong, and hiding that leaves
// the operator with "setup failed" and nothing to act on.
func stream(cmd *exec.Cmd, what string) error {
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s failed: %w", what, err)
}
return nil
}
// pause keeps the window open. Double-clicked from Explorer, a console program
// that finishes closes instantly and the operator sees nothing at all -
// success and failure look identical.
func pause() {
if runtime.GOOS != "windows" {
return
}
fmt.Print(" Press Enter to close. ")
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
}
// unlockDemo returns the sealed cameras a demo release ships, or nil when this
// is not a demo release. Asks for the unlock code on the console; three tries,
// because a code is read down a phone and typed by hand.
func unlockDemo(src string) (*demo.Payload, error) {
sealed, err := os.ReadFile(filepath.Join(src, "demo-cameras.enc"))
if err != nil {
return nil, nil // not a demo release
}
fmt.Println()
fmt.Println(" This is a demo release with the cameras already set up.")
fmt.Println(" It needs the unlock code you were given.")
fmt.Println()
in := bufio.NewReader(os.Stdin)
for attempt := 1; attempt <= 3; attempt++ {
fmt.Print(" Unlock code: ")
line, _ := in.ReadString('\n')
plain, err := demo.Open(line, sealed)
if err == nil {
payload, err := demo.Decode(plain)
if err != nil {
return nil, fmt.Errorf("the bundle unlocked but did not parse: %w", err)
}
fmt.Println()
return &payload, nil
}
fmt.Printf(" %v\n", err)
}
return nil, errors.New("no valid unlock code after three tries. Check it " +
"with whoever gave you this release and run setup again")
}
// claimShop redeems the installation code sealed in the bundle: the same call
// the app's Setup screen and `behavision-agent claim` make, so the PC ends up
// in exactly the state a customer's would - broker login, API token, the
// broker's CA on disk - and head office pushes its cameras down on the first
// sync.
func claimShop(code, base string) (string, error) {
if base == "" {
base = "https://mcp.loyaly.ai"
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
b, err := enrol.Claim(ctx, base, code)
if err != nil {
return "", err
}
path := paths.AgentConfig()
cfg, err := config.Load(path)
if err != nil {
return "", err
}
cfg.ClientID = b.ClientSlug
cfg.SiteID = b.SiteSlug
cfg.SiteName = b.SiteName
cfg.BrokerURL = b.MQTTURL
cfg.BrokerUsername = b.MQTTUser
cfg.BrokerPassword = b.MQTTPass
cfg.AgentToken = b.AgentToken
cfg.CloudBase = base
cfg.Standalone = false
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
if err != nil {
return "", err
}
cfg.BrokerCAFile = caPath
if err := cfg.Save(path); err != nil {
return "", err
}
return b.SiteName, nil
}
// addCameras posts each demo camera to the running engine, with the credential
// the engine generated for itself on first start.
func addCameras(cams []demo.Camera) error {
user, pass, err := engineCredential()
if err != nil {
return err
}
client := &http.Client{Timeout: 30 * time.Second}
for _, c := range cams {
if c.Port == 0 {
c.Port = 554
}
body, _ := json.Marshal(c)
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8010/api/cameras",
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("adding camera %s: %w", c.ID, err)
}
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
// 409 is "already there" - a re-run of setup, which is allowed.
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusConflict {
return fmt.Errorf("adding camera %s: %s: %s", c.ID, resp.Status,
strings.TrimSpace(string(msg)))
}
}
return nil
}
// engineCredential reads the Basic credential the engine wrote on its first
// start. Empty when the engine is configured without one.
func engineCredential() (string, string, error) {
b, err := os.ReadFile(paths.APICredentials())
if err != nil {
if os.IsNotExist(err) {
return "", "", nil
}
return "", "", err
}
var user, pass string
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "username="); ok {
user = strings.TrimSpace(v)
}
if v, ok := strings.CutPrefix(line, "password="); ok {
pass = strings.TrimSpace(v)
}
}
return user, pass, nil
}
// markStandalone records that this PC runs on its own, through the same
// config type the app reads.
func markStandalone() error {
path := paths.AgentConfig()
cfg, err := config.Load(path)
if err != nil {
return err
}
cfg.Standalone = true
return cfg.Save(path)
}

View File

@@ -0,0 +1,112 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// The choice this program makes silently, and got wrong.
//
// findPython took the newest interpreter on the machine, with a floor and no
// ceiling - backwards, because the newest Python is the one least likely to
// have binary wheels. On a Mac holding Python 3.14 it chose 3.14, pip found
// no numpy wheel for cp314, fell back to a source build and produced two
// screens of clang errors ending in "<arm_neon.h> is intended only for ARM
// and AArch64 targets". The operator's machine was fine; the version was not.
func TestTooNewIsRefusedRatherThanCompiled(t *testing.T) {
if got := pythonVerdict(3, maxMinor+1, true); got != verdictTooNew {
t.Errorf("3.%d = %q, want %q - picking it means a source build",
maxMinor+1, got, verdictTooNew)
}
if got := pythonVerdict(3, maxMinor, true); got != verdictOK {
t.Errorf("3.%d = %q, want %q - the ceiling is inclusive", maxMinor, got, verdictOK)
}
}
// Too old and too new must stay different answers. Telling somebody holding
// Python 3.14 that no Python was found, or that theirs is too old, sends them
// to install a newer one - which is the direction that already failed.
func TestOldAndNewAreDifferentAnswers(t *testing.T) {
old := pythonVerdict(3, minMinor-1, true)
fresh := pythonVerdict(3, maxMinor+1, true)
if old == fresh {
t.Fatalf("3.%d and 3.%d both reported %q", minMinor-1, maxMinor+1, old)
}
if old != verdictTooOld {
t.Errorf("3.%d = %q, want %q", minMinor-1, old, verdictTooOld)
}
}
// Every version in the range is accepted, so the window this program claims
// to support is the one it actually uses.
func TestTheWholeSupportedRangeIsAccepted(t *testing.T) {
for m := minMinor; m <= maxMinor; m++ {
if got := pythonVerdict(3, m, true); got != verdictOK {
t.Errorf("3.%d = %q, want %q", m, got, verdictOK)
}
}
if minMinor > maxMinor {
t.Fatal("the supported range is empty; nothing would ever be chosen")
}
}
// A major version nobody has tested against is not something to guess at, and
// an unreadable version string is not a working interpreter.
func TestUnknownVersionsAreNotAccepted(t *testing.T) {
for _, c := range []struct {
name string
major, minor int
parsed bool
}{
{"python 4", 4, 0, true},
{"python 2", 2, 7, true},
{"unparseable", 0, 0, false},
} {
if got := pythonVerdict(c.major, c.minor, c.parsed); got == verdictOK {
t.Errorf("%s was accepted", c.name)
}
}
}
// An environment already on disk is reused, and that is right until the Python
// inside it is one this build cannot use.
//
// It was reused unconditionally, which would have defeated the ceiling above
// on the exact machine that found the bug: that Mac already had a runtime
// built by Python 3.14, left behind by the run that failed. Setup would pick a
// good interpreter, find the 3.14 environment, keep it, and die in the same
// clang error as before - a fix defeated by the wreckage of the bug it fixes.
//
// Real environments, not a fake: the thing under test is what an interpreter
// on disk reports about itself.
func TestAnUnsupportedEnvironmentIsNotReused(t *testing.T) {
py, _, err := findPython()
if err != nil {
t.Skipf("no supported Python on this machine: %v", err)
}
venv := filepath.Join(t.TempDir(), "runtime")
if err := makeVenv(py, venv); err != nil {
t.Fatalf("makeVenv: %v", err)
}
if ok, ver := venvUsable(venv); !ok {
t.Fatalf("an environment built from the interpreter setup just chose "+
"reported itself unusable (%s)", ver)
}
// The two states that must not be confused with a working one.
empty := filepath.Join(t.TempDir(), "gone")
if ok, _ := venvUsable(empty); ok {
t.Error("a missing environment was reported usable")
}
broken := filepath.Join(t.TempDir(), "broken")
if err := os.MkdirAll(filepath.Dir(venvPython(broken)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(venvPython(broken), []byte("not an interpreter"), 0o755); err != nil {
t.Fatal(err)
}
if ok, ver := venvUsable(broken); ok {
t.Errorf("a half-created environment was reported usable (%s)", ver)
}
}

Binary file not shown.

View File

@@ -3,7 +3,11 @@ module github.com/loyaly/behavision-agent
go 1.22
require (
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
github.com/eclipse/paho.mqtt.golang v1.4.3
golang.org/x/sys v0.20.0
)
require (
github.com/gorilla/websocket v1.5.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sync v0.1.0 // indirect

View File

@@ -6,3 +6,5 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -29,6 +29,7 @@ import (
"github.com/loyaly/behavision-agent/pkg/cameras"
"github.com/loyaly/behavision-agent/pkg/config"
"github.com/loyaly/behavision-agent/pkg/engine"
"github.com/loyaly/behavision-agent/pkg/enrol"
"github.com/loyaly/behavision-agent/pkg/mqtt"
"github.com/loyaly/behavision-agent/pkg/paths"
"github.com/loyaly/behavision-agent/pkg/spool"
@@ -38,8 +39,15 @@ var version = "dev"
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "behavision-agent %s\n\nusage: %s <run|status|paths>\n",
version, filepath.Base(os.Args[0]))
fmt.Fprintf(os.Stderr, `behavision-agent %s
usage: %s <command>
run supervise the engine and report to head office (default)
claim <code> link this PC to a shop, using an installation code
status what this PC is and whether it is claimed
paths where this install reads and writes
`, version, filepath.Base(os.Args[0]))
}
flag.Parse()
@@ -53,6 +61,8 @@ func main() {
err = cmdRun()
case "status":
err = cmdStatus()
case "claim":
err = cmdClaim(flag.Args()[1:])
case "paths":
err = cmdPaths()
default:
@@ -64,6 +74,74 @@ func main() {
}
}
// cmdClaim is the headless half of onboarding.
//
// The desktop app has had a Setup screen for this; a back-office PC with no
// window had nothing at all, so the only way to claim one was to hand-edit
// agent.json - which is the state that screen was built to end.
func cmdClaim(args []string) error {
if len(args) == 0 {
//lint:ignore ST1005 usage text read by a person, never wrapped
return fmt.Errorf("usage: behavision-agent claim <installation code>\n" +
"Ask whoever manages your shops for one - they can create it from\n" +
"the Behavision platform, under the shop.")
}
// Joined rather than requiring quotes: the code is printed in groups for
// reading aloud, and an operator pasting it will paste the spaces too.
code := strings.Join(args, "")
if err := paths.EnsureState(); err != nil {
return err
}
cfg, err := config.Load(paths.AgentConfig())
if err != nil {
return err
}
base := cfg.CloudBase
if v := os.Getenv("BEHAVISION_CLOUD"); v != "" {
base = v
}
if base == "" {
base = "https://mcp.loyaly.ai"
}
b, err := enrol.Claim(context.Background(), base, code)
if err != nil {
return err
}
// The slugs, not the uuids: the topic prefix is <client>.<site> and the
// broker's ACL is written against exactly that username.
cfg.ClientID = b.ClientSlug
cfg.SiteID = b.SiteSlug
cfg.SiteName = b.SiteName
cfg.BrokerURL = b.MQTTURL
cfg.BrokerUsername = b.MQTTUser
cfg.BrokerPassword = b.MQTTPass
cfg.AgentToken = b.AgentToken
cfg.CloudBase = base
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
if err != nil {
return err
}
cfg.BrokerCAFile = caPath
// A PC that was running on its own and has now been linked is no longer
// standalone.
cfg.Standalone = false
if err := cfg.Save(paths.AgentConfig()); err != nil {
// Reported, never swallowed: a claim that is not on disk works until
// the next restart and then silently is not claimed any more, which
// looks exactly like a wrong code.
return fmt.Errorf("could not save the settings: %w", err)
}
fmt.Printf("linked to %s (%s.%s)\n", b.SiteName, b.ClientSlug, b.SiteSlug)
fmt.Printf("settings written to %s\n", paths.AgentConfig())
fmt.Println("restart the agent for it to take effect.")
return nil
}
func cmdPaths() error {
return json.NewEncoder(os.Stdout).Encode(map[string]string{
"version": version,
@@ -84,6 +162,7 @@ func cmdStatus() error {
// which is the default. Reading it here is what stops every call the agent
// makes to the engine coming back 401 on a stock install.
cfg = cfg.WithEngineCredentials(paths.APICredentials())
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
if err != nil {
return err
@@ -92,7 +171,7 @@ func cmdStatus() error {
Command: func(context.Context) *exec.Cmd { return nil },
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
User: cfg.APIUser, Password: cfg.APIPassword,
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -127,6 +206,7 @@ func cmdRun() error {
// which is the default. Reading it here is what stops every call the agent
// makes to the engine coming back 401 on a stock install.
cfg = cfg.WithEngineCredentials(paths.APICredentials())
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
// Opened before the engine starts: detections arriving in the first second
// must have somewhere to land.
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
@@ -152,6 +232,14 @@ func cmdRun() error {
sup := engine.New(engine.Options{
Command: func(ctx context.Context) *exec.Cmd {
cmd := exec.CommandContext(ctx, exe, cfg.EngineArgs...)
// Run the engine FROM a known directory rather than from whatever
// happened to launch us. A double-clicked bundle hands its child
// "/", and an engine invoked as `-m behavision` then cannot find
// itself - measured on macOS, where it retried forever.
cmd.Dir = cfg.EngineDir
if cmd.Dir == "" {
cmd.Dir = paths.InstallRoot()
}
// How the engine learns where to send detections. The engine's
// config already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`
// and python-dotenv does not override a variable the process
@@ -161,13 +249,13 @@ func cmdRun() error {
// nothing - the URL was returned, logged and even exposed on the
// desktop's status object, and never actually given to the engine.
// A claimed shop PC published heartbeats and zero visits.
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL)
cmd.Env = engine.ChildEnv(hookURL)
return cmd
},
LogWriter: logFile,
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
User: cfg.APIUser, Password: cfg.APIPassword,
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
})
ctx, stop := signal.NotifyContext(context.Background(),
@@ -202,7 +290,11 @@ func cmdRun() error {
cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken)
cloud.Upload = uploader.UploadBytes
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
eng.Creds = creds
go cameras.New(eng, cloud, logger).Run(ctx)
// The live relay, which uploads nothing until somebody at head office is
// actually watching a camera.
go cameras.NewLive(eng, cloud, logger).Run(ctx)
// Before the engine starts, so the engine can be launched already knowing
// where to post its detections.
@@ -233,7 +325,7 @@ func cmdRun() error {
cfg.ClientID, cfg.SiteID, cfg.BrokerURL)
client, err := mqtt.NewClient(mqtt.ClientOptions{
BrokerURL: cfg.BrokerURL,
ClientID: "behavision-" + cfg.ClientID + "-" + cfg.SiteID,
ClientID: cfg.MQTTClientID(),
Username: cfg.BrokerUsername, Password: cfg.BrokerPassword,
CAFile: cfg.BrokerCAFile, Log: logger,
})

View File

@@ -106,6 +106,18 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string,
}
target, err := u.target(ctx)
if errors.Is(err, ErrImagesOff) {
// No object storage on this server. Send the bytes to the API itself,
// which holds them for a deployment that has no bucket - the same
// fallback camera snapshots already take, and chosen by the SENTINEL
// rather than by matching the message, because a prose change would
// otherwise silently stop every photo in the estate.
//
// Only after target() has spoken. The unclaimed case returns the same
// sentinel from the guard at the top of this function, and a PC with no
// credentials has no server to PUT to either.
return u.uploadDirect(ctx, body)
}
if err != nil {
return "", err
}
@@ -135,6 +147,54 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string,
return target.Key, nil
}
// uploadDirect posts the image to our own API, for a deployment with no bucket.
//
// Deliberately the second choice. A presigned PUT never passes a photograph
// through the server at all, which is what makes it the right route wherever
// object storage exists; this one is what stops "no S3 account" from meaning
// "no customer photo, ever" on every local install and every self-hosted site.
//
// The server decides where it lands and returns the key, exactly as the
// presigned route does. That symmetry is the point: the caller cannot tell
// which route ran, so the queued visit, the read path and erasure all stay
// single implementations.
func (u *SpacesUploader) uploadDirect(ctx context.Context, body []byte) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(u.BaseURL, "/")+"/api/agent/faces", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+u.Token)
req.Header.Set("Content-Type", "image/jpeg")
req.ContentLength = int64(len(body))
resp, err := u.httpClient().Do(req)
if err != nil {
return "", fmt.Errorf("upload face: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotImplemented {
// This server stores no images at all. Stop trying rather than retry
// every visitor forever.
return "", ErrImagesOff
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return "", fmt.Errorf("upload face returned %s: %s",
resp.Status, strings.TrimSpace(string(msg)))
}
var out struct {
Key string `json:"key"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<10)).Decode(&out); err != nil {
return "", err
}
if out.Key == "" {
return "", errors.New("server stored the face but named no key for it")
}
return out.Key, nil
}
func (u *SpacesUploader) target(ctx context.Context) (uploadTarget, error) {
var out uploadTarget
req, err := http.NewRequestWithContext(ctx, http.MethodPost,

View File

@@ -1,6 +1,7 @@
package bridge
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -200,3 +201,80 @@ func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) {
t.Fatal("the local image was left on disk")
}
}
// A deployment with no object storage must still get a photo onto the customer
// record. Until the fallback existed, `images_disabled` meant every local
// install and every self-hosted site showed no face for anybody, forever.
func TestNoBucketFallsBackToTheServer(t *testing.T) {
var askedURL, postedFace bool
var gotBody []byte
var gotAuth, gotType string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/agent/upload-url":
askedURL = true
// What a server with no bucket answers.
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"error":"images_disabled"}`))
case "/api/agent/faces":
postedFace = true
gotAuth = r.Header.Get("Authorization")
gotType = r.Header.Get("Content-Type")
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"key":"db:11111111-1111-4111-8111-111111111111"}`))
default:
t.Errorf("unexpected request to %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
img := []byte{0xFF, 0xD8, 0xFF, 0xE0, 'x', 'y', 'z'}
key, err := u.UploadBytes(context.Background(), img)
if err != nil {
t.Fatalf("upload: %v", err)
}
if !askedURL {
t.Error("the presigned route must be tried first - it is the right one where a bucket exists")
}
if !postedFace {
t.Fatal("no fallback upload was made")
}
if !strings.HasPrefix(key, "db:") {
t.Errorf("want the server's own key, got %q", key)
}
if !bytes.Equal(gotBody, img) {
t.Error("the bytes sent are not the bytes given")
}
if gotAuth != "Bearer agent-token" || gotType != "image/jpeg" {
t.Errorf("auth %q type %q", gotAuth, gotType)
}
}
// A server that stores no images AT ALL must stop the agent trying, rather than
// have it retry every visitor forever. Distinct from a failure, which is why
// it is a sentinel and not a message.
func TestAServerThatStoresNothingSaysSoOnce(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}))
defer srv.Close()
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
_, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8, 0xFF, 0xE0})
if !errors.Is(err, ErrImagesOff) {
t.Fatalf("want ErrImagesOff so the caller stops trying, got %v", err)
}
}
// An unclaimed PC has no server to send anything to. The fallback must not fire
// there - it would be a request to nowhere on every single visit.
func TestAnUnclaimedAgentDoesNotTryToUpload(t *testing.T) {
u := &SpacesUploader{} // no BaseURL, no token
if _, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8}); !errors.Is(err, ErrImagesOff) {
t.Fatalf("want ErrImagesOff, got %v", err)
}
}

View File

@@ -29,7 +29,14 @@ type Engine interface {
type Cloud interface {
Desired(ctx context.Context) ([]Desired, error)
Report(ctx context.Context, rep Report) error
// UploadSnapshot puts a JPEG in object storage and names it. Used for the
// placement check's proof picture, which is transient.
UploadSnapshot(ctx context.Context, jpeg []byte) (key string, err error)
// PutSnapshot gets a camera's latest frame to head office by whichever
// route this deployment has - the bucket, or the server itself when there
// is none. An empty key means the server already stored it, so the state
// report has nothing to carry.
PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (key string, err error)
}
// Local is a camera as the engine holds it.
@@ -48,6 +55,9 @@ type Local struct {
// Desired is a camera as head office holds it.
type Desired struct {
// ID is head office's uuid for this camera; CameraID is the name the
// engine on this PC knows it by. The live relay translates between them.
ID string `json:"id"`
CameraID string `json:"camera_id"`
Label string `json:"label"`
Host string `json:"host"`
@@ -252,7 +262,9 @@ func (s *Syncer) reportWith(ctx context.Context, adopt []Desired) {
// camera is down matters far more than having a picture of it,
// and the picture is the part most likely to fail.
if jpeg, err := s.Engine.Snapshot(ctx, c.ID); err == nil && len(jpeg) > 0 {
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
// An empty key is not a failure: it means this deployment has
// no object storage and the server stored the picture itself.
if key, err := s.Cloud.PutSnapshot(ctx, c.ID, jpeg); err == nil {
st.SnapshotKey = key
} else {
s.logf("camera %s: snapshot upload failed: %v", c.ID, err)

View File

@@ -59,6 +59,10 @@ type fakeCloud struct {
reports []Report
uploads int
uploadErr error
// direct records the cameras whose picture went to the server itself
// rather than to object storage.
direct []string
directOnly bool
}
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
@@ -68,6 +72,20 @@ func (f *fakeCloud) Report(_ context.Context, r Report) error {
f.reports = append(f.reports, r)
return nil
}
// PutSnapshot mirrors the real client: the bucket when there is one, the
// server itself when there is not.
func (f *fakeCloud) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
if f.directOnly {
if f.uploadErr != nil {
return "", f.uploadErr
}
f.direct = append(f.direct, cameraID)
return "", nil
}
return f.UploadSnapshot(ctx, jpeg)
}
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
if f.uploadErr != nil {
return "", f.uploadErr
@@ -239,3 +257,28 @@ func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
t.Fatal("reported state it could not have observed")
}
}
// A deployment with no object storage must still get its picture to head
// office. Before this, the camera screen said "This system is not storing
// images" for every camera, forever - on the one screen whose entire job is to
// show the camera.
func TestASnapshotStillReachesHeadOfficeWithNoObjectStorage(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: true})
c := &fakeCloud{directOnly: true}
syncer(e, c).Once(context.Background())
if len(c.direct) != 1 || c.direct[0] != "entrance" {
t.Fatalf("the picture did not reach the server: %v", c.direct)
}
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
t.Fatalf("no state was reported: %+v", c.reports)
}
// Empty, and that is the point: there is no object to name. A key here
// would have head office try to presign a bucket it does not have.
if key := c.reports[0].State[0].SnapshotKey; key != "" {
t.Fatalf("the direct route reported an object key %q", key)
}
if !c.reports[0].State[0].Connected {
t.Error("connected state was lost")
}
}

View File

@@ -4,12 +4,17 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/loyaly/behavision-agent/pkg/bridge"
"github.com/loyaly/behavision-agent/pkg/config"
)
// EngineClient talks to the recognition engine on this PC's loopback.
@@ -17,7 +22,12 @@ type EngineClient struct {
Base string
User string
Password string
Client *http.Client
// Creds re-reads the engine's generated credential when one is rejected.
// Without it a fresh install is 401 for the life of the process: the agent
// starts the engine, and the engine writes its credential file seconds
// after the agent has already read (and failed to find) it.
Creds *config.Creds
Client *http.Client
}
func NewEngineClient(base, user, password string) *EngineClient {
@@ -46,14 +56,24 @@ func (e *EngineClient) do(ctx context.Context, method, path string, body, out an
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if e.User != "" {
req.SetBasicAuth(e.User, e.Password)
user, pass := e.User, e.Password
if e.Creds != nil {
user, pass = e.Creds.Get()
}
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := e.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized && e.Creds != nil && e.Creds.Refresh() {
// The engine generated its credential after we last looked. Read it
// and try once more rather than failing for the life of the process.
resp.Body.Close()
return e.do(ctx, method, path, body, out)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The engine's message, not just a status. "camera stored but failed to
// start: connection refused" is something an operator can act on;
@@ -95,8 +115,29 @@ func (e *EngineClient) Remove(ctx context.Context, id string) error {
// trip. A camera that has not produced a frame yet answers 503, which is a
// normal state on a just-added camera and not an error worth logging loudly.
func (e *EngineClient) Snapshot(ctx context.Context, id string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
e.Base+"/api/cameras/"+url.PathEscape(id)+"/frame.jpg", nil)
return e.Frame(ctx, id, 0, 0)
}
// Frame fetches the latest frame, optionally re-encoded smaller.
//
// The live relay asks for ~640 px at quality 60 - about a third the bytes of
// the full frame - because it sends several a second up a shop's uplink, where
// the snapshot sends one a minute and can afford the detail. The engine does
// the re-encode: it already has OpenCV open and the frame in memory, and
// shipping a scaler into the agent to redo that would be the same work twice.
func (e *EngineClient) Frame(ctx context.Context, id string, width, quality int) ([]byte, error) {
q := url.Values{}
if width > 0 {
q.Set("width", strconv.Itoa(width))
}
if quality > 0 {
q.Set("quality", strconv.Itoa(quality))
}
target := e.Base + "/api/cameras/" + url.PathEscape(id) + "/frame.jpg"
if len(q) > 0 {
target += "?" + q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
@@ -192,6 +233,64 @@ func (c *CloudClient) UploadSnapshot(ctx context.Context, jpeg []byte) (string,
return c.Upload(ctx, jpeg)
}
// PutSnapshot gets a camera's latest frame to head office by whichever route
// that deployment has.
//
// The bucket first: a presigned PUT goes straight to object storage and never
// passes through the API, which is what makes it the right route at estate
// scale. When there is no bucket the picture goes to the server itself, which
// stores one row per camera. Without this second route head office reported
// "This system is not storing images" for every camera forever, on the screen
// whose entire job is to show the camera.
//
// The returned key is empty for the direct route - there is no object to name -
// and the server records the picture as it stores it, so the state report has
// nothing to carry.
func (c *CloudClient) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
if c.Upload != nil {
key, err := c.Upload(ctx, jpeg)
if err == nil {
return key, nil
}
// A bucket that is configured here but disabled at the server is the
// ordinary case on a self-hosted install: fall through rather than
// giving up, and let the direct route decide.
if !isImagesDisabled(err) {
return "", err
}
}
return "", c.putSnapshotDirect(ctx, cameraID, jpeg)
}
func (c *CloudClient) putSnapshotDirect(ctx context.Context, cameraID string, jpeg []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
c.Base+"/api/agent/cameras/"+url.PathEscape(cameraID)+"/snapshot",
bytes.NewReader(jpeg))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "image/jpeg")
resp, err := c.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("head office: %s", resp.Status)
}
return nil
}
// isImagesDisabled recognises the server saying it has no object storage.
//
// A sentinel, not a string match on the message: this decides whether to take a
// completely different route, and getting it wrong from prose that somebody
// later rewords would silently stop every camera picture in the estate.
func isImagesDisabled(err error) bool {
return errors.Is(err, bridge.ErrImagesOff)
}
// ---------------------------------------------------------------- probing
// Test opens the candidate stream once, without saving it.

242
agent/pkg/cameras/live.go Normal file
View File

@@ -0,0 +1,242 @@
package cameras
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
)
// Live relays camera frames to head office, but only while somebody is
// watching.
//
// The engine serves MJPEG on this PC's loopback and this PC sits behind a
// router with no inbound route, so head office cannot pull it. It can answer
// our outbound requests, which is the shape of everything else here: we ask
// "is anyone watching?", and push frames for as long as the answer is yes.
//
// It is a few frames a second of re-encoded JPEG, not 25 fps video. True video
// needs WebRTC and a TURN server; this needs neither, and answers the question
// somebody at head office is actually asking - what does that camera see right
// now - at a cost a shop's uplink can carry.
//
// **Nothing is uploaded when nobody is looking.** That is the entire cost
// argument, and it is why the wanted-check comes first and the push stops the
// moment the server says the last viewer has gone.
type Live struct {
Engine *EngineClient
Cloud *CloudClient
Log *log.Logger
FPS float64
Width int
Quality int
}
// Defaults, measured against the office camera rather than guessed.
//
// The engine produces ~12 distinct frames a second, so asking for more than
// that only re-sends pictures the viewer already has - which is why the poll
// runs slightly ahead of it and identical frames are dropped rather than sent.
// 640 px at quality 60 is ~20 KB, so a watcher costs ~200 KB/s at the full
// rate, and a camera nobody is watching costs nothing at all.
const (
DefaultLiveFPS = 15.0
DefaultLiveWidth = 640
DefaultLiveQuality = 60
)
func NewLive(eng *EngineClient, cloud *CloudClient, logger *log.Logger) *Live {
return &Live{Engine: eng, Cloud: cloud, Log: logger,
FPS: DefaultLiveFPS, Width: DefaultLiveWidth, Quality: DefaultLiveQuality}
}
// Run waits for viewers and serves them until the context ends.
func (l *Live) Run(ctx context.Context) {
if l.Engine == nil || l.Cloud == nil {
return
}
for {
if ctx.Err() != nil {
return
}
wanted, err := l.Cloud.LiveWanted(ctx)
if err != nil {
// Unclaimed, offline, or head office is down. All three mean the
// same thing here - nobody can be watching - so back off rather
// than hammering, and keep the shop's own recognition untouched.
if ctx.Err() != nil {
return
}
l.sleep(ctx, 15*time.Second)
continue
}
if len(wanted) == 0 {
// The poll is held open by the server, so an empty answer already
// means ~25 s passed. No extra delay.
continue
}
for _, id := range wanted {
if ctx.Err() != nil {
return
}
l.serve(ctx, id)
}
}
}
// serve pushes frames for one camera until the server says stop.
func (l *Live) serve(ctx context.Context, cameraID string) {
engineID, err := l.Cloud.LiveEngineID(ctx, cameraID)
if err != nil {
l.logf("live %s: %v", cameraID, err)
l.sleep(ctx, 2*time.Second)
return
}
interval := time.Duration(float64(time.Second) / l.fps())
// A pipe so frames can be written as they are grabbed while one request
// carries all of them. A request per frame would spend more on handshakes
// and headers than on pictures.
pr, pw := io.Pipe()
done := make(chan error, 1)
go func() { done <- l.Cloud.PushLive(ctx, cameraID, pr) }()
tick := time.NewTicker(interval)
defer tick.Stop()
// The engine re-serves its latest frame until the pipeline produces a new
// one, so polling faster than it encodes returns the SAME picture again.
// Measured: 93 polls in 6 s yielded 72 distinct frames. Sending the
// duplicates would cost a fifth of the bandwidth for nothing, so the poll
// runs a little ahead of the engine and the repeats are dropped - which is
// what lets the rate follow the camera instead of a guess.
var lastSum [32]byte
for {
select {
case <-ctx.Done():
_ = pw.CloseWithError(context.Canceled)
<-done
return
case err := <-done:
// The server closed the request: the last viewer went away, or the
// session cap was reached. Either way stop grabbing frames.
_ = pw.Close()
if err != nil {
l.logf("live %s ended: %v", cameraID, err)
}
return
case <-tick.C:
}
jpeg, err := l.Engine.Frame(ctx, engineID, l.Width, l.Quality)
if err != nil || len(jpeg) == 0 {
// A camera that is reconnecting has no frame. Keep the request
// open - the viewer sees the last frame rather than a dropped
// stream, and the next tick may well have one.
continue
}
if sum := sha256.Sum256(jpeg); sum == lastSum {
continue
} else {
lastSum = sum
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(jpeg)))
if _, err := pw.Write(hdr[:]); err != nil {
<-done
return
}
if _, err := pw.Write(jpeg); err != nil {
<-done
return
}
}
}
func (l *Live) fps() float64 {
if l.FPS <= 0 || l.FPS > 25 {
// A ceiling rather than a target: duplicate frames are dropped, so
// polling above what the engine encodes costs requests and no
// bandwidth - but it is still work, on the PC doing the recognition.
return DefaultLiveFPS
}
return l.FPS
}
func (l *Live) sleep(ctx context.Context, d time.Duration) {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
}
func (l *Live) logf(format string, args ...any) {
if l.Log != nil {
l.Log.Printf(format, args...)
}
}
// ------------------------------------------------------------------ wire --
// LiveWanted asks head office which of this site's cameras are being watched.
// The server holds the request open, so this returns promptly when somebody
// presses Live and after ~25 s when nobody has.
func (c *CloudClient) LiveWanted(ctx context.Context) ([]string, error) {
var body struct {
Cameras []string `json:"cameras"`
}
// Longer than the server's own wait, so a held request is not cut off by
// our own client timeout and reported as a failure.
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
if err := c.do(ctx, http.MethodGet, "/api/agent/live", nil, &body); err != nil {
return nil, err
}
return body.Cameras, nil
}
// LiveEngineID maps head office's camera uuid to the name the engine knows,
// which is the only name this PC can ask for a frame with.
func (c *CloudClient) LiveEngineID(ctx context.Context, cameraID string) (string, error) {
desired, err := c.Desired(ctx)
if err != nil {
return "", err
}
for _, d := range desired {
if d.ID == cameraID {
return d.CameraID, nil
}
}
return "", fmt.Errorf("camera %s is not one of this site's", cameraID)
}
// PushLive streams frames until the server stops reading.
func (c *CloudClient) PushLive(ctx context.Context, cameraID string, body io.Reader) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.Base+"/api/agent/cameras/"+cameraID+"/live", body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := c.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("head office: %s: %s", resp.Status, bytes.TrimSpace(blob))
}
var out struct {
Frames int `json:"frames"`
}
_ = json.Unmarshal(blob, &out)
return nil
}

View File

@@ -8,7 +8,9 @@
package config
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
@@ -37,7 +39,18 @@ type Config struct {
BrokerCAFile string `json:"broker_ca_file"`
// Engine process.
EngineExe string `json:"engine_exe"`
EngineExe string `json:"engine_exe"`
// EngineDir is the working directory the engine is launched IN. Empty
// means the install root.
//
// It exists because nothing set it, so the engine inherited whatever
// launched the app - and for an app started by double-clicking its
// bundle that is "/", not anywhere useful. The symptom on macOS was
// `python: No module named behavision` repeating forever: the dev engine
// is `-m behavision`, which resolves against the working directory. The
// same app started from a terminal in the repo worked, which is exactly
// the shape of a bug that survives every test run by a developer.
EngineDir string `json:"engine_dir,omitempty"`
EngineArgs []string `json:"engine_args"`
APIBase string `json:"api_base"`
APIUser string `json:"api_user"`
@@ -72,6 +85,10 @@ type Config struct {
// Queue.
SpoolMax int `json:"spool_max"`
// InstallID distinguishes THIS installation from every other one claimed
// to the same site. See MQTTClientID.
InstallID string `json:"install_id,omitempty"`
path string
}
@@ -113,6 +130,14 @@ func Load(path string) (Config, error) {
return cfg, fmt.Errorf("config %s: %w", path, err)
}
cfg.path = path
// Minted on first load and written back, so an installation that predates
// this field gets one without anybody doing anything. Best effort: a
// read-only config still yields a working id for this run, it is simply
// not the same one next time.
if cfg.InstallID == "" {
cfg.InstallID = newInstallID()
_ = cfg.Save(path)
}
for _, field := range []*string{&cfg.BrokerPassword, &cfg.APIPassword,
&cfg.SessionToken, &cfg.SessionRefresh, &cfg.AgentToken} {
plain, err := reveal(*field)
@@ -199,3 +224,41 @@ func reveal(stored string) (string, error) {
}
return string(plain), nil
}
// MQTTClientID names this INSTALLATION, not this site.
//
// It was `behavision-<client>-<site>`, which is the same string on every
// computer claimed to one shop. MQTT requires client ids to be unique and a
// broker enforces it by disconnecting the older session when a new one
// arrives with the same id - so two machines on one site take turns kicking
// each other off, forever. Measured on a second Mac claimed to a live shop:
//
// broker connected / broker connection lost: EOF / broker connected / ...
//
// The damage is not confined to the new machine. The shop's own till is the
// other half of that loop, so somebody signing in on a laptop to look at the
// product stops the shop delivering visits - and nothing at either end says
// why, because from each side it reads as an unstable network.
//
// The site stays in the id because it is what a broker log is read by, and
// the random half is short for the same reason. `CleanSession(true)` means
// there is no session state for a changed id to strand.
func (c Config) MQTTClientID() string {
id := c.InstallID
if id == "" {
// A config that could not be written still has to produce a UNIQUE
// id, or this falls straight back into the collision it exists to
// prevent. Per-run is the right failure: the connection works and the
// only cost is a new name in the broker's log after a restart.
id = newInstallID()
}
return "behavision-" + c.ClientID + "-" + c.SiteID + "-" + id
}
func newInstallID() string {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
return "x"
}
return hex.EncodeToString(b)
}

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"os"
"strings"
"sync"
)
// EngineCredentials reads the Basic credentials the engine generated for
@@ -60,3 +61,60 @@ func (c Config) WithEngineCredentials(path string) Config {
c.APIUser, c.APIPassword = EngineCredentials(path)
return c
}
// Creds resolves the engine's Basic credentials, re-reading the file when it
// has none.
//
// Reading once at startup is wrong on a fresh install, and that is the case
// that matters: the agent starts the engine, the engine generates its
// credential and writes the file a few seconds later, and an agent that read
// the file before that holds "" forever. Every call it makes - health, stats,
// camera sync, the embedding for a visit - then comes back 401 for the life of
// the process, on a brand new shop PC, with the tray showing a red engine that
// is running perfectly. Measured on a fresh state directory: three 401s and no
// camera ever reconciled.
//
// A configured credential is never re-read: an operator who set
// BEHAVISION_API_USER means it.
type Creds struct {
path string
mu sync.Mutex
user string
pass string
fixed bool
}
// NewCreds takes whatever the config already has. Non-empty means configured,
// and is used unchanged.
func NewCreds(path, user, password string) *Creds {
c := &Creds{path: path, user: user, pass: password}
c.fixed = user != "" || password != ""
return c
}
// Get returns the current pair, reading the file if it has nothing yet.
func (c *Creds) Get() (string, string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.user == "" && !c.fixed {
c.user, c.pass = EngineCredentials(c.path)
}
return c.user, c.pass
}
// Refresh re-reads the file after a rejection and reports whether the pair
// changed. Callers retry once when it did - which covers both the fresh-install
// race and a credential the engine regenerated under a running agent.
func (c *Creds) Refresh() bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.fixed {
return false
}
u, p := EngineCredentials(c.path)
if u == c.user && p == c.pass {
return false
}
c.user, c.pass = u, p
return u != ""
}

View File

@@ -0,0 +1,77 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// The sequence on a brand new shop PC, in order:
//
// agent starts -> file does not exist yet
// agent starts the engine
// engine generates its credential and writes the file
// agent calls the engine -> must now succeed
//
// Read once at startup, the agent holds "" for the life of the process and
// every engine call is 401: health, stats, camera sync, the embedding for a
// visit. The tray shows a red engine that is running perfectly, and nothing
// says why. Measured on a fresh state directory before this existed.
func TestCredentialsArriveAfterTheAgentHasAlreadyLooked(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "api_credentials.txt")
creds := NewCreds(path, "", "") // nothing configured, file not there yet
if u, _ := creds.Get(); u != "" {
t.Fatalf("expected no credential before the engine has written one, got %q", u)
}
// the engine starts and writes its credential
if err := os.WriteFile(path, []byte("username=behavision\npassword=s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
// a 401 makes the agent look again
if !creds.Refresh() {
t.Fatal("Refresh did not pick up the credential the engine just wrote")
}
u, p := creds.Get()
if u != "behavision" || p != "s3cret" {
t.Fatalf("got %q/%q", u, p)
}
}
// An operator who set BEHAVISION_API_USER means it, and a file must never
// override them.
func TestAConfiguredCredentialIsNeverReplacedByTheFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "api_credentials.txt")
if err := os.WriteFile(path, []byte("username=generated\npassword=nope\n"), 0o600); err != nil {
t.Fatal(err)
}
creds := NewCreds(path, "chosen", "byhand")
if u, p := creds.Get(); u != "chosen" || p != "byhand" {
t.Fatalf("configured credential was replaced: %q/%q", u, p)
}
if creds.Refresh() {
t.Fatal("Refresh overrode a configured credential")
}
}
// A credential the engine regenerates under a running agent is picked up too -
// the same mechanism, and the reason paths.APICredentials says the agent reads
// the file "rather than storing a second copy".
func TestARegeneratedCredentialIsPickedUp(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "api_credentials.txt")
os.WriteFile(path, []byte("username=behavision\npassword=old\n"), 0o600)
creds := NewCreds(path, "", "")
creds.Get()
os.WriteFile(path, []byte("username=behavision\npassword=new\n"), 0o600)
if !creds.Refresh() {
t.Fatal("a regenerated password was not picked up")
}
if _, p := creds.Get(); p != "new" {
t.Fatalf("still holding %q", p)
}
}

View File

@@ -0,0 +1,88 @@
package config
import (
"path/filepath"
"strings"
"testing"
)
// The bug this exists to prevent, measured on a second Mac claimed to a live
// shop: MQTT requires client ids to be unique, and a broker enforces it by
// disconnecting the older session when a new one arrives with the same id. The
// id was `behavision-<client>-<site>` - identical on every computer claimed to
// one shop - so the two took turns kicking each other off:
//
// broker connected / broker connection lost: EOF / broker connected / ...
//
// The damage is not confined to the new machine. The shop's own till is the
// other half of that loop, so somebody signing in on a laptop to look at the
// product stops the shop delivering visits.
func TestTwoInstallsOnOneSiteGetDifferentClientIDs(t *testing.T) {
dir := t.TempDir()
one := writeClaimed(t, filepath.Join(dir, "a.json"))
two := writeClaimed(t, filepath.Join(dir, "b.json"))
if one.MQTTClientID() == two.MQTTClientID() {
t.Fatalf("both installs answered to %q; the broker will disconnect one "+
"whenever the other connects", one.MQTTClientID())
}
}
// And the same install keeps its name across restarts, or a broker log is a
// list of strangers and nobody can tell one till from a stream of new ones.
func TestOneInstallKeepsItsClientIDAcrossRestarts(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.json")
first := writeClaimed(t, path)
again, err := Load(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if got, want := again.MQTTClientID(), first.MQTTClientID(); got != want {
t.Errorf("after a restart the id was %q, want %q", got, want)
}
}
// The site stays in the id: it is what somebody reading a broker log is
// reading FOR, and an opaque random string would make every connection
// anonymous.
func TestTheClientIDStillNamesTheShop(t *testing.T) {
c := Config{ClientID: "tenext-retail", SiteID: "chennai", InstallID: "abcd1234"}
id := c.MQTTClientID()
for _, want := range []string{"tenext-retail", "chennai", "abcd1234"} {
if !strings.Contains(id, want) {
t.Errorf("client id %q does not contain %q", id, want)
}
}
}
// A config that could not be written still has to produce a UNIQUE id, or a
// read-only install falls straight back into the collision. Per-run is the
// right failure: the connection works, and the only cost is a new name in the
// broker's log after a restart.
func TestAnUnsavedConfigStillGetsAUniqueID(t *testing.T) {
a := Config{ClientID: "c", SiteID: "s"}
b := Config{ClientID: "c", SiteID: "s"}
if a.MQTTClientID() == b.MQTTClientID() {
t.Fatal("two configs with no install id produced the same client id")
}
}
func writeClaimed(t *testing.T, path string) Config {
t.Helper()
cfg := Defaults()
cfg.ClientID, cfg.SiteID = "tenext-retail", "chennai"
if err := cfg.Save(path); err != nil {
t.Fatalf("save: %v", err)
}
// Loading is what mints the id, so an installation that predates the
// field gets one without anybody doing anything.
got, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if got.InstallID == "" {
t.Fatal("loading a config without an install id did not mint one")
}
return got
}

140
agent/pkg/demo/bundle.go Normal file
View File

@@ -0,0 +1,140 @@
// Package demo seals a camera list so a release can carry it without carrying
// the credentials in any usable form.
//
// The need: a demo build that installs with the office cameras already set up,
// handed to people who should not be able to read the cameras' admin password
// out of the zip. "Encode it" does not do that - anything the installer can
// decode, anyone holding the installer can decode. So the bundle is encrypted
// with a key that is NOT in the package: a short unlock code, generated when
// the bundle is sealed, spoken or messaged to whoever runs setup, and typed
// once. Without it the file is noise.
//
// The code is random, not chosen, so it is used as key material directly
// (through SHA-256) rather than stretched with a KDF. A human-chosen
// passphrase would need argon2 and a dependency; 120 random bits do not.
package demo
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Magic identifies the file and the format version, so a future change can be
// told apart from corruption instead of failing as "authentication failed".
const magic = "BVDEMO1\n"
// Payload is what a sealed bundle carries. Two demo shapes exist:
//
// - cameras only: the PC runs on its own with these cameras (the first
// demo build);
// - an enrolment code: the PC claims a real shop at head office and gets
// its cameras from there, exactly as a customer install would, so the
// demo exercises the whole product rather than a local copy of it. The
// code is single-use, so one bundle is one install.
//
// A bundle from the first build is a bare JSON array; Decode accepts both.
type Payload struct {
Cameras []Camera `json:"cameras,omitempty"`
EnrolCode string `json:"enrol_code,omitempty"`
CloudBase string `json:"cloud_base,omitempty"`
}
// Decode reads either payload shape.
func Decode(plain []byte) (Payload, error) {
var p Payload
if len(plain) > 0 && plain[0] == '[' {
return p, json.Unmarshal(plain, &p.Cameras)
}
return p, json.Unmarshal(plain, &p)
}
// Camera is one entry as the engine's Add Camera endpoint accepts it.
type Camera struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
Username string `json:"username"`
Password string `json:"password"`
MaxWidth int `json:"max_width,omitempty"`
}
// NewCode mints an unlock code: 15 random bytes as 24 base32 characters in
// four groups, the same shape as an installation code, for the same reason -
// it gets read down a phone.
func NewCode() (string, error) {
raw := make([]byte, 15)
if _, err := rand.Read(raw); err != nil {
return "", err
}
s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
return fmt.Sprintf("%s-%s-%s-%s", s[0:6], s[6:12], s[12:18], s[18:24]), nil
}
// NormalizeCode makes the typed and the printed form hash the same: case,
// spaces and dashes are all noise a person adds or drops.
func NormalizeCode(code string) string {
code = strings.ToUpper(code)
code = strings.NewReplacer("-", "", " ", "", "\t", "", "\r", "", "\n", "").Replace(code)
return code
}
func keyFor(code string) []byte {
sum := sha256.Sum256([]byte("behavision-demo-bundle:" + NormalizeCode(code)))
return sum[:]
}
// Seal encrypts plaintext under the code. Output is magic || nonce || ciphertext.
func Seal(code string, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(keyFor(code))
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
out := append([]byte(magic), nonce...)
return gcm.Seal(out, nonce, plaintext, []byte(magic)), nil
}
// ErrWrongCode is what a mistyped code looks like. GCM cannot tell a wrong key
// from a corrupted file, and neither can we, so both read as this.
var ErrWrongCode = errors.New("that unlock code does not open this bundle")
// Open decrypts a sealed bundle.
func Open(code string, sealed []byte) ([]byte, error) {
if !strings.HasPrefix(string(sealed), magic) {
return nil, errors.New("not a Behavision demo bundle")
}
body := sealed[len(magic):]
block, err := aes.NewCipher(keyFor(code))
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(body) < gcm.NonceSize() {
return nil, errors.New("bundle is truncated")
}
nonce, ct := body[:gcm.NonceSize()], body[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, []byte(magic))
if err != nil {
return nil, ErrWrongCode
}
return plain, nil
}

View File

@@ -0,0 +1,87 @@
package demo
import (
"bytes"
"errors"
"strings"
"testing"
)
func TestSealedBundleRoundTripsWithTheCodeAsTyped(t *testing.T) {
code, err := NewCode()
if err != nil {
t.Fatal(err)
}
if len(NormalizeCode(code)) != 24 {
t.Fatalf("code should be 24 base32 chars, got %q", code)
}
secret := []byte(`[{"id":"cam1","password":"the-camera-admin-password"}]`)
sealed, err := Seal(code, secret)
if err != nil {
t.Fatal(err)
}
// People type codes in lower case, with the dashes dropped, with a space
// where a dash was. All of those are the same code.
for _, typed := range []string{
code,
strings.ToLower(code),
strings.ReplaceAll(code, "-", ""),
strings.ReplaceAll(code, "-", " "),
" " + code + "\n",
} {
got, err := Open(typed, sealed)
if err != nil {
t.Fatalf("open with %q: %v", typed, err)
}
if !bytes.Equal(got, secret) {
t.Fatalf("round trip changed the contents")
}
}
}
// The whole point of the file: the password is not in it.
func TestTheSealedFileDoesNotContainTheSecret(t *testing.T) {
code, _ := NewCode()
sealed, _ := Seal(code, []byte(`{"password":"the-camera-admin-password","host":"192.168.1.121"}`))
for _, leak := range []string{"the-camera-admin-password", "192.168.1.121", "password"} {
if bytes.Contains(sealed, []byte(leak)) {
t.Fatalf("sealed bundle contains %q in the clear", leak)
}
}
}
func TestAWrongCodeIsRefusedNotMisread(t *testing.T) {
code, _ := NewCode()
other, _ := NewCode()
sealed, _ := Seal(code, []byte("secret"))
if _, err := Open(other, sealed); !errors.Is(err, ErrWrongCode) {
t.Fatalf("a different code should be ErrWrongCode, got %v", err)
}
// One flipped byte in the ciphertext is the same answer: GCM refuses
// rather than returning garbage that then gets written into cameras.json.
tampered := append([]byte{}, sealed...)
tampered[len(tampered)-1] ^= 0x01
if _, err := Open(code, tampered); !errors.Is(err, ErrWrongCode) {
t.Fatalf("a tampered bundle should be refused, got %v", err)
}
}
func TestSomethingThatIsNotABundleSaysSo(t *testing.T) {
if _, err := Open("ABCDEF-GHIJKL-MNOPQR-STUVWX", []byte("hello")); err == nil ||
errors.Is(err, ErrWrongCode) {
t.Fatalf("a non-bundle should be named as such, not blamed on the code: %v", err)
}
}
// Two seals of the same plaintext under the same code must differ: a fixed
// nonce would let two releases' bundles be compared byte for byte.
func TestEverySealIsDifferent(t *testing.T) {
code, _ := NewCode()
a, _ := Seal(code, []byte("same"))
b, _ := Seal(code, []byte("same"))
if bytes.Equal(a, b) {
t.Fatal("nonce is not random")
}
}

41
agent/pkg/engine/env.go Normal file
View File

@@ -0,0 +1,41 @@
package engine
import (
"os"
"github.com/loyaly/behavision-agent/pkg/paths"
)
// ChildEnv is the environment the engine is launched with, wherever it is
// launched from - the desktop app and the headless agent both go through
// here, so a third caller cannot get it half right.
//
// The line that matters is BEHAVISION_DATA_DIR.
//
// The engine's paths.py knows two worlds: frozen with PyInstaller, where state
// lives under ProgramData, and a checkout, where everything sits in the repo
// root. An engine installed from source into a virtual environment is neither.
// Left to itself it resolves its state root to site-packages - writes its
// database and camera list there, and generates its API credential into a
// folder this process never reads - while this process resolves the same
// state root to ProgramData. The two halves then disagree about where
// everything lives, and every call to the engine is 401 on a stock install,
// with nothing in either log saying why. Seen twice: once on a Mac checkout
// (the app in ~/Library, the engine in the repo) and once in a clean Linux
// container running the installer.
//
// Telling the engine where THIS process keeps state makes the two agree by
// construction, however the engine was installed. paths.py honours the
// override ahead of every other rule it has.
//
// hookURL is where the engine posts detections; empty is allowed and means
// the bridge has not started, which the engine treats as "no webhook".
func ChildEnv(hookURL string) []string {
env := append(os.Environ(),
"BEHAVISION_DATA_DIR="+paths.StateRoot(),
)
if hookURL != "" {
env = append(env, "BEHAVISION_WEBHOOK_URL="+hookURL)
}
return env
}

View File

@@ -0,0 +1,44 @@
package engine
import (
"strings"
"testing"
"github.com/loyaly/behavision-agent/pkg/paths"
)
// The engine must be told where THIS process keeps state, or a pip-installed
// engine decides on site-packages and the two halves never find each other.
func TestTheEngineIsToldWhereStateLives(t *testing.T) {
t.Setenv("BEHAVISION_DATA_DIR", t.TempDir())
env := ChildEnv("http://127.0.0.1:5555/events")
want := "BEHAVISION_DATA_DIR=" + paths.StateRoot()
if !contains(env, want) {
t.Fatalf("engine env lacks %q - a source-installed engine would put its "+
"database and credential somewhere this process never looks", want)
}
if !contains(env, "BEHAVISION_WEBHOOK_URL=http://127.0.0.1:5555/events") {
t.Fatal("webhook url not passed to the engine")
}
}
// Before the bridge has a port there is no webhook. An empty variable would be
// read by the engine as a webhook at "", which is not the same as none.
func TestNoWebhookMeansNoVariable(t *testing.T) {
for _, v := range ChildEnv("") {
if strings.HasPrefix(v, "BEHAVISION_WEBHOOK_URL=") {
t.Fatalf("empty hook still exported: %q", v)
}
}
}
func contains(env []string, want string) bool {
for _, v := range env {
if v == want {
return true
}
}
return false
}

View File

@@ -21,7 +21,12 @@ import (
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"github.com/loyaly/behavision-agent/pkg/config"
"time"
)
@@ -56,6 +61,9 @@ type Options struct {
// no captured output is undiagnosable, which on a customer site means a
// site visit.
LogWriter io.Writer
// Creds re-reads the engine's generated credential when one is rejected,
// which is the ordinary case on a first run.
Creds *config.Creds
// HealthURL, StatsURL, User, Password address the engine's own API.
HealthURL string
StatsURL string
@@ -76,6 +84,7 @@ type Supervisor struct {
restarts int
cancel context.CancelFunc
done chan struct{}
progress Progress
}
func New(opts Options) *Supervisor {
@@ -109,6 +118,37 @@ func (s *Supervisor) Start() {
go s.supervise(ctx, done)
}
// Progress is what the engine is busy with before it answers - on first run,
// downloading ~275 MB of models. Empty once the engine is up.
type Progress struct {
What string `json:"what"`
Percent int `json:"percent"`
}
var progressRe = regexp.MustCompile(`download: (.+?) (\d{1,3})%`)
func (s *Supervisor) noteProgress(line string) {
m := progressRe.FindStringSubmatch(line)
if m == nil {
return
}
pct, _ := strconv.Atoi(m[2])
s.mu.Lock()
if pct >= 100 {
s.progress = Progress{}
} else {
s.progress = Progress{What: m[1], Percent: pct}
}
s.mu.Unlock()
}
// Progress reports the current first-run download, if any.
func (s *Supervisor) Progress() Progress {
s.mu.Lock()
defer s.mu.Unlock()
return s.progress
}
// Stop asks the engine to exit and waits for it.
func (s *Supervisor) Stop() {
s.mu.Lock()
@@ -194,22 +234,66 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
return err
}
cmd.Stderr = cmd.Stdout
// Cancel ends the whole process tree, not just the process exec spawned.
// `kill` is filled in after Start, once the tree is confined; until then
// it is exec's own behaviour.
var kill func() error
cmd.Cancel = func() error {
if kill == nil {
return cmd.Process.Kill()
}
return kill()
}
// Cancel sends the kill; WaitDelay bounds how long Wait() then waits for
// the output pipes to close. Without it Wait() blocks until every writer
// is gone - and Stop() blocks on Wait() - so one grandchild still holding
// the engine's stdout hangs Stop FOREVER, which on the desktop app means
// the tray's Quit never returns. stopGrace was declared for exactly this
// and never wired to anything; staticcheck found it as an unused const.
cmd.WaitDelay = stopGrace
prepare(cmd)
if err := cmd.Start(); err != nil {
return fmt.Errorf("engine failed to start: %w", err)
}
k, release, err := confine(cmd)
if err != nil {
// Not fatal: the engine runs, and stopping it falls back to killing
// the one process. Logged because on Windows that fallback is the
// bug this exists to fix.
fmt.Fprintf(s.opts.LogWriter, "supervisor: could not confine engine process tree: %v\n", err)
}
kill = k
defer release()
// The last few lines the engine printed travel with the failure, because
// "engine exited: exit status 1" sends somebody to a log file on a shop
// PC, and the one line that matters - "port 8010 is already in use" - was
// right there.
var tailMu sync.Mutex
var tail []string
pumped := make(chan struct{})
go func() {
defer close(pumped)
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
fmt.Fprintln(s.opts.LogWriter, sc.Text())
line := sc.Text()
fmt.Fprintln(s.opts.LogWriter, line)
s.noteProgress(line)
tailMu.Lock()
tail = append(tail, line)
if len(tail) > 12 {
tail = tail[1:]
}
tailMu.Unlock()
}
}()
s.setState(Running, nil)
waitErr := cmd.Wait()
s.mu.Lock()
s.progress = Progress{}
s.mu.Unlock()
<-pumped
// A context cancel terminates the child through exec's own handling; the
@@ -218,6 +302,12 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
return nil
}
if waitErr != nil {
tailMu.Lock()
reason := explain(tail)
tailMu.Unlock()
if reason != "" {
return fmt.Errorf("%s (%v)", reason, waitErr)
}
return fmt.Errorf("engine exited: %w", waitErr)
}
return errors.New("engine exited unexpectedly with status 0")
@@ -331,14 +421,24 @@ func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
if err != nil {
return err
}
if s.opts.User != "" {
req.SetBasicAuth(s.opts.User, s.opts.Password)
user, pass := s.opts.User, s.opts.Password
if s.opts.Creds != nil {
user, pass = s.opts.Creds.Get()
}
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized && s.opts.Creds != nil && s.opts.Creds.Refresh() {
// See cameras.EngineClient: on a fresh install the engine writes its
// credential after the agent has already read for one.
resp.Body.Close()
return s.getJSON(ctx, url, out)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s returned %s", url, resp.Status)
}
@@ -353,3 +453,28 @@ func LogFile(path string) (*os.File, error) {
}
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
}
// explain turns the engine's last output into the sentence the tray shows.
// The cases are the ones seen on real installs; anything else shows the last
// non-empty line verbatim.
func explain(tail []string) string {
last := ""
for _, l := range tail {
low := strings.ToLower(l)
switch {
case strings.Contains(low, "address already in use") || strings.Contains(low, "only one usage of each socket address"):
return "port 8010 is already in use - another Behavision or its engine is still running"
case strings.Contains(low, "no module named behavision"):
return "the engine is not installed in this Python - run behavision-setup again"
case strings.Contains(low, "modulenotfounderror") || strings.Contains(low, "importerror"):
return "the engine is missing a library - run behavision-setup again"
}
if strings.TrimSpace(l) != "" {
last = strings.TrimSpace(l)
}
}
if len(last) > 120 {
last = last[:120] + "…"
}
return last
}

View File

@@ -0,0 +1,13 @@
//go:build !windows
package engine
import "os/exec"
// On every other platform the engine is one process and exec's own kill is
// enough. See tree_windows.go for why Windows is not.
func prepare(*exec.Cmd) {}
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
return cmd.Process.Kill, func() {}, nil
}

View File

@@ -0,0 +1,111 @@
//go:build windows
package engine
import (
"fmt"
"os/exec"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// The engine is not one process on Windows, and stopping it used to leave
// recognition running.
//
// The installer starts it as `<venv>\Scripts\python.exe -m behavision run`.
// Since Python 3.7.2 that python.exe is a REDIRECTOR: a small launcher that
// spawns the base interpreter as a child and waits for it. Stop() cancelled the
// context, exec terminated the launcher, and the interpreter that actually
// holds the cameras and the SQLite WAL carried on with no parent, no tray icon
// and nothing left that could stop it. Seen on a Windows install: "Quit
// Behavision" from the tray, and the engine still running.
//
// The fix is the primitive Windows has for exactly this: a job object with
// KILL_ON_JOB_CLOSE. Every process the engine spawns inherits membership, and
// the whole tree dies when the job is terminated or when this process's last
// handle to it goes away - so "quitting the app stops recognition" holds even
// if the app crashes, which no amount of careful Stop() code can promise.
//
// The child is started SUSPENDED and resumed only after it is in the job.
// Assigning after the fact leaves a window in which the launcher has already
// spawned the interpreter outside it, and that window is precisely the case
// this file exists to close.
// prepare is applied to the command before it starts.
func prepare(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
// CREATE_NO_WINDOW: python.exe is a console program and Behavision.exe is
// not, so without this Windows opens a black console window for the
// engine on a shop counter - the app looks like it has crashed into a
// terminal. Output still arrives on the pipes.
cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED | windows.CREATE_NO_WINDOW
}
// confine is applied after Start. It puts the process in a kill-on-close job,
// then resumes it. It returns a function that ends the whole tree, and one
// that releases the job handle once the tree has exited.
//
// If the job cannot be set up the process is still resumed and the plain
// terminate remains: a suspended engine that never runs is strictly worse
// than one that may outlive its parent.
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
pid := uint32(cmd.Process.Pid)
defer resumeProcess(pid)
kill = cmd.Process.Kill
release = func() {}
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return kill, release, fmt.Errorf("create job object: %w", err)
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
windows.CloseHandle(job)
return kill, release, fmt.Errorf("configure job object: %w", err)
}
proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, pid)
if err != nil {
windows.CloseHandle(job)
return kill, release, fmt.Errorf("open engine process: %w", err)
}
defer windows.CloseHandle(proc)
if err := windows.AssignProcessToJobObject(job, proc); err != nil {
windows.CloseHandle(job)
return kill, release, fmt.Errorf("assign engine to job: %w", err)
}
kill = func() error { return windows.TerminateJobObject(job, 1) }
release = func() { windows.CloseHandle(job) }
return kill, release, nil
}
// resumeProcess resumes every thread of a process started CREATE_SUSPENDED.
// exec does not hand back the main thread handle, so it is found through the
// toolhelp snapshot; a suspended new process has exactly one.
func resumeProcess(pid uint32) {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
if err != nil {
return
}
defer windows.CloseHandle(snap)
var te windows.ThreadEntry32
te.Size = uint32(unsafe.Sizeof(te))
for err = windows.Thread32First(snap, &te); err == nil; err = windows.Thread32Next(snap, &te) {
if te.OwnerProcessID != pid {
continue
}
h, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, te.ThreadID)
if err != nil {
continue
}
windows.ResumeThread(h)
windows.CloseHandle(h)
}
}

113
agent/pkg/enrol/enrol.go Normal file
View File

@@ -0,0 +1,113 @@
// Package enrol links a PC to a shop, using the one-shot code an operator is
// given.
//
// It existed only inside the desktop app, which meant a HEADLESS install - a
// back-office PC with no window, the configuration the agent binary is for -
// could not be claimed at all. The only route was hand-editing agent.json,
// which is exactly the state the desktop's Setup screen was built to end.
//
// The endpoint behind this is deliberately unauthenticated: the PC doing it has
// nobody signed in yet, and requiring a login would mean shipping a password to
// every shop that installs the software.
package enrol
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// Bootstrap is what the server hands back: which shop this PC is, and the
// credentials it needs to say so.
type Bootstrap struct {
ClientSlug string `json:"client_slug"`
SiteSlug string `json:"site_slug"`
SiteName string `json:"site_name"`
MQTTURL string `json:"mqtt_url"`
MQTTUser string `json:"mqtt_username"`
MQTTPass string `json:"mqtt_password"`
CACert string `json:"ca_cert,omitempty"`
AgentToken string `json:"agent_token"`
}
// Claim redeems an installation code.
//
// The code is read aloud down a phone and photographed off screens, so what is
// typed here can be as untidy as it needs to be: the server strips spaces,
// dashes and case at its end. Sending it as typed keeps ONE implementation of
// that normalisation, on the side that also issued the code - two would
// eventually disagree and hash to something the redeemer never produces.
func Claim(ctx context.Context, base, code string) (Bootstrap, error) {
var out Bootstrap
base = strings.TrimRight(base, "/")
if base == "" {
return out, fmt.Errorf("no server address configured (set cloud_base or BEHAVISION_CLOUD)")
}
body, err := json.Marshal(map[string]string{"site_token": code})
if err != nil {
return out, err
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
base+"/api/agent/enrol", bytes.NewReader(body))
if err != nil {
return out, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return out, fmt.Errorf("could not reach %s: %w", base, err)
}
defer resp.Body.Close()
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode != http.StatusOK {
// The server answers unknown, expired and already-used identically on
// purpose - the difference only helps somebody guessing codes, and the
// operator's next step is the same in all three cases. Its own words
// are passed through rather than reworded here.
var e struct {
Message string `json:"message"`
}
_ = json.Unmarshal(blob, &e)
if e.Message != "" {
return out, fmt.Errorf("%s", e.Message)
}
return out, fmt.Errorf("head office: %s", resp.Status)
}
if err := json.Unmarshal(blob, &out); err != nil {
return out, err
}
if out.SiteSlug == "" || out.MQTTURL == "" {
return out, fmt.Errorf("head office returned an incomplete setup")
}
return out, nil
}
// SaveCA writes the broker's CA beside the agent config and returns its path.
//
// The server hands the CA out at enrolment precisely so it never has to be
// shipped in an installer - and for a while nothing on the receiving end
// wrote it anywhere. Every claimed PC then dialled tls://mcp.loyaly.ai:8883
// with the system trust store, the private CA failed verification, and the
// agent reported "the broker did not accept this PC" (a TLS failure is
// indistinguishable from a refusal at that layer). No real site could ever
// publish a visit. An empty CA returns "" so a deployment on a public
// certificate keeps working unchanged.
func SaveCA(pem, path string) (string, error) {
if strings.TrimSpace(pem) == "" {
return "", nil
}
if err := os.WriteFile(path, []byte(pem), 0o600); err != nil {
return "", fmt.Errorf("write broker CA: %w", err)
}
return path, nil
}

View File

@@ -16,7 +16,8 @@ import (
"errors"
"fmt"
"log"
neturl "net/url"
"net"
"net/url"
"os"
"strings"
"time"
@@ -104,7 +105,13 @@ func NewClient(opts ClientOptions) (*Client, error) {
tok := c.client.Connect()
if !tok.WaitTimeout(20 * time.Second) {
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL)
// SetConnectRetry means paho retries internally and this token never
// completes, so a REFUSED connection and an UNREACHABLE broker both
// arrive here as a timeout. They need opposite actions - re-link this
// PC, or go and look at the network - and reporting both as "timed
// out" sent the diagnosis to the wrong place. Measured: mosquitto
// logged "not authorised" while the agent logged a timeout.
return c, fmt.Errorf("mqtt: %s", describeStall(opts.BrokerURL))
}
if err := tok.Error(); err != nil {
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
@@ -112,6 +119,47 @@ func NewClient(opts ClientOptions) (*Client, error) {
return c, nil
}
// describeStall says which of the two failures this is, by asking the one
// question that separates them: can we open a socket to the broker at all?
//
// It cannot name the exact reason - the broker does not tell a rejected client
// why, and a TLS failure looks the same from here - so it says what is known
// and what to check, rather than guessing. Being reachable but not accepted is
// overwhelmingly a credential this PC no longer has, which is what happens when
// a site is re-provisioned.
func describeStall(brokerURL string) string {
host := brokerHostPort(brokerURL)
if host == "" {
return fmt.Sprintf("connect to %s timed out", brokerURL)
}
conn, err := net.DialTimeout("tcp", host, 5*time.Second)
if err != nil {
return fmt.Sprintf("cannot reach the broker at %s: %v - check the "+
"network and that the broker is running", host, err)
}
_ = conn.Close()
return fmt.Sprintf("the broker at %s is reachable but did not accept this "+
"PC - usually its credentials are no longer valid; re-link it with "+
"`behavision-agent claim <code>`", host)
}
// brokerHostPort extracts host:port for the reachability probe. Parsed with
// net/url, never by scanning for the first ":" - an IPv6 literal is bracketed
// and full of them.
func brokerHostPort(brokerURL string) string {
u, err := url.Parse(brokerURL)
if err != nil || u.Host == "" {
return ""
}
if u.Port() != "" {
return u.Host
}
if strings.HasPrefix(brokerURL, "tls://") || strings.HasPrefix(brokerURL, "ssl://") {
return net.JoinHostPort(u.Hostname(), "8883")
}
return net.JoinHostPort(u.Hostname(), "1883")
}
// Publish sends one message at QoS 1 and waits for the broker's PUBACK.
//
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack
@@ -176,7 +224,7 @@ func checkTransport(raw string) error {
// url.Parse, not hand-rolled splitting: an IPv6 literal is bracketed and
// full of colons, so scanning for the first ":" turns "[::1]:1883" into
// "[" and refuses a perfectly good loopback address.
u, err := neturl.Parse(raw)
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("mqtt: cannot parse broker url %q: %w", raw, err)
}

View File

@@ -1,6 +1,7 @@
package mqtt
import (
"net"
"strings"
"testing"
)
@@ -91,7 +92,11 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
// The pump calls this on every tick; a nil-client panic would take the
// whole agent down instead of backing off.
c := &Client{}
if err := c.Publish(nil, "t", []byte("{}")); err == nil { //nolint:staticcheck
// The nil context is the POINT: the pump must not panic on a client that
// never connected. //nolint is golangci-lint's directive and staticcheck
// ignores it, which is why this kept being reported.
//lint:ignore SA1012 passing nil is what is under test
if err := c.Publish(nil, "t", []byte("{}")); err == nil {
t.Fatal("publish on an unconnected client reported success")
}
if c.Connected() {
@@ -102,3 +107,66 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
func writeFile(path, content string) error {
return osWriteFile(path, []byte(content), 0o600)
}
// "The broker refused this PC" and "the broker is not there" need opposite
// actions - re-link this PC, or go and look at the network - and paho's
// connect-retry makes both arrive as a timeout. Measured on a real broker:
// mosquitto logged "not authorised" while the agent logged a timeout, which
// sent the diagnosis to the wrong place.
func TestARefusedBrokerIsNotDescribedAsUnreachable(t *testing.T) {
// A listener that accepts TCP and then says nothing is exactly what a
// broker rejecting a client looks like from out here.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
_ = c
}
}()
got := describeStall("tcp://" + ln.Addr().String())
if !strings.Contains(got, "reachable but did not accept") {
t.Fatalf("a reachable broker was described as unreachable: %s", got)
}
if !strings.Contains(got, "claim") {
t.Errorf("the message does not say what to do about it: %s", got)
}
}
func TestAnAbsentBrokerIsDescribedAsUnreachable(t *testing.T) {
// Bound and immediately closed, so the port is certainly nobody's.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
ln.Close()
got := describeStall("tcp://" + addr)
if !strings.Contains(got, "cannot reach the broker") {
t.Fatalf("an absent broker was not described as unreachable: %s", got)
}
}
// An IPv6 literal is bracketed and full of colons, so scanning for the first
// one gives "[". The same bug this package already fixed once for broker URLs.
func TestTheProbeAddressHandlesIPv6AndDefaultPorts(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"tcp://127.0.0.1:51883", "127.0.0.1:51883"},
{"tcp://[::1]:1883", "[::1]:1883"},
{"tcp://broker.example", "broker.example:1883"},
{"tls://broker.example", "broker.example:8883"},
{"tls://[2001:db8::1]:8884", "[2001:db8::1]:8884"},
} {
if got := brokerHostPort(tc.in); got != tc.want {
t.Errorf("brokerHostPort(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

View File

@@ -205,14 +205,3 @@ func (p *Pump) logf(format string, args ...any) {
p.Log.Printf(format, args...)
}
}
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}

View File

@@ -57,8 +57,11 @@ func InstallRoot() string {
}
func AgentConfig() string { return filepath.Join(StateRoot(), "agent.json") }
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
// BrokerCA is the broker's CA certificate, written at enrolment.
func BrokerCA() string { return filepath.Join(StateRoot(), "broker-ca.crt") }
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
// APICredentials is the file the engine writes when it generates its own
// Basic credentials. The agent reads it rather than storing a second copy,
@@ -67,6 +70,12 @@ func APICredentials() string {
return filepath.Join(StateRoot(), "data", "api_credentials.txt")
}
// CamerasFile is the engine's own camera store. The agent never edits it -
// cameras go through the engine's API so passwords are sealed - but setup
// removes it when a PC joins a shop, because head office is the source of
// truth from then on.
func CamerasFile() string { return filepath.Join(StateRoot(), "data", "cameras.json") }
// EnsureState creates the writable tree. Called before anything opens a file
// under it, so a first run on a fresh machine does not fail on a missing dir.
func EnsureState() error {

Binary file not shown.

View File

@@ -21,6 +21,17 @@ def cmd_run(args: argparse.Namespace) -> int:
cfg = load_config(args.config)
setup_logging(cfg.app.log_level, cfg.app.data_dir)
if cfg.app.detect_threads > 0:
# OpenCV sizes its pool for one big job on an idle machine. This is a
# small job repeated forever on a machine also running the recogniser,
# the tracker and possibly three other cameras, so the default costs
# twice the CPU for no useful latency. Measured: 31 ms CPU/frame at the
# default against 15 ms at one thread, for 6 ms more wall time against
# a 66 ms budget.
import cv2
cv2.setNumThreads(cfg.app.detect_threads)
log.info("detection threads: %d (OpenCV default was %d)",
cfg.app.detect_threads, cv2.getNumThreads())
missing = setup_models(cfg.app.models_dir)
if missing:
log.error("required models missing: %s", ", ".join(missing))

View File

@@ -6,6 +6,7 @@ models finish loading without a single unguarded None dereference.
from __future__ import annotations
import asyncio
import time
import logging
import secrets
from pathlib import Path
@@ -111,6 +112,35 @@ def _auth_dependencies(api_cfg: ApiSection) -> list:
return [Depends(check)]
def _reencode(jpeg: bytes, width: int, quality: int) -> "bytes | None":
"""Decode, scale and re-encode one frame. None on any failure.
None rather than an exception on purpose: the caller falls back to the
original frame, so a re-encode that fails costs bandwidth rather than the
picture. A live view that goes blank because a resize failed is a worse
outcome than one that is briefly larger than asked for.
"""
try:
import cv2
import numpy as np
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return None
if 0 < width < img.shape[1]:
# Only ever DOWN. Upscaling a frame to a requested width would send
# more bytes than the original for no more detail.
scale = width / img.shape[1]
img = cv2.resize(img, (width, max(1, int(img.shape[0] * scale))),
interpolation=cv2.INTER_AREA)
q = quality if 1 <= quality <= 100 else 75
ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), q])
return buf.tobytes() if ok else None
except Exception:
log.debug("frame re-encode failed", exc_info=True)
return None
def create_app(engine: Engine) -> FastAPI:
app = FastAPI(title="Behavision", version="1.0.0",
dependencies=_auth_dependencies(engine.cfg.api))
@@ -125,11 +155,25 @@ def create_app(engine: Engine) -> FastAPI:
def dashboard() -> str:
return (_STATIC / "dashboard.html").read_text(encoding="utf-8")
@app.get("/static/favicon.png")
def favicon() -> Response:
# The one static asset besides the page itself. Served explicitly
# rather than mounting the directory: nothing else in there is meant
# to be reachable, and a mount would make that a matter of what lands
# in the folder.
return Response((_STATIC / "favicon.png").read_bytes(), media_type="image/png",
headers={"cache-control": "public, max-age=86400"})
@app.get("/api/health")
def health() -> dict:
from .paths import describe
# A gallery the running encoder cannot read is the failure most
# worth catching from outside: the process is healthy, the cameras
# are up, and the shop recognises nobody it already knows.
stranded = engine.gallery.health["stranded"]
return {"status": "ok" if engine.started_at else "starting",
"recognition_model": engine.encoder.model_name,
"gallery_unreadable_embeddings": stranded,
# "where is my database" must be answerable from the API: the
# tray, the installer and support all need it, and installed
# it is not next to the code.
@@ -293,6 +337,17 @@ def create_app(engine: Engine) -> FastAPI:
worker.commission.cancel()
return {"cancelled": camera_id}
@app.get("/api/cameras/discover")
def discover_cameras() -> dict:
"""Cameras on this PC's network, for the add-camera form to pick from.
A sync def so FastAPI runs it in the threadpool: it holds a socket
open for a couple of seconds and sweeps a /24, and the event loop
must keep serving the live picture meanwhile.
"""
from .discover import discover
return discover()
@app.post("/api/cameras/test")
def test_camera(payload: CameraPayload) -> dict:
"""Try a camera WITHOUT saving it - the UI's Test button.
@@ -313,10 +368,23 @@ def create_app(engine: Engine) -> FastAPI:
return probe_source(source, cam.max_width)
@app.get("/api/cameras/{camera_id}/frame.jpg")
def frame(camera_id: str) -> Response:
def frame(camera_id: str, width: int = 0, quality: int = 0) -> Response:
"""The latest frame, optionally re-encoded smaller.
`width`/`quality` exist for the live relay, which sends several frames
a second up a shop's uplink and cannot afford the full-size picture the
dashboard uses. The re-encode happens here rather than in the agent
because this process already has OpenCV open and the frame decoded;
shipping a scaler into the agent would be the same work done twice.
Done on demand, not on every frame: a camera nobody is watching must
not pay for a second encode it will never use.
"""
jpeg = worker_or_404(camera_id).latest_jpeg()
if jpeg is None:
raise HTTPException(503, "no frame yet")
if width > 0 or quality > 0:
jpeg = _reencode(jpeg, width, quality) or jpeg
return Response(jpeg, media_type="image/jpeg")
@app.get("/api/cameras/{camera_id}/stream.mjpeg")
@@ -328,11 +396,23 @@ def create_app(engine: Engine) -> FastAPI:
# Stop when the camera is deleted or its worker dies - otherwise a
# removed camera leaves this generator running for the life of the
# process, holding a reference to a worker nothing else can see.
# Driven by the camera, not a timer: a frame goes out when the
# capture thread has one newer than the last one sent, so nothing
# is sent twice and nothing waits on the recognition pipeline.
# Capped at 15 fps - the office cameras' own rate - so a viewer
# never costs more encodes than the camera produces pictures.
last_ts, min_gap, sent_at = 0.0, 1.0 / 15, 0.0
while engine.workers.get(camera_id) is worker and worker.is_alive():
jpeg = worker.latest_jpeg()
if jpeg is not None:
yield boundary + jpeg + b"\r\n"
await asyncio.sleep(0.1) # ~10 fps to the browser
now = time.time()
if now - sent_at < min_gap:
await asyncio.sleep(min_gap - (now - sent_at))
continue
jpeg, ts = worker.latest_jpeg_since(last_ts)
if jpeg is None:
await asyncio.sleep(0.02)
continue
last_ts, sent_at = ts, time.time()
yield boundary + jpeg + b"\r\n"
return StreamingResponse(
generate(),

View File

@@ -17,13 +17,57 @@ import numpy as np
log = logging.getLogger(__name__)
# Force TCP transport and a 5s socket timeout for RTSP before OpenCV loads
# ffmpeg. UDP is the default and silently drops frames on lossy Wi-Fi.
# Set before OpenCV loads ffmpeg, which reads this once.
#
# rtsp_transport=tcp: UDP is the default and silently drops frames on lossy
# Wi-Fi.
#
# The timeout here is NOT what bounds a dead camera, and the comment that
# once said it did was wrong. Measured against OpenCV 4.11 / FFmpeg 7.1 on a
# socket that accepts the connection and then says nothing:
#
# stimeout;5000000 -> 30.0s timeout;5000000 -> 30.0s
# stimeout;2000000 -> 30.5s timeout;2000000 -> 30.4s
# no timeout option at all -> 30.3s
#
# Identical with the option absent, so it is not being honoured under either
# name through this path. `stimeout` was renamed `timeout` in FFmpeg 5.0, and
# neither reaches the RTSP protocol here. What actually bounds it is
# OpenCV's own interrupt callback (30s for open, 30s for read), which is a
# compile-time constant we do not control.
#
# Both names are still set, because on a build where they DO take effect the
# shorter bound is what we want and an unrecognised option is ignored. But
# nothing may depend on it: a wrong address is caught by _tcp_reachable
# below, in code we own, in under a second.
#
# fflags=nobuffer and flags=low_delay: without them ffmpeg's RTSP demuxer
# holds a comfortable queue of frames before handing over the first, which
# on a live feed is half a second to two seconds of latency that no amount of
# work downstream can recover - the frame is already old when we get it. A
# recorder wants that buffer; a live view does not. max_delay caps the
# reorder wait for the same reason.
os.environ.setdefault(
"OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp|stimeout;5000000"
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
"rtsp_transport;tcp|stimeout;5000000|timeout;5000000"
"|fflags;nobuffer|flags;low_delay|max_delay;200000",
)
# A stream can stay open and stop delivering. OpenCV breaks a blocked read
# after 30s and we reconnect, but for those 30s `connected` is True and the
# camera is dead — and a stream that trickles a frame every 20s never trips
# that timeout at all, so it never reconnects and never recovers either.
#
# 10s is not a preference. The tracker gives up on a face after `max_misses`
# (25 frames, ~1.7s at 15 fps), so by 10s every track is long gone and 150
# frames are missing: whatever this is, it is not something recognition can
# work with. Reported separately from `connected` because the two need
# opposite actions — one says check the network, the other says the camera
# is answering but sending nothing.
STALL_AFTER_S = 10.0
def _tcp_reachable(source: "str | int", timeout: float
) -> "tuple[bool, str]":
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
@@ -47,6 +91,23 @@ def _tcp_reachable(source: "str | int", timeout: float
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
def _fourcc(cap) -> str:
"""The stream's codec as a four-character code, or "" if unknown.
FFmpeg reports H.265 as "hevc" and H.264 as "h264"/"avc1" depending on the
container. Returned as-is rather than mapped to a friendly name: the raw
value is what somebody searching their camera's manual will match.
"""
try:
raw = int(cap.get(cv2.CAP_PROP_FOURCC))
except Exception:
return ""
if raw <= 0:
return ""
code = "".join(chr((raw >> (8 * i)) & 0xFF) for i in range(4))
return code.strip().strip("\x00")
def probe_source(source: "str | int", max_width: int = 1280,
timeout: float = 12.0, connect_timeout: float = 3.0) -> dict:
"""Open a candidate camera, grab one frame, and let go.
@@ -97,6 +158,17 @@ def probe_source(source: "str | int", max_width: int = 1280,
return {
"ok": True, "width": int(width), "height": int(height),
"downscaled_to": int(preview.shape[1]) if preview is not frame else None,
"fps": round(cap.get(cv2.CAP_PROP_FPS) or 0, 1),
# The codec decides whether head office can ever show TRUE live
# video from this camera. A browser plays H.264 everywhere; H.265
# only on some platforms, so a passthrough relay cannot rely on it
# and the picture has to be re-encoded frame by frame instead.
# Reported here because it is a property of the camera's settings
# that an installer can usually change, and because otherwise the
# only way to learn it is to read RTSP by hand — which is how this
# was found: a camera whose paths end in ".264" was emitting H.265
# on both streams.
"codec": _fourcc(cap),
"snapshot": (base64.b64encode(buf.tobytes()).decode("ascii")
if ok else None),
}
@@ -131,6 +203,10 @@ class VideoSource(threading.Thread):
self.frames_total = 0
self.reconnects = 0
self._ever_connected = False
# Why the last open failed, in the words an installer can act on.
# Without it a camera that never connects reports only `connected:
# false`, which cannot distinguish a wrong IP from a wrong password.
self.last_error = ""
# -- public ---------------------------------------------------------
def latest(self) -> "tuple[Optional[np.ndarray], float]":
@@ -161,11 +237,23 @@ class VideoSource(threading.Thread):
def stop(self) -> None:
self._stopping.set()
def stalled(self) -> bool:
"""Open, but not delivering. See STALL_AFTER_S."""
if not self.connected or not self._frame_ts:
return False
return (time.time() - self._frame_ts) > STALL_AFTER_S
def stats(self) -> dict:
return {
"camera_id": self.camera_id,
"url": self._display_url,
"connected": self.connected,
# Connected AND delivering. `connected` alone stays true through
# a stall, so it is the wrong thing for a dashboard to colour a
# camera green on.
"streaming": self.connected and not self.stalled(),
"stalled": self.stalled(),
"last_error": self.last_error,
"frames_total": self.frames_total,
"reconnects": self.reconnects,
"last_frame_age_s": round(time.time() - self._frame_ts, 1)
@@ -222,6 +310,19 @@ class VideoSource(threading.Thread):
log.info("[%s] capture stopped", self.camera_id)
def _open(self) -> Optional[cv2.VideoCapture]:
# Pre-flight the socket, exactly as probe_source does. Without it a
# camera that is off, moved or mistyped costs 30s per attempt inside
# the VideoCapture constructor (measured; it is OpenCV's interrupt
# timeout, not ours to shorten) — and the constructor is not
# interruptible, so stop() cannot cut it short and a removed camera
# leaves a daemon thread holding a socket for half a minute. A
# refused or unroutable address answers in well under a second, which
# is also what lets the backoff below mean what it says.
reachable, why = _tcp_reachable(self._source, 2.0)
if not reachable:
log.debug("[%s] %s", self.camera_id, why)
self.last_error = why
return None
try:
if isinstance(self._source, int):
cap = cv2.VideoCapture(self._source)
@@ -230,8 +331,12 @@ class VideoSource(threading.Thread):
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
if not cap.isOpened():
cap.release()
self.last_error = ("reachable, but the stream would not open "
"- check the path and credentials")
return None
self.last_error = ""
return cap
except cv2.error:
log.exception("[%s] VideoCapture error", self.camera_id)
self.last_error = "VideoCapture error - see the engine log"
return None

View File

@@ -132,6 +132,29 @@ class AppSection(BaseModel):
# on changes what the system is under GDPR and India's DPDP, so it has to
# be a decision somebody makes rather than one they inherit.
store_faces: bool = False
# How many threads OpenCV may use for detection. Measured on the office
# camera (800x448 sub-stream): the default of 8 costs 31 ms of CPU per
# frame for 8.9 ms of wall time, while ONE thread costs 15.3 ms of CPU for
# 15.3 ms of wall - half the CPU for 6 ms more latency, against a 66 ms
# frame budget at 15 fps. The default is wrong here because OpenCV sizes it
# for one big job on an idle machine, and this is a small job repeated
# forever on a machine also running the recogniser, the tracker and three
# other cameras. 0 leaves OpenCV's own default alone.
detect_threads: int = 1
# Skip detection on frames where nothing has changed and nothing is being
# tracked. A shop is empty most of the day and a frame of an empty room
# costs exactly as much to search as a busy one. See CameraWorker.run for
# why this cannot lose a face.
motion_gate: bool = True
# Mean absolute difference, 0-255, over a 160x90 greyscale thumbnail. 1.0
# is well below the noise floor of a real camera - measured on this one,
# an empty room varies by ~0.3 between frames - so it triggers on movement
# rather than on sensor noise, and anything ambiguous detects.
motion_threshold: float = 1.0
# Detect at least this often regardless of the gate, so a change the
# thumbnail cannot see - someone entering at the far edge, a slow lean into
# frame - is still found within a second.
motion_max_skip: int = 12
class ApiSection(BaseModel):

206
behavision/discover.py Normal file
View File

@@ -0,0 +1,206 @@
"""Find the cameras on the shop's network, so nobody has to type an address.
The add-camera form asked for an IP address, and a shop owner does not know
their camera's IP address. It is on a sticker under the camera, if at all, or
inside the camera's own app under a menu called something different for every
make. That one field is where onboarding stopped for anyone who was not an
installer.
Two probes, merged:
- **WS-Discovery** (ONVIF's discovery protocol): one multicast to
239.255.255.250:3702 and every ONVIF camera on the LAN answers with its
address and, usually, its make and model. Cheap, fast, and names the device
- but only cameras that speak ONVIF answer, and some cheap ones do not.
- **A TCP sweep of port 554** across the local /24: anything listening on the
RTSP port is very probably a camera or a recorder. Names nothing, misses
nothing that streams.
A host found by either is a candidate; one found by both is a camera with a
name. The result is a list to pick from, not a decision: the person still
supplies the password, and Test still proves the stream opens.
Stdlib only. This runs inside the engine, which ships as a small wheel, and a
network-scanning dependency would be a large thing to add for two sockets.
"""
from __future__ import annotations
import ipaddress
import re
import socket
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field, asdict
from typing import Iterable
# ONVIF WS-Discovery probe. The MessageID must be unique per probe; devices
# ignore a repeat.
_PROBE = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" '
'xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" '
'xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" '
'xmlns:dn="http://www.onvif.org/ver10/network/wsdl">'
'<e:Header><w:MessageID>uuid:{mid}</w:MessageID>'
'<w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To>'
'<w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action>'
'</e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body>'
'</e:Envelope>'
)
_MCAST = ("239.255.255.250", 3702)
# Makes we can name from an ONVIF scope or hostname, mapped to the ids the
# camera-make picker uses so the form can preselect the stream path.
_MAKES = (
("hikvision", "hikvision"), ("hik", "hikvision"), ("dahua", "dahua"),
("cp plus", "cpplus"), ("cpplus", "cpplus"), ("cp-plus", "cpplus"),
("uniview", "uniview"), ("unv", "uniview"), ("tapo", "tplink"),
("tp-link", "tplink"), ("reolink", "reolink"), ("amcrest", "amcrest"),
("axis", "axis"),
)
@dataclass
class Found:
host: str
rtsp: bool = False # port 554 answered
onvif: bool = False # answered WS-Discovery
name: str = "" # from ONVIF scopes, e.g. "Hikvision DS-2CD2043"
make: str = "" # picker id, when it can be guessed
onvif_url: str = ""
sources: list[str] = field(default_factory=list)
def local_networks() -> list[ipaddress.IPv4Network]:
"""The /24s this machine sits on, best effort and without dependencies.
Interface masks are not portable in the stdlib, so this assumes /24 - the
shape of nearly every shop's router - for each local IPv4 address it can
find. A bigger network would need a scan anyway that this should not run
unasked.
"""
addrs: set[str] = set()
try:
# The address the OS would use to reach the internet: the LAN we care about.
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.5)
s.connect(("8.8.8.8", 80))
addrs.add(s.getsockname()[0])
s.close()
except OSError:
pass
try:
for a in socket.gethostbyname_ex(socket.gethostname())[2]:
addrs.add(a)
except OSError:
pass
nets = []
for a in addrs:
try:
ip = ipaddress.IPv4Address(a)
except ValueError:
continue
if ip.is_loopback or ip.is_link_local:
continue
nets.append(ipaddress.IPv4Network(f"{a}/24", strict=False))
return sorted(set(nets), key=str)
def ws_discover(timeout: float = 2.5) -> list[Found]:
"""One ONVIF probe, every answer within `timeout` seconds."""
out: dict[str, Found] = {}
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
sock.settimeout(timeout)
sock.sendto(_PROBE.format(mid=uuid.uuid4()).encode(), _MCAST)
except OSError:
return []
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
data, (host, _) = sock.recvfrom(65535)
except socket.timeout:
break
except OSError:
break
f = parse_probe_match(data.decode("utf-8", "replace"), host)
if f:
out[f.host] = f
sock.close()
return list(out.values())
_XADDR = re.compile(r"<[^>]*XAddrs[^>]*>([^<]+)<")
_SCOPES = re.compile(r"<[^>]*Scopes[^>]*>([^<]+)<")
def parse_probe_match(xml: str, host: str) -> Found | None:
"""Pull the address and the human-readable scopes out of a ProbeMatch.
A regex rather than an XML parser on purpose: cameras emit every namespace
prefix imaginable and some emit XML that is not quite well-formed, and the
two fields wanted are flat text.
"""
xaddrs = _XADDR.search(xml)
scopes = _SCOPES.search(xml)
if not xaddrs and not scopes:
return None
url = xaddrs.group(1).split()[0] if xaddrs else ""
# Prefer the host from the XAddrs URL: a device with several interfaces
# answers from the one it heard us on, which is the one we can reach.
m = re.match(r"https?://([^/:]+)", url)
ip = m.group(1) if m else host
f = Found(host=ip, onvif=True, onvif_url=url, sources=["onvif"])
if scopes:
words = []
for s in scopes.group(1).split():
if "/name/" in s or "/hardware/" in s:
from urllib.parse import unquote
words.append(unquote(s.rsplit("/", 1)[-1]))
f.name = " ".join(dict.fromkeys(words)) # dedupe, keep order
f.make = guess_make(f.name)
return f
def guess_make(text: str) -> str:
low = text.lower()
for needle, make in _MAKES:
if needle in low:
return make
return ""
def rtsp_sweep(nets: Iterable[ipaddress.IPv4Network], timeout: float = 0.5,
workers: int = 128) -> list[str]:
"""Every host in `nets` with port 554 open. ~254 hosts in about a second."""
def probe(ip: str) -> str | None:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
return ip if s.connect_ex((ip, 554)) == 0 else None
except OSError:
return None
finally:
s.close()
hosts = [str(h) for n in nets for h in n.hosts()]
with ThreadPoolExecutor(max_workers=workers) as ex:
return [ip for ip in ex.map(probe, hosts) if ip]
def discover(timeout: float = 2.5) -> dict:
"""Both probes, merged, as the API returns it."""
nets = local_networks()
found: dict[str, Found] = {f.host: f for f in ws_discover(timeout)}
for ip in rtsp_sweep(nets):
f = found.setdefault(ip, Found(host=ip))
f.rtsp = True
f.sources.append("rtsp")
cams = sorted(found.values(), key=lambda f: (not (f.rtsp and f.onvif), not f.rtsp,
ipaddress.IPv4Address(f.host)))
return {
"networks": [str(n) for n in nets],
"cameras": [asdict(c) for c in cams],
}

View File

@@ -18,6 +18,7 @@ import numpy as np
from .attributes import AttributeEstimator, aggregate as aggregate_attrs
from .cameras import CameraStore
from . import capture
from .capture import VideoSource
from .faces import FaceOutbox
from .commission import CommissionRun
@@ -162,10 +163,20 @@ class CameraWorker(threading.Thread):
# every test using a stubbed worker passed.
self._stopping = threading.Event()
self._lock = threading.Lock()
self._annotated_jpeg: Optional[bytes] = None
# What the live view draws over the freshest frame: the boxes from
# the most recent processed frame, and when they were computed. NOT a
# pre-rendered JPEG - see latest_jpeg for why.
self._overlay: "list[tuple[tuple[int, int, int, int], tuple[int, int, int], str]]" = []
self._overlay_ts = 0.0
self._last_frame_ts = 0.0
self._was_connected = False
self._was_stalled = False
self.frames_processed = 0
# Motion gate state: a 160x90 greyscale thumbnail of the last frame we
# actually searched, and how many frames we have skipped since.
self._motion_prev = None
self._motion_skipped = 0
self.frames_skipped = 0
self.faces_seen = 0
self.pipeline = PipelineStats()
# One outbox per worker, all writing into the same directory. Files are
@@ -187,13 +198,52 @@ class CameraWorker(threading.Thread):
self.source.stop()
def latest_jpeg(self) -> Optional[bytes]:
jpeg, _ = self.latest_jpeg_since(0.0)
return jpeg
def latest_jpeg_since(self, known_ts: float) -> "tuple[Optional[bytes], float]":
"""The freshest captured frame with the latest boxes drawn on it, or
(None, known_ts) if the camera has produced nothing newer.
The live picture is deliberately NOT the frame the pipeline last
finished with. That version advanced only when detection, tracking and
identification had all completed on a frame - a few times a second on a
modest shop PC - and every picture it showed was already as old as that
processing. It looked like lag because it was lag. Here the picture runs
at the camera's rate off the capture thread's latest frame, and the
boxes - which genuinely can only update at pipeline rate - are drawn
over it from the last processed frame. Boxes may trail a fast walker by
one pipeline period; the picture never does.
Encoded on demand, per request, so a camera nobody is watching pays for
no JPEG at all. The old path encoded every processed frame whether or
not a viewer existed - CPU spent on precisely the machine short of it.
"""
frame, ts = self.source.latest_since(known_ts)
if frame is None:
return None, known_ts
with self._lock:
return self._annotated_jpeg
overlay, overlay_ts = list(self._overlay), self._overlay_ts
# A stalled pipeline must not leave a box floating over an empty spot.
# Older than a second and the person has walked out from under it.
draw = overlay if (time.time() - overlay_ts) < 1.0 else []
if draw:
frame = frame.copy()
for (x1, y1, x2, y2), color, text in draw:
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
if text:
cv2.putText(frame, text, (x1, max(20, y1 - 8)),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
if not ok:
return None, known_ts
return buf.tobytes(), ts
def stats(self) -> dict:
return {
**self.source.stats(),
"frames_processed": self.frames_processed,
"frames_skipped": self.frames_skipped,
"faces_seen": self.faces_seen,
"active_tracks": len(self.tracker.tracks),
"pipeline": self.pipeline.snapshot(self.rcfg.min_enroll_quality),
@@ -202,6 +252,43 @@ class CameraWorker(threading.Thread):
"enroll_threshold": self.rcfg.enroll_threshold},
}
def _nothing_moved(self, frame) -> bool:
"""True when this frame is close enough to the last searched one that
searching it again would find the same nothing.
It cannot lose a face, and that property is what makes it acceptable
rather than merely cheap. Three guards, in order:
* the caller only asks while NO track is open, so a person already
being followed is never affected by it;
* `motion_max_skip` forces a real detection about once a second
whatever the thumbnail says, which covers a change too small or too
gradual for it - someone easing into frame at the far edge;
* the threshold sits well above measured sensor noise and well below
a person, and anything ambiguous falls through to detection. When
in doubt it looks.
Cost is 0.1 ms against detection's 15 ms, so an empty shop stops paying
for a search of an empty room ~90 times a second.
"""
import cv2 as _cv2
small = _cv2.resize(_cv2.cvtColor(frame, _cv2.COLOR_BGR2GRAY), (160, 90),
interpolation=_cv2.INTER_AREA)
prev, self._motion_prev = self._motion_prev, small
if prev is None:
return False
if self._motion_skipped >= self.cfg.app.motion_max_skip:
self._motion_skipped = 0
return False
if float(_cv2.absdiff(small, prev).mean()) >= self.cfg.app.motion_threshold:
self._motion_skipped = 0
# Keep the thumbnail we just searched against, not this one, so a
# slow drift cannot creep past the threshold one frame at a time.
return False
self._motion_prev = prev
self._motion_skipped += 1
return True
# -- thread ---------------------------------------------------------
def run(self) -> None:
tcfg = self.cfg.tracking
@@ -214,6 +301,15 @@ class CameraWorker(threading.Thread):
continue
self._last_frame_ts = ts
# An empty room costs exactly as much to search as a busy one,
# and a shop is empty most of the day. Only ever while nothing
# is being tracked - see _nothing_moved.
if (self.cfg.app.motion_gate and not self.tracker.tracks
and self._nothing_moved(frame)):
self.frames_skipped += 1
self._remember_tracks([])
continue
detections = self.detector.detect(frame)
for det in detections:
det.quality = face_quality(frame, det.box, det.kps)
@@ -236,7 +332,7 @@ class CameraWorker(threading.Thread):
for track in ended:
self._finish_track(track, ts)
self._publish_annotated(frame, active)
self._remember_tracks(active)
self.frames_processed += 1
except Exception:
log.exception("[%s] frame processing failed", self.cam_cfg.id)
@@ -252,6 +348,20 @@ class CameraWorker(threading.Thread):
type="camera.up" if connected else "camera.down",
camera_id=self.cam_cfg.id))
# A stall is not a disconnect and must not be reported as one: the
# socket is fine, the camera is answering, and nothing is arriving.
# Logged on the transition only — a per-frame warning would bury the
# one line that matters under thousands of copies of itself.
stalled = self.source.stalled()
if stalled != self._was_stalled:
self._was_stalled = stalled
if stalled:
log.warning("[%s] connected but no frame for over %.0fs - the "
"camera is answering and sending nothing",
self.cam_cfg.id, capture.STALL_AFTER_S)
else:
log.info("[%s] frames resumed", self.cam_cfg.id)
def _finish_track(self, track: Track, ts: float) -> None:
"""Record what became of a track, once, as it ends.
@@ -426,12 +536,14 @@ class CameraWorker(threading.Thread):
track.quality, rcfg=self.rcfg):
track.reinforcements += 1
def _publish_annotated(self, frame: np.ndarray, tracks: "list[Track]") -> None:
canvas = frame.copy()
def _remember_tracks(self, tracks: "list[Track]") -> None:
"""Record what to draw. Cheap: a handful of tuples under the lock,
no frame copy and no encode. The encode happens in latest_jpeg_since,
only when somebody is looking."""
overlay = []
for t in tracks:
if t.misses > 0:
continue # only draw tracks matched in this frame
x1, y1, x2, y2 = t.box
if t.state == "resolved":
color = _COLORS["known"] if t.label and not str(t.label).startswith(
"Visitor") else _COLORS["new"]
@@ -440,15 +552,10 @@ class CameraWorker(threading.Thread):
color, text = _COLORS["ambiguous"], "?"
else:
color, text = _COLORS["pending"], ""
cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 2)
if text:
cv2.putText(canvas, text, (x1, max(20, y1 - 8)),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
ok, buf = cv2.imencode(".jpg", canvas,
[int(cv2.IMWRITE_JPEG_QUALITY), 80])
if ok:
with self._lock:
self._annotated_jpeg = buf.tobytes()
overlay.append((tuple(t.box), color, text))
with self._lock:
self._overlay = overlay
self._overlay_ts = time.time()
class Engine:
@@ -590,7 +697,10 @@ class Engine:
"age_model": ("genderage" if self.attributes is not None
and self.attributes.has_genderage else "caffe/none"),
},
"gallery": self.store.stats(),
# Counts, plus whether the running encoder can actually SEARCH
# them. A gallery of 21 identities that the loaded model cannot
# read is the silent version of an empty one.
"gallery": {**self.store.stats(), **self.gallery.health},
"cameras": [w.stats() for w in self.snapshot_workers()],
}

View File

@@ -53,10 +53,56 @@ class Gallery:
# vectors from a different model are numerically incompatible.
ids, vecs = store.all_embeddings(index.dim, model=model_name)
index.add(ids, vecs)
self.health = self._assess(len(ids))
if self.health["stranded"]:
# Not an INFO line. The encoder fallback chain exists so a
# memory-starved box still runs, and when it fires every vector
# written by the previous encoder becomes invisible: the shop
# keeps its customer list and recognises nobody on it, greeting
# every regular as new and enrolling them a second time. Footfall
# stays right, which is exactly why nothing looks wrong. The old
# message for that state was "gallery ready: 0 embeddings".
log.warning(
"gallery: %d of %d stored embeddings were written by a "
"DIFFERENT encoder (%s) and cannot be searched - %d known "
"%s unrecognisable under the running model '%s'. Either "
"restore that model or accept that these identities start "
"over.",
self.health["stranded"], self.health["stored"],
", ".join(sorted(self.health["other_models"])),
self.health["identities_stranded"],
"person is" if self.health["identities_stranded"] == 1
else "people are",
model_name)
log.info("gallery ready: %d embeddings (model '%s') across %d "
"identities", len(ids), model_name,
store.stats()["identities"])
def _assess(self, usable: int) -> dict:
"""What share of the gallery the running encoder can actually reach.
Reported rather than merely logged, because a log line on a shop PC
is read by nobody: this travels to head office the same way
`fraction_below_gate` does, beside the number it qualifies.
"""
counts = self.store.model_counts()
stored = sum(counts.values())
others = {m: n for m, n in counts.items() if m != self.model_name}
identities = self.store.stats()["identities"]
return {
"model": self.model_name,
"stored": stored,
"usable": usable,
"stranded": sum(others.values()),
"other_models": sorted(others),
"identities": identities,
"identities_usable": self.store.identities_with_model(
self.model_name),
"identities_stranded": max(
0, identities - self.store.identities_with_model(
self.model_name)),
}
def resolve(self, embedding: np.ndarray, quality: float, camera_id: str,
ts: "float | None" = None,
attributes: "dict | None" = None,

View File

@@ -281,6 +281,31 @@ class IdentityStore:
return None
return np.frombuffer(row["vector"], dtype=np.float32), float(row["quality"])
def model_counts(self) -> "dict[str, int]":
"""How many stored embeddings each encoder produced.
The gallery only ever searches vectors tagged with the *running*
encoder, so this is what says whether the rest of the gallery is
reachable at all. See `Gallery.health` for why that matters.
"""
with self._lock:
rows = self._db.execute(
"SELECT model, COUNT(*) AS n FROM embeddings "
"GROUP BY model").fetchall()
return {str(r["model"]): int(r["n"]) for r in rows}
def identities_with_model(self, model: str) -> int:
"""Identities holding at least one embedding from this encoder.
Not the same as the identity count: an identity whose only vectors
came from a previous encoder still exists, and is unrecognisable.
"""
with self._lock:
row = self._db.execute(
"SELECT COUNT(DISTINCT identity_id) AS n FROM embeddings "
"WHERE model=?", (model,)).fetchone()
return int(row["n"]) if row else 0
def embedding_owners(self, model: "str | None" = None) -> "dict[int, int]":
"""embedding_id -> identity_id, for turning index hits into identity
pairs without a round trip to SQLite per hit."""

View File

@@ -35,6 +35,31 @@ _COPY_MAP = {
}
def _fetch(url: str, dest: Path, label: str) -> None:
"""Download with progress on stdout the supervisor can read.
On first run this is minutes of nothing: the API is not up yet, so the
app cannot ask the engine what it is doing, and a shop PC that shows a
stopped engine for five minutes after install looks broken. The
supervisor watches for `download: <label> <n>%` and puts the number in
the tray and the window. Logged every 5 points, not every chunk, so the
log file does not fill with a progress bar.
"""
last = -5
def hook(blocks: int, block_size: int, total: int) -> None:
nonlocal last
if total <= 0:
return
pct = min(100, blocks * block_size * 100 // total)
if pct >= last + 5:
last = pct
log.info("download: %s %d%%", label, pct)
urllib.request.urlretrieve(url, dest, hook)
log.info("download: %s 100%%", label)
def setup_models(models_dir: Path) -> "list[str]":
"""Ensure all model files exist in models_dir. Returns missing ones."""
models_dir = Path(models_dir)
@@ -44,7 +69,7 @@ def setup_models(models_dir: Path) -> "list[str]":
if not yunet.exists():
log.info("downloading YuNet face detector (~230 KB)...")
tmp = yunet.with_suffix(".part")
urllib.request.urlretrieve(YUNET_URL, tmp)
_fetch(YUNET_URL, tmp, "face detector")
tmp.rename(yunet)
log.info("YuNet saved to %s", yunet)
@@ -88,7 +113,7 @@ def setup_models(models_dir: Path) -> "list[str]":
import zipfile
tmp = models_dir / "buffalo_l.zip.part"
urllib.request.urlretrieve(BUFFALO_L_URL, tmp)
_fetch(BUFFALO_L_URL, tmp, "recognition models")
with zipfile.ZipFile(tmp) as zf:
for name, target in wanted.items():
member = next((n for n in zf.namelist()

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Behavision</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; margin: 0; }
@@ -513,11 +514,24 @@ async function refresh() {
]);
renderFeeds(camList);
renderCameras(camList);
// 'stalled' is its own word on purpose: connected and offline send you
// to the network, a camera that is answering and sending nothing does
// not. Three states, because two of them need opposite actions.
const cams = stats.cameras.map(c =>
`${c.camera_id}: ${c.connected ? 'live' : 'offline'}`).join(' · ');
`${c.camera_id}: ${c.streaming ? 'live' : c.connected ? 'stalled' : 'offline'}`
).join(' · ');
// The one failure that otherwise looks like perfect health: the encoder
// that loaded cannot read the embeddings already stored, so every known
// customer is a stranger. Counts stay right, which is why it needs saying.
const stranded = stats.gallery.stranded || 0;
const warn = stranded
? ` · ⚠ ${stats.gallery.identities_stranded} people unrecognisable `
+ `(${stranded} embeddings from ${stats.gallery.other_models.join(', ')}, `
+ `running ${stats.gallery.model})`
: '';
// textContent, not innerHTML — no escaping needed here.
document.getElementById('status').textContent =
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings`;
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings${warn}`;
document.getElementById('events').innerHTML = events.map(e => {
const cls = e.type === 'person.new' ? 'new'

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
brand/loyaly-icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
brand/loyaly-icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

BIN
brand/loyaly-icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
brand/loyaly-icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
brand/loyaly-icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
brand/loyaly-icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
brand/loyaly-icon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
brand/loyaly-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
brand/loyaly.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

258
demo/console.html Normal file
View File

@@ -0,0 +1,258 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Behavision — live demo</title>
<style>
:root{
--bg:#0A0E12; --s1:#11171C; --s2:#161D24; --s3:#1D262E;
--line:#24303A; --line2:#1B242C;
--ink:#E8EEF3; --ink2:#9FB0BD; --ink3:#6B7E8C;
--accent:#3DD0C4; --accent-dim:#123039;
--ok:#3FBF7F; --warn:#E0A33A; --bad:#E15B4C;
--mono:'SF Mono',ui-monospace,Menlo,monospace;
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--ink);font-family:var(--font);font-size:14px;line-height:1.5;
-webkit-font-smoothing:antialiased;padding:18px;min-height:100vh}
.top{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
.brand img{width:26px;height:26px;object-fit:contain}
.brand b{font-size:16px;letter-spacing:-.01em}
.brand span{color:var(--ink3);font-size:12px}
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:99px;
border:1px solid var(--line);background:var(--s1);font-size:12px;color:var(--ink2)}
.dot{width:7px;height:7px;border-radius:99px;background:var(--ink3);flex:none}
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,191,127,.16)}
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px rgba(225,91,76,.16)}
.dot.warn{background:var(--warn);box-shadow:0 0 0 3px rgba(224,163,58,.16)}
.grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start}
@media(max-width:1100px){.grid{grid-template-columns:minmax(0,1fr)}}
.card{background:var(--s1);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.card h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink3);
padding:12px 16px;border-bottom:1px solid var(--line2);display:flex;align-items:center;gap:10px}
.card h2 .grow{margin-left:auto;font-weight:500;letter-spacing:0;text-transform:none;font-size:12px;color:var(--ink3)}
.pad{padding:16px}
.cam{position:relative;aspect-ratio:16/9;background:#05090C}
.cam img{width:100%;height:100%;object-fit:cover;display:block}
.cam .none{position:absolute;inset:0;display:grid;place-items:center;color:var(--ink3);font-size:13px;text-align:center;padding:20px}
.cam .tag{position:absolute;top:10px;left:10px;background:rgba(10,14,18,.78);backdrop-filter:blur(8px);
border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:11.5px;font-family:var(--mono)}
/* the chain */
.chain{display:flex;flex-direction:column;gap:0}
.step{display:grid;grid-template-columns:26px 1fr auto;gap:12px;align-items:start;padding:11px 16px;
border-bottom:1px solid var(--line2);opacity:.38;transition:opacity .25s}
.step:last-child{border-bottom:0}
.step.on{opacity:1}
.step .n{width:22px;height:22px;border-radius:99px;display:grid;place-items:center;font-size:11px;font-weight:600;
background:var(--s3);color:var(--ink3);border:1px solid var(--line);margin-top:1px}
.step.on .n{background:var(--accent);color:#04161B;border-color:transparent}
.step b{font-size:13.5px;font-weight:550;display:block}
.step small{color:var(--ink2);font-size:12px;display:block;margin-top:1px;font-family:var(--mono)}
.step .ms{font-family:var(--mono);font-size:11.5px;color:var(--accent);white-space:nowrap;margin-top:2px}
.empty{padding:34px 16px;text-align:center;color:var(--ink3);font-size:13px;line-height:1.6}
.empty b{display:block;color:var(--ink2);font-size:14px;margin-bottom:5px}
/* customer */
.who{display:flex;gap:13px;align-items:center;padding:16px;border-bottom:1px solid var(--line2)}
.av{width:50px;height:50px;border-radius:10px;background:var(--s3);border:1px solid var(--line);
display:grid;place-items:center;font-weight:600;font-size:17px;color:var(--ink2);flex:none;overflow:hidden}
.av img{width:100%;height:100%;object-fit:cover}
.who .n{font-size:16px;font-weight:600;letter-spacing:-.01em}
.who .m{color:var(--ink3);font-size:12.5px;margin-top:2px}
.badge{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10.5px;font-weight:600;
letter-spacing:.04em;text-transform:uppercase}
.badge.new{background:var(--accent-dim);color:var(--accent)}
.badge.seen{background:rgba(63,191,127,.14);color:var(--ok)}
label{display:block;font-size:11.5px;font-weight:550;color:var(--ink2);margin-bottom:5px}
input{width:100%;background:var(--s2);border:1px solid var(--line);border-radius:7px;padding:9px 11px;
color:var(--ink);font:inherit;font-size:13.5px}
input:focus{outline:none;border-color:var(--accent)}
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
button{background:var(--accent);color:#04161B;border:0;border-radius:7px;padding:9px 16px;
font:inherit;font-size:13px;font-weight:600;cursor:pointer}
button:disabled{opacity:.45;cursor:default}
button.sec{background:var(--s3);color:var(--ink);border:1px solid var(--line)}
.saved{color:var(--ok);font-size:12.5px;margin-top:9px;display:flex;align-items:center;gap:6px}
/* raw json */
pre{font-family:var(--mono);font-size:11px;line-height:1.55;color:var(--ink2);
background:#080C10;border-top:1px solid var(--line2);padding:13px 16px;margin:0;
max-height:230px;overflow:auto;white-space:pre-wrap;word-break:break-word}
.req{font-family:var(--mono);font-size:11.5px;color:var(--accent);padding:10px 16px;background:var(--s2)}
.req .st{float:right;color:var(--ink3)}
.hint{color:var(--ink3);font-size:12px;padding:10px 16px 14px;line-height:1.55}
.stack{display:flex;flex-direction:column;gap:14px}
</style>
</head>
<body>
<div class="top">
<div class="brand">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDIxcy04LTQuNS04LTEwYTQuNSA0LjUgMCAwIDEgOC0yLjggNC41IDQuNSAwIDAgMSA4IDIuOGMwIDUuNS04IDEwLTggMTB6IiBmaWxsPSIjRjJDMTFGIi8+PC9zdmc+" alt="">
<div><b>Behavision</b> <span id="site">— live demo</span></div>
</div>
<span class="pill"><i class="dot" id="d-eng"></i><span id="t-eng">engine…</span></span>
<span class="pill"><i class="dot" id="d-cam"></i><span id="t-cam">camera…</span></span>
<span class="pill"><i class="dot" id="d-cloud"></i><span id="t-cloud">cloud…</span></span>
</div>
<div class="grid">
<div class="stack">
<div class="card">
<h2>The camera <span class="grow" id="camname"></span></h2>
<div class="cam">
<img id="feed" alt="" style="display:none">
<div class="none" id="feednone">waiting for the camera…</div>
<div class="tag" id="camtag" style="display:none"></div>
</div>
</div>
<div class="card">
<h2>The customer <span class="grow">type a name, then walk past again</span></h2>
<div id="cust">
<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>
</div>
</div>
</div>
<div class="stack">
<div class="card">
<h2>What just happened <span class="grow" id="lat"></span></h2>
<div id="chain"><div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div></div>
</div>
<div class="card">
<h2>What the mobile app receives</h2>
<div class="req" id="m-req">GET /api/visits<span class="st" id="m-st"></span></div>
<pre id="m-body">…</pre>
</div>
<div class="card">
<h2>What the dashboard receives</h2>
<div class="req" id="d-req">GET /api/reports/footfall<span class="st" id="d-st"></span></div>
<pre id="d-body">…</pre>
<div class="hint">Both of these are the real production API at mcp.loyaly.ai, called from this
machine with a staff login — not a mock, and not the local engine.</div>
</div>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
let camStarted = null, current = null, savedFor = null;
function setPill(dot, text, tone, label){
$(dot).className = 'dot' + (tone ? ' ' + tone : '');
$(text).textContent = label;
}
function initials(name, ref){
const m = /^Visitor (\d+)$/.exec((name||'').trim());
if (m) return m[1];
const w = (name||'').trim().split(/\s+/).filter(Boolean);
if (!w.length) return '?';
return (w[0][0] + (w[1]?.[0] ?? '')).toUpperCase();
}
function drawChain(e){
if (!e){ $('#chain').innerHTML = '<div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div>'; $('#lat').textContent=''; return; }
const g = e.engine, c = e.cloud;
const steps = [
[true, 'Camera saw a face', g.quality != null ? `quality ${(+g.quality).toFixed(2)} · camera ${g.camera}` : `camera ${g.camera}`, ''],
[true, e.kind === 'new' ? 'Engine: nobody it knows → enrolled' : 'Engine: matched a returning customer',
(g.label || '') + (g.similarity != null && g.similarity >= 0 ? ` · similarity ${(+g.similarity).toFixed(2)}` : '') , ''],
[true, 'Agent queued the visit', 'durable on this disk until the broker confirms', ''],
[!!c, 'Broker delivered it', 'MQTT over TLS to mcp.loyaly.ai', ''],
[!!c, 'Server recorded it', c ? `${c.site} · ${c.is_new ? 'new customer' : 'returning'}` : 'waiting…', ''],
[!!c, 'Mobile + dashboard can see it', c ? `visit ${String(c.visit_id).slice(0,8)}` : 'waiting…',
e.latency != null ? `+${e.latency}s` : ''],
];
$('#chain').innerHTML = steps.map(([on,title,sub,ms],i)=>
`<div class="step ${on?'on':''}"><div class="n">${i+1}</div><div><b>${title}</b><small>${sub}</small></div><div class="ms">${ms}</div></div>`
).join('');
$('#lat').textContent = e.latency != null ? `camera → cloud in ${e.latency}s` : '';
}
function drawCustomer(e){
const c = e && e.cloud;
if (!c){ if(!current) $('#cust').innerHTML = '<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>'; return; }
const changed = !current || current.visitor_id !== c.visitor_id || current.visit_id !== c.visit_id;
if (!changed) return;
current = c;
const name = c.label || 'Unrecognised';
const img = c.image && c.image.available && c.image.url;
$('#cust').innerHTML = `
<div class="who">
<div class="av">${img ? `<img src="${img}">` : initials(name)}</div>
<div style="flex:1;min-width:0">
<div class="n">${name}</div>
<div class="m">${c.ref ? c.ref + ' · ' : ''}${c.is_new ? 'first time here' : 'returning'}${c.similarity>0 ? ' · match ' + (+c.similarity).toFixed(2) : ''}</div>
</div>
<span class="badge ${c.is_new?'new':'seen'}">${c.is_new?'new':'returning'}</span>
</div>
<div class="pad">
<div class="row">
<div><label>Name</label><input id="f-name" placeholder="e.g. Suriya" value=""></div>
<div><label>Phone</label><input id="f-phone" placeholder="+91…" value=""></div>
</div>
<button id="save">Save to the customer record</button>
<div id="savedmsg"></div>
<div class="hint" style="padding:12px 0 0">This writes to the production API. Walk past again and
the name comes back through the cloud instead of “${name}”.</div>
</div>`;
$('#save').onclick = async () => {
const b = $('#save'); b.disabled = true; b.textContent = 'Saving…';
const r = await fetch('/api/profile', {method:'POST', headers:{'content-type':'application/json'},
body: JSON.stringify({id: c.visitor_id, full_name: $('#f-name').value, phone: $('#f-phone').value})});
const d = await r.json();
b.disabled = false; b.textContent = 'Save to the customer record';
$('#savedmsg').innerHTML = (d.status===200||d.status===204)
? '<div class="saved">✓ Saved — PUT /api/visitors/'+String(c.visitor_id).slice(0,8)+'…/profile → '+d.status+'</div>'
: '<div class="saved" style="color:var(--bad)">'+(d.body&&d.body.message||('HTTP '+d.status))+'</div>';
savedFor = c.visitor_id;
};
}
async function tick(){
let s;
try { s = await (await fetch('/api/snapshot')).json(); } catch { return; }
const eng = s.engine || {};
setPill('#d-eng','#t-eng', eng.up ? 'ok' : 'bad', eng.up ? ('engine · ' + (eng.model||'starting')) : 'engine starting…');
const cam = (eng.cameras||[])[0];
setPill('#d-cam','#t-cam', cam && cam.connected ? 'ok' : 'warn',
cam ? (cam.connected ? `camera live · ${cam.frames||0} frames` : 'camera connecting…') : 'no camera yet');
setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable');
$('#camname').textContent = cam ? cam.id : '';
if (cam && cam.connected){
if (camStarted !== cam.id){
camStarted = cam.id;
$('#feed').style.display = 'block'; $('#feednone').style.display = 'none';
$('#camtag').style.display = 'block';
// A polled still rather than an MJPEG stream: the engine re-serves its
// latest frame anyway, and a multipart stream through a proxy is one
// more thing to fail in front of an audience.
setInterval(() => { $('#feed').src = '/camera.jpg?id=' +
encodeURIComponent(camStarted) + '&t=' + Date.now(); }, 350);
}
$('#camtag').textContent = cam.id + (cam.tracks ? ` · ${cam.tracks} in frame` : '');
}
const e = (s.chain||[])[0];
drawChain(e); drawCustomer(e);
$('#m-st').textContent = s.mobile.status;
$('#m-body').textContent = JSON.stringify(s.mobile.body, null, 1).slice(0, 2600);
$('#d-st').textContent = s.dashboard.status;
$('#d-body').textContent = JSON.stringify(s.dashboard.body, null, 1).slice(0, 1800);
}
tick(); setInterval(tick, 1500);
</script>
</body>
</html>

330
demo/console.py Normal file
View File

@@ -0,0 +1,330 @@
"""A one-screen live demo of the whole Behavision chain, for showing someone.
Run it on the shop PC (here, this Mac) while the engine and agent are running.
It holds every credential itself and the browser holds none, so the page can be
put on a projector without putting a token on it.
What it shows, and why each part is there:
- the live camera, so the person walking past sees themselves;
- the CHAIN, measured rather than described: the engine recognised a face at
this instant, the same visit appeared in the cloud API this many seconds
later. That number is the product's claim, and it is computed here from two
independent sources rather than asserted;
- the customer, editable - type a name, walk past again, watch the name come
back through the cloud instead of "Visitor 5";
- the raw JSON a phone and a dashboard receive, side by side, because a
colleague's real question is "is this actually wired up or is it a mock".
.venv/bin/python demo/console.py # http://127.0.0.1:8099
"""
from __future__ import annotations
import base64
import json
import os
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
STATE = Path(os.environ.get("BEHAVISION_DATA_DIR", ROOT / ".demo"))
CLOUD = os.environ.get("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")
ENGINE = "http://127.0.0.1:8010"
PORT = int(os.environ.get("DEMO_PORT", "8099"))
# Whoever the demo signs in as. Staff on purpose: it is the weakest role that
# can do everything the shop floor does, so nothing here is only possible
# because we used an owner.
EMAIL = os.environ.get("DEMO_EMAIL", "staff.demo@tenext.in")
PASSWORD = os.environ.get("DEMO_PASSWORD", "admin@123")
def engine_auth() -> str:
"""The engine invents a Basic credential when none is configured, and
writes it here. Read it rather than keeping a second copy."""
f = STATE / "data" / "api_credentials.txt"
if not f.exists():
return ""
user = pw = ""
for line in f.read_text().splitlines():
# `key=value`, and `key: value` too - the engine writes one and people
# read the other, and which is which is not worth a support call.
if "=" in line or ":" in line:
k, v = line.split("=", 1) if "=" in line else line.split(":", 1)
if k.strip().lower() == "username":
user = v.strip()
elif k.strip().lower() == "password":
pw = v.strip()
if not user:
return ""
return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
def fetch(url: str, *, headers=None, body=None, method="GET", timeout=20):
req = urllib.request.Request(url, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={k: v for k, v in (headers or {}).items() if v})
if body is not None:
req.add_header("content-type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw and r.headers.get("content-type", "").startswith("application/json") else raw)
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw or b"{}")
except Exception:
return e.code, raw[:400]
except Exception as e:
return 0, {"error": str(e)}
class Cloud:
"""The signed-in session, refreshed when it expires."""
def __init__(self):
self.token = ""
self.lock = threading.Lock()
def sign_in(self) -> bool:
st, d = fetch(f"{CLOUD}/api/auth/login", method="POST",
body={"email": EMAIL, "password": PASSWORD, "device": "Demo console"})
if st == 200 and isinstance(d, dict):
self.token = d.get("access_token", "")
return True
return False
def call(self, path, method="GET", body=None, retry=True):
with self.lock:
if not self.token and not self.sign_in():
return 0, {"error": "cannot sign in to the platform"}
tok = self.token
st, d = fetch(f"{CLOUD}{path}", method=method, body=body,
headers={"authorization": f"Bearer {tok}"})
if st == 401 and retry:
with self.lock:
self.sign_in()
return self.call(path, method, body, retry=False)
return st, d
cloud = Cloud()
# The chain, as the watcher builds it. One dict per recognition, newest first.
events: list[dict] = []
events_lock = threading.Lock()
def watch():
"""Poll the engine's own event log and the cloud feed, and join them.
They are joined on the identity and the second, not on a shared id,
because the engine numbers identities locally and the server numbers them
per tenant - the two are deliberately different (see CLAUDE.md). What
matters for the demo is the LATENCY between one seeing a person and the
other, and that only needs the same person and the same moment.
"""
seen_local: set[str] = set()
while True:
try:
auth = engine_auth()
st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth})
# The engine returns a bare list; a dict with "events" is accepted
# too so this survives either shape.
evs = d if isinstance(d, list) else (d or {}).get("events", []) if isinstance(d, dict) else []
if st == 200:
for e in evs:
if e.get("type") not in ("person.new", "person.seen"):
continue
key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}"
if key in seen_local:
continue
seen_local.add(key)
data = e.get("data") or {}
with events_lock:
events.insert(0, {
"key": key,
"at": time.time(),
"kind": "new" if e["type"] == "person.new" else "seen",
"engine": {
"label": data.get("label"),
"identity_id": data.get("identity_id"),
"similarity": data.get("similarity"),
"quality": data.get("quality"),
"gender": data.get("gender"),
"age": data.get("age"),
"camera": e.get("camera_id"),
"ts": e.get("ts"),
},
"cloud": None,
"latency": None,
})
del events[40:]
except Exception:
pass
# the other half: has the cloud got it yet?
try:
with events_lock:
pending = [e for e in events if e["cloud"] is None][:6]
if pending:
st, d = cloud.call("/api/visits?limit=12")
arrivals = (d or {}).get("arrivals", []) if isinstance(d, dict) else []
for e in pending:
for a in arrivals:
# same camera, and the cloud's visit is not older than
# the engine's sighting
if a.get("camera_id") != e["engine"]["camera"]:
continue
if a.get("visit_id") in [x["cloud"].get("visit_id") for x in events if x["cloud"]]:
continue
with events_lock:
e["cloud"] = {
"visit_id": a.get("visit_id"),
"visitor_id": a.get("visitor_id"),
"label": a.get("label"),
"ref": a.get("customer_ref") or a.get("ref"),
"is_new": a.get("is_new_visitor"),
"similarity": a.get("similarity"),
"site": a.get("site"),
"occurred_at": a.get("occurred_at"),
"image": a.get("image"),
}
e["latency"] = round(time.time() - e["at"], 1)
break
except Exception:
pass
time.sleep(1.0)
def snapshot() -> dict:
"""Everything the page draws, in one reply."""
auth = engine_auth()
_, health = fetch(f"{ENGINE}/api/health", headers={"authorization": auth})
_, stats = fetch(f"{ENGINE}/api/stats", headers={"authorization": auth})
st_v, visits = cloud.call("/api/visits?limit=3")
st_f, foot = cloud.call("/api/reports/footfall?from=%s&to=%s"
% (time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400)),
time.strftime("%Y-%m-%d")))
with events_lock:
chain = json.loads(json.dumps(events[:8]))
cams = (stats or {}).get("cameras", []) if isinstance(stats, dict) else []
return {
"engine": {
"up": isinstance(health, dict) and bool(health.get("status")),
"model": (health or {}).get("recognition_model") if isinstance(health, dict) else None,
"cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"),
"frames": c.get("frames_processed") or c.get("frames_total"),
"faces": c.get("faces_seen"),
"tracks": c.get("active_tracks")} for c in cams],
},
"cloud_ok": st_v == 200,
"chain": chain,
"mobile": {"request": "GET /api/visits?limit=3", "status": st_v, "body": visits},
"dashboard": {"request": "GET /api/reports/footfall?from=…&to=…", "status": st_f, "body": foot},
}
def main():
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
page = (Path(__file__).parent / "console.html").read_bytes()
class H(BaseHTTPRequestHandler):
def log_message(self, *a): # quiet
pass
def _send(self, code, body, ctype="application/json"):
self.send_response(code)
self.send_header("content-type", ctype)
self.send_header("content-length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def do_GET(self):
u = urlparse(self.path)
if u.path == "/":
return self._send(200, page, "text/html; charset=utf-8")
if u.path == "/api/snapshot":
return self._send(200, json.dumps(snapshot()).encode())
if u.path == "/api/customer":
vid = parse_qs(u.query).get("id", [""])[0]
if not vid:
return self._send(400, b'{"error":"no id"}')
st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8")
return self._send(200, json.dumps({"status": st, "history": d}).encode())
if u.path == "/camera.jpg":
# A polled still, not the MJPEG stream. The engine re-serves its
# latest frame until the pipeline produces a new one, so polling
# shows the same picture - and a multipart stream through a
# proxy is one more thing to fail in front of an audience.
cam = parse_qs(u.query).get("id", [""])[0]
st, body = fetch(f"{ENGINE}/api/cameras/{cam}/frame.jpg",
headers={"authorization": engine_auth()}, timeout=15)
if st != 200 or not isinstance(body, (bytes, bytearray)):
return self._send(502, b'{"error":"no frame"}')
self.send_response(200)
self.send_header("content-type", "image/jpeg")
self.send_header("cache-control", "no-store")
self.send_header("content-length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
self._send(404, b'{"error":"no"}')
def do_POST(self):
u = urlparse(self.path)
n = int(self.headers.get("content-length", 0))
body = json.loads(self.rfile.read(n) or b"{}")
if u.path == "/api/profile":
vid = body.pop("id", "")
st, d = cloud.call(f"/api/visitors/{vid}/profile", method="PUT", body=body)
return self._send(200, json.dumps({"status": st, "body": d}).encode())
self._send(404, b'{"error":"no"}')
def _proxy_stream(self, url):
"""The engine's MJPEG, relayed so the browser needs no credential.
The engine's API is Basic-authenticated with a credential it
generated locally; putting that in a page would hand the whole
biometric API to anyone who opened it.
"""
try:
req = urllib.request.Request(url, headers={"authorization": engine_auth()})
up = urllib.request.urlopen(req, timeout=20)
except Exception:
return self._send(502, b'{"error":"camera not available"}')
self.send_response(200)
self.send_header("content-type", up.headers.get("content-type", "multipart/x-mixed-replace"))
self.end_headers()
try:
while True:
chunk = up.read(8192)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
except Exception:
pass
finally:
up.close()
threading.Thread(target=watch, daemon=True).start()
print(f"\n Demo console → http://127.0.0.1:{PORT}\n")
print(f" engine {ENGINE} · cloud {CLOUD} · signed in as {EMAIL}\n")
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
if __name__ == "__main__":
main()

View File

@@ -23,6 +23,7 @@ import (
agentcameras "github.com/loyaly/behavision-agent/pkg/cameras"
agentcfg "github.com/loyaly/behavision-agent/pkg/config"
agentengine "github.com/loyaly/behavision-agent/pkg/engine"
"github.com/loyaly/behavision-agent/pkg/enrol"
agentmqtt "github.com/loyaly/behavision-agent/pkg/mqtt"
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
agentspool "github.com/loyaly/behavision-agent/pkg/spool"
@@ -43,6 +44,12 @@ type App struct {
broker *agentmqtt.Client
stopBridge func()
hookURL string
// The resolved engine command, so engineMissing() and the supervisor are
// never looking at two different paths.
engineExe string
// Relays camera feeds to the webview so the engine's credential never has
// to travel in an <img> src, which a Chromium webview would strip anyway.
proxy *streamProxy
// Set once the operator logs in. Until then the UI shows the login sheet
// and nothing else is reachable.
onSessionChange func(bool)
@@ -62,7 +69,8 @@ func NewApp() *App {
return &App{
cfg: cfg,
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
local: local.New(base, cfg.APIUser, cfg.APIPassword),
local: localWithCreds(base, cfg),
proxy: newStreamProxy(),
}
}
@@ -70,6 +78,22 @@ func (a *App) startup(ctx context.Context) {
a.ctx = ctx
_ = agentpaths.EnsureState()
// Before any screen asks for a camera URL. A failure here is logged and
// not fatal: the rest of the app - people, cameras, the engine controls -
// works without a picture, and refusing to start over a broken tile would
// take a working shop offline.
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.
if a.cfg.SessionToken != "" {
@@ -88,10 +112,21 @@ func (a *App) startup(ctx context.Context) {
if exe != "" && !filepath.IsAbs(exe) {
exe = filepath.Join(agentpaths.InstallRoot(), exe)
}
a.mu.Lock()
a.engineExe = exe
a.mu.Unlock()
logFile, _ := agentengine.LogFile(agentpaths.EngineLog())
a.sup = agentengine.New(agentengine.Options{
Command: func(c context.Context) *exec.Cmd {
cmd := exec.CommandContext(c, exe, a.cfg.EngineArgs...)
// Run the engine FROM a known directory rather than from whatever
// happened to launch us. A double-clicked bundle hands its child
// "/", and an engine invoked as `-m behavision` then cannot find
// itself - measured on macOS, where it retried forever.
cmd.Dir = a.cfg.EngineDir
if cmd.Dir == "" {
cmd.Dir = agentpaths.InstallRoot()
}
// How the engine learns where to post its detections. Its config
// already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`,
// and python-dotenv does not override a variable the process
@@ -102,7 +137,7 @@ func (a *App) startup(ctx context.Context) {
// be told again. Without it the engine recognised people and the
// bridge received nothing: a claimed shop PC published heartbeats
// and zero visits.
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
cmd.Env = agentengine.ChildEnv(a.webhookURL())
return cmd
},
LogWriter: logFile,
@@ -112,6 +147,66 @@ func (a *App) startup(ctx context.Context) {
})
a.startPipeline(ctx)
go a.watchConfig(ctx)
// Recognition starts with the app. Until this, the engine only ever
// started when somebody pressed Start - which meant a till that rebooted
// overnight came back with the window open, the tray icon showing, the
// session restored, and recognition off until a shop assistant noticed.
// That is the failure the tray colours exist to catch, and it should not
// be the default state every morning.
//
// Guarded on the interpreter actually being there: on a PC where setup has
// not run yet, starting the supervisor would loop on a missing executable
// with nothing useful to say. The Start button still exists for the one
// case where somebody has deliberately stopped it.
if why := a.engineMissing(); why == "" {
a.sup.Start()
} else {
log.Printf("%s (looked for %s)", why, exe)
}
}
// engineMissing says, in a sentence somebody can act on, why recognition
// cannot start here - or "" when it can.
//
// It exists because the answer was only ever given at startup, to a log file
// nobody on a shop counter opens. Pressing Start went straight to the
// supervisor, which reported what exec reported:
//
// engine failed to start: fork/exec /private/var/folders/c2/.../
// AppTranslocation/500A5354-.../d/Behavision.app/Contents/MacOS/engine/
// behavision: no such file or directory
//
// Every word of that is true and none of it says "run the setup tool". One
// function, consulted by the startup path, the Start button and the status
// panel, so the three cannot give three different accounts of one fact.
func (a *App) engineMissing() string {
a.mu.RLock()
exe := a.engineExe
a.mu.RUnlock()
if exe == "" {
return "The recognition engine is not set up on this computer yet."
}
if _, err := os.Stat(exe); err == nil {
return ""
}
msg := "The recognition engine is not installed on this computer yet. " +
"Run behavision-setup from the folder you unzipped, then press Start."
// macOS quarantines a downloaded app it cannot verify and runs it from a
// randomly named READ-ONLY copy - App Translocation. Every relative path
// then resolves inside that copy, which is why the engine folder appears
// to be missing from a bundle that plainly contains one, and why an
// install into it would not survive a restart. Detectable, unguessable,
// and fixed by one drag; saying nothing leaves somebody re-running a
// setup tool that cannot win.
if strings.Contains(exe, "/AppTranslocation/") {
msg = "macOS is running Behavision from a temporary read-only copy, " +
"because it was opened straight from Downloads. Move Behavision " +
"to your Applications folder and open it from there, then run " +
"behavision-setup."
}
return msg
}
// webhookURL is the loopback address the bridge is listening on, or empty
@@ -195,7 +290,7 @@ func (a *App) startPipeline(ctx context.Context) {
}
client, err := agentmqtt.NewClient(agentmqtt.ClientOptions{
BrokerURL: a.cfg.BrokerURL,
ClientID: "behavision-" + a.cfg.ClientID + "-" + a.cfg.SiteID,
ClientID: a.cfg.MQTTClientID(),
Username: a.cfg.BrokerUsername, Password: a.cfg.BrokerPassword,
CAFile: a.cfg.BrokerCAFile, Log: logger,
})
@@ -229,6 +324,9 @@ func (a *App) startLocalCameras(ctx context.Context, logger *log.Logger) {
// this app - and the headless agent - both ended up wiring configuration
// and forgetting the check runner, so "Test connection" at head office
// never completed on any shop PC.
// The live relay runs alongside the reconciler and uploads nothing until
// somebody at head office is actually watching a camera.
go agentcameras.NewLive(camEngine, camCloud, logger).Run(ctx)
agentcameras.New(camEngine, camCloud, logger).Run(ctx)
}
@@ -270,8 +368,8 @@ type PipelineStatus struct {
// Standalone separates "nothing is being sent because this PC is set up on
// its own" from "nothing is being sent and something is wrong". They look
// identical from the counters alone, and only one of them is a fault.
Standalone bool `json:"standalone"`
BrokerUp bool `json:"broker_up"`
Standalone bool `json:"standalone"`
BrokerUp bool `json:"broker_up"`
Accepted uint64 `json:"accepted"`
}
@@ -402,6 +500,14 @@ func (a *App) Claim(code string) (SessionInfo, error) {
a.cfg.BrokerPassword = b.MQTTPass
a.cfg.AgentToken = b.AgentToken
a.cfg.CloudBase = a.cloud.Base
// A new head office: whoever was signed in was signed in somewhere else.
a.cfg.SessionToken, a.cfg.SessionRefresh, a.cfg.SessionEmail = "", "", ""
a.cloud.Clear()
caPath, err := enrol.SaveCA(b.CACert, agentpaths.BrokerCA())
if err != nil {
return SessionInfo{}, err
}
a.cfg.BrokerCAFile = caPath
// A PC that was running on its own and has now been linked is no longer
// standalone. Leaving the flag set would keep the head-office screens
// hidden on the one machine that just earned them.
@@ -426,6 +532,62 @@ func (a *App) Claim(code string) (SessionInfo, error) {
// the current config. Only Claim needs it today; it exists as its own method
// because "stop everything that reads the config, then start it" is the part
// that is easy to get half right.
// watchConfig reloads agent.json when something else writes it.
//
// behavision-setup re-run on a PC with the app open re-claims the shop and
// rotates its API token; the running app kept the old one and every camera
// sync was refused from then on - heartbeats still flowed, so head office
// looked fine while the cameras went stale. A claim from `behavision-agent
// claim` does the same. Rather than ask people to restart the app, the app
// watches the file and picks the new credentials up itself.
func (a *App) watchConfig(ctx context.Context) {
path := agentpaths.AgentConfig()
last := mtime(path)
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
now := mtime(path)
if now.IsZero() || now.Equal(last) {
continue
}
last = now
fresh, err := agentcfg.Load(path)
if err != nil {
continue
}
fresh = fresh.WithEngineCredentials(agentpaths.APICredentials())
a.mu.Lock()
changed := fresh.AgentToken != a.cfg.AgentToken || fresh.SiteID != a.cfg.SiteID ||
fresh.BrokerPassword != a.cfg.BrokerPassword || fresh.CloudBase != a.cfg.CloudBase ||
fresh.Standalone != a.cfg.Standalone
if changed {
// Keep this process's live session; a claim clears it in the file
// deliberately, and that is honoured too.
a.cfg = fresh
if fresh.SessionToken == "" {
a.cloud.Clear()
}
}
a.mu.Unlock()
if changed {
a.restartPipeline()
}
}
}
func mtime(path string) time.Time {
st, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return st.ModTime()
}
func (a *App) restartPipeline() {
if a.stopBridge != nil {
a.stopBridge()
@@ -463,6 +625,8 @@ type EngineStatus struct {
Reachable bool `json:"reachable"`
Model string `json:"recognition_model,omitempty"`
Cameras map[string]bool `json:"cameras,omitempty"`
// Progress is the first-run model download, when one is happening.
Progress *agentengine.Progress `json:"progress,omitempty"`
}
func (a *App) EngineStatus() EngineStatus {
@@ -473,9 +637,17 @@ func (a *App) EngineStatus() EngineStatus {
st, err := a.sup.State()
out.State = string(st)
out.Restarts = a.sup.Restarts()
if p := a.sup.Progress(); p.What != "" {
out.Progress = &p
}
if err != nil {
out.Error = err.Error()
}
// The supervisor's own error is an exec failure; this replaces it with
// the reason, which is the part that tells somebody what to do.
if why := a.engineMissing(); why != "" {
out.Error = why
}
ctx, cancel := context.WithTimeout(a.ctx, 4*time.Second)
defer cancel()
// A running process is not a working engine: on a memory-starved box the
@@ -490,6 +662,13 @@ func (a *App) EngineStatus() EngineStatus {
}
func (a *App) StartEngine() EngineStatus {
// Refused rather than attempted. Handing a missing path to the supervisor
// produces a retry loop and an exec error for a message.
if why := a.engineMissing(); why != "" {
st := a.EngineStatus()
st.Error = why
return st
}
if a.sup != nil {
a.sup.Start()
}
@@ -505,10 +684,52 @@ 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
// form is a pick-list and not a request for an IP address nobody knows.
func (a *App) DiscoverCameras() (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
defer cancel()
return a.local.DiscoverCameras(ctx)
}
func (a *App) TestCamera(cam map[string]any) (map[string]any, error) {
@@ -546,15 +767,42 @@ func (a *App) PlacementResult(id string) (map[string]any, error) {
return a.local.PlacementResult(ctx, id)
}
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an
// <img> tag can load it. Loopback only - it never leaves this machine.
// StreamURL is the MJPEG endpoint for a camera tile.
//
// It points at this app's own loopback relay, not at the engine directly. The
// previous version put the engine's Basic credentials inline in the URL, with
// a comment saying they were there "so an <img> tag can load it" - which a
// browser will not do. Chromium strips credentials from subresource URLs, and
// WebView2 is Chromium, so every camera tile on a shop PC was a broken image.
// See stream_proxy.go for the measurement.
//
// The relay is also why no password appears in the page any more. If it is not
// running the fallback is the bare engine URL with no credential: correct for
// an engine configured without auth, and for one with auth a tile that fails
// to load rather than a password sitting in the DOM.
func (a *App) StreamURL(cameraID string) string {
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
if a.local.User == "" {
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
if u := a.proxy.urlFor(cameraID, "stream.mjpeg"); u != "" {
return u
}
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg",
a.local.User, a.local.Password, base, cameraID)
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
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 --
@@ -562,20 +810,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 --
@@ -611,6 +932,15 @@ func (a *App) Sites() ([]cloud.SiteHealth, error) {
}
// VisitorHistory is one customer's timeline, for the customer record screen.
// Ask is the help panel. It needs head office: the assistant runs there,
// against this company's own data, as this signed-in user. A PC running on
// its own has nobody to ask, and the panel says so rather than erroring.
func (a *App) Ask(history []cloud.AssistantTurn) (cloud.AssistantAnswer, error) {
ctx, cancel := context.WithTimeout(a.ctx, 90*time.Second)
defer cancel()
return a.cloud.Ask(ctx, history)
}
func (a *App) VisitorHistory(id string, limit int) ([]cloud.Visit, error) {
if limit <= 0 {
limit = 100
@@ -685,3 +1015,12 @@ func envOr(key, def string) string {
}
return def
}
// localWithCreds builds the engine client with a credential resolver, so a
// first run - where the engine writes its credential after the app has looked
// for it - recovers by itself instead of 401ing for the life of the process.
func localWithCreds(base string, cfg agentcfg.Config) *local.Client {
c := local.New(base, cfg.APIUser, cfg.APIPassword)
c.Creds = agentcfg.NewCreds(agentpaths.APICredentials(), cfg.APIUser, cfg.APIPassword)
return c
}

BIN
desktop/build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{safeBundleID .Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{safeBundleID .Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

25
desktop/darwin_link.go Normal file
View File

@@ -0,0 +1,25 @@
//go:build darwin
// Link the framework Wails' darwin frontend forgets.
//
// It references UTType (UniformTypeIdentifiers) without linking it, so a macOS
// build fails at the LINK step with `Undefined symbols: _OBJC_CLASS_$_UTType`
// - after compiling everything successfully, which makes it read like a broken
// toolchain rather than one missing flag. That is why there was no Mac build:
// not a design limit, a link error nobody had chased.
//
// Declared in the source rather than passed as CGO_LDFLAGS on the command
// line, for the same reason deploy.sh now finds Go itself: a build that needs
// the operator to know an incantation is a build that does not happen. Plain
// `go build` and `wails build` both work on a Mac with this file present, and
// the build tag makes it inert everywhere else.
//
// Note for anyone editing: the comment directly above `import "C"` is cgo's C
// PREAMBLE, not documentation. This paragraph sits above `package main` on
// purpose - put it there and the prose is compiled as C, which is how the
// first attempt failed.
package main
// #cgo LDFLAGS: -framework UniformTypeIdentifiers
import "C"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -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-whFsTNQf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.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>

View File

@@ -1,11 +1,14 @@
import { useCallback, useEffect, useState } from 'react'
import { api, isDesktop, message } from './bridge.js'
import { usePolled } from './hooks.js'
import * as Icon from './ui/icons.jsx'
import logo from './assets/loyaly-mark.png'
import Login from './views/Login.jsx'
import Setup from './views/Setup.jsx'
import Live from './views/Live.jsx'
import Customers from './views/Customers.jsx'
import Cameras from './views/Cameras.jsx'
import Assistant from './views/Assistant.jsx'
// Three screens, and the trim is by AUDIENCE rather than by taste.
//
@@ -22,9 +25,9 @@ import Cameras from './views/Cameras.jsx'
// the customer record lives on the server, the cameras and what this PC is
// seeing do not.
const VIEWS = [
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
{ id: 'live', label: 'Live', Glyph: Icon.Live, View: Live },
{ id: 'customers', label: 'Customers', Glyph: Icon.People, View: Customers, cloud: true },
{ id: 'cameras', label: 'Cameras', Glyph: Icon.Camera, View: Cameras },
]
export default function App() {
@@ -34,12 +37,23 @@ export default function App() {
// A standalone PC can join head office later. That is the same Setup screen,
// reached deliberately rather than because the app will not open otherwise.
const [linking, setLinking] = useState(false)
const [helping, setHelping] = useState(false)
useEffect(() => {
(async () => {
try { setSession(await api.session()) } catch { setSession(null) }
setBooting(false)
})()
let alive = true
const load = async () => {
try {
const s = await api.session()
if (alive) setSession(prev => JSON.stringify(prev) === JSON.stringify(s) ? prev : s)
} catch { if (alive) setSession(null) }
if (alive) setBooting(false)
}
load()
// Re-read every few seconds: a session the server has ended - or one
// that never belonged to this head office - must put Login back on
// screen, not leave "session expired" banners on every page.
const id = setInterval(load, 8000)
return () => { alive = false; clearInterval(id) }
}, [])
if (!isDesktop()) {
@@ -48,6 +62,7 @@ export default function App() {
// error nobody will read.
return (
<div className="login"><div className="box">
<span className="mark"><img src={logo} alt="" /></span>
<h1>Behavision</h1>
<p className="lead">
This is the Behavision window running outside the app, so it has no
@@ -78,41 +93,56 @@ export default function App() {
<div className="shell">
<aside className="side">
<div className="brand">
<h1>Behavision</h1>
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
<span className="mark"><img src={logo} alt="" /></span>
<div className="id">
<h1>Behavision</h1>
<p>{session.site_name || session.user?.client_name || 'This shop'}</p>
</div>
</div>
<nav className="nav">
{views.map(v => (
<button key={v.id} onClick={() => setView(v.id)}
aria-current={v.id === view ? 'page' : undefined}>
<span className="glyph">{v.glyph}</span>{v.label}
{views.map(({ id, label, Glyph }) => (
<button key={id} onClick={() => setView(id)}
aria-current={id === view ? 'page' : undefined}>
<Glyph size={17} />{label}
</button>
))}
</nav>
<EngineBox />
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
<div className="who">
{session.standalone
? <>
<div className="note" style={{ marginBottom: 8 }}>
Running on its own
<div className="id">
<b>On its own</b>
<span>No head office</span>
</div>
<button className="btn sm" style={{ width: '100%' }}
<button className="btn sm icon" title="Link to head office"
onClick={() => setLinking(true)}>
Link to head office
<Icon.Link size={15} />
</button>
</>
: <>
<div className="note" style={{ marginBottom: 8 }}>
{session.user?.email}
<div className="id">
<b>Signed in</b>
<span>{session.user?.email}</span>
</div>
<button className="btn sm" style={{ width: '100%' }}
<button className="btn sm icon" title="Sign out"
onClick={async () => setSession(await api.logout())}>
Sign out
<Icon.Logout size={15} />
</button>
</>}
</div>
</aside>
<main className="main"><Current session={session} /></main>
<main className="main">
<Current session={session} onNavigate={setView} />
{/* Loya's door, top right of every screen. A buddy you have to find in
a sidebar is not around; one in the corner is. */}
{!helping && (
<button className="loya-fab" onClick={() => setHelping(true)} aria-label="Ask Loya" title="Ask Loya">
<img src={logo} alt="" /><span>Loya</span>
</button>
)}
</main>
{helping && <Assistant session={session} onClose={() => setHelping(false)} />}
</div>
)
}
@@ -136,7 +166,13 @@ function EngineBox() {
const up = cams.filter(Boolean).length
let tone = 'idle', text = 'Stopped'
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
// Reachable but not ours: somebody started the engine outside this app, or a
// previous copy is still up. Saying "Stopped" beside live camera feeds is the
// two-surfaces-disagreeing bug the tray exists to avoid - and it is exactly
// what this panel showed while recognition was visibly running.
if (!running && s.reachable) { tone = 'warn'; text = 'Running outside the app' }
else if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
else if (running && !s.reachable && s.progress) { tone = 'warn'; text = `Downloading ${s.progress.what}… ${s.progress.percent}%` }
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
@@ -145,16 +181,21 @@ function EngineBox() {
return (
<div className="enginebox">
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
{s.recognition_model && (
<span className="label">Model: {s.recognition_model}</span>
)}
<div className="row">
<i className={`dot ${tone === 'ok' ? 'live' : tone}`} />
<span className="state">{text}</span>
</div>
{s.recognition_model && <span className="label">{s.recognition_model}</span>}
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
<div className="actions">
<button className="btn sm" disabled={busy || running}
onClick={() => act(api.startEngine)}>Start</button>
<button className="btn sm" disabled={busy || running || s.reachable}
onClick={() => act(api.startEngine)}>
<Icon.Play size={13} />Start
</button>
<button className="btn sm" disabled={busy || !running}
onClick={() => act(api.stopEngine)}>Stop</button>
onClick={() => act(api.stopEngine)}>
<Icon.Stop size={13} />Stop
</button>
</div>
</div>
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -32,11 +32,15 @@ export const api = {
cameras: () => call('Cameras'),
testCamera: (cam) => call('TestCamera', cam),
discoverCameras: () => call('DiscoverCameras'),
saveCamera: (id, cam) => call('SaveCamera', id, cam),
deleteCamera: (id) => call('DeleteCamera', id),
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'),
@@ -51,6 +55,7 @@ export const api = {
sales: (from, to) => call('Sales', from, to),
customers: (q, limit) => call('Customers', q, limit),
saveProfile: (p) => call('SaveProfile', p),
ask: (history) => call('Ask', history),
recordPurchase: (id, amount, items, notes) =>
call('RecordPurchase', id, amount, items, notes),
}

View File

@@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
// Dev only: ?mock=<scenario> renders the app in a browser with fake bindings.
if (import.meta.env.DEV) await import('./mock.js')
createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>
)

View File

@@ -0,0 +1,83 @@
// A stand-in for the Go bindings, for looking at screens in a browser.
//
// The app only exists inside Wails, so until this there was no way to put a
// screen in front of somebody without a Windows build - which is how the
// first-run experience went unreviewed. Loaded ONLY by the dev server and only
// with ?mock=<scenario>; a production bundle never contains it.
//
// ?mock=fresh first launch after install: unclaimed, engine starting
// ?mock=standalone chose "run on this PC only", no cameras yet
// ?mock=claimed claimed, signed in, one camera, arrivals flowing
//
// State is in memory and advances as you click, so the flow can be walked.
const scenario = new URLSearchParams(location.search).get('mock')
if (scenario) {
const st = {
claimed: scenario === 'claimed',
standalone: scenario === 'standalone',
logged_in: scenario === 'claimed',
cameras: scenario === 'claimed' ? [{id: 'entrance', label: 'Entrance', host: '192.168.1.122', port: 554, path: '/ch0_1.264', username: 'admin', has_password: true, enabled: true}] : [],
engine: scenario === 'fresh' ? 'starting' : 'running',
startedAt: Date.now(),
}
const user = {id: 'u1', email: 'suriya@tenext.in', full_name: 'Suriya', role: 'owner', client_id: 'c1', client_name: 'TeNext Retail'}
const session = () => ({logged_in: st.logged_in, user: st.logged_in ? user : {}, site_name: st.claimed ? 'TeNext Chennai' : '', claimed: st.claimed, standalone: st.standalone && !st.claimed})
const now = () => new Date().toISOString()
const arrivals = () => st.cameras.length === 0 ? [] : [
{type: 'person.seen', ts: now(), camera_id: 'entrance', data: {label: 'Visitor 3', identity_id: 3, similarity: 0.61, gender: 'Male', age: 34, emotion: 'neutral'}},
{type: 'person.new', ts: new Date(Date.now() - 95e3).toISOString(), camera_id: 'entrance', data: {label: 'Visitor 7', identity_id: 7, gender: 'Female', age: 28}},
{type: 'person.seen', ts: new Date(Date.now() - 410e3).toISOString(), camera_id: 'entrance', data: {label: 'Priya', identity_id: 2, similarity: 0.72, gender: 'Female', age: 41}},
]
const delay = (v, ms = 120) => new Promise(r => setTimeout(() => r(v), ms))
const App = {
Session: () => delay(session()),
Login: (email) => { st.logged_in = true; user.email = email || user.email; return delay(session()) },
Logout: () => { st.logged_in = false; return delay(session()) },
Claim: (code) => code.replace(/[^A-Z0-9]/gi, '').length >= 20
? (st.claimed = true, st.standalone = false, delay(session(), 900))
: Promise.reject(new Error('That installation code is not valid. Ask for a new one.')),
RunStandalone: () => { st.standalone = true; return delay(session()) },
EngineStatus: () => {
// The engine takes a minute or two on first run (models download).
if (st.engine === 'starting' && Date.now() - st.startedAt > 20000) st.engine = 'running'
const cams = Object.fromEntries(st.cameras.map(c => [c.id, true]))
return delay({state: st.engine === 'starting' ? 'running' : 'running', reachable: st.engine !== 'starting', recognition_model: st.engine === 'starting' ? '' : 'w600k_r50', cameras: cams, restarts: 0})
},
StartEngine: () => delay({state: 'running', reachable: true}),
StopEngine: () => delay({state: 'stopped', reachable: false}),
Cameras: () => delay(st.cameras.map(c => ({...c, connected: true, frames: 1200, faces: 9}))),
DiscoverCameras: () => delay({networks: ['192.168.1.0/24'], cameras: [
{host: '192.168.1.122', rtsp: true, onvif: true, name: 'HIKVISION DS-2CD2043G2', make: 'hikvision'},
{host: '192.168.1.121', rtsp: true, onvif: true, name: 'IPC-model IPC', make: ''},
{host: '192.168.1.40', rtsp: true, onvif: false, name: '', make: ''},
]}, 2500),
TestCamera: (cam) => delay({ok: Boolean(cam.host), width: 800, height: 448, codec: 'hevc', error: cam.host ? '' : 'no host'}, 1500),
SaveCamera: (id, cam) => { const c = {id: id || cam.id || 'cam' + (st.cameras.length + 1), ...cam, has_password: Boolean(cam.password)}; delete c.password; st.cameras = [...st.cameras.filter(x => x.id !== c.id), c]; return delay(c) },
DeleteCamera: (id) => { st.cameras = st.cameras.filter(c => c.id !== id); return delay(null) },
StartPlacementCheck: () => delay({state: 'running'}),
PlacementResult: () => delay({state: 'finished', verdict: 'good', headline: 'faces recognised on a walk-past', advice: []}),
StreamURL: () => '',
Live: () => delay({stats: {cameras: st.cameras.map(c => ({camera_id: c.id, connected: true, pipeline: {best_quality: {n: 40, fraction_below_gate: 0.18}}})), gallery: {identities: st.cameras.length ? 7 : 0, sightings: st.cameras.length ? 44 : 0}}, events: arrivals()}),
PipelineStatus: () => delay({webhook_url: 'http://127.0.0.1:53658/events', queued: 0, dropped: 0, claimed: st.claimed, standalone: st.standalone && !st.claimed, broker_up: st.claimed, accepted: st.claimed ? 12 : 0}),
LocalIdentities: () => delay([]), LocalSightings: () => delay([]),
Footfall: () => delay({total: 0, buckets: []}), Sites: () => delay([]),
VisitorHistory: () => delay([]), VisitorPhoto: () => delay({available: false, reason: 'This system is not storing images.'}),
ForgetCustomer: () => delay(null), Sales: () => delay([]),
Customers: () => delay([{id: 'v1', label: 'Priya', number: 2, first_seen_at: now(), last_seen_at: now(), visits: 6}]),
SaveProfile: () => delay(null), RecordPurchase: () => delay(null),
Ask: (history) => {
const q = history[history.length - 1]?.text ?? ''
if (!st.claimed) return Promise.reject(new Error('The assistant is not switched on for this server.'))
const text = /camera/i.test(q)
? 'Go to Cameras and press Add camera. The address is on a sticker on the camera itself; pick the make and I fill in the stream path. Test it, save it, then walk past it once so I can tell you whether the placement works.'
: /code|install/i.test(q)
? 'Whoever runs head office makes one: open the shop there, press Set up a shop PC, and read the code out. It works once.'
: /who|morning|came/i.test(q)
? 'Three people so far: Priya at 13:12 (her sixth visit), a new face at 13:18 I have called Visitor 7, and Visitor 3 just now.'
: 'Chennai is online and the door camera is connected, but nobody has proved it yet. Walk past it once with Check placement running and I will tell you if it can actually see faces - until then a quiet screen might just be a badly aimed camera.'
return delay({text, used: /camera|code/i.test(q) ? [] : ['site_status', 'cameras']}, 1400)
},
}
window.go = {main: {App}}
}

View File

@@ -1,252 +1,665 @@
/* Behavision desktop — an instrument panel, not a website.
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
but not cramped, and state readable at a glance from across a counter. */
/* Behavision desktop — a shop-floor instrument, not a website.
===========================================================================
Designed for one situation: a PC behind a counter, on a cheap monitor, in a
room with daylight, glanced at by somebody who is mid-conversation with a
customer. Everything below follows from that.
- Dark, because the screen sits in peripheral vision all day and a white
field at 1000 lux is a lamp pointed at the operator.
- State is carried by shape AND colour: a pill, a dot and an edge stripe,
never colour alone. This gets read from two metres away, and some
operators do not see red and green apart.
- One spacing scale and one type scale. The previous version set margins
inline, per screen, which is how a UI ends up looking assembled rather
than designed.
- Motion only where it carries meaning: a live camera, a fresh arrival.
Nothing loops for decoration — this process shares a CPU with recognition.
=========================================================================== */
:root {
--ground: #0E1317;
--surface: #161D23;
--surface-2: #1D262D;
--line: #27333B;
--line-soft: #1F2A31;
--ink: #E7EEF3;
--ink-2: #B4C2CC;
--muted: #7C8B97;
--accent: #45B0C7;
--accent-dim:#123039;
--ok: #4FB37B;
--warn: #E0A33A;
--bad: #E0655A;
--radius: 8px;
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
/* ground → raised, four steps, blue-green biased: the product lives in the
world of lenses and CCTV, and a neutral grey reads as unfinished. */
--bg: #0A0F13;
--s1: #111A20;
--s2: #17232B;
--s3: #1E2D37;
--line: #223038;
--line-2: #1A252C;
--ink: #ECF3F7;
--ink-2: #A3B6C2;
--ink-3: #6C808D;
/* Accent is for state and focus only, never decoration, so that when it does
appear the eye goes to it. */
--accent: #40C4DC;
--accent-2: #0F3B47;
--accent-3: #0B2A33;
--ok: #48C78E; --ok-2: #102E22;
--warn: #EAAA3D; --warn-2: #31260F;
--bad: #EC6A5C; --bad-2: #331815;
--r-sm: 6px; --r: 10px; --r-lg: 14px;
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
--sp-5: 20px; --sp-6: 24px; --sp-7: 32px; --sp-8: 40px;
--shadow: 0 1px 2px rgb(0 0 0 / .4), 0 8px 24px -12px rgb(0 0 0 / .6);
--shadow-lg: 0 2px 4px rgb(0 0 0 / .4), 0 24px 48px -16px rgb(0 0 0 / .7);
/* Segoe UI Variable first: it is on every Windows 11 shop PC, it has real
optical sizes, and it is what makes this look like an application rather
than a web page in a frame. No webfont — a shop PC has no internet at
install time, and a font that fails to arrive is a layout that shifts
under the operator. */
--font: "Segoe UI Variable Text", "Segoe UI", Inter, -apple-system,
BlinkMacSystemFont, system-ui, "Helvetica Neue", Arial, sans-serif;
--font-display: "Segoe UI Variable Display", var(--font);
--mono: "Cascadia Mono", "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
/* Kept as aliases so any screen not yet rewritten keeps its colours. */
--ground: var(--bg); --surface: var(--s1); --surface-2: var(--s2);
--line-soft: var(--line-2); --muted: var(--ink-3); --radius: var(--r);
--accent-dim: var(--accent-3);
}
* { box-sizing: border-box; margin: 0; }
html, body, #root { height: 100%; }
body {
background: var(--ground);
background: var(--bg);
color: var(--ink);
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
user-select: none;
}
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------------------------------------------------------------- shell -- */
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
button, input, select, textarea { font: inherit; color: inherit; }
input, textarea { user-select: text; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
::selection { background: var(--accent-2); color: var(--ink); }
/* Digits that line up wherever they are compared or refreshed in place. */
.num, .value, .metric-v, .when, .mono, .code, td { font-variant-numeric: tabular-nums; }
.mono, .code { font-family: var(--mono); }
/* The default light scrollbar on a dark panel is the most obvious "this is a
web page" tell there is. */
* { scrollbar-width: thin; scrollbar-color: var(--s3) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: var(--s3); border-radius: 99px; border: 3px solid var(--bg); }
*::-webkit-scrollbar-thumb:hover { background: #2A3D49; }
/* ================================================================ shell == */
.shell { display: grid; grid-template-columns: 232px 1fr auto; height: 100%; }
.side {
background: var(--surface); border-right: 1px solid var(--line);
background: var(--s1); border-right: 1px solid var(--line);
display: flex; flex-direction: column; min-height: 0;
}
.side .brand {
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
}
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
.side .brand { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); }
.side .brand .mark { width: 30px; height: 30px; flex: none; display: grid; place-items: center; }
.side .brand .mark img, .login .mark img { width: 100%; height: 100%; object-fit: contain; display: block; }
.side .brand .id { min-width: 0; }
.side .brand h1 { font-family: var(--font-display); font-size: 15px; font-weight: 600; letter-spacing: -.012em; line-height: 1.2; }
.side .brand p { font-size: 11.5px; color: var(--ink-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: 2px; flex: 1; }
.nav button {
display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
position: relative; display: flex; align-items: center; gap: var(--sp-3); width: 100%;
background: none; border: 0; border-radius: var(--r-sm); padding: 9px var(--sp-3);
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px; font-weight: 450;
transition: background .12s ease, color .12s ease;
}
.nav button:hover { background: var(--surface-2); color: var(--ink); }
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
display: block; line-height: 1.4; }
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
.main { min-width: 0; min-height: 0; overflow-y: auto; }
.page { padding: 22px 26px 40px; max-width: 1180px; }
.page > header { margin-bottom: 18px; }
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
/* --------------------------------------------------------------- pieces -- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 16px;
.nav button svg { flex: none; opacity: .9; }
.nav button:hover { background: var(--s2); color: var(--ink); }
.nav button[aria-current="page"] { background: var(--accent-3); color: var(--accent); font-weight: 550; }
/* A rail, not a background wash: it survives being looked at sideways. */
.nav button[aria-current="page"]::before {
content: ""; position: absolute; left: -12px; top: 7px; bottom: 7px;
width: 2.5px; border-radius: 0 2px 2px 0; background: var(--accent);
}
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
.grid { display: grid; gap: 14px; }
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
font-variant-numeric: tabular-nums; line-height: 1.1; }
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
/* The one control that starts and stops the product, so it gets its own block
at the foot rather than a row in a list. */
.enginebox {
margin: var(--sp-3); padding: var(--sp-3) var(--sp-4) var(--sp-4);
border: 1px solid var(--line); border-radius: var(--r); background: var(--s2);
}
.enginebox .row { display: flex; align-items: center; gap: var(--sp-2); }
.enginebox .state { font-size: 12.5px; font-weight: 600; letter-spacing: -.005em; }
.enginebox .label { display: block; color: var(--ink-3); font-size: 11px; line-height: 1.45; margin-top: 3px; font-variant-numeric: tabular-nums; }
.enginebox .actions, .enginebox .controls { display: flex; gap: var(--sp-2); margin-top: var(--sp-3); }
.enginebox .actions .btn, .enginebox .controls .btn { flex: 1; justify-content: center; padding: 6px 8px; font-size: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.bad { background: var(--bad); }
.dot.idle { background: var(--muted); }
.side .who {
padding: var(--sp-3) var(--sp-5) var(--sp-5); border-top: 1px solid var(--line-2);
display: flex; align-items: center; gap: var(--sp-3);
}
.side .who .id { min-width: 0; flex: 1; }
.side .who .id b { display: block; font-size: 12.5px; font-weight: 550; }
.side .who .id span { display: block; font-size: 11px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
color: var(--muted); white-space: nowrap; }
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
.main { min-width: 0; min-height: 0; overflow: auto; position: relative; }
/* ================================================================= page == */
.page { padding: var(--sp-6) var(--sp-7) var(--sp-8); max-width: 1500px; }
.page > header { margin-bottom: var(--sp-5); }
.page > header h2, .page h2 { font-family: var(--font-display); font-size: 22px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
.page > header p, .page header p { color: var(--ink-3); font-size: 13px; margin-top: 3px; }
.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap; }
h3 { font-size: 11px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
/* ================================================================ cards == */
.card { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-4); }
.card > h3 { margin-bottom: var(--sp-3); }
.card.flush { padding: 0; overflow: hidden; }
.panel { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.panel > .panelhead {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--line-2);
background: linear-gradient(var(--s2), var(--s1)); flex: none;
}
.panel > .panelhead h3 { margin: 0; }
.panel > .panelbody { padding: var(--sp-4); min-height: 0; overflow: auto; }
.panel > .panelbody.flush { padding: 0; }
.grid { display: grid; gap: var(--sp-4); }
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 1180px) { .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
@media (max-width: 980px) { .cols-2, .cols-3 { grid-template-columns: minmax(0,1fr); } }
/* Four equal boxes used to dominate this screen. The numbers matter, but they
are not what anybody opens the app to see. */
.metrics {
display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
gap: 1px; background: var(--line); border: 1px solid var(--line);
border-radius: var(--r); overflow: hidden;
}
.metric { background: var(--s1); padding: var(--sp-3) var(--sp-4) var(--sp-4); }
.metric .metric-k { font-size: 10.5px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
.metric .metric-v { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; margin-top: 5px; }
.metric .metric-s { font-size: 11.5px; color: var(--ink-3); margin-top: 3px; line-height: 1.4; }
.metric.ok .metric-v { color: var(--ok); }
.metric.warn .metric-v { color: var(--warn); }
.metric.bad .metric-v { color: var(--bad); }
/* legacy .stat, for screens not yet rewritten */
.stat h3 { margin-bottom: var(--sp-2); }
.stat .value { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; }
.stat .unit { font-size: 15px; color: var(--ink-3); margin-left: 3px; }
.stat .sub { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; line-height: 1.4; }
/* =============================================================== status == */
.dot { width: 7px; height: 7px; border-radius: 99px; flex: none; background: var(--ink-3); }
.dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 18%, transparent); }
.dot.warn { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 18%, transparent); }
.dot.bad { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 18%, transparent); }
.dot.idle { background: var(--ink-3); }
/* A live camera is the one thing that should breathe: it is how an operator
knows the picture is not frozen. Everything else holds still. */
.dot.live { background: var(--ok); animation: pulse 2.4s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ok) 0%, transparent); }
}
.pill {
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px 3px 7px;
border-radius: 99px; font-size: 11px; font-weight: 600; letter-spacing: .02em;
background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); white-space: nowrap;
}
.pill.ok { background: var(--ok-2); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 28%, transparent); }
.pill.warn { background: var(--warn-2); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 28%, transparent); }
.pill.bad { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 28%, transparent); }
.pill.accent { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
.tag {
display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--r-sm);
font-size: 10.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
background: var(--s3); color: var(--ink-2);
}
.tag.new { background: var(--accent-3); color: var(--accent); }
.tag.seen { background: var(--ok-2); color: var(--ok); }
.tag.miss { background: var(--warn-2); color: var(--warn); }
/* One line that answers "is this shop working" above everything else. */
.statusbar {
display: flex; align-items: center; gap: var(--sp-5); flex-wrap: wrap;
padding: var(--sp-3) var(--sp-4); background: var(--s1);
border: 1px solid var(--line); border-radius: var(--r); margin-bottom: var(--sp-4);
}
.statusbar .item { display: flex; align-items: center; gap: var(--sp-2); font-size: 12.5px; }
.statusbar .item b { font-weight: 600; letter-spacing: -.005em; }
.statusbar .item svg { color: var(--ink-3); }
.statusbar .sep { width: 1px; align-self: stretch; background: var(--line); }
.statusbar .grow { flex: 1; }
/* ============================================================= arrivals == */
/* The reason the product exists, so it gets the width and the weight. */
.arrivals { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); }
.arrival {
display: grid; grid-template-columns: 46px 1fr auto; gap: var(--sp-3); align-items: center;
padding: var(--sp-3); border-radius: var(--r);
background: var(--s2); border: 1px solid var(--line-2);
position: relative; overflow: hidden;
}
.arrival::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2.5px; background: var(--ink-3); }
.arrival.is-new::before { background: var(--accent); }
.arrival.is-seen::before { background: var(--ok); }
.arrival.is-miss::before { background: var(--warn); }
/* Only the newest row animates, and only once. */
.arrival.fresh { animation: slidein .28s cubic-bezier(.2,.8,.3,1); }
@keyframes slidein { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
.arrival .avatar {
width: 46px; height: 46px; border-radius: var(--r-sm); display: grid; place-items: center;
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
font-family: var(--font-display); font-size: 15px; font-weight: 600;
color: var(--ink-2); letter-spacing: -.01em; font-variant-numeric: tabular-nums;
}
.arrival .avatar img { width: 100%; height: 100%; object-fit: cover; }
.arrival.is-new .avatar { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
.arrival .who { min-width: 0; }
.arrival .who .name { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.arrival .who .meta { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.arrival .right { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }
.arrival .right .when { font-size: 11.5px; color: var(--ink-3); }
/* ================================================================ feeds == */
.feeds { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); padding: var(--sp-3); }
.feed { position: relative; border-radius: var(--r); overflow: hidden; background: #05090C; border: 1px solid var(--line); aspect-ratio: 16 / 9; }
.feed img { width: 100%; height: 100%; object-fit: cover; display: block; }
.feed .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
/* Caption over the picture, not beneath it: the tile stays a picture. */
.feed .cap {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
padding: var(--sp-5) var(--sp-3) var(--sp-3);
background: linear-gradient(transparent, rgb(0 0 0 / .8));
font-size: 12.5px; font-weight: 600; letter-spacing: -.005em;
}
/* ================================================================ lists == */
.events, .timeline { list-style: none; padding: 0; display: flex; flex-direction: column; }
.events li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-4); border-bottom: 1px solid var(--line-2); font-size: 13px; }
.timeline li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
.events li:last-child, .timeline li:last-child { border-bottom: 0; }
.events .when, .timeline .when { font-size: 11.5px; color: var(--ink-3); width: 46px; flex: none; }
.empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: var(--sp-3); padding: var(--sp-8) var(--sp-5); color: var(--ink-3); text-align: center; font-size: 12.5px;
}
.empty svg { opacity: .35; }
.empty b { display: block; color: var(--ink-2); font-size: 13.5px; font-weight: 550; }
.empty p { max-width: 34ch; line-height: 1.5; }
.tablewrap { overflow: auto; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th {
text-align: left; padding: 9px var(--sp-4); font-size: 10.5px; font-weight: 600;
letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3);
background: var(--s2); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 1;
}
td { padding: 10px var(--sp-4); border-bottom: 1px solid var(--line-2); }
tbody tr:last-child td { border-bottom: 0; }
tbody tr[role="button"], tbody tr.clickable { cursor: pointer; }
tbody tr[role="button"]:hover, tbody tr.clickable:hover { background: var(--s2); }
/* ============================================================= controls == */
.btn {
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
color: var(--ink); white-space: nowrap;
display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
border-radius: var(--r-sm); background: var(--s3); color: var(--ink);
border: 1px solid var(--line); cursor: pointer;
font-size: 13px; font-weight: 550; letter-spacing: -.005em; white-space: nowrap;
transition: background .12s ease, border-color .12s ease, transform .06s ease;
}
.btn:hover:not(:disabled) { background: #26323a; }
.btn:disabled { opacity: .45; cursor: default; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
.btn.danger { color: var(--bad); border-color: #4a2823; }
.btn.sm { padding: 4px 9px; font-size: 12px; }
.btn:hover:not(:disabled) { background: #253643; border-color: #2E414E; }
.btn:active:not(:disabled) { transform: translateY(.5px); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn svg { flex: none; }
.btn.primary { background: var(--accent); color: #04171C; border-color: transparent; font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: #55D0E6; }
.btn.danger { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 32%, transparent); }
.btn.danger:hover:not(:disabled) { background: #43201C; }
.btn.ghost { background: transparent; }
.btn.ghost:hover:not(:disabled) { background: var(--s2); }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.icon { padding: 7px; }
.field { display: block; margin-bottom: 12px; }
.field span { display: block; font-size: 11.5px; color: var(--muted);
margin-bottom: 4px; letter-spacing: .01em; }
.linkbtn { background: none; border: 0; color: var(--accent); cursor: pointer; font-size: 12.5px; padding: 2px 0; text-align: left; }
.linkbtn:hover { text-decoration: underline; }
.seg { display: inline-flex; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 2px; gap: 2px; }
.seg button { background: none; border: 0; border-radius: 4px; padding: 5px 11px; color: var(--ink-3); cursor: pointer; font-size: 12.5px; font-weight: 500; }
.seg button[aria-pressed="true"] { background: var(--s3); color: var(--ink); }
/* ================================================================ forms == */
.field { display: block; margin-bottom: var(--sp-4); }
.field > span { display: block; font-size: 11.5px; font-weight: 550; color: var(--ink-2); margin-bottom: 6px; }
.field input, .field select, .field textarea {
width: 100%; background: var(--ground); border: 1px solid var(--line);
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
user-select: text;
width: 100%; padding: 9px 11px; background: var(--s2); color: var(--ink);
border: 1px solid var(--line); border-radius: var(--r-sm);
transition: border-color .12s ease, background .12s ease, box-shadow .12s ease;
}
.field input::placeholder { color: var(--ink-3); }
.field input:hover, .field select:hover, .field textarea:hover { border-color: #2C3D49; }
.field input:focus, .field select:focus, .field textarea:focus {
border-color: var(--accent); outline: none;
outline: none; border-color: var(--accent); background: var(--s1); box-shadow: 0 0 0 3px var(--accent-3);
}
.field textarea { resize: vertical; min-height: 66px; }
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
.field .hint { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 5px; line-height: 1.45; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
letter-spacing: .08em; color: var(--muted); font-weight: 600;
padding: 8px 10px; border-bottom: 1px solid var(--line); }
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
tr:last-child td { border-bottom: 0; }
tbody tr.click { cursor: pointer; }
tbody tr.click:hover { background: var(--surface-2); }
td.num { font-variant-numeric: tabular-nums; text-align: right; }
.tablewrap { overflow-x: auto; }
.fieldrow { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-3); }
.fieldrow.three { grid-template-columns: repeat(3, minmax(0,1fr)); }
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
.err {
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
display: flex; align-items: flex-start; gap: var(--sp-2);
background: var(--bad-2); color: var(--bad);
border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent);
border-radius: var(--r-sm); padding: 9px 11px; font-size: 12.5px; line-height: 1.45; margin-bottom: var(--sp-4);
}
.note { color: var(--muted); font-size: 12.5px; }
.mono { font-family: var(--mono); font-size: 12px; }
.err svg { flex: none; margin-top: 1px; }
/* --------------------------------------------------------------- login --- */
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
.login .box { width: 100%; max-width: 380px; }
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
.login form { background: var(--surface); border: 1px solid var(--line);
border-radius: 10px; padding: 20px; }
.login .btn { width: 100%; margin-top: 6px; }
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
text-align: center; line-height: 1.5; }
/* The second way out of the setup screen: a shop with no head office. Styled
quieter than the form above it because linking is still the common case,
but present, because for a single-till shop it is the only one that works. */
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
border-top: 1px solid var(--line-soft); }
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
.note { color: var(--ink-3); font-size: 12px; line-height: 1.5; }
.note.warn { color: var(--warn); }
.note.bad { color: var(--bad); }
.lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; }
.sm { font-size: 12px; }
.lbl, .key { color: var(--ink-3); font-size: 11.5px; }
.grow { flex: 1; }
.row { display: flex; align-items: center; gap: var(--sp-3); }
/* ================================================================= gate == */
/* Login and Setup: the first thing anybody sees, and previously a grey box on
a grey field. One soft light behind the card gives the window a centre and
costs nothing — it is a static gradient, not an animation. */
.login {
height: 100%; display: grid; place-items: center; padding: var(--sp-6); overflow: auto;
background: radial-gradient(900px 480px at 50% -10%, #10303A 0%, transparent 62%), var(--bg);
}
.login .box {
width: 100%; max-width: 396px; background: var(--s1); border: 1px solid var(--line);
border-radius: var(--r-lg); padding: var(--sp-7); box-shadow: var(--shadow-lg);
}
.login .mark { width: 44px; height: 44px; margin-bottom: var(--sp-4); display: grid; place-items: center; }
.login h1 { font-family: var(--font-display); font-size: 21px; font-weight: 600; letter-spacing: -.022em; }
.login .lead { margin: 6px 0 var(--sp-5); }
.login .btn { width: 100%; justify-content: center; margin-top: var(--sp-1); }
.login .foot { font-size: 11.5px; color: var(--ink-3); line-height: 1.55; margin-top: var(--sp-5); padding-top: var(--sp-4); border-top: 1px solid var(--line-2); }
.login .alt { margin-top: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
.login .alt .btn { margin-top: 0; }
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
font: inherit; font-size: 12.5px; color: var(--accent);
text-decoration: underline; text-underline-offset: 3px; }
.linkbtn:hover { color: var(--ink); }
.login .note { margin: 0; }
/* ---------------------------------------------------------------- live --- */
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; }
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
.feed .cap { display: flex; justify-content: space-between; align-items: center;
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
/* =============================================================== drawer == */
.events { list-style: none; max-height: 420px; overflow-y: auto; }
.events li { display: flex; gap: 9px; align-items: baseline;
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.events li:last-child { border-bottom: 0; }
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
flex: none; }
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
background: var(--surface-2); color: var(--muted); }
.tag.new { background: #17364f; color: #86c2ec; }
.tag.seen { background: #14301f; color: #7fcb9c; }
.tag.miss { background: #3a1c1a; color: #eb9a92; }
.drawer { position: fixed; inset: 0; z-index: 40; background: rgb(4 8 11 / .6); display: flex; justify-content: flex-end; animation: fade .16s ease; }
@keyframes fade { from { opacity: 0 } to { opacity: 1 } }
.drawer .sheet {
width: min(540px, 100%); height: 100%; overflow: auto;
background: var(--s1); border-left: 1px solid var(--line); box-shadow: var(--shadow-lg);
animation: slidein-r .2s cubic-bezier(.2,.8,.3,1);
}
@keyframes slidein-r { from { transform: translateX(16px); opacity: .6 } to { transform: none; opacity: 1 } }
.drawer .sheethead {
position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); background: var(--s1); border-bottom: 1px solid var(--line);
}
.drawer .sheethead h2 { font-family: var(--font-display); font-size: 17px; font-weight: 600; letter-spacing: -.015em; }
.drawer .sheetbody { padding: var(--sp-5); }
.drawer .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
.drawer .close:hover { background: var(--s2); color: var(--ink); }
/* -------------------------------------------------------------- charts --- */
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
gap: 2px; min-width: 0; }
.bars .seg { border-radius: 2px 2px 0 0; }
.bars .seg.ret { background: var(--accent); }
.bars .seg.new { background: #2f6f81; }
.axis { display: flex; justify-content: space-between; color: var(--muted);
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
margin-right: 5px; vertical-align: -1px; }
.avatar {
width: 44px; height: 44px; border-radius: var(--r-sm); flex: none; display: grid; place-items: center;
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
/* --------------------------------------------------------------- drawer -- */
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
display: flex; justify-content: flex-end; z-index: 30; }
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
/* Close lives in the sticky header (.who) now. Positioned against the fixed
overlay it stayed put while the sheet scrolled underneath it, printing the
button on top of whatever happened to be at the top of the viewport. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
/* -- customer record ---------------------------------------------------- */
/* Full-bleed sticky header: a customer record is long enough to scroll, and
both the name and the way out have to stay reachable. The negative margins
cancel the panel's padding so the background covers the full width. */
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
align-items: flex-start; background: var(--surface);
margin: -20px -22px 18px; padding: 20px 22px 14px;
border-bottom: 1px solid var(--line-soft); }
.who .grow { flex: 1; min-width: 0; }
.who h3 { margin-bottom: 2px; }
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
object-fit: cover; background: var(--ground);
border: 1px solid var(--line); }
.avatar.none { display: grid; place-items: center; color: var(--muted);
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
/* Cameras and arrivals side by side, the same height, each scrolling its own
content. Left to itself the arrivals panel shrank to fit two cards and left
a hole beside a tall camera tile - the layout looked broken precisely when
the shop was quiet, which is most of the time. */
.live-split {
display: grid; gap: var(--sp-4);
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
align-items: stretch;
min-height: 420px;
}
.live-split > .panel { max-height: 62vh; }
@media (max-width: 1100px) {
.live-split { grid-template-columns: minmax(0, 1fr); }
.live-split > .panel { max-height: none; }
}
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.timeline li:last-child { border-bottom: 0; }
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
flex: none; min-width: 108px; }
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
/* Arrivals alone on the Live screen: one column, capped so a long day scrolls
inside the panel rather than pushing the metrics off the bottom. */
.arrivals-panel { max-height: 64vh; margin-bottom: var(--sp-4); }
.arrivals-panel .arrivals { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--sp-2); }
/* Visually separated from Save: this is the one control in the sheet that
cannot be undone, and it must not read as just another button in a row. */
.danger-zone { margin-top: 22px; border-color: #4a2823; }
.danger-zone > h3 { color: var(--bad); }
.danger-zone .note { margin-bottom: 10px; }
/* =============================================================== cameras == */
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
margin-bottom: 12px; }
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); margin-bottom: 5px; }
.confirm .lbl.bad { color: var(--bad); }
.confirm ul { list-style: none; font-size: 12.5px; }
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
.confirm li::before { content: '·'; position: absolute; left: 2px;
color: var(--muted); }
.confirm .row { display: flex; gap: 8px; }
.pagehead { display: flex; justify-content: space-between; align-items: flex-end; gap: var(--sp-4); }
.page > header.pagehead { margin-bottom: var(--sp-5); }
.camgrid { display: grid; gap: var(--sp-4); grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); }
.camcard { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; }
.camview { position: relative; aspect-ratio: 16 / 9; background: #05090C; }
.camview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.camview .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
.camview .pill.over { position: absolute; top: 10px; right: 10px; backdrop-filter: blur(6px); }
.cambody { padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
.camtitle { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--sp-3); }
.camtitle h3 { font-family: var(--font-display); font-size: 16px; font-weight: 600; letter-spacing: -.012em; text-transform: none; color: var(--ink); margin: 0 0 2px; }
.camtitle .note { font-size: 12px; }
.camactions { display: flex; gap: 6px; flex: none; }
.camproof { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--sp-3);
padding: var(--sp-3); border-radius: var(--r); background: var(--s2); border: 1px solid var(--line-2); }
.camproof .note { font-size: 12px; line-height: 1.45; }
@media (max-width: 640px) { .camproof { grid-template-columns: 1fr; } }
.empty.tall { padding: var(--sp-8) var(--sp-6); }
.empty.tall .btn { margin-top: var(--sp-3); }
/* The sheet is a form that reads top to bottom: a sentence saying what is
needed, three short sections, the result of the test, the actions. */
.drawer .sheet { width: min(600px, 100%); }
.sheetbody .lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; margin-bottom: var(--sp-5); }
.formsection { margin-bottom: var(--sp-5); }
.formsection h4 { font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 var(--sp-3); padding-bottom: 6px; border-bottom: 1px solid var(--line-2); }
.field .hint { font-style: normal; }
.fieldrow { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.fieldrow .field.narrow { max-width: 140px; }
.fieldrow:has(.field.narrow) { grid-template-columns: minmax(0, 1fr) 140px; }
.field input.mono { font-family: var(--font-mono); font-size: 12.5px; }
.sheetactions { display: flex; gap: var(--sp-2); justify-content: flex-end; padding-top: var(--sp-4); border-top: 1px solid var(--line-2); margin-top: var(--sp-2); }
.testresult { display: flex; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border-radius: var(--r); margin-bottom: var(--sp-4); border: 1px solid; font-size: 13px; }
.testresult.ok { background: var(--ok-2); border-color: color-mix(in srgb, var(--ok) 30%, transparent); color: var(--ok); }
.testresult.bad { background: var(--bad-2); border-color: color-mix(in srgb, var(--bad) 30%, transparent); color: var(--bad); }
.testresult b { display: block; }
.testresult span { display: block; color: var(--ink-2); margin-top: 2px; }
.testresult img { width: 100%; margin-top: var(--sp-3); border-radius: var(--r-sm); border: 1px solid var(--line); display: block; }
/* Placement check: two numbered steps, then a verdict box in the tone of the answer. */
.steps { list-style: none; counter-reset: step; margin: 0 0 var(--sp-5); padding: 0; display: grid; gap: var(--sp-3); }
.steps li { counter-increment: step; position: relative; padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; border-radius: var(--r); border: 1px solid var(--line-2); background: var(--s2); opacity: .55; }
.steps li.now, .steps li.done { opacity: 1; }
.steps li::before { content: counter(step); position: absolute; left: 16px; top: 14px; width: 24px; height: 24px; border-radius: 50%;
display: grid; place-items: center; font-size: 12px; font-weight: 600; background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); }
.steps li.now::before { background: var(--accent); color: #041014; border-color: transparent; }
.steps li.done::before { content: '✓'; background: var(--ok-2); color: var(--ok); }
.steps b { display: block; font-size: 14px; }
.steps span { display: block; font-size: 12.5px; color: var(--ink-2); line-height: 1.5; margin-top: 2px; }
.progress { height: 4px; background: var(--s3); border-radius: 2px; overflow: hidden; margin-top: var(--sp-3); }
.progress > div { height: 100%; background: var(--accent); transition: width .4s linear; }
.verdict { padding: var(--sp-4); border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); }
.verdict.ok { border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-2); }
.verdict.warn { border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: var(--warn-2); }
.verdict.bad { border-color: color-mix(in srgb, var(--bad) 35%, transparent); background: var(--bad-2); }
.verdict-head { display: flex; align-items: center; gap: var(--sp-2); font-size: 15px; }
.verdict.ok .verdict-head { color: var(--ok); } .verdict.warn .verdict-head { color: var(--warn); } .verdict.bad .verdict-head { color: var(--bad); }
.verdict ul { margin: var(--sp-3) 0 0 18px; font-size: 13px; color: var(--ink); line-height: 1.5; }
.verdict ul li { margin-bottom: 5px; }
.verdict .note { margin-top: var(--sp-3); }
.spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg) } }
/* ============================================================== assistant == */
.helpbtn { display: flex; align-items: center; gap: 9px; margin: 0 var(--sp-3) var(--sp-3); padding: 9px 10px; border-radius: var(--r);
border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; font-size: 12.5px; font-weight: 550; text-align: left; }
.helpbtn img { width: 18px; height: 18px; object-fit: contain; }
.helpbtn span { flex: 1; }
.helpbtn:hover, .helpbtn[aria-pressed="true"] { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: var(--accent-3); }
.helper { width: 380px; height: 100%; display: flex; flex-direction: column; min-height: 0;
background: var(--s1); border-left: 1px solid var(--line); animation: slidein-r .2s cubic-bezier(.2,.8,.3,1); }
.helperhead { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4); border-bottom: 1px solid var(--line); }
.helperhead img { width: 26px; height: 26px; object-fit: contain; }
.helperhead div { flex: 1; min-width: 0; }
.helperhead b { display: block; font-family: var(--font-display); font-size: 14.5px; }
.helperhead span { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 1px; }
.helperhead .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
.helperhead .close:hover { background: var(--s2); color: var(--ink); }
.helperbody { flex: 1; overflow: auto; padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
.helperintro p { color: var(--ink-2); font-size: 13px; line-height: 1.55; margin-bottom: var(--sp-3); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { border: 1px solid var(--line); background: var(--s2); color: var(--ink); border-radius: 99px; padding: 6px 11px; font-size: 12px; cursor: pointer; text-align: left; }
.chip:hover { border-color: var(--accent); color: var(--accent); }
.chip:disabled { opacity: .5; cursor: default; }
.helpernote { display: flex; gap: var(--sp-3); padding: var(--sp-3); border-radius: var(--r); background: var(--warn-2); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); color: var(--warn); font-size: 12.5px; }
.helpernote b { display: block; } .helpernote span { display: block; color: var(--ink-2); margin-top: 2px; line-height: 1.45; }
.turn { display: flex; flex-direction: column; gap: 4px; }
.turn.user { align-items: flex-end; }
.bubble { max-width: 92%; padding: 10px 13px; border-radius: 14px; font-size: 13.5px; line-height: 1.5; white-space: pre-wrap; }
.turn.user .bubble { background: var(--accent); color: #041014; border-bottom-right-radius: 4px; }
.turn.assistant .bubble { background: var(--s2); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
.turn .used { font-size: 11px; color: var(--ink-3); padding: 0 4px; }
.bubble.thinking { display: flex; gap: 4px; padding: 12px 14px; }
.bubble.thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); animation: blink 1.2s infinite ease-in-out; }
.bubble.thinking span:nth-child(2) { animation-delay: .2s } .bubble.thinking span:nth-child(3) { animation-delay: .4s }
@keyframes blink { 0%, 80%, 100% { opacity: .25 } 40% { opacity: 1 } }
.helperask { display: flex; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--line); }
.helperask input { flex: 1; min-width: 0; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r); padding: 10px 12px; font-size: 13.5px; color: var(--ink); }
.helperask input:focus { outline: none; border-color: var(--accent); }
.helperask .btn { padding: 0 12px; }
.quickhelp { margin-top: var(--sp-5); border-top: 1px solid var(--line-2); padding-top: var(--sp-4); }
.quickhelp dt { font-size: 12.5px; font-weight: 600; margin-top: var(--sp-3); }
.quickhelp dd { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; margin: 3px 0 0; }
@media (max-width: 1100px) { .helper { position: fixed; right: 0; top: 0; bottom: 0; z-index: 30; box-shadow: var(--shadow-lg); } }
.search { display: flex; align-items: center; gap: 8px; background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: 0 12px; height: 36px; width: min(340px, 100%); color: var(--ink-3); }
.search input { flex: 1; min-width: 0; background: none; border: 0; outline: none; color: var(--ink); font-size: 13.5px; }
.search:focus-within { border-color: var(--accent); }
.sheethead.who { align-items: center; gap: var(--sp-3); }
.sheethead.who .grow { flex: 1; min-width: 0; }
.sheethead.who .note { margin-top: 2px; font-size: 12px; }
.sheethead .close { font-size: 14px; line-height: 1; }
.formsection .field:last-child { margin-bottom: 0; }
.formsection.dangerzone { margin-top: var(--sp-6); padding: var(--sp-4); border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent); border-radius: var(--r); background: var(--bad-2); }
.formsection.dangerzone h4 { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 30%, transparent); }
tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
/* Loya's door: fixed to the top-right of the content area, on every screen. */
.loya-fab { position: fixed; top: var(--sp-4); right: var(--sp-5); z-index: 20;
display: flex; align-items: center; gap: 8px; padding: 8px 14px 8px 10px; border-radius: 99px;
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); background: var(--s1); color: var(--ink);
box-shadow: var(--shadow-lg); cursor: pointer; font-size: 13px; font-weight: 600; }
.loya-fab img { width: 20px; height: 20px; object-fit: contain; }
.loya-fab:hover { background: var(--accent-3); border-color: var(--accent); }
/* Camera finder: the pick-list that replaces "type an IP address". */
.finder { display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border: 1px dashed var(--line); border-radius: var(--r); background: var(--s2); }
.finder p { font-size: 13px; color: var(--ink-2); line-height: 1.5; margin: 0; }
.finder.busy { flex-direction: row; align-items: center; color: var(--ink-2); font-size: 13px; border-style: solid; }
.foundlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.foundlist li button { width: 100%; display: flex; align-items: center; gap: var(--sp-3); padding: 10px 12px; border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; }
.foundlist li button:hover { border-color: var(--accent); }
.foundlist li.picked button { border-color: var(--accent); background: var(--accent-3); }
.foundlist .mono { font-family: var(--font-mono); font-size: 12.5px; min-width: 120px; }
.foundlist .what { flex: 1; color: var(--ink-2); }
.foundlist li.rescan button { width: auto; border: 0; background: none; padding: 4px 0; color: var(--ink-3); }
/* Welcome: two paths, each a card. */
.login .box.wide { max-width: 640px; }
.choices { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); margin-top: var(--sp-2); }
@media (max-width: 720px) { .choices { grid-template-columns: 1fr; } }
.choice { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; text-align: left; padding: var(--sp-4);
border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; }
.choice:hover { border-color: var(--accent); background: var(--accent-3); }
.choice b { font-size: 14px; }
.choice span { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; }
.choice em { font-family: var(--font-mono); font-style: normal; font-size: 11.5px; }
.choice svg { color: var(--accent); }
.login .foot em { font-style: normal; color: var(--ink-2); }
.linkbtn { display: inline-flex; align-items: center; gap: 6px; }
/* Getting started */
.starter { margin-bottom: var(--sp-4); }
.starter .panelhead { padding: var(--sp-3) var(--sp-4); }
.starter .steps { margin: 0; padding: var(--sp-3) var(--sp-4) 0; }
.starter .steps.compact li { padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; }
.starter .steps li .btn { margin-top: var(--sp-2); }
.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; }

View File

@@ -0,0 +1,71 @@
// One icon set, drawn rather than typed.
//
// The navigation used to be text characters — ◉ ☺ ▢ — which render in whatever
// the system decides, sit on the text baseline instead of optical centre, and
// cannot take a stroke weight. On a shop PC that is the difference between
// software somebody trusts with their customers and something that looks
// improvised.
//
// All of these are 24-unit grid, 1.6 stroke, currentColor, no fill. That means
// one icon works on every surface and in every state without a second copy.
const base = {
width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none',
stroke: 'currentColor', strokeWidth: 1.6,
strokeLinecap: 'round', strokeLinejoin: 'round',
'aria-hidden': 'true', focusable: 'false',
}
function Svg({ size, children, ...rest }) {
return <svg {...base} {...rest} width={size ?? base.width} height={size ?? base.height}>{children}</svg>
}
export const Live = p => (
<Svg {...p}><circle cx="12" cy="12" r="3.2" /><path d="M5.6 5.6a9 9 0 0 0 0 12.8M18.4 18.4a9 9 0 0 0 0-12.8" /></Svg>
)
export const People = p => (
<Svg {...p}><circle cx="9" cy="8.5" r="3.2" /><path d="M2.8 19.5a6.4 6.4 0 0 1 12.4 0" /><path d="M16.5 6.2a3.2 3.2 0 0 1 0 6.1M18 19.5a6 6 0 0 0-1.6-4" /></Svg>
)
export const Camera = p => (
<Svg {...p}><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /></Svg>
)
export const Search = p => (
<Svg {...p}><circle cx="11" cy="11" r="6.4" /><path d="M15.8 15.8 20.5 20.5" /></Svg>
)
export const Plus = p => (<Svg {...p}><path d="M12 5.5v13M5.5 12h13" /></Svg>)
export const Close = p => (<Svg {...p}><path d="M6.5 6.5l11 11M17.5 6.5l-11 11" /></Svg>)
export const Check = p => (<Svg {...p}><path d="M5 12.8l4.4 4.2L19 7" /></Svg>)
export const Play = p => (<Svg {...p}><path d="M8 5.6v12.8L18.5 12z" /></Svg>)
export const Stop = p => (<Svg {...p}><rect x="7" y="7" width="10" height="10" rx="1.6" /></Svg>)
export const Warning = p => (
<Svg {...p}><path d="M12 4.6 21 19.4H3z" /><path d="M12 10v4.1" /><path d="M12 17.1v.01" /></Svg>
)
export const Signal = p => (
<Svg {...p}><path d="M5 19.4v-4.2M10.3 19.4v-7.6M15.7 19.4v-11M21 19.4V4.6" /></Svg>
)
export const Cloud = p => (
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 .3 9.4z" /></Svg>
)
export const CloudOff = p => (
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35 6.2 6.2 0 0 1 2-3.2M10.6 5.2A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 1.9 7.6" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
)
export const Shield = p => (
<Svg {...p}><path d="M12 3.8 19.4 6.6v5.2c0 4.2-3 7.4-7.4 8.4-4.4-1-7.4-4.2-7.4-8.4V6.6z" /></Svg>
)
export const Link = p => (
<Svg {...p}><path d="M10.2 13.8a3.6 3.6 0 0 0 5.2 0l2.8-2.8a3.7 3.7 0 0 0-5.2-5.2l-1.3 1.3" /><path d="M13.8 10.2a3.6 3.6 0 0 0-5.2 0l-2.8 2.8a3.7 3.7 0 0 0 5.2 5.2l1.3-1.3" /></Svg>
)
export const Logout = p => (
<Svg {...p}><path d="M14.4 7.6V5.4H4.6v13.2h9.8v-2.2" /><path d="M10 12h9.4M16.4 8.8 19.8 12l-3.4 3.2" /></Svg>
)
export const Back = p => (<Svg {...p}><path d="M14.6 5.6 8 12l6.6 6.4" /></Svg>)
export const Chevron = p => (<Svg {...p}><path d="M9.4 5.6 16 12l-6.6 6.4" /></Svg>)
export const Dot = p => (<Svg {...p}><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none" /></Svg>)
// Drawn for the empty states rather than an apologetic sentence in grey.
export const NoCamera = p => (
<Svg {...p} strokeWidth="1.2"><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
)
export const NoFaces = p => (
<Svg {...p} strokeWidth="1.2"><circle cx="12" cy="9" r="3.4" /><path d="M5.4 20a6.8 6.8 0 0 1 13.2 0" /></Svg>
)

View File

@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react'
import { api, message } from '../bridge.js'
import * as Icon from '../ui/icons.jsx'
import logo from '../assets/loyaly-mark.png'
// The help. A conversation with the head-office assistant, which answers from
// the shop's own data and knows how the product is set up - so "why is nobody
// being recognised" and "how do I add my camera" are both answered here, by
// the same thing, without leaving the app.
//
// The history lives in this component and is resent whole; nothing is stored
// anywhere. A PC running on its own has no head office to ask and is told so.
const SUGGESTED = [
'Is my shop working right now?',
'How do I add my camera?',
'Why has nobody been recognised today?',
'Who came in this morning?',
]
export default function Assistant({ session, onClose }) {
const [turns, setTurns] = useState([])
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const scroller = useRef(null)
const standalone = session?.standalone
useEffect(() => { scroller.current?.scrollTo({ top: 1e9, behavior: 'smooth' }) }, [turns, busy])
async function ask(text) {
const q = (text ?? draft).trim()
if (!q || busy) return
const history = [...turns, { role: 'user', text: q }]
setTurns(history); setDraft(''); setBusy(true); setError(null)
try {
const a = await api.ask(history.map(t => ({ role: t.role, text: t.text })))
setTurns([...history, { role: 'assistant', text: a.text, used: a.used ?? [] }])
} catch (e) {
const m = message(e)
// Her only failure that is not hers: the login is gone. Say what to do,
// not "session expired" - the shell returns to Login within seconds.
setError(/session expired|unauthori[sz]ed/i.test(m)
? 'You’re signed out of head office, so I can’t look anything up. Sign in again and ask me once more.'
: m)
setTurns(turns) // the question stays in the box, not in the transcript
setDraft(q)
} finally { setBusy(false) }
}
return (
<aside className="helper" role="dialog" aria-label="Loya">
<header className="helperhead">
<img src={logo} alt="" />
<div>
<b>Loya</b>
<span>Your Behavision buddy — knows your cameras, your customers and your numbers.</span>
</div>
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
</header>
<div className="helperbody" ref={scroller}>
{standalone && (
<div className="helpernote">
<Icon.CloudOff size={16} />
<div>
<b>This PC runs on its own</b>
<span>Loya lives at head office. Link this PC to a shop to talk to her; the basics are below.</span>
</div>
</div>
)}
{turns.length === 0 && (
<div className="helperintro">
<div className="turn assistant"><div className="bubble">
{greeting(session)} Ask me anything about {session?.site_name || 'this shop'} — what’s happening now, or how to set something up.
</div></div>
<div className="chips">
{SUGGESTED.map(s => <button key={s} className="chip" onClick={() => ask(s)} disabled={busy || standalone}>{s}</button>)}
</div>
{standalone && <QuickHelp />}
</div>
)}
{turns.map((t, i) => (
<div key={i} className={`turn ${t.role}`}>
<div className="bubble">{t.text}</div>
{t.used?.length > 0 && <span className="used">Looked at: {t.used.join(', ')}</span>}
</div>
))}
{busy && <div className="turn assistant"><div className="bubble thinking"><span /><span /><span /></div></div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
</div>
<form className="helperask" onSubmit={e => { e.preventDefault(); ask() }}>
<input value={draft} onChange={e => setDraft(e.target.value)} disabled={busy || standalone}
placeholder={standalone ? 'Link this PC to head office to talk to Loya' : 'Ask Loya…'} />
<button className="btn primary" disabled={busy || standalone || !draft.trim()} aria-label="Ask"><Icon.Chevron size={16} /></button>
</form>
</aside>
)
}
function greeting(session) {
const h = new Date().getHours()
const part = h < 12 ? 'Morning' : h < 17 ? 'Afternoon' : 'Evening'
const name = session?.user?.full_name?.split(' ')[0]
return name ? `${part}, ${name}.` : `${part}.`
}
// What a PC with no head office can still be told. Static on purpose: there is
// nobody to ask, and a chat box that always fails is worse than a short list.
function QuickHelp() {
return (
<dl className="quickhelp">
<dt>Adding a camera</dt>
<dd>Cameras → Add camera. The address is on a label on the camera; pick the make and the stream path fills itself in. Test, then save.</dd>
<dt>Proving it works</dt>
<dd>Press Check placement and walk past the camera like a customer for 25 seconds. Only “good” means it can recognise faces — otherwise move it to head height, facing the way people approach.</dd>
<dt>Nothing showing on Live</dt>
<dd>Check the camera is Connected and Proven. A camera aimed from above or the side streams fine and recognises nobody.</dd>
<dt>Linking to head office later</dt>
<dd>Use the link button at the bottom of the sidebar and type an installation code from head office. Nothing recorded here is lost.</dd>
</dl>
)
}

View File

@@ -1,69 +1,92 @@
import { useEffect, useRef, useState } from 'react'
import { api, message } from '../bridge.js'
import { usePolled } from '../hooks.js'
import * as Icon from '../ui/icons.jsx'
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '',
max_width: 1280 }
// The camera screen is a picture, not a settings table.
//
// The first version was a table of id / rtsp url / status with three buttons
// per row - the view a developer wants. A camera is a thing you look at, so
// the live picture is the card, the state sits over it, and the one line that
// matters is under it: whether anyone has PROVED this camera can recognise a
// face, which is a different claim from "connected" and is the gap a site gets
// signed off through.
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '', max_width: 1280 }
export default function Cameras() {
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
const [editing, setEditing] = useState(null)
const [check, setCheck] = useState(null)
const cams = data ?? []
// 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(id) {
if (!confirm(`Remove camera "${id}"? Recognition from it stops immediately.`)) return
try { await api.deleteCamera(id); reload() } catch (e) { alert(message(e)) }
async function remove(cam) {
if (!confirm(`Remove ${cam.id}? Recognition from it stops immediately.`)) return
try { await api.deleteCamera(cam.id); reload() } catch (e) { alert(message(e)) }
}
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<header className="pagehead">
<div>
<h2>Cameras</h2>
<p>Add a camera, check it can see faces properly, then it starts 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 })}>
Add camera
</button>
{!remote && <button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
<Icon.Plus size={15} />Add camera
</button>}
</header>
{error && <div className="err">{error}</div>}
{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>}
<div className="card">
{cams.length === 0
? <div className="empty">No cameras yet.</div>
: <div className="tablewrap">
<table>
<thead><tr><th>Name</th><th>Address</th><th>Status</th><th></th></tr></thead>
<tbody>
{cams.map(c => (
<tr key={c.id}>
<td>{c.id}</td>
<td className="mono">{c.url}</td>
<td>
{c.connected === undefined
? <span className="pill"><i className="dot idle" />stopped</span>
: c.connected
? <span className="pill ok"><i className="dot ok" />live</span>
: <span className="pill bad"><i className="dot bad" />offline</span>}
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn sm" onClick={() => setCheck(c.id)}>
Check placement
</button>{' '}
<button className="btn sm" onClick={() => setEditing(c)}>Edit</button>{' '}
<button className="btn sm danger" onClick={() => remove(c.id)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
{cams.length === 0
? <div className="panel">
<div className="empty tall">
<Icon.NoCamera size={40} />
<b>No cameras yet</b>
<p>Add the camera by its address. Most cameras print it on a label underneath, or show it in their own app.</p>
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}><Icon.Plus size={15} />Add your first camera</button>
</div>
</div>
: <div className="camgrid">
{cams.map(c => (
<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>}
{editing && <CameraSheet cam={editing} onClose={() => setEditing(null)}
onSaved={() => { setEditing(null); reload() }} />}
@@ -72,10 +95,101 @@ export default function Cameras() {
)
}
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.
// 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 || 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.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>
{!cam.remote && <div className="camactions">
<button className="btn sm" onClick={onEdit}>Edit</button>
<button className="btn sm danger" onClick={onRemove}>Remove</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>
)
}
// Stream URLs are fetched once per camera and left alone: reassigning an
// MJPEG <img> src restarts the stream, so rebuilding them on every poll makes
// every feed flicker forever.
function useStreamURLs(cams) {
const [urls, setUrls] = useState({})
useEffect(() => {
let alive = true
const missing = cams.filter(c => !(c.id in urls))
if (!missing.length) return
;(async () => {
const add = {}
for (const c of missing) { try { add[c.id] = await api.streamURL(c.id) } catch { add[c.id] = '' } }
if (alive) setUrls(u => ({ ...u, ...add }))
})()
return () => { alive = false }
}, [cams.map(c => c.id).join('|')]) // eslint-disable-line react-hooks/exhaustive-deps
return urls
}
function CameraSheet({ cam, onClose, onSaved }) {
const isNew = !cam.id
const [f, setF] = useState({ ...BLANK, ...cam, password: '',
path: cam.path || (isNew ? MAKES[0].path : '') })
const [f, setF] = useState({ ...BLANK, ...cam, password: '', path: cam.path || (isNew ? MAKES[0].path : '') })
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
const [test, setTest] = useState(null)
const [busy, setBusy] = useState(null)
@@ -114,93 +228,137 @@ function CameraSheet({ cam, onClose, onSaved }) {
catch (e) { setError(message(e)) } finally { setBusy(null) }
}
const chosen = makeById(make)
// The camera is picked from a scan of the shop's network rather than typed.
// Nobody knows their camera's address; the sticker is under the camera and
// the menu is different in every make's app. The scan names ONVIF cameras
// and lists anything with the RTSP port open; picking one fills the
// address and, when the make is recognisable, the stream path too.
const [scan, setScan] = useState(null) // null | 'busy' | {cameras, networks} | {error}
async function findCameras() {
setScan('busy')
try { setScan(await api.discoverCameras()) } catch (e) { setScan({ error: message(e) }) }
}
function pick(c) {
const m = c.make ? makeById(c.make) : null
setF(prev => ({ ...prev, host: c.host, path: m?.path || prev.path,
id: prev.id || (m ? '' : ''), }))
if (m) setMake(m.id)
setTest(null)
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<button className="btn sm close" onClick={onClose}>Close</button>
<h3>{isNew ? 'Add camera' : cam.id}</h3>
<p className="note" style={{ marginBottom: 18 }}>
Test the connection before saving — a wrong address is the most common mistake.
</p>
<form onSubmit={save}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Name</span>
<input value={f.id} onChange={set('id')} disabled={!isNew}
placeholder="entrance" required autoComplete="off" />
</label>
{/* The highest-value field on this form. The address and the
password are on a label or in the installer's notes; the RTSP
path is not written anywhere a shop owner would look, and getting
it wrong produces "could not open stream", which reads like a
password problem and is not. */}
<label className="field"><span>Make of camera</span>
<select value={make} onChange={chooseMake}>
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</label>
{makeById(make).note && (
<p className="note" style={{ marginTop: -8, marginBottom: 12 }}>
{makeById(make).note}
</p>
)}
<div className="fieldrow">
<label className="field"><span>Camera address</span>
<input value={f.host} onChange={set('host')} placeholder="192.168.0.138"
autoComplete="off" />
</label>
<label className="field"><span>Port</span>
<input value={f.port} onChange={set('port')} inputMode="numeric"
autoComplete="off" />
</label>
</div>
<label className="field"><span>Stream path</span>
<input value={f.path} onChange={set('path')} placeholder="/ch0_0.264"
autoComplete="off" />
</label>
<div className="fieldrow">
{/* A text input next to a password input is a sign-in form as far
as the webview is concerned, so without this the browser offers
the operator's own Behavision email as the camera's username -
which fails with a message about credentials that points at the
camera. "off" alone is frequently ignored; a non-login name and
new-password on the secret are what actually work. */}
<label className="field"><span>Username</span>
<input value={f.username} onChange={set('username')}
name="camera-account" autoComplete="off" />
</label>
<label className="field"><span>Password</span>
<input type="password" value={f.password} onChange={set('password')}
name="camera-secret" autoComplete="new-password"
placeholder={cam.has_password ? '(unchanged)' : ''} />
</label>
</div>
<div className="sheet">
<div className="sheethead">
<h2>{isNew ? 'Add a camera' : `Edit ${cam.id}`}</h2>
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
</div>
<form className="sheetbody" onSubmit={save} autoComplete="off">
<p className="lead">Three things from the camera: its address, its make, and its password. Test before you save — a wrong address is the most common mistake.</p>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button type="button" className="btn" onClick={runTest} disabled={!!busy}>
{busy === 'test' ? 'Connecting…' : 'Test connection'}
</button>
<button className="btn primary" disabled={!!busy || !f.id}>
{busy === 'save' ? 'Saving…' : 'Save'}
</button>
</div>
{isNew && (
<section className="formsection">
<h4>Find it</h4>
{scan === null && (
<div className="finder">
<p>Behavision can look for cameras on this shop’s network.</p>
<button type="button" className="btn primary" onClick={findCameras}><Icon.Search size={14} />Find cameras on this network</button>
</div>
)}
{scan === 'busy' && <div className="finder busy"><span className="spinner" />Looking on the network… a few seconds.</div>}
{scan?.error && <div className="err"><Icon.Warning size={15} />{scan.error}</div>}
{scan?.cameras && (
scan.cameras.length === 0
? <div className="finder">
<p>Nothing answered on {scan.networks?.join(', ') || 'this network'}. The camera may be on a different network, switched off, or not yet connected — check its cable and power, then try again. You can still type its address below.</p>
<button type="button" className="btn" onClick={findCameras}>Try again</button>
</div>
: <ul className="foundlist">
{scan.cameras.map(c => (
<li key={c.host} className={f.host === c.host ? 'picked' : ''}>
<button type="button" onClick={() => pick(c)}>
<span className="mono">{c.host}</span>
<span className="what">{c.name || (c.rtsp ? 'Streams video (RTSP)' : 'Answers ONVIF')}</span>
{c.make && <span className="tag seen">{makeById(c.make).label}</span>}
{f.host === c.host && <Icon.Check size={16} />}
</button>
</li>
))}
<li className="rescan"><button type="button" className="btn sm" onClick={findCameras}>Scan again</button></li>
</ul>
)}
</section>
)}
<section className="formsection">
<h4>The camera</h4>
{isNew && (
<label className="field"><span>Name</span>
<input value={f.id} onChange={set('id')} placeholder="entrance" required autoFocus />
<em className="hint">Short, no spaces. It names this camera everywhere and cannot be changed later.</em>
</label>
)}
<div className="fieldrow">
<label className="field"><span>Address</span>
<input value={f.host} onChange={set('host')} placeholder="192.168.1.20" inputMode="decimal" />
<em className="hint">On a label on the camera, or in its own app under “network”.</em>
</label>
<label className="field narrow"><span>Port</span>
<input value={f.port} onChange={set('port')} inputMode="numeric" />
</label>
</div>
</section>
<section className="formsection">
<h4>The stream</h4>
<label className="field"><span>Make of camera</span>
<select value={make} onChange={chooseMake}>
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
{chosen.note && <em className="hint">{chosen.note}</em>}
</label>
<label className="field"><span>Stream path</span>
<input className="mono" value={f.path} onChange={set('path')} placeholder="/Streaming/Channels/101" />
<em className="hint">Filled in from the make. Change it only if the camera’s own app says something else.</em>
</label>
</section>
<section className="formsection">
<h4>Sign-in to the camera</h4>
<div className="fieldrow">
<label className="field"><span>Username</span>
<input name="rtsp-account" autoComplete="off" value={f.username} onChange={set('username')} placeholder="admin" />
</label>
<label className="field"><span>Password</span>
<input type="password" name="rtsp-secret" autoComplete="new-password" value={f.password}
onChange={set('password')} placeholder={cam.has_password ? '(unchanged)' : ''} />
</label>
</div>
</section>
{test && (
<div style={{ marginTop: 14 }}>
{test.ok
? <>
<p style={{ color: 'var(--ok)', fontSize: 13 }}>
Connected — {test.width}×{test.height}
</p>
{test.snapshot && (
<img alt="Camera preview" style={{ width: '100%', marginTop: 8,
borderRadius: 6, border: '1px solid var(--line)' }}
src={`data:image/jpeg;base64,${test.snapshot}`} />
)}
</>
: <div className="err">{test.error}</div>}
</div>
test.ok
? <div className="testresult ok">
<Icon.Check size={16} />
<div>
<b>Connected — {test.width}×{test.height}{test.codec ? ` · ${String(test.codec).toUpperCase()}` : ''}</b>
{test.snapshot && <img alt="Camera preview" src={`data:image/jpeg;base64,${test.snapshot}`} />}
</div>
</div>
: <div className="testresult bad"><Icon.Warning size={16} /><div><b>Could not connect</b><span>{test.error}</span></div></div>
)}
<div className="sheetactions">
<button type="button" className="btn" onClick={runTest} disabled={!!busy || !f.host}>
{busy === 'test' ? 'Connecting…' : 'Test connection'}
</button>
<button className="btn primary" disabled={!!busy || !f.id || !f.host}>
{busy === 'save' ? 'Saving…' : isNew ? 'Add camera' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
@@ -208,7 +366,7 @@ function CameraSheet({ cam, onClose, onSaved }) {
}
// The commissioning wizard. This is what stops a site being signed off with a
// camera that recognises nobody — the failure that otherwise shows up weeks
// camera that recognises nobody - the failure that otherwise shows up weeks
// later as a footfall report that was always zero.
function PlacementSheet({ id, onClose }) {
const [state, setState] = useState({ verdict: 'starting', advice: [] })
@@ -233,45 +391,54 @@ function PlacementSheet({ id, onClose }) {
return () => { alive = false; clearInterval(timer.current) }
}, [id])
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad',
no_faces: 'warn', inconclusive: 'warn' }[state.verdict]
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad', no_faces: 'warn',
inconclusive: 'warn', no_completed_passes: 'warn' }[state.verdict]
const pct = state.seconds ? Math.min(100, (state.elapsed / state.seconds) * 100) : 0
const running = state.running || state.verdict === 'starting'
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<button className="btn sm close" onClick={onClose}>Close</button>
<h3>Placement check — {id}</h3>
<p className="note" style={{ marginBottom: 18 }}>
Walk past the camera the way a customer would, a few times.
</p>
<div className="sheet">
<div className="sheethead">
<h2>Check placement · {id}</h2>
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
</div>
<div className="sheetbody">
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
{error && <div className="err">{error}</div>}
<ol className="steps">
<li className={running ? 'now' : 'done'}>
<b>Walk past the camera</b>
<span>The way a customer would — in through the door, not looking at the lens. Two or three times, for about 25 seconds.</span>
{running && <div className="progress"><div style={{ width: `${pct}%` }} /></div>}
</li>
<li className={running ? '' : 'now'}>
<b>Behavision judges what it saw</b>
<span>Not “were the frames sharp”, but “did a person walking past produce a face worth recognising”.</span>
</li>
</ol>
<div className="card">
<div style={{ fontSize: 15, fontWeight: 600,
color: tone ? `var(--${tone})` : 'var(--ink)' }}>
{state.headline || 'Starting…'}
</div>
{state.running && (
<div style={{ height: 5, background: 'var(--surface-2)', borderRadius: 3,
overflow: 'hidden', margin: '12px 0' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)',
transition: 'width .4s linear' }} />
<div className={`verdict ${tone ?? ''}`}>
<div className="verdict-head">
{running ? <span className="spinner" /> : tone === 'ok' ? <Icon.Check size={18} /> : <Icon.Warning size={18} />}
<b>{state.headline || 'Watching…'}</b>
</div>
{state.advice?.length > 0 && (
<ul>{state.advice.map((a, i) => <li key={i}>{a}</li>)}</ul>
)}
{state.quality?.n > 0 && (
<p className="note">
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen · median quality {state.quality.p50} ·
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% too poor to use
</p>
)}
</div>
{!running && (
<div className="sheetactions">
<button className="btn" onClick={onClose}>Close</button>
<button className="btn primary" onClick={() => { setState({ verdict: 'starting', advice: [] }); api.startPlacement(id, 25).then(setState).catch(e => setError(message(e))) }}>Run again</button>
</div>
)}
{state.advice?.length > 0 && (
<ul style={{ margin: '12px 0 0 18px', fontSize: 13, color: 'var(--ink-2)' }}>
{state.advice.map((a, i) => <li key={i} style={{ marginBottom: 5 }}>{a}</li>)}
</ul>
)}
{state.quality?.n > 0 && (
<p className="note" style={{ marginTop: 12 }}>
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen ·
median quality {state.quality.p50} ·
gate {state.gate} ·
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% below it
</p>
)}
</div>
</div>

View File

@@ -54,28 +54,28 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<div className="who">
<div className="sheet">
<div className="sheethead who">
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
customerRef={customer.ref}
onBroken={() => setPhoto({ available: false,
reason: 'The photo could not be loaded.' })} />
<div className="grow">
<h3>{customer.full_name || customer.label}</h3>
<h2>{customer.full_name || customer.label}</h2>
<p className="note">
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
{shown && !shown.available && shown.reason && ` · ${shown.reason}`}
</p>
{shown && !shown.available && shown.reason &&
<p className="note">{shown.reason}</p>}
</div>
<button type="button" className="btn sm" onClick={onClose}>Close</button>
<button type="button" className="close" onClick={onClose} aria-label="Close">✕</button>
</div>
<form onSubmit={save}>
<form className="sheetbody" onSubmit={save}>
{error && <div className="err">{error}</div>}
<div className="card" style={{ marginBottom: 14 }}>
<h3>Customer details</h3>
<section className="formsection">
<h4>Customer details</h4>
<label className="field">
<span>Full name</span>
<input value={f.full_name} onChange={set('full_name')} autoFocus />
@@ -108,10 +108,10 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
<textarea value={f.notes} onChange={set('notes')}
placeholder="Preferences, sizes, anything worth remembering" />
</label>
</div>
</section>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Purchase (optional)</h3>
<section className="formsection">
<h4>Purchase (optional)</h4>
<div className="fieldrow">
<label className="field">
<span>Amount</span>
@@ -126,10 +126,10 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
</div>
<p className="note">Leave the amount blank if they did not buy anything —
a visit without a sale is still worth recording.</p>
</div>
</section>
<div className="card" style={{ marginBottom: 16 }}>
<h3>Consent</h3>
<section className="formsection">
<h4>Consent</h4>
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
@@ -141,23 +141,23 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
Recorded with the date and who collected it. They can withdraw it
at any time, which erases their face data.
</p>
</div>
</section>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Visits</h3>
<section className="formsection">
<h4>Visits</h4>
<VisitHistory customer={customer} />
</div>
</section>
<div style={{ display: 'flex', gap: 8 }}>
<div className="sheetactions">
<button type="button" className="btn" onClick={onClose}>Cancel</button>
<button className="btn primary" disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
{canErase && (
<div className="card danger-zone">
<h3>At the customer's request</h3>
<section className="formsection dangerzone">
<h4>At the customer's request</h4>
{erasing
? <EraseCustomer customer={customer}
onCancel={() => setErasing(false)}
@@ -173,7 +173,7 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
Erase this customer…
</button>
</>}
</div>
</section>
)}
</form>
</div>

View File

@@ -30,16 +30,31 @@ export function useCustomerPhoto(id) {
// No photo is the normal case — images are off by default — so this renders
// initials, not an error.
export default function CustomerPhoto({ photo, name, onBroken }) {
export default function CustomerPhoto({ photo, name, customerRef, onBroken }) {
if (photo?.available) {
return <img className="avatar" src={photo.url} alt={`Photo of ${name}`}
onError={onBroken} />
}
const initials = String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
.map(w => w[0].toUpperCase()).join('') || '?'
return (
<div className="avatar none" role="img" aria-label={`No photo of ${name}`}>
<span>{initials}</span>
<span>{avatarText(name, customerRef)}</span>
</div>
)
}
// Initials of a name a human typed; the NUMBER for a customer the system named
// itself. Taking the first letter of each word of "Visitor 13" gives "V1" —
// which is also what "Visitor 10" and "Visitor 15" give, so three different
// people wear the same badge, and it reads as the V-1 reference for a fourth.
// Same fix as the web app's arrivals feed; the two must not disagree.
//
// customerRef, not `ref`: React reserves that prop name and it would never
// reach this component.
function avatarText(name, customerRef) {
const auto = /^Visitor (\d+)$/.exec(String(name || '').trim())
if (auto) return auto[1]
const n = /^V-(\d+)$/.exec(String(customerRef || ''))
if (n) return n[1]
return String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
.map(w => w[0].toUpperCase()).join('') || '?'
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, fmtDate } from '../hooks.js'
import * as Icon from '../ui/icons.jsx'
import CustomerForm from './CustomerForm.jsx'
// The customer database, and the form staff fill in when someone walks in.
@@ -15,34 +16,32 @@ export default function Customers({ session }) {
return (
<div className="page">
<header>
<h2>Customers</h2>
<p>Everyone this business has recognised. Fill in details once and they
are known at every store.</p>
<header className="pagehead">
<div>
<h2>Customers</h2>
<p>Everyone this business has recognised. Fill in details once and they are known at every store.</p>
</div>
<label className="search">
<Icon.Search size={15} />
<input placeholder="Search by name or phone" value={query} onChange={e => setQuery(e.target.value)} />
</label>
</header>
{error && <div className="err">{error}</div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="Known people" value={rows.length || '—'} />
<Stat label="With details" value={named || '—'}
sub={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
<Stat label="Returning" value={rows.filter(r => r.visit_count > 1).length || '—'} />
<Stat label="With consent" value={rows.filter(r => r.has_consent).length || '—'} />
<div className="metrics" style={{ marginBottom: 'var(--sp-4)' }}>
<Metric k="Known people" v={rows.length || '—'} />
<Metric k="With details" v={named || '—'} s={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
<Metric k="Returning" v={rows.filter(r => r.visit_count > 1).length || '—'} />
<Metric k="With consent" v={rows.filter(r => r.has_consent).length || '—'} />
</div>
<div className="card">
<div style={{ display: 'flex', gap: 10, marginBottom: 12 }}>
<input className="field" style={{ flex: 1, margin: 0, background: 'var(--ground)',
border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px' }}
placeholder="Search by name or phone"
value={query} onChange={e => setQuery(e.target.value)} />
<button className="btn" onClick={reload}>Refresh</button>
</div>
<div className="panel">
{rows.length === 0
? <div className="empty">
No customers yet. They appear here the first time a camera sees them.
? <div className="empty tall">
<Icon.NoFaces size={34} />
<b>No customers yet</b>
<p>They appear here the first time a camera recognises them. Click one to add a name and phone number.</p>
</div>
: <div className="tablewrap">
<table>
@@ -56,7 +55,14 @@ export default function Customers({ session }) {
<tbody>
{rows.map(c => (
<tr key={c.id} className="click" onClick={() => setSelected(c)}>
<td>{c.full_name || <span className="note">{c.label}</span>}</td>
<td>
{c.full_name || <span className="note">{c.label}</span>}
{/* Only beside a name a human typed: the auto label
already IS the number ("Visitor 13"), so showing
both reads as two identifiers for one person. */}
{c.full_name && c.ref &&
<span className="note"> · {c.ref}</span>}
</td>
<td className="mono">{c.phone || '—'}</td>
<td className="num">{c.visit_count}</td>
<td>{fmtDate(c.first_seen_at)}</td>
@@ -82,11 +88,12 @@ export default function Customers({ session }) {
)
}
function Stat({ label, value, sub }) {
function Metric({ k, v, s }) {
return (
<div className="card stat">
<h3>{label}</h3><div className="value">{value}</div>
{sub && <div className="sub">{sub}</div>}
<div className="metric">
<div className="metric-k">{k}</div>
<div className="metric-v">{v}</div>
{s && <div className="metric-s">{s}</div>}
</div>
)
}

View File

@@ -1,192 +1,275 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, fmtTime } from '../hooks.js'
import * as Icon from '../ui/icons.jsx'
// What is happening right now. The first screen a shop manager opens, so it
// answers "is it working" before it answers anything else.
export default function Live() {
// The shop floor screen.
//
// Rebuilt around what somebody standing at the counter is actually here for:
// WHO JUST WALKED IN. The previous version led with four large stat boxes and
// left arrivals as a thin list of "person.seen" rows in the corner — the least
// actionable content taking the most space, and the product's whole reason for
// existing rendered as a log.
//
// Now: a status strip that answers "is this working" in one line, and arrivals
// as cards big enough to recognise a customer from while looking up at them.
// No camera picture here - the person at the counter is not watching CCTV,
// and a live video tile costs CPU the recognition pipeline needs. The picture
// lives on the Cameras screen, where it is a setup tool.
export default function Live({ onNavigate }) {
const { data, error } = usePolled(() => api.live(), 3000)
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
const cams = useCameraFeeds()
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 here 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)
// 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 = 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])
return (
<div className="page">
<header>
<h2>Live</h2>
<p>Cameras, recent detections, and whether this site is recognising people.</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">{error}</div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="People known" value={gallery.identities ?? '—'} />
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
<Stat label="Cameras live"
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
<Stat label="Below quality gate"
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
: 'Share of faces too poor to enrol'} />
{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} />
{/* 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">
<h3>Who just walked in</h3>
{arrivals.length > 0 && <span className="note">{arrivals.length} today</span>}
</div>
<div className="panelbody flush">
{arrivals.length === 0
? <div className="empty">
<Icon.NoFaces size={34} />
<b>Nobody yet</b>
<p>Customers appear here the moment a camera recognises a face.</p>
</div>
: <div className="arrivals">
{arrivals.slice(0, 30).map((e, i) => (
<Arrival key={`${e.ts}-${i}`} e={e} fresh={i === 0 && freshest} />
))}
</div>}
</div>
</div>
<Pipeline pipe={pipe} />
<div className="grid cols-2">
<div>
<div className="card">
<h3>Cameras</h3>
{cameras.length === 0
? <div className="empty">No cameras yet. Add one in Cameras.</div>
: <div className="feeds">
{cameras.map(c => (
<div className="feed" key={c.camera_id}>
{cams[c.camera_id]
? <img src={cams[c.camera_id]} alt={c.camera_id} />
: <div style={{ aspectRatio: '16/9' }} />}
<div className="cap">
<span>{c.camera_id}</span>
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
{c.connected ? 'live' : 'offline'}
</span>
</div>
</div>
))}
</div>}
</div>
</div>
<div className="card">
<h3>Recent detections</h3>
{events.length === 0
? <div className="empty">Nothing detected yet.</div>
: <ul className="events">
{events.map((e, i) => <EventRow key={i} e={e} />)}
</ul>}
</div>
<div className="metrics" style={{ marginTop: 'var(--sp-4)' }}>
<Metric k="People known" v={gallery.identities ?? '—'} />
<Metric k="Sightings" v={gallery.sightings ?? '—'} />
<Metric k="Cameras live" v={cameras.length ? `${up}/${cameras.length}` : '—'}
tone={!cameras.length ? null : up === 0 ? 'bad' : up < cameras.length ? 'warn' : 'ok'} />
<Metric k="Faces too poor to use"
v={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
s={worst > 0.5 ? 'Most visitors are being missed — move the camera'
: 'Share of faces below the enrolment gate'} />
</div>
</div>
)
}
// Whether anything is actually reaching head office. Without this the app can
// look perfectly healthy while every detection piles up on disk unsent — which
// is exactly what it did before the bridge existed.
function Pipeline({ pipe }) {
// The first five minutes, as a checklist that ticks itself.
//
// Before this the Live screen after install was "Nobody yet" over a row of
// dashes, with an amber "No cameras" in the far corner. Nothing said what to
// do next. This says the three things, in order, and each step reads its own
// state from the engine: recognition ready, a camera added, the camera
// proven by a walk-past. It disappears on its own once someone has actually
// been recognised, because at that point the product has explained itself.
function GettingStarted({ cameras, arrivals, onNavigate }) {
const { data: eng } = usePolled(() => api.engineStatus(), 4000)
const [hidden, setHidden] = useState(() => { try { return localStorage.getItem('bv.gettingStarted') === 'done' } catch { return false } })
const hasCamera = cameras.length > 0
const anyUp = cameras.some(c => c.connected)
const { data: checks } = usePolled(async () => {
const out = {}
for (const c of cameras) { try { out[c.camera_id] = await api.placementResult(c.camera_id) } catch { /* not yet */ } }
return out
}, 10000, [cameras.map(c => c.camera_id).join('|')])
const proven = Object.values(checks ?? {}).some(r => r && !r.running && r.verdict === 'good')
const recognised = arrivals.length > 0
if (hidden || recognised) return null
const engineReady = Boolean(eng?.reachable && eng?.recognition_model)
const progress = eng?.progress
const steps = [
{ done: engineReady, now: !engineReady,
title: engineReady ? `Recognition ready (${eng.recognition_model})` : progress ? `Downloading ${progress.what}… ${progress.percent}%` : 'Starting recognition…',
text: engineReady ? null : 'First start downloads about 275 MB of recognition models. A few minutes on a normal connection; nothing to do meanwhile.' },
{ done: hasCamera && anyUp, now: engineReady && !(hasCamera && anyUp),
title: hasCamera ? (anyUp ? 'Camera connected' : 'Camera added — not connecting yet') : 'Add your camera',
text: hasCamera ? (anyUp ? null : 'Check its password and stream path under Cameras → Edit.') : 'Behavision can find it on the network; you type only its password.',
action: hasCamera ? null : { label: 'Add camera', go: 'cameras' } },
{ done: proven, now: hasCamera && anyUp && !proven,
title: proven ? 'Camera proven — it can recognise faces' : 'Walk past the camera',
text: proven ? null : 'Run Check placement and walk past like a customer for 25 seconds. Only a “good” verdict means it will recognise people.',
action: hasCamera && anyUp && !proven ? { label: 'Check placement', go: 'cameras' } : null },
]
return (
<section className="panel starter">
<div className="panelhead">
<h3>Getting started</h3>
<button className="btn sm ghost" onClick={() => { try { localStorage.setItem('bv.gettingStarted', 'done') } catch {} ; setHidden(true) }}>Hide</button>
</div>
<ol className="steps compact">
{steps.map((st, i) => (
<li key={i} className={st.done ? 'done' : st.now ? 'now' : ''}>
<b>{st.title}</b>
{st.text && <span>{st.text}</span>}
{st.action && <button className="btn sm primary" onClick={() => onNavigate?.(st.action.go)}>{st.action.label}</button>}
</li>
))}
</ol>
<p className="note">The moment a customer is recognised, this list goes away.</p>
</section>
)
}
// One customer, big enough to match against the person in front of you.
function Arrival({ e, fresh }) {
const isNew = e.type === 'person.new'
const name = e.data?.label || 'Unrecognised'
const bits = [e.data?.gender, e.data?.age ?? e.data?.age_range, e.data?.emotion].filter(Boolean)
const sim = typeof e.data?.similarity === 'number' ? e.data.similarity : null
return (
<article className={`arrival ${isNew ? 'is-new' : 'is-seen'} ${fresh ? 'fresh' : ''}`}>
<div className="avatar">{avatarText(name)}</div>
<div className="who">
<div className="name">{name}</div>
<div className="meta">
{e.camera_id}
{bits.length > 0 && <> · {bits.join(', ')}</>}
{sim !== null && !isNew && <> · match {sim.toFixed(2)}</>}
</div>
</div>
<div className="right">
<span className={`tag ${isNew ? 'new' : 'seen'}`}>{isNew ? 'new' : 'returning'}</span>
<span className="when">{fmtTime(e.ts)}</span>
</div>
</article>
)
}
// "Visitor 13" must show 13, not V1 — initials() would give the same two
// characters to Visitor 10, 13 and 15, and read as the reference V-1 for a
// fourth person. Found by looking at the screen, not by a test.
function avatarText(name) {
const auto = /^Visitor (\d+)$/.exec(String(name).trim())
if (auto) return auto[1]
const words = String(name).trim().split(/\s+/).filter(Boolean)
if (!words.length) return '?'
return (words[0][0] + (words[1]?.[0] ?? '')).toUpperCase()
}
// One line, above everything, answering the question every other screen is a
// detail of: is this shop working, and is anything leaving it.
function PipelineStrip({ pipe, cameras, up }) {
if (!pipe) return null
// A PC set up on its own is not "not linked yet" — nothing is coming, and
// saying so with an idle dot beside a count of zero reads as a fault.
// A PC set up on its own is not "not linked yet" — nothing is coming, and an
// idle dot beside a count of zero reads as a fault.
if (pipe.standalone) {
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 10, alignItems: 'center' }}>
<i className="dot ok" />
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
<span className="note">Recognition and customers stay here.</span>
<div className="statusbar">
<span className="item"><i className="dot ok" /><b>Running on this PC only</b></span>
<span className="sep" />
<span className="item note">Recognition and customers stay here.</span>
<span className="grow" />
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
</div>
)
}
const stuck = pipe.claimed && !pipe.broker_up
const tone = !pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'
const text = !pipe.claimed ? 'Not linked to head office'
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
<strong style={{ fontSize: 13 }}>
{!pipe.claimed ? 'Not linked to head office'
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
</strong>
<div className="statusbar">
<span className="item">
{pipe.broker_up ? <Icon.Cloud size={15} /> : <Icon.CloudOff size={15} />}
<i className={`dot ${tone}`} /><b>{text}</b>
</span>
<span className="note">{pipe.accepted} recorded today</span>
<span className="sep" />
<span className="item note">{pipe.accepted} recorded today</span>
{pipe.queued > 0 && (
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
{pipe.queued} waiting to send
</span>
<span className={`item note ${stuck ? 'warn' : ''}`}>{pipe.queued} waiting to send</span>
)}
{pipe.dropped > 0 && (
<span className="note" style={{ color: 'var(--bad)' }}>
{pipe.dropped} lost — this PC was offline too long
</span>
<span className="item note bad"><Icon.Warning size={14} />{pipe.dropped} lost — this PC was offline too long</span>
)}
<span className="grow" />
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
</div>
)
}
function EventRow({ e }) {
const cls = e.type === 'person.new' ? 'new'
: e.type === 'person.seen' ? 'seen'
: e.type === 'person.missed' ? 'miss' : ''
const age = e.data?.age ?? e.data?.age_range
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
function Metric({ k, v, s, tone }) {
return (
<li>
<span className="when">{fmtTime(e.ts)}</span>
<span className={`tag ${cls}`}>{label(e.type)}</span>
<span style={{ flex: 1, minWidth: 0 }}>
{e.data?.label || e.camera_id}
{extra && <span className="note"> · {extra}</span>}
</span>
</li>
)
}
// The event names are internal; a shop manager should not have to learn them.
function label(type) {
return {
'person.new': 'new',
'person.seen': 'returning',
'person.missed': 'missed',
'camera.up': 'camera up',
'camera.down': 'camera down',
'identity.merged': 'merged',
}[type] ?? type
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
{value}
</div>
{sub && <div className="sub">{sub}</div>}
<div className={`metric ${tone ?? ''}`}>
<div className="metric-k">{k}</div>
<div className="metric-v">{v}</div>
{s && <div className="metric-s">{s}</div>}
</div>
)
}
// Stream URLs are fetched once per camera and then left alone: reassigning an
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
// make every feed flicker permanently.
function useCameraFeeds() {
const [urls, setUrls] = useState({})
const { data } = usePolled(() => api.cameras(), 10000)
// True for a few seconds after a genuinely new arrival, so the top card can
// announce itself once. Keyed on the timestamp rather than the array, which
// changes identity on every poll.
function useFreshest(top) {
const [fresh, setFresh] = useState(false)
const seen = useRef(null)
useEffect(() => {
let cancelled = false
;(async () => {
const next = {}
for (const cam of data ?? []) {
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
}
const changed = Object.keys(next).length !== Object.keys(urls).length ||
Object.keys(next).some(k => next[k] !== urls[k])
if (!cancelled && changed) setUrls(next)
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
return urls
if (!top || top.ts === seen.current) return
const first = seen.current === null
seen.current = top.ts
if (first) return // do not flash the whole list on mount
setFresh(true)
const id = setTimeout(() => setFresh(false), 1200)
return () => clearTimeout(id)
}, [top?.ts])
return fresh
}

View File

@@ -1,5 +1,7 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
import * as Icon from '../ui/icons.jsx'
import logo from '../assets/loyaly-mark.png'
// The gate. Nothing else in the app is reachable until this succeeds, because
// the broker credentials and the customer database both live behind it.
@@ -24,10 +26,11 @@ export default function Login({ onDone }) {
return (
<div className="login">
<div className="box">
<span className="mark"><img src={logo} alt="" /></span>
<h1>Behavision</h1>
<p className="lead">Sign in to connect this PC to your store.</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<label className="field">
<span>Email</span>
<input type="email" value={email} autoComplete="username" required

View File

@@ -1,5 +1,7 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
import * as Icon from '../ui/icons.jsx'
import logo from '../assets/loyaly-mark.png'
// Linking this PC to a shop — the first thing that happens on a new install,
// and until now the one thing the app could not do.
@@ -13,7 +15,6 @@ export default function Setup({ onDone, onCancel }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(null)
const [error, setError] = useState(null)
const [alone, setAlone] = useState(false)
async function submit(e) {
e.preventDefault()
@@ -38,15 +39,69 @@ export default function Setup({ onDone, onCancel }) {
}
}
// First launch: a choice, not a code box. Two thirds of the people who
// open this have no idea what an installation code is; the other third has
// one in their hand. Both must see their own path in the first second.
const [path, setPath] = useState(onCancel ? 'code' : null)
if (path === null) {
return (
<div className="login">
<div className="box wide">
<span className="mark"><img src={logo} alt="" /></span>
<h1>Welcome to Behavision</h1>
<p className="lead">
This PC will watch your shop’s cameras and recognise returning customers.
First, one question: is this shop managed from a head office?
</p>
<div className="choices">
<button type="button" className="choice" onClick={() => setPath('code')}>
<Icon.Cloud size={22} />
<b>Yes — I have an installation code</b>
<span>Head office gave you a code like <em>ABCDEF-123456-…</em>. This PC joins that shop and gets its cameras from there.</span>
</button>
<button type="button" className="choice" onClick={() => setPath('alone')}>
<Icon.Shield size={22} />
<b>No — set up on this PC only</b>
<span>Cameras, customers and recognition stay on this PC. Nothing is sent anywhere. You can link to a head office later.</span>
</button>
</div>
</div>
</div>
)
}
if (path === 'alone') {
return (
<div className="login">
<div className="box">
<span className="mark"><img src={logo} alt="" /></span>
<h1>On this PC only</h1>
<p className="lead">
Behavision will run entirely here. Next you’ll add your camera — it can find it on the network for you — and walk past it once so it can prove it works.
</p>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<button type="button" className="btn primary" disabled={!!busy} onClick={standalone}>
{busy === 'alone' ? 'Setting up…' : 'Continue'}
</button>
<div className="alt">
<button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>
</div>
</div>
</div>
)
}
return (
<div className="login">
<div className="box">
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
<span className="mark"><img src={logo} alt="" /></span>
<h1>{onCancel ? 'Link to head office' : 'Join your shop'}</h1>
<p className="lead">
Type the installation code for this shop. You only do this once.
Type the installation code head office gave you. It works once, and this PC becomes that shop.
</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<label className="field">
<span>Installation code</span>
{/* Uppercase and letter-spaced because the code arrives read aloud
@@ -64,38 +119,12 @@ export default function Setup({ onDone, onCancel }) {
</button>
</form>
<p className="foot">
The code works once. Ask whoever manages your shops for it — they can
create one from the Behavision platform, under the shop.
Don’t have one? Whoever runs head office creates it under the shop: <em>Shops → the shop → Set up a shop PC</em>.
</p>
{/* The second way out of this screen, and the reason it exists.
Recognition, the cameras and this shop's own gallery all run on
this PC and need no server, so a shop with one till and no head
office was being blocked from adding a camera until somebody
issued it a code — the software refusing to do the thing it is
for. Linking later is still one click away, and it keeps the
visits already recorded here. */}
<div className="alt">
{onCancel
? <button type="button" className="linkbtn" onClick={onCancel}>
Not now — go back
</button>
: !alone
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
No head office — set this PC up on its own
</button>
: <>
<p className="note">
This PC will watch its cameras and recognise returning
customers on its own. Nothing is sent anywhere. You can link
it to head office later without losing anything recorded
here.
</p>
<button type="button" className="btn" disabled={!!busy}
onClick={standalone}>
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
</button>
</>}
? <button type="button" className="linkbtn" onClick={onCancel}>Not now — go back</button>
: <button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>}
</div>
</div>
</div>

View File

@@ -14,16 +14,34 @@ require (
)
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.10.2 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
github.com/leaanthony/gosod v1.0.3 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/samber/lo v1.38.1 // indirect
github.com/tkrajina/go-reflector v0.5.6 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.16 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
)

View File

@@ -3,6 +3,7 @@ fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
@@ -20,6 +21,7 @@ github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
@@ -30,6 +32,7 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -42,6 +45,7 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
@@ -50,6 +54,8 @@ github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -92,3 +98,5 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,28 +2,32 @@ package main
import (
"bytes"
_ "embed"
"encoding/binary"
"image"
"image/color"
"image/png"
"runtime"
"sync"
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
)
// iconFor renders the tray icon at run time rather than embedding four PNGs.
// iconFor renders the tray icon at run time: the Loyaly mark with a state
// dot in the corner. Green, amber, red or grey is the only thing a taskbar
// conveys at this size, and the mark is what makes it OURS among a row of
// other icons - a plain coloured circle read as a generic status light.
//
// A 16x16 filled circle is all the taskbar shows at this size, and generating
// it means the four states cannot drift apart visually or have one file go
// missing from a build.
// The mark is embedded once at 128px and scaled down here, so the four
// states cannot drift apart and no file can go missing from a build.
//
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
// to a temp file and, on Windows, hands the path to LoadImageW with
// IMAGE_ICON|LR_LOADFROMFILE — which decodes .ico and nothing else. A PNG
// IMAGE_ICON|LR_LOADFROMFILE - which decodes .ico and nothing else. A PNG
// there returns 0, systray logs "unable to set icon", and the product ships
// with no tray icon at all: the one control surface a shop manager has.
func iconFor(state string) []byte {
img := circle(colorFor(state))
img := trayImage(colorFor(state))
if runtime.GOOS == "windows" {
return encodeICO(img)
}
@@ -46,30 +50,93 @@ func colorFor(state string) color.RGBA {
}
}
const iconSize = 16
// 32px rather than 16: Windows shows 16 at 100% scaling and 24 at 150%, and
// scaling a 32 down looks right at both, where a 16 scaled up looks like 2005.
const iconSize = 32
func circle(c color.RGBA) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
const r = 6.5
cx, cy := float64(iconSize)/2-0.5, float64(iconSize)/2-0.5
//go:embed tray-logo.png
var trayLogoPNG []byte
var (
trayLogoOnce sync.Once
trayLogo *image.RGBA
)
// logo is the embedded mark, decoded once and box-filtered down to iconSize.
// A box filter rather than nearest-neighbour: 128->32 is an exact 4x4 average
// and nearest would drop three pixels in four, which shreds the thin outline.
func logo() *image.RGBA {
trayLogoOnce.Do(func() {
src, err := png.Decode(bytes.NewReader(trayLogoPNG))
if err != nil {
trayLogo = image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
return
}
b := src.Bounds()
f := b.Dx() / iconSize
out := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
for y := 0; y < iconSize; y++ {
for x := 0; x < iconSize; x++ {
var r, g, bl, a uint64
for dy := 0; dy < f; dy++ {
for dx := 0; dx < f; dx++ {
// Premultiplied so transparent pixels do not drag the
// colour of the edge towards black.
pr, pg, pb, pa := src.At(b.Min.X+x*f+dx, b.Min.Y+y*f+dy).RGBA()
r += uint64(pr)
g += uint64(pg)
bl += uint64(pb)
a += uint64(pa)
}
}
n := uint64(f * f)
out.SetRGBA(x, y, color.RGBA{
R: uint8(r / n >> 8), G: uint8(g / n >> 8), B: uint8(bl / n >> 8), A: uint8(a / n >> 8),
})
}
}
trayLogo = out
})
return trayLogo
}
// trayImage is the mark with a state dot over its bottom-right corner, ringed
// so it reads against both the yellow of the mark and a dark taskbar.
func trayImage(c color.RGBA) *image.RGBA {
base := logo()
img := image.NewRGBA(base.Bounds())
copy(img.Pix, base.Pix)
const r = 6.0
cx, cy := float64(iconSize)-r-0.5, float64(iconSize)-r-0.5
ring := color.RGBA{R: 0x11, G: 0x14, B: 0x18, A: 0xFF}
for y := 0; y < iconSize; y++ {
for x := 0; x < iconSize; x++ {
dx, dy := float64(x)-cx, float64(y)-cy
d := dx*dx + dy*dy
switch {
case d <= (r-1)*(r-1):
case d <= (r-1.5)*(r-1.5):
img.SetRGBA(x, y, c)
case d <= r*r:
img.SetRGBA(x, y, ring)
case d <= (r+1)*(r+1):
// One-pixel feathered edge; a hard-aliased circle looks broken
// next to every other icon in the tray.
a := uint8(float64(c.A) * (r*r - d) / (r*r - (r-1)*(r-1)))
img.SetRGBA(x, y, color.RGBA{R: c.R, G: c.G, B: c.B, A: a})
a := uint8(255 * ((r+1)*(r+1) - d) / ((r+1)*(r+1) - r*r))
bg := img.RGBAAt(x, y)
img.SetRGBA(x, y, blend(bg, ring, a))
}
}
}
return img
}
func blend(under, over color.RGBA, a uint8) color.RGBA {
fa := float64(a) / 255
mix := func(u, o uint8) uint8 { return uint8(float64(u)*(1-fa) + float64(o)*fa) }
ua := float64(under.A)/255*(1-fa) + fa
return color.RGBA{R: mix(under.R, over.R), G: mix(under.G, over.G), B: mix(under.B, over.B), A: uint8(ua * 255)}
}
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
//
// Vista and later also accept a PNG stored inside the .ico container, which
@@ -94,8 +161,8 @@ func encodeICO(img *image.RGBA) []byte {
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
b.WriteByte(byte(w))
b.WriteByte(byte(h))
b.WriteByte(0) // palette size: none
b.WriteByte(0) // reserved
b.WriteByte(0) // palette size: none
b.WriteByte(0) // reserved
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource

View File

@@ -13,7 +13,7 @@ import (
// nothing — and nothing on a Mac could notice. These tests are the substitute
// for the Windows box we do not have.
func TestEncodeICOIsAValidIconFile(t *testing.T) {
b := encodeICO(circle(colorFor("ok")))
b := encodeICO(trayImage(colorFor("ok")))
if len(b) < 22 {
t.Fatalf("far too short: %d bytes", len(b))
}
@@ -48,12 +48,13 @@ func TestEncodeICOIsAValidIconFile(t *testing.T) {
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
want := colorFor("error") // red: distinguishable from B and G if swapped
img := circle(want)
img := trayImage(want)
b := encodeICO(img)
// Centre of the circle, which is solid fill. Bottom-up means image row
// iconSize/2 lands at DIB row iconSize/2-1 counting from the start.
row := iconSize - 1 - iconSize/2
i := 22 + 40 + (row*iconSize+iconSize/2)*4
// Centre of the state dot, which is solid fill. Bottom-up means image row
// y lands at DIB row iconSize-1-y counting from the start.
x, y := iconSize-6-1, iconSize-6-1
row := iconSize - 1 - y
i := 22 + 40 + (row*iconSize+x)*4
got := b[i : i+4]
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
t.Errorf("centre pixel = % x, want % x (BGRA)",

View File

@@ -9,10 +9,12 @@ package cloud
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
@@ -38,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 {
@@ -107,8 +122,16 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
}
if rerr := c.Refresh(ctx); rerr != nil {
// The refresh token is gone too, so this really is a sign-in, not a
// transient failure. Report it as such so the UI shows the login sheet
// rather than an error dialog.
// transient failure. Forget the session - in memory AND on disk, through
// the same callback that persists rotations - so the app goes back to
// Login instead of showing "session expired" on every screen until
// somebody finds Sign out. Seen on a PC that had been claimed against a
// demo head office and then re-claimed against the real one: the old
// login sat there, dead, for the whole session.
c.Clear()
if c.onRefresh != nil {
c.onRefresh(Session{})
}
return ErrUnauthorized
}
return c.send(ctx, method, path, raw, out)
@@ -179,9 +202,16 @@ func (c *Client) send(ctx context.Context, method, path string, raw []byte, out
switch {
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
return errTokenExpired
case resp.StatusCode == http.StatusUnauthorized:
case resp.StatusCode == http.StatusUnauthorized && tok != "":
// A 401 on a call we sent a session with: the session is the problem.
return ErrUnauthorized
case resp.StatusCode >= 400:
// Every other 4xx/5xx - including a 401 on a call that carried NO
// session, such as redeeming an installation code - is about the
// request, and the server wrote its message for exactly this moment.
// Mapping those to "session expired" told an installer their session
// had lapsed on a screen where they had never signed in, and hid
// "That installation code is not valid" behind it.
msg := e.Message
if msg == "" {
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
@@ -393,6 +423,11 @@ type Photo struct {
ExpiresIn int `json:"expires_in"`
Available bool `json:"available"`
Reason string `json:"reason"`
// Auth is set by the server when the URL is one of its own endpoints and
// needs this session's bearer, rather than a presigned object-store link
// that carries its own signature. It never reaches the front end - see
// VisitorImage, which resolves it here.
Auth bool `json:"auth"`
}
// VisitorImage fetches a short-lived signed link to this customer's photo.
@@ -413,9 +448,91 @@ func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
return Photo{}, err
}
out.Available = out.URL != ""
// A deployment with no object storage serves the photo from the API itself,
// which means a RELATIVE url that needs this session's bearer. Neither
// works in the window: a webview <img> resolves a relative src against
// wails://, not against the cloud, and it cannot send an Authorization
// header at all - so handing it straight through renders a broken picture
// on exactly the deployments that have just started storing photos.
//
// Fetched here and passed as a data: URI. The alternative is a local proxy
// inside this process holding the session, which is a second authenticated
// surface on the shop PC to get wrong. One photo per sheet, ~90 KB, and the
// server already records the read where the link was handed out.
if out.Available && out.Auth {
data, err := c.fetchImage(ctx, out.URL)
if err != nil {
// The record itself is worth far more than the picture, so this is
// an absence with a reason rather than a failure that blanks the
// customer - the same rule the whole image path follows.
return Photo{Reason: "That photo could not be loaded."}, nil
}
out.URL = data
out.Auth = false
}
return out, nil
}
// fetchImage reads an image this server holds itself and returns a data: URI.
//
// Deliberately not routed through send(): that decodes JSON into `out`, and
// these are bytes. It shares the token and the expiry retry, because a sheet
// opened twelve hours after the last one must not show a broken photo.
func (c *Client) fetchImage(ctx context.Context, path string) (string, error) {
body, err := c.imageBytes(ctx, path)
if errors.Is(err, errTokenExpired) {
if rerr := c.Refresh(ctx); rerr != nil {
return "", rerr
}
body, err = c.imageBytes(ctx, path)
}
if err != nil {
return "", err
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body), nil
}
// maxPhotoBytes bounds what will be pulled into memory and then base64'd into
// the window. Face crops are ~20 KB and a camera still ~100 KB; anything near
// this is a different file or a fault, and a shop PC should not spend its
// memory finding that out.
const maxPhotoBytes = 4 << 20
func (c *Client) imageBytes(ctx context.Context, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil)
if err != nil {
return nil, err
}
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
var e struct {
Error string `json:"error"`
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
_ = json.Unmarshal(body, &e)
if e.Error == "token_expired" {
return nil, errTokenExpired
}
return nil, ErrUnauthorized
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("photo: %s", resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, maxPhotoBytes))
}
// ForgetVisitor erases a customer: face template, photo and profile.
//
// Irreversible by design — a soft-deleted face template is a retained
@@ -427,6 +544,27 @@ func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
"/api/visitors/"+url.PathEscape(id), nil, nil)
}
// AssistantTurn is one message in the help conversation. The browser holds
// the history and resends it; nothing is stored server-side.
type AssistantTurn struct {
Role string `json:"role"`
Text string `json:"text"`
}
// AssistantAnswer is the reply, and the names of what it looked at - shown to
// the user, because an assistant that silently ran a camera check would be
// alarming and naming what it consulted makes a wrong answer traceable.
type AssistantAnswer struct {
Text string `json:"text"`
Used []string `json:"used,omitempty"`
}
// Ask puts a question to the head-office assistant as this signed-in user.
func (c *Client) Ask(ctx context.Context, history []AssistantTurn) (AssistantAnswer, error) {
var out AssistantAnswer
return out, c.do(ctx, http.MethodPost, "/api/assistant", map[string]any{"history": history}, &out)
}
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
var out []Visit
return out, c.do(ctx, http.MethodGet,
@@ -457,7 +595,10 @@ func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error
}
type Customer struct {
ID string `json:"id"`
ID string `json:"id"`
// Ref is the customer number - "V-42" - and is what staff say to each
// other. It is accepted anywhere this customer's id is.
Ref string `json:"ref"`
Label string `json:"label"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
@@ -502,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
}

View File

@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -137,3 +138,43 @@ func TestVisitorIDIsPathEscaped(t *testing.T) {
t.Errorf("path = %q", got)
}
}
// Redeeming an installation code is the one call a fresh PC makes before it
// has any session. When the server refuses it - wrong code, wrong head office -
// it answers 401 with a message written for the installer. That message must
// reach them: "session expired" on a screen where nobody has signed in sent a
// real installer looking for a login problem that did not exist.
func TestARefusedInstallationCodeSaysWhyNotSessionExpired(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
t.Errorf("enrol must not carry a session, got %q", r.Header.Get("Authorization"))
}
fail(w, http.StatusUnauthorized, "bad_token",
"That installation code is not valid. Ask for a new one.")
}))
t.Cleanup(srv.Close)
c := New(srv.URL) // deliberately no session
_, err := c.Bootstrap(context.Background(), "KWFH5S-EH46LT-EE4X47-OSOH7D")
if err == nil {
t.Fatal("a refused code must be an error")
}
if errors.Is(err, ErrUnauthorized) {
t.Fatalf("a refused code is not a session problem, got %v", err)
}
if !strings.Contains(err.Error(), "installation code is not valid") {
t.Fatalf("the server's own words should reach the installer, got %v", err)
}
}
// The other side of the same rule: a 401 on a call that DID carry a session is
// a session problem, and must still read as one.
func TestARejectedSessionStillReadsAsSessionExpired(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusUnauthorized, "unauthorized", "Sign in again.")
})
err := c.do(context.Background(), http.MethodGet, "/api/auth/me", nil, nil)
if !errors.Is(err, ErrUnauthorized) {
t.Fatalf("a 401 with a session should be ErrUnauthorized, got %v", err)
}
}

View File

@@ -10,6 +10,7 @@ import (
"context"
"encoding/json"
"fmt"
agentconfig "github.com/loyaly/behavision-agent/pkg/config"
"io"
"net/http"
"strings"
@@ -20,7 +21,11 @@ type Client struct {
Base string
User string
Password string
http *http.Client
// Creds re-reads the engine's generated credential when one is rejected.
// On a first run the app starts the engine, and the engine writes that
// file seconds later - after the app has already looked for it.
Creds *agentconfig.Creds
http *http.Client
}
func New(base, user, password string) *Client {
@@ -48,8 +53,12 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.User != "" {
req.SetBasicAuth(c.User, c.Password)
user, pass := c.User, c.Password
if c.Creds != nil {
user, pass = c.Creds.Get()
}
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := c.http.Do(req)
if err != nil {
@@ -105,6 +114,12 @@ func (c *Client) DeleteCamera(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/cameras/"+id, nil, nil)
}
// DiscoverCameras asks the engine to scan the shop's network. A few seconds.
func (c *Client) DiscoverCameras(ctx context.Context) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodGet, "/api/cameras/discover", nil, &out)
}
func (c *Client) TestCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)

View File

@@ -12,10 +12,12 @@ import (
"context"
"embed"
"log"
runtime2 "runtime"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
"github.com/wailsapp/wails/v2/pkg/options/windows"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -27,10 +29,38 @@ func main() {
app := NewApp()
tray := newTray(app)
// One process per PC, enforced by the OS rather than by hoping.
//
// The window hides to the tray on close, so the ordinary next thing a shop
// assistant does is double-click the desktop shortcut again to get it
// back. Without this lock that started a SECOND complete copy: a second
// tray icon, a second engine supervisor on the same SQLite WAL and the
// same port - the "start twice" failure the agent package exists to
// prevent, on the one binary that never had the guard. Seen on a Windows
// install as a row of Behavision icons in the tray. A second launch now
// only brings the existing window to the front, which is what the person
// wanted in the first place.
var ctxRef context.Context
single := &options.SingleInstanceLock{
UniqueId: "ai.loyaly.behavision.desktop",
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
if ctxRef != nil {
// The same four calls the tray uses, and for the same reasons:
// this runs on Wails' own listener goroutine rather than the
// window's thread, and the launching process holds the
// foreground, so without the flip the window comes back behind
// it. Double-clicking the desktop icon while it is already
// running is the single most common way anyone reaches this.
go openWindow(ctxRef)
}
},
}
err := wails.Run(&options.App{
Title: "Behavision",
Width: 1280,
Height: 820,
SingleInstanceLock: single,
Title: "Behavision",
Width: 1280,
Height: 820,
// Small enough to still be usable on a cramped shop-counter monitor.
MinWidth: 1024,
MinHeight: 640,
@@ -38,20 +68,48 @@ func main() {
// Closing the window hides it rather than quitting: the engine must
// keep recognising after a shop assistant clicks the X, and the tray
// is where they get the window back.
HideWindowOnClose: true,
// Windows hides on close because the tray is how the window comes
// back and how recognition is stopped. macOS has no tray here (see
// tray_run_darwin.go), so hiding would leave a running engine with no
// window, no tray and no way to reach either - force-quit or nothing.
// Closing the window therefore quits, which also stops the engine
// through OnShutdown. Same rule as the tray's Quit: never leave it
// watching with no visible control.
HideWindowOnClose: runtime2.GOOS == "windows",
OnStartup: func(ctx context.Context) {
ctxRef = ctx
app.startup(ctx)
tray.start(ctx)
},
OnBeforeClose: func(ctx context.Context) bool {
runtime.Hide(ctx)
return true // prevent the close
if runtime2.GOOS != "windows" {
return false // let it close, and OnShutdown stops the engine
}
// WindowHide, not Hide. They are different calls on Windows -
// WindowHide locks the OS thread for the Win32 work and Hide does
// not - and the tray's reopen uses WindowShow, so hiding through
// the other one leaves the pair mismatched. Same call, opposite
// direction.
runtime.WindowHide(ctx)
return true // prevent the close; the tray is how it comes back
},
OnShutdown: func(ctx context.Context) {
tray.stop()
app.proxy.stop()
app.StopEngine()
},
Bind: []any{app},
// This block has to EXIST, not merely be empty. Wails computes
// `zoomable = !Mac.DisableZoom` inside `if frontendOptions.Mac !=
// nil`, and the variable defaults to 0 - so leaving Mac unset does not
// mean "defaults", it means the green maximise button is created dead.
// There was a Windows block and no Mac one, so the window could not be
// zoomed on macOS and nothing anywhere said why.
Mac: &mac.Options{
WebviewIsTransparent: false,
WindowIsTranslucent: false,
DisableZoom: false,
},
Windows: &windows.Options{
WebviewIsTransparent: false,
WindowIsTranslucent: false,

Binary file not shown.

297
desktop/stream_proxy.go Normal file
View File

@@ -0,0 +1,297 @@
package main
// streamProxy serves the engine's camera feeds to this app's own webview
// without putting a credential in the page.
//
// What this replaces: StreamURL used to build
// http://user:pass@127.0.0.1:8010/api/cameras/<id>/stream.mjpeg and hand it
// to an <img>, with a comment saying the credentials were inline "so an <img>
// tag can load it". It cannot. Chromium strips credentials from subresource
// URLs and has since M59, and WebView2 is Chromium - so on the one platform
// this product ships to, every camera tile on the shop floor renders as a
// broken image. Measured against the same running engine: the app's Go-side
// calls returned stats and people while an <img> on the very same URL failed,
// and curl proved the URL itself answered 200. The engine was never the
// problem; the browser was throwing the password away before it asked.
//
// So the password stays on this side of the process boundary. The webview
// asks this loopback listener, the listener attaches Basic auth and relays
// the engine's bytes back unchanged. It is the same reasoning the head-office
// web app already follows in Shot.jsx, where an <img> equally cannot carry a
// session and the bytes are fetched and handed over as an object URL.
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
// A camera id reaches this from the engine and from a person typing into the
// Add Camera form. Validated rather than interpolated: without this a `..`
// would climb out of the two paths below and turn a camera relay into a proxy
// for any engine endpoint, with the credential helpfully attached.
var safeCameraIDChars = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
// safeCameraID is the character check AND the two names that pass it and still
// mean something to a path resolver.
//
// The pattern allows `.` because real camera ids contain them - which means it
// also allows exactly `.` and `..`, and `/api/cameras/../stream.mjpeg` is not
// the endpoint anyone intended. The id can never contain a slash (the path is
// split on them before we get here), so these two strings are the entire
// remaining traversal surface. Found by the test, not by reading the regex.
func safeCameraID(id string) bool {
if id == "." || id == ".." {
return false
}
return safeCameraIDChars.MatchString(id)
}
type streamProxy struct {
mu sync.RWMutex
ln net.Listener
srv *http.Server
client *http.Client
token string
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{} }
// 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 {
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
// listener would expose it. An unauthenticated loopback relay would hand
// that same feed to any other process on this PC, which on a shop counter
// is not a theoretical set. A per-run token, minted here and given only to
// this app's own webview, keeps the relay as private as the engine is.
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return fmt.Errorf("proxy token: %w", err)
}
// Port 0: the OS picks a free one. A fixed port would collide with
// whatever else a shop PC happens to be running, and the failure would be
// "the cameras stopped working" with nothing pointing at the cause.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("stream proxy listen: %w", err)
}
p.ln = ln
p.token = hex.EncodeToString(raw)
// 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.
p.client = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
},
}
srv := &http.Server{Handler: http.HandlerFunc(p.handle)}
p.srv = srv
// srv and ln are captured, not read off the struct inside the goroutine:
// stop() sets both to nil, so a serve loop that reached for them after a
// quick start/stop would dereference nil and take the whole app down. The
// test that stops the relay found exactly that.
go func() { _ = srv.Serve(ln) }()
return nil
}
func (p *streamProxy) stop() {
p.mu.Lock()
srv, ln := p.srv, p.ln
p.srv, p.ln, p.token, p.live = nil, nil, "", nil
p.mu.Unlock()
if srv != nil {
_ = srv.Close()
}
if ln != nil {
_ = ln.Close()
}
}
// urlFor returns the loopback URL for one camera resource, or "" when the
// proxy is not running so the caller can fall back.
func (p *streamProxy) urlFor(cameraID, file string) string {
p.mu.RLock()
defer p.mu.RUnlock()
if p.ln == nil || p.token == "" || !safeCameraID(cameraID) {
return ""
}
return fmt.Sprintf("http://%s/s/%s/%s/%s",
p.ln.Addr().String(), p.token, cameraID, file)
}
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)
return
}
// /s/<token>/<camera>/<file>
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
if len(parts) != 4 || parts[0] != "s" {
http.NotFound(w, r)
return
}
// Constant time: the token is the only thing standing between another
// local process and a live view of customers' faces.
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 {
// 404 rather than 403. There is nothing here to tell an unwelcome
// caller they have found the right door with the wrong key.
http.NotFound(w, r)
return
}
cameraID := parts[2]
if !safeCameraID(cameraID) {
http.NotFound(w, r)
return
}
// An allow-list, not a prefix match. Everything else the engine serves -
// the identity list, the gallery, erasure - stays unreachable through here
// even for a caller holding the token.
//
// frame.jpg is listed although no screen asks for one yet. It is reachable
// only through urlFor, which is internal, so it adds no bound API nobody
// 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":
enginePath = "/api/cameras/" + cameraID + "/stream.mjpeg"
case "frame.jpg":
enginePath = "/api/cameras/" + cameraID + "/frame.jpg"
default:
http.NotFound(w, r)
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target+enginePath, nil)
if err != nil {
http.Error(w, "bad upstream request", http.StatusInternalServerError)
return
}
// frame.jpg takes width and quality; the engine re-encodes on demand.
req.URL.RawQuery = r.URL.RawQuery
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := client.Do(req)
if err != nil {
http.Error(w, "engine unreachable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for _, h := range []string{"Content-Type", "Cache-Control", "Pragma", "Expires"} {
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, v)
}
}
w.WriteHeader(resp.StatusCode)
// Copied by hand rather than with io.Copy so every chunk is flushed. An
// MJPEG stream never ends, so anything buffered waiting for a full buffer
// is a tile that stays blank forever - which is the same symptom as the
// bug this file exists to fix, and would look like it had not worked.
flusher, _ := w.(http.Flusher)
buf := make([]byte, 32*1024)
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return // webview went away
}
if flusher != nil {
flusher.Flush()
}
}
if rerr != nil {
return
}
}
}

View File

@@ -0,0 +1,296 @@
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// fakeEngine stands in for the Python engine: it demands Basic auth exactly as
// the real one does when a credential is configured, and records what it was
// asked for.
type fakeEngine struct {
*httptest.Server
gotPath string
gotUser string
gotPass string
hadAuth bool
}
func newFakeEngine(t *testing.T, body string) *fakeEngine {
t.Helper()
f := &fakeEngine{}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.gotPath = r.URL.Path
if r.URL.RawQuery != "" {
f.gotPath += "?" + r.URL.RawQuery
}
f.gotUser, f.gotPass, f.hadAuth = r.BasicAuth()
if !f.hadAuth {
w.Header().Set("WWW-Authenticate", `Basic realm="behavision"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
_, _ = io.WriteString(w, body)
}))
t.Cleanup(f.Close)
return f
}
func startProxy(t *testing.T, engine string, user, pass string) *streamProxy {
t.Helper()
p := newStreamProxy()
if err := p.start(engine, user, pass); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(p.stop)
return p
}
func get(t *testing.T, url string) (int, string) {
t.Helper()
c := &http.Client{Timeout: 5 * time.Second}
resp, err := c.Get(url)
if err != nil {
t.Fatalf("get %s: %v", url, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// The whole point: the webview gets a URL it can actually load, and the
// password stays behind. A credential in the src is both unloadable in a
// Chromium webview and readable by anything that can see the DOM.
func TestTheCameraURLCarriesNoPassword(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "hunter2-the-real-one")
u := p.urlFor("cam2", "stream.mjpeg")
if u == "" {
t.Fatal("no url while the proxy is running")
}
if strings.Contains(u, "hunter2-the-real-one") || strings.Contains(u, "behavision:") {
t.Fatalf("credential leaked into the tile URL: %s", u)
}
if !strings.HasPrefix(u, "http://127.0.0.1:") {
t.Fatalf("relay must be loopback only, got %s", u)
}
}
func TestTheRelayAttachesTheCredentialItself(t *testing.T) {
engine := newFakeEngine(t, "frame-bytes")
p := startProxy(t, engine.URL, "behavision", "s3cret")
code, body := get(t, p.urlFor("cam2", "stream.mjpeg"))
if code != http.StatusOK {
t.Fatalf("want 200 through the relay, got %d", code)
}
if body != "frame-bytes" {
t.Fatalf("body not relayed unchanged: %q", body)
}
if !engine.hadAuth || engine.gotUser != "behavision" || engine.gotPass != "s3cret" {
t.Fatalf("engine did not receive the credential: auth=%v user=%q",
engine.hadAuth, engine.gotUser)
}
if engine.gotPath != "/api/cameras/cam2/stream.mjpeg" {
t.Fatalf("wrong upstream path: %s", engine.gotPath)
}
}
// The token is what keeps every other process on a shop PC from opening a live
// view of customers' faces, now that the relay itself has no password.
func TestAnotherProcessCannotGuessItsWayIn(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "s3cret")
addr := p.ln.Addr().String()
for _, bad := range []string{"", "0", strings.Repeat("a", 64), "wrong-token"} {
url := fmt.Sprintf("http://%s/s/%s/cam2/stream.mjpeg", addr, bad)
if code, _ := get(t, url); code != http.StatusNotFound {
t.Fatalf("token %q got %d, want 404", bad, code)
}
}
if engine.hadAuth {
t.Fatal("a rejected request still reached the engine")
}
}
// A camera id is interpolated into the upstream path, so it has to be a camera
// id and not a way to walk to a different endpoint with the credential
// attached.
func TestACameraIdCannotClimbOutOfItsPath(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "s3cret")
addr := p.ln.Addr().String()
for _, bad := range []string{"..", "%2e%2e", "cam2/../../api/identities", "cam 2", ""} {
url := fmt.Sprintf("http://%s/s/%s/%s/stream.mjpeg", addr, p.token, bad)
code, _ := get(t, url)
if code != http.StatusNotFound {
t.Fatalf("camera id %q got %d, want 404", bad, code)
}
}
if strings.Contains(engine.gotPath, "identities") {
t.Fatalf("reached a non-camera endpoint: %s", engine.gotPath)
}
}
// Only the two files a tile needs. The engine also serves the identity list and
// the erasure endpoint; holding the token must not open those.
func TestOnlyTheTwoCameraFilesAreReachable(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "s3cret")
addr := p.ln.Addr().String()
for _, bad := range []string{"identities", "stats", "commission", "stream.mjpeg.bak"} {
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, bad)
if code, _ := get(t, url); code != http.StatusNotFound {
t.Fatalf("file %q got %d, want 404", bad, code)
}
}
for _, good := range []string{"stream.mjpeg", "frame.jpg"} {
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, good)
if code, _ := get(t, url); code != http.StatusOK {
t.Fatalf("file %q got %d, want 200", good, code)
}
}
}
// frame.jpg takes width and quality - the engine re-encodes on demand, and a
// relay that dropped the query would silently serve full-size frames.
func TestTheQueryStringSurvivesTheRelay(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "s3cret")
url := p.urlFor("cam2", "frame.jpg") + "?width=640&quality=70"
if code, _ := get(t, url); code != http.StatusOK {
t.Fatalf("got %d", code)
}
if !strings.Contains(engine.gotPath, "width=640") ||
!strings.Contains(engine.gotPath, "quality=70") {
t.Fatalf("query dropped: %s", engine.gotPath)
}
}
// An engine that is not running must read as a bad gateway, not as a hang. A
// blank tile that never resolves is the symptom this whole file exists to end.
func TestAnEngineThatIsDownFailsQuickly(t *testing.T) {
// Port 1 on loopback: nothing listens, and the connection is refused
// rather than dropped, so this is fast and deterministic.
p := startProxy(t, "http://127.0.0.1:1", "behavision", "s3cret")
done := make(chan int, 1)
go func() { code, _ := get(t, p.urlFor("cam2", "stream.mjpeg")); done <- code }()
select {
case code := <-done:
if code != http.StatusBadGateway {
t.Fatalf("want 502, got %d", code)
}
case <-time.After(8 * time.Second):
t.Fatal("a dead engine left the request hanging")
}
}
// Stopping must actually free the port, or a restarted engine leaves listeners
// behind for the life of the process.
func TestStoppingReleasesEverything(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := newStreamProxy()
if err := p.start(engine.URL, "u", "p"); err != nil {
t.Fatalf("start: %v", err)
}
url := p.urlFor("cam2", "stream.mjpeg")
if code, _ := get(t, url); code != http.StatusOK {
t.Fatalf("want 200 before stop, got %d", code)
}
p.stop()
if got := p.urlFor("cam2", "stream.mjpeg"); got != "" {
t.Fatalf("still handing out URLs after stop: %s", got)
}
c := &http.Client{Timeout: 3 * time.Second}
if resp, err := c.Get(url); err == nil {
resp.Body.Close()
t.Fatal("listener still accepting after stop")
}
}
// start twice must not leave two listeners, which is what a restarted engine
// would otherwise cause.
func TestStartingTwiceIsANoOp(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "u", "p")
first := p.urlFor("cam2", "stream.mjpeg")
if err := p.start(engine.URL, "u", "p"); err != nil {
t.Fatalf("second start: %v", err)
}
if second := p.urlFor("cam2", "stream.mjpeg"); second != first {
t.Fatalf("second start moved the relay: %s -> %s", first, second)
}
}
// Against the real engine, which the unit tests above deliberately do not
// touch. Skipped unless TEST_ENGINE_URL is set, the same rule the server's
// live store tests follow: the suite must stay runnable with no services.
//
// TEST_ENGINE_URL=http://127.0.0.1:8010 \
// TEST_ENGINE_USER=... TEST_ENGINE_PASS=... go test ./desktop/ -run Live
//
// It exists because everything above proves the relay against a fake that
// agrees with me. Only a real engine proves the thing that was actually
// broken: that a multipart MJPEG stream arrives through the relay in pieces,
// rather than being buffered into a tile that never paints.
func TestLiveRelayCarriesRealMJPEGFrames(t *testing.T) {
base := os.Getenv("TEST_ENGINE_URL")
if base == "" {
t.Skip("set TEST_ENGINE_URL to run the live relay test")
}
cam := os.Getenv("TEST_ENGINE_CAMERA")
if cam == "" {
cam = "cam2"
}
p := startProxy(t, base, os.Getenv("TEST_ENGINE_USER"), os.Getenv("TEST_ENGINE_PASS"))
url := p.urlFor(cam, "stream.mjpeg")
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("relay: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("relay returned %d - the credential did not reach the engine", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart") {
t.Fatalf("not a stream: Content-Type %q", ct)
}
// Read until two JPEG start markers have gone past. One proves it opened;
// two prove it is still delivering, which is the difference between a
// working tile and a single frozen frame.
deadline := time.Now().Add(15 * time.Second)
var seen, total int
buf := make([]byte, 16*1024)
for seen < 2 && time.Now().Before(deadline) {
n, rerr := resp.Body.Read(buf)
total += n
seen += strings.Count(string(buf[:n]), "\xff\xd8\xff")
if rerr != nil {
break
}
}
if seen < 2 {
t.Fatalf("only %d JPEG frames in %d bytes - the relay is not streaming", seen, total)
}
t.Logf("relayed %d frames in %d bytes with no credential in the URL", seen, total)
}

149
desktop/stream_remote.go Normal file
View 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
}

View 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")
}
}

View 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")
}
}

BIN
desktop/tray-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Some files were not shown because too many files have changed in this diff Show More