Compare commits
14 Commits
v0.4.9-dem
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6a2c392d9 | |||
| b296e8a74a | |||
| c9be9b5807 | |||
| 0558344dc2 | |||
| 8f07dee048 | |||
| 3cddd9c2e1 | |||
| 248025cdf9 | |||
| ff4f95c3b0 | |||
| 48a30d97db | |||
| ecc8bbba6f | |||
| 97a8ecc03a | |||
| 50d122e5f0 | |||
| dd3331ee9d | |||
| 68a50d10b1 |
582
CLAUDE.md
582
CLAUDE.md
@@ -2568,6 +2568,93 @@ raw string that is two literal characters and Postgres requires exactly one —
|
||||
it would have failed the **whole** customer search at runtime, on a query no
|
||||
in-memory test executes.
|
||||
|
||||
## The desktop app on two platforms, and three bugs found by launching it
|
||||
|
||||
All three were reported or found by *starting the app the way a person
|
||||
starts one*, and all three had survived every test.
|
||||
|
||||
### "Open dashboard" in the tray did nothing reliable
|
||||
|
||||
`runtime.Show` was wrong three times over and the first is why it failed
|
||||
rather than merely misbehaved. Wails implements `Show()` as a bare
|
||||
`mainWindow.Show()` while `WindowShow()` wraps the identical work in
|
||||
`runtime.LockOSThread`. Win32 window operations must run on the thread owning
|
||||
the window's message pump, and the tray handler runs on the **systray's**
|
||||
goroutine, which never is.
|
||||
|
||||
Two more, each sufficient alone: showing is not un-minimising (hidden and
|
||||
minimised are different states), and Windows refuses the foreground to a
|
||||
process that does not already hold it — so the window returned *behind*
|
||||
whatever was being looked at. A tray click is by definition a moment when the
|
||||
app is not in front, so that is every time, not an edge case. The
|
||||
always-on-top flip is the ordinary way to ask, and it is why this runs in a
|
||||
goroutine: a menu loop that sleeps is a tray that ignores the next click.
|
||||
|
||||
`OnSecondInstanceLaunch` had the same shape and is hit far more often —
|
||||
double-clicking the desktop icon while the app is already running.
|
||||
|
||||
### The engine inherited whatever directory launched the app
|
||||
|
||||
Nothing ever set `cmd.Dir`, so the child took the parent's — and an app
|
||||
started by double-clicking its bundle is handed `/`. On macOS the symptom was
|
||||
`python: No module named behavision` forever, because the dev engine runs as
|
||||
`-m behavision`, which resolves against the working directory.
|
||||
|
||||
**The same app launched from a terminal inside the repo worked perfectly**,
|
||||
which is the shape of a bug that survives every test a developer runs.
|
||||
`Config.EngineDir` (empty = install root) is set by both launchers, which had
|
||||
identical code and the identical omission.
|
||||
|
||||
### macOS is a supported DEVELOPER target, not a product
|
||||
|
||||
Indian retail counters are Windows. A Mac product means an Apple Developer
|
||||
account, notarisation, a second installer, and DPAPI having no macOS
|
||||
equivalent — a permanent second platform for customers who do not have Macs.
|
||||
What it *is* worth is demoing on the machine this is written on.
|
||||
|
||||
It cost one missing framework and then two crashes:
|
||||
|
||||
```
|
||||
link Undefined symbols: _OBJC_CLASS_$_UTType
|
||||
Wails' darwin frontend references it and does not link
|
||||
UniformTypeIdentifiers. Fails at the LINK step after compiling
|
||||
everything, so it reads like a broken toolchain.
|
||||
systray.Run SIGTRAP in cgo — nativeLoop takes the macOS main
|
||||
run loop and Wails already has it
|
||||
RunWithExternalLoop "NSWindow should only be instantiated on the main
|
||||
thread!" — still builds AppKit objects, and
|
||||
OnStartup is not the main thread
|
||||
```
|
||||
|
||||
So **there is no tray on macOS**, and the consequence is handled rather than
|
||||
left: with no tray there is no way back from a hidden window and no way to
|
||||
quit, so on macOS closing the window quits and stops the engine. Same rule the
|
||||
tray's Quit follows — never leave it watching with no visible control.
|
||||
|
||||
**The window could not be maximised**, and that was an omission with a precise
|
||||
consequence. Wails computes `zoomable` *inside* `if frontendOptions.Mac !=
|
||||
nil`; the variable defaults to 0, and the native side then does
|
||||
`if (!zoomable && resizable) [zoomButton setEnabled: NO]`. There was a
|
||||
`Windows` options block and no `Mac` one — so the platform that was configured
|
||||
behaved and the platform that was not looked broken.
|
||||
|
||||
### A macOS release costs nothing extra, and the reason is worth keeping
|
||||
|
||||
`release.sh` has never used PyInstaller. The Windows package is a **source
|
||||
install**: a pure-Python wheel plus `behavision-setup`, which builds a venv on
|
||||
the target machine, done that way because PyInstaller cannot cross-compile.
|
||||
macOS therefore needs nothing new — same wheel, same setup tool, a natively
|
||||
built `.app` instead of the `.exe`. `MAC=1 ./release.sh` opts in.
|
||||
|
||||
Not notarised, and that is stated in the notes rather than discovered: macOS
|
||||
*blocks* an unsigned download rather than warning like SmartScreen, so a first
|
||||
launch needs right-click → Open.
|
||||
|
||||
Verified by extracting the published zip to a clean directory: signature
|
||||
intact through the round trip, the app runs, and it reports *"engine not
|
||||
installed yet; run behavision-setup, then Start"* — the correct fresh-machine
|
||||
state rather than a crash.
|
||||
|
||||
## Setting up on a new machine
|
||||
|
||||
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds
|
||||
@@ -3220,3 +3307,498 @@ Against real Postgres, on the demo tenant:
|
||||
- HTML, PDF, GIF and empty bodies are all refused as face images: the check is
|
||||
on the magic bytes, never the `Content-Type` header, because this endpoint
|
||||
stores what it is handed and serves it back to a browser.
|
||||
|
||||
## Viewer mode: the app on a computer that is not watching anything
|
||||
|
||||
Signing in on a second Mac showed `engine not reachable at
|
||||
http://127.0.0.1:8010` and **0 of 0 cameras**, on an account whose shops were
|
||||
running and recognising people the whole time. Nothing was broken. `App.Live()`
|
||||
and `App.Cameras()` read **only** `a.local`, so the app answered as though the
|
||||
person had never signed in — and camera sync goes *through* the engine, which
|
||||
is why the count was zero rather than merely stale.
|
||||
|
||||
That is the wrong model of what this application is. A shop PC watches
|
||||
cameras; an owner's laptop, a manager's machine, a second till being set up do
|
||||
not, and all three are signed in to the same estate. **Having no engine is a
|
||||
normal state, not a failure**, and the app now says what it can see from where
|
||||
it is standing instead of reporting the absence of something it does not need.
|
||||
|
||||
Both methods try loopback first and fall back to head office when it fails and
|
||||
somebody is signed in. The order matters: a real shop PC must never be shown
|
||||
head office's minute-old summary when the engine two milliseconds away has the
|
||||
live one.
|
||||
|
||||
- **`Viewing` is on the snapshot, not inferred in the browser.** Three surfaces
|
||||
read it — the banner, the camera tally, the getting-started panel — and a
|
||||
screen that computed it separately is how the shops screen once came out
|
||||
labelled **Working**, in green, directly above *"2 of 3 cameras not
|
||||
connecting"*. One fact, one place, the same rule as the tray being a client
|
||||
of `EngineStatus()`.
|
||||
- **`fraction_below_gate` is the WORST shop, never an average.** 0.10 against
|
||||
0.73 averages to 0.42 and hides the only shop anyone needs to visit. Same
|
||||
rule the heartbeat already follows with `worst_site`.
|
||||
- **A remote camera is flagged `remote: true`, and the screen withholds Edit,
|
||||
Remove and Check placement.** Those talk to a camera on a LAN this computer
|
||||
cannot reach, and an Edit button that cannot work is worse than one that is
|
||||
absent. The tenant response structurally cannot carry `host`, `username` or
|
||||
`has_password`, so nothing here can invent them either — a test asserts that.
|
||||
- **`connected` is three states.** `null` is "no shop computer has reported on
|
||||
this yet" and reads as *waiting*; `false` is *"Not connecting"*. A bare false
|
||||
sends somebody to check cabling on a camera nobody has tried to reach.
|
||||
- **The picture is the last snapshot, and it says so.** There is no live video
|
||||
here: the engine's MJPEG stream is on the shop PC's loopback behind a router
|
||||
with no inbound route. Head office's `LiveHub` relay is the answer to that
|
||||
and is a further step for this client; the banner does not imply otherwise.
|
||||
- **Snapshots are fetched in Go and passed as `data:` URIs, cached by
|
||||
`snapshot_at`.** A webview `<img>` resolves a relative src against `wails://`
|
||||
and cannot send the session's bearer — the same problem `VisitorImage`
|
||||
already solved — and this screen polls every 8 seconds at ~90 KB a camera, so
|
||||
re-fetching an unchanged frame is megabytes an hour to redraw the same
|
||||
picture. Keyed on the server's `snapshot_at`, because a new timestamp is the
|
||||
only thing that means a new photograph.
|
||||
- **With no engine AND nobody signed in, the engine error is still the answer.**
|
||||
There is nothing else to show and the person is most likely setting this PC
|
||||
up; naming head office there points them at a step they have not reached.
|
||||
|
||||
## Watching a camera from the app, in another building
|
||||
|
||||
Snapshots answer *"is that camera working"*. They do not answer *"what is
|
||||
happening in my shop right now"*, which is what somebody who opens the app
|
||||
away from the counter is asking. Head office's browser already had the answer
|
||||
— `LiveHub` plus `cameras.Live`, where the shop PC asks outbound whether
|
||||
anybody is watching and pushes JPEG frames up for exactly as long as somebody
|
||||
is — and the app could not reach it.
|
||||
|
||||
`cloud.CameraLive` opens that feed and the app's own loopback relay re-emits
|
||||
it as **multipart MJPEG**, which is the whole trick: frames arrive base64 over
|
||||
SSE, an `<img>` cannot render that, and an `<img>` renders MJPEG natively. So a
|
||||
tile is an ordinary `<img>` pointed at loopback whether the camera is in this
|
||||
room or another city, and no screen has to know which.
|
||||
|
||||
- **Reconnecting happens in the relay, not the page.** The server caps one push
|
||||
at five minutes so a tab left open for a week cannot leave a shop uploading
|
||||
for a week. Doing it here means the `<img>` never sees the stream end.
|
||||
- **The headers are flushed before the first frame.** Go writes them on the
|
||||
first body write, so without that the whole response — status line included —
|
||||
waits for the shop PC to start pushing. Measured against production: thirty
|
||||
seconds and not even a `Content-Type`, which surfaces as the *request* timing
|
||||
out rather than a stream that has not painted yet.
|
||||
- **One camera at a time.** Watching makes a shop PC upload, so a grid that
|
||||
went live at once would put an estate's worth of cameras on the wire because
|
||||
somebody opened a page. `Watch live` is per tile and toggles the previous one
|
||||
off.
|
||||
- **`live.mjpeg` is behind the same per-run token as the engine routes**, and a
|
||||
wrong token is a 404 that never reaches head office at all. It is a live view
|
||||
of a shop floor; the relay being on loopback is not on its own a control.
|
||||
- **`CameraLive` uses its own HTTP client.** The shared one has a 30-second
|
||||
timeout that covers the whole response and would therefore sever a working
|
||||
live view every thirty seconds — the same trap that made the server set
|
||||
`WriteTimeout` to zero for its own SSE endpoint.
|
||||
|
||||
## A camera read "Connected" for 34 minutes after the shop PC went blind
|
||||
|
||||
Found while verifying the live view against production, and it is the reason
|
||||
that verification looked like a failure: head office registered the viewer and
|
||||
no frame ever came.
|
||||
|
||||
`reportWith` returns early when the engine is unreachable — correctly, because
|
||||
it has nothing to say — so the last state it sent **stays in the database
|
||||
looking current**. Measured on the live estate: `cam2` and `entrance` both
|
||||
reading **Connected**, in green, with `last_seen_at` thirty-four minutes old,
|
||||
while the heartbeat from the same PC said `cameras_up: 0, cameras_total: 0`.
|
||||
Two surfaces reading two stored fields and disagreeing about one fact.
|
||||
|
||||
`false` could not be the answer. It means *"this camera is not connecting"*,
|
||||
which sends an installer to check cabling on a camera that was working
|
||||
perfectly the last time anybody could ask it. So there are four states, not
|
||||
three, and `api.CameraState` is the one function that decides them:
|
||||
|
||||
| state | meaning | what to do |
|
||||
|---|---|---|
|
||||
| `connected` | reported within `CameraStaleAfter`, and working | — |
|
||||
| `not_connecting` | reported recently, and the stream will not open | check the address, password, cabling |
|
||||
| `waiting` | no shop PC has ever reported this camera | it has not reached the PC yet |
|
||||
| `stale` | reported once, and not lately | check the PC is on and Behavision is running |
|
||||
|
||||
- **`Connected` is CLEARED when the state is `stale` or `waiting`.** Leaving a
|
||||
stale `true` in place keeps the lie available to every client that reads the
|
||||
field directly — a mobile app, a script, an older desktop build — and leaves
|
||||
two fields on one object disagreeing, which is exactly how the shops screen
|
||||
once came out labelled **Working**, in green, above *"2 of 3 cameras not
|
||||
connecting"*.
|
||||
- **It is computed in `scanCamera`**, so every camera anybody reads passes
|
||||
through it. A state computed per handler is a state one handler forgets, and
|
||||
this one had already reached three screens.
|
||||
- **`CameraStaleAfter` is 5 minutes — five missed reports, not one.** The agent
|
||||
reports on a 60-second tick, so one miss is a dropped packet. Same reasoning
|
||||
as a site being offline after three missed heartbeats: an indicator that
|
||||
cries wolf is one people learn to ignore.
|
||||
- **An unparseable `last_seen_at` is stale**, not connected. It should be
|
||||
impossible, which is precisely why it must not fall through to the state that
|
||||
says everything is fine.
|
||||
|
||||
## A demo on somebody else's Mac found four things, all of them silent
|
||||
|
||||
Three failures in one afternoon on 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 for anything. It picked
|
||||
3.14, pip found no numpy wheel for cp314 (`numpy<2.0` caps the resolver at
|
||||
1.26.4, whose newest is cp312), fell back to building numpy from source and
|
||||
produced `ERROR: 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`.
|
||||
|
||||
Two screens of C compiler output on a shop counter, for a version choice this
|
||||
program made silently. `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. It is a *wheel-availability*
|
||||
ceiling, not a language one: onnxruntime is the binding dependency today
|
||||
(cp314 is its newest), numpy publishes further ahead, and opencv ships a
|
||||
stable-ABI wheel that covers everything.
|
||||
|
||||
### `numpy<2.0` was the cap; OpenCV was the hazard
|
||||
|
||||
Widening to `<3.0` 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 to prove the per-camera rule. 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
|
||||
and 4.11 merely survived it.
|
||||
|
||||
It never reached the product — `Engine._build_worker` builds a detector per
|
||||
camera, which is the rule and is what the second test guards. What it reached
|
||||
was the suite: two runs in three died with **no failing assertion in them**,
|
||||
turning "we upgraded OpenCV" into the hardest kind of CI failure to read. The
|
||||
race now runs in a **subprocess**, so a segfault is an observed outcome rather
|
||||
than the end of the run, and one clean attempt proves nothing — the premise
|
||||
holds if *any* of several attempts misbehaves. With that fixed the suite is
|
||||
226 passed / 2 skipped on numpy 2.0.2, five runs out of five.
|
||||
|
||||
`opencv-python` stays capped below 5. Everything above was measured on 4.x, and
|
||||
an uncapped `>=4.8.1` means every NEW install silently gets a major release
|
||||
this project has never run a real camera through while every existing one keeps
|
||||
4.11.
|
||||
|
||||
### And the fix was defeated by the wreckage of the bug
|
||||
|
||||
`makeVenv` reused any environment already on disk, whatever Python built it.
|
||||
That machine had a runtime built by **3.14**, left behind 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, not in there. 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.
|
||||
|
||||
### 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 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 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 shop's till is the other
|
||||
half of that loop, so somebody 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, because nothing says otherwise.
|
||||
|
||||
`Config.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*. A
|
||||
config that could not be written falls back to a per-run id rather than a
|
||||
shared one: the right failure is a new name in the log after a restart, not the
|
||||
collision this exists to end.
|
||||
|
||||
### "no such file or directory" for an engine nobody had installed
|
||||
|
||||
Pressing Start with no engine 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 true, none of it saying *run the setup tool*. The startup path did
|
||||
have that sentence — in a log file nobody on a shop counter opens.
|
||||
`App.engineMissing()` is now the one function the startup path, the Start
|
||||
button and the status panel all consult, so three surfaces cannot give three
|
||||
accounts of one fact.
|
||||
|
||||
It also names **App Translocation**, which is in that path and is unguessable.
|
||||
macOS quarantines a downloaded app it cannot verify and runs it from a randomly
|
||||
named read-only copy, so every relative path resolves inside that copy — which
|
||||
is why the engine folder appears missing from a bundle that plainly contains
|
||||
one, and why installing into it would not survive a restart. Fixed by dragging
|
||||
the app to Applications; saying nothing leaves somebody re-running a setup tool
|
||||
that cannot win. The product is unsigned, so this is the *normal* first-run
|
||||
state on every Mac, not an edge case.
|
||||
|
||||
### And then it could not download a 230 KB file
|
||||
|
||||
With all of the above fixed the install succeeded on that Mac - Python 3.14
|
||||
chosen and accepted, numpy 2.5.3, onnxruntime 1.30, faiss 1.15.1, the engine
|
||||
itself - and setup died on the last step, fetching the YuNet model:
|
||||
|
||||
```
|
||||
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
|
||||
certificate verify failed: unable to get local issuer certificate
|
||||
```
|
||||
|
||||
A python.org macOS build ships its **own OpenSSL with no trust store**, and
|
||||
populates one only when somebody double-clicks `Install Certificates.command`
|
||||
in the Python folder. Nobody installing face-recognition software has any
|
||||
reason to know that exists, and the failure is forty lines of traceback about
|
||||
`_ssl.c` at the end of a ten-minute install.
|
||||
|
||||
`_urlopen` tries the default context first and retries with **certifi's**
|
||||
bundle on a verification failure. The order is the whole design:
|
||||
|
||||
- Default first, because on Windows and on a system or Homebrew Python the
|
||||
default context reads the machine's own certificate store - which is what
|
||||
makes a corporate proxy with its own root CA work. Replacing it
|
||||
unconditionally would break every site that has one in order to fix a
|
||||
different platform.
|
||||
- certifi second, because it is already installed: `requests` is a hard
|
||||
dependency and brings it.
|
||||
- A `URLError` is re-raised untouched. "No route to host" and "no trust store"
|
||||
are different problems, and retrying the first with a different CA list only
|
||||
delays the real message.
|
||||
|
||||
`urlretrieve` had to go, since it offers no way to pass a context - and that is
|
||||
exactly the kind of rewrite that silently drops something. The
|
||||
`download: <label> <n>%` lines are a **contract**: `supervisor.go`'s
|
||||
`progressRe` parses them to put first-run progress in the tray, because the API
|
||||
is not up yet and a shop PC showing a stopped engine for five minutes after
|
||||
install looks broken. `tests/test_model_download.py` asserts them, and the
|
||||
rewritten fetch was checked against the real URL: 232,589 bytes, sha256
|
||||
identical to the model already on disk.
|
||||
|
||||
## The engine stopped updating, and said "installed" every time
|
||||
|
||||
The SSL fix above was released and **did not reach the machine it was written
|
||||
for**. Its log said so, one line above the tick:
|
||||
|
||||
```
|
||||
behavision is already installed with the same version as the provided wheel.
|
||||
Use --force-reinstall to force an installation of the wheel.
|
||||
[ok] Engine and dependencies installed
|
||||
```
|
||||
|
||||
and the traceback that followed still pointed at `model_assets.py", line 59,
|
||||
in _fetch / urllib.request.urlretrieve` — code the fix had deleted.
|
||||
|
||||
Two frozen literals caused it: `version = "1.1.0"` in `pyproject.toml` and
|
||||
`__version__ = "1.0.0"` in `behavision/__init__.py`. They disagreed with each
|
||||
other and neither tracked a release, so **every release built
|
||||
`behavision-1.1.0-py3-none-any.whl`** and `pip install --upgrade` on a machine
|
||||
that already had 1.1.0 is a no-op. The comment beside that call even claimed
|
||||
the opposite: *"`--upgrade` so re-running after a new release replaces the
|
||||
engine rather than leaving the old one in place and reporting success."*
|
||||
|
||||
The shape of the damage is what makes it bad. The Go binaries — the app, the
|
||||
agent, the setup tool — are rebuilt every release and updated normally. So a
|
||||
shop PC ran a current app supervising an engine several releases old, and
|
||||
nothing anywhere said which: `/api/health` reported the model, the paths, the
|
||||
cameras and the gallery, and **no version at all**.
|
||||
|
||||
Three changes, and the second is the one that does not depend on the first
|
||||
being remembered:
|
||||
|
||||
- **One version, in the package**, read by `pyproject.toml` through
|
||||
`[tool.setuptools.dynamic]`. Two literals in two files is how they came to
|
||||
disagree. In a checkout it reads `0.0.0+dev`, because a plausible-looking
|
||||
number on a developer's `/api/health` would be worse than none.
|
||||
- **`pip install --force-reinstall --no-deps <wheel>`, after the ordinary
|
||||
`--upgrade`.** `--upgrade` settles the dependencies; the second call
|
||||
guarantees our own code is the code in the folder. `--no-deps` is what keeps
|
||||
it cheap — forcing the dependencies too would re-download ~300 MB of numpy,
|
||||
OpenCV and onnxruntime on every run. A rebuild at an unchanged version is the
|
||||
ordinary case while developing, so this must not rely on the version moving.
|
||||
- **`release.sh` stamps the tag**: `v0.5.6-demo` → `0.5.6+demo`, valid PEP 440
|
||||
(a local segment takes alphanumerics and dots, never hyphens). Into a copy of
|
||||
the line, reverted in a `trap`, so the tree is never left dirty.
|
||||
|
||||
`/api/health` reports `version` now. Without it there is no way to tell a shop
|
||||
PC three releases behind from a current one, which is precisely how this
|
||||
survived several releases.
|
||||
|
||||
Reproduced end to end before fixing, against real wheels on Python 3.12: two
|
||||
builds of the same version, `--upgrade` leaves the old code in place and prints
|
||||
the same sentence the colleague's Mac printed, `--force-reinstall --no-deps`
|
||||
replaces it.
|
||||
|
||||
### `urllib` does not let `SSLCertVerificationError` out, and a fake said it did
|
||||
|
||||
The certificate fix above shipped and **failed on the machine it was written
|
||||
for, with the exact traceback it was meant to prevent**. The retry was written
|
||||
|
||||
```python
|
||||
except ssl.SSLCertVerificationError:
|
||||
```
|
||||
|
||||
and urllib never raises that from `urlopen`. It catches it and re-raises
|
||||
`urllib.error.URLError(err)`, carrying the original on `.reason`. So the
|
||||
`except` matched nothing, ever, and the fallback could not fire.
|
||||
|
||||
The unit test passed throughout, because the stub it used raised the bare SSL
|
||||
error — **a shape real urllib never produces**. That is the whole lesson: a
|
||||
fake that agrees with the author is worse than no test, because it converts an
|
||||
untested path into a tested-looking one. The same sentence is already in this
|
||||
file about `UPDATE ... RETURNING` and about the in-memory API fake, and it was
|
||||
written again here anyway.
|
||||
|
||||
`_is_cert_failure` checks the exception **and** its `.reason`, and the tests
|
||||
now raise `URLError(SSLCertVerificationError(...))`, which is what his
|
||||
traceback shows. A plain `URLError` — "no route to host" — is re-raised
|
||||
untouched: retrying that with a different CA list changes nothing except how
|
||||
long the operator waits for the real message, and a test asserts the second
|
||||
attempt never happens.
|
||||
|
||||
Beside the stubs there is now a **real** reproduction,
|
||||
`test_against_a_real_machine_with_no_trust_store`, opt-in behind
|
||||
`BEHAVISION_NETWORK_TESTS=1` because it reaches github.com. Python's default
|
||||
context honours `SSL_CERT_FILE`, so pointing it at an empty file gives a
|
||||
default context that trusts nobody — the python.org condition exactly — while
|
||||
certifi is loaded by path and is unaffected.
|
||||
|
||||
It skips rather than passes where it cannot reproduce that, and the difference
|
||||
is measured rather than assumed:
|
||||
|
||||
```
|
||||
macOS Command Line Tools LibreSSL 2.8.3 128 CAs with SSL_CERT_FILE empty
|
||||
(reads the system keychain)
|
||||
python.org / pyenv build OpenSSL 3.5.8 0 CAs -> reproduces it
|
||||
```
|
||||
|
||||
Checked for teeth by putting the shipped `except` back: both the corrected stub
|
||||
test and the live one fail, and pass again when it is restored.
|
||||
|
||||
## "The cameras won't connect from my mobile internet" — and why that is not a bug
|
||||
|
||||
Asked directly by the owner, about his own cameras, from his phone's
|
||||
connection. The answer is physics, and the product was not giving it.
|
||||
|
||||
A camera lives on the shop's LAN behind a router. `192.168.1.121` means
|
||||
*"something on the network I am attached to"* and nothing more — from mobile
|
||||
data, a hotel, or head office it resolves to nobody, or to a completely
|
||||
different device that happens to hold that number. There is no route in from
|
||||
the internet and **there must not be**: an RTSP camera reachable from outside
|
||||
is how a shop's cameras end up being watched by strangers.
|
||||
|
||||
This is the reason the product is split the way it is, and it is worth stating
|
||||
plainly next to the split itself: the shop PC is the only machine on the
|
||||
camera's LAN, so it does the connecting, and every other surface reaches it
|
||||
**outbound** — the agent's pull, the arrivals feed, and `LiveHub`'s frame relay,
|
||||
which is what makes **Watch live** work from anywhere while nothing connects in.
|
||||
|
||||
What was wrong is the message. `cannot reach 192.168.1.121:554 - Operation
|
||||
timed out` reads as a broken camera and sends somebody to re-type an address
|
||||
and a password that were always correct. `_wrong_network_hint` now names the
|
||||
cause, and it distinguishes two states that need opposite actions — the same
|
||||
rule as `artifact` against `no_faces`, and `stale` against `not_connecting`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| this computer **is** on that network | check the camera is powered on and that the address is right |
|
||||
| this computer is **somewhere else** | the computer is in the wrong place; no setting here fixes it, recognition has to run on a machine in the shop |
|
||||
|
||||
- **The local address comes from a `connect`ed UDP socket** that sends nothing.
|
||||
It only fixes a route so the kernel will name the source address — no packet
|
||||
leaves, and it needs no dependency in an engine that already ships 200 MB of
|
||||
models.
|
||||
- **The LAN ranges are spelled out, not `is_private`.** That property is
|
||||
broader than "an address on somebody's LAN": it also covers carrier-grade NAT
|
||||
and the documentation networks (192.0.2, 198.51.100, 203.0.113), and telling
|
||||
somebody who typed one of those that it is "on the shop's own network" is a
|
||||
confident wrong answer in the place people look first. Found by a test using
|
||||
`203.0.113.9` as its example of a *public* address, which `is_private` calls
|
||||
private.
|
||||
- **A DNS name gets no hint at all.** Nothing can be concluded about
|
||||
`camera.local` from the string, and guessing is the failure mode this whole
|
||||
message exists to fix.
|
||||
- **With no network at all it still names the cause** and drops the comparison,
|
||||
rather than claiming to know which network this machine is on.
|
||||
|
||||
## A tunnel for the demo: when it is the right answer, and when it is not
|
||||
|
||||
Asked after the mobile-internet question: *"what if we created a secure tunnel
|
||||
or vpn, then the cameras in our office can be shown in the demo version too?"*
|
||||
|
||||
Three different jobs get confused here, and only one of them needs a tunnel.
|
||||
|
||||
**Seeing the estate from anywhere already works and needs nothing.** Viewer
|
||||
mode plus `LiveHub` is exactly this: the shop PC pushes frames outbound and any
|
||||
signed-in app sees them, from mobile data, a hotel or a customer's office. A
|
||||
VPN would add an installation, a credential and a moving part to something that
|
||||
already works with none of them.
|
||||
|
||||
**Demonstrating recognition is better done on the demo machine's own camera.**
|
||||
`webcam: 0` picks a capture index instead of building an RTSP URL, and the
|
||||
engine has supported it since the first version — `CameraStore` round-trips it,
|
||||
`source()` returns the index, `safe_url()` reports `webcam:0`, and
|
||||
`POST /api/cameras {"id":"laptop","webcam":0}` has always worked. **No screen
|
||||
offered it**, which is the same gap this file already records for the customer
|
||||
record and for per-camera tuning: the API could, the UI could not reach it.
|
||||
|
||||
It is now an option in the make picker, and it is the strongest demo available:
|
||||
real faces, in the room, instant, depending on no network at all. A demo
|
||||
pointed at a camera in another building depends on two internet connections and
|
||||
a tunnel staying up while somebody is talking.
|
||||
|
||||
**The one case a tunnel genuinely answers** is running the engine on a remote
|
||||
machine against the office's own cameras — LAN access to `192.168.1.121` from
|
||||
somewhere that is not that LAN. A mesh VPN (Tailscale and similar) does that
|
||||
honestly: a subnet router at the office, the client on the demo machine, and
|
||||
the address works unchanged. Free at this scale, no port forwarding, no
|
||||
exposed camera. Worth using for our *own* office when that is really the goal.
|
||||
|
||||
**It is still the wrong answer for the product**, and the reasons are not about
|
||||
difficulty:
|
||||
|
||||
- It is per-site infrastructure — an account, a node, a key — on every shop PC
|
||||
we ship, to replace something that already works over ordinary HTTPS.
|
||||
- It gives head office **network-level access into a customer's LAN**. The
|
||||
current design can read a camera's picture; a VPN can reach the customer's
|
||||
till, their router and everything else on that network. That is a far larger
|
||||
thing to be trusted with, and a far larger thing to have breached.
|
||||
- The failure modes are worse and less legible: a tunnel that is down looks
|
||||
like a camera that is down.
|
||||
|
||||
So: tunnel for our own office if we want the engine running remotely; nothing
|
||||
at all for showing customers their shops; the laptop's own camera for showing
|
||||
anybody what the product does.
|
||||
|
||||
### And the suite was quietly 32 tests smaller than it looked
|
||||
|
||||
Installing `httpx` to check the webcam path took the engine suite from **239
|
||||
passed to 271**. `tests/test_api_cameras.py` and its siblings begin with
|
||||
`pytest.importorskip("httpx")` so a bare checkout still runs — deliberate, and
|
||||
it means the number at the bottom of a run is not the number of tests that
|
||||
exist. `pip install -e .[dev]` is the opt-in.
|
||||
|
||||
@@ -46,6 +46,61 @@ import (
|
||||
// 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)
|
||||
@@ -93,6 +148,12 @@ func run() error {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -179,8 +240,19 @@ func run() error {
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
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.")
|
||||
// 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
|
||||
}
|
||||
@@ -241,13 +313,59 @@ func findPython() (string, string, error) {
|
||||
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})
|
||||
|
||||
var tried []string
|
||||
// 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, err := exec.LookPath(c.exe)
|
||||
if err != nil {
|
||||
continue
|
||||
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])")
|
||||
@@ -257,7 +375,18 @@ func findPython() (string, string, error) {
|
||||
}
|
||||
ver := strings.TrimSpace(string(out))
|
||||
tried = append(tried, c.exe+" "+ver)
|
||||
if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) {
|
||||
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, " ")
|
||||
@@ -270,7 +399,30 @@ func findPython() (string, string, error) {
|
||||
// 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.
|
||||
msg := "no Python 3.10 or newer was found on this computer.\n\n"
|
||||
// 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" +
|
||||
@@ -282,6 +434,9 @@ func findPython() (string, string, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -318,14 +473,52 @@ func venvPython(venv string) string {
|
||||
// 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 {
|
||||
return nil // already built; pip below brings it up to date
|
||||
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.")
|
||||
@@ -346,7 +539,30 @@ func pipInstall(vpy, src string) error {
|
||||
// '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]),
|
||||
// TWO calls, and the second is the one that matters.
|
||||
//
|
||||
// `--upgrade` alone is not an upgrade when the version has not moved:
|
||||
// pip skips the wheel and says so, one line above this program
|
||||
// printing "[ok] Engine and dependencies installed". The wheel version
|
||||
// was a frozen literal for several releases, so every engine fix in
|
||||
// them silently failed to reach any machine that had run setup once -
|
||||
// while the Go binaries beside it, rebuilt every release, updated
|
||||
// normally. Half the product current, half of it months old, and
|
||||
// nothing saying which.
|
||||
//
|
||||
// release.sh stamps the tag into the version now, so the versions do
|
||||
// differ. This does not rely on that: a rebuild at the same version is
|
||||
// the ordinary case while developing, and "installed" has to mean the
|
||||
// code in this folder either way.
|
||||
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
|
||||
"installing the engine"); err != nil {
|
||||
return err
|
||||
}
|
||||
// --no-deps so this is our own package only: the call above has
|
||||
// already settled the dependencies, and forcing those too would
|
||||
// re-download ~300 MB of numpy, OpenCV and onnxruntime every run.
|
||||
return stream(exec.Command(vpy, "-m", "pip", "install",
|
||||
"--force-reinstall", "--no-deps", wheels[0]),
|
||||
"installing the engine")
|
||||
}
|
||||
tmp, err := os.MkdirTemp("", "behavision-src-")
|
||||
|
||||
112
agent/cmd/behavision-setup/python_test.go
Normal file
112
agent/cmd/behavision-setup/python_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@ 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
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
)
|
||||
|
||||
@@ -81,6 +81,7 @@ usage: %s <command>
|
||||
// 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.")
|
||||
@@ -324,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,
|
||||
})
|
||||
|
||||
@@ -30,13 +30,12 @@ import (
|
||||
// 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
|
||||
pollDelay time.Duration
|
||||
Engine *EngineClient
|
||||
Cloud *CloudClient
|
||||
Log *log.Logger
|
||||
FPS float64
|
||||
Width int
|
||||
Quality int
|
||||
}
|
||||
|
||||
// Defaults, measured against the office camera rather than guessed.
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -83,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
|
||||
}
|
||||
|
||||
@@ -124,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)
|
||||
@@ -210,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)
|
||||
}
|
||||
|
||||
88
agent/pkg/config/installid_test.go
Normal file
88
agent/pkg/config/installid_test.go
Normal 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
|
||||
}
|
||||
@@ -244,6 +244,13 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -225,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)
|
||||
}
|
||||
|
||||
@@ -92,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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,15 @@ Pipeline: capture -> detect (YuNet) -> track (IoU) -> align + encode
|
||||
(ArcFace ONNX) -> match / auto-enroll (FAISS + SQLite) -> events + API.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
# Stamped by release.sh from the git tag, into a copy of this line, so a
|
||||
# release always produces a wheel nobody has installed before. It was a frozen
|
||||
# "1.0.0" here and a frozen "1.1.0" in pyproject.toml - two literals that
|
||||
# disagreed with each other and tracked nothing - which is how every engine fix
|
||||
# for several releases silently failed to reach a machine that had run setup
|
||||
# once. pip skips a wheel whose version is already installed and says so, one
|
||||
# line above setup printing "[ok] Engine and dependencies installed".
|
||||
#
|
||||
# In a checkout it stays obviously a checkout: "0.0.0+dev" on /api/health is
|
||||
# the truth about a developer's machine, and a plausible-looking number there
|
||||
# would be worse than none.
|
||||
__version__ = "0.0.0+dev"
|
||||
|
||||
@@ -17,6 +17,7 @@ from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from . import __version__
|
||||
from .config import ApiSection, CameraConfig, CameraTuning
|
||||
from .commission import CommissionRun
|
||||
from .events import Event
|
||||
@@ -142,7 +143,7 @@ def _reencode(jpeg: bytes, width: int, quality: int) -> "bytes | None":
|
||||
|
||||
|
||||
def create_app(engine: Engine) -> FastAPI:
|
||||
app = FastAPI(title="Behavision", version="1.0.0",
|
||||
app = FastAPI(title="Behavision", version=__version__,
|
||||
dependencies=_auth_dependencies(engine.cfg.api))
|
||||
|
||||
def worker_or_404(camera_id: str):
|
||||
@@ -172,6 +173,10 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
# are up, and the shop recognises nobody it already knows.
|
||||
stranded = engine.gallery.health["stranded"]
|
||||
return {"status": "ok" if engine.started_at else "starting",
|
||||
# Which build is actually running. Without it there was no way
|
||||
# to tell a shop PC three releases behind from a current one -
|
||||
# which is precisely how a silent install failure survived.
|
||||
"version": __version__,
|
||||
"recognition_model": engine.encoder.model_name,
|
||||
"gallery_unreadable_embeddings": stranded,
|
||||
# "where is my database" must be answerable from the API: the
|
||||
|
||||
@@ -68,6 +68,78 @@ os.environ.setdefault(
|
||||
STALL_AFTER_S = 10.0
|
||||
|
||||
|
||||
def _local_ipv4() -> str:
|
||||
"""This machine's address on the interface holding the default route.
|
||||
|
||||
A UDP socket is `connect`ed and nothing is sent - it only fixes a route so
|
||||
the kernel will name the source address. No packet leaves, and it needs no
|
||||
dependency, which matters in an engine that already ships 200 MB of models.
|
||||
"""
|
||||
import socket
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.settimeout(0.5)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _wrong_network_hint(host: str) -> str:
|
||||
"""Why a private camera address is unreachable, when that is the reason.
|
||||
|
||||
A camera lives on the shop's LAN behind a router, and 192.168.x.x means
|
||||
"something on the network I am attached to" - nothing more. From mobile
|
||||
data, a hotel, or head office it either resolves to nobody or to a
|
||||
completely different device that happens to hold that number. There is no
|
||||
route in from the internet and there must not be: an RTSP camera reachable
|
||||
from outside is how a shop's cameras end up being watched by strangers.
|
||||
|
||||
Without this the answer was "cannot reach 192.168.1.121:554 - Operation
|
||||
timed out", which reads as a broken camera and sends somebody to re-type
|
||||
an address and a password that were always correct. Asked directly by the
|
||||
owner, about his own cameras, from his phone's connection.
|
||||
|
||||
Two states, two different actions, so they must not share a sentence: on
|
||||
the same network the camera or its address is the problem; on a different
|
||||
one the COMPUTER is in the wrong place and no setting will fix it.
|
||||
"""
|
||||
import ipaddress
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return "" # a DNS name; nothing can be concluded from the string
|
||||
# The RFC1918 blocks and link-local, spelled out rather than `is_private`.
|
||||
# That property is broader than "an address on somebody's LAN": it also
|
||||
# covers the carrier-grade NAT range and the documentation networks
|
||||
# (192.0.2, 198.51.100, 203.0.113), and telling somebody who typed one of
|
||||
# those that it is "on the shop's own network" would be a confident wrong
|
||||
# answer in the place people look first. Found by a test using 203.0.113.9
|
||||
# as an example of a PUBLIC address, which `is_private` calls private.
|
||||
lan = any(addr in ipaddress.ip_network(n) for n in
|
||||
("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16")
|
||||
if addr.version == 4)
|
||||
if not lan:
|
||||
return ""
|
||||
|
||||
mine = _local_ipv4()
|
||||
if not mine:
|
||||
return (" - that is a private address, reachable only from inside "
|
||||
"the network the camera is on")
|
||||
try:
|
||||
same = ipaddress.ip_network(f"{mine}/24", strict=False).supernet_of(
|
||||
ipaddress.ip_network(f"{host}/24", strict=False))
|
||||
except (ValueError, TypeError):
|
||||
same = False
|
||||
if same:
|
||||
return (f" - this computer is on that network ({mine}), so check the "
|
||||
f"camera is powered on and that {host} is its address")
|
||||
return (f" - this computer is on {mine}, not the camera's network. A "
|
||||
f"private address like {host} is only reachable from inside the "
|
||||
f"shop's own network, never over the internet or mobile data, so "
|
||||
f"recognition has to run on a computer in the shop")
|
||||
|
||||
|
||||
def _tcp_reachable(source: "str | int", timeout: float
|
||||
) -> "tuple[bool, str]":
|
||||
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
|
||||
@@ -85,10 +157,11 @@ def _tcp_reachable(source: "str | int", timeout: float
|
||||
return True, ""
|
||||
except socket.timeout:
|
||||
return False, (f"no response from {parsed.hostname}:{port} within "
|
||||
f"{timeout:.0f}s - check the IP address and that the "
|
||||
f"camera is on the same network")
|
||||
f"{timeout:.0f}s{_wrong_network_hint(parsed.hostname)}")
|
||||
except OSError as exc:
|
||||
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
|
||||
return False, (f"cannot reach {parsed.hostname}:{port} - "
|
||||
f"{exc.strerror or exc}"
|
||||
f"{_wrong_network_hint(parsed.hostname)}")
|
||||
|
||||
|
||||
def _fourcc(cap) -> str:
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,6 +37,81 @@ _COPY_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _https_context() -> "ssl.SSLContext | None":
|
||||
"""The CA store to trust, or None to use whatever Python defaults to.
|
||||
|
||||
Returning None first is deliberate. On Windows and on a Homebrew or
|
||||
system Python, the default context reads the machine's own certificate
|
||||
store - which is what makes a corporate proxy with its own root CA work.
|
||||
Replacing that with certifi's bundle unconditionally would break every
|
||||
site that has one, in order to fix a different platform.
|
||||
|
||||
The platform this fixes is a python.org macOS build. It ships its own
|
||||
OpenSSL with NO trust store, and populates one only when somebody
|
||||
double-clicks `Install Certificates.command` in the Python folder -
|
||||
which nobody installing face-recognition software has any reason to know
|
||||
about. Every HTTPS request from that interpreter fails with:
|
||||
|
||||
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
|
||||
certificate verify failed: unable to get local issuer certificate
|
||||
|
||||
Measured on a colleague's Mac: the engine installed perfectly and then
|
||||
could not download a 230 KB model file, ending setup in forty lines of
|
||||
traceback about `_ssl.c`.
|
||||
"""
|
||||
try:
|
||||
import certifi
|
||||
except ImportError: # pragma: no cover - certifi ships with requests
|
||||
return None
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
|
||||
def _is_cert_failure(err: BaseException) -> bool:
|
||||
"""Is this a certificate-verification failure, however it is wrapped?
|
||||
|
||||
`urllib` does NOT let `ssl.SSLCertVerificationError` out. It catches it and
|
||||
re-raises `urllib.error.URLError(err)`, carrying the original on `.reason`
|
||||
- so `except ssl.SSLCertVerificationError` around `urlopen` matches
|
||||
nothing, ever.
|
||||
|
||||
That is not a subtlety this file gets to record academically: the first
|
||||
version of the fallback below was written exactly that way, shipped, and
|
||||
failed on the machine it was written for with the very traceback it was
|
||||
meant to prevent. The unit test passed throughout, because the fake it
|
||||
used raised the bare SSL error - a shape real urllib never produces. A
|
||||
stub that agrees with the author is worse than no test, and the test now
|
||||
raises what urllib raises.
|
||||
"""
|
||||
reason = getattr(err, "reason", None)
|
||||
return isinstance(err, ssl.SSLCertVerificationError) or \
|
||||
isinstance(reason, ssl.SSLCertVerificationError)
|
||||
|
||||
|
||||
def _urlopen(url: str, timeout: float = 60.0):
|
||||
"""Open a URL, falling back to certifi's CA bundle on a verify failure.
|
||||
|
||||
Default first, certifi second, so the fix is additive: a machine whose
|
||||
own store works keeps using it, and one with no store at all gets a
|
||||
bundle rather than a traceback. certifi is already here - `requests` is a
|
||||
hard dependency and brings it.
|
||||
"""
|
||||
try:
|
||||
return urllib.request.urlopen(url, timeout=timeout)
|
||||
except (urllib.error.URLError, ssl.SSLCertVerificationError) as err:
|
||||
# Only a certificate problem is worth a second attempt. "No route to
|
||||
# host" and "connection refused" arrive as URLError too, and retrying
|
||||
# those with a different CA list changes nothing except how long the
|
||||
# operator waits for the real message.
|
||||
if not _is_cert_failure(err):
|
||||
raise
|
||||
ctx = _https_context()
|
||||
if ctx is None:
|
||||
raise
|
||||
log.info("the system certificate store could not verify %s; "
|
||||
"using the bundled CA list", url.split("/")[2])
|
||||
return urllib.request.urlopen(url, timeout=timeout, context=ctx)
|
||||
|
||||
|
||||
def _fetch(url: str, dest: Path, label: str) -> None:
|
||||
"""Download with progress on stdout the supervisor can read.
|
||||
|
||||
@@ -56,7 +133,22 @@ def _fetch(url: str, dest: Path, label: str) -> None:
|
||||
last = pct
|
||||
log.info("download: %s %d%%", label, pct)
|
||||
|
||||
urllib.request.urlretrieve(url, dest, hook)
|
||||
# Streamed rather than urlretrieve, only because urlretrieve offers no way
|
||||
# to pass an SSL context and the whole point here is choosing one. The
|
||||
# `download: <label> <n>%` lines are a contract: the supervisor parses
|
||||
# them (`progressRe`) to put first-run progress in the tray, and without
|
||||
# them a shop PC shows a stopped engine for five minutes after install.
|
||||
with _urlopen(url) as resp:
|
||||
total = int(resp.headers.get("Content-Length") or 0)
|
||||
blocks, block_size = 0, 64 * 1024
|
||||
with open(dest, "wb") as out:
|
||||
while True:
|
||||
chunk = resp.read(block_size)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
blocks += 1
|
||||
hook(blocks, block_size, total)
|
||||
log.info("download: %s 100%%", label)
|
||||
|
||||
|
||||
@@ -91,7 +183,7 @@ def setup_models(models_dir: Path) -> "list[str]":
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
with urllib.request.urlopen(BUFFALO_SC_URL) as resp:
|
||||
with _urlopen(BUFFALO_SC_URL) as resp:
|
||||
payload = io.BytesIO(resp.read())
|
||||
with zipfile.ZipFile(payload) as zf, \
|
||||
zf.open("w600k_mbf.onnx") as src, \
|
||||
|
||||
204
desktop/app.go
204
desktop/app.go
@@ -44,6 +44,9 @@ 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
|
||||
@@ -82,6 +85,14 @@ func (a *App) startup(ctx context.Context) {
|
||||
if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil {
|
||||
log.Printf("camera relay unavailable, tiles will not load: %v", err)
|
||||
}
|
||||
// And the other direction: watching a camera in another building, through
|
||||
// head office's relay. Enabled unconditionally rather than only when a
|
||||
// session already exists, because signing in is a thing that happens
|
||||
// while the app is open - and CameraLive refuses without a session
|
||||
// anyway, so there is nothing to gate.
|
||||
if err := a.proxy.watchRemote(a.cloud.CameraLive); err != nil {
|
||||
log.Printf("remote camera view unavailable: %v", err)
|
||||
}
|
||||
|
||||
// A saved session means a shop PC that rebooted overnight comes back
|
||||
// working instead of waiting for someone to log in.
|
||||
@@ -101,6 +112,9 @@ 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 {
|
||||
@@ -146,13 +160,55 @@ func (a *App) startup(ctx context.Context) {
|
||||
// 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 _, err := os.Stat(exe); err == nil {
|
||||
if why := a.engineMissing(); why == "" {
|
||||
a.sup.Start()
|
||||
} else {
|
||||
log.Printf("engine not installed yet (%s); run behavision-setup, then Start", exe)
|
||||
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
|
||||
// before it has started.
|
||||
func (a *App) webhookURL() string {
|
||||
@@ -234,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,
|
||||
})
|
||||
@@ -587,6 +643,11 @@ func (a *App) EngineStatus() EngineStatus {
|
||||
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
|
||||
@@ -601,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()
|
||||
}
|
||||
@@ -616,10 +684,44 @@ func (a *App) StopEngine() EngineStatus {
|
||||
|
||||
// ---------------------------------------------------------------- cameras --
|
||||
|
||||
// Cameras lists this PC's cameras, or the company's if this PC has none of
|
||||
// its own.
|
||||
//
|
||||
// The distinction is load-bearing and the UI is told which it got. A camera
|
||||
// from the local engine is one THIS machine can reach, edit and stream. One
|
||||
// from head office is a camera at a shop somewhere else: it has a snapshot
|
||||
// and a connection state, and it cannot be edited from here because the shop
|
||||
// PC on that LAN is the only thing that can reach it. Offering an Edit button
|
||||
// that could not work would be worse than not showing the camera at all.
|
||||
func (a *App) Cameras() ([]map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return a.local.Cameras(ctx)
|
||||
|
||||
cams, err := a.local.Cameras(ctx)
|
||||
if err == nil {
|
||||
return cams, nil
|
||||
}
|
||||
if !a.cloud.LoggedIn() {
|
||||
return nil, err
|
||||
}
|
||||
remote, rerr := a.cloud.RemoteCameras(ctx)
|
||||
if rerr != nil {
|
||||
return nil, err // the local failure is the one worth reporting
|
||||
}
|
||||
out := make([]map[string]any, 0, len(remote))
|
||||
for _, c := range remote {
|
||||
out = append(out, map[string]any{
|
||||
"id": c.ID, "camera_id": c.CameraID, "label": c.Label,
|
||||
"site": c.Site, "enabled": c.Enabled,
|
||||
"connected": c.Connected, "last_seen_at": c.LastSeenAt,
|
||||
"state": c.State, "state_note": c.StateNote,
|
||||
"snapshot": c.Snapshot, "snapshot_at": c.SnapshotAt,
|
||||
// What the screen keys off to hide Edit, Test and Check: this
|
||||
// camera is on a network this PC cannot reach.
|
||||
"remote": true,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiscoverCameras lists the cameras on this PC's network, so the add-camera
|
||||
@@ -686,25 +788,115 @@ func (a *App) StreamURL(cameraID string) string {
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
}
|
||||
|
||||
// RemoteStreamURL is the live view of a camera in another building.
|
||||
//
|
||||
// The picture comes from head office's relay - the shop PC pushes frames
|
||||
// outbound because nothing can reach in - and this app re-emits them as MJPEG
|
||||
// on its own loopback, so a tile is an ordinary <img> either way. A screen
|
||||
// therefore never has to know which building it is looking at.
|
||||
//
|
||||
// Empty when the relay is not running, and the caller shows the last snapshot
|
||||
// instead. There is no useful fallback URL: the head-office endpoint needs
|
||||
// this session's bearer, which an <img> cannot send.
|
||||
func (a *App) RemoteStreamURL(cameraID string) string {
|
||||
if !a.cloud.LoggedIn() {
|
||||
return ""
|
||||
}
|
||||
return a.proxy.urlFor(cameraID, "live.mjpeg")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- live --
|
||||
|
||||
type LiveSnapshot struct {
|
||||
Stats map[string]any `json:"stats"`
|
||||
Events []map[string]any `json:"events"`
|
||||
// Viewing is true when none of this came from an engine on THIS PC. The
|
||||
// screen must say so: the numbers are the company's, not this machine's,
|
||||
// and a laptop in a hotel showing "2 cameras live" without that word
|
||||
// would be claiming to be watching a shop it cannot see.
|
||||
Viewing bool `json:"viewing"`
|
||||
}
|
||||
|
||||
// Live is what the shop PC sees, and falls back to what HEAD OFFICE sees.
|
||||
//
|
||||
// A PC with no engine is not necessarily broken - it is somebody signed in on
|
||||
// a laptop away from the shop, which is the ordinary way an owner looks at
|
||||
// their estate. Until now that produced "engine not reachable at
|
||||
// 127.0.0.1:8010", an accurate sentence and a useless one when the reader was
|
||||
// never expecting an engine on that machine.
|
||||
//
|
||||
// The local engine always wins when it is there: it is this shop's own
|
||||
// ground truth and it is live rather than a heartbeat old.
|
||||
func (a *App) Live() (LiveSnapshot, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats, err := a.local.Stats(ctx)
|
||||
if err == nil {
|
||||
events, eerr := a.local.Events(ctx, 40)
|
||||
if eerr == nil {
|
||||
return LiveSnapshot{Stats: stats, Events: events}, nil
|
||||
}
|
||||
}
|
||||
// No engine here. If nobody is signed in either, the honest answer is
|
||||
// still the local error - there is nothing else to show and the person
|
||||
// is most likely setting this PC up.
|
||||
if !a.cloud.LoggedIn() {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
return a.liveFromCloud(ctx)
|
||||
}
|
||||
|
||||
// liveFromCloud builds the same shape the Live screen already renders, out of
|
||||
// the estate's own feed, so the view needs no second code path.
|
||||
func (a *App) liveFromCloud(ctx context.Context) (LiveSnapshot, error) {
|
||||
sites, err := a.cloud.Sites(ctx)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
events, err := a.local.Events(ctx, 40)
|
||||
arrivals, err := a.cloud.Arrivals(ctx, 40)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
return LiveSnapshot{Stats: stats, Events: events}, nil
|
||||
|
||||
// The counters are summed across the estate, and fraction_below_gate
|
||||
// takes the WORST site rather than an average - one badly placed camera
|
||||
// is a hole in the numbers, and averaging it against three good ones
|
||||
// hides the only site anyone needs to visit. Same rule the heartbeat
|
||||
// already follows.
|
||||
var up, total int
|
||||
worst := 0.0
|
||||
people := map[string]struct{}{}
|
||||
for _, s := range sites {
|
||||
up, total = up+s.CamerasUp, total+s.CamerasTotal
|
||||
if s.FractionBelowGate > worst {
|
||||
worst = s.FractionBelowGate
|
||||
}
|
||||
}
|
||||
events := make([]map[string]any, 0, len(arrivals))
|
||||
for _, v := range arrivals {
|
||||
if v.VisitorID != "" {
|
||||
people[v.VisitorID] = struct{}{}
|
||||
}
|
||||
events = append(events, map[string]any{
|
||||
"type": map[bool]string{true: "person.new", false: "person.seen"}[v.IsNew],
|
||||
"ts": v.OccurredAt, "camera_id": v.CameraID,
|
||||
"data": map[string]any{
|
||||
"label": v.Label, "ref": v.Ref, "site": v.Site,
|
||||
"similarity": v.Similarity, "attributes": v.Attributes,
|
||||
},
|
||||
})
|
||||
}
|
||||
return LiveSnapshot{
|
||||
Viewing: true,
|
||||
Events: events,
|
||||
Stats: map[string]any{
|
||||
"cameras": []map[string]any{},
|
||||
"gallery": map[string]any{"identities": len(people), "sightings": len(arrivals)},
|
||||
"cameras_up": up, "cameras_total": total,
|
||||
"fraction_below_gate": worst,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reports --
|
||||
|
||||
40
desktop/frontend/dist/assets/index-Be_Iv2Nz.js
vendored
40
desktop/frontend/dist/assets/index-Be_Iv2Nz.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-p8f6baZq.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-p8f6baZq.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
desktop/frontend/dist/index.html
vendored
4
desktop/frontend/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="./assets/index-Be_Iv2Nz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-lhDNZRcC.css">
|
||||
<script type="module" crossorigin src="./assets/index-p8f6baZq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -38,6 +38,9 @@ export const api = {
|
||||
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
|
||||
placementResult: (id) => call('PlacementResult', id),
|
||||
streamURL: (id) => call('StreamURL', id),
|
||||
// The live view of a camera in another building, relayed through head
|
||||
// office. Empty when nobody is signed in.
|
||||
remoteStreamURL: (id) => call('RemoteStreamURL', id),
|
||||
|
||||
live: () => call('Live'),
|
||||
pipelineStatus: () => call('PipelineStatus'),
|
||||
|
||||
@@ -634,3 +634,32 @@ tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
|
||||
.starter .note { padding: var(--sp-3) var(--sp-4); font-size: 12px; }
|
||||
.btn.ghost { background: none; border-color: transparent; color: var(--ink-3); }
|
||||
.btn.ghost:hover { color: var(--ink); }
|
||||
|
||||
/* Viewer mode: this PC has no engine, so the screens show the company's own
|
||||
data from head office. Informational, not an error - it is the ordinary
|
||||
state of a laptop away from a shop, and styling it red would train people
|
||||
to ignore the red that means something. */
|
||||
.viewing {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 12px 14px; margin-bottom: 14px;
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
color: var(--ink-2); font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
.viewing b { color: var(--ink); font-weight: 600; }
|
||||
.viewing svg { flex: none; margin-top: 2px; color: var(--accent); }
|
||||
|
||||
/* Watch live sits over the picture, opposite the connection pill. It is on
|
||||
the tile rather than in the button row because it is about the picture, and
|
||||
because the row it would otherwise join is hidden on a remote camera. */
|
||||
.camview .btn.watch {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
border-color: rgba(255, 255, 255, .25);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.camview .btn.watch:hover { background: rgba(0, 0, 0, .72); }
|
||||
.camview .btn.watch.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
import { MAKES, makeById, parseRtspUrl } from '../../../../shared/cameraMakes.js'
|
||||
|
||||
// The camera screen is a picture, not a settings table.
|
||||
//
|
||||
@@ -14,12 +14,44 @@ import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
|
||||
// signed off through.
|
||||
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '', max_width: 1280 }
|
||||
|
||||
// "This computer's own camera" - a webcam or a built-in FaceTime camera.
|
||||
//
|
||||
// The engine has supported it since the first version (`webcam: 0` picks a
|
||||
// capture index instead of building an RTSP URL) and no screen has ever
|
||||
// offered it: another case of the API being able to do something the UI
|
||||
// could not reach. It matters most for the thing it was missing from, which
|
||||
// is showing the product to somebody. A laptop's own camera gives real
|
||||
// recognition, of real faces, in the room, depending on no network at all -
|
||||
// where pointing a demo machine at a camera in another building depends on
|
||||
// two internet connections and a tunnel staying up while you talk.
|
||||
const WEBCAM = 'webcam'
|
||||
|
||||
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 ?? []
|
||||
const streams = useStreamURLs(cams)
|
||||
// Every camera is remote or none is: this list comes from the engine on
|
||||
// loopback, and when that is unreachable the whole list comes from head
|
||||
// office instead. A remote camera is on a network this computer cannot
|
||||
// reach, so the picture is the shop PC's last snapshot and the buttons that
|
||||
// would talk to the camera are not offered - one that cannot work is worse
|
||||
// than one that is absent.
|
||||
const remote = cams.some(c => c.remote)
|
||||
const streams = useStreamURLs(remote ? [] : cams)
|
||||
// ONE camera at a time, and that is a cost decision rather than a layout
|
||||
// one. A remote view makes the shop computer upload frames for as long as
|
||||
// somebody is watching, so a grid that all went live at once would put an
|
||||
// estate's worth of cameras on the wire because somebody opened a page.
|
||||
const [watching, setWatching] = useState(null)
|
||||
const [watchURL, setWatchURL] = useState('')
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
if (!watching) { setWatchURL(''); return }
|
||||
api.remoteStreamURL(watching).then(u => { if (alive) setWatchURL(u || '') })
|
||||
.catch(() => { if (alive) setWatchURL('') })
|
||||
return () => { alive = false }
|
||||
}, [watching])
|
||||
|
||||
async function remove(cam) {
|
||||
if (!confirm(`Remove ${cam.id}? Recognition from it stops immediately.`)) return
|
||||
@@ -31,13 +63,22 @@ export default function Cameras() {
|
||||
<header className="pagehead">
|
||||
<div>
|
||||
<h2>Cameras</h2>
|
||||
<p>Add a camera, then prove it can see faces with a walk-past. Only then is it working.</p>
|
||||
<p>{remote
|
||||
? 'The cameras across your shops, as the shop computers last reported them.'
|
||||
: 'Add a camera, then prove it can see faces with a walk-past. Only then is it working.'}</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
{!remote && <button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
<Icon.Plus size={15} />Add camera
|
||||
</button>
|
||||
</button>}
|
||||
</header>
|
||||
|
||||
{remote && <div className="viewing">
|
||||
<b>Viewing your shops from here.</b> These cameras are wired to the shop
|
||||
computers, so they are set up and checked there. Each tile shows that
|
||||
camera's most recent frame; <b>Watch live</b> asks the shop computer to
|
||||
send video for as long as you are looking.
|
||||
</div>}
|
||||
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{cams.length === 0
|
||||
@@ -51,7 +92,10 @@ export default function Cameras() {
|
||||
</div>
|
||||
: <div className="camgrid">
|
||||
{cams.map(c => (
|
||||
<CameraCard key={c.id} cam={c} stream={streams[c.id]}
|
||||
<CameraCard key={c.id} cam={c}
|
||||
stream={watching === c.id ? watchURL : streams[c.id]}
|
||||
watching={watching === c.id}
|
||||
onWatch={() => setWatching(watching === c.id ? null : c.id)}
|
||||
onEdit={() => setEditing(c)} onCheck={() => setCheck(c.id)} onRemove={() => remove(c)} />
|
||||
))}
|
||||
</div>}
|
||||
@@ -63,41 +107,74 @@ export default function Cameras() {
|
||||
)
|
||||
}
|
||||
|
||||
function CameraCard({ cam, stream, onEdit, onCheck, onRemove }) {
|
||||
const conn = cam.connected === undefined ? { tone: 'idle', label: 'Engine stopped' }
|
||||
: cam.connected ? { tone: 'ok', label: 'Connected' } : { tone: 'bad', label: 'Not connecting' }
|
||||
function CameraCard({ cam, stream, watching, onWatch, onEdit, onCheck, onRemove }) {
|
||||
// Three states, not two, and the third is why `connected` is a pointer on
|
||||
// the wire: null means no shop computer has reported on this camera yet,
|
||||
// which reads as waiting rather than as a fault to go and investigate.
|
||||
const conn = cam.remote
|
||||
// Four states, decided once by the server. `stale` is the one that was
|
||||
// missing: the shop computer reports nothing when it cannot reach its own
|
||||
// engine, so its last report used to sit there reading Connected -
|
||||
// measured at 34 minutes on the live estate.
|
||||
? ({ connected: { tone: 'ok', label: 'Connected' },
|
||||
not_connecting: { tone: 'bad', label: 'Not connecting' },
|
||||
stale: { tone: 'warn', label: 'Not reporting' } }[cam.state]
|
||||
|| { tone: 'idle', label: 'Waiting for the shop computer' })
|
||||
: cam.connected === undefined || cam.connected === null
|
||||
? { tone: 'idle', label: 'Engine stopped' }
|
||||
: cam.connected ? { tone: 'ok', label: 'Connected' }
|
||||
: { tone: 'bad', label: 'Not connecting' }
|
||||
// The last placement verdict, so "proven" survives closing the sheet. Only
|
||||
// `good` is a pass: marginal means half the visitors are silently discarded.
|
||||
const { data: last } = usePolled(() => api.placementResult(cam.id), 15000, [cam.id])
|
||||
// Never asked for a remote camera: that answer lives on the shop computer,
|
||||
// and polling loopback for it here only produces an error every 15 seconds.
|
||||
const { data: last } = usePolled(
|
||||
() => cam.remote ? Promise.resolve(null) : api.placementResult(cam.id), 15000, [cam.id])
|
||||
const proof = !last || last.running || !last.verdict || last.verdict === 'starting'
|
||||
? { tone: 'miss', label: 'Not yet proven', text: 'Walk past it once and Behavision will tell you if the placement works.' }
|
||||
: last.verdict === 'good'
|
||||
? { tone: 'seen', label: 'Proven', text: last.headline || 'Faces recognised on a walk-past.' }
|
||||
: { tone: 'miss', label: 'Not proven', text: last.headline || 'Move the camera and check again.' }
|
||||
const shot = cam.snapshot?.available ? cam.snapshot.url : ''
|
||||
return (
|
||||
<article className="camcard">
|
||||
<div className="camview">
|
||||
{stream
|
||||
? <img src={stream} alt={cam.id} />
|
||||
{stream || shot
|
||||
? <img src={stream || shot} alt={cam.id} />
|
||||
: <div className="placeholder"><Icon.NoCamera size={34} /></div>}
|
||||
<span className={`pill ${conn.tone === 'idle' ? '' : conn.tone} over`}><i className={`dot ${conn.tone}`} />{conn.label}</span>
|
||||
{cam.remote && <button className={`btn sm watch ${watching ? 'on' : ''}`} onClick={onWatch}>
|
||||
<Icon.Play size={13} />{watching ? 'Stop watching' : 'Watch live'}
|
||||
</button>}
|
||||
</div>
|
||||
<div className="cambody">
|
||||
<div className="camtitle">
|
||||
<div>
|
||||
<h3>{cam.id}</h3>
|
||||
<span className="mono note">{cam.host || cam.url}{cam.path ? ` · ${cam.path}` : ''}</span>
|
||||
<h3>{cam.label || cam.camera_id || cam.id}</h3>
|
||||
<span className="mono note">{cam.remote
|
||||
? cam.site || cam.site_slug || ''
|
||||
: `${cam.host || cam.url}${cam.path ? ` · ${cam.path}` : ''}`}</span>
|
||||
</div>
|
||||
<div className="camactions">
|
||||
{!cam.remote && <div className="camactions">
|
||||
<button className="btn sm" onClick={onEdit}>Edit</button>
|
||||
<button className="btn sm danger" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="camproof">
|
||||
<span className={`tag ${proof.tone}`}>{proof.label}</span>
|
||||
<span className="note">{proof.text}</span>
|
||||
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
|
||||
</div>}
|
||||
</div>
|
||||
{cam.remote
|
||||
? <div className="camproof">
|
||||
<span className="note">{cam.state_note
|
||||
? cam.state_note
|
||||
: watching
|
||||
? 'Live from the shop computer. It uploads only while you watch.'
|
||||
: cam.snapshot?.available
|
||||
? 'Last picture from the shop computer. Watch live to see it now.'
|
||||
: cam.snapshot?.reason || 'No picture yet from the shop computer.'}</span>
|
||||
</div>
|
||||
: <div className="camproof">
|
||||
<span className={`tag ${proof.tone}`}>{proof.label}</span>
|
||||
<span className="note">{proof.text}</span>
|
||||
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
|
||||
</div>}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
@@ -125,7 +202,9 @@ function useStreamURLs(cams) {
|
||||
function CameraSheet({ cam, onClose, onSaved }) {
|
||||
const isNew = !cam.id
|
||||
const [f, setF] = useState({ ...BLANK, ...cam, password: '', path: cam.path || (isNew ? MAKES[0].path : '') })
|
||||
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
|
||||
const [make, setMake] = useState(isNew ? MAKES[0].id : (cam.webcam != null ? WEBCAM : 'manual'))
|
||||
const [index, setIndex] = useState(cam.webcam != null ? String(cam.webcam) : '0')
|
||||
const local = make === WEBCAM
|
||||
const [test, setTest] = useState(null)
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
@@ -134,6 +213,7 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
// Only overwrite the path when the preset has one, so choosing "I know the
|
||||
// path" does not wipe what the installer already typed.
|
||||
function chooseMake(e) {
|
||||
if (e.target.value === WEBCAM) { setMake(WEBCAM); setTest(null); return }
|
||||
const m = makeById(e.target.value)
|
||||
setMake(m.id)
|
||||
setF(prev => ({ ...prev, path: m.path || prev.path }))
|
||||
@@ -147,6 +227,14 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
if (v === '' || v === null || v === undefined) continue
|
||||
out[k] = (k === 'port' || k === 'max_width') ? Number(v) : v
|
||||
}
|
||||
if (local) {
|
||||
// An address and a webcam index are alternatives, not extras: the
|
||||
// engine's source() takes the webcam first, so leaving a half-typed
|
||||
// host behind would make the saved camera describe two different
|
||||
// things and only one of them would be used.
|
||||
for (const k of ['host', 'path', 'username', 'password']) delete out[k]
|
||||
out.webcam = Number(index) || 0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -165,6 +253,27 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
|
||||
const chosen = makeById(make)
|
||||
|
||||
// Paste the whole RTSP URL. It is how people actually hold this
|
||||
// information - it is what the camera's own app shows and what an installer
|
||||
// writes down - and splitting it into five fields by eye is exactly where a
|
||||
// password containing `@` or `/` goes wrong.
|
||||
const [pasted, setPasted] = useState('')
|
||||
const [pasteError, setPasteError] = useState('')
|
||||
function applyUrl(text) {
|
||||
setPasted(text)
|
||||
if (!text.trim()) { setPasteError(''); return }
|
||||
const got = parseRtspUrl(text)
|
||||
if (!got) { setPasteError('That does not look like an RTSP address.'); return }
|
||||
setPasteError('')
|
||||
setTest(null)
|
||||
// Only what the URL actually carried: a URL with no credentials must not
|
||||
// wipe a password the operator typed above it.
|
||||
setF(prev => ({ ...prev, host: got.host, port: got.port, path: got.path,
|
||||
...(got.username ? { username: got.username } : {}),
|
||||
...(got.password ? { password: got.password } : {}) }))
|
||||
setMake('manual')
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -194,7 +303,7 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
<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>}
|
||||
|
||||
{isNew && (
|
||||
{isNew && !local && (
|
||||
<section className="formsection">
|
||||
<h4>Find it</h4>
|
||||
{scan === null && (
|
||||
@@ -228,6 +337,20 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isNew && !local && (
|
||||
<section className="formsection">
|
||||
<h4>Paste its address</h4>
|
||||
<label className="field"><span>RTSP address</span>
|
||||
<input className="mono" value={pasted} onChange={e => applyUrl(e.target.value)}
|
||||
placeholder="rtsp://admin:password@192.168.1.20:554/ch0_0.264"
|
||||
autoComplete="off" name="rtsp-url" spellCheck="false" />
|
||||
<em className="hint">{pasteError
|
||||
? pasteError
|
||||
: 'If the camera’s own app shows an RTSP address, paste it here and the fields below fill in. Otherwise leave this empty and fill them in yourself.'}</em>
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="formsection">
|
||||
<h4>The camera</h4>
|
||||
{isNew && (
|
||||
@@ -236,15 +359,20 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
<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>
|
||||
{local
|
||||
? <label className="field narrow"><span>Camera number</span>
|
||||
<input value={index} onChange={e => { setIndex(e.target.value); setTest(null) }} inputMode="numeric" />
|
||||
<em className="hint">0 is the built-in camera. Try 1 if a second one is plugged in.</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">
|
||||
@@ -252,27 +380,34 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
<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>)}
|
||||
<option value={WEBCAM}>This computer’s own camera</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>
|
||||
{local
|
||||
? <em className="hint">Recognition runs on this computer’s built-in or plugged-in camera. Nothing on the network is involved.</em>
|
||||
: chosen.note && <em className="hint">{chosen.note}</em>}
|
||||
</label>
|
||||
{!local && (
|
||||
<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>
|
||||
{!local && (
|
||||
<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 && (
|
||||
test.ok
|
||||
|
||||
@@ -23,15 +23,22 @@ export default function Live({ onNavigate }) {
|
||||
const cameras = data?.stats?.cameras ?? []
|
||||
const gallery = data?.stats?.gallery ?? {}
|
||||
const events = data?.events ?? []
|
||||
// No engine on THIS PC, so everything below came from head office. It has to
|
||||
// be said rather than implied: a laptop in a hotel showing "2 cameras live"
|
||||
// without this line is claiming to watch a shop it cannot see.
|
||||
const viewing = data?.viewing === true
|
||||
|
||||
// fraction_below_gate is the number that decides a site: what share of the
|
||||
// faces this camera saw were too poor to enrol. Surfaced rather than buried,
|
||||
// because a high value looks exactly like "a quiet day".
|
||||
const worst = cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
const up = cameras.filter(c => c.connected).length
|
||||
const worst = viewing
|
||||
? (data?.stats?.fraction_below_gate ?? 0)
|
||||
: cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
// Viewing: the server already summed these across the estate.
|
||||
const up = viewing ? (data?.stats?.cameras_up ?? 0) : cameras.filter(c => c.connected).length
|
||||
|
||||
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
|
||||
const freshest = useFreshest(arrivals[0])
|
||||
@@ -40,14 +47,29 @@ export default function Live({ onNavigate }) {
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Live</h2>
|
||||
<p>Who is in the shop, and whether it is reaching head office.</p>
|
||||
<p>{viewing
|
||||
? 'Your shops, as head office sees them.'
|
||||
: 'Who is in the shop, and whether it is reaching head office.'}</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{viewing && (
|
||||
<div className="viewing">
|
||||
<Icon.Cloud size={15} />
|
||||
<span><b>Viewing your shops from here.</b> This computer is not watching
|
||||
any cameras itself — everything below is what your shop PCs reported.
|
||||
To recognise people on this machine, it has to be on the same network
|
||||
as a camera.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
|
||||
|
||||
<GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />
|
||||
{/* Getting Started walks somebody through setting up a camera on THIS
|
||||
PC — not what a viewer is doing, and not something they could finish
|
||||
from here. */}
|
||||
{!viewing && <GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />}
|
||||
|
||||
<div className="panel arrivals-panel">
|
||||
<div className="panelhead">
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -39,6 +40,19 @@ type Client struct {
|
||||
// single-use refresh token.
|
||||
refreshMu sync.Mutex
|
||||
onRefresh func(Session)
|
||||
|
||||
// Camera snapshots already fetched, keyed by camera id. The Cameras screen
|
||||
// polls every 8 seconds and a snapshot is ~90 KB, so re-fetching one that
|
||||
// has not changed would put megabytes an hour on the wire to redraw the
|
||||
// same picture - the same trap the web app's useAuthedImage avoids by
|
||||
// keying on the url rather than the object around it.
|
||||
shotMu sync.Mutex
|
||||
shots map[string]cachedShot
|
||||
}
|
||||
|
||||
type cachedShot struct {
|
||||
at string // the server's snapshot_at; a new one is a new picture
|
||||
uri string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -629,3 +643,182 @@ func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
|
||||
"items": items, "source": "manual", "notes": notes,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- viewing --
|
||||
//
|
||||
// A PC with no engine of its own is not broken, it is a VIEWER: somebody
|
||||
// signed in on a laptop away from the shop. Everything below reads head
|
||||
// office so those screens have something true to show instead of "engine not
|
||||
// reachable", which is an accurate sentence and a useless one when the reader
|
||||
// was never expecting an engine on that machine.
|
||||
|
||||
// Arrival is one visit as the estate's feed reports it, across every shop -
|
||||
// not just this PC's. `GET /api/visits`.
|
||||
type Arrival struct {
|
||||
VisitID string `json:"visit_id"`
|
||||
VisitRef string `json:"visit_ref"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Site string `json:"site"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
CameraID string `json:"camera_id"`
|
||||
VisitorID string `json:"visitor_id"`
|
||||
Ref string `json:"ref"`
|
||||
Label string `json:"label"`
|
||||
IsNew bool `json:"is_new_visitor"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
Image Photo `json:"image"`
|
||||
}
|
||||
|
||||
// RemoteCamera is a camera as HEAD OFFICE knows it. Deliberately not the same
|
||||
// type the local engine returns: this one can never be edited from here (the
|
||||
// shop PC on that LAN is the only thing that can reach it) and it carries a
|
||||
// snapshot rather than a stream.
|
||||
type RemoteCamera struct {
|
||||
ID string `json:"id"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Site string `json:"site"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected *bool `json:"connected"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
// State is the server's single answer - connected / not_connecting /
|
||||
// waiting / stale - and the screen renders that rather than deciding
|
||||
// again from Connected. Two places deciding one fact is how a shop came
|
||||
// out labelled Working, in green, above "2 of 3 cameras not connecting".
|
||||
State string `json:"state"`
|
||||
StateNote string `json:"state_note"`
|
||||
Snapshot Photo `json:"snapshot"`
|
||||
SnapshotAt string `json:"snapshot_at"`
|
||||
}
|
||||
|
||||
// Arrivals reads the estate's recent visits, newest last.
|
||||
func (c *Client) Arrivals(ctx context.Context, limit int) ([]Arrival, error) {
|
||||
var out struct {
|
||||
Arrivals []Arrival `json:"arrivals"`
|
||||
}
|
||||
if err := c.send(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/visits?limit=%d", limit), nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Arrivals, nil
|
||||
}
|
||||
|
||||
// RemoteCameras lists every camera head office knows about for this company.
|
||||
func (c *Client) RemoteCameras(ctx context.Context) ([]RemoteCamera, error) {
|
||||
var out []RemoteCamera
|
||||
if err := c.send(ctx, http.MethodGet, "/api/cameras", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Snapshot = c.resolveShot(ctx, out[i].ID, out[i].SnapshotAt, out[i].Snapshot)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveShot turns a camera snapshot into something the window can render.
|
||||
//
|
||||
// Same problem VisitorImage has and the same answer: a deployment with no
|
||||
// object storage serves the picture from the API itself, so the url is
|
||||
// relative and needs this session's bearer. A webview <img> can supply
|
||||
// neither - it resolves a relative src against wails:// and cannot set a
|
||||
// header - so the bytes are fetched here and passed as a data: URI.
|
||||
//
|
||||
// A failure is an absence with a reason, never an error. Whether the camera is
|
||||
// CONNECTED is the answer this screen exists to give; the photograph is
|
||||
// decoration, and blanking the card because a picture would not load would
|
||||
// hide the part that matters.
|
||||
func (c *Client) resolveShot(ctx context.Context, camID, at string, p Photo) Photo {
|
||||
if !p.Available || !p.Auth || p.URL == "" {
|
||||
return p
|
||||
}
|
||||
c.shotMu.Lock()
|
||||
hit, ok := c.shots[camID]
|
||||
c.shotMu.Unlock()
|
||||
if ok && hit.at == at && at != "" {
|
||||
p.URL, p.Auth = hit.uri, false
|
||||
return p
|
||||
}
|
||||
uri, err := c.fetchImage(ctx, p.URL)
|
||||
if err != nil {
|
||||
return Photo{Reason: "That camera's picture could not be loaded."}
|
||||
}
|
||||
c.shotMu.Lock()
|
||||
if c.shots == nil {
|
||||
c.shots = map[string]cachedShot{}
|
||||
}
|
||||
c.shots[camID] = cachedShot{at: at, uri: uri}
|
||||
c.shotMu.Unlock()
|
||||
p.URL, p.Auth = uri, false
|
||||
return p
|
||||
}
|
||||
|
||||
// CameraLive opens head office's live relay for one camera and returns the
|
||||
// live SSE response for the caller to read and close.
|
||||
//
|
||||
// A response rather than frames, because the consumer is the app's own
|
||||
// loopback relay: it re-emits these frames as MJPEG so an <img> can show them,
|
||||
// and buffering the stream through a channel here would only add a place for
|
||||
// frames to queue. A stale frame is worthless - the only one worth having is
|
||||
// the newest - which is the whole reason LiveHub drops rather than queues.
|
||||
//
|
||||
// There is no client timeout on this request. A live view is endless by
|
||||
// design and any deadline would cut the picture off mid-shift; the context is
|
||||
// what ends it, when the viewer navigates away.
|
||||
func (c *Client) CameraLive(ctx context.Context, cameraID string) (*http.Response, error) {
|
||||
resp, err := c.liveOnce(ctx, cameraID)
|
||||
if errors.Is(err, errTokenExpired) {
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
resp, err = c.liveOnce(ctx, cameraID)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (c *Client) liveOnce(ctx context.Context, cameraID string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
c.Base+"/api/cameras/"+url.PathEscape(cameraID)+"/live", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
if tok == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
|
||||
// c.http has a 30 s timeout, which covers the whole response and would
|
||||
// therefore sever a working live view every thirty seconds - the same
|
||||
// trap that made the server set WriteTimeout to zero for its own SSE
|
||||
// endpoint. A dedicated client, with the dial bounded instead.
|
||||
hc := &http.Client{Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
resp.Body.Close()
|
||||
_ = json.Unmarshal(body, &e)
|
||||
if e.Error == "token_expired" {
|
||||
return nil, errTokenExpired
|
||||
}
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("live view: %s", resp.Status)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ package main
|
||||
// session and the bytes are fetched and handed over as an object URL.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
@@ -64,6 +65,12 @@ type streamProxy struct {
|
||||
target string // engine origin, e.g. http://127.0.0.1:8010
|
||||
user string
|
||||
pass string
|
||||
|
||||
// Opens head office's live relay for one camera. Set on a computer that
|
||||
// is signed in, whether or not an engine runs here - which is the whole
|
||||
// point: watching a camera in another building is precisely the case
|
||||
// where there is no engine on this machine to ask.
|
||||
live func(ctx context.Context, cameraID string) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||
@@ -71,18 +78,49 @@ func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||
// start binds a loopback listener and begins relaying. Calling it again while
|
||||
// running is a no-op, so a restarted engine cannot leave two listeners behind.
|
||||
func (p *streamProxy) start(base, user, pass string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.srv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
base = "http://" + base
|
||||
}
|
||||
if _, err := url.Parse(base); err != nil {
|
||||
return fmt.Errorf("engine base %q: %w", base, err)
|
||||
}
|
||||
if err := p.bind(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.target = strings.TrimRight(base, "/")
|
||||
p.user, p.pass = user, pass
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchRemote makes the relay able to serve head office's live view, and
|
||||
// binds it if nothing else has.
|
||||
//
|
||||
// Separate from start() because the two are independent: a shop PC has both
|
||||
// an engine and a session, an owner's laptop has only a session, and a PC
|
||||
// still being set up has only an engine. Folding them together would mean a
|
||||
// computer with no engine could not watch a camera at all - which is the one
|
||||
// computer most likely to be trying to.
|
||||
func (p *streamProxy) watchRemote(fn func(context.Context, string) (*http.Response, error)) error {
|
||||
if err := p.bind(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.live = fn
|
||||
return nil
|
||||
}
|
||||
|
||||
// bind starts the loopback listener once. Calling it again while running is a
|
||||
// no-op, so neither a restarted engine nor a second sign-in can leave two
|
||||
// listeners behind.
|
||||
func (p *streamProxy) bind() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.srv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The engine's own credential exists precisely so that the live face feed
|
||||
// is never served open - CLAUDE.md is explicit that an unauthenticated
|
||||
@@ -105,8 +143,6 @@ func (p *streamProxy) start(base, user, pass string) error {
|
||||
|
||||
p.ln = ln
|
||||
p.token = hex.EncodeToString(raw)
|
||||
p.target = strings.TrimRight(base, "/")
|
||||
p.user, p.pass = user, pass
|
||||
// No client timeout: an MJPEG stream is endless by design and any deadline
|
||||
// would cut the picture off mid-shift. The request context ends it when
|
||||
// the webview navigates away or the tile is replaced.
|
||||
@@ -130,7 +166,7 @@ func (p *streamProxy) start(base, user, pass string) error {
|
||||
func (p *streamProxy) stop() {
|
||||
p.mu.Lock()
|
||||
srv, ln := p.srv, p.ln
|
||||
p.srv, p.ln, p.token = nil, nil, ""
|
||||
p.srv, p.ln, p.token, p.live = nil, nil, "", nil
|
||||
p.mu.Unlock()
|
||||
if srv != nil {
|
||||
_ = srv.Close()
|
||||
@@ -155,6 +191,7 @@ func (p *streamProxy) urlFor(cameraID, file string) string {
|
||||
func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.RLock()
|
||||
token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client
|
||||
liveFn := p.live
|
||||
p.mu.RUnlock()
|
||||
if token == "" || client == nil {
|
||||
http.NotFound(w, r)
|
||||
@@ -190,6 +227,17 @@ func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||
// calls; it is here so that adding a still later is a change to a screen
|
||||
// rather than a change to the one file where a mistake is a credentialed
|
||||
// proxy onto the biometric API.
|
||||
// Head office's relay, not the engine. The two are different machines and
|
||||
// different credentials, so this returns rather than falling through.
|
||||
if parts[3] == "live.mjpeg" {
|
||||
if liveFn == nil {
|
||||
http.Error(w, "not signed in to head office", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
p.relayRemote(w, r, cameraID, liveFn)
|
||||
return
|
||||
}
|
||||
|
||||
var enginePath string
|
||||
switch parts[3] {
|
||||
case "stream.mjpeg":
|
||||
|
||||
149
desktop/stream_remote.go
Normal file
149
desktop/stream_remote.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
// Watching a camera in another building, from the app.
|
||||
//
|
||||
// The shop PC sits behind a router with no inbound route, so nothing here can
|
||||
// pull its MJPEG stream - that stream is served on the shop PC's own loopback
|
||||
// and always will be. Head office's LiveHub is the way round it: the agent
|
||||
// asks outbound whether anyone is watching and pushes JPEG frames up for
|
||||
// exactly as long as somebody is. The head-office web app already consumes
|
||||
// that; this is the same feed, for the app.
|
||||
//
|
||||
// It arrives as base64 frames over SSE, which an <img> cannot render, so this
|
||||
// re-emits them as multipart MJPEG - which an <img> renders natively, through
|
||||
// the relay that already exists for the local engine. That is what keeps ONE
|
||||
// code path in the screens: a tile points at a loopback URL and does not know
|
||||
// or care which building the picture came from.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The boundary is ours to choose; it only has to be a string the JPEG bytes
|
||||
// cannot contain, and a marker line never appears inside JPEG data.
|
||||
const mjpegBoundary = "behavisionframe"
|
||||
|
||||
// A frame is base64, so ~1.33 bytes on the wire per byte of picture. The
|
||||
// engine re-encodes to 640 px for the relay and those measure ~20 KB, so this
|
||||
// is roughly a hundredfold headroom - large enough never to clip a real frame
|
||||
// and small enough that a broken or hostile stream cannot grow this process's
|
||||
// memory without bound.
|
||||
const maxFrameLine = 8 << 20
|
||||
|
||||
func (p *streamProxy) relayRemote(w http.ResponseWriter, r *http.Request,
|
||||
cameraID string, open func(context.Context, string) (*http.Response, error)) {
|
||||
|
||||
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary="+mjpegBoundary)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
// Send the headers NOW, before any frame exists. Go writes them on the
|
||||
// first body write, so without this the whole response - status line
|
||||
// included - waits for the shop computer to start pushing, and a viewer
|
||||
// whose camera is slow to answer sees the REQUEST time out rather than a
|
||||
// stream that has not painted yet. Measured against production: 30
|
||||
// seconds and not even a Content-Type.
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Reconnecting is normal, not an error. The server caps one push at five
|
||||
// minutes so that a tab left open for a week cannot leave a shop
|
||||
// uploading for a week - so a viewer who IS still there simply asks
|
||||
// again. Doing it here rather than in the page is what lets the <img>
|
||||
// survive the cap: it never sees the stream end.
|
||||
sent := 0
|
||||
for {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
n, err := p.pumpRemote(w, flusher, r.Context(), cameraID, open)
|
||||
sent += n
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
// Nothing was written and the attempt failed. Writing an error body
|
||||
// now would be writing it into a multipart stream the <img> is
|
||||
// already parsing, so the picture simply stays on whatever it last
|
||||
// showed and the screen's own "not connecting" state is the report.
|
||||
if err != nil && sent == 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pumpRemote runs one SSE connection to exhaustion and returns how many
|
||||
// frames it forwarded.
|
||||
func (p *streamProxy) pumpRemote(w http.ResponseWriter, flusher http.Flusher,
|
||||
ctx context.Context, cameraID string,
|
||||
open func(context.Context, string) (*http.Response, error)) (int, error) {
|
||||
|
||||
resp, err := open(ctx, cameraID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), maxFrameLine)
|
||||
|
||||
var event, data string
|
||||
frames := 0
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event: "):
|
||||
event = strings.TrimSpace(line[7:])
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
data = line[6:]
|
||||
case line == "":
|
||||
// End of one SSE event. `waiting` means head office has us
|
||||
// registered and the shop PC has not started pushing yet - a real
|
||||
// second or two while the agent is asked, and nothing to draw.
|
||||
if event == "frame" && data != "" {
|
||||
if err := writeMJPEGFrame(w, flusher, data); err != nil {
|
||||
return frames, err // the webview went away
|
||||
}
|
||||
frames++
|
||||
}
|
||||
event, data = "", ""
|
||||
}
|
||||
}
|
||||
return frames, sc.Err()
|
||||
}
|
||||
|
||||
func writeMJPEGFrame(w http.ResponseWriter, flusher http.Flusher, b64 string) error {
|
||||
jpg, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(jpg) == 0 {
|
||||
// One malformed frame is not a reason to tear down a working view.
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintf(w,
|
||||
"--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
|
||||
mjpegBoundary, len(jpg)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(jpg); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte("\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
// Flushed per frame. Anything held waiting for a full buffer is a tile
|
||||
// that stays blank, which is indistinguishable from the view not working.
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
103
desktop/stream_remote_live_test.go
Normal file
103
desktop/stream_remote_live_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-desktop/internal/cloud"
|
||||
)
|
||||
|
||||
// The whole chain against the real head office and a real shop computer:
|
||||
//
|
||||
// TEST_CLOUD_EMAIL=... TEST_CLOUD_PASSWORD=... \
|
||||
// go test ./desktop/ -run RemoteLive -v
|
||||
//
|
||||
// Everything in stream_remote_test.go proves the relay against a fake that
|
||||
// agrees with me. Only this proves the part that cannot be faked: that a shop
|
||||
// computer behind a router with no inbound route actually pushes frames when
|
||||
// asked, that they survive base64 and SSE, and that what comes out of the
|
||||
// loopback relay is a multipart stream an <img> will paint.
|
||||
//
|
||||
// It also costs something to run, which is why it is opt-in: watching makes
|
||||
// the shop computer upload for as long as the test reads.
|
||||
func TestRemoteLiveFromProduction(t *testing.T) {
|
||||
email, pass := os.Getenv("TEST_CLOUD_EMAIL"), os.Getenv("TEST_CLOUD_PASSWORD")
|
||||
if email == "" || pass == "" {
|
||||
t.Skip("set TEST_CLOUD_EMAIL and TEST_CLOUD_PASSWORD to run against production")
|
||||
}
|
||||
base := os.Getenv("TEST_CLOUD_URL")
|
||||
if base == "" {
|
||||
base = "https://mcp.loyaly.ai"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
c := cloud.New(base)
|
||||
if _, err := c.Login(ctx, email, pass); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
|
||||
cams, err := c.RemoteCameras(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("cameras: %v", err)
|
||||
}
|
||||
t.Logf("%d cameras", len(cams))
|
||||
target := os.Getenv("TEST_CLOUD_CAMERA")
|
||||
for _, cam := range cams {
|
||||
conn := "waiting"
|
||||
if cam.Connected != nil {
|
||||
conn = map[bool]string{true: "connected", false: "not connecting"}[*cam.Connected]
|
||||
}
|
||||
t.Logf(" %-10s %-16s %-15s snapshot=%v", cam.CameraID, cam.Site, conn, cam.Snapshot.Available)
|
||||
if target == "" && cam.Connected != nil && *cam.Connected {
|
||||
target = cam.CameraID
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
t.Skip("no connected camera to watch")
|
||||
}
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(c.CameraLive); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
rctx, rcancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer rcancel()
|
||||
req, _ := http.NewRequestWithContext(rctx, http.MethodGet, p.urlFor(target, "live.mjpeg"), nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
start := time.Now()
|
||||
acc, buf, frames := make([]byte, 0, 1<<20), make([]byte, 32*1024), 0
|
||||
for frames < 10 {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
acc = append(acc, buf[:n]...)
|
||||
frames = bytes.Count(acc, []byte("--"+mjpegBoundary))
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
el := time.Since(start)
|
||||
t.Logf("watching %q: %d frames, %d bytes, %.1fs (%.1f fps, %.0f KB/s)",
|
||||
target, frames, len(acc), el.Seconds(),
|
||||
float64(frames)/el.Seconds(), float64(len(acc))/el.Seconds()/1024)
|
||||
|
||||
if frames < 3 {
|
||||
t.Fatalf("got %d frames from a connected camera - the shop computer is "+
|
||||
"not answering head office's request to push", frames)
|
||||
}
|
||||
// Bytes that are actually a picture, not a framing header that happens to
|
||||
// be well formed. A JPEG begins FFD8.
|
||||
if !bytes.Contains(acc, []byte{0xFF, 0xD8, 0xFF}) {
|
||||
t.Error("no JPEG start marker anywhere in the stream")
|
||||
}
|
||||
}
|
||||
209
desktop/stream_remote_test.go
Normal file
209
desktop/stream_remote_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jpg is a byte sequence that is not valid JPEG and does not need to be: what
|
||||
// is under test is that the bytes arrive intact and framed, not that a decoder
|
||||
// likes them.
|
||||
var jpg = []byte{0xFF, 0xD8, 'h', 'e', 'l', 'l', 'o', 0xFF, 0xD9}
|
||||
|
||||
// sseServer answers head office's live endpoint with `pushes` frames and then
|
||||
// ends the response, which is what the server's five-minute cap does.
|
||||
func sseServer(t *testing.T, frames int, hits *int32) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(hits, 1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fl, _ := w.(http.Flusher)
|
||||
// Registered, nothing being pushed yet. Nothing may be drawn for it.
|
||||
fmt.Fprint(w, "event: waiting\ndata: \n\n")
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
for i := 0; i < frames; i++ {
|
||||
fmt.Fprintf(w, "event: frame\ndata: %s\n\n",
|
||||
base64.StdEncoding.EncodeToString(jpg))
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func openerFor(srv *httptest.Server) func(context.Context, string) (*http.Response, error) {
|
||||
return func(ctx context.Context, cam string) (*http.Response, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/live/"+cam, nil)
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point: base64 frames over SSE are not something an <img> can show,
|
||||
// and a multipart MJPEG stream is. Without this the app could only ever show a
|
||||
// still, on exactly the computers that cannot reach the camera any other way.
|
||||
func TestRemoteFramesReachTheWebviewAsMJPEG(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 3, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
u := p.urlFor("cam2", "live.mjpeg")
|
||||
if u == "" {
|
||||
t.Fatal("no relay url; the proxy did not bind")
|
||||
}
|
||||
|
||||
// The relay reconnects for as long as the viewer is there, so the read is
|
||||
// bounded by us rather than by the stream ending - exactly as an <img>
|
||||
// would behave.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/x-mixed-replace") {
|
||||
t.Fatalf("Content-Type = %q, an <img> will not treat that as a stream", ct)
|
||||
}
|
||||
|
||||
// Read the first three frames' worth and stop; the relay would otherwise
|
||||
// go on reconnecting forever, which is the behaviour being relied on.
|
||||
want := append([]byte(fmt.Sprintf("--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
|
||||
mjpegBoundary, len(jpg))), jpg...)
|
||||
got := make([]byte, 0, 4096)
|
||||
buf := make([]byte, 512)
|
||||
for len(got) < 3*len(want) {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
got = append(got, buf[:n]...)
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if n := bytes.Count(got, []byte("--"+mjpegBoundary)); n < 3 {
|
||||
t.Fatalf("got %d frames in %d bytes, want at least 3", n, len(got))
|
||||
}
|
||||
if !bytes.Contains(got, want) {
|
||||
t.Errorf("a frame was not framed as expected:\n%q", got[:min(len(got), 300)])
|
||||
}
|
||||
// `waiting` is a real state - head office has us registered and the shop
|
||||
// computer has not started pushing - and there is nothing to draw for it.
|
||||
// Emitting an empty part would blank a tile that already had a picture.
|
||||
if bytes.Contains(got, []byte("Content-Length: 0")) {
|
||||
t.Error("an empty frame was written for a waiting event")
|
||||
}
|
||||
}
|
||||
|
||||
// The server caps one push at five minutes so a tab left open for a week
|
||||
// cannot leave a shop uploading for a week. Reconnecting is therefore a normal
|
||||
// event, and doing it here rather than in the page is what lets the <img>
|
||||
// survive the cap - it never sees the stream end.
|
||||
func TestTheRelayReconnectsWhenHeadOfficeEndsAPush(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 1, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.urlFor("cam2", "live.mjpeg"), nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Two frames means two pushes, because each push carries exactly one.
|
||||
seen, buf := 0, make([]byte, 256)
|
||||
acc := make([]byte, 0, 2048)
|
||||
for seen < 2 {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
acc = append(acc, buf[:n]...)
|
||||
seen = bytes.Count(acc, []byte("--"+mjpegBoundary))
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen < 2 {
|
||||
t.Fatalf("got %d frames across reconnects, want 2", seen)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got < 2 {
|
||||
t.Errorf("head office was asked %d times, want at least 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Signed out, the relay must not pretend. There is no fallback URL to offer
|
||||
// either: the head-office endpoint needs this session's bearer, which an <img>
|
||||
// cannot send - so a tile that silently failed would be the only alternative.
|
||||
func TestTheRelayRefusesWhenNobodyIsSignedIn(t *testing.T) {
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(nil); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
resp, err := http.Get(p.urlFor("cam2", "live.mjpeg"))
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The relay is credentialed - it is a path to a live view of a shop floor -
|
||||
// and the token is the only thing standing between another local process and
|
||||
// it. live.mjpeg must be behind exactly the same door as the engine routes.
|
||||
func TestTheRemoteRouteIsBehindTheSameToken(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 1, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
// The right shape, the wrong value.
|
||||
parts := strings.Split(p.urlFor("cam2", "live.mjpeg"), "/")
|
||||
parts[4] = strings.Repeat("0", len(parts[4]))
|
||||
bad := strings.Join(parts, "/")
|
||||
|
||||
resp, err := http.Get(bad)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404 - and 404 rather than 403, because there is nothing here to tell an unwelcome caller they found the right door", resp.StatusCode)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) != 0 {
|
||||
t.Error("a request with the wrong token still made the shop computer upload")
|
||||
}
|
||||
}
|
||||
166
desktop/viewing_test.go
Normal file
166
desktop/viewing_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-desktop/internal/cloud"
|
||||
"github.com/loyaly/behavision-desktop/internal/local"
|
||||
)
|
||||
|
||||
// Viewer mode: what the app shows on a computer that is signed in and is not
|
||||
// itself watching any cameras.
|
||||
//
|
||||
// This is the friend's-Mac case, and before it existed the app was honest and
|
||||
// useless: Live() and Cameras() read ONLY the engine on 127.0.0.1, so a laptop
|
||||
// with no engine got "engine not reachable at http://127.0.0.1:8010" and
|
||||
// "0 of 0 cameras" - on an account whose shops were running and recognising
|
||||
// people the whole time. Signing in is what the person did; the app answered
|
||||
// as if they had not.
|
||||
//
|
||||
// The engine here is a port nothing listens on, which is precisely what a PC
|
||||
// with no engine is.
|
||||
const noEngine = "http://127.0.0.1:1" // reserved, refuses immediately
|
||||
|
||||
func viewerApp(t *testing.T, srv *httptest.Server) *App {
|
||||
t.Helper()
|
||||
c := cloud.New(srv.URL)
|
||||
c.SetSession(cloud.Session{Token: "test-token"})
|
||||
return &App{
|
||||
ctx: context.Background(),
|
||||
cloud: c,
|
||||
local: local.New(noEngine, "", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveFallsBackToHeadOfficeWhenThereIsNoEngine(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/api/sites":
|
||||
// Two shops. One is fine, one is the Office1 case.
|
||||
w.Write([]byte(`[
|
||||
{"slug":"a","name":"A","cameras_up":2,"cameras_total":2,"fraction_below_gate":0.10},
|
||||
{"slug":"b","name":"B","cameras_up":1,"cameras_total":3,"fraction_below_gate":0.73}
|
||||
]`))
|
||||
case strings.HasPrefix(r.URL.Path, "/api/visits"):
|
||||
w.Write([]byte(`{"arrivals":[
|
||||
{"visit_id":"v1","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2","is_new_visitor":true},
|
||||
{"visit_id":"v2","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2"},
|
||||
{"visit_id":"v3","camera_id":"entrance"}
|
||||
]}`))
|
||||
default:
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
snap, err := viewerApp(t, srv).Live()
|
||||
if err != nil {
|
||||
t.Fatalf("Live: %v", err)
|
||||
}
|
||||
if !snap.Viewing {
|
||||
t.Fatal("the snapshot did not say it was a view of somewhere else")
|
||||
}
|
||||
if got := snap.Stats["cameras_up"]; got != 3 {
|
||||
t.Errorf("cameras_up = %v, want 3 summed across both shops", got)
|
||||
}
|
||||
if got := snap.Stats["cameras_total"]; got != 5 {
|
||||
t.Errorf("cameras_total = %v, want 5", got)
|
||||
}
|
||||
// The WORST site, never an average. Averaging 0.10 against 0.73 reports
|
||||
// 0.42 and hides the only shop anyone needs to go and fix - the same rule
|
||||
// the heartbeat already follows with worst_site.
|
||||
if got := snap.Stats["fraction_below_gate"]; got != 0.73 {
|
||||
t.Errorf("fraction_below_gate = %v, want the worst shop's 0.73", got)
|
||||
}
|
||||
// Three arrivals, two of them the same person, one unidentified. A visit
|
||||
// with no visitor_id is real footfall and an unknown person, so it counts
|
||||
// as a sighting and not as somebody known.
|
||||
g := snap.Stats["gallery"].(map[string]any)
|
||||
if g["identities"] != 1 || g["sightings"] != 3 {
|
||||
t.Errorf("gallery = %v, want 1 identity over 3 sightings", g)
|
||||
}
|
||||
if len(snap.Events) != 3 {
|
||||
t.Fatalf("got %d events, want 3", len(snap.Events))
|
||||
}
|
||||
if snap.Events[0]["type"] != "person.new" || snap.Events[1]["type"] != "person.seen" {
|
||||
t.Errorf("arrival types wrong: %v", snap.Events)
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody signed in: the local failure is the honest answer. There is nothing
|
||||
// else to show, and the person is most likely setting this PC up - telling
|
||||
// them about head office would be telling them about something they have not
|
||||
// got to yet.
|
||||
func TestLiveWithNoEngineAndNoSessionReportsTheEngine(t *testing.T) {
|
||||
a := &App{ctx: context.Background(), cloud: cloud.New("https://example.invalid"),
|
||||
local: local.New(noEngine, "", "")}
|
||||
if _, err := a.Live(); err == nil {
|
||||
t.Fatal("want the engine error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// A remote camera is flagged, because the screen has to withhold every button
|
||||
// that would talk to a camera on a network this computer cannot reach. An Edit
|
||||
// button that cannot work is worse than one that is absent.
|
||||
func TestRemoteCamerasAreFlaggedAndCarryNoCredentials(t *testing.T) {
|
||||
jpeg := base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xD9})
|
||||
var shots int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/camera-snapshots/") {
|
||||
shots++
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
b, _ := base64.StdEncoding.DecodeString(jpeg)
|
||||
w.Write(b)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`[
|
||||
{"id":"c1","camera_id":"cam2","label":"Open office","site":"Coimbatore",
|
||||
"connected":true,"snapshot_at":"2026-09-30T10:00:00Z",
|
||||
"snapshot":{"available":true,"url":"/api/camera-snapshots/c1.jpg","auth":true}}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := viewerApp(t, srv)
|
||||
cams, err := a.Cameras()
|
||||
if err != nil {
|
||||
t.Fatalf("Cameras: %v", err)
|
||||
}
|
||||
if len(cams) != 1 {
|
||||
t.Fatalf("got %d cameras, want 1", len(cams))
|
||||
}
|
||||
if cams[0]["remote"] != true {
|
||||
t.Error("the camera was not flagged remote")
|
||||
}
|
||||
// The RTSP details are a live path into the camera itself and the server
|
||||
// does not send them to a tenant at all. Nothing here may invent them.
|
||||
for _, k := range []string{"host", "port", "path", "username", "password"} {
|
||||
if _, ok := cams[0][k]; ok {
|
||||
t.Errorf("a remote camera carried %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
// The picture has to be fetched here: a webview <img> resolves a relative
|
||||
// src against wails:// and cannot send the session's bearer.
|
||||
shot := cams[0]["snapshot"].(cloud.Photo)
|
||||
if !strings.HasPrefix(shot.URL, "data:image/jpeg;base64,") || shot.Auth {
|
||||
t.Errorf("snapshot url = %q auth=%v, want an inline data URI", shot.URL, shot.Auth)
|
||||
}
|
||||
|
||||
// And fetched ONCE. This screen polls every 8 seconds and a real snapshot
|
||||
// is ~90 KB, so re-fetching an unchanged picture is megabytes an hour to
|
||||
// redraw the same frame.
|
||||
if _, err := a.Cameras(); err != nil {
|
||||
t.Fatalf("second poll: %v", err)
|
||||
}
|
||||
if shots != 1 {
|
||||
t.Errorf("fetched the same snapshot %d times across two polls", shots)
|
||||
}
|
||||
}
|
||||
521
docs/TECHNICAL-DOSSIER.md
Normal file
521
docs/TECHNICAL-DOSSIER.md
Normal file
@@ -0,0 +1,521 @@
|
||||
# Behavision — Technical Dossier
|
||||
|
||||
Everything below is extracted from the repository as of release 0.4.1 (engine 1.1.0, schema at migration 013). File paths, function names, thresholds, topics, ports and table definitions are the real ones. Where something lives outside the repository (the production host's proxy and container configuration) it is stated as such rather than invented.
|
||||
|
||||
> **Snapshot, not a live document.** Written against release 0.4.1 / schema
|
||||
> 013, and the repository is past that. It predates at least: the engine's
|
||||
> motion gate and one-thread detector (CPU 214% → 16%), `Gallery.health` and
|
||||
> the stalled-camera state, the `tenantOnly` guard, `POST /api/auth/password`,
|
||||
> `POST /api/customers` and the visitor merge, the admin console drill-down,
|
||||
> `GET /api/sales` and `/api/dashboard/summary`, migration 014, and the macOS
|
||||
> desktop build. Everything it *does* describe was extracted from the code and
|
||||
> was true then; nothing here was invented. For the current surface read
|
||||
> `API.md`, which is kept up to date, and `CLAUDE.md` for the decisions.
|
||||
|
||||
Companion documents: `API.md` (every route with request/response shapes), `docs/openapi.yaml` (generated), `docs/Behavision-Architecture.html` (diagrams).
|
||||
|
||||
---
|
||||
|
||||
## 1. System architecture
|
||||
|
||||
### 1.1 Repository layout
|
||||
|
||||
```
|
||||
behavision/ Recognition engine (Python 3.10+)
|
||||
__main__.py CLI: run | enroll | setup-models | calibrate | paths
|
||||
api.py FastAPI on 127.0.0.1:8010 — dashboard, cameras, stream, stats
|
||||
capture.py VideoSource: RTSP capture thread, latest-frame slot, probe_source
|
||||
detection.py FaceDetector (YuNet), Detection
|
||||
geometry.py umeyama, align_face, iou, clip_box
|
||||
recognition.py ArcFaceEncoder (ONNX Runtime), face_quality
|
||||
tracking.py Track, IouTracker
|
||||
engine.py Engine, CameraWorker, PipelineStats — the per-camera pipeline
|
||||
attributes.py AttributeEstimator (gender/age/emotion), aggregate
|
||||
commission.py CommissionRun — placement check verdicts
|
||||
gallery/store.py IdentityStore — SQLite (identities, embeddings, sightings)
|
||||
gallery/index.py VectorIndex — FAISS IndexIDMap2(IndexFlatIP) / numpy fallback
|
||||
gallery/service.py Gallery — resolve, enroll, reinforce, merge, duplicates
|
||||
events.py EventBus + LogSink / WebhookSink / EmailSink
|
||||
cameras.py CameraStore (cameras.json), protect/unprotect (DPAPI)
|
||||
config.py pydantic Config, ${ENV} expansion, RTSP URL building
|
||||
paths.py install_root / state_root / config_path resolution
|
||||
static/dashboard.html Engine's own dashboard (no build step)
|
||||
config/default.yaml Engine config (thresholds, cameras via ${ENV})
|
||||
tests/ Engine tests (204), dependency-light: no camera, no models
|
||||
|
||||
agent/ Shop-PC agent (Go 1.22, module github.com/loyaly/behavision-agent)
|
||||
main.go Headless agent binary
|
||||
cmd/behavision-setup/ Installer: venv, wheel, models, config, smoke test, demo bundle
|
||||
cmd/behavision-demo-pack/ Seals a camera list (AES-256-GCM) — build machine only
|
||||
pkg/spool/ Durable queue: one file per event, bounded, ack by delete
|
||||
pkg/mqtt/ paho adapter (client.go) + Pump + Waker (pump.go)
|
||||
pkg/bridge/ Loopback webhook the engine posts to; derives event_id; queues
|
||||
pkg/engine/ Supervisor (start/stop/restart/backoff), Health, ChildEnv
|
||||
pkg/cameras/ Syncer: pull desired cameras, adopt local, run checks
|
||||
pkg/enrol/ Redeem an installation code
|
||||
pkg/config/ agent.json with DPAPI-protected secrets
|
||||
pkg/paths/ StateRoot / InstallRoot — mirrors behavision/paths.py
|
||||
pkg/demo/ Sealed bundle: NewCode, Seal, Open
|
||||
|
||||
desktop/ Shop-PC app (Wails v2.9.2, Go + React)
|
||||
main.go wails.Run, HideWindowOnClose, tray start/stop
|
||||
app.go Methods bound to the frontend; owns Supervisor, Bridge, Pump, Syncer
|
||||
tray.go / icons.go fyne.io/systray; ICO rendered at runtime on Windows
|
||||
stream_proxy.go Loopback relay for camera MJPEG (credential never in the page)
|
||||
internal/local/ Client for the engine on 127.0.0.1:8010
|
||||
internal/cloud/ Client for the platform API (sessions, refresh, images)
|
||||
frontend/ React + Vite; src/bridge.js calls window.go.main.App.*
|
||||
|
||||
server/ Platform (Go, module github.com/loyaly/behavision-server)
|
||||
cmd/behavision-server/main.go One binary: migrate → store → hub → ingest → API → web
|
||||
Dockerfile Two-stage; static binary on alpine; EXPOSE 8080
|
||||
migrations/001..013_*.sql go:embed'ed; applied at boot under an advisory lock
|
||||
internal/api/ HTTP handlers, middleware, Hub (SSE doorbell), LiveHub (relay)
|
||||
internal/store/ PostgreSQL access (pgx) — every query tenant-scoped
|
||||
internal/ingest/ MQTT consumer: topic → site → visit/heartbeat → store
|
||||
internal/auth/ Passwords (bcrypt 12), tokens, codes, Principal + role checks
|
||||
internal/secret/ secret.Box — AES-256-GCM with AAD
|
||||
internal/blob/ S3-compatible object storage (presign, private ACL check)
|
||||
internal/assistant/ Claude tool loop; tools.go has no LLM import
|
||||
internal/contract/ The MQTT wire contract: Visit, Heartbeat, ParseTopic
|
||||
internal/migrate/ Migration runner (checksums, numeric order, baseline)
|
||||
internal/provision/ CLI: provision key|client|site|user|token
|
||||
internal/web/ go:embed of the built React console (web/dist → here)
|
||||
|
||||
web/ Head-office console (React + Vite); outDir → server/internal/web/dist
|
||||
shared/cameraMakes.js Camera make → RTSP path table, imported by web AND desktop
|
||||
installer/ build.ps1 (PyInstaller path), behavision.iss, INSTALL.txt, LAN launcher
|
||||
run-local.sh Whole platform locally: Postgres + Mosquitto in Docker, server as binary
|
||||
```
|
||||
|
||||
### 1.2 Processes and where they run
|
||||
|
||||
| Process | Language | Runs on | Listens | Talks to |
|
||||
|---|---|---|---|---|
|
||||
| Recognition engine | Python | shop PC | `127.0.0.1:8010` (Basic auth, generated) | cameras (RTSP), agent webhook (loopback) |
|
||||
| Agent (inside the desktop app, or headless) | Go | shop PC | loopback webhook, port 0 | engine API, Mosquitto (TLS 8883), platform API (HTTPS) |
|
||||
| Shop app | Go + webview | shop PC | loopback relay, port 0 | engine API, platform API |
|
||||
| Mosquitto | C | cloud | `8883` TLS (agents), `1883` internal (server) | — |
|
||||
| behavision-server | Go | cloud | `8080` (behind proxy) | PostgreSQL, Mosquitto (subscriber), object storage (optional), Anthropic API (optional) |
|
||||
| PostgreSQL + pgvector | C | cloud | `5432` internal | — |
|
||||
| Head-office console | React | browser | — | platform API |
|
||||
| Mobile app | — | phone | — | platform API |
|
||||
|
||||
### 1.3 Network, domains, TLS
|
||||
|
||||
| Endpoint | Purpose | TLS |
|
||||
|---|---|---|
|
||||
| `https://platform.loyaly.ai` | Head-office console + API (`/api/*`) | Terminated at the reverse proxy (Traefik); the server listens plain HTTP on `LISTEN_ADDR` (default `:8080`) |
|
||||
| `https://mcp.loyaly.ai/api/*` | Same API, the hostname the shop app defaults to (`BEHAVISION_CLOUD`) | Proxy |
|
||||
| `tls://mcp.loyaly.ai:8883` | MQTT for agents (`AGENT_MQTT_URL` default) | Mosquitto's own listener; certificate must carry `DNS:mcp.loyaly.ai`; agents pin the issuing CA (`AGENT_CA_FILE` delivered at enrolment) |
|
||||
| `tcp://behavision-mqtt:1883` | Server ↔ Mosquitto, internal network only (`MQTT_URL` default) | Plaintext on a private network |
|
||||
|
||||
Trust rules enforced in code:
|
||||
- `X-Forwarded-For` is trusted for the login throttle **only because** nothing reaches the server port except through the proxy (`api/throttle.go`).
|
||||
- The agent refuses `tcp://` to any non-loopback host unless `BEHAVISION_ALLOW_PLAINTEXT_MQTT=1` (`agent/pkg/mqtt/client.go`).
|
||||
- No inbound route to a shop PC is ever required: agent → broker, agent → API, app → API are all outbound.
|
||||
|
||||
### 1.4 Server configuration (environment)
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | required | PostgreSQL DSN |
|
||||
| `LISTEN_ADDR` | `:8080` | HTTP listener (behind proxy) |
|
||||
| `MQTT_URL` / `MQTT_USERNAME` / `MQTT_PASSWORD` | `tcp://behavision-mqtt:1883` | Server's subscriber credential |
|
||||
| `AGENT_MQTT_URL` | `tls://mcp.loyaly.ai:8883` | Broker URL handed to a PC at enrolment |
|
||||
| `AGENT_CA_FILE` | — | CA PEM handed to a PC at enrolment (pinned) |
|
||||
| `AGENT_MODELS_FILE` | — | Model manifest handed at enrolment |
|
||||
| `BEHAVISION_SECRET_KEY` | — | 32-byte key for `secret.Box`; enrolment and camera passwords need it |
|
||||
| `DO_SPACES_*` (`ENDPOINT`, `REGION`, `BUCKET`, `ACCESS_KEY`, `SECRET_KEY`, `PREFIX`) | prefix `behavision/v2` | Optional object storage; absent = images stored in Postgres |
|
||||
| `ANTHROPIC_API_KEY` / `ANTHROPIC_WORKSPACE_ID` / `BEHAVISION_ASSISTANT_MODEL` | model `claude-sonnet-5` | Assistant; absent = `501 assistant_off` |
|
||||
| `BEHAVISION_SKIP_MIGRATE` | — | Escape hatch; default applies migrations at boot |
|
||||
|
||||
### 1.5 Container / deployment shape
|
||||
|
||||
The repository ships `server/Dockerfile` (golang:1.25-alpine build → alpine:3.20 runtime, static binary, `EXPOSE 8080`, non-root user) and `run-local.sh`, which stands the whole platform up locally: `bv-pg` (pgvector/pgvector:pg16, port 55432), `bv-mqtt` (eclipse-mosquitto:2, port 51883, `passwd` + `acl` mounted), and the server as a local binary on 8088 with the console embedded.
|
||||
|
||||
Production host configuration (proxy routes, compose/unit files, certificate issuance) is **outside the repository**. What the code requires of it: a proxy terminating TLS for `platform.loyaly.ai` and forwarding to `LISTEN_ADDR`; Mosquitto with a TLS listener on 8883 whose certificate names `mcp.loyaly.ai`, a `passwd` file the `provision site` command adds to, and an ACL of the form `pattern write bv/%u/#`; PostgreSQL with the `vector` extension.
|
||||
|
||||
---
|
||||
|
||||
## 2. Recognition engine internals
|
||||
|
||||
### 2.1 Pipeline, function by function
|
||||
|
||||
```
|
||||
RTSP ──▶ capture.VideoSource.run() thread per camera; cv2.VideoCapture(CAP_FFMPEG)
|
||||
│ OPENCV_FFMPEG_CAPTURE_OPTIONS = rtsp_transport;tcp | stimeout;5000000 |
|
||||
│ fflags;nobuffer | flags;low_delay | max_delay;200000
|
||||
│ downscale to max_width (1280) with INTER_AREA
|
||||
│ latest frame + timestamp in a lock-protected slot
|
||||
▼
|
||||
engine.CameraWorker.run() thread per camera; takes source.latest_since(ts)
|
||||
│
|
||||
├─ detection.FaceDetector.detect(frame) cv2.FaceDetectorYN (YuNet 2023mar)
|
||||
│ score_threshold 0.82 · nms 0.3 · min_face_px 48 · max_faces 20
|
||||
│ → Detection(box, kps[5], score)
|
||||
│
|
||||
├─ recognition.face_quality(frame, box, kps) weighted: sharpness .35 · size .25 · brightness .15 · frontality .25
|
||||
│
|
||||
├─ tracking.IouTracker.update(dets, ts) greedy IoU association, iou_threshold 0.3, max_misses 25
|
||||
│ → active Track[], ended Track[] one Track == one person on camera
|
||||
│
|
||||
├─ for each active track, _should_identify(): hits ≥ 4 · quality ≥ min_quality_to_encode (0.35)
|
||||
│ ≤ max_id_attempts (8) · spaced id_retry_interval_seconds (0.5)
|
||||
│
|
||||
├─ _identify(track, frame):
|
||||
│ geometry.align_face(frame, kps) Umeyama similarity transform → 112×112 BGR chip
|
||||
│ recognition.ArcFaceEncoder.encode() BGR→RGB, (x−127.5)/127.5, NCHW float32, L2-normalised 512-d
|
||||
│ track.emb_sum += e; when emb_count ≥ min_embeddings_for_id (3): mean → normalise
|
||||
│ gallery.Gallery.resolve(mean, quality, rcfg) → Resolution(kind, identity, similarity)
|
||||
│
|
||||
├─ Resolution.kind:
|
||||
│ known sim ≥ match_threshold (0.42) → person.seen (+ reinforce if 0.32 ≤ sim < 0.55, q ≥ gate, < 5 stored)
|
||||
│ ambiguous 0.32 ≤ sim < 0.42 → wait; retry on a later frame
|
||||
│ new sim < enroll_threshold (0.32) → Gallery.enroll → "Visitor N" · person.new
|
||||
│ skipped quality < min_enroll_quality (0.65) → counted as rejected_quality
|
||||
│
|
||||
├─ attributes.AttributeEstimator.estimate() genderage.onnx on a loose 1.5× crop; FER+ on the chip;
|
||||
│ medianed over the track (attributes.aggregate)
|
||||
│
|
||||
├─ _finish_track(ended) PipelineStats.record(outcome) — exactly once per track
|
||||
│
|
||||
└─ _remember_tracks(active) boxes + labels for the live picture (no encode)
|
||||
|
||||
Live picture: CameraWorker.latest_jpeg_since(ts) — freshest CAPTURED frame + last boxes, encoded on demand.
|
||||
Events: events.EventBus → LogSink · WebhookSink (→ agent bridge) · EmailSink
|
||||
```
|
||||
|
||||
### 2.2 Models (`recognition.MODEL_CANDIDATES`, first loadable wins)
|
||||
|
||||
| Order | File | Role |
|
||||
|---|---|---|
|
||||
| 1–2 | `adaface_ir101.onnx`, `adaface_ir50.onnx` | wired, optional |
|
||||
| **3** | **`w600k_r50.onnx`** (166 MB) | **in use** — IJB-C 97.25; same-person p05 0.719 on the office camera |
|
||||
| 4 | `arcface_int8.onnx` | optional |
|
||||
| 5 | `w600k_mbf.onnx` (13 MB) | always loads; MobileFaceNet fallback (95.02) |
|
||||
| 6 | `arcface.onnx` (r100, 249 MB) | optional |
|
||||
| — | `face_detection_yunet_2023mar.onnx` | detector |
|
||||
| — | `genderage.onnx` (InsightFace buffalo_l) | attributes |
|
||||
|
||||
Every stored embedding is tagged with the model name; `IdentityStore.all_embeddings(model)` loads only same-model vectors into the index.
|
||||
|
||||
### 2.3 Local gallery
|
||||
|
||||
`gallery/store.py` — SQLite (WAL), single source of truth:
|
||||
```
|
||||
identities(id, label, kind auto|named, created_at, sighting_count)
|
||||
embeddings(id, identity_id, model, vector BLOB, quality, created_at)
|
||||
sightings(id, identity_id, camera_id, similarity, at)
|
||||
```
|
||||
`gallery/index.py` — `VectorIndex` over FAISS `IndexIDMap2(IndexFlatIP)` (exact inner product = cosine on L2-normalised vectors), rebuilt from SQLite at boot, −1 ids filtered, identical numpy fallback. Measured: 1k → 0.27 ms, 10k → 2.24 ms, 100k → 21.9 ms.
|
||||
|
||||
`gallery/service.py` — `Gallery.resolve` (three zones), `enroll`, `reinforce_identity` (refuses a view whose nearest neighbour is another identity), `merge_identities` (one transaction; human name outranks "Visitor N"; `sighting_count` recomputed; trimmed to 5 by quality), `duplicate_candidates` (k-NN across identities, O(n·k)).
|
||||
|
||||
### 2.4 What leaves the engine
|
||||
|
||||
`WebhookSink` POSTs each `person.seen` / `person.new` to the agent's loopback bridge with `identity_id`, `label`, `similarity`, `quality`, attributes, and optionally `image_path` (only when `app.store_faces: true`). The bridge fetches the identity's **best** stored embedding once per identity via `GET /api/identities/{id}/embedding`. `person.missed`, `camera.up/down` are diagnostics and never become visits.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend internals
|
||||
|
||||
### 3.1 Request path
|
||||
|
||||
```
|
||||
proxy (TLS) ─▶ net/http mux (Go 1.22 patterns, method + path)
|
||||
│
|
||||
├─ s.authed(h) Bearer token → SHA-256 → sessions row → Principal{UserID, ClientID, Role}
|
||||
│ token_expired vs unauthorized distinguished; last_used_at touched
|
||||
├─ s.adminOnly(h) Principal.Role == "admin" AND ClientID == "" (both) → else 404
|
||||
├─ s.agentAuthed(h) Agent token (hashed) → AgentPrincipal{ClientID, Client slug, SiteID, AgentID}
|
||||
└─ no wrapper login, refresh, invitation preview, register, enrol
|
||||
│
|
||||
▼
|
||||
handlers_*.go decode (unknown fields rejected) → validate → Store call → writeJSON
|
||||
│ every Store call receives p.ClientID from the session, never the body
|
||||
▼
|
||||
store/*.go (pgx) SQL with client_id in every WHERE / INSERT
|
||||
```
|
||||
|
||||
Login throttle (`api/throttle.go`): per-account 10 failures / 15 min and per-IP 60, in memory, pruned on read; success clears both. Unknown address is verified against `auth.DummyHash` so timing matches a wrong password.
|
||||
|
||||
### 3.2 Handler areas → store methods
|
||||
|
||||
| Area (file) | Routes | Store surface |
|
||||
|---|---|---|
|
||||
| `handlers_auth.go`, `handlers_sessions.go` | login, refresh, logout, me, sessions list/revoke | `UserByEmail`, `CreateSession`, `SessionByAccessHash`, `RotateSession`, `RevokeSession(s)` |
|
||||
| `handlers_team.go` | team, members, password reset, invitations, register | `Team`, `UpdateTeamMember`, `CreateMember`, `ResetMemberPassword`, `CreateInvitation`, `RedeemInvitation`, `OwnerCount` |
|
||||
| `handlers_admin.go` | admin/clients | `ListClients`, `CreateClientWithOwner` (one transaction) |
|
||||
| `handlers_arrivals.go`, `hub.go` | visits, visits/stream | `Arrivals` (keyset by `seq`), `Hub.Notify` doorbell → SSE |
|
||||
| `handlers_people.go` | visitors, history, profile, purchases, erasure | `SearchVisitors`, `VisitorHistory`, `SaveProfile`, `RecordPurchase`, `ForgetVisitor` |
|
||||
| `handlers_images.go`, `handlers_faces.go` | visitor image, face bytes | `VisitorImageKey`, `FaceImage`; `imageFor(key)` decides presigned vs `auth:true` |
|
||||
| `handlers_cameras.go`, `handlers_snapshots.go` | cameras CRUD, snapshot | `Cameras`, `CreateCamera`, `UpdateCamera`, `DeleteCamera` (tombstone), `Snapshot` |
|
||||
| `handlers_checks.go` | camera check, site check | `RequestCheck`, `ClaimChecks`, `ReleaseStaleChecks`, `RecordCheck`, `SiteCheck` |
|
||||
| `handlers_live.go`, `live.go` | cameras/{id}/live, agent live | `LiveHub` — one-slot buffer per viewer, on-demand upload |
|
||||
| `handlers_reports.go` | footfall, conversion | `Footfall`, `Conversion` — unique vs visits, first-ever "new", single currency |
|
||||
| `handlers_enrolment.go`, `handlers_agent.go` | enrol, agent cameras/checks/faces/upload-url | `RedeemEnrolment` (single-use via UPDATE), `AgentCameras`, `AgentReport`, `PutFace`, `UploadTarget` |
|
||||
| `handlers_assistant.go` | assistant | `assistant.Client.Ask` with the Principal passed at the call site |
|
||||
|
||||
### 3.3 Server-side recognition (`store/store.go`, `RecordVisit`)
|
||||
|
||||
```
|
||||
similarity = 1 - (embedding <=> $1::vector) -- pgvector cosine distance
|
||||
ORDER BY embedding <=> $1::vector LIMIT 1 -- within client_id, same model
|
||||
sim ≥ 0.42 → known visitor; reinforce if 0.32 ≤ sim < 0.55 AND quality ≥ floor AND < 5 stored
|
||||
sim < 0.42 → new visitor: clients.visitor_seq += 1 RETURNING (row-locks the client), label "Visitor N"
|
||||
INSERT visits ... ON CONFLICT (client_id, source_event_id) DO NOTHING -- idempotent
|
||||
```
|
||||
|
||||
### 3.4 Single-process composition (`cmd/behavision-server/main.go`)
|
||||
|
||||
```
|
||||
migrate.Apply(embedded FS) → store.Open → hub := api.NewHub()
|
||||
ingest.Consumer{Store, Notify: hub.Notify} ← paho client, SetOrderMatters(true), subscribed bv/+/+
|
||||
api.New(Store, Hub, LiveHub, Blob?, Assistant?) → web.Handler (embedded dist; /api/ keeps JSON 404)
|
||||
http.Server{ReadTimeout, IdleTimeout, WriteTimeout: 0} -- zero: SSE streams must outlive any write deadline
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. MQTT architecture
|
||||
|
||||
### 4.1 Identity and topics
|
||||
|
||||
Broker username = `<client-slug>.<site-slug>` (e.g. `tenext-retail.chennai`). ACL: `pattern write bv/%u/#` — a site physically cannot publish under another site's prefix.
|
||||
|
||||
```
|
||||
bv/<client>.<site>/visit Visit payload QoS 1 spooled, acked per event
|
||||
bv/<client>.<site>/heartbeat Heartbeat payload QoS 1 never spooled — only meaningful now
|
||||
bv/<client>.<site>/status reserved
|
||||
bv/<client>.<site>/cmd/... reserved (server → site)
|
||||
```
|
||||
|
||||
`contract.ParseTopic` → `Topic{Username, Client, Site, Kind, Rest}`; `Kind ∉ {visit, heartbeat, status, cmd}` is dropped as permanent.
|
||||
|
||||
### 4.2 Payloads (`server/internal/contract/contract.go`)
|
||||
|
||||
```json
|
||||
// visit
|
||||
{ "event_id": "tenext-retail.chennai|cam2|7|1757580000", // <site>|<camera>|<identity>|<unix second> — derived, never random
|
||||
"occurred_at": "2026-09-11T05:20:00Z", "camera_id": "cam2",
|
||||
"is_new": false, "similarity": 0.61, "quality": 0.70,
|
||||
"local_visitor_id": 7, "embedding": [512 floats], "model": "w600k_r50",
|
||||
"image_key": "behavision/v2/tenext-retail/chennai/2026/09/11/…jpg", // or "db:<uuid>", or absent
|
||||
"attributes": { "gender": "Male", "age": 32, "emotion": "neutral" } }
|
||||
|
||||
// heartbeat
|
||||
{ "sent_at": "…", "agent_version": "0.4.1", "engine_version": "1.1.0", "recognition_model": "w600k_r50",
|
||||
"cameras": { "cam1": true, "cam2": true }, "queued": 0, "dropped": 0, "fraction_below_gate": 0.47 }
|
||||
```
|
||||
|
||||
`Visit.Validate()` (permanent errors): `event_id` required ≤128; `occurred_at` required and not >24 h in the future; embedding must be exactly 512 and carry `model`.
|
||||
|
||||
### 4.3 Delivery semantics
|
||||
|
||||
| Stage | Component | Guarantee |
|
||||
|---|---|---|
|
||||
| Engine → agent | `WebhookSink` → `bridge.Bridge.Handle` | loopback HTTP; `event_id` derived from `<site>|<camera>|<identity>|<second>` (sighting cooldown is 30 s, so one person/camera cannot share a second) |
|
||||
| Append | `spool.Spool.Append` | one file per event, fsync, bounded (`SpoolMax`, default 50,000); on overflow drops oldest and counts `Dropped()`; corrupt entry quarantined, not retried |
|
||||
| Wake | `mqtt.Waker.Wake` **after** the append | a wake before durability is a drain that finds nothing |
|
||||
| Publish | `mqtt.Pump.Run` → `Client.Publish` | QoS 1, `CleanSession(true)`, publish bounded by a timeout as well as context (half-open TCP otherwise stalls forever); **a failed publish stops the batch** (ordering per visitor) |
|
||||
| Ack | `Spool.Ack(seq)` on PUBACK | per event, never per batch; file deleted only now |
|
||||
| Consume | `ingest.Consumer.Handle` | paho `SetOrderMatters(true)`; permanent error → `drop()` + log (message is acked, never redelivered); transient error → returned → redelivered |
|
||||
| Write | `store.RecordVisit` | `ON CONFLICT (client_id, source_event_id) DO NOTHING` — duplicates from at-least-once delivery are absorbed |
|
||||
| Notify | `hub.Notify(clientID)` | only on a genuine insert; SSE streams re-query from their own cursor |
|
||||
|
||||
Backoff: reconnect 1 → 30 s exponential; supervisor backoff resets only after a run that stayed up 60 s. `describeStall` distinguishes *broker refused the credential* (TCP opens, connect never completes) from *broker unreachable*.
|
||||
|
||||
### 4.4 Failure paths
|
||||
|
||||
| Failure | Behaviour |
|
||||
|---|---|
|
||||
| Internet down | spool grows on disk; heartbeats stop; head office shows site offline after 3 missed beats; on reconnect the backlog drains in order |
|
||||
| Broker rejects credential | pump logs "reachable but not accepted — re-link this PC"; spool retained |
|
||||
| Server down, broker up | broker holds nothing (clean session); agent's PUBACKs still arrive from the broker, so events are acked at the broker — the server's own subscription reconnects and Mosquitto delivers what it queued for the persistent server session |
|
||||
| Duplicate delivery | absorbed by `source_event_id` uniqueness |
|
||||
| Malformed event | dropped with a log line naming the site and reason; never blocks the queue |
|
||||
| Site clock wrong | `occurred_at` > 24 h ahead rejected as permanent; feed ordering uses server `seq`, so a wrong clock cannot hide a visit |
|
||||
|
||||
---
|
||||
|
||||
## 5. Database schema (PostgreSQL + pgvector, migrations 001–013)
|
||||
|
||||
### 5.1 Tables
|
||||
|
||||
```
|
||||
clients id PK · slug UQ (immutable, = MQTT prefix) · name · active · visitor_seq bigint
|
||||
sites id PK · client_id FK · slug (immutable, per client) · name · timezone · address · active
|
||||
agents id PK · client_id FK · site_id FK · mqtt_username UQ · mqtt_password_enc bytea (sealed)
|
||||
api_token_hash bytea · agent_version · engine_version · recognition_model
|
||||
last_heartbeat_at · last_event_at · fraction_below_gate · cameras_total/up · spool_queued/dropped
|
||||
app_users id PK · client_id FK (NULL = platform admin) · email (lower(email) UQ globally, 007)
|
||||
password_hash (bcrypt 12) · full_name · role owner|manager|staff|admin · active · last_login_at
|
||||
sessions id PK · user_id FK · client_id FK · access_hash UQ · refresh_hash UQ (SHA-256)
|
||||
access_expires_at · refresh_expires_at · revoked_at · device · last_used_at
|
||||
invitations id PK · client_id FK · email · full_name · role · code_hash UQ · invited_by FK
|
||||
expires_at · used_at · used_by FK · revoked_at
|
||||
site_enrolment_tokens id PK · client_id FK · site_id FK · token_hash UQ · label · expires_at · used_at · created_by
|
||||
visitors id PK · client_id FK · number bigint (per-client, immutable → "V-42") · label
|
||||
first_seen_at · last_seen_at · visit_count · deleted_at
|
||||
visitor_embeddings id PK · visitor_id FK · client_id FK · model · embedding vector(512) · quality · source_site_id FK
|
||||
visitor_profiles id PK · visitor_id FK · client_id FK · full_name · phone · email · gender · date_of_birth · notes
|
||||
consents id PK · visitor_id FK · client_id FK · scope · method · granted_at · revoked_at · evidence jsonb
|
||||
visits id PK · client_id FK · site_id FK · visitor_id FK (nullable) · source_event_id (UQ per client)
|
||||
occurred_at · received_at · camera_id text · is_new_visitor · similarity · quality
|
||||
attributes jsonb · image_key · image_deleted_at · seq bigserial (feed cursor)
|
||||
purchases id PK · client_id FK · site_id FK · visitor_id FK · visit_id FK · amount numeric(14,2)
|
||||
currency char(3) · items jsonb · source · external_ref · recorded_by · occurred_at
|
||||
site_cameras id PK · client_id FK · site_id FK · camera_id text (immutable, UQ per site) · label
|
||||
host · port · path · username · password_enc bytea (sealed, aad = site_id) · max_width
|
||||
tuning jsonb · enabled · revision · connected (nullable) · last_seen_at · snapshot_key
|
||||
snapshot_at · deleted_at (tombstone) · check_kind · check_* (requested/started/finished/result/image_key)
|
||||
camera_snapshots camera_id PK FK · client_id FK · site_id FK · image bytea · bytes · captured_at
|
||||
visit_faces id PK · client_id FK · site_id FK · image bytea · bytes · captured_at (one survives per visitor)
|
||||
audit_log id bigserial PK · client_id FK (SET NULL) · actor_id · actor_kind · action · entity · entity_id · detail jsonb · at
|
||||
schema_migrations version · checksum · applied_at · baselined
|
||||
```
|
||||
|
||||
### 5.2 Relationships
|
||||
|
||||
```
|
||||
clients ─┬─< sites ─┬─< agents
|
||||
│ ├─< site_cameras ──< camera_snapshots (1:1, PK = camera_id)
|
||||
│ ├─< site_enrolment_tokens
|
||||
│ ├─< visits
|
||||
│ ├─< purchases
|
||||
│ └─< visit_faces
|
||||
├─< app_users ─┬─< sessions
|
||||
│ └─< invitations (invited_by, used_by)
|
||||
├─< visitors ─┬─< visitor_embeddings
|
||||
│ ├─< visitor_profiles
|
||||
│ ├─< consents
|
||||
│ ├─< visits
|
||||
│ └─< purchases
|
||||
└─< audit_log (SET NULL)
|
||||
|
||||
visits ──< purchases (visit_id, SET NULL)
|
||||
```
|
||||
|
||||
Every FK onto `clients` is `ON DELETE CASCADE` except `audit_log` (`SET NULL`). Every tenant-owned table carries `client_id` directly, so no query needs a join to enforce tenancy.
|
||||
|
||||
### 5.3 Invariants enforced in the database
|
||||
|
||||
- `007` — `lower(email)` globally unique; the migration refuses to apply while duplicates exist and names them.
|
||||
- `012` — `visitors.number` per-client sequence from `clients.visitor_seq` (`UPDATE … RETURNING`, row-locked); unique on `(client_id, number)`.
|
||||
- `013` — triggers refuse changes to `clients.slug`, `sites.slug`, `site_cameras.camera_id`, `visitors.number` (`BEFORE UPDATE OF … WHEN OLD IS DISTINCT FROM NEW`). Display names are deliberately not frozen.
|
||||
- `004` — `visits.seq bigserial`; the arrivals cursor is `v1:<seq>` base64, opaque to clients.
|
||||
- `sites_slug_format` — `^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$`.
|
||||
|
||||
---
|
||||
|
||||
## 6. API specification
|
||||
|
||||
Full request/response shapes: `API.md`. Machine-readable: `docs/openapi.yaml`.
|
||||
|
||||
### 6.1 Authentication and session lifecycle
|
||||
|
||||
```
|
||||
POST /api/auth/login {email,password,device}
|
||||
→ {access_token (12 h), refresh_token (30 d), expires_at, user{id,email,full_name,role,client_id,client_name}}
|
||||
tokens: 256-bit random; only SHA-256 stored; bcrypt cost 12 verify; DummyHash for unknown addresses
|
||||
POST /api/auth/refresh {refresh_token,device}
|
||||
→ same shape; BOTH rotate; old refresh invalid immediately; client must serialise and persist before use
|
||||
401 {error:"token_expired"} → refresh once and retry
|
||||
401 {error:"bad_credentials"} / {error:"unauthorized"} → sign in
|
||||
GET/DELETE /api/auth/sessions[/{id}] · POST /api/auth/sessions/revoke-others
|
||||
```
|
||||
|
||||
### 6.2 Route families and least role
|
||||
|
||||
| Family | Routes | Least role |
|
||||
|---|---|---|
|
||||
| Auth | login, refresh, logout, me, sessions | none / authed |
|
||||
| Joining | `GET /api/auth/invitation?code=`, `POST /api/auth/register` | none |
|
||||
| Team | `GET /api/team` | authed (tenant users) |
|
||||
| | `POST /api/team/members`, `POST /api/team/{id}/password`, `PATCH /api/team/{id}`, `/api/team/invitations*` | manager |
|
||||
| Arrivals | `GET /api/visits`, `GET /api/visits/stream` (SSE) | authed |
|
||||
| Customers | `GET /api/visitors`, `/history`, `/image`, `GET /api/faces/{id}` | authed |
|
||||
| | `PUT /api/visitors/{id}/profile`, `POST /api/purchases` | staff |
|
||||
| | `DELETE /api/visitors/{id}` (erasure) | manager |
|
||||
| Shops & cameras | `GET /api/sites`, `/check`, `GET /api/cameras`, `/snapshot.jpg`, `/live` (SSE) | authed |
|
||||
| | `POST /api/sites/{site}/cameras`, `PATCH`/`DELETE /api/cameras/{id}`, `POST /api/cameras/{id}/check`, `POST /api/sites/{site}/enrolment-code` | manager |
|
||||
| Reports | `GET /api/reports/footfall`, `/conversion` | authed |
|
||||
| Assistant | `POST /api/assistant` | authed |
|
||||
| Admin | `GET`/`POST /api/admin/clients` | platform admin (role admin AND no client) |
|
||||
| Agent | `POST /api/agent/enrol` (none), then `/api/agent/{cameras, checks, faces, upload-url, live, cameras/{c}/snapshot, cameras/{c}/live}` | agent token |
|
||||
|
||||
Identifiers: any `{id}` or `site` accepts a uuid **or** the human reference (`V-42`, `chennai`, `cam1`). Unknown reference in a path → 404; in a query filter → 400. Another tenant's data → 404, never 403.
|
||||
|
||||
### 6.3 Assistant tools (`internal/assistant/tools.go`)
|
||||
|
||||
`list_sites`, `site_health`, `footfall`, `conversion`, `find_customer`, `customer_history`, `check_camera` (manager+; refuses staff in the tool, not the prompt). No tool takes a tenant id; the Principal is bound at the call site. Business tools only — never `execute_sql`. Loop bounded at 8 iterations; text produced alongside a tool call is discarded; failing tools return results, not errors.
|
||||
|
||||
---
|
||||
|
||||
## 7. Deployment topology
|
||||
|
||||
```
|
||||
INTERNET
|
||||
│
|
||||
┌───────────────┼───────────────────┐
|
||||
│ HTTPS 443 │ │ TLS 8883
|
||||
┌────────▼─────────┐ │ ┌────────▼─────────┐
|
||||
│ Traefik │ │ │ Mosquitto │
|
||||
│ platform.loyaly │ │ │ mcp.loyaly.ai │
|
||||
│ mcp.loyaly.ai │ │ │ passwd + ACL │
|
||||
│ TLS termination │ │ │ pattern write │
|
||||
└────────┬─────────┘ │ │ bv/%u/# │
|
||||
│ :8080 plain │ └────────┬─────────┘
|
||||
┌────────▼───────────────────────┐ │ :1883 internal
|
||||
│ behavision-server (one binary) │◄───────────┘ subscribe bv/+/+
|
||||
│ ├ migrate (boot) │
|
||||
│ ├ ingest consumer → Hub │
|
||||
│ ├ API (48 routes) → SSE │
|
||||
│ ├ LiveHub (camera relay) │
|
||||
│ ├ web (embedded React) │
|
||||
│ └ assistant (optional) │
|
||||
└────────┬───────────────┬───────┘
|
||||
│ │ presigned PUT/GET (optional)
|
||||
┌────────▼─────────┐ ┌──▼──────────────────┐
|
||||
│ PostgreSQL 16 │ │ Object storage │
|
||||
│ + pgvector │ │ (S3-compatible) │
|
||||
│ 17 tables │ │ private ACL │
|
||||
└──────────────────┘ └─────────────────────┘
|
||||
|
||||
════════════════════════ trust boundary: no inbound route ════════════════════════
|
||||
|
||||
SHOP NETWORK (one per shop, behind NAT)
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Cameras ──RTSP/TCP──▶ Engine :8010 ──webhook──▶ Agent │
|
||||
│ │ SQLite │ spool │
|
||||
│ │ FAISS │ │
|
||||
│ Shop app ◄─── relay ───────┘ │
|
||||
│ │
|
||||
│ outbound only: agent ──TLS 8883──▶ Mosquitto │
|
||||
│ agent ──HTTPS────▶ /api/agent/* │
|
||||
│ app ──HTTPS────▶ /api/* │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Failure domains.** A shop PC failing affects one shop; its footfall queues locally and nothing else notices except the heartbeat. Mosquitto failing stops delivery for all shops but loses nothing (every event is on a shop's disk). The server failing stops the console and API; Mosquitto retains the server's subscription backlog. PostgreSQL is the single stateful component in the cloud tier.
|
||||
|
||||
**Data residency.** Video never leaves the shop. Face templates leave the shop only as 512-float vectors inside visit events, over TLS, to the tenant's own prefix. Photographs leave only when `store_faces` is enabled, via presigned upload to a private object, or into Postgres when no bucket is configured. Every image read at head office is audited.
|
||||
|
||||
**Encrypted paths.** Camera credentials: DPAPI on the shop PC, AES-256-GCM (aad = site) in Postgres, plaintext only inside the agent's process and on the LAN RTSP connection to the camera. Broker password: sealed in `agent.json`, sealed in `agents.mqtt_password_enc`, hashed in Mosquitto's `passwd`. Sessions: SHA-256 at rest. User passwords: bcrypt 12.
|
||||
|
||||
---
|
||||
|
||||
## 8. Measured
|
||||
|
||||
| Metric | Value | Source |
|
||||
|---|---|---|
|
||||
| Identity stability | 103 tracks → 7 people, 44 re-recognitions, 5 min | engine `/api/stats`, office cam2 |
|
||||
| Same-person similarity | p05 0.719 (frontal webcam, 18,528 pairs) | `calibrate` |
|
||||
| Gallery search | 0.27 / 2.24 / 21.9 ms at 1k / 10k / 100k | `VectorIndex` benchmark |
|
||||
| Delivery under burst | 120 of 120 simultaneous visits | live broker + Postgres |
|
||||
| Camera → head office | ~3 s | end-to-end run |
|
||||
| Head-office live view | 13 fps, 259 KB/s, 0 duplicates | relay measurement |
|
||||
| Shop-PC live picture | 14.0 pictures/s vs 15 fps camera; engine CPU 90% → 62% | before/after, cam2 sub-stream |
|
||||
| Clean-machine install | 10/10 steps, both cameras connected | fresh container |
|
||||
| Server test suite | < 10 s (bcrypt cost lowered for tests only) | `go test ./...` |
|
||||
@@ -1,11 +1,12 @@
|
||||
[project]
|
||||
name = "behavision"
|
||||
version = "1.1.0"
|
||||
dynamic = ["version"]
|
||||
description = "Production face recognition over RTSP"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"numpy>=1.26,<2.0",
|
||||
"opencv-python>=4.8.1",
|
||||
# See requirements.txt for why numpy is uncapped and opencv is not.
|
||||
"numpy>=1.26,<3.0",
|
||||
"opencv-python>=4.8.1,<5",
|
||||
"onnxruntime>=1.16",
|
||||
"fastapi>=0.110",
|
||||
"uvicorn>=0.29",
|
||||
@@ -21,7 +22,11 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0"]
|
||||
# httpx is test-only and never ships in the wheel. The HTTP tests begin with
|
||||
# `pytest.importorskip("httpx")` so a bare checkout still runs - which is
|
||||
# right, and has a cost worth knowing: without it the suite reports 239 passed
|
||||
# and quietly SKIPS 32 API tests. `pip install -e .[dev]` is how to get them.
|
||||
dev = ["pytest>=8.0", "httpx>=0.27"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["behavision*"]
|
||||
@@ -31,3 +36,9 @@ behavision = ["static/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
# One version for the package and the wheel. Two literals in two files is how
|
||||
# they came to disagree (1.0.0 here, 1.1.0 there) and how neither tracked a
|
||||
# release.
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "behavision.__version__"}
|
||||
|
||||
21
release.sh
21
release.sh
@@ -50,7 +50,28 @@ step "2. Agent and setup tool"
|
||||
step "3. Engine source and wheel"
|
||||
# The wheel is built with the checkout's own interpreter; requires-python is a
|
||||
# statement about the SHOP PC, which setup enforces when it finds Python there.
|
||||
# Stamp the tag into the wheel version. Without this every release built
|
||||
# behavision-1.1.0-py3-none-any.whl, and `pip install --upgrade` on a machine
|
||||
# that already had 1.1.0 is a no-op - so an engine fix reached nobody who had
|
||||
# ever run setup, while the Go binaries beside it updated normally. Nothing
|
||||
# reported a version either, so there was no way to tell a shop PC three
|
||||
# releases behind from a current one.
|
||||
#
|
||||
# v0.5.6-demo -> 0.5.6+demo, which is valid PEP 440: a local segment takes
|
||||
# alphanumerics and dots, never hyphens.
|
||||
TMPVER=$(mktemp)
|
||||
PEP440=$(printf '%s' "${TAG#v}" | sed 's/-/+/; s/[^0-9A-Za-z.+]/./g')
|
||||
trap 'git checkout -- behavision/__init__.py 2>/dev/null || true; rm -f "$TMPVER"' EXIT
|
||||
# A temp file rather than `sed -i`, whose argument handling differs between
|
||||
# BSD and GNU - this script is run from a Mac today and that is not a reason
|
||||
# to plant a portability trap in a release path.
|
||||
sed "s/^__version__ = .*/__version__ = \"$PEP440\"/" behavision/__init__.py > "$TMPVER"
|
||||
cat "$TMPVER" > behavision/__init__.py
|
||||
.venv/bin/python -m pip wheel --no-deps --ignore-requires-python -q -w "$STAGE/engine-src" . 2>&1 | grep -v "DEPRECATION\|WARNING: Ignoring" || true
|
||||
git checkout -- behavision/__init__.py
|
||||
rm -f "$TMPVER"
|
||||
trap - EXIT
|
||||
echo " engine wheel version $PEP440"
|
||||
ls "$STAGE"/engine-src/behavision-*.whl >/dev/null || { echo "wheel was not built" >&2; exit 1; }
|
||||
cp pyproject.toml requirements.txt "$STAGE/engine-src/"
|
||||
mkdir -p "$STAGE/engine-src/config" && cp config/default.yaml "$STAGE/engine-src/config/"
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
numpy>=1.26,<2.0
|
||||
opencv-python>=4.8.1
|
||||
# numpy 2 is allowed, and that is what lets this install on a current Python.
|
||||
# `<2.0` capped the resolver at numpy 1.26.4, whose newest wheel is cp312, so
|
||||
# on a Mac with Python 3.14 pip fell back to BUILDING numpy from source and
|
||||
# died in clang - two screens of C compiler output on a shop counter, for a
|
||||
# version choice made silently by behavision-setup.
|
||||
#
|
||||
# Measured before changing it, nine runs of the detector guard per combination
|
||||
# on one machine:
|
||||
#
|
||||
# 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 there; OpenCV is. The crash was a test racing a
|
||||
# shared cv2.FaceDetectorYN on purpose - undefined behaviour in C++, which 4.11
|
||||
# usually turned into an exception and 4.14 usually turns into a segfault. The
|
||||
# product never shares one (Engine._build_worker builds a detector per camera),
|
||||
# and the test now runs that race out of process. With that fixed, the whole
|
||||
# suite is 226 passed / 2 skipped on numpy 2.0.2, five runs out of five.
|
||||
#
|
||||
# opencv IS capped, and the two are not the same call. Everything above was
|
||||
# measured on 4.x; OpenCV 5.0 is a major release this project has never run a
|
||||
# real camera or an emotion model through, and an uncapped `>=4.8.1` means
|
||||
# every NEW install silently gets it while every existing one keeps 4.11. Lift
|
||||
# it after running a camera on 5.x, not before.
|
||||
numpy>=1.26,<3.0
|
||||
opencv-python>=4.8.1,<5
|
||||
onnxruntime>=1.16
|
||||
fastapi>=0.110
|
||||
uvicorn>=0.29
|
||||
|
||||
96
server/internal/api/camera_state_test.go
Normal file
96
server/internal/api/camera_state_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ptr(b bool) *bool { return &b }
|
||||
|
||||
// The state this was written for. Two cameras read "Connected", in green, on
|
||||
// the live estate thirty-four minutes after the shop computer had stopped
|
||||
// being able to see either of them - because the agent correctly reports
|
||||
// nothing when it cannot reach the engine, and the last value it sent stays
|
||||
// in the database looking current.
|
||||
func TestAStaleReportIsNotAConnectedCamera(t *testing.T) {
|
||||
now := time.Date(2026, 9, 30, 11, 13, 0, 0, time.UTC)
|
||||
c := Camera{Connected: ptr(true),
|
||||
LastSeenAt: now.Add(-34 * time.Minute).Format(time.RFC3339)}
|
||||
c.CameraState(now)
|
||||
|
||||
if c.State != CameraStale {
|
||||
t.Errorf("state = %q, want %q", c.State, CameraStale)
|
||||
}
|
||||
// Cleared, not merely overruled. A stale true left in place stays
|
||||
// available to every client that reads the field directly, and leaves two
|
||||
// fields on one object disagreeing.
|
||||
if c.Connected != nil {
|
||||
t.Errorf("connected = %v, want null - nobody currently knows", *c.Connected)
|
||||
}
|
||||
if c.StateNote == "" {
|
||||
t.Error("a stale camera said nothing about what to do")
|
||||
}
|
||||
}
|
||||
|
||||
// One missed report is a dropped packet. Warning on it would put an alarm on a
|
||||
// healthy estate every few minutes, and an indicator that cries wolf is one
|
||||
// people learn to ignore.
|
||||
func TestOneMissedReportIsStillConnected(t *testing.T) {
|
||||
now := time.Now()
|
||||
c := Camera{Connected: ptr(true),
|
||||
LastSeenAt: now.Add(-90 * time.Second).Format(time.RFC3339)}
|
||||
c.CameraState(now)
|
||||
if c.State != CameraConnected {
|
||||
t.Errorf("state = %q after 90s, want %q", c.State, CameraConnected)
|
||||
}
|
||||
if c.Connected == nil || !*c.Connected {
|
||||
t.Error("a fresh report lost its connected flag")
|
||||
}
|
||||
}
|
||||
|
||||
// "Nobody has ever told us" and "nobody has told us lately" need different
|
||||
// sentences: the first is a camera head office added a minute ago and the
|
||||
// shop computer has not picked up, the second is a shop computer that has
|
||||
// stopped. Sending an installer to the wrong one wastes a journey.
|
||||
func TestNeverReportedIsNotTheSameAsStopped(t *testing.T) {
|
||||
now := time.Now()
|
||||
var never Camera
|
||||
never.CameraState(now)
|
||||
if never.State != CameraWaiting {
|
||||
t.Errorf("state = %q, want %q", never.State, CameraWaiting)
|
||||
}
|
||||
|
||||
stopped := Camera{LastSeenAt: now.Add(-time.Hour).Format(time.RFC3339)}
|
||||
stopped.CameraState(now)
|
||||
if stopped.State == never.State {
|
||||
t.Fatal("a camera nobody has reported and one that stopped read the same")
|
||||
}
|
||||
if stopped.StateNote == never.StateNote {
|
||||
t.Error("two states that need different actions gave the same advice")
|
||||
}
|
||||
}
|
||||
|
||||
// A camera the shop computer CAN see and cannot open is the one case where
|
||||
// "check the cabling" is the right advice, and it must stay distinguishable
|
||||
// from the three where it is not.
|
||||
func TestAFreshFailureSaysCheckTheCamera(t *testing.T) {
|
||||
now := time.Now()
|
||||
c := Camera{Connected: ptr(false), LastSeenAt: now.Format(time.RFC3339)}
|
||||
c.CameraState(now)
|
||||
if c.State != CameraNotConnect {
|
||||
t.Errorf("state = %q, want %q", c.State, CameraNotConnect)
|
||||
}
|
||||
if c.Connected == nil || *c.Connected {
|
||||
t.Error("a reported failure must stay false, not become unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// An unparseable timestamp is not a working camera. It should not be possible,
|
||||
// which is exactly why it must not fall through to "connected".
|
||||
func TestAnUnreadableTimestampIsStale(t *testing.T) {
|
||||
c := Camera{Connected: ptr(true), LastSeenAt: "not a time"}
|
||||
c.CameraState(time.Now())
|
||||
if c.State != CameraStale || c.Connected != nil {
|
||||
t.Errorf("state = %q connected = %v, want stale and unknown", c.State, c.Connected)
|
||||
}
|
||||
}
|
||||
@@ -655,6 +655,11 @@ type Camera struct {
|
||||
Snapshot Image `json:"snapshot"`
|
||||
SnapshotAt string `json:"snapshot_at,omitempty"`
|
||||
|
||||
// State is the ONE answer a screen should render, because there are four
|
||||
// of them and only three were ever expressed. See CameraState.
|
||||
State string `json:"state"`
|
||||
StateNote string `json:"state_note,omitempty"`
|
||||
|
||||
// Check is the last attempt to prove this camera works. Always present so
|
||||
// a client can tell "never checked" from "checked and failed" without
|
||||
// guessing from an absent field.
|
||||
@@ -962,3 +967,70 @@ type DeviceSession struct {
|
||||
// hand.
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// Camera states, and why a fourth one had to exist.
|
||||
//
|
||||
// A shop computer reports each camera's state about once a minute. When it
|
||||
// cannot reach the recognition engine it reports NOTHING - correctly, because
|
||||
// it has nothing to say - and the last value it sent stays in the database
|
||||
// unchanged. Measured on the live estate: two cameras reading **Connected**,
|
||||
// in green, thirty-four minutes after the shop computer had stopped being
|
||||
// able to see either of them, while the heartbeat from the same PC said
|
||||
// 0 of 0 cameras. Both surfaces were reading stored fields and disagreeing.
|
||||
//
|
||||
// `false` could not be the answer. It means "this camera is not connecting",
|
||||
// which sends an installer to check cabling on a camera that was working
|
||||
// perfectly the last time anybody could ask it. The honest statement is that
|
||||
// nobody currently knows - and that is a different sentence from "nobody has
|
||||
// ever told us", which is what an unreported camera needs. Two states that
|
||||
// need different actions must never share a word; the same rule that keeps
|
||||
// `artifact` apart from `no_faces` in the commissioning verdicts.
|
||||
const (
|
||||
CameraConnected = "connected" // reported recently, and working
|
||||
CameraNotConnect = "not_connecting" // reported recently, and not
|
||||
CameraWaiting = "waiting" // no shop computer has ever reported
|
||||
CameraStale = "stale" // reported once, and not lately
|
||||
)
|
||||
|
||||
// CameraStaleAfter is five missed reports, not one.
|
||||
//
|
||||
// The agent reports on a 60 s tick, so one miss is a dropped packet or a slow
|
||||
// upload. Calling that stale would put a warning on a healthy estate every few
|
||||
// minutes, and an indicator that cries wolf is one people learn to ignore -
|
||||
// which is the same reasoning that makes a site offline after three missed
|
||||
// heartbeats rather than one.
|
||||
const CameraStaleAfter = 5 * time.Minute
|
||||
|
||||
// CameraState decides the four states, and nulls Connected when it is not
|
||||
// entitled to an opinion.
|
||||
//
|
||||
// Connected is CLEARED rather than left alone on purpose. Leaving a stale true
|
||||
// in place would keep the lie available to every client that reads the field
|
||||
// directly - a mobile app, a script, an older build of our own desktop app -
|
||||
// and would leave two fields on one object disagreeing, which is precisely how
|
||||
// the shops screen once came out labelled Working in green above "2 of 3
|
||||
// cameras not connecting".
|
||||
func (c *Camera) CameraState(now time.Time) {
|
||||
switch {
|
||||
case c.LastSeenAt == "":
|
||||
c.State = CameraWaiting
|
||||
c.StateNote = "No shop computer has reported on this camera yet."
|
||||
c.Connected = nil
|
||||
return
|
||||
}
|
||||
seen, err := time.Parse(time.RFC3339, c.LastSeenAt)
|
||||
if err != nil || now.Sub(seen) > CameraStaleAfter {
|
||||
c.State = CameraStale
|
||||
c.StateNote = "The shop computer has stopped reporting this camera. " +
|
||||
"Check that the computer is on and Behavision is running on it."
|
||||
c.Connected = nil
|
||||
return
|
||||
}
|
||||
if c.Connected != nil && *c.Connected {
|
||||
c.State = CameraConnected
|
||||
return
|
||||
}
|
||||
c.State = CameraNotConnect
|
||||
c.StateNote = "The shop computer cannot open this camera's stream. " +
|
||||
"Check the address, the password and the cabling."
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ func scanCamera(row pgx.Row) (api.Camera, error) {
|
||||
// The KEY travels in ImageKey, which is json:"-", and the handler swaps it
|
||||
// for a signed link. Same rule as an arrival's face.
|
||||
c.Snapshot.Key = snapKey
|
||||
// Here rather than in a handler, because every camera anybody reads comes
|
||||
// through this function and a state computed per caller is a state one
|
||||
// caller forgets - which is how a stale `connected` reached three screens
|
||||
// at once.
|
||||
c.CameraState(time.Now())
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
server/internal/web/dist/index.html
vendored
2
server/internal/web/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-Ckr5hGZd.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BAlXnnln.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -79,3 +79,48 @@ export const MAKES = [
|
||||
]
|
||||
|
||||
export const makeById = (id) => MAKES.find(m => m.id === id) || MAKES[MAKES.length - 1]
|
||||
|
||||
// Paste the camera's RTSP URL, rather than taking it apart by hand.
|
||||
//
|
||||
// The engine has always accepted a whole URL (`CameraConfig.url` wins over the
|
||||
// parts) and no form has ever offered one - the same gap as the webcam option,
|
||||
// and it costs more here. A URL is how people actually HAVE this information:
|
||||
// it is what the camera's own app shows, what an installer writes down and
|
||||
// what gets pasted into a message. Splitting it into five fields by eye is
|
||||
// where a password containing `@` or `/` goes wrong, and this repository
|
||||
// already records a whole class of bug from unencoded `@` in RTSP credentials.
|
||||
//
|
||||
// Split into fields rather than stored whole, deliberately: everything else on
|
||||
// the form - Test, the make picker, editing later, and the rule that a
|
||||
// password is never returned to the browser - works on the parts. A URL kept
|
||||
// intact would carry the password back out to every screen that reads a
|
||||
// camera.
|
||||
export function parseRtspUrl(raw) {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return null
|
||||
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text)
|
||||
// A bare `host/path` is a reasonable thing to paste, so the scheme is
|
||||
// optional - but something has to mark this as a URL rather than a word.
|
||||
// Without the slash test, `nonsense` parses as a perfectly good hostname
|
||||
// and silently fills the Address field with it: a wrong answer that looks
|
||||
// like it worked, which is worse than refusing.
|
||||
if (!hasScheme && !text.includes('/')) return null
|
||||
const withScheme = hasScheme ? text : 'rtsp://' + text
|
||||
let u
|
||||
try {
|
||||
u = new URL(withScheme)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!u.hostname) return null
|
||||
// WHATWG splits user info at the LAST `@`, which is what makes an unencoded
|
||||
// `@` inside a password parse the way a person means it.
|
||||
const out = {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 554,
|
||||
path: (u.pathname || '') + (u.search || '') || '/',
|
||||
username: decodeURIComponent(u.username || ''),
|
||||
password: decodeURIComponent(u.password || ''),
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -225,3 +225,42 @@ def test_an_inverted_per_camera_pair_is_rejected_not_stored(client):
|
||||
tuning={"enroll_threshold": 0.8, "match_threshold": 0.5})
|
||||
assert r.status_code == 400
|
||||
assert c.get("/api/cameras").json() == []
|
||||
|
||||
|
||||
# "This computer's own camera" — the demo case, and a capability the engine
|
||||
# has always had with nothing able to reach it.
|
||||
#
|
||||
# It matters most for showing the product to somebody. A laptop's own camera
|
||||
# gives real recognition, of real faces, in the room, depending on no network
|
||||
# at all — where pointing a demo machine at a camera in another building
|
||||
# depends on two internet connections and a tunnel staying up while you talk.
|
||||
def test_this_computers_own_camera_can_be_added(client):
|
||||
c, eng = client
|
||||
r = c.post("/api/cameras", json={"id": "laptop", "webcam": 0})
|
||||
assert r.status_code == 201, r.text
|
||||
assert "laptop" in eng.workers
|
||||
|
||||
cam = next(x for x in c.get("/api/cameras").json() if x["id"] == "laptop")
|
||||
# Returned, or the form cannot tell a webcam camera from a half-filled
|
||||
# RTSP one when somebody opens it to edit.
|
||||
assert cam["webcam"] == 0
|
||||
assert eng.camera_store.get("laptop").source() == 0
|
||||
|
||||
|
||||
def test_a_second_camera_index_is_kept(client):
|
||||
"""0 is the built-in one; a plugged-in camera is usually 1. An index
|
||||
silently coerced to 0 would open the wrong camera and look like the
|
||||
setting had no effect."""
|
||||
c, eng = client
|
||||
assert c.post("/api/cameras", json={"id": "usb", "webcam": 1}).status_code == 201
|
||||
assert eng.camera_store.get("usb").source() == 1
|
||||
|
||||
|
||||
def test_a_webcam_camera_survives_a_reload(client, tmp_path):
|
||||
"""It has to be on disk, not only in the running engine: a demo that
|
||||
forgets its camera when the app restarts is worse than no demo."""
|
||||
c, _ = client
|
||||
c.post("/api/cameras", json={"id": "laptop", "webcam": 0})
|
||||
again = CameraStore(tmp_path / "cameras.json").get("laptop")
|
||||
assert again.source() == 0
|
||||
assert again.safe_url() == "webcam:0"
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
"""cv2.FaceDetectorYN caches its input size and is not thread-safe, so camera
|
||||
workers must not share one. Skipped when the model is absent, matching the
|
||||
faiss-optional pattern in test_index.py — the suite stays runnable with no
|
||||
models installed."""
|
||||
models installed.
|
||||
|
||||
The premise is proved in a SUBPROCESS, and that is the whole lesson of this
|
||||
file. The first version raced a shared detector in-process and asserted that
|
||||
an exception came back, because on OpenCV 4.11 one usually did. It is a data
|
||||
race in C++: what it produces is undefined, and on 4.14 what it mostly
|
||||
produces is a segmentation fault. Measured, nine runs each, same machine:
|
||||
|
||||
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, and 4.11 crashing once says the hazard
|
||||
was always there and 4.11 merely survived it. A test that takes the whole
|
||||
suite down two runs in three is worse than no test: it turns "we upgraded
|
||||
OpenCV" into a CI failure with no failing assertion in it, which is the
|
||||
hardest kind to read.
|
||||
|
||||
None of this reaches the product. `Engine._build_worker` constructs a
|
||||
FaceDetector per camera, which is what the rule says and what the second test
|
||||
here guards.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
@@ -11,10 +36,16 @@ import pytest
|
||||
from behavision.config import Config
|
||||
from behavision.detection import YUNET_FILENAME, FaceDetector
|
||||
|
||||
MODELS = Path(__file__).resolve().parent.parent / "models"
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MODELS = ROOT / "models"
|
||||
pytestmark = pytest.mark.skipif(not (MODELS / YUNET_FILENAME).exists(),
|
||||
reason="YuNet model not installed")
|
||||
|
||||
# A shared detector fails probabilistically, so one clean attempt proves
|
||||
# nothing. Several do: the premise holds if ANY attempt misbehaves, and only
|
||||
# an unbroken run of clean ones is evidence it has stopped being true.
|
||||
ATTEMPTS = 6
|
||||
|
||||
|
||||
def _detector():
|
||||
d = Config().detection
|
||||
@@ -44,11 +75,37 @@ def _race(det_a, det_b):
|
||||
return errors
|
||||
|
||||
|
||||
_SHARED_RACE = textwrap.dedent("""
|
||||
import sys
|
||||
sys.path.insert(0, {root!r})
|
||||
from tests.test_detector_concurrency import _detector, _race
|
||||
shared = _detector()
|
||||
print("raced" if _race(shared, shared) else "clean")
|
||||
""")
|
||||
|
||||
|
||||
def _shared_race_outcome():
|
||||
"""Run one shared-detector race out of process.
|
||||
|
||||
Returns "raced" (an exception came back), "crashed" (the process died,
|
||||
which is the same premise arriving by a blunter route) or "clean".
|
||||
"""
|
||||
proc = subprocess.run([sys.executable, "-c", _SHARED_RACE.format(root=str(ROOT))],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
if proc.returncode != 0:
|
||||
return "crashed"
|
||||
return proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else "clean"
|
||||
|
||||
|
||||
def test_a_shared_detector_really_does_race():
|
||||
"""Guards the premise: if this ever stops failing, the test below is
|
||||
proving nothing and the per-camera split can be revisited."""
|
||||
shared = _detector()
|
||||
assert _race(shared, shared), "expected a shared detector to race"
|
||||
seen = [_shared_race_outcome() for _ in range(ATTEMPTS)]
|
||||
assert any(o != "clean" for o in seen), (
|
||||
f"a shared detector survived {ATTEMPTS} races ({seen}) - if that is "
|
||||
"reproducible, cv2.FaceDetectorYN may have become thread-safe and the "
|
||||
"per-camera rule in CLAUDE.md can be revisited"
|
||||
)
|
||||
|
||||
|
||||
def test_per_camera_detectors_do_not_race():
|
||||
|
||||
164
tests/test_model_download.py
Normal file
164
tests/test_model_download.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Downloading the models is the last step of every fresh install, and it ran
|
||||
into the one macOS trap nothing else here does.
|
||||
|
||||
A python.org macOS build ships its own OpenSSL with NO trust store, and
|
||||
populates one only when somebody double-clicks `Install Certificates.command`
|
||||
in the Python folder. Measured on a colleague's Mac: the engine installed
|
||||
perfectly - numpy, onnxruntime, faiss, all of it - and then could not fetch a
|
||||
230 KB model file, ending setup in forty lines of traceback about `_ssl.c`.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from behavision import model_assets
|
||||
|
||||
|
||||
class _Resp(io.BytesIO):
|
||||
"""Enough of an http response for _fetch: read() and .headers."""
|
||||
|
||||
def __init__(self, payload: bytes):
|
||||
super().__init__(payload)
|
||||
self.headers = {"Content-Length": str(len(payload))}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
|
||||
def _verify_error():
|
||||
"""What urlopen ACTUALLY raises, which is not what it looks like.
|
||||
|
||||
urllib catches ssl.SSLCertVerificationError and re-raises
|
||||
urllib.error.URLError(err), carrying the original on `.reason`. The first
|
||||
version of this test raised the bare SSL error - a shape real urllib never
|
||||
produces - so it passed against a fallback that could never fire, and the
|
||||
fix shipped and failed on the machine it was written for with the exact
|
||||
traceback it was meant to prevent.
|
||||
"""
|
||||
return urllib.error.URLError(ssl.SSLCertVerificationError(
|
||||
"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: "
|
||||
"unable to get local issuer certificate"))
|
||||
|
||||
|
||||
def test_a_machine_with_no_trust_store_falls_back_to_the_bundled_one(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake(url, timeout=None, context=None):
|
||||
calls.append(context)
|
||||
if context is None:
|
||||
raise _verify_error()
|
||||
return _Resp(b"ok")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake)
|
||||
with model_assets._urlopen("https://example.invalid/m.onnx") as resp:
|
||||
assert resp.read() == b"ok"
|
||||
|
||||
assert len(calls) == 2, f"expected a retry, got {calls}"
|
||||
# Default FIRST, and that order is the point. On Windows and on a system
|
||||
# Python the default context reads the machine's own certificate store,
|
||||
# which is what makes a corporate proxy with its own root CA work.
|
||||
# Replacing it unconditionally would break every site that has one in
|
||||
# order to fix a different platform.
|
||||
assert calls[0] is None
|
||||
assert isinstance(calls[1], ssl.SSLContext)
|
||||
|
||||
|
||||
def test_a_working_trust_store_is_used_as_is(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake(url, timeout=None, context=None):
|
||||
calls.append(context)
|
||||
return _Resp(b"ok")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake)
|
||||
model_assets._urlopen("https://example.invalid/m.onnx").close()
|
||||
assert calls == [None], "the machine's own certificate store was bypassed"
|
||||
|
||||
|
||||
def test_a_real_network_failure_is_not_disguised_as_a_certificate_problem(monkeypatch):
|
||||
"""A URLError is not automatically a certificate problem.
|
||||
|
||||
"No route to host" and "connection refused" arrive as URLError too, and
|
||||
retrying those with a different CA list changes nothing except how long
|
||||
the operator waits for the real message.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def fake(url, timeout=None, context=None):
|
||||
calls.append(context)
|
||||
raise urllib.error.URLError("no route to host")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake)
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
model_assets._urlopen("https://example.invalid/m.onnx")
|
||||
assert calls == [None], f"a dead network was retried as a CA problem: {calls}"
|
||||
|
||||
|
||||
def test_progress_lines_survive_the_rewrite(tmp_path, monkeypatch, caplog):
|
||||
"""`download: <label> <n>%` is a CONTRACT, not logging.
|
||||
|
||||
The supervisor parses it (progressRe) to put first-run progress in the
|
||||
tray and the window, because the API is not up yet and a shop PC showing
|
||||
a stopped engine for five minutes after install looks broken. Switching
|
||||
off urlretrieve - needed because it offers no way to pass an SSL context -
|
||||
is exactly the kind of change that drops it silently.
|
||||
"""
|
||||
payload = b"x" * (64 * 1024 * 8)
|
||||
monkeypatch.setattr(urllib.request, "urlopen",
|
||||
lambda url, timeout=None, context=None: _Resp(payload))
|
||||
dest = tmp_path / "model.onnx"
|
||||
with caplog.at_level(logging.INFO, logger="behavision.model_assets"):
|
||||
model_assets._fetch("https://example.invalid/m.onnx", dest, "face detector")
|
||||
|
||||
assert dest.read_bytes() == payload
|
||||
lines = [r.getMessage() for r in caplog.records]
|
||||
pct = [ln for ln in lines if ln.startswith("download: face detector ")]
|
||||
assert pct, f"no progress lines at all: {lines}"
|
||||
assert "download: face detector 100%" in pct, pct
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("BEHAVISION_NETWORK_TESTS"),
|
||||
reason="set BEHAVISION_NETWORK_TESTS=1 to reach github.com")
|
||||
def test_against_a_real_machine_with_no_trust_store(tmp_path, monkeypatch):
|
||||
"""The whole thing, over the real network, with the real failure.
|
||||
|
||||
Every stub above is a statement about what I believe urllib does, and the
|
||||
first version of this file proved how much that is worth. Python's default
|
||||
context honours SSL_CERT_FILE, so pointing it at an EMPTY file reproduces
|
||||
a python.org macOS build exactly: a default context that trusts nobody.
|
||||
certifi is loaded by path and is unaffected by the variable, so if the
|
||||
fallback works here it works there.
|
||||
"""
|
||||
empty = tmp_path / "no-cas.pem"
|
||||
empty.write_text("")
|
||||
monkeypatch.setenv("SSL_CERT_FILE", str(empty))
|
||||
monkeypatch.setenv("SSL_CERT_DIR", str(tmp_path / "nothing"))
|
||||
|
||||
# Not every interpreter can be put into that state, and pretending
|
||||
# otherwise would turn this into a test that passes by not running. A
|
||||
# Python linked against LibreSSL - which is what macOS Command Line Tools
|
||||
# ships - reads the system keychain and ignores SSL_CERT_FILE entirely:
|
||||
# measured, 128 CAs loaded with the variable pointing at an empty file. A
|
||||
# python.org build links OpenSSL and honours it: 0 CAs, which is the
|
||||
# condition being reproduced.
|
||||
if ssl.create_default_context().get_ca_certs():
|
||||
pytest.skip("this interpreter ignores SSL_CERT_FILE (%s), so the "
|
||||
"no-trust-store condition cannot be reproduced here"
|
||||
% ssl.OPENSSL_VERSION)
|
||||
|
||||
# The premise: the default context really is broken in this process.
|
||||
with pytest.raises(urllib.error.URLError) as caught:
|
||||
urllib.request.urlopen(model_assets.YUNET_URL, timeout=30)
|
||||
assert model_assets._is_cert_failure(caught.value), caught.value
|
||||
|
||||
dest = tmp_path / "yunet.onnx"
|
||||
model_assets._fetch(model_assets.YUNET_URL, dest, "face detector")
|
||||
assert dest.stat().st_size > 200_000
|
||||
91
tests/test_rtsp_paste.py
Normal file
91
tests/test_rtsp_paste.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""`shared/cameraMakes.js` is imported by BOTH camera forms and has no test
|
||||
runner of its own. This is the same safety net `test_dashboard.py` provides:
|
||||
node is driven from pytest and the check is skipped when node is absent, so
|
||||
the suite stays dependency-light.
|
||||
|
||||
What it guards is the field people actually have. The engine has always
|
||||
accepted a whole RTSP URL (`CameraConfig.url` wins over the parts) and no form
|
||||
ever offered one, so an operator holding the address their camera's own app
|
||||
shows had to take it apart into five fields by eye — which is exactly where a
|
||||
password containing `@` goes wrong, a class of bug this repository has already
|
||||
been bitten by once.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SHARED = ROOT / "shared" / "cameraMakes.js"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None,
|
||||
reason="node not installed")
|
||||
|
||||
|
||||
def parse(text):
|
||||
out = subprocess.run(
|
||||
["node", "--input-type=module", "-e",
|
||||
f"import {{ parseRtspUrl }} from {json.dumps(str(SHARED))};"
|
||||
f"console.log(JSON.stringify(parseRtspUrl({json.dumps(text)})))"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout.strip())
|
||||
|
||||
|
||||
def test_a_plain_url_becomes_the_five_fields():
|
||||
assert parse("rtsp://admin:Pass123@192.168.1.121:554/ch0_0.264") == {
|
||||
"host": "192.168.1.121", "port": 554, "path": "/ch0_0.264",
|
||||
"username": "admin", "password": "Pass123"}
|
||||
|
||||
|
||||
def test_an_at_sign_in_the_password_survives():
|
||||
"""The one that matters. A URL is split at the LAST `@`, which is what
|
||||
makes an unencoded `@` inside a password parse the way a person means it -
|
||||
and an operator splitting this by eye would put `p` in the password box
|
||||
and `ssw0rd@192.168.1.121` in the address box."""
|
||||
got = parse("rtsp://admin:p@ssw0rd@192.168.1.121:554/Streaming/Channels/101")
|
||||
assert got["password"] == "p@ssw0rd"
|
||||
assert got["host"] == "192.168.1.121"
|
||||
|
||||
|
||||
def test_a_percent_encoded_password_is_decoded():
|
||||
"""Stored decoded, because CameraConfig.source() percent-encodes when it
|
||||
rebuilds the URL. Keeping it encoded would double-encode it and the camera
|
||||
would refuse a password that is correct."""
|
||||
assert parse("rtsp://admin:p%40ss@10.0.0.5/live")["password"] == "p@ss"
|
||||
|
||||
|
||||
def test_the_default_port_is_filled_in():
|
||||
assert parse("rtsp://192.168.1.122/ch0_1.264")["port"] == 554
|
||||
|
||||
|
||||
def test_a_query_string_stays_with_the_path():
|
||||
"""Some cameras carry the channel in a query. Dropping it opens the wrong
|
||||
channel, which looks like a camera pointed somewhere unexpected."""
|
||||
assert parse("rtsp://cam.local:8554/live?channel=1")["path"] == "/live?channel=1"
|
||||
|
||||
|
||||
def test_a_scheme_is_optional_but_structure_is_not():
|
||||
assert parse("192.168.1.122/ch0_1.264")["host"] == "192.168.1.122"
|
||||
# A bare word parses as a perfectly good hostname, so without this it
|
||||
# would silently fill the Address field with it - a wrong answer that
|
||||
# looks like it worked, which is worse than refusing.
|
||||
assert parse("nonsense") is None
|
||||
assert parse("192.168.1.121") is None, "a bare address is not a URL; the Address field takes it"
|
||||
|
||||
|
||||
def test_nothing_is_nothing():
|
||||
assert parse("") is None
|
||||
assert parse(" ") is None
|
||||
|
||||
|
||||
def test_both_forms_import_it():
|
||||
"""Two copies of this would be worse than not offering it, because an
|
||||
operator trusts a filled-in field. Same rule as the make picker."""
|
||||
for form in (ROOT / "desktop/frontend/src/views/Cameras.jsx",
|
||||
ROOT / "web/src/views/CameraSetup.jsx"):
|
||||
src = form.read_text(encoding="utf-8")
|
||||
assert "parseRtspUrl" in src, f"{form.name} does not offer the paste field"
|
||||
assert "cameraMakes.js" in src, f"{form.name} defines its own parser"
|
||||
81
tests/test_wrong_network.py
Normal file
81
tests/test_wrong_network.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Why a camera "will not connect", when the real answer is that the computer
|
||||
asking is in the wrong building.
|
||||
|
||||
Asked directly by the owner, about his own cameras, from his phone's
|
||||
connection: *"when i connect from my mobile internet the cameras wont connect,
|
||||
why is that"*. The answer was `cannot reach 192.168.1.121:554 - Operation
|
||||
timed out`, which reads as a broken camera and sends somebody to re-type an
|
||||
address and a password that were always correct.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from behavision import capture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def on(monkeypatch):
|
||||
def _set(ip):
|
||||
monkeypatch.setattr(capture, "_local_ipv4", lambda: ip)
|
||||
return _set
|
||||
|
||||
|
||||
def test_a_different_network_says_so_and_says_what_to_do(on):
|
||||
on("10.11.12.13") # a phone's tethered network
|
||||
hint = capture._wrong_network_hint("192.168.1.121")
|
||||
assert "not the camera's network" in hint
|
||||
# The action, not just the diagnosis: no setting on this screen fixes it.
|
||||
assert "computer in the shop" in hint
|
||||
|
||||
|
||||
def test_the_same_network_sends_you_to_the_camera_instead(on):
|
||||
on("192.168.1.120")
|
||||
hint = capture._wrong_network_hint("192.168.1.121")
|
||||
assert "on that network" in hint
|
||||
assert "powered on" in hint
|
||||
# Two states that need opposite actions must not share a sentence.
|
||||
assert "not the camera's network" not in hint
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"8.8.8.8", # plainly routable
|
||||
"203.0.113.9", # TEST-NET-3: `is_private` calls this private, and it is
|
||||
# not a LAN address - which is why the check spells out
|
||||
# the RFC1918 blocks instead of asking is_private
|
||||
"100.64.0.5", # carrier-grade NAT, what a mobile network hands out
|
||||
])
|
||||
def test_an_address_that_is_not_a_lan_address_gets_no_hint(on, host):
|
||||
"""A routable address unreachable from here is an ordinary network fault,
|
||||
and inventing a story about private networks would be wrong."""
|
||||
on("192.168.1.120")
|
||||
assert capture._wrong_network_hint(host) == ""
|
||||
|
||||
|
||||
def test_a_name_gets_no_hint(on):
|
||||
"""Nothing can be concluded about `camera.local` from the string, and a
|
||||
guess here is a confident wrong answer in the place people look first."""
|
||||
on("192.168.1.120")
|
||||
assert capture._wrong_network_hint("camera.local") == ""
|
||||
|
||||
|
||||
def test_loopback_gets_no_hint(on):
|
||||
on("192.168.1.120")
|
||||
assert capture._wrong_network_hint("127.0.0.1") == ""
|
||||
|
||||
|
||||
def test_with_no_network_at_all_it_still_names_the_cause(on):
|
||||
"""A machine with no route cannot say which network it is on, and must not
|
||||
pretend: the private-address fact is still true and still the reason."""
|
||||
on("")
|
||||
hint = capture._wrong_network_hint("192.168.1.121")
|
||||
assert "private address" in hint
|
||||
assert "this computer is on" not in hint
|
||||
|
||||
|
||||
def test_the_hint_reaches_the_message_a_person_reads():
|
||||
"""The whole point is the sentence on the screen, not a helper nobody
|
||||
calls. Port 1 on a private address refuses or times out immediately."""
|
||||
ok, msg = capture._tcp_reachable("rtsp://192.168.1.121:1/ch0", 1.0)
|
||||
assert not ok
|
||||
assert "192.168.1.121" in msg
|
||||
# Whichever branch the OS takes, the explanation travels with it.
|
||||
assert "network" in msg
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { MAKES, makeById } from '../../../shared/cameraMakes.js'
|
||||
import { MAKES, makeById, parseRtspUrl } from '../../../shared/cameraMakes.js'
|
||||
|
||||
// Setting up a camera, for somebody who has never done it.
|
||||
//
|
||||
@@ -30,6 +30,25 @@ export default function CameraSetup({ sites, existing, onClose, onSaved }) {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
|
||||
|
||||
// Paste the whole RTSP address. It is how people actually hold this
|
||||
// information - it is what the camera's own app shows and what an installer
|
||||
// writes down - and splitting it into five fields by eye is where a
|
||||
// password containing `@` or `/` goes wrong.
|
||||
const [pasted, setPasted] = useState('')
|
||||
const [pasteError, setPasteError] = useState('')
|
||||
const applyUrl = (text) => {
|
||||
setPasted(text)
|
||||
if (!text.trim()) { setPasteError(''); return }
|
||||
const got = parseRtspUrl(text)
|
||||
if (!got) { setPasteError('That does not look like an RTSP address.'); return }
|
||||
setPasteError('')
|
||||
// Only what the URL actually carried: one with no credentials must not
|
||||
// wipe a password already typed.
|
||||
setForm(f => ({ ...f, make: 'manual', host: got.host, port: got.port, path: got.path,
|
||||
...(got.username ? { username: got.username } : {}),
|
||||
...(got.password ? { password: got.password } : {}) }))
|
||||
}
|
||||
const chooseMake = (e) => {
|
||||
const m = makeById(e.target.value)
|
||||
// Only overwrite the path when the preset has one, so choosing "I know the
|
||||
@@ -115,6 +134,14 @@ export default function CameraSetup({ sites, existing, onClose, onSaved }) {
|
||||
|
||||
{step === 1 && (
|
||||
<div className="drawer-body">
|
||||
<label>Paste the camera’s RTSP address, if you have one
|
||||
<input className="mono" value={pasted} onChange={(e) => applyUrl(e.target.value)}
|
||||
placeholder="rtsp://admin:password@192.168.0.138:554/ch0_0.264"
|
||||
autoComplete="off" name="rtsp-url" spellCheck="false" />
|
||||
<span className="hint">{pasteError
|
||||
? pasteError
|
||||
: 'Optional. Paste it and the fields below fill in; otherwise fill them in yourself.'}</span>
|
||||
</label>
|
||||
<label>Camera’s address on the shop’s network
|
||||
<input value={form.host} onChange={set('host')}
|
||||
placeholder="192.168.0.138" autoFocus />
|
||||
|
||||
@@ -47,9 +47,13 @@ export default function Cameras({ user }) {
|
||||
|
||||
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
|
||||
const list = cams || []
|
||||
const up = list.filter(c => c.connected).length
|
||||
const down = list.filter(c => c.connected === false).length
|
||||
const waiting = list.filter(c => c.connected == null).length
|
||||
// Counted off `state`, the one field the server computes, never off
|
||||
// `connected`. Two places deciding the same fact is how a shop came out
|
||||
// labelled Working, in green, above "2 of 3 cameras not connecting".
|
||||
const up = list.filter(c => c.state === 'connected').length
|
||||
const down = list.filter(c => c.state === 'not_connecting').length
|
||||
const waiting = list.filter(c => c.state === 'waiting').length
|
||||
const stale = list.filter(c => c.state === 'stale').length
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -58,6 +62,7 @@ export default function Cameras({ user }) {
|
||||
<p className="sub">
|
||||
{list.length} {list.length === 1 ? 'camera' : 'cameras'}
|
||||
{up > 0 && <> · <b className="ok">{up} connected</b></>}
|
||||
{stale > 0 && <> · <b className="warn">{stale} not reporting</b></>}
|
||||
{down > 0 && <> · <b className="bad">{down} down</b></>}
|
||||
{waiting > 0 && <> · {waiting} waiting for the shop PC</>}
|
||||
</p>
|
||||
@@ -108,10 +113,13 @@ function CameraCard({ cam, canEdit, onEdit, onWatch }) {
|
||||
// Three states, not two. A camera nobody has tried yet is not a camera that
|
||||
// is down, and telling an operator to check the cabling on a camera the shop
|
||||
// PC has not even seen sends them to the wrong building.
|
||||
const state = cam.connected == null ? 'idle'
|
||||
: cam.connected ? 'ok' : 'bad'
|
||||
const words = cam.connected == null ? 'Waiting for the shop PC'
|
||||
: cam.connected ? 'Connected' : 'Not connecting'
|
||||
// Four states, and the fourth is the one that was missing: a camera whose
|
||||
// shop PC has stopped reporting it. The agent correctly says nothing when it
|
||||
// cannot reach the engine, so the last value it sent used to sit in the
|
||||
// database reading Connected - measured at 34 minutes on the live estate.
|
||||
const state = { connected: 'ok', not_connecting: 'bad', stale: 'warn' }[cam.state] || 'idle'
|
||||
const words = { connected: 'Connected', not_connecting: 'Not connecting',
|
||||
stale: 'Not reporting' }[cam.state] || 'Waiting for the shop PC'
|
||||
const verified = verification(cam)
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user