Compare commits
82 Commits
c7024b57ca
...
v0.5.4-dem
| Author | SHA1 | Date | |
|---|---|---|---|
| 248025cdf9 | |||
| ff4f95c3b0 | |||
| 48a30d97db | |||
| ecc8bbba6f | |||
| 97a8ecc03a | |||
| 50d122e5f0 | |||
| dd3331ee9d | |||
| 68a50d10b1 | |||
| 8137480877 | |||
| f5eb2bb124 | |||
| 0b29dd4a50 | |||
| e33761d6d0 | |||
| 02b2c5bc39 | |||
| e5a63cc412 | |||
| e27eb8e927 | |||
| 7c564aca3c | |||
| 7dda6ab508 | |||
| e0bd764e44 | |||
| 6068b2c3c7 | |||
| dae18d651b | |||
| c7312d31b4 | |||
| f4102443ea | |||
| 359d48e1c4 | |||
| 830c1c1573 | |||
| 4db71e9381 | |||
| 6fafecd6e4 | |||
| 0e4cb1274e | |||
| abcf6aa012 | |||
| f61da2eeed | |||
| 2e60fbb57a | |||
| f97ffc913a | |||
| 62c2cc8a7b | |||
| 16f0e69cec | |||
| 9062d2fc51 | |||
| 177584e812 | |||
| 81e2c605b9 | |||
| 1607f4ce74 | |||
| f88d441bbf | |||
| 4ac08e5a85 | |||
| 7c74431fcf | |||
| 01f1c17c7f | |||
| 448fba8770 | |||
| 4bd1718491 | |||
| 51b9cb9743 | |||
| 2835252bb3 | |||
| 7021d5d2f5 | |||
| 4c62fc0ef3 | |||
| effa4f3d62 | |||
| 6c210f792f | |||
| 8786a5b0b4 | |||
| c93fbff31f | |||
| 4c750cb2ac | |||
| 5f83a1077d | |||
| a74cb899b4 | |||
| 8c88aad06e | |||
| 50a843ce46 | |||
| 979aa77cda | |||
| 3d3775c8be | |||
| a1fe0942e2 | |||
| e262fc8482 | |||
| b59e667a68 | |||
| 70c447873d | |||
| 719ba2c7f5 | |||
| 5e1dcf7050 | |||
| 92573e9067 | |||
| 92b12bcb1c | |||
| c50a74de47 | |||
| 0a423ed8cc | |||
| 22196ab9ba | |||
| 5e544eee3d | |||
| 9521cb986b | |||
| 30e01765ae | |||
| ee9e8b80b7 | |||
| 3598d8e9c0 | |||
| ce0223006b | |||
| 08873f4a67 | |||
| 9182f70442 | |||
| 3f9fb33b24 | |||
| ffae7e45d5 | |||
| 18686cbceb | |||
| 2cd7a78ddc | |||
| e0ceb14589 |
15
.gitignore
vendored
@@ -52,3 +52,18 @@ node_modules/
|
||||
# in the tree `go build ./...` fails on a fresh checkout - on a machine that may
|
||||
# have no npm at all. They are ~200 KB and regenerating them is one command; a
|
||||
# repository that does not compile is the more expensive problem.
|
||||
|
||||
# Backups of .env made when editing camera credentials.
|
||||
/.env.bak-*
|
||||
|
||||
# Generated by the wails CLI on every dev run and build, not source.
|
||||
# NOT /desktop/build/ as a whole: appicon.png, darwin/ and windows/ under it
|
||||
# are the Wails project scaffolding (icon, Info.plist, manifest) that a
|
||||
# reproducible Windows build needs. Only the compiled output is ignored.
|
||||
/desktop/frontend/wailsjs/
|
||||
/desktop/frontend/package.json.md5
|
||||
|
||||
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
|
||||
/behavision.egg-info/
|
||||
/.prod/
|
||||
/.demo/
|
||||
|
||||
2
RUN.md
@@ -251,7 +251,7 @@ the frozen engine once to prove it runs, and compiles the installer.
|
||||
3. Launch from the Start menu. **Set this PC up on its own** — no code needed.
|
||||
4. Cameras → Add camera → pick the make → Test connection → Save. The feed must
|
||||
appear with no restart.
|
||||
5. Check `/api/health` reports `recognition_model`. On a 16 GB machine the
|
||||
5. Check `/api/health` reports `recognition_model`. On a small machine the
|
||||
166 MB r50 can lose the fallback chain to the 13 MB mbf, and embeddings are
|
||||
model-tagged, so which one wins decides whether a gallery carries over.
|
||||
6. Sign out of the tray (Quit) — recognition must stop with it. Reboot; the app
|
||||
|
||||
93
agent/cmd/behavision-demo-pack/main.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Command behavision-demo-pack seals a camera list into demo-cameras.enc for a
|
||||
// demo release. It runs on the machine that builds the release and is never
|
||||
// shipped.
|
||||
//
|
||||
// behavision-demo-pack -cameras cameras.json -out demo-cameras.enc
|
||||
//
|
||||
// Prints the unlock code exactly once. It is not stored anywhere; a code you
|
||||
// can look up later is a code anyone with access to the build machine holds.
|
||||
// Lose it and seal again.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/demo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password) - the PC then runs on its own")
|
||||
enrolCode := flag.String("enrol-code", "", "an installation code from head office - the PC then claims that shop and gets its cameras from there")
|
||||
cloud := flag.String("cloud", "https://mcp.loyaly.ai", "head office, with -enrol-code")
|
||||
out := flag.String("out", "demo-cameras.enc", "sealed bundle to write")
|
||||
flag.Parse()
|
||||
if *in == "" && *enrolCode == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack (-cameras cameras.json | -enrol-code CODE [-cloud URL]) [-out demo-cameras.enc]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var payload demo.Payload
|
||||
if *in != "" {
|
||||
raw, err := os.ReadFile(*in)
|
||||
if err != nil {
|
||||
die("read cameras: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload.Cameras); err != nil {
|
||||
die("cameras.json: %v", err)
|
||||
}
|
||||
if len(payload.Cameras) == 0 {
|
||||
die("no cameras in %s", *in)
|
||||
}
|
||||
}
|
||||
payload.EnrolCode = *enrolCode
|
||||
if *enrolCode != "" {
|
||||
payload.CloudBase = *cloud
|
||||
}
|
||||
cams := payload.Cameras
|
||||
for i, c := range cams {
|
||||
switch {
|
||||
case c.ID == "":
|
||||
die("camera %d has no id", i)
|
||||
case c.Host == "":
|
||||
die("camera %q has no host", c.ID)
|
||||
case c.Path == "":
|
||||
die("camera %q has no path - the stream path is the field nobody can guess", c.ID)
|
||||
}
|
||||
}
|
||||
// Re-marshal so only the fields the engine accepts travel, in a stable
|
||||
// shape, whatever extra keys the input happened to carry.
|
||||
plain, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
die("marshal: %v", err)
|
||||
}
|
||||
|
||||
code, err := demo.NewCode()
|
||||
if err != nil {
|
||||
die("code: %v", err)
|
||||
}
|
||||
sealed, err := demo.Seal(code, plain)
|
||||
if err != nil {
|
||||
die("seal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*out, sealed, 0o644); err != nil {
|
||||
die("write: %v", err)
|
||||
}
|
||||
|
||||
if payload.EnrolCode != "" {
|
||||
fmt.Printf("\n sealed an installation code for %s into %s (%d bytes)\n\n", payload.CloudBase, *out, len(sealed))
|
||||
} else {
|
||||
fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed))
|
||||
}
|
||||
fmt.Printf(" unlock code: %s\n\n", code)
|
||||
fmt.Println(" Shown once. Give it to whoever runs behavision-setup, by voice")
|
||||
fmt.Println(" or message - not in the same place as the zip.")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, " "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
838
agent/cmd/behavision-setup/main.go
Normal file
@@ -0,0 +1,838 @@
|
||||
// Command behavision-setup prepares a shop PC to run the recognition engine.
|
||||
//
|
||||
// It exists because the engine is Python and the rest of the product is Go.
|
||||
// The Go halves cross-compile to Windows from any machine; the engine, frozen
|
||||
// with PyInstaller, does not - PyInstaller bundles the interpreter and native
|
||||
// wheels of the machine it runs on, so a frozen engine can only be built on
|
||||
// Windows. That single fact was the whole reason a release could not be cut.
|
||||
//
|
||||
// So this installs the engine from source instead of shipping it frozen: find
|
||||
// a Python, build a private virtual environment beside the database, install
|
||||
// the engine into it, fetch the models, and record how to start it. Everything
|
||||
// in the release can then be built anywhere.
|
||||
//
|
||||
// The trade, stated plainly because whoever runs this is standing in a shop:
|
||||
// it needs Python and a working internet connection at install time, and it
|
||||
// takes minutes rather than seconds. A frozen build needs neither. What it
|
||||
// buys is a release that exists.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"github.com/loyaly/behavision-agent/pkg/demo"
|
||||
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/enrol"
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// The engine needs 3.10; nothing here works below it and the failure would
|
||||
// otherwise arrive as a syntax error deep inside a dependency.
|
||||
const minMinor = 10
|
||||
|
||||
// maxMinor is a WHEEL-availability ceiling, not a language one, and it is the
|
||||
// reason this constant exists at all.
|
||||
//
|
||||
// findPython used to take the newest interpreter it could find, with a floor
|
||||
// and no ceiling - which is precisely backwards, because the newest Python is
|
||||
// the one least likely to have binary wheels for anything. Measured on a
|
||||
// second Mac: it chose Python 3.14, pip found no numpy wheel for cp314, fell
|
||||
// back to building numpy from source, and produced
|
||||
//
|
||||
// ERROR: Unknown compiler(s): [['cc'], ['gcc'], ['clang'], ...]
|
||||
//
|
||||
// then, once the operator installed Xcode's command line tools to get past
|
||||
// that, ten minutes of compiling ending in
|
||||
//
|
||||
// arm_neon.h:28:2: error: "<arm_neon.h> is intended only for ARM and
|
||||
// AArch64 targets"
|
||||
//
|
||||
// Two screens of C compiler output, on a shop counter, for a version choice
|
||||
// made silently by this program. Refusing in one line, before anything is
|
||||
// downloaded, is the whole of the fix.
|
||||
//
|
||||
// Raise it when the dependency set has wheels for the next version. Today
|
||||
// onnxruntime is the binding one (cp314 is its newest); numpy publishes
|
||||
// further ahead, and opencv-python ships a stable-ABI wheel that covers
|
||||
// everything. `pip download --only-binary=:all: -r requirements.txt` against
|
||||
// a candidate interpreter is the check.
|
||||
const maxMinor = 14
|
||||
|
||||
// The three answers a candidate interpreter can get. Three, not two: a
|
||||
// version that is too new and one that is too old need opposite actions from
|
||||
// the operator, and collapsing them tells somebody holding Python 3.14 to go
|
||||
// and install a newer Python.
|
||||
const (
|
||||
verdictOK = "ok"
|
||||
verdictTooOld = "old"
|
||||
verdictTooNew = "new"
|
||||
verdictUnknown = "unparseable"
|
||||
)
|
||||
|
||||
func pythonVerdict(major, minor int, parsed bool) string {
|
||||
switch {
|
||||
case !parsed:
|
||||
return verdictUnknown
|
||||
case major != 3:
|
||||
// Python 4 is not a version this has been tried against, and 2 is
|
||||
// long gone. Neither is a thing to guess about.
|
||||
return verdictTooNew
|
||||
case minor < minMinor:
|
||||
return verdictTooOld
|
||||
case minor > maxMinor:
|
||||
return verdictTooNew
|
||||
}
|
||||
return verdictOK
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
|
||||
pause()
|
||||
os.Exit(1)
|
||||
}
|
||||
pause()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
fmt.Println()
|
||||
fmt.Println(" Behavision setup")
|
||||
fmt.Println(" ----------------")
|
||||
fmt.Println()
|
||||
|
||||
state := paths.StateRoot()
|
||||
src, err := engineSource()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" engine source %s\n", src)
|
||||
fmt.Printf(" install into %s\n", state)
|
||||
fmt.Println()
|
||||
|
||||
if err := paths.EnsureState(); err != nil {
|
||||
return fmt.Errorf("could not create %s: %w", state, err)
|
||||
}
|
||||
|
||||
// A demo release ships its cameras sealed. Ask for the code NOW, before
|
||||
// the ten-minute download, so a mistyped one costs seconds; the cameras
|
||||
// are actually added at the end, through the running engine.
|
||||
bundle, err := unlockDemo(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var demoCams []demo.Camera
|
||||
if bundle != nil {
|
||||
demoCams = bundle.Cameras
|
||||
switch {
|
||||
case bundle.EnrolCode != "":
|
||||
step("Demo", "unlocked - this PC will join a shop at head office")
|
||||
default:
|
||||
step("Demo cameras", fmt.Sprintf("%d unlocked", len(demoCams)))
|
||||
}
|
||||
}
|
||||
|
||||
if running := behavisionRunning(); running != "" {
|
||||
// lint:ignore ST1005 — this is not a wrapped error, it is the whole
|
||||
// message an operator reads at a shop counter. ST1005 forbids
|
||||
// trailing punctuation because errors get concatenated mid-sentence;
|
||||
// nothing wraps this one, and stripping the full stops would make
|
||||
// three sentences run together.
|
||||
//lint:ignore ST1005 operator-facing prose, never wrapped
|
||||
return fmt.Errorf("%s is running. Quit Behavision from the tray icon first, then run setup again.\n\n"+
|
||||
"Setting up underneath a running copy starts a second engine on the same port and, in a demo,\n"+
|
||||
"re-claims the shop while the open app still holds the old credentials.", running)
|
||||
}
|
||||
|
||||
py, ver, err := findPython()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
step("Python", fmt.Sprintf("%s (%s)", ver, py))
|
||||
|
||||
venv := filepath.Join(state, "runtime")
|
||||
if err := makeVenv(py, venv); err != nil {
|
||||
return err
|
||||
}
|
||||
vpy := venvPython(venv)
|
||||
step("Virtual environment", venv)
|
||||
|
||||
// The engine reads its settings from <state>/config/default.yaml and will
|
||||
// seed that from beside its own code on first run - which works when its
|
||||
// code is a checkout or a frozen folder and not when it is a package in
|
||||
// site-packages, where there is no config beside it. Seeded here, from the
|
||||
// copy the release ships. Never overwritten: an upgrade must not revert an
|
||||
// operator's thresholds.
|
||||
if err := seedConfig(src, state); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Settings", filepath.Join(state, "config", "default.yaml"))
|
||||
|
||||
// --upgrade so re-running after a new release replaces the engine rather
|
||||
// than leaving the old one in place and reporting success.
|
||||
if err := pipInstall(vpy, src); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Engine and dependencies", "installed")
|
||||
|
||||
if err := runEngine(vpy, "setup-models"); err != nil {
|
||||
return fmt.Errorf("downloading the recognition models: %w", err)
|
||||
}
|
||||
step("Recognition models", "downloaded")
|
||||
|
||||
if err := writeConfig(vpy); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Startup settings", filepath.Join(state, "agent.json"))
|
||||
|
||||
// Proving it starts is the point. An installer that reports success and
|
||||
// leaves a shop with an engine that will not run has done worse than
|
||||
// failing: the failure surfaces later, to someone who did not install it.
|
||||
// Joining a shop: head office supplies the cameras, so any left on this PC
|
||||
// from an earlier install go first. Otherwise the reconciler offers them UP
|
||||
// to head office - without their passwords, which the engine never returns
|
||||
// - and the shop ends up with the same lens listed twice, one copy of which
|
||||
// can never be pushed to another PC. Measured on the first claimed demo.
|
||||
if bundle != nil && bundle.EnrolCode != "" {
|
||||
if err := os.Remove(paths.CamerasFile()); err == nil {
|
||||
step("Earlier cameras", "removed - head office supplies them now")
|
||||
}
|
||||
}
|
||||
if err := smokeTest(vpy, demoCams); err != nil {
|
||||
return fmt.Errorf("the engine installed but would not start: %w", err)
|
||||
}
|
||||
step("Engine starts and answers", "verified")
|
||||
if len(demoCams) > 0 {
|
||||
step("Demo cameras", "added to the engine")
|
||||
}
|
||||
switch {
|
||||
case bundle != nil && bundle.EnrolCode != "":
|
||||
// The demo that IS the product: this PC claims a real shop, exactly
|
||||
// as a customer install does, and its cameras arrive from head office
|
||||
// on the first sync. The app then opens on Login.
|
||||
siteName, err := claimShop(bundle.EnrolCode, bundle.CloudBase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not join the shop at head office: %w", err)
|
||||
}
|
||||
step("Head office", "linked to "+siteName)
|
||||
case bundle != nil:
|
||||
// No head office in this demo. Without this the app opens on "type an
|
||||
// installation code" and sits there; with it, it opens on Live.
|
||||
if err := markStandalone(); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Head office", "none - running on this PC only")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
// The last thing setup says is the first thing the operator does, so it
|
||||
// has to describe THEIR machine. On macOS there is no Start menu and,
|
||||
// deliberately, no tray at all - telling somebody to right-click a tray
|
||||
// icon that does not exist is how software loses their trust on the step
|
||||
// where it was otherwise finished.
|
||||
if runtime.GOOS == "windows" {
|
||||
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
|
||||
fmt.Println(" It appears in the system tray; right-click there to stop it.")
|
||||
} else {
|
||||
fmt.Println(" Done. Open Behavision.app - right-click it and choose Open the")
|
||||
fmt.Println(" first time, because this build is not notarised.")
|
||||
fmt.Println(" There is no tray on macOS: closing the window stops recognition.")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func step(label, detail string) {
|
||||
fmt.Printf(" [ok] %-24s %s\n", label, detail)
|
||||
}
|
||||
|
||||
// engineSource finds the Python source shipped beside this executable. Beside,
|
||||
// not downloaded: the engine and the app must be the same release, and a
|
||||
// version skew between them is the class of bug nobody can reproduce.
|
||||
func engineSource() (string, error) {
|
||||
candidates := []string{
|
||||
filepath.Join(paths.InstallRoot(), "engine-src"),
|
||||
filepath.Join(paths.InstallRoot(), "..", "engine-src"),
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(wd, "engine-src"), wd)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil {
|
||||
abs, _ := filepath.Abs(c)
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("could not find the engine source (expected an " +
|
||||
"engine-src folder with pyproject.toml beside this program). " +
|
||||
"Unzip the whole release together rather than moving this file out of it")
|
||||
}
|
||||
|
||||
// findPython returns the first interpreter that is new enough.
|
||||
//
|
||||
// `py -3` first on Windows: the launcher is what the official installer puts
|
||||
// on PATH, and `python` there is often the Microsoft Store stub that prints an
|
||||
// advert and exits 9009 instead of running anything.
|
||||
// behavisionRunning names a Behavision process if one is up. Windows only -
|
||||
// that is the platform setup ships on - and by image name via tasklist, which
|
||||
// needs no extra privilege.
|
||||
func behavisionRunning() string {
|
||||
if runtime.GOOS != "windows" {
|
||||
return ""
|
||||
}
|
||||
for _, name := range []string{"Behavision.exe", "behavision-agent.exe"} {
|
||||
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name, "/NH").Output()
|
||||
if err == nil && strings.Contains(strings.ToLower(string(out)), strings.ToLower(name)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findPython() (string, string, error) {
|
||||
type cand struct {
|
||||
exe string
|
||||
args []string
|
||||
}
|
||||
var cands []cand
|
||||
if runtime.GOOS == "windows" {
|
||||
cands = append(cands, cand{"py", []string{"-3"}})
|
||||
}
|
||||
|
||||
// Versioned names FIRST, newest first, and this is not belt-and-braces on
|
||||
// macOS - it is the only thing that works. `/usr/bin/python3` there is
|
||||
// always the Command Line Tools build, 3.9 on current macOS, which is
|
||||
// below the 3.10 floor. Anything newer installs as `python3.12` or into a
|
||||
// directory that is not on a GUI application's PATH. Searching only
|
||||
// `python3` therefore told a Mac with Python 3.12 sitting on it to go and
|
||||
// install Python - measured on this machine, which has 3.12 under
|
||||
// ~/.local/opt and reported "Found, but too old: python3 3.9".
|
||||
// Newest first WITHIN the supported range. Newest overall is what broke
|
||||
// this; a version nobody has built wheels for is not a better choice than
|
||||
// one that works.
|
||||
var versions []string
|
||||
for v := maxMinor; v >= minMinor; v-- {
|
||||
versions = append(versions, fmt.Sprintf("3.%d", v))
|
||||
}
|
||||
for _, v := range versions {
|
||||
cands = append(cands, cand{"python" + v, nil})
|
||||
}
|
||||
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
|
||||
|
||||
// And the places a Mac puts an interpreter that LookPath will not find,
|
||||
// because a double-clicked app inherits a minimal PATH rather than the
|
||||
// one a shell profile builds.
|
||||
if runtime.GOOS != "windows" {
|
||||
home, _ := os.UserHomeDir()
|
||||
for _, v := range versions {
|
||||
for _, dir := range []string{
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/Library/Frameworks/Python.framework/Versions/" + v + "/bin",
|
||||
filepath.Join(home, ".local", "opt", "python"+v, "bin"),
|
||||
} {
|
||||
cands = append(cands, cand{filepath.Join(dir, "python"+v), nil})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tried, tooNew []string
|
||||
for _, c := range cands {
|
||||
exe := c.exe
|
||||
if filepath.IsAbs(exe) {
|
||||
// An absolute candidate is a guess about where an interpreter
|
||||
// might be; most will not exist, and that is not an error.
|
||||
if fi, err := os.Stat(exe); err != nil || fi.IsDir() {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
found, err := exec.LookPath(exe)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
exe = found
|
||||
}
|
||||
args := append(append([]string{}, c.args...), "-c",
|
||||
"import sys;print('%d.%d'%sys.version_info[:2])")
|
||||
out, err := exec.Command(exe, args...).Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ver := strings.TrimSpace(string(out))
|
||||
tried = append(tried, c.exe+" "+ver)
|
||||
major, minor, parsed := parseVer(ver)
|
||||
switch verdict := pythonVerdict(major, minor, parsed); verdict {
|
||||
case verdictTooNew:
|
||||
// Recorded separately: "too new" and "too old" need opposite
|
||||
// actions, and a single "found, but unsuitable" list sends
|
||||
// somebody to upgrade a Python that is already past the problem.
|
||||
tooNew = append(tooNew, c.exe+" "+ver)
|
||||
continue
|
||||
case verdictTooOld, verdictUnknown:
|
||||
continue
|
||||
}
|
||||
{
|
||||
full := exe
|
||||
if len(c.args) > 0 {
|
||||
full = exe + " " + strings.Join(c.args, " ")
|
||||
}
|
||||
return full, "Python " + ver, nil
|
||||
}
|
||||
}
|
||||
|
||||
// The advice has to match the machine. Telling a Mac user to tick "Add
|
||||
// python.exe to PATH" on a Windows installer page reads as software that
|
||||
// does not know where it is running, which is exactly the moment somebody
|
||||
// stops trusting the rest of what it says.
|
||||
// Only a too-new Python is a different problem with a different fix, and
|
||||
// saying "no Python was found" to somebody looking at Python 3.14 is the
|
||||
// kind of message that makes people stop believing the next one.
|
||||
if len(tooNew) > 0 && len(tried) == 0 {
|
||||
// Built as a value and wrapped, not written as an fmt.Errorf literal:
|
||||
// this is a paragraph shown to an operator, and a linter that wants
|
||||
// error strings to be lower-case fragments is right about errors
|
||||
// programs read and wrong about the ones people do.
|
||||
tooNewMsg := fmt.Sprintf(
|
||||
"this computer has %s, which is newer than Behavision supports.\n\n"+
|
||||
" Some of the libraries the engine needs have no build for it\n"+
|
||||
" yet, so installing would fail part-way through.\n\n"+
|
||||
" Install Python 3.%d and run this again:\n"+
|
||||
" macOS: brew install python@3.%d\n"+
|
||||
" or https://www.python.org/downloads/macos/\n"+
|
||||
" Windows: https://www.python.org/downloads/windows/\n\n"+
|
||||
" Both versions can sit on the machine together; this picks\n"+
|
||||
" the one it can use.",
|
||||
strings.Join(tooNew, ", "), maxMinor, maxMinor)
|
||||
return "", "", errors.New(tooNewMsg)
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("no Python between 3.%d and 3.%d was found on this computer.\n\n",
|
||||
minMinor, maxMinor)
|
||||
if runtime.GOOS == "windows" {
|
||||
msg += " Install it from https://www.python.org/downloads/windows/\n" +
|
||||
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
|
||||
" then run this again."
|
||||
} else {
|
||||
msg += " Install it with `brew install python@3.12`, or from\n" +
|
||||
" https://www.python.org/downloads/macos/, then run this again."
|
||||
}
|
||||
if len(tried) > 0 {
|
||||
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
|
||||
}
|
||||
if len(tooNew) > 0 {
|
||||
msg += "\n\n Found, but too new: " + strings.Join(tooNew, ", ")
|
||||
}
|
||||
return "", "", errors.New(msg)
|
||||
}
|
||||
|
||||
func parseVer(s string) (int, int, bool) {
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) < 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
major, err1 := strconv.Atoi(parts[0])
|
||||
minor, err2 := strconv.Atoi(parts[1])
|
||||
return major, minor, err1 == nil && err2 == nil
|
||||
}
|
||||
|
||||
// splitLauncher turns `py -3` back into a command and its arguments.
|
||||
func splitLauncher(s string) (string, []string) {
|
||||
f := strings.Fields(s)
|
||||
if len(f) == 0 {
|
||||
return s, nil
|
||||
}
|
||||
return f[0], f[1:]
|
||||
}
|
||||
|
||||
func venvPython(venv string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(venv, "Scripts", "python.exe")
|
||||
}
|
||||
return filepath.Join(venv, "bin", "python")
|
||||
}
|
||||
|
||||
// makeVenv builds the engine's own interpreter under the writable state root.
|
||||
//
|
||||
// A virtual environment rather than the system Python: a shop PC may have
|
||||
// Python there for something else, and pinning numpy below 2.0 - which the
|
||||
// engine requires - inside a shared interpreter is how you break the other
|
||||
// thing months later, silently.
|
||||
func makeVenv(py, venv string) error {
|
||||
// An existing environment is reused - but only if the Python inside it is
|
||||
// one this build supports.
|
||||
//
|
||||
// It used to be reused unconditionally, and that would have made the
|
||||
// version ceiling above look like it did not work. The machine this was
|
||||
// all found on already had a runtime built by Python 3.14, from the run
|
||||
// that failed: with the ceiling in place setup would choose a good
|
||||
// interpreter, reach here, find the 3.14 environment, keep it, and die in
|
||||
// the same clang error as before. A fix that is defeated by the wreckage
|
||||
// of the bug it fixes is not one.
|
||||
//
|
||||
// Rebuilding costs a re-download of the libraries and nothing else. The
|
||||
// models are in the state root, not in here, so they survive.
|
||||
if _, err := os.Stat(venvPython(venv)); err == nil {
|
||||
ok, ver := venvUsable(venv)
|
||||
if ok {
|
||||
return nil // pip below brings it up to date
|
||||
}
|
||||
fmt.Printf(" [..] %-24s %s\n", "Rebuilding environment",
|
||||
"the existing one uses "+ver+", which is not supported")
|
||||
if err := os.RemoveAll(venv); err != nil {
|
||||
return fmt.Errorf("removing the old environment at %s: %w", venv, err)
|
||||
}
|
||||
}
|
||||
exe, args := splitLauncher(py)
|
||||
args = append(args, "-m", "venv", venv)
|
||||
return stream(exec.Command(exe, args...), "creating the virtual environment")
|
||||
}
|
||||
|
||||
// venvUsable reports whether the interpreter already inside an environment is
|
||||
// one this build supports, and what it is when it is not.
|
||||
//
|
||||
// An environment that cannot be asked counts as unusable: a half-created or
|
||||
// truncated one answers nothing, and reusing it fails later in pip with an
|
||||
// error about a package rather than about the environment.
|
||||
func venvUsable(venv string) (bool, string) {
|
||||
out, err := exec.Command(venvPython(venv), "-c",
|
||||
"import sys;print('%d.%d'%sys.version_info[:2])").Output()
|
||||
if err != nil {
|
||||
return false, "an interpreter that will not run"
|
||||
}
|
||||
ver := strings.TrimSpace(string(out))
|
||||
major, minor, parsed := parseVer(ver)
|
||||
return pythonVerdict(major, minor, parsed) == verdictOK, "Python " + ver
|
||||
}
|
||||
|
||||
func pipInstall(vpy, src string) error {
|
||||
fmt.Println(" Installing the engine and its libraries. This downloads a few")
|
||||
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
|
||||
fmt.Println()
|
||||
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade",
|
||||
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A wheel if the release ships one - nothing to build on the shop PC, and
|
||||
// pip never has to touch the folder the release was unzipped into.
|
||||
//
|
||||
// That matters more than it sounds: `pip install <folder>` makes setuptools
|
||||
// write behavision.egg-info INTO that folder, and the folder is read-only
|
||||
// whenever the release was unzipped somewhere sensible - Program Files, or
|
||||
// the shared drive INSTALL.txt says is fine. Found by running this in a
|
||||
// container with the source mounted read-only: "could not create
|
||||
// 'behavision.egg-info': Read-only file system". Falling back to source
|
||||
// copies it somewhere writable first, for the same reason.
|
||||
if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 {
|
||||
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
|
||||
"installing the engine")
|
||||
}
|
||||
tmp, err := os.MkdirTemp("", "behavision-src-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
if err := copyTree(src, tmp); err != nil {
|
||||
return fmt.Errorf("staging the engine source: %w", err)
|
||||
}
|
||||
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp),
|
||||
"installing the engine")
|
||||
}
|
||||
|
||||
// seedConfig puts the shipped default.yaml where the engine will look for it,
|
||||
// and leaves an existing one alone.
|
||||
func seedConfig(src, state string) error {
|
||||
dst := filepath.Join(state, "config", "default.yaml")
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
from := filepath.Join(src, "config", "default.yaml")
|
||||
b, err := os.ReadFile(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the release is missing config/default.yaml: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, b, 0o644)
|
||||
}
|
||||
|
||||
// copyTree copies a source tree, skipping the caches a checkout accumulates.
|
||||
func copyTree(from, to string) error {
|
||||
return filepath.WalkDir(from, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(from, path)
|
||||
if d.IsDir() {
|
||||
if d.Name() == "__pycache__" || strings.HasSuffix(d.Name(), ".egg-info") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return os.MkdirAll(filepath.Join(to, rel), 0o755)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(to, rel), b, 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
// runEngine runs the engine exactly as the app will later: same interpreter,
|
||||
// same environment. In particular ChildEnv sets BEHAVISION_DATA_DIR, without
|
||||
// which a pip-installed engine decides its state lives in site-packages and
|
||||
// downloads the models to a place the app never looks.
|
||||
func runEngine(vpy string, args ...string) error {
|
||||
full := append([]string{"-m", "behavision"}, args...)
|
||||
cmd := exec.Command(vpy, full...)
|
||||
cmd.Env = engine.ChildEnv("")
|
||||
return stream(cmd, "running the engine")
|
||||
}
|
||||
|
||||
// writeConfig records how to start the engine, in the same file and through
|
||||
// the same type the app reads, so the two cannot disagree about it.
|
||||
func writeConfig(vpy string) error {
|
||||
path := paths.AgentConfig()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
// An absolute path: the app resolves a relative EngineExe against its own
|
||||
// install root under Program Files, and the interpreter is not there.
|
||||
cfg.EngineExe = vpy
|
||||
cfg.EngineArgs = []string{"-m", "behavision", "run"}
|
||||
if cfg.APIBase == "" {
|
||||
cfg.APIBase = "http://127.0.0.1:8010"
|
||||
}
|
||||
return cfg.Save(path)
|
||||
}
|
||||
|
||||
// smokeTest starts the engine exactly as the app will and waits for its API to
|
||||
// answer. Any reply counts, including 401: the engine invents its own
|
||||
// credential when none is configured, and a refusal proves it is serving.
|
||||
func smokeTest(vpy string, demoCams []demo.Camera) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
||||
cmd.Env = engine.ChildEnv("")
|
||||
var log strings.Builder
|
||||
cmd.Stdout, cmd.Stderr = &log, &log
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
deadline := time.Now().Add(75 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get("http://127.0.0.1:8010/api/health")
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if demoCams == nil {
|
||||
return nil
|
||||
}
|
||||
// Through the engine's own Add Camera, not written to its file:
|
||||
// the store is what applies DPAPI to the password on Windows, so
|
||||
// this is how the credential ends up encrypted on disk rather
|
||||
// than sitting in cameras.json for anyone who can read
|
||||
// ProgramData.
|
||||
return addCameras(demoCams)
|
||||
}
|
||||
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
|
||||
tail(log.String(), 15))
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return " " + strings.Join(lines, "\n ")
|
||||
}
|
||||
|
||||
// stream runs a command and shows its output. Shown, not swallowed: pip failing
|
||||
// on a missing build tool prints exactly what is wrong, and hiding that leaves
|
||||
// the operator with "setup failed" and nothing to act on.
|
||||
func stream(cmd *exec.Cmd, what string) error {
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s failed: %w", what, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pause keeps the window open. Double-clicked from Explorer, a console program
|
||||
// that finishes closes instantly and the operator sees nothing at all -
|
||||
// success and failure look identical.
|
||||
func pause() {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
fmt.Print(" Press Enter to close. ")
|
||||
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
}
|
||||
|
||||
// unlockDemo returns the sealed cameras a demo release ships, or nil when this
|
||||
// is not a demo release. Asks for the unlock code on the console; three tries,
|
||||
// because a code is read down a phone and typed by hand.
|
||||
func unlockDemo(src string) (*demo.Payload, error) {
|
||||
sealed, err := os.ReadFile(filepath.Join(src, "demo-cameras.enc"))
|
||||
if err != nil {
|
||||
return nil, nil // not a demo release
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" This is a demo release with the cameras already set up.")
|
||||
fmt.Println(" It needs the unlock code you were given.")
|
||||
fmt.Println()
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
fmt.Print(" Unlock code: ")
|
||||
line, _ := in.ReadString('\n')
|
||||
plain, err := demo.Open(line, sealed)
|
||||
if err == nil {
|
||||
payload, err := demo.Decode(plain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the bundle unlocked but did not parse: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
return &payload, nil
|
||||
}
|
||||
fmt.Printf(" %v\n", err)
|
||||
}
|
||||
return nil, errors.New("no valid unlock code after three tries. Check it " +
|
||||
"with whoever gave you this release and run setup again")
|
||||
}
|
||||
|
||||
// claimShop redeems the installation code sealed in the bundle: the same call
|
||||
// the app's Setup screen and `behavision-agent claim` make, so the PC ends up
|
||||
// in exactly the state a customer's would - broker login, API token, the
|
||||
// broker's CA on disk - and head office pushes its cameras down on the first
|
||||
// sync.
|
||||
func claimShop(code, base string) (string, error) {
|
||||
if base == "" {
|
||||
base = "https://mcp.loyaly.ai"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
b, err := enrol.Claim(ctx, base, code)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := paths.AgentConfig()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg.ClientID = b.ClientSlug
|
||||
cfg.SiteID = b.SiteSlug
|
||||
cfg.SiteName = b.SiteName
|
||||
cfg.BrokerURL = b.MQTTURL
|
||||
cfg.BrokerUsername = b.MQTTUser
|
||||
cfg.BrokerPassword = b.MQTTPass
|
||||
cfg.AgentToken = b.AgentToken
|
||||
cfg.CloudBase = base
|
||||
cfg.Standalone = false
|
||||
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
|
||||
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg.BrokerCAFile = caPath
|
||||
if err := cfg.Save(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b.SiteName, nil
|
||||
}
|
||||
|
||||
// addCameras posts each demo camera to the running engine, with the credential
|
||||
// the engine generated for itself on first start.
|
||||
func addCameras(cams []demo.Camera) error {
|
||||
user, pass, err := engineCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
for _, c := range cams {
|
||||
if c.Port == 0 {
|
||||
c.Port = 554
|
||||
}
|
||||
body, _ := json.Marshal(c)
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8010/api/cameras",
|
||||
bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding camera %s: %w", c.ID, err)
|
||||
}
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
// 409 is "already there" - a re-run of setup, which is allowed.
|
||||
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusConflict {
|
||||
return fmt.Errorf("adding camera %s: %s: %s", c.ID, resp.Status,
|
||||
strings.TrimSpace(string(msg)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// engineCredential reads the Basic credential the engine wrote on its first
|
||||
// start. Empty when the engine is configured without one.
|
||||
func engineCredential() (string, string, error) {
|
||||
b, err := os.ReadFile(paths.APICredentials())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
var user, pass string
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "username="); ok {
|
||||
user = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "password="); ok {
|
||||
pass = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return user, pass, nil
|
||||
}
|
||||
|
||||
// markStandalone records that this PC runs on its own, through the same
|
||||
// config type the app reads.
|
||||
func markStandalone() error {
|
||||
path := paths.AgentConfig()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Standalone = true
|
||||
return cfg.Save(path)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
BIN
agent/cmd/behavision-setup/rsrc_windows_amd64.syso
Normal file
@@ -3,7 +3,11 @@ module github.com/loyaly/behavision-agent
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3
|
||||
golang.org/x/sys v0.20.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
|
||||
@@ -6,3 +6,5 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
104
agent/main.go
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/loyaly/behavision-agent/pkg/cameras"
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/enrol"
|
||||
"github.com/loyaly/behavision-agent/pkg/mqtt"
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
"github.com/loyaly/behavision-agent/pkg/spool"
|
||||
@@ -38,8 +39,15 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "behavision-agent %s\n\nusage: %s <run|status|paths>\n",
|
||||
version, filepath.Base(os.Args[0]))
|
||||
fmt.Fprintf(os.Stderr, `behavision-agent %s
|
||||
|
||||
usage: %s <command>
|
||||
|
||||
run supervise the engine and report to head office (default)
|
||||
claim <code> link this PC to a shop, using an installation code
|
||||
status what this PC is and whether it is claimed
|
||||
paths where this install reads and writes
|
||||
`, version, filepath.Base(os.Args[0]))
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
@@ -53,6 +61,8 @@ func main() {
|
||||
err = cmdRun()
|
||||
case "status":
|
||||
err = cmdStatus()
|
||||
case "claim":
|
||||
err = cmdClaim(flag.Args()[1:])
|
||||
case "paths":
|
||||
err = cmdPaths()
|
||||
default:
|
||||
@@ -64,6 +74,74 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// cmdClaim is the headless half of onboarding.
|
||||
//
|
||||
// The desktop app has had a Setup screen for this; a back-office PC with no
|
||||
// window had nothing at all, so the only way to claim one was to hand-edit
|
||||
// agent.json - which is the state that screen was built to end.
|
||||
func cmdClaim(args []string) error {
|
||||
if len(args) == 0 {
|
||||
//lint:ignore ST1005 usage text read by a person, never wrapped
|
||||
return fmt.Errorf("usage: behavision-agent claim <installation code>\n" +
|
||||
"Ask whoever manages your shops for one - they can create it from\n" +
|
||||
"the Behavision platform, under the shop.")
|
||||
}
|
||||
// Joined rather than requiring quotes: the code is printed in groups for
|
||||
// reading aloud, and an operator pasting it will paste the spaces too.
|
||||
code := strings.Join(args, "")
|
||||
|
||||
if err := paths.EnsureState(); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(paths.AgentConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := cfg.CloudBase
|
||||
if v := os.Getenv("BEHAVISION_CLOUD"); v != "" {
|
||||
base = v
|
||||
}
|
||||
if base == "" {
|
||||
base = "https://mcp.loyaly.ai"
|
||||
}
|
||||
|
||||
b, err := enrol.Claim(context.Background(), base, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The slugs, not the uuids: the topic prefix is <client>.<site> and the
|
||||
// broker's ACL is written against exactly that username.
|
||||
cfg.ClientID = b.ClientSlug
|
||||
cfg.SiteID = b.SiteSlug
|
||||
cfg.SiteName = b.SiteName
|
||||
cfg.BrokerURL = b.MQTTURL
|
||||
cfg.BrokerUsername = b.MQTTUser
|
||||
cfg.BrokerPassword = b.MQTTPass
|
||||
cfg.AgentToken = b.AgentToken
|
||||
cfg.CloudBase = base
|
||||
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
|
||||
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.BrokerCAFile = caPath
|
||||
// A PC that was running on its own and has now been linked is no longer
|
||||
// standalone.
|
||||
cfg.Standalone = false
|
||||
if err := cfg.Save(paths.AgentConfig()); err != nil {
|
||||
// Reported, never swallowed: a claim that is not on disk works until
|
||||
// the next restart and then silently is not claimed any more, which
|
||||
// looks exactly like a wrong code.
|
||||
return fmt.Errorf("could not save the settings: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("linked to %s (%s.%s)\n", b.SiteName, b.ClientSlug, b.SiteSlug)
|
||||
fmt.Printf("settings written to %s\n", paths.AgentConfig())
|
||||
fmt.Println("restart the agent for it to take effect.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdPaths() error {
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]string{
|
||||
"version": version,
|
||||
@@ -84,6 +162,7 @@ func cmdStatus() error {
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -92,7 +171,7 @@ func cmdStatus() error {
|
||||
Command: func(context.Context) *exec.Cmd { return nil },
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -127,6 +206,7 @@ func cmdRun() error {
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
// Opened before the engine starts: detections arriving in the first second
|
||||
// must have somewhere to land.
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
@@ -152,6 +232,14 @@ func cmdRun() error {
|
||||
sup := engine.New(engine.Options{
|
||||
Command: func(ctx context.Context) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, exe, cfg.EngineArgs...)
|
||||
// Run the engine FROM a known directory rather than from whatever
|
||||
// happened to launch us. A double-clicked bundle hands its child
|
||||
// "/", and an engine invoked as `-m behavision` then cannot find
|
||||
// itself - measured on macOS, where it retried forever.
|
||||
cmd.Dir = cfg.EngineDir
|
||||
if cmd.Dir == "" {
|
||||
cmd.Dir = paths.InstallRoot()
|
||||
}
|
||||
// How the engine learns where to send detections. The engine's
|
||||
// config already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`
|
||||
// and python-dotenv does not override a variable the process
|
||||
@@ -161,13 +249,13 @@ func cmdRun() error {
|
||||
// nothing - the URL was returned, logged and even exposed on the
|
||||
// desktop's status object, and never actually given to the engine.
|
||||
// A claimed shop PC published heartbeats and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||
cmd.Env = engine.ChildEnv(hookURL)
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
@@ -202,7 +290,11 @@ func cmdRun() error {
|
||||
cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken)
|
||||
cloud.Upload = uploader.UploadBytes
|
||||
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
|
||||
eng.Creds = creds
|
||||
go cameras.New(eng, cloud, logger).Run(ctx)
|
||||
// The live relay, which uploads nothing until somebody at head office is
|
||||
// actually watching a camera.
|
||||
go cameras.NewLive(eng, cloud, logger).Run(ctx)
|
||||
|
||||
// Before the engine starts, so the engine can be launched already knowing
|
||||
// where to post its detections.
|
||||
@@ -233,7 +325,7 @@ func cmdRun() error {
|
||||
cfg.ClientID, cfg.SiteID, cfg.BrokerURL)
|
||||
client, err := mqtt.NewClient(mqtt.ClientOptions{
|
||||
BrokerURL: cfg.BrokerURL,
|
||||
ClientID: "behavision-" + cfg.ClientID + "-" + cfg.SiteID,
|
||||
ClientID: cfg.MQTTClientID(),
|
||||
Username: cfg.BrokerUsername, Password: cfg.BrokerPassword,
|
||||
CAFile: cfg.BrokerCAFile, Log: logger,
|
||||
})
|
||||
|
||||
@@ -106,6 +106,18 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string,
|
||||
}
|
||||
|
||||
target, err := u.target(ctx)
|
||||
if errors.Is(err, ErrImagesOff) {
|
||||
// No object storage on this server. Send the bytes to the API itself,
|
||||
// which holds them for a deployment that has no bucket - the same
|
||||
// fallback camera snapshots already take, and chosen by the SENTINEL
|
||||
// rather than by matching the message, because a prose change would
|
||||
// otherwise silently stop every photo in the estate.
|
||||
//
|
||||
// Only after target() has spoken. The unclaimed case returns the same
|
||||
// sentinel from the guard at the top of this function, and a PC with no
|
||||
// credentials has no server to PUT to either.
|
||||
return u.uploadDirect(ctx, body)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -135,6 +147,54 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string,
|
||||
return target.Key, nil
|
||||
}
|
||||
|
||||
// uploadDirect posts the image to our own API, for a deployment with no bucket.
|
||||
//
|
||||
// Deliberately the second choice. A presigned PUT never passes a photograph
|
||||
// through the server at all, which is what makes it the right route wherever
|
||||
// object storage exists; this one is what stops "no S3 account" from meaning
|
||||
// "no customer photo, ever" on every local install and every self-hosted site.
|
||||
//
|
||||
// The server decides where it lands and returns the key, exactly as the
|
||||
// presigned route does. That symmetry is the point: the caller cannot tell
|
||||
// which route ran, so the queued visit, the read path and erasure all stay
|
||||
// single implementations.
|
||||
func (u *SpacesUploader) uploadDirect(ctx context.Context, body []byte) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(u.BaseURL, "/")+"/api/agent/faces", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+u.Token)
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
req.ContentLength = int64(len(body))
|
||||
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload face: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotImplemented {
|
||||
// This server stores no images at all. Stop trying rather than retry
|
||||
// every visitor forever.
|
||||
return "", ErrImagesOff
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return "", fmt.Errorf("upload face returned %s: %s",
|
||||
resp.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<10)).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Key == "" {
|
||||
return "", errors.New("server stored the face but named no key for it")
|
||||
}
|
||||
return out.Key, nil
|
||||
}
|
||||
|
||||
func (u *SpacesUploader) target(ctx context.Context) (uploadTarget, error) {
|
||||
var out uploadTarget
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -200,3 +201,80 @@ func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) {
|
||||
t.Fatal("the local image was left on disk")
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no object storage must still get a photo onto the customer
|
||||
// record. Until the fallback existed, `images_disabled` meant every local
|
||||
// install and every self-hosted site showed no face for anybody, forever.
|
||||
func TestNoBucketFallsBackToTheServer(t *testing.T) {
|
||||
var askedURL, postedFace bool
|
||||
var gotBody []byte
|
||||
var gotAuth, gotType string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/agent/upload-url":
|
||||
askedURL = true
|
||||
// What a server with no bucket answers.
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = w.Write([]byte(`{"error":"images_disabled"}`))
|
||||
case "/api/agent/faces":
|
||||
postedFace = true
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotType = r.Header.Get("Content-Type")
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"key":"db:11111111-1111-4111-8111-111111111111"}`))
|
||||
default:
|
||||
t.Errorf("unexpected request to %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
|
||||
img := []byte{0xFF, 0xD8, 0xFF, 0xE0, 'x', 'y', 'z'}
|
||||
key, err := u.UploadBytes(context.Background(), img)
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
if !askedURL {
|
||||
t.Error("the presigned route must be tried first - it is the right one where a bucket exists")
|
||||
}
|
||||
if !postedFace {
|
||||
t.Fatal("no fallback upload was made")
|
||||
}
|
||||
if !strings.HasPrefix(key, "db:") {
|
||||
t.Errorf("want the server's own key, got %q", key)
|
||||
}
|
||||
if !bytes.Equal(gotBody, img) {
|
||||
t.Error("the bytes sent are not the bytes given")
|
||||
}
|
||||
if gotAuth != "Bearer agent-token" || gotType != "image/jpeg" {
|
||||
t.Errorf("auth %q type %q", gotAuth, gotType)
|
||||
}
|
||||
}
|
||||
|
||||
// A server that stores no images AT ALL must stop the agent trying, rather than
|
||||
// have it retry every visitor forever. Distinct from a failure, which is why
|
||||
// it is a sentinel and not a message.
|
||||
func TestAServerThatStoresNothingSaysSoOnce(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
|
||||
_, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8, 0xFF, 0xE0})
|
||||
if !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("want ErrImagesOff so the caller stops trying, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An unclaimed PC has no server to send anything to. The fallback must not fire
|
||||
// there - it would be a request to nowhere on every single visit.
|
||||
func TestAnUnclaimedAgentDoesNotTryToUpload(t *testing.T) {
|
||||
u := &SpacesUploader{} // no BaseURL, no token
|
||||
if _, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8}); !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("want ErrImagesOff, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,14 @@ type Engine interface {
|
||||
type Cloud interface {
|
||||
Desired(ctx context.Context) ([]Desired, error)
|
||||
Report(ctx context.Context, rep Report) error
|
||||
// UploadSnapshot puts a JPEG in object storage and names it. Used for the
|
||||
// placement check's proof picture, which is transient.
|
||||
UploadSnapshot(ctx context.Context, jpeg []byte) (key string, err error)
|
||||
// PutSnapshot gets a camera's latest frame to head office by whichever
|
||||
// route this deployment has - the bucket, or the server itself when there
|
||||
// is none. An empty key means the server already stored it, so the state
|
||||
// report has nothing to carry.
|
||||
PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (key string, err error)
|
||||
}
|
||||
|
||||
// Local is a camera as the engine holds it.
|
||||
@@ -48,6 +55,9 @@ type Local struct {
|
||||
|
||||
// Desired is a camera as head office holds it.
|
||||
type Desired struct {
|
||||
// ID is head office's uuid for this camera; CameraID is the name the
|
||||
// engine on this PC knows it by. The live relay translates between them.
|
||||
ID string `json:"id"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Host string `json:"host"`
|
||||
@@ -252,7 +262,9 @@ func (s *Syncer) reportWith(ctx context.Context, adopt []Desired) {
|
||||
// camera is down matters far more than having a picture of it,
|
||||
// and the picture is the part most likely to fail.
|
||||
if jpeg, err := s.Engine.Snapshot(ctx, c.ID); err == nil && len(jpeg) > 0 {
|
||||
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
|
||||
// An empty key is not a failure: it means this deployment has
|
||||
// no object storage and the server stored the picture itself.
|
||||
if key, err := s.Cloud.PutSnapshot(ctx, c.ID, jpeg); err == nil {
|
||||
st.SnapshotKey = key
|
||||
} else {
|
||||
s.logf("camera %s: snapshot upload failed: %v", c.ID, err)
|
||||
|
||||
@@ -59,6 +59,10 @@ type fakeCloud struct {
|
||||
reports []Report
|
||||
uploads int
|
||||
uploadErr error
|
||||
// direct records the cameras whose picture went to the server itself
|
||||
// rather than to object storage.
|
||||
direct []string
|
||||
directOnly bool
|
||||
}
|
||||
|
||||
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
|
||||
@@ -68,6 +72,20 @@ func (f *fakeCloud) Report(_ context.Context, r Report) error {
|
||||
f.reports = append(f.reports, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PutSnapshot mirrors the real client: the bucket when there is one, the
|
||||
// server itself when there is not.
|
||||
func (f *fakeCloud) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
|
||||
if f.directOnly {
|
||||
if f.uploadErr != nil {
|
||||
return "", f.uploadErr
|
||||
}
|
||||
f.direct = append(f.direct, cameraID)
|
||||
return "", nil
|
||||
}
|
||||
return f.UploadSnapshot(ctx, jpeg)
|
||||
}
|
||||
|
||||
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
|
||||
if f.uploadErr != nil {
|
||||
return "", f.uploadErr
|
||||
@@ -239,3 +257,28 @@ func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
|
||||
t.Fatal("reported state it could not have observed")
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no object storage must still get its picture to head
|
||||
// office. Before this, the camera screen said "This system is not storing
|
||||
// images" for every camera, forever - on the one screen whose entire job is to
|
||||
// show the camera.
|
||||
func TestASnapshotStillReachesHeadOfficeWithNoObjectStorage(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
c := &fakeCloud{directOnly: true}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if len(c.direct) != 1 || c.direct[0] != "entrance" {
|
||||
t.Fatalf("the picture did not reach the server: %v", c.direct)
|
||||
}
|
||||
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
|
||||
t.Fatalf("no state was reported: %+v", c.reports)
|
||||
}
|
||||
// Empty, and that is the point: there is no object to name. A key here
|
||||
// would have head office try to presign a bucket it does not have.
|
||||
if key := c.reports[0].State[0].SnapshotKey; key != "" {
|
||||
t.Fatalf("the direct route reported an object key %q", key)
|
||||
}
|
||||
if !c.reports[0].State[0].Connected {
|
||||
t.Error("connected state was lost")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/bridge"
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
)
|
||||
|
||||
// EngineClient talks to the recognition engine on this PC's loopback.
|
||||
@@ -17,7 +22,12 @@ type EngineClient struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
Client *http.Client
|
||||
// Creds re-reads the engine's generated credential when one is rejected.
|
||||
// Without it a fresh install is 401 for the life of the process: the agent
|
||||
// starts the engine, and the engine writes its credential file seconds
|
||||
// after the agent has already read (and failed to find) it.
|
||||
Creds *config.Creds
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewEngineClient(base, user, password string) *EngineClient {
|
||||
@@ -46,14 +56,24 @@ func (e *EngineClient) do(ctx context.Context, method, path string, body, out an
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if e.User != "" {
|
||||
req.SetBasicAuth(e.User, e.Password)
|
||||
user, pass := e.User, e.Password
|
||||
if e.Creds != nil {
|
||||
user, pass = e.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := e.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized && e.Creds != nil && e.Creds.Refresh() {
|
||||
// The engine generated its credential after we last looked. Read it
|
||||
// and try once more rather than failing for the life of the process.
|
||||
resp.Body.Close()
|
||||
return e.do(ctx, method, path, body, out)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The engine's message, not just a status. "camera stored but failed to
|
||||
// start: connection refused" is something an operator can act on;
|
||||
@@ -95,8 +115,29 @@ func (e *EngineClient) Remove(ctx context.Context, id string) error {
|
||||
// trip. A camera that has not produced a frame yet answers 503, which is a
|
||||
// normal state on a just-added camera and not an error worth logging loudly.
|
||||
func (e *EngineClient) Snapshot(ctx context.Context, id string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
e.Base+"/api/cameras/"+url.PathEscape(id)+"/frame.jpg", nil)
|
||||
return e.Frame(ctx, id, 0, 0)
|
||||
}
|
||||
|
||||
// Frame fetches the latest frame, optionally re-encoded smaller.
|
||||
//
|
||||
// The live relay asks for ~640 px at quality 60 - about a third the bytes of
|
||||
// the full frame - because it sends several a second up a shop's uplink, where
|
||||
// the snapshot sends one a minute and can afford the detail. The engine does
|
||||
// the re-encode: it already has OpenCV open and the frame in memory, and
|
||||
// shipping a scaler into the agent to redo that would be the same work twice.
|
||||
func (e *EngineClient) Frame(ctx context.Context, id string, width, quality int) ([]byte, error) {
|
||||
q := url.Values{}
|
||||
if width > 0 {
|
||||
q.Set("width", strconv.Itoa(width))
|
||||
}
|
||||
if quality > 0 {
|
||||
q.Set("quality", strconv.Itoa(quality))
|
||||
}
|
||||
target := e.Base + "/api/cameras/" + url.PathEscape(id) + "/frame.jpg"
|
||||
if len(q) > 0 {
|
||||
target += "?" + q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,6 +233,64 @@ func (c *CloudClient) UploadSnapshot(ctx context.Context, jpeg []byte) (string,
|
||||
return c.Upload(ctx, jpeg)
|
||||
}
|
||||
|
||||
// PutSnapshot gets a camera's latest frame to head office by whichever route
|
||||
// that deployment has.
|
||||
//
|
||||
// The bucket first: a presigned PUT goes straight to object storage and never
|
||||
// passes through the API, which is what makes it the right route at estate
|
||||
// scale. When there is no bucket the picture goes to the server itself, which
|
||||
// stores one row per camera. Without this second route head office reported
|
||||
// "This system is not storing images" for every camera forever, on the screen
|
||||
// whose entire job is to show the camera.
|
||||
//
|
||||
// The returned key is empty for the direct route - there is no object to name -
|
||||
// and the server records the picture as it stores it, so the state report has
|
||||
// nothing to carry.
|
||||
func (c *CloudClient) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
|
||||
if c.Upload != nil {
|
||||
key, err := c.Upload(ctx, jpeg)
|
||||
if err == nil {
|
||||
return key, nil
|
||||
}
|
||||
// A bucket that is configured here but disabled at the server is the
|
||||
// ordinary case on a self-hosted install: fall through rather than
|
||||
// giving up, and let the direct route decide.
|
||||
if !isImagesDisabled(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", c.putSnapshotDirect(ctx, cameraID, jpeg)
|
||||
}
|
||||
|
||||
func (c *CloudClient) putSnapshotDirect(ctx context.Context, cameraID string, jpeg []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
|
||||
c.Base+"/api/agent/cameras/"+url.PathEscape(cameraID)+"/snapshot",
|
||||
bytes.NewReader(jpeg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("head office: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isImagesDisabled recognises the server saying it has no object storage.
|
||||
//
|
||||
// A sentinel, not a string match on the message: this decides whether to take a
|
||||
// completely different route, and getting it wrong from prose that somebody
|
||||
// later rewords would silently stop every camera picture in the estate.
|
||||
func isImagesDisabled(err error) bool {
|
||||
return errors.Is(err, bridge.ErrImagesOff)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- probing
|
||||
|
||||
// Test opens the candidate stream once, without saving it.
|
||||
|
||||
242
agent/pkg/cameras/live.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Live relays camera frames to head office, but only while somebody is
|
||||
// watching.
|
||||
//
|
||||
// The engine serves MJPEG on this PC's loopback and this PC sits behind a
|
||||
// router with no inbound route, so head office cannot pull it. It can answer
|
||||
// our outbound requests, which is the shape of everything else here: we ask
|
||||
// "is anyone watching?", and push frames for as long as the answer is yes.
|
||||
//
|
||||
// It is a few frames a second of re-encoded JPEG, not 25 fps video. True video
|
||||
// needs WebRTC and a TURN server; this needs neither, and answers the question
|
||||
// somebody at head office is actually asking - what does that camera see right
|
||||
// now - at a cost a shop's uplink can carry.
|
||||
//
|
||||
// **Nothing is uploaded when nobody is looking.** That is the entire cost
|
||||
// argument, and it is why the wanted-check comes first and the push stops the
|
||||
// moment the server says the last viewer has gone.
|
||||
type Live struct {
|
||||
Engine *EngineClient
|
||||
Cloud *CloudClient
|
||||
Log *log.Logger
|
||||
FPS float64
|
||||
Width int
|
||||
Quality int
|
||||
}
|
||||
|
||||
// Defaults, measured against the office camera rather than guessed.
|
||||
//
|
||||
// The engine produces ~12 distinct frames a second, so asking for more than
|
||||
// that only re-sends pictures the viewer already has - which is why the poll
|
||||
// runs slightly ahead of it and identical frames are dropped rather than sent.
|
||||
// 640 px at quality 60 is ~20 KB, so a watcher costs ~200 KB/s at the full
|
||||
// rate, and a camera nobody is watching costs nothing at all.
|
||||
const (
|
||||
DefaultLiveFPS = 15.0
|
||||
DefaultLiveWidth = 640
|
||||
DefaultLiveQuality = 60
|
||||
)
|
||||
|
||||
func NewLive(eng *EngineClient, cloud *CloudClient, logger *log.Logger) *Live {
|
||||
return &Live{Engine: eng, Cloud: cloud, Log: logger,
|
||||
FPS: DefaultLiveFPS, Width: DefaultLiveWidth, Quality: DefaultLiveQuality}
|
||||
}
|
||||
|
||||
// Run waits for viewers and serves them until the context ends.
|
||||
func (l *Live) Run(ctx context.Context) {
|
||||
if l.Engine == nil || l.Cloud == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
wanted, err := l.Cloud.LiveWanted(ctx)
|
||||
if err != nil {
|
||||
// Unclaimed, offline, or head office is down. All three mean the
|
||||
// same thing here - nobody can be watching - so back off rather
|
||||
// than hammering, and keep the shop's own recognition untouched.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
l.sleep(ctx, 15*time.Second)
|
||||
continue
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
// The poll is held open by the server, so an empty answer already
|
||||
// means ~25 s passed. No extra delay.
|
||||
continue
|
||||
}
|
||||
for _, id := range wanted {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
l.serve(ctx, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serve pushes frames for one camera until the server says stop.
|
||||
func (l *Live) serve(ctx context.Context, cameraID string) {
|
||||
engineID, err := l.Cloud.LiveEngineID(ctx, cameraID)
|
||||
if err != nil {
|
||||
l.logf("live %s: %v", cameraID, err)
|
||||
l.sleep(ctx, 2*time.Second)
|
||||
return
|
||||
}
|
||||
interval := time.Duration(float64(time.Second) / l.fps())
|
||||
|
||||
// A pipe so frames can be written as they are grabbed while one request
|
||||
// carries all of them. A request per frame would spend more on handshakes
|
||||
// and headers than on pictures.
|
||||
pr, pw := io.Pipe()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- l.Cloud.PushLive(ctx, cameraID, pr) }()
|
||||
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
// The engine re-serves its latest frame until the pipeline produces a new
|
||||
// one, so polling faster than it encodes returns the SAME picture again.
|
||||
// Measured: 93 polls in 6 s yielded 72 distinct frames. Sending the
|
||||
// duplicates would cost a fifth of the bandwidth for nothing, so the poll
|
||||
// runs a little ahead of the engine and the repeats are dropped - which is
|
||||
// what lets the rate follow the camera instead of a guess.
|
||||
var lastSum [32]byte
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = pw.CloseWithError(context.Canceled)
|
||||
<-done
|
||||
return
|
||||
case err := <-done:
|
||||
// The server closed the request: the last viewer went away, or the
|
||||
// session cap was reached. Either way stop grabbing frames.
|
||||
_ = pw.Close()
|
||||
if err != nil {
|
||||
l.logf("live %s ended: %v", cameraID, err)
|
||||
}
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
jpeg, err := l.Engine.Frame(ctx, engineID, l.Width, l.Quality)
|
||||
if err != nil || len(jpeg) == 0 {
|
||||
// A camera that is reconnecting has no frame. Keep the request
|
||||
// open - the viewer sees the last frame rather than a dropped
|
||||
// stream, and the next tick may well have one.
|
||||
continue
|
||||
}
|
||||
if sum := sha256.Sum256(jpeg); sum == lastSum {
|
||||
continue
|
||||
} else {
|
||||
lastSum = sum
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(jpeg)))
|
||||
if _, err := pw.Write(hdr[:]); err != nil {
|
||||
<-done
|
||||
return
|
||||
}
|
||||
if _, err := pw.Write(jpeg); err != nil {
|
||||
<-done
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Live) fps() float64 {
|
||||
if l.FPS <= 0 || l.FPS > 25 {
|
||||
// A ceiling rather than a target: duplicate frames are dropped, so
|
||||
// polling above what the engine encodes costs requests and no
|
||||
// bandwidth - but it is still work, on the PC doing the recognition.
|
||||
return DefaultLiveFPS
|
||||
}
|
||||
return l.FPS
|
||||
}
|
||||
|
||||
func (l *Live) sleep(ctx context.Context, d time.Duration) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Live) logf(format string, args ...any) {
|
||||
if l.Log != nil {
|
||||
l.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ wire --
|
||||
|
||||
// LiveWanted asks head office which of this site's cameras are being watched.
|
||||
// The server holds the request open, so this returns promptly when somebody
|
||||
// presses Live and after ~25 s when nobody has.
|
||||
func (c *CloudClient) LiveWanted(ctx context.Context) ([]string, error) {
|
||||
var body struct {
|
||||
Cameras []string `json:"cameras"`
|
||||
}
|
||||
// Longer than the server's own wait, so a held request is not cut off by
|
||||
// our own client timeout and reported as a failure.
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
if err := c.do(ctx, http.MethodGet, "/api/agent/live", nil, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body.Cameras, nil
|
||||
}
|
||||
|
||||
// LiveEngineID maps head office's camera uuid to the name the engine knows,
|
||||
// which is the only name this PC can ask for a frame with.
|
||||
func (c *CloudClient) LiveEngineID(ctx context.Context, cameraID string) (string, error) {
|
||||
desired, err := c.Desired(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, d := range desired {
|
||||
if d.ID == cameraID {
|
||||
return d.CameraID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("camera %s is not one of this site's", cameraID)
|
||||
}
|
||||
|
||||
// PushLive streams frames until the server stops reading.
|
||||
func (c *CloudClient) PushLive(ctx context.Context, cameraID string, body io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.Base+"/api/agent/cameras/"+cameraID+"/live", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("head office: %s: %s", resp.Status, bytes.TrimSpace(blob))
|
||||
}
|
||||
var out struct {
|
||||
Frames int `json:"frames"`
|
||||
}
|
||||
_ = json.Unmarshal(blob, &out)
|
||||
return nil
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -37,7 +39,18 @@ type Config struct {
|
||||
BrokerCAFile string `json:"broker_ca_file"`
|
||||
|
||||
// Engine process.
|
||||
EngineExe string `json:"engine_exe"`
|
||||
EngineExe string `json:"engine_exe"`
|
||||
// EngineDir is the working directory the engine is launched IN. Empty
|
||||
// means the install root.
|
||||
//
|
||||
// It exists because nothing set it, so the engine inherited whatever
|
||||
// launched the app - and for an app started by double-clicking its
|
||||
// bundle that is "/", not anywhere useful. The symptom on macOS was
|
||||
// `python: No module named behavision` repeating forever: the dev engine
|
||||
// is `-m behavision`, which resolves against the working directory. The
|
||||
// same app started from a terminal in the repo worked, which is exactly
|
||||
// the shape of a bug that survives every test run by a developer.
|
||||
EngineDir string `json:"engine_dir,omitempty"`
|
||||
EngineArgs []string `json:"engine_args"`
|
||||
APIBase string `json:"api_base"`
|
||||
APIUser string `json:"api_user"`
|
||||
@@ -72,6 +85,10 @@ type Config struct {
|
||||
// Queue.
|
||||
SpoolMax int `json:"spool_max"`
|
||||
|
||||
// InstallID distinguishes THIS installation from every other one claimed
|
||||
// to the same site. See MQTTClientID.
|
||||
InstallID string `json:"install_id,omitempty"`
|
||||
|
||||
path string
|
||||
}
|
||||
|
||||
@@ -113,6 +130,14 @@ func Load(path string) (Config, error) {
|
||||
return cfg, fmt.Errorf("config %s: %w", path, err)
|
||||
}
|
||||
cfg.path = path
|
||||
// Minted on first load and written back, so an installation that predates
|
||||
// this field gets one without anybody doing anything. Best effort: a
|
||||
// read-only config still yields a working id for this run, it is simply
|
||||
// not the same one next time.
|
||||
if cfg.InstallID == "" {
|
||||
cfg.InstallID = newInstallID()
|
||||
_ = cfg.Save(path)
|
||||
}
|
||||
for _, field := range []*string{&cfg.BrokerPassword, &cfg.APIPassword,
|
||||
&cfg.SessionToken, &cfg.SessionRefresh, &cfg.AgentToken} {
|
||||
plain, err := reveal(*field)
|
||||
@@ -199,3 +224,41 @@ func reveal(stored string) (string, error) {
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// MQTTClientID names this INSTALLATION, not this site.
|
||||
//
|
||||
// It was `behavision-<client>-<site>`, which is the same string on every
|
||||
// computer claimed to one shop. MQTT requires client ids to be unique and a
|
||||
// broker enforces it by disconnecting the older session when a new one
|
||||
// arrives with the same id - so two machines on one site take turns kicking
|
||||
// each other off, forever. Measured on a second Mac claimed to a live shop:
|
||||
//
|
||||
// broker connected / broker connection lost: EOF / broker connected / ...
|
||||
//
|
||||
// The damage is not confined to the new machine. The shop's own till is the
|
||||
// other half of that loop, so somebody signing in on a laptop to look at the
|
||||
// product stops the shop delivering visits - and nothing at either end says
|
||||
// why, because from each side it reads as an unstable network.
|
||||
//
|
||||
// The site stays in the id because it is what a broker log is read by, and
|
||||
// the random half is short for the same reason. `CleanSession(true)` means
|
||||
// there is no session state for a changed id to strand.
|
||||
func (c Config) MQTTClientID() string {
|
||||
id := c.InstallID
|
||||
if id == "" {
|
||||
// A config that could not be written still has to produce a UNIQUE
|
||||
// id, or this falls straight back into the collision it exists to
|
||||
// prevent. Per-run is the right failure: the connection works and the
|
||||
// only cost is a new name in the broker's log after a restart.
|
||||
id = newInstallID()
|
||||
}
|
||||
return "behavision-" + c.ClientID + "-" + c.SiteID + "-" + id
|
||||
}
|
||||
|
||||
func newInstallID() string {
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "x"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// EngineCredentials reads the Basic credentials the engine generated for
|
||||
@@ -60,3 +61,60 @@ func (c Config) WithEngineCredentials(path string) Config {
|
||||
c.APIUser, c.APIPassword = EngineCredentials(path)
|
||||
return c
|
||||
}
|
||||
|
||||
// Creds resolves the engine's Basic credentials, re-reading the file when it
|
||||
// has none.
|
||||
//
|
||||
// Reading once at startup is wrong on a fresh install, and that is the case
|
||||
// that matters: the agent starts the engine, the engine generates its
|
||||
// credential and writes the file a few seconds later, and an agent that read
|
||||
// the file before that holds "" forever. Every call it makes - health, stats,
|
||||
// camera sync, the embedding for a visit - then comes back 401 for the life of
|
||||
// the process, on a brand new shop PC, with the tray showing a red engine that
|
||||
// is running perfectly. Measured on a fresh state directory: three 401s and no
|
||||
// camera ever reconciled.
|
||||
//
|
||||
// A configured credential is never re-read: an operator who set
|
||||
// BEHAVISION_API_USER means it.
|
||||
type Creds struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
user string
|
||||
pass string
|
||||
fixed bool
|
||||
}
|
||||
|
||||
// NewCreds takes whatever the config already has. Non-empty means configured,
|
||||
// and is used unchanged.
|
||||
func NewCreds(path, user, password string) *Creds {
|
||||
c := &Creds{path: path, user: user, pass: password}
|
||||
c.fixed = user != "" || password != ""
|
||||
return c
|
||||
}
|
||||
|
||||
// Get returns the current pair, reading the file if it has nothing yet.
|
||||
func (c *Creds) Get() (string, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.user == "" && !c.fixed {
|
||||
c.user, c.pass = EngineCredentials(c.path)
|
||||
}
|
||||
return c.user, c.pass
|
||||
}
|
||||
|
||||
// Refresh re-reads the file after a rejection and reports whether the pair
|
||||
// changed. Callers retry once when it did - which covers both the fresh-install
|
||||
// race and a credential the engine regenerated under a running agent.
|
||||
func (c *Creds) Refresh() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.fixed {
|
||||
return false
|
||||
}
|
||||
u, p := EngineCredentials(c.path)
|
||||
if u == c.user && p == c.pass {
|
||||
return false
|
||||
}
|
||||
c.user, c.pass = u, p
|
||||
return u != ""
|
||||
}
|
||||
|
||||
77
agent/pkg/config/credentials_firstrun_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The sequence on a brand new shop PC, in order:
|
||||
//
|
||||
// agent starts -> file does not exist yet
|
||||
// agent starts the engine
|
||||
// engine generates its credential and writes the file
|
||||
// agent calls the engine -> must now succeed
|
||||
//
|
||||
// Read once at startup, the agent holds "" for the life of the process and
|
||||
// every engine call is 401: health, stats, camera sync, the embedding for a
|
||||
// visit. The tray shows a red engine that is running perfectly, and nothing
|
||||
// says why. Measured on a fresh state directory before this existed.
|
||||
func TestCredentialsArriveAfterTheAgentHasAlreadyLooked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
|
||||
creds := NewCreds(path, "", "") // nothing configured, file not there yet
|
||||
if u, _ := creds.Get(); u != "" {
|
||||
t.Fatalf("expected no credential before the engine has written one, got %q", u)
|
||||
}
|
||||
|
||||
// the engine starts and writes its credential
|
||||
if err := os.WriteFile(path, []byte("username=behavision\npassword=s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// a 401 makes the agent look again
|
||||
if !creds.Refresh() {
|
||||
t.Fatal("Refresh did not pick up the credential the engine just wrote")
|
||||
}
|
||||
u, p := creds.Get()
|
||||
if u != "behavision" || p != "s3cret" {
|
||||
t.Fatalf("got %q/%q", u, p)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator who set BEHAVISION_API_USER means it, and a file must never
|
||||
// override them.
|
||||
func TestAConfiguredCredentialIsNeverReplacedByTheFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
if err := os.WriteFile(path, []byte("username=generated\npassword=nope\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creds := NewCreds(path, "chosen", "byhand")
|
||||
if u, p := creds.Get(); u != "chosen" || p != "byhand" {
|
||||
t.Fatalf("configured credential was replaced: %q/%q", u, p)
|
||||
}
|
||||
if creds.Refresh() {
|
||||
t.Fatal("Refresh overrode a configured credential")
|
||||
}
|
||||
}
|
||||
|
||||
// A credential the engine regenerates under a running agent is picked up too -
|
||||
// the same mechanism, and the reason paths.APICredentials says the agent reads
|
||||
// the file "rather than storing a second copy".
|
||||
func TestARegeneratedCredentialIsPickedUp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
os.WriteFile(path, []byte("username=behavision\npassword=old\n"), 0o600)
|
||||
creds := NewCreds(path, "", "")
|
||||
creds.Get()
|
||||
os.WriteFile(path, []byte("username=behavision\npassword=new\n"), 0o600)
|
||||
if !creds.Refresh() {
|
||||
t.Fatal("a regenerated password was not picked up")
|
||||
}
|
||||
if _, p := creds.Get(); p != "new" {
|
||||
t.Fatalf("still holding %q", p)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
140
agent/pkg/demo/bundle.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Package demo seals a camera list so a release can carry it without carrying
|
||||
// the credentials in any usable form.
|
||||
//
|
||||
// The need: a demo build that installs with the office cameras already set up,
|
||||
// handed to people who should not be able to read the cameras' admin password
|
||||
// out of the zip. "Encode it" does not do that - anything the installer can
|
||||
// decode, anyone holding the installer can decode. So the bundle is encrypted
|
||||
// with a key that is NOT in the package: a short unlock code, generated when
|
||||
// the bundle is sealed, spoken or messaged to whoever runs setup, and typed
|
||||
// once. Without it the file is noise.
|
||||
//
|
||||
// The code is random, not chosen, so it is used as key material directly
|
||||
// (through SHA-256) rather than stretched with a KDF. A human-chosen
|
||||
// passphrase would need argon2 and a dependency; 120 random bits do not.
|
||||
package demo
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Magic identifies the file and the format version, so a future change can be
|
||||
// told apart from corruption instead of failing as "authentication failed".
|
||||
const magic = "BVDEMO1\n"
|
||||
|
||||
// Payload is what a sealed bundle carries. Two demo shapes exist:
|
||||
//
|
||||
// - cameras only: the PC runs on its own with these cameras (the first
|
||||
// demo build);
|
||||
// - an enrolment code: the PC claims a real shop at head office and gets
|
||||
// its cameras from there, exactly as a customer install would, so the
|
||||
// demo exercises the whole product rather than a local copy of it. The
|
||||
// code is single-use, so one bundle is one install.
|
||||
//
|
||||
// A bundle from the first build is a bare JSON array; Decode accepts both.
|
||||
type Payload struct {
|
||||
Cameras []Camera `json:"cameras,omitempty"`
|
||||
EnrolCode string `json:"enrol_code,omitempty"`
|
||||
CloudBase string `json:"cloud_base,omitempty"`
|
||||
}
|
||||
|
||||
// Decode reads either payload shape.
|
||||
func Decode(plain []byte) (Payload, error) {
|
||||
var p Payload
|
||||
if len(plain) > 0 && plain[0] == '[' {
|
||||
return p, json.Unmarshal(plain, &p.Cameras)
|
||||
}
|
||||
return p, json.Unmarshal(plain, &p)
|
||||
}
|
||||
|
||||
// Camera is one entry as the engine's Add Camera endpoint accepts it.
|
||||
type Camera struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MaxWidth int `json:"max_width,omitempty"`
|
||||
}
|
||||
|
||||
// NewCode mints an unlock code: 15 random bytes as 24 base32 characters in
|
||||
// four groups, the same shape as an installation code, for the same reason -
|
||||
// it gets read down a phone.
|
||||
func NewCode() (string, error) {
|
||||
raw := make([]byte, 15)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
|
||||
return fmt.Sprintf("%s-%s-%s-%s", s[0:6], s[6:12], s[12:18], s[18:24]), nil
|
||||
}
|
||||
|
||||
// NormalizeCode makes the typed and the printed form hash the same: case,
|
||||
// spaces and dashes are all noise a person adds or drops.
|
||||
func NormalizeCode(code string) string {
|
||||
code = strings.ToUpper(code)
|
||||
code = strings.NewReplacer("-", "", " ", "", "\t", "", "\r", "", "\n", "").Replace(code)
|
||||
return code
|
||||
}
|
||||
|
||||
func keyFor(code string) []byte {
|
||||
sum := sha256.Sum256([]byte("behavision-demo-bundle:" + NormalizeCode(code)))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Seal encrypts plaintext under the code. Output is magic || nonce || ciphertext.
|
||||
func Seal(code string, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := append([]byte(magic), nonce...)
|
||||
return gcm.Seal(out, nonce, plaintext, []byte(magic)), nil
|
||||
}
|
||||
|
||||
// ErrWrongCode is what a mistyped code looks like. GCM cannot tell a wrong key
|
||||
// from a corrupted file, and neither can we, so both read as this.
|
||||
var ErrWrongCode = errors.New("that unlock code does not open this bundle")
|
||||
|
||||
// Open decrypts a sealed bundle.
|
||||
func Open(code string, sealed []byte) ([]byte, error) {
|
||||
if !strings.HasPrefix(string(sealed), magic) {
|
||||
return nil, errors.New("not a Behavision demo bundle")
|
||||
}
|
||||
body := sealed[len(magic):]
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) < gcm.NonceSize() {
|
||||
return nil, errors.New("bundle is truncated")
|
||||
}
|
||||
nonce, ct := body[:gcm.NonceSize()], body[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ct, []byte(magic))
|
||||
if err != nil {
|
||||
return nil, ErrWrongCode
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
87
agent/pkg/demo/bundle_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealedBundleRoundTripsWithTheCodeAsTyped(t *testing.T) {
|
||||
code, err := NewCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(NormalizeCode(code)) != 24 {
|
||||
t.Fatalf("code should be 24 base32 chars, got %q", code)
|
||||
}
|
||||
secret := []byte(`[{"id":"cam1","password":"the-camera-admin-password"}]`)
|
||||
|
||||
sealed, err := Seal(code, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// People type codes in lower case, with the dashes dropped, with a space
|
||||
// where a dash was. All of those are the same code.
|
||||
for _, typed := range []string{
|
||||
code,
|
||||
strings.ToLower(code),
|
||||
strings.ReplaceAll(code, "-", ""),
|
||||
strings.ReplaceAll(code, "-", " "),
|
||||
" " + code + "\n",
|
||||
} {
|
||||
got, err := Open(typed, sealed)
|
||||
if err != nil {
|
||||
t.Fatalf("open with %q: %v", typed, err)
|
||||
}
|
||||
if !bytes.Equal(got, secret) {
|
||||
t.Fatalf("round trip changed the contents")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the file: the password is not in it.
|
||||
func TestTheSealedFileDoesNotContainTheSecret(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte(`{"password":"the-camera-admin-password","host":"192.168.1.121"}`))
|
||||
for _, leak := range []string{"the-camera-admin-password", "192.168.1.121", "password"} {
|
||||
if bytes.Contains(sealed, []byte(leak)) {
|
||||
t.Fatalf("sealed bundle contains %q in the clear", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongCodeIsRefusedNotMisread(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
other, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte("secret"))
|
||||
|
||||
if _, err := Open(other, sealed); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a different code should be ErrWrongCode, got %v", err)
|
||||
}
|
||||
// One flipped byte in the ciphertext is the same answer: GCM refuses
|
||||
// rather than returning garbage that then gets written into cameras.json.
|
||||
tampered := append([]byte{}, sealed...)
|
||||
tampered[len(tampered)-1] ^= 0x01
|
||||
if _, err := Open(code, tampered); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a tampered bundle should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotABundleSaysSo(t *testing.T) {
|
||||
if _, err := Open("ABCDEF-GHIJKL-MNOPQR-STUVWX", []byte("hello")); err == nil ||
|
||||
errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a non-bundle should be named as such, not blamed on the code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two seals of the same plaintext under the same code must differ: a fixed
|
||||
// nonce would let two releases' bundles be compared byte for byte.
|
||||
func TestEverySealIsDifferent(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
a, _ := Seal(code, []byte("same"))
|
||||
b, _ := Seal(code, []byte("same"))
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("nonce is not random")
|
||||
}
|
||||
}
|
||||
41
agent/pkg/engine/env.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// ChildEnv is the environment the engine is launched with, wherever it is
|
||||
// launched from - the desktop app and the headless agent both go through
|
||||
// here, so a third caller cannot get it half right.
|
||||
//
|
||||
// The line that matters is BEHAVISION_DATA_DIR.
|
||||
//
|
||||
// The engine's paths.py knows two worlds: frozen with PyInstaller, where state
|
||||
// lives under ProgramData, and a checkout, where everything sits in the repo
|
||||
// root. An engine installed from source into a virtual environment is neither.
|
||||
// Left to itself it resolves its state root to site-packages - writes its
|
||||
// database and camera list there, and generates its API credential into a
|
||||
// folder this process never reads - while this process resolves the same
|
||||
// state root to ProgramData. The two halves then disagree about where
|
||||
// everything lives, and every call to the engine is 401 on a stock install,
|
||||
// with nothing in either log saying why. Seen twice: once on a Mac checkout
|
||||
// (the app in ~/Library, the engine in the repo) and once in a clean Linux
|
||||
// container running the installer.
|
||||
//
|
||||
// Telling the engine where THIS process keeps state makes the two agree by
|
||||
// construction, however the engine was installed. paths.py honours the
|
||||
// override ahead of every other rule it has.
|
||||
//
|
||||
// hookURL is where the engine posts detections; empty is allowed and means
|
||||
// the bridge has not started, which the engine treats as "no webhook".
|
||||
func ChildEnv(hookURL string) []string {
|
||||
env := append(os.Environ(),
|
||||
"BEHAVISION_DATA_DIR="+paths.StateRoot(),
|
||||
)
|
||||
if hookURL != "" {
|
||||
env = append(env, "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||
}
|
||||
return env
|
||||
}
|
||||
44
agent/pkg/engine/env_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// The engine must be told where THIS process keeps state, or a pip-installed
|
||||
// engine decides on site-packages and the two halves never find each other.
|
||||
func TestTheEngineIsToldWhereStateLives(t *testing.T) {
|
||||
t.Setenv("BEHAVISION_DATA_DIR", t.TempDir())
|
||||
|
||||
env := ChildEnv("http://127.0.0.1:5555/events")
|
||||
|
||||
want := "BEHAVISION_DATA_DIR=" + paths.StateRoot()
|
||||
if !contains(env, want) {
|
||||
t.Fatalf("engine env lacks %q - a source-installed engine would put its "+
|
||||
"database and credential somewhere this process never looks", want)
|
||||
}
|
||||
if !contains(env, "BEHAVISION_WEBHOOK_URL=http://127.0.0.1:5555/events") {
|
||||
t.Fatal("webhook url not passed to the engine")
|
||||
}
|
||||
}
|
||||
|
||||
// Before the bridge has a port there is no webhook. An empty variable would be
|
||||
// read by the engine as a webhook at "", which is not the same as none.
|
||||
func TestNoWebhookMeansNoVariable(t *testing.T) {
|
||||
for _, v := range ChildEnv("") {
|
||||
if strings.HasPrefix(v, "BEHAVISION_WEBHOOK_URL=") {
|
||||
t.Fatalf("empty hook still exported: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(env []string, want string) bool {
|
||||
for _, v := range env {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -21,7 +21,12 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -56,6 +61,9 @@ type Options struct {
|
||||
// no captured output is undiagnosable, which on a customer site means a
|
||||
// site visit.
|
||||
LogWriter io.Writer
|
||||
// Creds re-reads the engine's generated credential when one is rejected,
|
||||
// which is the ordinary case on a first run.
|
||||
Creds *config.Creds
|
||||
// HealthURL, StatsURL, User, Password address the engine's own API.
|
||||
HealthURL string
|
||||
StatsURL string
|
||||
@@ -76,6 +84,7 @@ type Supervisor struct {
|
||||
restarts int
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
progress Progress
|
||||
}
|
||||
|
||||
func New(opts Options) *Supervisor {
|
||||
@@ -109,6 +118,37 @@ func (s *Supervisor) Start() {
|
||||
go s.supervise(ctx, done)
|
||||
}
|
||||
|
||||
// Progress is what the engine is busy with before it answers - on first run,
|
||||
// downloading ~275 MB of models. Empty once the engine is up.
|
||||
type Progress struct {
|
||||
What string `json:"what"`
|
||||
Percent int `json:"percent"`
|
||||
}
|
||||
|
||||
var progressRe = regexp.MustCompile(`download: (.+?) (\d{1,3})%`)
|
||||
|
||||
func (s *Supervisor) noteProgress(line string) {
|
||||
m := progressRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
pct, _ := strconv.Atoi(m[2])
|
||||
s.mu.Lock()
|
||||
if pct >= 100 {
|
||||
s.progress = Progress{}
|
||||
} else {
|
||||
s.progress = Progress{What: m[1], Percent: pct}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Progress reports the current first-run download, if any.
|
||||
func (s *Supervisor) Progress() Progress {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.progress
|
||||
}
|
||||
|
||||
// Stop asks the engine to exit and waits for it.
|
||||
func (s *Supervisor) Stop() {
|
||||
s.mu.Lock()
|
||||
@@ -194,22 +234,66 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
// Cancel ends the whole process tree, not just the process exec spawned.
|
||||
// `kill` is filled in after Start, once the tree is confined; until then
|
||||
// it is exec's own behaviour.
|
||||
var kill func() error
|
||||
cmd.Cancel = func() error {
|
||||
if kill == nil {
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
return kill()
|
||||
}
|
||||
// Cancel sends the kill; WaitDelay bounds how long Wait() then waits for
|
||||
// the output pipes to close. Without it Wait() blocks until every writer
|
||||
// is gone - and Stop() blocks on Wait() - so one grandchild still holding
|
||||
// the engine's stdout hangs Stop FOREVER, which on the desktop app means
|
||||
// the tray's Quit never returns. stopGrace was declared for exactly this
|
||||
// and never wired to anything; staticcheck found it as an unused const.
|
||||
cmd.WaitDelay = stopGrace
|
||||
prepare(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("engine failed to start: %w", err)
|
||||
}
|
||||
k, release, err := confine(cmd)
|
||||
if err != nil {
|
||||
// Not fatal: the engine runs, and stopping it falls back to killing
|
||||
// the one process. Logged because on Windows that fallback is the
|
||||
// bug this exists to fix.
|
||||
fmt.Fprintf(s.opts.LogWriter, "supervisor: could not confine engine process tree: %v\n", err)
|
||||
}
|
||||
kill = k
|
||||
defer release()
|
||||
|
||||
// The last few lines the engine printed travel with the failure, because
|
||||
// "engine exited: exit status 1" sends somebody to a log file on a shop
|
||||
// PC, and the one line that matters - "port 8010 is already in use" - was
|
||||
// right there.
|
||||
var tailMu sync.Mutex
|
||||
var tail []string
|
||||
pumped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pumped)
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
fmt.Fprintln(s.opts.LogWriter, sc.Text())
|
||||
line := sc.Text()
|
||||
fmt.Fprintln(s.opts.LogWriter, line)
|
||||
s.noteProgress(line)
|
||||
tailMu.Lock()
|
||||
tail = append(tail, line)
|
||||
if len(tail) > 12 {
|
||||
tail = tail[1:]
|
||||
}
|
||||
tailMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
s.setState(Running, nil)
|
||||
waitErr := cmd.Wait()
|
||||
s.mu.Lock()
|
||||
s.progress = Progress{}
|
||||
s.mu.Unlock()
|
||||
<-pumped
|
||||
|
||||
// A context cancel terminates the child through exec's own handling; the
|
||||
@@ -218,6 +302,12 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
if waitErr != nil {
|
||||
tailMu.Lock()
|
||||
reason := explain(tail)
|
||||
tailMu.Unlock()
|
||||
if reason != "" {
|
||||
return fmt.Errorf("%s (%v)", reason, waitErr)
|
||||
}
|
||||
return fmt.Errorf("engine exited: %w", waitErr)
|
||||
}
|
||||
return errors.New("engine exited unexpectedly with status 0")
|
||||
@@ -331,14 +421,24 @@ func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.opts.User != "" {
|
||||
req.SetBasicAuth(s.opts.User, s.opts.Password)
|
||||
user, pass := s.opts.User, s.opts.Password
|
||||
if s.opts.Creds != nil {
|
||||
user, pass = s.opts.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized && s.opts.Creds != nil && s.opts.Creds.Refresh() {
|
||||
// See cameras.EngineClient: on a fresh install the engine writes its
|
||||
// credential after the agent has already read for one.
|
||||
resp.Body.Close()
|
||||
return s.getJSON(ctx, url, out)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s returned %s", url, resp.Status)
|
||||
}
|
||||
@@ -353,3 +453,28 @@ func LogFile(path string) (*os.File, error) {
|
||||
}
|
||||
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
}
|
||||
|
||||
// explain turns the engine's last output into the sentence the tray shows.
|
||||
// The cases are the ones seen on real installs; anything else shows the last
|
||||
// non-empty line verbatim.
|
||||
func explain(tail []string) string {
|
||||
last := ""
|
||||
for _, l := range tail {
|
||||
low := strings.ToLower(l)
|
||||
switch {
|
||||
case strings.Contains(low, "address already in use") || strings.Contains(low, "only one usage of each socket address"):
|
||||
return "port 8010 is already in use - another Behavision or its engine is still running"
|
||||
case strings.Contains(low, "no module named behavision"):
|
||||
return "the engine is not installed in this Python - run behavision-setup again"
|
||||
case strings.Contains(low, "modulenotfounderror") || strings.Contains(low, "importerror"):
|
||||
return "the engine is missing a library - run behavision-setup again"
|
||||
}
|
||||
if strings.TrimSpace(l) != "" {
|
||||
last = strings.TrimSpace(l)
|
||||
}
|
||||
}
|
||||
if len(last) > 120 {
|
||||
last = last[:120] + "…"
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
13
agent/pkg/engine/tree_other.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package engine
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// On every other platform the engine is one process and exec's own kill is
|
||||
// enough. See tree_windows.go for why Windows is not.
|
||||
func prepare(*exec.Cmd) {}
|
||||
|
||||
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
|
||||
return cmd.Process.Kill, func() {}, nil
|
||||
}
|
||||
111
agent/pkg/engine/tree_windows.go
Normal file
@@ -0,0 +1,111 @@
|
||||
//go:build windows
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The engine is not one process on Windows, and stopping it used to leave
|
||||
// recognition running.
|
||||
//
|
||||
// The installer starts it as `<venv>\Scripts\python.exe -m behavision run`.
|
||||
// Since Python 3.7.2 that python.exe is a REDIRECTOR: a small launcher that
|
||||
// spawns the base interpreter as a child and waits for it. Stop() cancelled the
|
||||
// context, exec terminated the launcher, and the interpreter that actually
|
||||
// holds the cameras and the SQLite WAL carried on with no parent, no tray icon
|
||||
// and nothing left that could stop it. Seen on a Windows install: "Quit
|
||||
// Behavision" from the tray, and the engine still running.
|
||||
//
|
||||
// The fix is the primitive Windows has for exactly this: a job object with
|
||||
// KILL_ON_JOB_CLOSE. Every process the engine spawns inherits membership, and
|
||||
// the whole tree dies when the job is terminated or when this process's last
|
||||
// handle to it goes away - so "quitting the app stops recognition" holds even
|
||||
// if the app crashes, which no amount of careful Stop() code can promise.
|
||||
//
|
||||
// The child is started SUSPENDED and resumed only after it is in the job.
|
||||
// Assigning after the fact leaves a window in which the launcher has already
|
||||
// spawned the interpreter outside it, and that window is precisely the case
|
||||
// this file exists to close.
|
||||
|
||||
// prepare is applied to the command before it starts.
|
||||
func prepare(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
// CREATE_NO_WINDOW: python.exe is a console program and Behavision.exe is
|
||||
// not, so without this Windows opens a black console window for the
|
||||
// engine on a shop counter - the app looks like it has crashed into a
|
||||
// terminal. Output still arrives on the pipes.
|
||||
cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED | windows.CREATE_NO_WINDOW
|
||||
}
|
||||
|
||||
// confine is applied after Start. It puts the process in a kill-on-close job,
|
||||
// then resumes it. It returns a function that ends the whole tree, and one
|
||||
// that releases the job handle once the tree has exited.
|
||||
//
|
||||
// If the job cannot be set up the process is still resumed and the plain
|
||||
// terminate remains: a suspended engine that never runs is strictly worse
|
||||
// than one that may outlive its parent.
|
||||
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
|
||||
pid := uint32(cmd.Process.Pid)
|
||||
defer resumeProcess(pid)
|
||||
|
||||
kill = cmd.Process.Kill
|
||||
release = func() {}
|
||||
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return kill, release, fmt.Errorf("create job object: %w", err)
|
||||
}
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("configure job object: %w", err)
|
||||
}
|
||||
proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, pid)
|
||||
if err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("open engine process: %w", err)
|
||||
}
|
||||
defer windows.CloseHandle(proc)
|
||||
if err := windows.AssignProcessToJobObject(job, proc); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("assign engine to job: %w", err)
|
||||
}
|
||||
|
||||
kill = func() error { return windows.TerminateJobObject(job, 1) }
|
||||
release = func() { windows.CloseHandle(job) }
|
||||
return kill, release, nil
|
||||
}
|
||||
|
||||
// resumeProcess resumes every thread of a process started CREATE_SUSPENDED.
|
||||
// exec does not hand back the main thread handle, so it is found through the
|
||||
// toolhelp snapshot; a suspended new process has exactly one.
|
||||
func resumeProcess(pid uint32) {
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer windows.CloseHandle(snap)
|
||||
var te windows.ThreadEntry32
|
||||
te.Size = uint32(unsafe.Sizeof(te))
|
||||
for err = windows.Thread32First(snap, &te); err == nil; err = windows.Thread32Next(snap, &te) {
|
||||
if te.OwnerProcessID != pid {
|
||||
continue
|
||||
}
|
||||
h, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, te.ThreadID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
windows.ResumeThread(h)
|
||||
windows.CloseHandle(h)
|
||||
}
|
||||
}
|
||||
113
agent/pkg/enrol/enrol.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Package enrol links a PC to a shop, using the one-shot code an operator is
|
||||
// given.
|
||||
//
|
||||
// It existed only inside the desktop app, which meant a HEADLESS install - a
|
||||
// back-office PC with no window, the configuration the agent binary is for -
|
||||
// could not be claimed at all. The only route was hand-editing agent.json,
|
||||
// which is exactly the state the desktop's Setup screen was built to end.
|
||||
//
|
||||
// The endpoint behind this is deliberately unauthenticated: the PC doing it has
|
||||
// nobody signed in yet, and requiring a login would mean shipping a password to
|
||||
// every shop that installs the software.
|
||||
package enrol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bootstrap is what the server hands back: which shop this PC is, and the
|
||||
// credentials it needs to say so.
|
||||
type Bootstrap struct {
|
||||
ClientSlug string `json:"client_slug"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
SiteName string `json:"site_name"`
|
||||
MQTTURL string `json:"mqtt_url"`
|
||||
MQTTUser string `json:"mqtt_username"`
|
||||
MQTTPass string `json:"mqtt_password"`
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
// Claim redeems an installation code.
|
||||
//
|
||||
// The code is read aloud down a phone and photographed off screens, so what is
|
||||
// typed here can be as untidy as it needs to be: the server strips spaces,
|
||||
// dashes and case at its end. Sending it as typed keeps ONE implementation of
|
||||
// that normalisation, on the side that also issued the code - two would
|
||||
// eventually disagree and hash to something the redeemer never produces.
|
||||
func Claim(ctx context.Context, base, code string) (Bootstrap, error) {
|
||||
var out Bootstrap
|
||||
base = strings.TrimRight(base, "/")
|
||||
if base == "" {
|
||||
return out, fmt.Errorf("no server address configured (set cloud_base or BEHAVISION_CLOUD)")
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"site_token": code})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
base+"/api/agent/enrol", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("could not reach %s: %w", base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// The server answers unknown, expired and already-used identically on
|
||||
// purpose - the difference only helps somebody guessing codes, and the
|
||||
// operator's next step is the same in all three cases. Its own words
|
||||
// are passed through rather than reworded here.
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(blob, &e)
|
||||
if e.Message != "" {
|
||||
return out, fmt.Errorf("%s", e.Message)
|
||||
}
|
||||
return out, fmt.Errorf("head office: %s", resp.Status)
|
||||
}
|
||||
if err := json.Unmarshal(blob, &out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.SiteSlug == "" || out.MQTTURL == "" {
|
||||
return out, fmt.Errorf("head office returned an incomplete setup")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveCA writes the broker's CA beside the agent config and returns its path.
|
||||
//
|
||||
// The server hands the CA out at enrolment precisely so it never has to be
|
||||
// shipped in an installer - and for a while nothing on the receiving end
|
||||
// wrote it anywhere. Every claimed PC then dialled tls://mcp.loyaly.ai:8883
|
||||
// with the system trust store, the private CA failed verification, and the
|
||||
// agent reported "the broker did not accept this PC" (a TLS failure is
|
||||
// indistinguishable from a refusal at that layer). No real site could ever
|
||||
// publish a visit. An empty CA returns "" so a deployment on a public
|
||||
// certificate keeps working unchanged.
|
||||
func SaveCA(pem, path string) (string, error) {
|
||||
if strings.TrimSpace(pem) == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(pem), 0o600); err != nil {
|
||||
return "", fmt.Errorf("write broker CA: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
neturl "net/url"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -104,7 +105,13 @@ func NewClient(opts ClientOptions) (*Client, error) {
|
||||
|
||||
tok := c.client.Connect()
|
||||
if !tok.WaitTimeout(20 * time.Second) {
|
||||
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL)
|
||||
// SetConnectRetry means paho retries internally and this token never
|
||||
// completes, so a REFUSED connection and an UNREACHABLE broker both
|
||||
// arrive here as a timeout. They need opposite actions - re-link this
|
||||
// PC, or go and look at the network - and reporting both as "timed
|
||||
// out" sent the diagnosis to the wrong place. Measured: mosquitto
|
||||
// logged "not authorised" while the agent logged a timeout.
|
||||
return c, fmt.Errorf("mqtt: %s", describeStall(opts.BrokerURL))
|
||||
}
|
||||
if err := tok.Error(); err != nil {
|
||||
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
|
||||
@@ -112,6 +119,47 @@ func NewClient(opts ClientOptions) (*Client, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// describeStall says which of the two failures this is, by asking the one
|
||||
// question that separates them: can we open a socket to the broker at all?
|
||||
//
|
||||
// It cannot name the exact reason - the broker does not tell a rejected client
|
||||
// why, and a TLS failure looks the same from here - so it says what is known
|
||||
// and what to check, rather than guessing. Being reachable but not accepted is
|
||||
// overwhelmingly a credential this PC no longer has, which is what happens when
|
||||
// a site is re-provisioned.
|
||||
func describeStall(brokerURL string) string {
|
||||
host := brokerHostPort(brokerURL)
|
||||
if host == "" {
|
||||
return fmt.Sprintf("connect to %s timed out", brokerURL)
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", host, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("cannot reach the broker at %s: %v - check the "+
|
||||
"network and that the broker is running", host, err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
return fmt.Sprintf("the broker at %s is reachable but did not accept this "+
|
||||
"PC - usually its credentials are no longer valid; re-link it with "+
|
||||
"`behavision-agent claim <code>`", host)
|
||||
}
|
||||
|
||||
// brokerHostPort extracts host:port for the reachability probe. Parsed with
|
||||
// net/url, never by scanning for the first ":" - an IPv6 literal is bracketed
|
||||
// and full of them.
|
||||
func brokerHostPort(brokerURL string) string {
|
||||
u, err := url.Parse(brokerURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
if u.Port() != "" {
|
||||
return u.Host
|
||||
}
|
||||
if strings.HasPrefix(brokerURL, "tls://") || strings.HasPrefix(brokerURL, "ssl://") {
|
||||
return net.JoinHostPort(u.Hostname(), "8883")
|
||||
}
|
||||
return net.JoinHostPort(u.Hostname(), "1883")
|
||||
}
|
||||
|
||||
// Publish sends one message at QoS 1 and waits for the broker's PUBACK.
|
||||
//
|
||||
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack
|
||||
@@ -176,7 +224,7 @@ func checkTransport(raw string) error {
|
||||
// url.Parse, not hand-rolled splitting: an IPv6 literal is bracketed and
|
||||
// full of colons, so scanning for the first ":" turns "[::1]:1883" into
|
||||
// "[" and refuses a perfectly good loopback address.
|
||||
u, err := neturl.Parse(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mqtt: cannot parse broker url %q: %w", raw, err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -91,7 +92,11 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
|
||||
// The pump calls this on every tick; a nil-client panic would take the
|
||||
// whole agent down instead of backing off.
|
||||
c := &Client{}
|
||||
if err := c.Publish(nil, "t", []byte("{}")); err == nil { //nolint:staticcheck
|
||||
// The nil context is the POINT: the pump must not panic on a client that
|
||||
// never connected. //nolint is golangci-lint's directive and staticcheck
|
||||
// ignores it, which is why this kept being reported.
|
||||
//lint:ignore SA1012 passing nil is what is under test
|
||||
if err := c.Publish(nil, "t", []byte("{}")); err == nil {
|
||||
t.Fatal("publish on an unconnected client reported success")
|
||||
}
|
||||
if c.Connected() {
|
||||
@@ -102,3 +107,66 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
|
||||
func writeFile(path, content string) error {
|
||||
return osWriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
// "The broker refused this PC" and "the broker is not there" need opposite
|
||||
// actions - re-link this PC, or go and look at the network - and paho's
|
||||
// connect-retry makes both arrive as a timeout. Measured on a real broker:
|
||||
// mosquitto logged "not authorised" while the agent logged a timeout, which
|
||||
// sent the diagnosis to the wrong place.
|
||||
func TestARefusedBrokerIsNotDescribedAsUnreachable(t *testing.T) {
|
||||
// A listener that accepts TCP and then says nothing is exactly what a
|
||||
// broker rejecting a client looks like from out here.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = c
|
||||
}
|
||||
}()
|
||||
|
||||
got := describeStall("tcp://" + ln.Addr().String())
|
||||
if !strings.Contains(got, "reachable but did not accept") {
|
||||
t.Fatalf("a reachable broker was described as unreachable: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "claim") {
|
||||
t.Errorf("the message does not say what to do about it: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAbsentBrokerIsDescribedAsUnreachable(t *testing.T) {
|
||||
// Bound and immediately closed, so the port is certainly nobody's.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
|
||||
got := describeStall("tcp://" + addr)
|
||||
if !strings.Contains(got, "cannot reach the broker") {
|
||||
t.Fatalf("an absent broker was not described as unreachable: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An IPv6 literal is bracketed and full of colons, so scanning for the first
|
||||
// one gives "[". The same bug this package already fixed once for broker URLs.
|
||||
func TestTheProbeAddressHandlesIPv6AndDefaultPorts(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"tcp://127.0.0.1:51883", "127.0.0.1:51883"},
|
||||
{"tcp://[::1]:1883", "[::1]:1883"},
|
||||
{"tcp://broker.example", "broker.example:1883"},
|
||||
{"tls://broker.example", "broker.example:8883"},
|
||||
{"tls://[2001:db8::1]:8884", "[2001:db8::1]:8884"},
|
||||
} {
|
||||
if got := brokerHostPort(tc.in); got != tc.want {
|
||||
t.Errorf("brokerHostPort(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,11 @@ func InstallRoot() string {
|
||||
}
|
||||
|
||||
func AgentConfig() string { return filepath.Join(StateRoot(), "agent.json") }
|
||||
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
|
||||
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
|
||||
|
||||
// BrokerCA is the broker's CA certificate, written at enrolment.
|
||||
func BrokerCA() string { return filepath.Join(StateRoot(), "broker-ca.crt") }
|
||||
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
|
||||
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
|
||||
|
||||
// APICredentials is the file the engine writes when it generates its own
|
||||
// Basic credentials. The agent reads it rather than storing a second copy,
|
||||
@@ -67,6 +70,12 @@ func APICredentials() string {
|
||||
return filepath.Join(StateRoot(), "data", "api_credentials.txt")
|
||||
}
|
||||
|
||||
// CamerasFile is the engine's own camera store. The agent never edits it -
|
||||
// cameras go through the engine's API so passwords are sealed - but setup
|
||||
// removes it when a PC joins a shop, because head office is the source of
|
||||
// truth from then on.
|
||||
func CamerasFile() string { return filepath.Join(StateRoot(), "data", "cameras.json") }
|
||||
|
||||
// EnsureState creates the writable tree. Called before anything opens a file
|
||||
// under it, so a first run on a fresh machine does not fail on a missing dir.
|
||||
func EnsureState() error {
|
||||
|
||||
BIN
agent/rsrc_windows_amd64.syso
Normal file
@@ -21,6 +21,17 @@ def cmd_run(args: argparse.Namespace) -> int:
|
||||
|
||||
cfg = load_config(args.config)
|
||||
setup_logging(cfg.app.log_level, cfg.app.data_dir)
|
||||
if cfg.app.detect_threads > 0:
|
||||
# OpenCV sizes its pool for one big job on an idle machine. This is a
|
||||
# small job repeated forever on a machine also running the recogniser,
|
||||
# the tracker and possibly three other cameras, so the default costs
|
||||
# twice the CPU for no useful latency. Measured: 31 ms CPU/frame at the
|
||||
# default against 15 ms at one thread, for 6 ms more wall time against
|
||||
# a 66 ms budget.
|
||||
import cv2
|
||||
cv2.setNumThreads(cfg.app.detect_threads)
|
||||
log.info("detection threads: %d (OpenCV default was %d)",
|
||||
cfg.app.detect_threads, cv2.getNumThreads())
|
||||
missing = setup_models(cfg.app.models_dir)
|
||||
if missing:
|
||||
log.error("required models missing: %s", ", ".join(missing))
|
||||
|
||||
@@ -6,6 +6,7 @@ models finish loading without a single unguarded None dereference.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
@@ -111,6 +112,35 @@ def _auth_dependencies(api_cfg: ApiSection) -> list:
|
||||
return [Depends(check)]
|
||||
|
||||
|
||||
def _reencode(jpeg: bytes, width: int, quality: int) -> "bytes | None":
|
||||
"""Decode, scale and re-encode one frame. None on any failure.
|
||||
|
||||
None rather than an exception on purpose: the caller falls back to the
|
||||
original frame, so a re-encode that fails costs bandwidth rather than the
|
||||
picture. A live view that goes blank because a resize failed is a worse
|
||||
outcome than one that is briefly larger than asked for.
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
return None
|
||||
if 0 < width < img.shape[1]:
|
||||
# Only ever DOWN. Upscaling a frame to a requested width would send
|
||||
# more bytes than the original for no more detail.
|
||||
scale = width / img.shape[1]
|
||||
img = cv2.resize(img, (width, max(1, int(img.shape[0] * scale))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
q = quality if 1 <= quality <= 100 else 75
|
||||
ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), q])
|
||||
return buf.tobytes() if ok else None
|
||||
except Exception:
|
||||
log.debug("frame re-encode failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def create_app(engine: Engine) -> FastAPI:
|
||||
app = FastAPI(title="Behavision", version="1.0.0",
|
||||
dependencies=_auth_dependencies(engine.cfg.api))
|
||||
@@ -125,11 +155,25 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
def dashboard() -> str:
|
||||
return (_STATIC / "dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
@app.get("/static/favicon.png")
|
||||
def favicon() -> Response:
|
||||
# The one static asset besides the page itself. Served explicitly
|
||||
# rather than mounting the directory: nothing else in there is meant
|
||||
# to be reachable, and a mount would make that a matter of what lands
|
||||
# in the folder.
|
||||
return Response((_STATIC / "favicon.png").read_bytes(), media_type="image/png",
|
||||
headers={"cache-control": "public, max-age=86400"})
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
from .paths import describe
|
||||
# A gallery the running encoder cannot read is the failure most
|
||||
# worth catching from outside: the process is healthy, the cameras
|
||||
# are up, and the shop recognises nobody it already knows.
|
||||
stranded = engine.gallery.health["stranded"]
|
||||
return {"status": "ok" if engine.started_at else "starting",
|
||||
"recognition_model": engine.encoder.model_name,
|
||||
"gallery_unreadable_embeddings": stranded,
|
||||
# "where is my database" must be answerable from the API: the
|
||||
# tray, the installer and support all need it, and installed
|
||||
# it is not next to the code.
|
||||
@@ -293,6 +337,17 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
worker.commission.cancel()
|
||||
return {"cancelled": camera_id}
|
||||
|
||||
@app.get("/api/cameras/discover")
|
||||
def discover_cameras() -> dict:
|
||||
"""Cameras on this PC's network, for the add-camera form to pick from.
|
||||
|
||||
A sync def so FastAPI runs it in the threadpool: it holds a socket
|
||||
open for a couple of seconds and sweeps a /24, and the event loop
|
||||
must keep serving the live picture meanwhile.
|
||||
"""
|
||||
from .discover import discover
|
||||
return discover()
|
||||
|
||||
@app.post("/api/cameras/test")
|
||||
def test_camera(payload: CameraPayload) -> dict:
|
||||
"""Try a camera WITHOUT saving it - the UI's Test button.
|
||||
@@ -313,10 +368,23 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
return probe_source(source, cam.max_width)
|
||||
|
||||
@app.get("/api/cameras/{camera_id}/frame.jpg")
|
||||
def frame(camera_id: str) -> Response:
|
||||
def frame(camera_id: str, width: int = 0, quality: int = 0) -> Response:
|
||||
"""The latest frame, optionally re-encoded smaller.
|
||||
|
||||
`width`/`quality` exist for the live relay, which sends several frames
|
||||
a second up a shop's uplink and cannot afford the full-size picture the
|
||||
dashboard uses. The re-encode happens here rather than in the agent
|
||||
because this process already has OpenCV open and the frame decoded;
|
||||
shipping a scaler into the agent would be the same work done twice.
|
||||
|
||||
Done on demand, not on every frame: a camera nobody is watching must
|
||||
not pay for a second encode it will never use.
|
||||
"""
|
||||
jpeg = worker_or_404(camera_id).latest_jpeg()
|
||||
if jpeg is None:
|
||||
raise HTTPException(503, "no frame yet")
|
||||
if width > 0 or quality > 0:
|
||||
jpeg = _reencode(jpeg, width, quality) or jpeg
|
||||
return Response(jpeg, media_type="image/jpeg")
|
||||
|
||||
@app.get("/api/cameras/{camera_id}/stream.mjpeg")
|
||||
@@ -328,11 +396,23 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
# Stop when the camera is deleted or its worker dies - otherwise a
|
||||
# removed camera leaves this generator running for the life of the
|
||||
# process, holding a reference to a worker nothing else can see.
|
||||
# Driven by the camera, not a timer: a frame goes out when the
|
||||
# capture thread has one newer than the last one sent, so nothing
|
||||
# is sent twice and nothing waits on the recognition pipeline.
|
||||
# Capped at 15 fps - the office cameras' own rate - so a viewer
|
||||
# never costs more encodes than the camera produces pictures.
|
||||
last_ts, min_gap, sent_at = 0.0, 1.0 / 15, 0.0
|
||||
while engine.workers.get(camera_id) is worker and worker.is_alive():
|
||||
jpeg = worker.latest_jpeg()
|
||||
if jpeg is not None:
|
||||
yield boundary + jpeg + b"\r\n"
|
||||
await asyncio.sleep(0.1) # ~10 fps to the browser
|
||||
now = time.time()
|
||||
if now - sent_at < min_gap:
|
||||
await asyncio.sleep(min_gap - (now - sent_at))
|
||||
continue
|
||||
jpeg, ts = worker.latest_jpeg_since(last_ts)
|
||||
if jpeg is None:
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
last_ts, sent_at = ts, time.time()
|
||||
yield boundary + jpeg + b"\r\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
|
||||
@@ -17,13 +17,57 @@ import numpy as np
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Force TCP transport and a 5s socket timeout for RTSP before OpenCV loads
|
||||
# ffmpeg. UDP is the default and silently drops frames on lossy Wi-Fi.
|
||||
# Set before OpenCV loads ffmpeg, which reads this once.
|
||||
#
|
||||
# rtsp_transport=tcp: UDP is the default and silently drops frames on lossy
|
||||
# Wi-Fi.
|
||||
#
|
||||
# The timeout here is NOT what bounds a dead camera, and the comment that
|
||||
# once said it did was wrong. Measured against OpenCV 4.11 / FFmpeg 7.1 on a
|
||||
# socket that accepts the connection and then says nothing:
|
||||
#
|
||||
# stimeout;5000000 -> 30.0s timeout;5000000 -> 30.0s
|
||||
# stimeout;2000000 -> 30.5s timeout;2000000 -> 30.4s
|
||||
# no timeout option at all -> 30.3s
|
||||
#
|
||||
# Identical with the option absent, so it is not being honoured under either
|
||||
# name through this path. `stimeout` was renamed `timeout` in FFmpeg 5.0, and
|
||||
# neither reaches the RTSP protocol here. What actually bounds it is
|
||||
# OpenCV's own interrupt callback (30s for open, 30s for read), which is a
|
||||
# compile-time constant we do not control.
|
||||
#
|
||||
# Both names are still set, because on a build where they DO take effect the
|
||||
# shorter bound is what we want and an unrecognised option is ignored. But
|
||||
# nothing may depend on it: a wrong address is caught by _tcp_reachable
|
||||
# below, in code we own, in under a second.
|
||||
#
|
||||
# fflags=nobuffer and flags=low_delay: without them ffmpeg's RTSP demuxer
|
||||
# holds a comfortable queue of frames before handing over the first, which
|
||||
# on a live feed is half a second to two seconds of latency that no amount of
|
||||
# work downstream can recover - the frame is already old when we get it. A
|
||||
# recorder wants that buffer; a live view does not. max_delay caps the
|
||||
# reorder wait for the same reason.
|
||||
os.environ.setdefault(
|
||||
"OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp|stimeout;5000000"
|
||||
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
|
||||
"rtsp_transport;tcp|stimeout;5000000|timeout;5000000"
|
||||
"|fflags;nobuffer|flags;low_delay|max_delay;200000",
|
||||
)
|
||||
|
||||
|
||||
# A stream can stay open and stop delivering. OpenCV breaks a blocked read
|
||||
# after 30s and we reconnect, but for those 30s `connected` is True and the
|
||||
# camera is dead — and a stream that trickles a frame every 20s never trips
|
||||
# that timeout at all, so it never reconnects and never recovers either.
|
||||
#
|
||||
# 10s is not a preference. The tracker gives up on a face after `max_misses`
|
||||
# (25 frames, ~1.7s at 15 fps), so by 10s every track is long gone and 150
|
||||
# frames are missing: whatever this is, it is not something recognition can
|
||||
# work with. Reported separately from `connected` because the two need
|
||||
# opposite actions — one says check the network, the other says the camera
|
||||
# is answering but sending nothing.
|
||||
STALL_AFTER_S = 10.0
|
||||
|
||||
|
||||
def _tcp_reachable(source: "str | int", timeout: float
|
||||
) -> "tuple[bool, str]":
|
||||
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
|
||||
@@ -47,6 +91,23 @@ def _tcp_reachable(source: "str | int", timeout: float
|
||||
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
|
||||
|
||||
|
||||
def _fourcc(cap) -> str:
|
||||
"""The stream's codec as a four-character code, or "" if unknown.
|
||||
|
||||
FFmpeg reports H.265 as "hevc" and H.264 as "h264"/"avc1" depending on the
|
||||
container. Returned as-is rather than mapped to a friendly name: the raw
|
||||
value is what somebody searching their camera's manual will match.
|
||||
"""
|
||||
try:
|
||||
raw = int(cap.get(cv2.CAP_PROP_FOURCC))
|
||||
except Exception:
|
||||
return ""
|
||||
if raw <= 0:
|
||||
return ""
|
||||
code = "".join(chr((raw >> (8 * i)) & 0xFF) for i in range(4))
|
||||
return code.strip().strip("\x00")
|
||||
|
||||
|
||||
def probe_source(source: "str | int", max_width: int = 1280,
|
||||
timeout: float = 12.0, connect_timeout: float = 3.0) -> dict:
|
||||
"""Open a candidate camera, grab one frame, and let go.
|
||||
@@ -97,6 +158,17 @@ def probe_source(source: "str | int", max_width: int = 1280,
|
||||
return {
|
||||
"ok": True, "width": int(width), "height": int(height),
|
||||
"downscaled_to": int(preview.shape[1]) if preview is not frame else None,
|
||||
"fps": round(cap.get(cv2.CAP_PROP_FPS) or 0, 1),
|
||||
# The codec decides whether head office can ever show TRUE live
|
||||
# video from this camera. A browser plays H.264 everywhere; H.265
|
||||
# only on some platforms, so a passthrough relay cannot rely on it
|
||||
# and the picture has to be re-encoded frame by frame instead.
|
||||
# Reported here because it is a property of the camera's settings
|
||||
# that an installer can usually change, and because otherwise the
|
||||
# only way to learn it is to read RTSP by hand — which is how this
|
||||
# was found: a camera whose paths end in ".264" was emitting H.265
|
||||
# on both streams.
|
||||
"codec": _fourcc(cap),
|
||||
"snapshot": (base64.b64encode(buf.tobytes()).decode("ascii")
|
||||
if ok else None),
|
||||
}
|
||||
@@ -131,6 +203,10 @@ class VideoSource(threading.Thread):
|
||||
self.frames_total = 0
|
||||
self.reconnects = 0
|
||||
self._ever_connected = False
|
||||
# Why the last open failed, in the words an installer can act on.
|
||||
# Without it a camera that never connects reports only `connected:
|
||||
# false`, which cannot distinguish a wrong IP from a wrong password.
|
||||
self.last_error = ""
|
||||
|
||||
# -- public ---------------------------------------------------------
|
||||
def latest(self) -> "tuple[Optional[np.ndarray], float]":
|
||||
@@ -161,11 +237,23 @@ class VideoSource(threading.Thread):
|
||||
def stop(self) -> None:
|
||||
self._stopping.set()
|
||||
|
||||
def stalled(self) -> bool:
|
||||
"""Open, but not delivering. See STALL_AFTER_S."""
|
||||
if not self.connected or not self._frame_ts:
|
||||
return False
|
||||
return (time.time() - self._frame_ts) > STALL_AFTER_S
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"camera_id": self.camera_id,
|
||||
"url": self._display_url,
|
||||
"connected": self.connected,
|
||||
# Connected AND delivering. `connected` alone stays true through
|
||||
# a stall, so it is the wrong thing for a dashboard to colour a
|
||||
# camera green on.
|
||||
"streaming": self.connected and not self.stalled(),
|
||||
"stalled": self.stalled(),
|
||||
"last_error": self.last_error,
|
||||
"frames_total": self.frames_total,
|
||||
"reconnects": self.reconnects,
|
||||
"last_frame_age_s": round(time.time() - self._frame_ts, 1)
|
||||
@@ -222,6 +310,19 @@ class VideoSource(threading.Thread):
|
||||
log.info("[%s] capture stopped", self.camera_id)
|
||||
|
||||
def _open(self) -> Optional[cv2.VideoCapture]:
|
||||
# Pre-flight the socket, exactly as probe_source does. Without it a
|
||||
# camera that is off, moved or mistyped costs 30s per attempt inside
|
||||
# the VideoCapture constructor (measured; it is OpenCV's interrupt
|
||||
# timeout, not ours to shorten) — and the constructor is not
|
||||
# interruptible, so stop() cannot cut it short and a removed camera
|
||||
# leaves a daemon thread holding a socket for half a minute. A
|
||||
# refused or unroutable address answers in well under a second, which
|
||||
# is also what lets the backoff below mean what it says.
|
||||
reachable, why = _tcp_reachable(self._source, 2.0)
|
||||
if not reachable:
|
||||
log.debug("[%s] %s", self.camera_id, why)
|
||||
self.last_error = why
|
||||
return None
|
||||
try:
|
||||
if isinstance(self._source, int):
|
||||
cap = cv2.VideoCapture(self._source)
|
||||
@@ -230,8 +331,12 @@ class VideoSource(threading.Thread):
|
||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
self.last_error = ("reachable, but the stream would not open "
|
||||
"- check the path and credentials")
|
||||
return None
|
||||
self.last_error = ""
|
||||
return cap
|
||||
except cv2.error:
|
||||
log.exception("[%s] VideoCapture error", self.camera_id)
|
||||
self.last_error = "VideoCapture error - see the engine log"
|
||||
return None
|
||||
|
||||
@@ -132,6 +132,29 @@ class AppSection(BaseModel):
|
||||
# on changes what the system is under GDPR and India's DPDP, so it has to
|
||||
# be a decision somebody makes rather than one they inherit.
|
||||
store_faces: bool = False
|
||||
# How many threads OpenCV may use for detection. Measured on the office
|
||||
# camera (800x448 sub-stream): the default of 8 costs 31 ms of CPU per
|
||||
# frame for 8.9 ms of wall time, while ONE thread costs 15.3 ms of CPU for
|
||||
# 15.3 ms of wall - half the CPU for 6 ms more latency, against a 66 ms
|
||||
# frame budget at 15 fps. The default is wrong here because OpenCV sizes it
|
||||
# for one big job on an idle machine, and this is a small job repeated
|
||||
# forever on a machine also running the recogniser, the tracker and three
|
||||
# other cameras. 0 leaves OpenCV's own default alone.
|
||||
detect_threads: int = 1
|
||||
# Skip detection on frames where nothing has changed and nothing is being
|
||||
# tracked. A shop is empty most of the day and a frame of an empty room
|
||||
# costs exactly as much to search as a busy one. See CameraWorker.run for
|
||||
# why this cannot lose a face.
|
||||
motion_gate: bool = True
|
||||
# Mean absolute difference, 0-255, over a 160x90 greyscale thumbnail. 1.0
|
||||
# is well below the noise floor of a real camera - measured on this one,
|
||||
# an empty room varies by ~0.3 between frames - so it triggers on movement
|
||||
# rather than on sensor noise, and anything ambiguous detects.
|
||||
motion_threshold: float = 1.0
|
||||
# Detect at least this often regardless of the gate, so a change the
|
||||
# thumbnail cannot see - someone entering at the far edge, a slow lean into
|
||||
# frame - is still found within a second.
|
||||
motion_max_skip: int = 12
|
||||
|
||||
|
||||
class ApiSection(BaseModel):
|
||||
|
||||
206
behavision/discover.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Find the cameras on the shop's network, so nobody has to type an address.
|
||||
|
||||
The add-camera form asked for an IP address, and a shop owner does not know
|
||||
their camera's IP address. It is on a sticker under the camera, if at all, or
|
||||
inside the camera's own app under a menu called something different for every
|
||||
make. That one field is where onboarding stopped for anyone who was not an
|
||||
installer.
|
||||
|
||||
Two probes, merged:
|
||||
|
||||
- **WS-Discovery** (ONVIF's discovery protocol): one multicast to
|
||||
239.255.255.250:3702 and every ONVIF camera on the LAN answers with its
|
||||
address and, usually, its make and model. Cheap, fast, and names the device
|
||||
- but only cameras that speak ONVIF answer, and some cheap ones do not.
|
||||
- **A TCP sweep of port 554** across the local /24: anything listening on the
|
||||
RTSP port is very probably a camera or a recorder. Names nothing, misses
|
||||
nothing that streams.
|
||||
|
||||
A host found by either is a candidate; one found by both is a camera with a
|
||||
name. The result is a list to pick from, not a decision: the person still
|
||||
supplies the password, and Test still proves the stream opens.
|
||||
|
||||
Stdlib only. This runs inside the engine, which ships as a small wheel, and a
|
||||
network-scanning dependency would be a large thing to add for two sockets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Iterable
|
||||
|
||||
# ONVIF WS-Discovery probe. The MessageID must be unique per probe; devices
|
||||
# ignore a repeat.
|
||||
_PROBE = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" '
|
||||
'xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" '
|
||||
'xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" '
|
||||
'xmlns:dn="http://www.onvif.org/ver10/network/wsdl">'
|
||||
'<e:Header><w:MessageID>uuid:{mid}</w:MessageID>'
|
||||
'<w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To>'
|
||||
'<w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action>'
|
||||
'</e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body>'
|
||||
'</e:Envelope>'
|
||||
)
|
||||
_MCAST = ("239.255.255.250", 3702)
|
||||
|
||||
# Makes we can name from an ONVIF scope or hostname, mapped to the ids the
|
||||
# camera-make picker uses so the form can preselect the stream path.
|
||||
_MAKES = (
|
||||
("hikvision", "hikvision"), ("hik", "hikvision"), ("dahua", "dahua"),
|
||||
("cp plus", "cpplus"), ("cpplus", "cpplus"), ("cp-plus", "cpplus"),
|
||||
("uniview", "uniview"), ("unv", "uniview"), ("tapo", "tplink"),
|
||||
("tp-link", "tplink"), ("reolink", "reolink"), ("amcrest", "amcrest"),
|
||||
("axis", "axis"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Found:
|
||||
host: str
|
||||
rtsp: bool = False # port 554 answered
|
||||
onvif: bool = False # answered WS-Discovery
|
||||
name: str = "" # from ONVIF scopes, e.g. "Hikvision DS-2CD2043"
|
||||
make: str = "" # picker id, when it can be guessed
|
||||
onvif_url: str = ""
|
||||
sources: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def local_networks() -> list[ipaddress.IPv4Network]:
|
||||
"""The /24s this machine sits on, best effort and without dependencies.
|
||||
|
||||
Interface masks are not portable in the stdlib, so this assumes /24 - the
|
||||
shape of nearly every shop's router - for each local IPv4 address it can
|
||||
find. A bigger network would need a scan anyway that this should not run
|
||||
unasked.
|
||||
"""
|
||||
addrs: set[str] = set()
|
||||
try:
|
||||
# The address the OS would use to reach the internet: the LAN we care about.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(0.5)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
addrs.add(s.getsockname()[0])
|
||||
s.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
for a in socket.gethostbyname_ex(socket.gethostname())[2]:
|
||||
addrs.add(a)
|
||||
except OSError:
|
||||
pass
|
||||
nets = []
|
||||
for a in addrs:
|
||||
try:
|
||||
ip = ipaddress.IPv4Address(a)
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.is_loopback or ip.is_link_local:
|
||||
continue
|
||||
nets.append(ipaddress.IPv4Network(f"{a}/24", strict=False))
|
||||
return sorted(set(nets), key=str)
|
||||
|
||||
|
||||
def ws_discover(timeout: float = 2.5) -> list[Found]:
|
||||
"""One ONVIF probe, every answer within `timeout` seconds."""
|
||||
out: dict[str, Found] = {}
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
sock.settimeout(timeout)
|
||||
sock.sendto(_PROBE.format(mid=uuid.uuid4()).encode(), _MCAST)
|
||||
except OSError:
|
||||
return []
|
||||
import time
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
data, (host, _) = sock.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
f = parse_probe_match(data.decode("utf-8", "replace"), host)
|
||||
if f:
|
||||
out[f.host] = f
|
||||
sock.close()
|
||||
return list(out.values())
|
||||
|
||||
|
||||
_XADDR = re.compile(r"<[^>]*XAddrs[^>]*>([^<]+)<")
|
||||
_SCOPES = re.compile(r"<[^>]*Scopes[^>]*>([^<]+)<")
|
||||
|
||||
|
||||
def parse_probe_match(xml: str, host: str) -> Found | None:
|
||||
"""Pull the address and the human-readable scopes out of a ProbeMatch.
|
||||
|
||||
A regex rather than an XML parser on purpose: cameras emit every namespace
|
||||
prefix imaginable and some emit XML that is not quite well-formed, and the
|
||||
two fields wanted are flat text.
|
||||
"""
|
||||
xaddrs = _XADDR.search(xml)
|
||||
scopes = _SCOPES.search(xml)
|
||||
if not xaddrs and not scopes:
|
||||
return None
|
||||
url = xaddrs.group(1).split()[0] if xaddrs else ""
|
||||
# Prefer the host from the XAddrs URL: a device with several interfaces
|
||||
# answers from the one it heard us on, which is the one we can reach.
|
||||
m = re.match(r"https?://([^/:]+)", url)
|
||||
ip = m.group(1) if m else host
|
||||
f = Found(host=ip, onvif=True, onvif_url=url, sources=["onvif"])
|
||||
if scopes:
|
||||
words = []
|
||||
for s in scopes.group(1).split():
|
||||
if "/name/" in s or "/hardware/" in s:
|
||||
from urllib.parse import unquote
|
||||
words.append(unquote(s.rsplit("/", 1)[-1]))
|
||||
f.name = " ".join(dict.fromkeys(words)) # dedupe, keep order
|
||||
f.make = guess_make(f.name)
|
||||
return f
|
||||
|
||||
|
||||
def guess_make(text: str) -> str:
|
||||
low = text.lower()
|
||||
for needle, make in _MAKES:
|
||||
if needle in low:
|
||||
return make
|
||||
return ""
|
||||
|
||||
|
||||
def rtsp_sweep(nets: Iterable[ipaddress.IPv4Network], timeout: float = 0.5,
|
||||
workers: int = 128) -> list[str]:
|
||||
"""Every host in `nets` with port 554 open. ~254 hosts in about a second."""
|
||||
def probe(ip: str) -> str | None:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
return ip if s.connect_ex((ip, 554)) == 0 else None
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
s.close()
|
||||
hosts = [str(h) for n in nets for h in n.hosts()]
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
return [ip for ip in ex.map(probe, hosts) if ip]
|
||||
|
||||
|
||||
def discover(timeout: float = 2.5) -> dict:
|
||||
"""Both probes, merged, as the API returns it."""
|
||||
nets = local_networks()
|
||||
found: dict[str, Found] = {f.host: f for f in ws_discover(timeout)}
|
||||
for ip in rtsp_sweep(nets):
|
||||
f = found.setdefault(ip, Found(host=ip))
|
||||
f.rtsp = True
|
||||
f.sources.append("rtsp")
|
||||
cams = sorted(found.values(), key=lambda f: (not (f.rtsp and f.onvif), not f.rtsp,
|
||||
ipaddress.IPv4Address(f.host)))
|
||||
return {
|
||||
"networks": [str(n) for n in nets],
|
||||
"cameras": [asdict(c) for c in cams],
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import numpy as np
|
||||
|
||||
from .attributes import AttributeEstimator, aggregate as aggregate_attrs
|
||||
from .cameras import CameraStore
|
||||
from . import capture
|
||||
from .capture import VideoSource
|
||||
from .faces import FaceOutbox
|
||||
from .commission import CommissionRun
|
||||
@@ -162,10 +163,20 @@ class CameraWorker(threading.Thread):
|
||||
# every test using a stubbed worker passed.
|
||||
self._stopping = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._annotated_jpeg: Optional[bytes] = None
|
||||
# What the live view draws over the freshest frame: the boxes from
|
||||
# the most recent processed frame, and when they were computed. NOT a
|
||||
# pre-rendered JPEG - see latest_jpeg for why.
|
||||
self._overlay: "list[tuple[tuple[int, int, int, int], tuple[int, int, int], str]]" = []
|
||||
self._overlay_ts = 0.0
|
||||
self._last_frame_ts = 0.0
|
||||
self._was_connected = False
|
||||
self._was_stalled = False
|
||||
self.frames_processed = 0
|
||||
# Motion gate state: a 160x90 greyscale thumbnail of the last frame we
|
||||
# actually searched, and how many frames we have skipped since.
|
||||
self._motion_prev = None
|
||||
self._motion_skipped = 0
|
||||
self.frames_skipped = 0
|
||||
self.faces_seen = 0
|
||||
self.pipeline = PipelineStats()
|
||||
# One outbox per worker, all writing into the same directory. Files are
|
||||
@@ -187,13 +198,52 @@ class CameraWorker(threading.Thread):
|
||||
self.source.stop()
|
||||
|
||||
def latest_jpeg(self) -> Optional[bytes]:
|
||||
jpeg, _ = self.latest_jpeg_since(0.0)
|
||||
return jpeg
|
||||
|
||||
def latest_jpeg_since(self, known_ts: float) -> "tuple[Optional[bytes], float]":
|
||||
"""The freshest captured frame with the latest boxes drawn on it, or
|
||||
(None, known_ts) if the camera has produced nothing newer.
|
||||
|
||||
The live picture is deliberately NOT the frame the pipeline last
|
||||
finished with. That version advanced only when detection, tracking and
|
||||
identification had all completed on a frame - a few times a second on a
|
||||
modest shop PC - and every picture it showed was already as old as that
|
||||
processing. It looked like lag because it was lag. Here the picture runs
|
||||
at the camera's rate off the capture thread's latest frame, and the
|
||||
boxes - which genuinely can only update at pipeline rate - are drawn
|
||||
over it from the last processed frame. Boxes may trail a fast walker by
|
||||
one pipeline period; the picture never does.
|
||||
|
||||
Encoded on demand, per request, so a camera nobody is watching pays for
|
||||
no JPEG at all. The old path encoded every processed frame whether or
|
||||
not a viewer existed - CPU spent on precisely the machine short of it.
|
||||
"""
|
||||
frame, ts = self.source.latest_since(known_ts)
|
||||
if frame is None:
|
||||
return None, known_ts
|
||||
with self._lock:
|
||||
return self._annotated_jpeg
|
||||
overlay, overlay_ts = list(self._overlay), self._overlay_ts
|
||||
# A stalled pipeline must not leave a box floating over an empty spot.
|
||||
# Older than a second and the person has walked out from under it.
|
||||
draw = overlay if (time.time() - overlay_ts) < 1.0 else []
|
||||
if draw:
|
||||
frame = frame.copy()
|
||||
for (x1, y1, x2, y2), color, text in draw:
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
||||
if text:
|
||||
cv2.putText(frame, text, (x1, max(20, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
||||
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if not ok:
|
||||
return None, known_ts
|
||||
return buf.tobytes(), ts
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
**self.source.stats(),
|
||||
"frames_processed": self.frames_processed,
|
||||
"frames_skipped": self.frames_skipped,
|
||||
"faces_seen": self.faces_seen,
|
||||
"active_tracks": len(self.tracker.tracks),
|
||||
"pipeline": self.pipeline.snapshot(self.rcfg.min_enroll_quality),
|
||||
@@ -202,6 +252,43 @@ class CameraWorker(threading.Thread):
|
||||
"enroll_threshold": self.rcfg.enroll_threshold},
|
||||
}
|
||||
|
||||
def _nothing_moved(self, frame) -> bool:
|
||||
"""True when this frame is close enough to the last searched one that
|
||||
searching it again would find the same nothing.
|
||||
|
||||
It cannot lose a face, and that property is what makes it acceptable
|
||||
rather than merely cheap. Three guards, in order:
|
||||
|
||||
* the caller only asks while NO track is open, so a person already
|
||||
being followed is never affected by it;
|
||||
* `motion_max_skip` forces a real detection about once a second
|
||||
whatever the thumbnail says, which covers a change too small or too
|
||||
gradual for it - someone easing into frame at the far edge;
|
||||
* the threshold sits well above measured sensor noise and well below
|
||||
a person, and anything ambiguous falls through to detection. When
|
||||
in doubt it looks.
|
||||
|
||||
Cost is 0.1 ms against detection's 15 ms, so an empty shop stops paying
|
||||
for a search of an empty room ~90 times a second.
|
||||
"""
|
||||
import cv2 as _cv2
|
||||
small = _cv2.resize(_cv2.cvtColor(frame, _cv2.COLOR_BGR2GRAY), (160, 90),
|
||||
interpolation=_cv2.INTER_AREA)
|
||||
prev, self._motion_prev = self._motion_prev, small
|
||||
if prev is None:
|
||||
return False
|
||||
if self._motion_skipped >= self.cfg.app.motion_max_skip:
|
||||
self._motion_skipped = 0
|
||||
return False
|
||||
if float(_cv2.absdiff(small, prev).mean()) >= self.cfg.app.motion_threshold:
|
||||
self._motion_skipped = 0
|
||||
# Keep the thumbnail we just searched against, not this one, so a
|
||||
# slow drift cannot creep past the threshold one frame at a time.
|
||||
return False
|
||||
self._motion_prev = prev
|
||||
self._motion_skipped += 1
|
||||
return True
|
||||
|
||||
# -- thread ---------------------------------------------------------
|
||||
def run(self) -> None:
|
||||
tcfg = self.cfg.tracking
|
||||
@@ -214,6 +301,15 @@ class CameraWorker(threading.Thread):
|
||||
continue
|
||||
self._last_frame_ts = ts
|
||||
|
||||
# An empty room costs exactly as much to search as a busy one,
|
||||
# and a shop is empty most of the day. Only ever while nothing
|
||||
# is being tracked - see _nothing_moved.
|
||||
if (self.cfg.app.motion_gate and not self.tracker.tracks
|
||||
and self._nothing_moved(frame)):
|
||||
self.frames_skipped += 1
|
||||
self._remember_tracks([])
|
||||
continue
|
||||
|
||||
detections = self.detector.detect(frame)
|
||||
for det in detections:
|
||||
det.quality = face_quality(frame, det.box, det.kps)
|
||||
@@ -236,7 +332,7 @@ class CameraWorker(threading.Thread):
|
||||
for track in ended:
|
||||
self._finish_track(track, ts)
|
||||
|
||||
self._publish_annotated(frame, active)
|
||||
self._remember_tracks(active)
|
||||
self.frames_processed += 1
|
||||
except Exception:
|
||||
log.exception("[%s] frame processing failed", self.cam_cfg.id)
|
||||
@@ -252,6 +348,20 @@ class CameraWorker(threading.Thread):
|
||||
type="camera.up" if connected else "camera.down",
|
||||
camera_id=self.cam_cfg.id))
|
||||
|
||||
# A stall is not a disconnect and must not be reported as one: the
|
||||
# socket is fine, the camera is answering, and nothing is arriving.
|
||||
# Logged on the transition only — a per-frame warning would bury the
|
||||
# one line that matters under thousands of copies of itself.
|
||||
stalled = self.source.stalled()
|
||||
if stalled != self._was_stalled:
|
||||
self._was_stalled = stalled
|
||||
if stalled:
|
||||
log.warning("[%s] connected but no frame for over %.0fs - the "
|
||||
"camera is answering and sending nothing",
|
||||
self.cam_cfg.id, capture.STALL_AFTER_S)
|
||||
else:
|
||||
log.info("[%s] frames resumed", self.cam_cfg.id)
|
||||
|
||||
def _finish_track(self, track: Track, ts: float) -> None:
|
||||
"""Record what became of a track, once, as it ends.
|
||||
|
||||
@@ -426,12 +536,14 @@ class CameraWorker(threading.Thread):
|
||||
track.quality, rcfg=self.rcfg):
|
||||
track.reinforcements += 1
|
||||
|
||||
def _publish_annotated(self, frame: np.ndarray, tracks: "list[Track]") -> None:
|
||||
canvas = frame.copy()
|
||||
def _remember_tracks(self, tracks: "list[Track]") -> None:
|
||||
"""Record what to draw. Cheap: a handful of tuples under the lock,
|
||||
no frame copy and no encode. The encode happens in latest_jpeg_since,
|
||||
only when somebody is looking."""
|
||||
overlay = []
|
||||
for t in tracks:
|
||||
if t.misses > 0:
|
||||
continue # only draw tracks matched in this frame
|
||||
x1, y1, x2, y2 = t.box
|
||||
if t.state == "resolved":
|
||||
color = _COLORS["known"] if t.label and not str(t.label).startswith(
|
||||
"Visitor") else _COLORS["new"]
|
||||
@@ -440,15 +552,10 @@ class CameraWorker(threading.Thread):
|
||||
color, text = _COLORS["ambiguous"], "?"
|
||||
else:
|
||||
color, text = _COLORS["pending"], ""
|
||||
cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 2)
|
||||
if text:
|
||||
cv2.putText(canvas, text, (x1, max(20, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
||||
ok, buf = cv2.imencode(".jpg", canvas,
|
||||
[int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if ok:
|
||||
with self._lock:
|
||||
self._annotated_jpeg = buf.tobytes()
|
||||
overlay.append((tuple(t.box), color, text))
|
||||
with self._lock:
|
||||
self._overlay = overlay
|
||||
self._overlay_ts = time.time()
|
||||
|
||||
|
||||
class Engine:
|
||||
@@ -590,7 +697,10 @@ class Engine:
|
||||
"age_model": ("genderage" if self.attributes is not None
|
||||
and self.attributes.has_genderage else "caffe/none"),
|
||||
},
|
||||
"gallery": self.store.stats(),
|
||||
# Counts, plus whether the running encoder can actually SEARCH
|
||||
# them. A gallery of 21 identities that the loaded model cannot
|
||||
# read is the silent version of an empty one.
|
||||
"gallery": {**self.store.stats(), **self.gallery.health},
|
||||
"cameras": [w.stats() for w in self.snapshot_workers()],
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,56 @@ class Gallery:
|
||||
# vectors from a different model are numerically incompatible.
|
||||
ids, vecs = store.all_embeddings(index.dim, model=model_name)
|
||||
index.add(ids, vecs)
|
||||
self.health = self._assess(len(ids))
|
||||
if self.health["stranded"]:
|
||||
# Not an INFO line. The encoder fallback chain exists so a
|
||||
# memory-starved box still runs, and when it fires every vector
|
||||
# written by the previous encoder becomes invisible: the shop
|
||||
# keeps its customer list and recognises nobody on it, greeting
|
||||
# every regular as new and enrolling them a second time. Footfall
|
||||
# stays right, which is exactly why nothing looks wrong. The old
|
||||
# message for that state was "gallery ready: 0 embeddings".
|
||||
log.warning(
|
||||
"gallery: %d of %d stored embeddings were written by a "
|
||||
"DIFFERENT encoder (%s) and cannot be searched - %d known "
|
||||
"%s unrecognisable under the running model '%s'. Either "
|
||||
"restore that model or accept that these identities start "
|
||||
"over.",
|
||||
self.health["stranded"], self.health["stored"],
|
||||
", ".join(sorted(self.health["other_models"])),
|
||||
self.health["identities_stranded"],
|
||||
"person is" if self.health["identities_stranded"] == 1
|
||||
else "people are",
|
||||
model_name)
|
||||
log.info("gallery ready: %d embeddings (model '%s') across %d "
|
||||
"identities", len(ids), model_name,
|
||||
store.stats()["identities"])
|
||||
|
||||
def _assess(self, usable: int) -> dict:
|
||||
"""What share of the gallery the running encoder can actually reach.
|
||||
|
||||
Reported rather than merely logged, because a log line on a shop PC
|
||||
is read by nobody: this travels to head office the same way
|
||||
`fraction_below_gate` does, beside the number it qualifies.
|
||||
"""
|
||||
counts = self.store.model_counts()
|
||||
stored = sum(counts.values())
|
||||
others = {m: n for m, n in counts.items() if m != self.model_name}
|
||||
identities = self.store.stats()["identities"]
|
||||
return {
|
||||
"model": self.model_name,
|
||||
"stored": stored,
|
||||
"usable": usable,
|
||||
"stranded": sum(others.values()),
|
||||
"other_models": sorted(others),
|
||||
"identities": identities,
|
||||
"identities_usable": self.store.identities_with_model(
|
||||
self.model_name),
|
||||
"identities_stranded": max(
|
||||
0, identities - self.store.identities_with_model(
|
||||
self.model_name)),
|
||||
}
|
||||
|
||||
def resolve(self, embedding: np.ndarray, quality: float, camera_id: str,
|
||||
ts: "float | None" = None,
|
||||
attributes: "dict | None" = None,
|
||||
|
||||
@@ -281,6 +281,31 @@ class IdentityStore:
|
||||
return None
|
||||
return np.frombuffer(row["vector"], dtype=np.float32), float(row["quality"])
|
||||
|
||||
def model_counts(self) -> "dict[str, int]":
|
||||
"""How many stored embeddings each encoder produced.
|
||||
|
||||
The gallery only ever searches vectors tagged with the *running*
|
||||
encoder, so this is what says whether the rest of the gallery is
|
||||
reachable at all. See `Gallery.health` for why that matters.
|
||||
"""
|
||||
with self._lock:
|
||||
rows = self._db.execute(
|
||||
"SELECT model, COUNT(*) AS n FROM embeddings "
|
||||
"GROUP BY model").fetchall()
|
||||
return {str(r["model"]): int(r["n"]) for r in rows}
|
||||
|
||||
def identities_with_model(self, model: str) -> int:
|
||||
"""Identities holding at least one embedding from this encoder.
|
||||
|
||||
Not the same as the identity count: an identity whose only vectors
|
||||
came from a previous encoder still exists, and is unrecognisable.
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._db.execute(
|
||||
"SELECT COUNT(DISTINCT identity_id) AS n FROM embeddings "
|
||||
"WHERE model=?", (model,)).fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def embedding_owners(self, model: "str | None" = None) -> "dict[int, int]":
|
||||
"""embedding_id -> identity_id, for turning index hits into identity
|
||||
pairs without a round trip to SQLite per hit."""
|
||||
|
||||
@@ -35,6 +35,31 @@ _COPY_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _fetch(url: str, dest: Path, label: str) -> None:
|
||||
"""Download with progress on stdout the supervisor can read.
|
||||
|
||||
On first run this is minutes of nothing: the API is not up yet, so the
|
||||
app cannot ask the engine what it is doing, and a shop PC that shows a
|
||||
stopped engine for five minutes after install looks broken. The
|
||||
supervisor watches for `download: <label> <n>%` and puts the number in
|
||||
the tray and the window. Logged every 5 points, not every chunk, so the
|
||||
log file does not fill with a progress bar.
|
||||
"""
|
||||
last = -5
|
||||
|
||||
def hook(blocks: int, block_size: int, total: int) -> None:
|
||||
nonlocal last
|
||||
if total <= 0:
|
||||
return
|
||||
pct = min(100, blocks * block_size * 100 // total)
|
||||
if pct >= last + 5:
|
||||
last = pct
|
||||
log.info("download: %s %d%%", label, pct)
|
||||
|
||||
urllib.request.urlretrieve(url, dest, hook)
|
||||
log.info("download: %s 100%%", label)
|
||||
|
||||
|
||||
def setup_models(models_dir: Path) -> "list[str]":
|
||||
"""Ensure all model files exist in models_dir. Returns missing ones."""
|
||||
models_dir = Path(models_dir)
|
||||
@@ -44,7 +69,7 @@ def setup_models(models_dir: Path) -> "list[str]":
|
||||
if not yunet.exists():
|
||||
log.info("downloading YuNet face detector (~230 KB)...")
|
||||
tmp = yunet.with_suffix(".part")
|
||||
urllib.request.urlretrieve(YUNET_URL, tmp)
|
||||
_fetch(YUNET_URL, tmp, "face detector")
|
||||
tmp.rename(yunet)
|
||||
log.info("YuNet saved to %s", yunet)
|
||||
|
||||
@@ -88,7 +113,7 @@ def setup_models(models_dir: Path) -> "list[str]":
|
||||
import zipfile
|
||||
|
||||
tmp = models_dir / "buffalo_l.zip.part"
|
||||
urllib.request.urlretrieve(BUFFALO_L_URL, tmp)
|
||||
_fetch(BUFFALO_L_URL, tmp, "recognition models")
|
||||
with zipfile.ZipFile(tmp) as zf:
|
||||
for name, target in wanted.items():
|
||||
member = next((n for n in zf.namelist()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Behavision</title>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
@@ -513,11 +514,24 @@ async function refresh() {
|
||||
]);
|
||||
renderFeeds(camList);
|
||||
renderCameras(camList);
|
||||
// 'stalled' is its own word on purpose: connected and offline send you
|
||||
// to the network, a camera that is answering and sending nothing does
|
||||
// not. Three states, because two of them need opposite actions.
|
||||
const cams = stats.cameras.map(c =>
|
||||
`${c.camera_id}: ${c.connected ? 'live' : 'offline'}`).join(' · ');
|
||||
`${c.camera_id}: ${c.streaming ? 'live' : c.connected ? 'stalled' : 'offline'}`
|
||||
).join(' · ');
|
||||
// The one failure that otherwise looks like perfect health: the encoder
|
||||
// that loaded cannot read the embeddings already stored, so every known
|
||||
// customer is a stranger. Counts stay right, which is why it needs saying.
|
||||
const stranded = stats.gallery.stranded || 0;
|
||||
const warn = stranded
|
||||
? ` · ⚠ ${stats.gallery.identities_stranded} people unrecognisable `
|
||||
+ `(${stranded} embeddings from ${stats.gallery.other_models.join(', ')}, `
|
||||
+ `running ${stats.gallery.model})`
|
||||
: '';
|
||||
// textContent, not innerHTML — no escaping needed here.
|
||||
document.getElementById('status').textContent =
|
||||
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings`;
|
||||
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings${warn}`;
|
||||
|
||||
document.getElementById('events').innerHTML = events.map(e => {
|
||||
const cls = e.type === 'person.new' ? 'new'
|
||||
|
||||
BIN
behavision/static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
brand/loyaly-icon-128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
brand/loyaly-icon-16.png
Normal file
|
After Width: | Height: | Size: 732 B |
BIN
brand/loyaly-icon-256.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
brand/loyaly-icon-32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
brand/loyaly-icon-48.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
brand/loyaly-icon-512.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
brand/loyaly-icon-64.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
brand/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
brand/loyaly.ico
Normal file
|
After Width: | Height: | Size: 67 KiB |
258
demo/console.html
Normal file
@@ -0,0 +1,258 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Behavision — live demo</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0A0E12; --s1:#11171C; --s2:#161D24; --s3:#1D262E;
|
||||
--line:#24303A; --line2:#1B242C;
|
||||
--ink:#E8EEF3; --ink2:#9FB0BD; --ink3:#6B7E8C;
|
||||
--accent:#3DD0C4; --accent-dim:#123039;
|
||||
--ok:#3FBF7F; --warn:#E0A33A; --bad:#E15B4C;
|
||||
--mono:'SF Mono',ui-monospace,Menlo,monospace;
|
||||
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--ink);font-family:var(--font);font-size:14px;line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;padding:18px;min-height:100vh}
|
||||
.top{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
|
||||
.brand img{width:26px;height:26px;object-fit:contain}
|
||||
.brand b{font-size:16px;letter-spacing:-.01em}
|
||||
.brand span{color:var(--ink3);font-size:12px}
|
||||
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:99px;
|
||||
border:1px solid var(--line);background:var(--s1);font-size:12px;color:var(--ink2)}
|
||||
.dot{width:7px;height:7px;border-radius:99px;background:var(--ink3);flex:none}
|
||||
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,191,127,.16)}
|
||||
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px rgba(225,91,76,.16)}
|
||||
.dot.warn{background:var(--warn);box-shadow:0 0 0 3px rgba(224,163,58,.16)}
|
||||
|
||||
.grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start}
|
||||
@media(max-width:1100px){.grid{grid-template-columns:minmax(0,1fr)}}
|
||||
.card{background:var(--s1);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
||||
.card h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink3);
|
||||
padding:12px 16px;border-bottom:1px solid var(--line2);display:flex;align-items:center;gap:10px}
|
||||
.card h2 .grow{margin-left:auto;font-weight:500;letter-spacing:0;text-transform:none;font-size:12px;color:var(--ink3)}
|
||||
.pad{padding:16px}
|
||||
|
||||
.cam{position:relative;aspect-ratio:16/9;background:#05090C}
|
||||
.cam img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.cam .none{position:absolute;inset:0;display:grid;place-items:center;color:var(--ink3);font-size:13px;text-align:center;padding:20px}
|
||||
.cam .tag{position:absolute;top:10px;left:10px;background:rgba(10,14,18,.78);backdrop-filter:blur(8px);
|
||||
border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:11.5px;font-family:var(--mono)}
|
||||
|
||||
/* the chain */
|
||||
.chain{display:flex;flex-direction:column;gap:0}
|
||||
.step{display:grid;grid-template-columns:26px 1fr auto;gap:12px;align-items:start;padding:11px 16px;
|
||||
border-bottom:1px solid var(--line2);opacity:.38;transition:opacity .25s}
|
||||
.step:last-child{border-bottom:0}
|
||||
.step.on{opacity:1}
|
||||
.step .n{width:22px;height:22px;border-radius:99px;display:grid;place-items:center;font-size:11px;font-weight:600;
|
||||
background:var(--s3);color:var(--ink3);border:1px solid var(--line);margin-top:1px}
|
||||
.step.on .n{background:var(--accent);color:#04161B;border-color:transparent}
|
||||
.step b{font-size:13.5px;font-weight:550;display:block}
|
||||
.step small{color:var(--ink2);font-size:12px;display:block;margin-top:1px;font-family:var(--mono)}
|
||||
.step .ms{font-family:var(--mono);font-size:11.5px;color:var(--accent);white-space:nowrap;margin-top:2px}
|
||||
|
||||
.empty{padding:34px 16px;text-align:center;color:var(--ink3);font-size:13px;line-height:1.6}
|
||||
.empty b{display:block;color:var(--ink2);font-size:14px;margin-bottom:5px}
|
||||
|
||||
/* customer */
|
||||
.who{display:flex;gap:13px;align-items:center;padding:16px;border-bottom:1px solid var(--line2)}
|
||||
.av{width:50px;height:50px;border-radius:10px;background:var(--s3);border:1px solid var(--line);
|
||||
display:grid;place-items:center;font-weight:600;font-size:17px;color:var(--ink2);flex:none;overflow:hidden}
|
||||
.av img{width:100%;height:100%;object-fit:cover}
|
||||
.who .n{font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||||
.who .m{color:var(--ink3);font-size:12.5px;margin-top:2px}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10.5px;font-weight:600;
|
||||
letter-spacing:.04em;text-transform:uppercase}
|
||||
.badge.new{background:var(--accent-dim);color:var(--accent)}
|
||||
.badge.seen{background:rgba(63,191,127,.14);color:var(--ok)}
|
||||
label{display:block;font-size:11.5px;font-weight:550;color:var(--ink2);margin-bottom:5px}
|
||||
input{width:100%;background:var(--s2);border:1px solid var(--line);border-radius:7px;padding:9px 11px;
|
||||
color:var(--ink);font:inherit;font-size:13.5px}
|
||||
input:focus{outline:none;border-color:var(--accent)}
|
||||
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
|
||||
button{background:var(--accent);color:#04161B;border:0;border-radius:7px;padding:9px 16px;
|
||||
font:inherit;font-size:13px;font-weight:600;cursor:pointer}
|
||||
button:disabled{opacity:.45;cursor:default}
|
||||
button.sec{background:var(--s3);color:var(--ink);border:1px solid var(--line)}
|
||||
.saved{color:var(--ok);font-size:12.5px;margin-top:9px;display:flex;align-items:center;gap:6px}
|
||||
|
||||
/* raw json */
|
||||
pre{font-family:var(--mono);font-size:11px;line-height:1.55;color:var(--ink2);
|
||||
background:#080C10;border-top:1px solid var(--line2);padding:13px 16px;margin:0;
|
||||
max-height:230px;overflow:auto;white-space:pre-wrap;word-break:break-word}
|
||||
.req{font-family:var(--mono);font-size:11.5px;color:var(--accent);padding:10px 16px;background:var(--s2)}
|
||||
.req .st{float:right;color:var(--ink3)}
|
||||
.hint{color:var(--ink3);font-size:12px;padding:10px 16px 14px;line-height:1.55}
|
||||
.stack{display:flex;flex-direction:column;gap:14px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="brand">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDIxcy04LTQuNS04LTEwYTQuNSA0LjUgMCAwIDEgOC0yLjggNC41IDQuNSAwIDAgMSA4IDIuOGMwIDUuNS04IDEwLTggMTB6IiBmaWxsPSIjRjJDMTFGIi8+PC9zdmc+" alt="">
|
||||
<div><b>Behavision</b> <span id="site">— live demo</span></div>
|
||||
</div>
|
||||
<span class="pill"><i class="dot" id="d-eng"></i><span id="t-eng">engine…</span></span>
|
||||
<span class="pill"><i class="dot" id="d-cam"></i><span id="t-cam">camera…</span></span>
|
||||
<span class="pill"><i class="dot" id="d-cloud"></i><span id="t-cloud">cloud…</span></span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>The camera <span class="grow" id="camname"></span></h2>
|
||||
<div class="cam">
|
||||
<img id="feed" alt="" style="display:none">
|
||||
<div class="none" id="feednone">waiting for the camera…</div>
|
||||
<div class="tag" id="camtag" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>The customer <span class="grow">type a name, then walk past again</span></h2>
|
||||
<div id="cust">
|
||||
<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>What just happened <span class="grow" id="lat"></span></h2>
|
||||
<div id="chain"><div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>What the mobile app receives</h2>
|
||||
<div class="req" id="m-req">GET /api/visits<span class="st" id="m-st"></span></div>
|
||||
<pre id="m-body">…</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>What the dashboard receives</h2>
|
||||
<div class="req" id="d-req">GET /api/reports/footfall<span class="st" id="d-st"></span></div>
|
||||
<pre id="d-body">…</pre>
|
||||
<div class="hint">Both of these are the real production API at mcp.loyaly.ai, called from this
|
||||
machine with a staff login — not a mock, and not the local engine.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
let camStarted = null, current = null, savedFor = null;
|
||||
|
||||
function setPill(dot, text, tone, label){
|
||||
$(dot).className = 'dot' + (tone ? ' ' + tone : '');
|
||||
$(text).textContent = label;
|
||||
}
|
||||
|
||||
function initials(name, ref){
|
||||
const m = /^Visitor (\d+)$/.exec((name||'').trim());
|
||||
if (m) return m[1];
|
||||
const w = (name||'').trim().split(/\s+/).filter(Boolean);
|
||||
if (!w.length) return '?';
|
||||
return (w[0][0] + (w[1]?.[0] ?? '')).toUpperCase();
|
||||
}
|
||||
|
||||
function drawChain(e){
|
||||
if (!e){ $('#chain').innerHTML = '<div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div>'; $('#lat').textContent=''; return; }
|
||||
const g = e.engine, c = e.cloud;
|
||||
const steps = [
|
||||
[true, 'Camera saw a face', g.quality != null ? `quality ${(+g.quality).toFixed(2)} · camera ${g.camera}` : `camera ${g.camera}`, ''],
|
||||
[true, e.kind === 'new' ? 'Engine: nobody it knows → enrolled' : 'Engine: matched a returning customer',
|
||||
(g.label || '') + (g.similarity != null && g.similarity >= 0 ? ` · similarity ${(+g.similarity).toFixed(2)}` : '') , ''],
|
||||
[true, 'Agent queued the visit', 'durable on this disk until the broker confirms', ''],
|
||||
[!!c, 'Broker delivered it', 'MQTT over TLS to mcp.loyaly.ai', ''],
|
||||
[!!c, 'Server recorded it', c ? `${c.site} · ${c.is_new ? 'new customer' : 'returning'}` : 'waiting…', ''],
|
||||
[!!c, 'Mobile + dashboard can see it', c ? `visit ${String(c.visit_id).slice(0,8)}` : 'waiting…',
|
||||
e.latency != null ? `+${e.latency}s` : ''],
|
||||
];
|
||||
$('#chain').innerHTML = steps.map(([on,title,sub,ms],i)=>
|
||||
`<div class="step ${on?'on':''}"><div class="n">${i+1}</div><div><b>${title}</b><small>${sub}</small></div><div class="ms">${ms}</div></div>`
|
||||
).join('');
|
||||
$('#lat').textContent = e.latency != null ? `camera → cloud in ${e.latency}s` : '';
|
||||
}
|
||||
|
||||
function drawCustomer(e){
|
||||
const c = e && e.cloud;
|
||||
if (!c){ if(!current) $('#cust').innerHTML = '<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>'; return; }
|
||||
const changed = !current || current.visitor_id !== c.visitor_id || current.visit_id !== c.visit_id;
|
||||
if (!changed) return;
|
||||
current = c;
|
||||
const name = c.label || 'Unrecognised';
|
||||
const img = c.image && c.image.available && c.image.url;
|
||||
$('#cust').innerHTML = `
|
||||
<div class="who">
|
||||
<div class="av">${img ? `<img src="${img}">` : initials(name)}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="n">${name}</div>
|
||||
<div class="m">${c.ref ? c.ref + ' · ' : ''}${c.is_new ? 'first time here' : 'returning'}${c.similarity>0 ? ' · match ' + (+c.similarity).toFixed(2) : ''}</div>
|
||||
</div>
|
||||
<span class="badge ${c.is_new?'new':'seen'}">${c.is_new?'new':'returning'}</span>
|
||||
</div>
|
||||
<div class="pad">
|
||||
<div class="row">
|
||||
<div><label>Name</label><input id="f-name" placeholder="e.g. Suriya" value=""></div>
|
||||
<div><label>Phone</label><input id="f-phone" placeholder="+91…" value=""></div>
|
||||
</div>
|
||||
<button id="save">Save to the customer record</button>
|
||||
<div id="savedmsg"></div>
|
||||
<div class="hint" style="padding:12px 0 0">This writes to the production API. Walk past again and
|
||||
the name comes back through the cloud instead of “${name}”.</div>
|
||||
</div>`;
|
||||
$('#save').onclick = async () => {
|
||||
const b = $('#save'); b.disabled = true; b.textContent = 'Saving…';
|
||||
const r = await fetch('/api/profile', {method:'POST', headers:{'content-type':'application/json'},
|
||||
body: JSON.stringify({id: c.visitor_id, full_name: $('#f-name').value, phone: $('#f-phone').value})});
|
||||
const d = await r.json();
|
||||
b.disabled = false; b.textContent = 'Save to the customer record';
|
||||
$('#savedmsg').innerHTML = (d.status===200||d.status===204)
|
||||
? '<div class="saved">✓ Saved — PUT /api/visitors/'+String(c.visitor_id).slice(0,8)+'…/profile → '+d.status+'</div>'
|
||||
: '<div class="saved" style="color:var(--bad)">'+(d.body&&d.body.message||('HTTP '+d.status))+'</div>';
|
||||
savedFor = c.visitor_id;
|
||||
};
|
||||
}
|
||||
|
||||
async function tick(){
|
||||
let s;
|
||||
try { s = await (await fetch('/api/snapshot')).json(); } catch { return; }
|
||||
|
||||
const eng = s.engine || {};
|
||||
setPill('#d-eng','#t-eng', eng.up ? 'ok' : 'bad', eng.up ? ('engine · ' + (eng.model||'starting')) : 'engine starting…');
|
||||
const cam = (eng.cameras||[])[0];
|
||||
setPill('#d-cam','#t-cam', cam && cam.connected ? 'ok' : 'warn',
|
||||
cam ? (cam.connected ? `camera live · ${cam.frames||0} frames` : 'camera connecting…') : 'no camera yet');
|
||||
setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable');
|
||||
$('#camname').textContent = cam ? cam.id : '';
|
||||
|
||||
if (cam && cam.connected){
|
||||
if (camStarted !== cam.id){
|
||||
camStarted = cam.id;
|
||||
$('#feed').style.display = 'block'; $('#feednone').style.display = 'none';
|
||||
$('#camtag').style.display = 'block';
|
||||
// A polled still rather than an MJPEG stream: the engine re-serves its
|
||||
// latest frame anyway, and a multipart stream through a proxy is one
|
||||
// more thing to fail in front of an audience.
|
||||
setInterval(() => { $('#feed').src = '/camera.jpg?id=' +
|
||||
encodeURIComponent(camStarted) + '&t=' + Date.now(); }, 350);
|
||||
}
|
||||
$('#camtag').textContent = cam.id + (cam.tracks ? ` · ${cam.tracks} in frame` : '');
|
||||
}
|
||||
|
||||
const e = (s.chain||[])[0];
|
||||
drawChain(e); drawCustomer(e);
|
||||
|
||||
$('#m-st').textContent = s.mobile.status;
|
||||
$('#m-body').textContent = JSON.stringify(s.mobile.body, null, 1).slice(0, 2600);
|
||||
$('#d-st').textContent = s.dashboard.status;
|
||||
$('#d-body').textContent = JSON.stringify(s.dashboard.body, null, 1).slice(0, 1800);
|
||||
}
|
||||
tick(); setInterval(tick, 1500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
330
demo/console.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""A one-screen live demo of the whole Behavision chain, for showing someone.
|
||||
|
||||
Run it on the shop PC (here, this Mac) while the engine and agent are running.
|
||||
It holds every credential itself and the browser holds none, so the page can be
|
||||
put on a projector without putting a token on it.
|
||||
|
||||
What it shows, and why each part is there:
|
||||
|
||||
- the live camera, so the person walking past sees themselves;
|
||||
- the CHAIN, measured rather than described: the engine recognised a face at
|
||||
this instant, the same visit appeared in the cloud API this many seconds
|
||||
later. That number is the product's claim, and it is computed here from two
|
||||
independent sources rather than asserted;
|
||||
- the customer, editable - type a name, walk past again, watch the name come
|
||||
back through the cloud instead of "Visitor 5";
|
||||
- the raw JSON a phone and a dashboard receive, side by side, because a
|
||||
colleague's real question is "is this actually wired up or is it a mock".
|
||||
|
||||
.venv/bin/python demo/console.py # http://127.0.0.1:8099
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
STATE = Path(os.environ.get("BEHAVISION_DATA_DIR", ROOT / ".demo"))
|
||||
CLOUD = os.environ.get("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")
|
||||
ENGINE = "http://127.0.0.1:8010"
|
||||
PORT = int(os.environ.get("DEMO_PORT", "8099"))
|
||||
|
||||
# Whoever the demo signs in as. Staff on purpose: it is the weakest role that
|
||||
# can do everything the shop floor does, so nothing here is only possible
|
||||
# because we used an owner.
|
||||
EMAIL = os.environ.get("DEMO_EMAIL", "staff.demo@tenext.in")
|
||||
PASSWORD = os.environ.get("DEMO_PASSWORD", "admin@123")
|
||||
|
||||
|
||||
def engine_auth() -> str:
|
||||
"""The engine invents a Basic credential when none is configured, and
|
||||
writes it here. Read it rather than keeping a second copy."""
|
||||
f = STATE / "data" / "api_credentials.txt"
|
||||
if not f.exists():
|
||||
return ""
|
||||
user = pw = ""
|
||||
for line in f.read_text().splitlines():
|
||||
# `key=value`, and `key: value` too - the engine writes one and people
|
||||
# read the other, and which is which is not worth a support call.
|
||||
if "=" in line or ":" in line:
|
||||
k, v = line.split("=", 1) if "=" in line else line.split(":", 1)
|
||||
if k.strip().lower() == "username":
|
||||
user = v.strip()
|
||||
elif k.strip().lower() == "password":
|
||||
pw = v.strip()
|
||||
if not user:
|
||||
return ""
|
||||
return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||
|
||||
|
||||
def fetch(url: str, *, headers=None, body=None, method="GET", timeout=20):
|
||||
req = urllib.request.Request(url, method=method,
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={k: v for k, v in (headers or {}).items() if v})
|
||||
if body is not None:
|
||||
req.add_header("content-type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw and r.headers.get("content-type", "").startswith("application/json") else raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
return e.code, json.loads(raw or b"{}")
|
||||
except Exception:
|
||||
return e.code, raw[:400]
|
||||
except Exception as e:
|
||||
return 0, {"error": str(e)}
|
||||
|
||||
|
||||
class Cloud:
|
||||
"""The signed-in session, refreshed when it expires."""
|
||||
|
||||
def __init__(self):
|
||||
self.token = ""
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def sign_in(self) -> bool:
|
||||
st, d = fetch(f"{CLOUD}/api/auth/login", method="POST",
|
||||
body={"email": EMAIL, "password": PASSWORD, "device": "Demo console"})
|
||||
if st == 200 and isinstance(d, dict):
|
||||
self.token = d.get("access_token", "")
|
||||
return True
|
||||
return False
|
||||
|
||||
def call(self, path, method="GET", body=None, retry=True):
|
||||
with self.lock:
|
||||
if not self.token and not self.sign_in():
|
||||
return 0, {"error": "cannot sign in to the platform"}
|
||||
tok = self.token
|
||||
st, d = fetch(f"{CLOUD}{path}", method=method, body=body,
|
||||
headers={"authorization": f"Bearer {tok}"})
|
||||
if st == 401 and retry:
|
||||
with self.lock:
|
||||
self.sign_in()
|
||||
return self.call(path, method, body, retry=False)
|
||||
return st, d
|
||||
|
||||
|
||||
cloud = Cloud()
|
||||
|
||||
# The chain, as the watcher builds it. One dict per recognition, newest first.
|
||||
events: list[dict] = []
|
||||
events_lock = threading.Lock()
|
||||
|
||||
|
||||
def watch():
|
||||
"""Poll the engine's own event log and the cloud feed, and join them.
|
||||
|
||||
They are joined on the identity and the second, not on a shared id,
|
||||
because the engine numbers identities locally and the server numbers them
|
||||
per tenant - the two are deliberately different (see CLAUDE.md). What
|
||||
matters for the demo is the LATENCY between one seeing a person and the
|
||||
other, and that only needs the same person and the same moment.
|
||||
"""
|
||||
seen_local: set[str] = set()
|
||||
while True:
|
||||
try:
|
||||
auth = engine_auth()
|
||||
st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth})
|
||||
# The engine returns a bare list; a dict with "events" is accepted
|
||||
# too so this survives either shape.
|
||||
evs = d if isinstance(d, list) else (d or {}).get("events", []) if isinstance(d, dict) else []
|
||||
if st == 200:
|
||||
for e in evs:
|
||||
if e.get("type") not in ("person.new", "person.seen"):
|
||||
continue
|
||||
key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}"
|
||||
if key in seen_local:
|
||||
continue
|
||||
seen_local.add(key)
|
||||
data = e.get("data") or {}
|
||||
with events_lock:
|
||||
events.insert(0, {
|
||||
"key": key,
|
||||
"at": time.time(),
|
||||
"kind": "new" if e["type"] == "person.new" else "seen",
|
||||
"engine": {
|
||||
"label": data.get("label"),
|
||||
"identity_id": data.get("identity_id"),
|
||||
"similarity": data.get("similarity"),
|
||||
"quality": data.get("quality"),
|
||||
"gender": data.get("gender"),
|
||||
"age": data.get("age"),
|
||||
"camera": e.get("camera_id"),
|
||||
"ts": e.get("ts"),
|
||||
},
|
||||
"cloud": None,
|
||||
"latency": None,
|
||||
})
|
||||
del events[40:]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# the other half: has the cloud got it yet?
|
||||
try:
|
||||
with events_lock:
|
||||
pending = [e for e in events if e["cloud"] is None][:6]
|
||||
if pending:
|
||||
st, d = cloud.call("/api/visits?limit=12")
|
||||
arrivals = (d or {}).get("arrivals", []) if isinstance(d, dict) else []
|
||||
for e in pending:
|
||||
for a in arrivals:
|
||||
# same camera, and the cloud's visit is not older than
|
||||
# the engine's sighting
|
||||
if a.get("camera_id") != e["engine"]["camera"]:
|
||||
continue
|
||||
if a.get("visit_id") in [x["cloud"].get("visit_id") for x in events if x["cloud"]]:
|
||||
continue
|
||||
with events_lock:
|
||||
e["cloud"] = {
|
||||
"visit_id": a.get("visit_id"),
|
||||
"visitor_id": a.get("visitor_id"),
|
||||
"label": a.get("label"),
|
||||
"ref": a.get("customer_ref") or a.get("ref"),
|
||||
"is_new": a.get("is_new_visitor"),
|
||||
"similarity": a.get("similarity"),
|
||||
"site": a.get("site"),
|
||||
"occurred_at": a.get("occurred_at"),
|
||||
"image": a.get("image"),
|
||||
}
|
||||
e["latency"] = round(time.time() - e["at"], 1)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Everything the page draws, in one reply."""
|
||||
auth = engine_auth()
|
||||
_, health = fetch(f"{ENGINE}/api/health", headers={"authorization": auth})
|
||||
_, stats = fetch(f"{ENGINE}/api/stats", headers={"authorization": auth})
|
||||
st_v, visits = cloud.call("/api/visits?limit=3")
|
||||
st_f, foot = cloud.call("/api/reports/footfall?from=%s&to=%s"
|
||||
% (time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400)),
|
||||
time.strftime("%Y-%m-%d")))
|
||||
with events_lock:
|
||||
chain = json.loads(json.dumps(events[:8]))
|
||||
cams = (stats or {}).get("cameras", []) if isinstance(stats, dict) else []
|
||||
return {
|
||||
"engine": {
|
||||
"up": isinstance(health, dict) and bool(health.get("status")),
|
||||
"model": (health or {}).get("recognition_model") if isinstance(health, dict) else None,
|
||||
"cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"),
|
||||
"frames": c.get("frames_processed") or c.get("frames_total"),
|
||||
"faces": c.get("faces_seen"),
|
||||
"tracks": c.get("active_tracks")} for c in cams],
|
||||
},
|
||||
"cloud_ok": st_v == 200,
|
||||
"chain": chain,
|
||||
"mobile": {"request": "GET /api/visits?limit=3", "status": st_v, "body": visits},
|
||||
"dashboard": {"request": "GET /api/reports/footfall?from=…&to=…", "status": st_f, "body": foot},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
page = (Path(__file__).parent / "console.html").read_bytes()
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): # quiet
|
||||
pass
|
||||
|
||||
def _send(self, code, body, ctype="application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("content-type", ctype)
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
u = urlparse(self.path)
|
||||
if u.path == "/":
|
||||
return self._send(200, page, "text/html; charset=utf-8")
|
||||
if u.path == "/api/snapshot":
|
||||
return self._send(200, json.dumps(snapshot()).encode())
|
||||
if u.path == "/api/customer":
|
||||
vid = parse_qs(u.query).get("id", [""])[0]
|
||||
if not vid:
|
||||
return self._send(400, b'{"error":"no id"}')
|
||||
st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8")
|
||||
return self._send(200, json.dumps({"status": st, "history": d}).encode())
|
||||
if u.path == "/camera.jpg":
|
||||
# A polled still, not the MJPEG stream. The engine re-serves its
|
||||
# latest frame until the pipeline produces a new one, so polling
|
||||
# shows the same picture - and a multipart stream through a
|
||||
# proxy is one more thing to fail in front of an audience.
|
||||
cam = parse_qs(u.query).get("id", [""])[0]
|
||||
st, body = fetch(f"{ENGINE}/api/cameras/{cam}/frame.jpg",
|
||||
headers={"authorization": engine_auth()}, timeout=15)
|
||||
if st != 200 or not isinstance(body, (bytes, bytearray)):
|
||||
return self._send(502, b'{"error":"no frame"}')
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "image/jpeg")
|
||||
self.send_header("cache-control", "no-store")
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
self._send(404, b'{"error":"no"}')
|
||||
|
||||
def do_POST(self):
|
||||
u = urlparse(self.path)
|
||||
n = int(self.headers.get("content-length", 0))
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
if u.path == "/api/profile":
|
||||
vid = body.pop("id", "")
|
||||
st, d = cloud.call(f"/api/visitors/{vid}/profile", method="PUT", body=body)
|
||||
return self._send(200, json.dumps({"status": st, "body": d}).encode())
|
||||
self._send(404, b'{"error":"no"}')
|
||||
|
||||
def _proxy_stream(self, url):
|
||||
"""The engine's MJPEG, relayed so the browser needs no credential.
|
||||
|
||||
The engine's API is Basic-authenticated with a credential it
|
||||
generated locally; putting that in a page would hand the whole
|
||||
biometric API to anyone who opened it.
|
||||
"""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"authorization": engine_auth()})
|
||||
up = urllib.request.urlopen(req, timeout=20)
|
||||
except Exception:
|
||||
return self._send(502, b'{"error":"camera not available"}')
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", up.headers.get("content-type", "multipart/x-mixed-replace"))
|
||||
self.end_headers()
|
||||
try:
|
||||
while True:
|
||||
chunk = up.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
up.close()
|
||||
|
||||
threading.Thread(target=watch, daemon=True).start()
|
||||
print(f"\n Demo console → http://127.0.0.1:{PORT}\n")
|
||||
print(f" engine {ENGINE} · cloud {CLOUD} · signed in as {EMAIL}\n")
|
||||
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
369
desktop/app.go
@@ -23,6 +23,7 @@ import (
|
||||
agentcameras "github.com/loyaly/behavision-agent/pkg/cameras"
|
||||
agentcfg "github.com/loyaly/behavision-agent/pkg/config"
|
||||
agentengine "github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/enrol"
|
||||
agentmqtt "github.com/loyaly/behavision-agent/pkg/mqtt"
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
agentspool "github.com/loyaly/behavision-agent/pkg/spool"
|
||||
@@ -43,6 +44,12 @@ type App struct {
|
||||
broker *agentmqtt.Client
|
||||
stopBridge func()
|
||||
hookURL string
|
||||
// The resolved engine command, so engineMissing() and the supervisor are
|
||||
// never looking at two different paths.
|
||||
engineExe string
|
||||
// Relays camera feeds to the webview so the engine's credential never has
|
||||
// to travel in an <img> src, which a Chromium webview would strip anyway.
|
||||
proxy *streamProxy
|
||||
// Set once the operator logs in. Until then the UI shows the login sheet
|
||||
// and nothing else is reachable.
|
||||
onSessionChange func(bool)
|
||||
@@ -62,7 +69,8 @@ func NewApp() *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
||||
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
||||
local: localWithCreds(base, cfg),
|
||||
proxy: newStreamProxy(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +78,22 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
_ = agentpaths.EnsureState()
|
||||
|
||||
// Before any screen asks for a camera URL. A failure here is logged and
|
||||
// not fatal: the rest of the app - people, cameras, the engine controls -
|
||||
// works without a picture, and refusing to start over a broken tile would
|
||||
// take a working shop offline.
|
||||
if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil {
|
||||
log.Printf("camera relay unavailable, tiles will not load: %v", err)
|
||||
}
|
||||
// And the other direction: watching a camera in another building, through
|
||||
// head office's relay. Enabled unconditionally rather than only when a
|
||||
// session already exists, because signing in is a thing that happens
|
||||
// while the app is open - and CameraLive refuses without a session
|
||||
// anyway, so there is nothing to gate.
|
||||
if err := a.proxy.watchRemote(a.cloud.CameraLive); err != nil {
|
||||
log.Printf("remote camera view unavailable: %v", err)
|
||||
}
|
||||
|
||||
// A saved session means a shop PC that rebooted overnight comes back
|
||||
// working instead of waiting for someone to log in.
|
||||
if a.cfg.SessionToken != "" {
|
||||
@@ -88,10 +112,21 @@ func (a *App) startup(ctx context.Context) {
|
||||
if exe != "" && !filepath.IsAbs(exe) {
|
||||
exe = filepath.Join(agentpaths.InstallRoot(), exe)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.engineExe = exe
|
||||
a.mu.Unlock()
|
||||
logFile, _ := agentengine.LogFile(agentpaths.EngineLog())
|
||||
a.sup = agentengine.New(agentengine.Options{
|
||||
Command: func(c context.Context) *exec.Cmd {
|
||||
cmd := exec.CommandContext(c, exe, a.cfg.EngineArgs...)
|
||||
// Run the engine FROM a known directory rather than from whatever
|
||||
// happened to launch us. A double-clicked bundle hands its child
|
||||
// "/", and an engine invoked as `-m behavision` then cannot find
|
||||
// itself - measured on macOS, where it retried forever.
|
||||
cmd.Dir = a.cfg.EngineDir
|
||||
if cmd.Dir == "" {
|
||||
cmd.Dir = agentpaths.InstallRoot()
|
||||
}
|
||||
// How the engine learns where to post its detections. Its config
|
||||
// already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`,
|
||||
// and python-dotenv does not override a variable the process
|
||||
@@ -102,7 +137,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
// be told again. Without it the engine recognised people and the
|
||||
// bridge received nothing: a claimed shop PC published heartbeats
|
||||
// and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
|
||||
cmd.Env = agentengine.ChildEnv(a.webhookURL())
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
@@ -112,6 +147,66 @@ func (a *App) startup(ctx context.Context) {
|
||||
})
|
||||
|
||||
a.startPipeline(ctx)
|
||||
go a.watchConfig(ctx)
|
||||
|
||||
// Recognition starts with the app. Until this, the engine only ever
|
||||
// started when somebody pressed Start - which meant a till that rebooted
|
||||
// overnight came back with the window open, the tray icon showing, the
|
||||
// session restored, and recognition off until a shop assistant noticed.
|
||||
// That is the failure the tray colours exist to catch, and it should not
|
||||
// be the default state every morning.
|
||||
//
|
||||
// Guarded on the interpreter actually being there: on a PC where setup has
|
||||
// not run yet, starting the supervisor would loop on a missing executable
|
||||
// with nothing useful to say. The Start button still exists for the one
|
||||
// case where somebody has deliberately stopped it.
|
||||
if why := a.engineMissing(); why == "" {
|
||||
a.sup.Start()
|
||||
} else {
|
||||
log.Printf("%s (looked for %s)", why, exe)
|
||||
}
|
||||
}
|
||||
|
||||
// engineMissing says, in a sentence somebody can act on, why recognition
|
||||
// cannot start here - or "" when it can.
|
||||
//
|
||||
// It exists because the answer was only ever given at startup, to a log file
|
||||
// nobody on a shop counter opens. Pressing Start went straight to the
|
||||
// supervisor, which reported what exec reported:
|
||||
//
|
||||
// engine failed to start: fork/exec /private/var/folders/c2/.../
|
||||
// AppTranslocation/500A5354-.../d/Behavision.app/Contents/MacOS/engine/
|
||||
// behavision: no such file or directory
|
||||
//
|
||||
// Every word of that is true and none of it says "run the setup tool". One
|
||||
// function, consulted by the startup path, the Start button and the status
|
||||
// panel, so the three cannot give three different accounts of one fact.
|
||||
func (a *App) engineMissing() string {
|
||||
a.mu.RLock()
|
||||
exe := a.engineExe
|
||||
a.mu.RUnlock()
|
||||
if exe == "" {
|
||||
return "The recognition engine is not set up on this computer yet."
|
||||
}
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := "The recognition engine is not installed on this computer yet. " +
|
||||
"Run behavision-setup from the folder you unzipped, then press Start."
|
||||
// macOS quarantines a downloaded app it cannot verify and runs it from a
|
||||
// randomly named READ-ONLY copy - App Translocation. Every relative path
|
||||
// then resolves inside that copy, which is why the engine folder appears
|
||||
// to be missing from a bundle that plainly contains one, and why an
|
||||
// install into it would not survive a restart. Detectable, unguessable,
|
||||
// and fixed by one drag; saying nothing leaves somebody re-running a
|
||||
// setup tool that cannot win.
|
||||
if strings.Contains(exe, "/AppTranslocation/") {
|
||||
msg = "macOS is running Behavision from a temporary read-only copy, " +
|
||||
"because it was opened straight from Downloads. Move Behavision " +
|
||||
"to your Applications folder and open it from there, then run " +
|
||||
"behavision-setup."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// webhookURL is the loopback address the bridge is listening on, or empty
|
||||
@@ -195,7 +290,7 @@ func (a *App) startPipeline(ctx context.Context) {
|
||||
}
|
||||
client, err := agentmqtt.NewClient(agentmqtt.ClientOptions{
|
||||
BrokerURL: a.cfg.BrokerURL,
|
||||
ClientID: "behavision-" + a.cfg.ClientID + "-" + a.cfg.SiteID,
|
||||
ClientID: a.cfg.MQTTClientID(),
|
||||
Username: a.cfg.BrokerUsername, Password: a.cfg.BrokerPassword,
|
||||
CAFile: a.cfg.BrokerCAFile, Log: logger,
|
||||
})
|
||||
@@ -229,6 +324,9 @@ func (a *App) startLocalCameras(ctx context.Context, logger *log.Logger) {
|
||||
// this app - and the headless agent - both ended up wiring configuration
|
||||
// and forgetting the check runner, so "Test connection" at head office
|
||||
// never completed on any shop PC.
|
||||
// The live relay runs alongside the reconciler and uploads nothing until
|
||||
// somebody at head office is actually watching a camera.
|
||||
go agentcameras.NewLive(camEngine, camCloud, logger).Run(ctx)
|
||||
agentcameras.New(camEngine, camCloud, logger).Run(ctx)
|
||||
}
|
||||
|
||||
@@ -270,8 +368,8 @@ type PipelineStatus struct {
|
||||
// Standalone separates "nothing is being sent because this PC is set up on
|
||||
// its own" from "nothing is being sent and something is wrong". They look
|
||||
// identical from the counters alone, and only one of them is a fault.
|
||||
Standalone bool `json:"standalone"`
|
||||
BrokerUp bool `json:"broker_up"`
|
||||
Standalone bool `json:"standalone"`
|
||||
BrokerUp bool `json:"broker_up"`
|
||||
Accepted uint64 `json:"accepted"`
|
||||
}
|
||||
|
||||
@@ -402,6 +500,14 @@ func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
a.cfg.BrokerPassword = b.MQTTPass
|
||||
a.cfg.AgentToken = b.AgentToken
|
||||
a.cfg.CloudBase = a.cloud.Base
|
||||
// A new head office: whoever was signed in was signed in somewhere else.
|
||||
a.cfg.SessionToken, a.cfg.SessionRefresh, a.cfg.SessionEmail = "", "", ""
|
||||
a.cloud.Clear()
|
||||
caPath, err := enrol.SaveCA(b.CACert, agentpaths.BrokerCA())
|
||||
if err != nil {
|
||||
return SessionInfo{}, err
|
||||
}
|
||||
a.cfg.BrokerCAFile = caPath
|
||||
// A PC that was running on its own and has now been linked is no longer
|
||||
// standalone. Leaving the flag set would keep the head-office screens
|
||||
// hidden on the one machine that just earned them.
|
||||
@@ -426,6 +532,62 @@ func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
// the current config. Only Claim needs it today; it exists as its own method
|
||||
// because "stop everything that reads the config, then start it" is the part
|
||||
// that is easy to get half right.
|
||||
// watchConfig reloads agent.json when something else writes it.
|
||||
//
|
||||
// behavision-setup re-run on a PC with the app open re-claims the shop and
|
||||
// rotates its API token; the running app kept the old one and every camera
|
||||
// sync was refused from then on - heartbeats still flowed, so head office
|
||||
// looked fine while the cameras went stale. A claim from `behavision-agent
|
||||
// claim` does the same. Rather than ask people to restart the app, the app
|
||||
// watches the file and picks the new credentials up itself.
|
||||
func (a *App) watchConfig(ctx context.Context) {
|
||||
path := agentpaths.AgentConfig()
|
||||
last := mtime(path)
|
||||
t := time.NewTicker(10 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
now := mtime(path)
|
||||
if now.IsZero() || now.Equal(last) {
|
||||
continue
|
||||
}
|
||||
last = now
|
||||
fresh, err := agentcfg.Load(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fresh = fresh.WithEngineCredentials(agentpaths.APICredentials())
|
||||
a.mu.Lock()
|
||||
changed := fresh.AgentToken != a.cfg.AgentToken || fresh.SiteID != a.cfg.SiteID ||
|
||||
fresh.BrokerPassword != a.cfg.BrokerPassword || fresh.CloudBase != a.cfg.CloudBase ||
|
||||
fresh.Standalone != a.cfg.Standalone
|
||||
if changed {
|
||||
// Keep this process's live session; a claim clears it in the file
|
||||
// deliberately, and that is honoured too.
|
||||
a.cfg = fresh
|
||||
if fresh.SessionToken == "" {
|
||||
a.cloud.Clear()
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if changed {
|
||||
a.restartPipeline()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mtime(path string) time.Time {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return st.ModTime()
|
||||
}
|
||||
|
||||
func (a *App) restartPipeline() {
|
||||
if a.stopBridge != nil {
|
||||
a.stopBridge()
|
||||
@@ -463,6 +625,8 @@ type EngineStatus struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
Model string `json:"recognition_model,omitempty"`
|
||||
Cameras map[string]bool `json:"cameras,omitempty"`
|
||||
// Progress is the first-run model download, when one is happening.
|
||||
Progress *agentengine.Progress `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) EngineStatus() EngineStatus {
|
||||
@@ -473,9 +637,17 @@ func (a *App) EngineStatus() EngineStatus {
|
||||
st, err := a.sup.State()
|
||||
out.State = string(st)
|
||||
out.Restarts = a.sup.Restarts()
|
||||
if p := a.sup.Progress(); p.What != "" {
|
||||
out.Progress = &p
|
||||
}
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
}
|
||||
// The supervisor's own error is an exec failure; this replaces it with
|
||||
// the reason, which is the part that tells somebody what to do.
|
||||
if why := a.engineMissing(); why != "" {
|
||||
out.Error = why
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
// A running process is not a working engine: on a memory-starved box the
|
||||
@@ -490,6 +662,13 @@ func (a *App) EngineStatus() EngineStatus {
|
||||
}
|
||||
|
||||
func (a *App) StartEngine() EngineStatus {
|
||||
// Refused rather than attempted. Handing a missing path to the supervisor
|
||||
// produces a retry loop and an exec error for a message.
|
||||
if why := a.engineMissing(); why != "" {
|
||||
st := a.EngineStatus()
|
||||
st.Error = why
|
||||
return st
|
||||
}
|
||||
if a.sup != nil {
|
||||
a.sup.Start()
|
||||
}
|
||||
@@ -505,10 +684,52 @@ func (a *App) StopEngine() EngineStatus {
|
||||
|
||||
// ---------------------------------------------------------------- cameras --
|
||||
|
||||
// Cameras lists this PC's cameras, or the company's if this PC has none of
|
||||
// its own.
|
||||
//
|
||||
// The distinction is load-bearing and the UI is told which it got. A camera
|
||||
// from the local engine is one THIS machine can reach, edit and stream. One
|
||||
// from head office is a camera at a shop somewhere else: it has a snapshot
|
||||
// and a connection state, and it cannot be edited from here because the shop
|
||||
// PC on that LAN is the only thing that can reach it. Offering an Edit button
|
||||
// that could not work would be worse than not showing the camera at all.
|
||||
func (a *App) Cameras() ([]map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return a.local.Cameras(ctx)
|
||||
|
||||
cams, err := a.local.Cameras(ctx)
|
||||
if err == nil {
|
||||
return cams, nil
|
||||
}
|
||||
if !a.cloud.LoggedIn() {
|
||||
return nil, err
|
||||
}
|
||||
remote, rerr := a.cloud.RemoteCameras(ctx)
|
||||
if rerr != nil {
|
||||
return nil, err // the local failure is the one worth reporting
|
||||
}
|
||||
out := make([]map[string]any, 0, len(remote))
|
||||
for _, c := range remote {
|
||||
out = append(out, map[string]any{
|
||||
"id": c.ID, "camera_id": c.CameraID, "label": c.Label,
|
||||
"site": c.Site, "enabled": c.Enabled,
|
||||
"connected": c.Connected, "last_seen_at": c.LastSeenAt,
|
||||
"state": c.State, "state_note": c.StateNote,
|
||||
"snapshot": c.Snapshot, "snapshot_at": c.SnapshotAt,
|
||||
// What the screen keys off to hide Edit, Test and Check: this
|
||||
// camera is on a network this PC cannot reach.
|
||||
"remote": true,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiscoverCameras lists the cameras on this PC's network, so the add-camera
|
||||
// form is a pick-list and not a request for an IP address nobody knows.
|
||||
func (a *App) DiscoverCameras() (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return a.local.DiscoverCameras(ctx)
|
||||
}
|
||||
|
||||
func (a *App) TestCamera(cam map[string]any) (map[string]any, error) {
|
||||
@@ -546,15 +767,42 @@ func (a *App) PlacementResult(id string) (map[string]any, error) {
|
||||
return a.local.PlacementResult(ctx, id)
|
||||
}
|
||||
|
||||
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an
|
||||
// <img> tag can load it. Loopback only - it never leaves this machine.
|
||||
// StreamURL is the MJPEG endpoint for a camera tile.
|
||||
//
|
||||
// It points at this app's own loopback relay, not at the engine directly. The
|
||||
// previous version put the engine's Basic credentials inline in the URL, with
|
||||
// a comment saying they were there "so an <img> tag can load it" - which a
|
||||
// browser will not do. Chromium strips credentials from subresource URLs, and
|
||||
// WebView2 is Chromium, so every camera tile on a shop PC was a broken image.
|
||||
// See stream_proxy.go for the measurement.
|
||||
//
|
||||
// The relay is also why no password appears in the page any more. If it is not
|
||||
// running the fallback is the bare engine URL with no credential: correct for
|
||||
// an engine configured without auth, and for one with auth a tile that fails
|
||||
// to load rather than a password sitting in the DOM.
|
||||
func (a *App) StreamURL(cameraID string) string {
|
||||
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
||||
if a.local.User == "" {
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
if u := a.proxy.urlFor(cameraID, "stream.mjpeg"); u != "" {
|
||||
return u
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg",
|
||||
a.local.User, a.local.Password, base, cameraID)
|
||||
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
}
|
||||
|
||||
// RemoteStreamURL is the live view of a camera in another building.
|
||||
//
|
||||
// The picture comes from head office's relay - the shop PC pushes frames
|
||||
// outbound because nothing can reach in - and this app re-emits them as MJPEG
|
||||
// on its own loopback, so a tile is an ordinary <img> either way. A screen
|
||||
// therefore never has to know which building it is looking at.
|
||||
//
|
||||
// Empty when the relay is not running, and the caller shows the last snapshot
|
||||
// instead. There is no useful fallback URL: the head-office endpoint needs
|
||||
// this session's bearer, which an <img> cannot send.
|
||||
func (a *App) RemoteStreamURL(cameraID string) string {
|
||||
if !a.cloud.LoggedIn() {
|
||||
return ""
|
||||
}
|
||||
return a.proxy.urlFor(cameraID, "live.mjpeg")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- live --
|
||||
@@ -562,20 +810,93 @@ func (a *App) StreamURL(cameraID string) string {
|
||||
type LiveSnapshot struct {
|
||||
Stats map[string]any `json:"stats"`
|
||||
Events []map[string]any `json:"events"`
|
||||
// Viewing is true when none of this came from an engine on THIS PC. The
|
||||
// screen must say so: the numbers are the company's, not this machine's,
|
||||
// and a laptop in a hotel showing "2 cameras live" without that word
|
||||
// would be claiming to be watching a shop it cannot see.
|
||||
Viewing bool `json:"viewing"`
|
||||
}
|
||||
|
||||
// Live is what the shop PC sees, and falls back to what HEAD OFFICE sees.
|
||||
//
|
||||
// A PC with no engine is not necessarily broken - it is somebody signed in on
|
||||
// a laptop away from the shop, which is the ordinary way an owner looks at
|
||||
// their estate. Until now that produced "engine not reachable at
|
||||
// 127.0.0.1:8010", an accurate sentence and a useless one when the reader was
|
||||
// never expecting an engine on that machine.
|
||||
//
|
||||
// The local engine always wins when it is there: it is this shop's own
|
||||
// ground truth and it is live rather than a heartbeat old.
|
||||
func (a *App) Live() (LiveSnapshot, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats, err := a.local.Stats(ctx)
|
||||
if err == nil {
|
||||
events, eerr := a.local.Events(ctx, 40)
|
||||
if eerr == nil {
|
||||
return LiveSnapshot{Stats: stats, Events: events}, nil
|
||||
}
|
||||
}
|
||||
// No engine here. If nobody is signed in either, the honest answer is
|
||||
// still the local error - there is nothing else to show and the person
|
||||
// is most likely setting this PC up.
|
||||
if !a.cloud.LoggedIn() {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
return a.liveFromCloud(ctx)
|
||||
}
|
||||
|
||||
// liveFromCloud builds the same shape the Live screen already renders, out of
|
||||
// the estate's own feed, so the view needs no second code path.
|
||||
func (a *App) liveFromCloud(ctx context.Context) (LiveSnapshot, error) {
|
||||
sites, err := a.cloud.Sites(ctx)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
events, err := a.local.Events(ctx, 40)
|
||||
arrivals, err := a.cloud.Arrivals(ctx, 40)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
return LiveSnapshot{Stats: stats, Events: events}, nil
|
||||
|
||||
// The counters are summed across the estate, and fraction_below_gate
|
||||
// takes the WORST site rather than an average - one badly placed camera
|
||||
// is a hole in the numbers, and averaging it against three good ones
|
||||
// hides the only site anyone needs to visit. Same rule the heartbeat
|
||||
// already follows.
|
||||
var up, total int
|
||||
worst := 0.0
|
||||
people := map[string]struct{}{}
|
||||
for _, s := range sites {
|
||||
up, total = up+s.CamerasUp, total+s.CamerasTotal
|
||||
if s.FractionBelowGate > worst {
|
||||
worst = s.FractionBelowGate
|
||||
}
|
||||
}
|
||||
events := make([]map[string]any, 0, len(arrivals))
|
||||
for _, v := range arrivals {
|
||||
if v.VisitorID != "" {
|
||||
people[v.VisitorID] = struct{}{}
|
||||
}
|
||||
events = append(events, map[string]any{
|
||||
"type": map[bool]string{true: "person.new", false: "person.seen"}[v.IsNew],
|
||||
"ts": v.OccurredAt, "camera_id": v.CameraID,
|
||||
"data": map[string]any{
|
||||
"label": v.Label, "ref": v.Ref, "site": v.Site,
|
||||
"similarity": v.Similarity, "attributes": v.Attributes,
|
||||
},
|
||||
})
|
||||
}
|
||||
return LiveSnapshot{
|
||||
Viewing: true,
|
||||
Events: events,
|
||||
Stats: map[string]any{
|
||||
"cameras": []map[string]any{},
|
||||
"gallery": map[string]any{"identities": len(people), "sightings": len(arrivals)},
|
||||
"cameras_up": up, "cameras_total": total,
|
||||
"fraction_below_gate": worst,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reports --
|
||||
@@ -611,6 +932,15 @@ func (a *App) Sites() ([]cloud.SiteHealth, error) {
|
||||
}
|
||||
|
||||
// VisitorHistory is one customer's timeline, for the customer record screen.
|
||||
// Ask is the help panel. It needs head office: the assistant runs there,
|
||||
// against this company's own data, as this signed-in user. A PC running on
|
||||
// its own has nobody to ask, and the panel says so rather than erroring.
|
||||
func (a *App) Ask(history []cloud.AssistantTurn) (cloud.AssistantAnswer, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.Ask(ctx, history)
|
||||
}
|
||||
|
||||
func (a *App) VisitorHistory(id string, limit int) ([]cloud.Visit, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
@@ -685,3 +1015,12 @@ func envOr(key, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// localWithCreds builds the engine client with a credential resolver, so a
|
||||
// first run - where the engine writes its credential after the app has looked
|
||||
// for it - recovers by itself instead of 401ing for the life of the process.
|
||||
func localWithCreds(base string, cfg agentcfg.Config) *local.Client {
|
||||
c := local.New(base, cfg.APIUser, cfg.APIPassword)
|
||||
c.Creds = agentcfg.NewCreds(agentpaths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
return c
|
||||
}
|
||||
|
||||
BIN
desktop/build/appicon.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
68
desktop/build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{safeBundleID .Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
63
desktop/build/darwin/Info.plist
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{safeBundleID .Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
desktop/build/windows/icon.ico
Normal file
|
After Width: | Height: | Size: 67 KiB |
15
desktop/build/windows/info.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
desktop/build/windows/wails.exe.manifest
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
25
desktop/darwin_link.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build darwin
|
||||
|
||||
// Link the framework Wails' darwin frontend forgets.
|
||||
//
|
||||
// It references UTType (UniformTypeIdentifiers) without linking it, so a macOS
|
||||
// build fails at the LINK step with `Undefined symbols: _OBJC_CLASS_$_UTType`
|
||||
// - after compiling everything successfully, which makes it read like a broken
|
||||
// toolchain rather than one missing flag. That is why there was no Mac build:
|
||||
// not a design limit, a link error nobody had chased.
|
||||
//
|
||||
// Declared in the source rather than passed as CGO_LDFLAGS on the command
|
||||
// line, for the same reason deploy.sh now finds Go itself: a build that needs
|
||||
// the operator to know an incantation is a build that does not happen. Plain
|
||||
// `go build` and `wails build` both work on a Mac with this file present, and
|
||||
// the build tag makes it inert everywhere else.
|
||||
//
|
||||
// Note for anyone editing: the comment directly above `import "C"` is cgo's C
|
||||
// PREAMBLE, not documentation. This paragraph sits above `package main` on
|
||||
// purpose - put it there and the prose is compiled as C, which is how the
|
||||
// first attempt failed.
|
||||
|
||||
package main
|
||||
|
||||
// #cgo LDFLAGS: -framework UniformTypeIdentifiers
|
||||
import "C"
|
||||
40
desktop/frontend/dist/assets/index-6LYbNlbD.js
vendored
Normal file
1
desktop/frontend/dist/assets/index-DOJ2bRrM.css
vendored
Normal file
40
desktop/frontend/dist/assets/index-whFsTNQf.js
vendored
BIN
desktop/frontend/dist/assets/loyaly-mark-Ch-P3VSF.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
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-whFsTNQf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
|
||||
<script type="module" crossorigin src="./assets/index-6LYbNlbD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, isDesktop, message } from './bridge.js'
|
||||
import { usePolled } from './hooks.js'
|
||||
import * as Icon from './ui/icons.jsx'
|
||||
import logo from './assets/loyaly-mark.png'
|
||||
import Login from './views/Login.jsx'
|
||||
import Setup from './views/Setup.jsx'
|
||||
import Live from './views/Live.jsx'
|
||||
import Customers from './views/Customers.jsx'
|
||||
import Cameras from './views/Cameras.jsx'
|
||||
import Assistant from './views/Assistant.jsx'
|
||||
|
||||
// Three screens, and the trim is by AUDIENCE rather than by taste.
|
||||
//
|
||||
@@ -22,9 +25,9 @@ import Cameras from './views/Cameras.jsx'
|
||||
// the customer record lives on the server, the cameras and what this PC is
|
||||
// seeing do not.
|
||||
const VIEWS = [
|
||||
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
|
||||
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
|
||||
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
|
||||
{ id: 'live', label: 'Live', Glyph: Icon.Live, View: Live },
|
||||
{ id: 'customers', label: 'Customers', Glyph: Icon.People, View: Customers, cloud: true },
|
||||
{ id: 'cameras', label: 'Cameras', Glyph: Icon.Camera, View: Cameras },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
@@ -34,12 +37,23 @@ export default function App() {
|
||||
// A standalone PC can join head office later. That is the same Setup screen,
|
||||
// reached deliberately rather than because the app will not open otherwise.
|
||||
const [linking, setLinking] = useState(false)
|
||||
const [helping, setHelping] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setSession(await api.session()) } catch { setSession(null) }
|
||||
setBooting(false)
|
||||
})()
|
||||
let alive = true
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await api.session()
|
||||
if (alive) setSession(prev => JSON.stringify(prev) === JSON.stringify(s) ? prev : s)
|
||||
} catch { if (alive) setSession(null) }
|
||||
if (alive) setBooting(false)
|
||||
}
|
||||
load()
|
||||
// Re-read every few seconds: a session the server has ended - or one
|
||||
// that never belonged to this head office - must put Login back on
|
||||
// screen, not leave "session expired" banners on every page.
|
||||
const id = setInterval(load, 8000)
|
||||
return () => { alive = false; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
if (!isDesktop()) {
|
||||
@@ -48,6 +62,7 @@ export default function App() {
|
||||
// error nobody will read.
|
||||
return (
|
||||
<div className="login"><div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">
|
||||
This is the Behavision window running outside the app, so it has no
|
||||
@@ -78,41 +93,56 @@ export default function App() {
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<div className="brand">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<div className="id">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'This shop'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{views.map(v => (
|
||||
<button key={v.id} onClick={() => setView(v.id)}
|
||||
aria-current={v.id === view ? 'page' : undefined}>
|
||||
<span className="glyph">{v.glyph}</span>{v.label}
|
||||
{views.map(({ id, label, Glyph }) => (
|
||||
<button key={id} onClick={() => setView(id)}
|
||||
aria-current={id === view ? 'page' : undefined}>
|
||||
<Glyph size={17} />{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<EngineBox />
|
||||
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div className="who">
|
||||
{session.standalone
|
||||
? <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
Running on its own
|
||||
<div className="id">
|
||||
<b>On its own</b>
|
||||
<span>No head office</span>
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
<button className="btn sm icon" title="Link to head office"
|
||||
onClick={() => setLinking(true)}>
|
||||
Link to head office
|
||||
<Icon.Link size={15} />
|
||||
</button>
|
||||
</>
|
||||
: <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
{session.user?.email}
|
||||
<div className="id">
|
||||
<b>Signed in</b>
|
||||
<span>{session.user?.email}</span>
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
<button className="btn sm icon" title="Sign out"
|
||||
onClick={async () => setSession(await api.logout())}>
|
||||
Sign out
|
||||
<Icon.Logout size={15} />
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main"><Current session={session} /></main>
|
||||
<main className="main">
|
||||
<Current session={session} onNavigate={setView} />
|
||||
{/* Loya's door, top right of every screen. A buddy you have to find in
|
||||
a sidebar is not around; one in the corner is. */}
|
||||
{!helping && (
|
||||
<button className="loya-fab" onClick={() => setHelping(true)} aria-label="Ask Loya" title="Ask Loya">
|
||||
<img src={logo} alt="" /><span>Loya</span>
|
||||
</button>
|
||||
)}
|
||||
</main>
|
||||
{helping && <Assistant session={session} onClose={() => setHelping(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -136,7 +166,13 @@ function EngineBox() {
|
||||
const up = cams.filter(Boolean).length
|
||||
|
||||
let tone = 'idle', text = 'Stopped'
|
||||
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
||||
// Reachable but not ours: somebody started the engine outside this app, or a
|
||||
// previous copy is still up. Saying "Stopped" beside live camera feeds is the
|
||||
// two-surfaces-disagreeing bug the tray exists to avoid - and it is exactly
|
||||
// what this panel showed while recognition was visibly running.
|
||||
if (!running && s.reachable) { tone = 'warn'; text = 'Running outside the app' }
|
||||
else if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
||||
else if (running && !s.reachable && s.progress) { tone = 'warn'; text = `Downloading ${s.progress.what}… ${s.progress.percent}%` }
|
||||
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
|
||||
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
|
||||
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
|
||||
@@ -145,16 +181,21 @@ function EngineBox() {
|
||||
|
||||
return (
|
||||
<div className="enginebox">
|
||||
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
|
||||
{s.recognition_model && (
|
||||
<span className="label">Model: {s.recognition_model}</span>
|
||||
)}
|
||||
<div className="row">
|
||||
<i className={`dot ${tone === 'ok' ? 'live' : tone}`} />
|
||||
<span className="state">{text}</span>
|
||||
</div>
|
||||
{s.recognition_model && <span className="label">{s.recognition_model}</span>}
|
||||
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
|
||||
<div className="actions">
|
||||
<button className="btn sm" disabled={busy || running}
|
||||
onClick={() => act(api.startEngine)}>Start</button>
|
||||
<button className="btn sm" disabled={busy || running || s.reachable}
|
||||
onClick={() => act(api.startEngine)}>
|
||||
<Icon.Play size={13} />Start
|
||||
</button>
|
||||
<button className="btn sm" disabled={busy || !running}
|
||||
onClick={() => act(api.stopEngine)}>Stop</button>
|
||||
onClick={() => act(api.stopEngine)}>
|
||||
<Icon.Stop size={13} />Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
BIN
desktop/frontend/src/assets/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
@@ -32,11 +32,15 @@ export const api = {
|
||||
|
||||
cameras: () => call('Cameras'),
|
||||
testCamera: (cam) => call('TestCamera', cam),
|
||||
discoverCameras: () => call('DiscoverCameras'),
|
||||
saveCamera: (id, cam) => call('SaveCamera', id, cam),
|
||||
deleteCamera: (id) => call('DeleteCamera', id),
|
||||
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
|
||||
placementResult: (id) => call('PlacementResult', id),
|
||||
streamURL: (id) => call('StreamURL', id),
|
||||
// The live view of a camera in another building, relayed through head
|
||||
// office. Empty when nobody is signed in.
|
||||
remoteStreamURL: (id) => call('RemoteStreamURL', id),
|
||||
|
||||
live: () => call('Live'),
|
||||
pipelineStatus: () => call('PipelineStatus'),
|
||||
@@ -51,6 +55,7 @@ export const api = {
|
||||
sales: (from, to) => call('Sales', from, to),
|
||||
customers: (q, limit) => call('Customers', q, limit),
|
||||
saveProfile: (p) => call('SaveProfile', p),
|
||||
ask: (history) => call('Ask', history),
|
||||
recordPurchase: (id, amount, items, notes) =>
|
||||
call('RecordPurchase', id, amount, items, notes),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
// Dev only: ?mock=<scenario> renders the app in a browser with fake bindings.
|
||||
if (import.meta.env.DEV) await import('./mock.js')
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode><App /></React.StrictMode>
|
||||
)
|
||||
|
||||
83
desktop/frontend/src/mock.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// A stand-in for the Go bindings, for looking at screens in a browser.
|
||||
//
|
||||
// The app only exists inside Wails, so until this there was no way to put a
|
||||
// screen in front of somebody without a Windows build - which is how the
|
||||
// first-run experience went unreviewed. Loaded ONLY by the dev server and only
|
||||
// with ?mock=<scenario>; a production bundle never contains it.
|
||||
//
|
||||
// ?mock=fresh first launch after install: unclaimed, engine starting
|
||||
// ?mock=standalone chose "run on this PC only", no cameras yet
|
||||
// ?mock=claimed claimed, signed in, one camera, arrivals flowing
|
||||
//
|
||||
// State is in memory and advances as you click, so the flow can be walked.
|
||||
const scenario = new URLSearchParams(location.search).get('mock')
|
||||
|
||||
if (scenario) {
|
||||
const st = {
|
||||
claimed: scenario === 'claimed',
|
||||
standalone: scenario === 'standalone',
|
||||
logged_in: scenario === 'claimed',
|
||||
cameras: scenario === 'claimed' ? [{id: 'entrance', label: 'Entrance', host: '192.168.1.122', port: 554, path: '/ch0_1.264', username: 'admin', has_password: true, enabled: true}] : [],
|
||||
engine: scenario === 'fresh' ? 'starting' : 'running',
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
const user = {id: 'u1', email: 'suriya@tenext.in', full_name: 'Suriya', role: 'owner', client_id: 'c1', client_name: 'TeNext Retail'}
|
||||
const session = () => ({logged_in: st.logged_in, user: st.logged_in ? user : {}, site_name: st.claimed ? 'TeNext Chennai' : '', claimed: st.claimed, standalone: st.standalone && !st.claimed})
|
||||
const now = () => new Date().toISOString()
|
||||
const arrivals = () => st.cameras.length === 0 ? [] : [
|
||||
{type: 'person.seen', ts: now(), camera_id: 'entrance', data: {label: 'Visitor 3', identity_id: 3, similarity: 0.61, gender: 'Male', age: 34, emotion: 'neutral'}},
|
||||
{type: 'person.new', ts: new Date(Date.now() - 95e3).toISOString(), camera_id: 'entrance', data: {label: 'Visitor 7', identity_id: 7, gender: 'Female', age: 28}},
|
||||
{type: 'person.seen', ts: new Date(Date.now() - 410e3).toISOString(), camera_id: 'entrance', data: {label: 'Priya', identity_id: 2, similarity: 0.72, gender: 'Female', age: 41}},
|
||||
]
|
||||
const delay = (v, ms = 120) => new Promise(r => setTimeout(() => r(v), ms))
|
||||
const App = {
|
||||
Session: () => delay(session()),
|
||||
Login: (email) => { st.logged_in = true; user.email = email || user.email; return delay(session()) },
|
||||
Logout: () => { st.logged_in = false; return delay(session()) },
|
||||
Claim: (code) => code.replace(/[^A-Z0-9]/gi, '').length >= 20
|
||||
? (st.claimed = true, st.standalone = false, delay(session(), 900))
|
||||
: Promise.reject(new Error('That installation code is not valid. Ask for a new one.')),
|
||||
RunStandalone: () => { st.standalone = true; return delay(session()) },
|
||||
EngineStatus: () => {
|
||||
// The engine takes a minute or two on first run (models download).
|
||||
if (st.engine === 'starting' && Date.now() - st.startedAt > 20000) st.engine = 'running'
|
||||
const cams = Object.fromEntries(st.cameras.map(c => [c.id, true]))
|
||||
return delay({state: st.engine === 'starting' ? 'running' : 'running', reachable: st.engine !== 'starting', recognition_model: st.engine === 'starting' ? '' : 'w600k_r50', cameras: cams, restarts: 0})
|
||||
},
|
||||
StartEngine: () => delay({state: 'running', reachable: true}),
|
||||
StopEngine: () => delay({state: 'stopped', reachable: false}),
|
||||
Cameras: () => delay(st.cameras.map(c => ({...c, connected: true, frames: 1200, faces: 9}))),
|
||||
DiscoverCameras: () => delay({networks: ['192.168.1.0/24'], cameras: [
|
||||
{host: '192.168.1.122', rtsp: true, onvif: true, name: 'HIKVISION DS-2CD2043G2', make: 'hikvision'},
|
||||
{host: '192.168.1.121', rtsp: true, onvif: true, name: 'IPC-model IPC', make: ''},
|
||||
{host: '192.168.1.40', rtsp: true, onvif: false, name: '', make: ''},
|
||||
]}, 2500),
|
||||
TestCamera: (cam) => delay({ok: Boolean(cam.host), width: 800, height: 448, codec: 'hevc', error: cam.host ? '' : 'no host'}, 1500),
|
||||
SaveCamera: (id, cam) => { const c = {id: id || cam.id || 'cam' + (st.cameras.length + 1), ...cam, has_password: Boolean(cam.password)}; delete c.password; st.cameras = [...st.cameras.filter(x => x.id !== c.id), c]; return delay(c) },
|
||||
DeleteCamera: (id) => { st.cameras = st.cameras.filter(c => c.id !== id); return delay(null) },
|
||||
StartPlacementCheck: () => delay({state: 'running'}),
|
||||
PlacementResult: () => delay({state: 'finished', verdict: 'good', headline: 'faces recognised on a walk-past', advice: []}),
|
||||
StreamURL: () => '',
|
||||
Live: () => delay({stats: {cameras: st.cameras.map(c => ({camera_id: c.id, connected: true, pipeline: {best_quality: {n: 40, fraction_below_gate: 0.18}}})), gallery: {identities: st.cameras.length ? 7 : 0, sightings: st.cameras.length ? 44 : 0}}, events: arrivals()}),
|
||||
PipelineStatus: () => delay({webhook_url: 'http://127.0.0.1:53658/events', queued: 0, dropped: 0, claimed: st.claimed, standalone: st.standalone && !st.claimed, broker_up: st.claimed, accepted: st.claimed ? 12 : 0}),
|
||||
LocalIdentities: () => delay([]), LocalSightings: () => delay([]),
|
||||
Footfall: () => delay({total: 0, buckets: []}), Sites: () => delay([]),
|
||||
VisitorHistory: () => delay([]), VisitorPhoto: () => delay({available: false, reason: 'This system is not storing images.'}),
|
||||
ForgetCustomer: () => delay(null), Sales: () => delay([]),
|
||||
Customers: () => delay([{id: 'v1', label: 'Priya', number: 2, first_seen_at: now(), last_seen_at: now(), visits: 6}]),
|
||||
SaveProfile: () => delay(null), RecordPurchase: () => delay(null),
|
||||
Ask: (history) => {
|
||||
const q = history[history.length - 1]?.text ?? ''
|
||||
if (!st.claimed) return Promise.reject(new Error('The assistant is not switched on for this server.'))
|
||||
const text = /camera/i.test(q)
|
||||
? 'Go to Cameras and press Add camera. The address is on a sticker on the camera itself; pick the make and I fill in the stream path. Test it, save it, then walk past it once so I can tell you whether the placement works.'
|
||||
: /code|install/i.test(q)
|
||||
? 'Whoever runs head office makes one: open the shop there, press Set up a shop PC, and read the code out. It works once.'
|
||||
: /who|morning|came/i.test(q)
|
||||
? 'Three people so far: Priya at 13:12 (her sixth visit), a new face at 13:18 I have called Visitor 7, and Visitor 3 just now.'
|
||||
: 'Chennai is online and the door camera is connected, but nobody has proved it yet. Walk past it once with Check placement running and I will tell you if it can actually see faces - until then a quiet screen might just be a badly aimed camera.'
|
||||
return delay({text, used: /camera|code/i.test(q) ? [] : ['site_status', 'cameras']}, 1400)
|
||||
},
|
||||
}
|
||||
window.go = {main: {App}}
|
||||
}
|
||||
@@ -1,252 +1,665 @@
|
||||
/* Behavision desktop — an instrument panel, not a website.
|
||||
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
|
||||
but not cramped, and state readable at a glance from across a counter. */
|
||||
/* Behavision desktop — a shop-floor instrument, not a website.
|
||||
===========================================================================
|
||||
Designed for one situation: a PC behind a counter, on a cheap monitor, in a
|
||||
room with daylight, glanced at by somebody who is mid-conversation with a
|
||||
customer. Everything below follows from that.
|
||||
|
||||
- Dark, because the screen sits in peripheral vision all day and a white
|
||||
field at 1000 lux is a lamp pointed at the operator.
|
||||
- State is carried by shape AND colour: a pill, a dot and an edge stripe,
|
||||
never colour alone. This gets read from two metres away, and some
|
||||
operators do not see red and green apart.
|
||||
- One spacing scale and one type scale. The previous version set margins
|
||||
inline, per screen, which is how a UI ends up looking assembled rather
|
||||
than designed.
|
||||
- Motion only where it carries meaning: a live camera, a fresh arrival.
|
||||
Nothing loops for decoration — this process shares a CPU with recognition.
|
||||
=========================================================================== */
|
||||
|
||||
:root {
|
||||
--ground: #0E1317;
|
||||
--surface: #161D23;
|
||||
--surface-2: #1D262D;
|
||||
--line: #27333B;
|
||||
--line-soft: #1F2A31;
|
||||
--ink: #E7EEF3;
|
||||
--ink-2: #B4C2CC;
|
||||
--muted: #7C8B97;
|
||||
--accent: #45B0C7;
|
||||
--accent-dim:#123039;
|
||||
--ok: #4FB37B;
|
||||
--warn: #E0A33A;
|
||||
--bad: #E0655A;
|
||||
--radius: 8px;
|
||||
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
/* ground → raised, four steps, blue-green biased: the product lives in the
|
||||
world of lenses and CCTV, and a neutral grey reads as unfinished. */
|
||||
--bg: #0A0F13;
|
||||
--s1: #111A20;
|
||||
--s2: #17232B;
|
||||
--s3: #1E2D37;
|
||||
--line: #223038;
|
||||
--line-2: #1A252C;
|
||||
|
||||
--ink: #ECF3F7;
|
||||
--ink-2: #A3B6C2;
|
||||
--ink-3: #6C808D;
|
||||
|
||||
/* Accent is for state and focus only, never decoration, so that when it does
|
||||
appear the eye goes to it. */
|
||||
--accent: #40C4DC;
|
||||
--accent-2: #0F3B47;
|
||||
--accent-3: #0B2A33;
|
||||
|
||||
--ok: #48C78E; --ok-2: #102E22;
|
||||
--warn: #EAAA3D; --warn-2: #31260F;
|
||||
--bad: #EC6A5C; --bad-2: #331815;
|
||||
|
||||
--r-sm: 6px; --r: 10px; --r-lg: 14px;
|
||||
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
|
||||
--sp-5: 20px; --sp-6: 24px; --sp-7: 32px; --sp-8: 40px;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / .4), 0 8px 24px -12px rgb(0 0 0 / .6);
|
||||
--shadow-lg: 0 2px 4px rgb(0 0 0 / .4), 0 24px 48px -16px rgb(0 0 0 / .7);
|
||||
|
||||
/* Segoe UI Variable first: it is on every Windows 11 shop PC, it has real
|
||||
optical sizes, and it is what makes this look like an application rather
|
||||
than a web page in a frame. No webfont — a shop PC has no internet at
|
||||
install time, and a font that fails to arrive is a layout that shifts
|
||||
under the operator. */
|
||||
--font: "Segoe UI Variable Text", "Segoe UI", Inter, -apple-system,
|
||||
BlinkMacSystemFont, system-ui, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: "Segoe UI Variable Display", var(--font);
|
||||
--mono: "Cascadia Mono", "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
|
||||
/* Kept as aliases so any screen not yet rewritten keeps its colours. */
|
||||
--ground: var(--bg); --surface: var(--s1); --surface-2: var(--s2);
|
||||
--line-soft: var(--line-2); --muted: var(--ink-3); --radius: var(--r);
|
||||
--accent-dim: var(--accent-3);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
background: var(--ground);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ---------------------------------------------------------------- shell -- */
|
||||
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
input, textarea { user-select: text; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
||||
::selection { background: var(--accent-2); color: var(--ink); }
|
||||
|
||||
/* Digits that line up wherever they are compared or refreshed in place. */
|
||||
.num, .value, .metric-v, .when, .mono, .code, td { font-variant-numeric: tabular-nums; }
|
||||
.mono, .code { font-family: var(--mono); }
|
||||
|
||||
/* The default light scrollbar on a dark panel is the most obvious "this is a
|
||||
web page" tell there is. */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--s3) transparent; }
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--s3); border-radius: 99px; border: 3px solid var(--bg); }
|
||||
*::-webkit-scrollbar-thumb:hover { background: #2A3D49; }
|
||||
|
||||
/* ================================================================ shell == */
|
||||
|
||||
.shell { display: grid; grid-template-columns: 232px 1fr auto; height: 100%; }
|
||||
|
||||
.side {
|
||||
background: var(--surface); border-right: 1px solid var(--line);
|
||||
background: var(--s1); border-right: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.side .brand {
|
||||
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
|
||||
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.side .brand { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); }
|
||||
.side .brand .mark { width: 30px; height: 30px; flex: none; display: grid; place-items: center; }
|
||||
.side .brand .mark img, .login .mark img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.side .brand .id { min-width: 0; }
|
||||
.side .brand h1 { font-family: var(--font-display); font-size: 15px; font-weight: 600; letter-spacing: -.012em; line-height: 1.2; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--ink-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.nav { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.nav button {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
|
||||
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
|
||||
position: relative; display: flex; align-items: center; gap: var(--sp-3); width: 100%;
|
||||
background: none; border: 0; border-radius: var(--r-sm); padding: 9px var(--sp-3);
|
||||
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px; font-weight: 450;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
}
|
||||
.nav button:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
|
||||
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
|
||||
|
||||
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
|
||||
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
|
||||
display: block; line-height: 1.4; }
|
||||
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
|
||||
.main { min-width: 0; min-height: 0; overflow-y: auto; }
|
||||
.page { padding: 22px 26px 40px; max-width: 1180px; }
|
||||
.page > header { margin-bottom: 18px; }
|
||||
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
/* --------------------------------------------------------------- pieces -- */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 16px;
|
||||
.nav button svg { flex: none; opacity: .9; }
|
||||
.nav button:hover { background: var(--s2); color: var(--ink); }
|
||||
.nav button[aria-current="page"] { background: var(--accent-3); color: var(--accent); font-weight: 550; }
|
||||
/* A rail, not a background wash: it survives being looked at sideways. */
|
||||
.nav button[aria-current="page"]::before {
|
||||
content: ""; position: absolute; left: -12px; top: 7px; bottom: 7px;
|
||||
width: 2.5px; border-radius: 0 2px 2px 0; background: var(--accent);
|
||||
}
|
||||
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
|
||||
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
|
||||
font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
|
||||
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
|
||||
/* The one control that starts and stops the product, so it gets its own block
|
||||
at the foot rather than a row in a list. */
|
||||
.enginebox {
|
||||
margin: var(--sp-3); padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border: 1px solid var(--line); border-radius: var(--r); background: var(--s2);
|
||||
}
|
||||
.enginebox .row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.enginebox .state { font-size: 12.5px; font-weight: 600; letter-spacing: -.005em; }
|
||||
.enginebox .label { display: block; color: var(--ink-3); font-size: 11px; line-height: 1.45; margin-top: 3px; font-variant-numeric: tabular-nums; }
|
||||
.enginebox .actions, .enginebox .controls { display: flex; gap: var(--sp-2); margin-top: var(--sp-3); }
|
||||
.enginebox .actions .btn, .enginebox .controls .btn { flex: 1; justify-content: center; padding: 6px 8px; font-size: 12px; }
|
||||
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.bad { background: var(--bad); }
|
||||
.dot.idle { background: var(--muted); }
|
||||
.side .who {
|
||||
padding: var(--sp-3) var(--sp-5) var(--sp-5); border-top: 1px solid var(--line-2);
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
}
|
||||
.side .who .id { min-width: 0; flex: 1; }
|
||||
.side .who .id b { display: block; font-size: 12.5px; font-weight: 550; }
|
||||
.side .who .id span { display: block; font-size: 11px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
|
||||
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
|
||||
color: var(--muted); white-space: nowrap; }
|
||||
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
|
||||
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
|
||||
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
|
||||
.main { min-width: 0; min-height: 0; overflow: auto; position: relative; }
|
||||
|
||||
/* ================================================================= page == */
|
||||
|
||||
.page { padding: var(--sp-6) var(--sp-7) var(--sp-8); max-width: 1500px; }
|
||||
.page > header { margin-bottom: var(--sp-5); }
|
||||
.page > header h2, .page h2 { font-family: var(--font-display); font-size: 22px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
|
||||
.page > header p, .page header p { color: var(--ink-3); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap; }
|
||||
|
||||
h3 { font-size: 11px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
|
||||
|
||||
/* ================================================================ cards == */
|
||||
|
||||
.card { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-4); }
|
||||
.card > h3 { margin-bottom: var(--sp-3); }
|
||||
.card.flush { padding: 0; overflow: hidden; }
|
||||
|
||||
.panel { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
|
||||
.panel > .panelhead {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--line-2);
|
||||
background: linear-gradient(var(--s2), var(--s1)); flex: none;
|
||||
}
|
||||
.panel > .panelhead h3 { margin: 0; }
|
||||
.panel > .panelbody { padding: var(--sp-4); min-height: 0; overflow: auto; }
|
||||
.panel > .panelbody.flush { padding: 0; }
|
||||
|
||||
.grid { display: grid; gap: var(--sp-4); }
|
||||
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
@media (max-width: 1180px) { .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
|
||||
@media (max-width: 980px) { .cols-2, .cols-3 { grid-template-columns: minmax(0,1fr); } }
|
||||
|
||||
/* Four equal boxes used to dominate this screen. The numbers matter, but they
|
||||
are not what anybody opens the app to see. */
|
||||
.metrics {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
|
||||
gap: 1px; background: var(--line); border: 1px solid var(--line);
|
||||
border-radius: var(--r); overflow: hidden;
|
||||
}
|
||||
.metric { background: var(--s1); padding: var(--sp-3) var(--sp-4) var(--sp-4); }
|
||||
.metric .metric-k { font-size: 10.5px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.metric .metric-v { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; margin-top: 5px; }
|
||||
.metric .metric-s { font-size: 11.5px; color: var(--ink-3); margin-top: 3px; line-height: 1.4; }
|
||||
.metric.ok .metric-v { color: var(--ok); }
|
||||
.metric.warn .metric-v { color: var(--warn); }
|
||||
.metric.bad .metric-v { color: var(--bad); }
|
||||
|
||||
/* legacy .stat, for screens not yet rewritten */
|
||||
.stat h3 { margin-bottom: var(--sp-2); }
|
||||
.stat .value { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; }
|
||||
.stat .unit { font-size: 15px; color: var(--ink-3); margin-left: 3px; }
|
||||
.stat .sub { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; line-height: 1.4; }
|
||||
|
||||
/* =============================================================== status == */
|
||||
|
||||
.dot { width: 7px; height: 7px; border-radius: 99px; flex: none; background: var(--ink-3); }
|
||||
.dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 18%, transparent); }
|
||||
.dot.warn { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 18%, transparent); }
|
||||
.dot.bad { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 18%, transparent); }
|
||||
.dot.idle { background: var(--ink-3); }
|
||||
|
||||
/* A live camera is the one thing that should breathe: it is how an operator
|
||||
knows the picture is not frozen. Everything else holds still. */
|
||||
.dot.live { background: var(--ok); animation: pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); }
|
||||
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ok) 0%, transparent); }
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px 3px 7px;
|
||||
border-radius: 99px; font-size: 11px; font-weight: 600; letter-spacing: .02em;
|
||||
background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); white-space: nowrap;
|
||||
}
|
||||
.pill.ok { background: var(--ok-2); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 28%, transparent); }
|
||||
.pill.warn { background: var(--warn-2); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 28%, transparent); }
|
||||
.pill.bad { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 28%, transparent); }
|
||||
.pill.accent { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--r-sm);
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
background: var(--s3); color: var(--ink-2);
|
||||
}
|
||||
.tag.new { background: var(--accent-3); color: var(--accent); }
|
||||
.tag.seen { background: var(--ok-2); color: var(--ok); }
|
||||
.tag.miss { background: var(--warn-2); color: var(--warn); }
|
||||
|
||||
/* One line that answers "is this shop working" above everything else. */
|
||||
.statusbar {
|
||||
display: flex; align-items: center; gap: var(--sp-5); flex-wrap: wrap;
|
||||
padding: var(--sp-3) var(--sp-4); background: var(--s1);
|
||||
border: 1px solid var(--line); border-radius: var(--r); margin-bottom: var(--sp-4);
|
||||
}
|
||||
.statusbar .item { display: flex; align-items: center; gap: var(--sp-2); font-size: 12.5px; }
|
||||
.statusbar .item b { font-weight: 600; letter-spacing: -.005em; }
|
||||
.statusbar .item svg { color: var(--ink-3); }
|
||||
.statusbar .sep { width: 1px; align-self: stretch; background: var(--line); }
|
||||
.statusbar .grow { flex: 1; }
|
||||
|
||||
/* ============================================================= arrivals == */
|
||||
|
||||
/* The reason the product exists, so it gets the width and the weight. */
|
||||
.arrivals { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); }
|
||||
|
||||
.arrival {
|
||||
display: grid; grid-template-columns: 46px 1fr auto; gap: var(--sp-3); align-items: center;
|
||||
padding: var(--sp-3); border-radius: var(--r);
|
||||
background: var(--s2); border: 1px solid var(--line-2);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.arrival::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2.5px; background: var(--ink-3); }
|
||||
.arrival.is-new::before { background: var(--accent); }
|
||||
.arrival.is-seen::before { background: var(--ok); }
|
||||
.arrival.is-miss::before { background: var(--warn); }
|
||||
|
||||
/* Only the newest row animates, and only once. */
|
||||
.arrival.fresh { animation: slidein .28s cubic-bezier(.2,.8,.3,1); }
|
||||
@keyframes slidein { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.arrival .avatar {
|
||||
width: 46px; height: 46px; border-radius: var(--r-sm); display: grid; place-items: center;
|
||||
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
|
||||
font-family: var(--font-display); font-size: 15px; font-weight: 600;
|
||||
color: var(--ink-2); letter-spacing: -.01em; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.arrival .avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.arrival.is-new .avatar { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
|
||||
|
||||
.arrival .who { min-width: 0; }
|
||||
.arrival .who .name { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.arrival .who .meta { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.arrival .right { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }
|
||||
.arrival .right .when { font-size: 11.5px; color: var(--ink-3); }
|
||||
|
||||
/* ================================================================ feeds == */
|
||||
|
||||
.feeds { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); padding: var(--sp-3); }
|
||||
|
||||
.feed { position: relative; border-radius: var(--r); overflow: hidden; background: #05090C; border: 1px solid var(--line); aspect-ratio: 16 / 9; }
|
||||
.feed img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.feed .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
|
||||
/* Caption over the picture, not beneath it: the tile stays a picture. */
|
||||
.feed .cap {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
||||
padding: var(--sp-5) var(--sp-3) var(--sp-3);
|
||||
background: linear-gradient(transparent, rgb(0 0 0 / .8));
|
||||
font-size: 12.5px; font-weight: 600; letter-spacing: -.005em;
|
||||
}
|
||||
|
||||
/* ================================================================ lists == */
|
||||
|
||||
.events, .timeline { list-style: none; padding: 0; display: flex; flex-direction: column; }
|
||||
.events li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-4); border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.timeline li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.events li:last-child, .timeline li:last-child { border-bottom: 0; }
|
||||
.events .when, .timeline .when { font-size: 11.5px; color: var(--ink-3); width: 46px; flex: none; }
|
||||
|
||||
.empty {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: var(--sp-3); padding: var(--sp-8) var(--sp-5); color: var(--ink-3); text-align: center; font-size: 12.5px;
|
||||
}
|
||||
.empty svg { opacity: .35; }
|
||||
.empty b { display: block; color: var(--ink-2); font-size: 13.5px; font-weight: 550; }
|
||||
.empty p { max-width: 34ch; line-height: 1.5; }
|
||||
|
||||
.tablewrap { overflow: auto; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th {
|
||||
text-align: left; padding: 9px var(--sp-4); font-size: 10.5px; font-weight: 600;
|
||||
letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3);
|
||||
background: var(--s2); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 1;
|
||||
}
|
||||
td { padding: 10px var(--sp-4); border-bottom: 1px solid var(--line-2); }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr[role="button"], tbody tr.clickable { cursor: pointer; }
|
||||
tbody tr[role="button"]:hover, tbody tr.clickable:hover { background: var(--s2); }
|
||||
|
||||
/* ============================================================= controls == */
|
||||
|
||||
.btn {
|
||||
background: var(--surface-2); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
|
||||
color: var(--ink); white-space: nowrap;
|
||||
display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
|
||||
border-radius: var(--r-sm); background: var(--s3); color: var(--ink);
|
||||
border: 1px solid var(--line); cursor: pointer;
|
||||
font-size: 13px; font-weight: 550; letter-spacing: -.005em; white-space: nowrap;
|
||||
transition: background .12s ease, border-color .12s ease, transform .06s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #26323a; }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
|
||||
font-weight: 600; }
|
||||
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
|
||||
.btn.danger { color: var(--bad); border-color: #4a2823; }
|
||||
.btn.sm { padding: 4px 9px; font-size: 12px; }
|
||||
.btn:hover:not(:disabled) { background: #253643; border-color: #2E414E; }
|
||||
.btn:active:not(:disabled) { transform: translateY(.5px); }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn svg { flex: none; }
|
||||
.btn.primary { background: var(--accent); color: #04171C; border-color: transparent; font-weight: 600; }
|
||||
.btn.primary:hover:not(:disabled) { background: #55D0E6; }
|
||||
.btn.danger { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 32%, transparent); }
|
||||
.btn.danger:hover:not(:disabled) { background: #43201C; }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.ghost:hover:not(:disabled) { background: var(--s2); }
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn.icon { padding: 7px; }
|
||||
|
||||
.field { display: block; margin-bottom: 12px; }
|
||||
.field span { display: block; font-size: 11.5px; color: var(--muted);
|
||||
margin-bottom: 4px; letter-spacing: .01em; }
|
||||
.linkbtn { background: none; border: 0; color: var(--accent); cursor: pointer; font-size: 12.5px; padding: 2px 0; text-align: left; }
|
||||
.linkbtn:hover { text-decoration: underline; }
|
||||
|
||||
.seg { display: inline-flex; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 2px; gap: 2px; }
|
||||
.seg button { background: none; border: 0; border-radius: 4px; padding: 5px 11px; color: var(--ink-3); cursor: pointer; font-size: 12.5px; font-weight: 500; }
|
||||
.seg button[aria-pressed="true"] { background: var(--s3); color: var(--ink); }
|
||||
|
||||
/* ================================================================ forms == */
|
||||
|
||||
.field { display: block; margin-bottom: var(--sp-4); }
|
||||
.field > span { display: block; font-size: 11.5px; font-weight: 550; color: var(--ink-2); margin-bottom: 6px; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%; background: var(--ground); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
|
||||
user-select: text;
|
||||
width: 100%; padding: 9px 11px; background: var(--s2); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: var(--r-sm);
|
||||
transition: border-color .12s ease, background .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.field input::placeholder { color: var(--ink-3); }
|
||||
.field input:hover, .field select:hover, .field textarea:hover { border-color: #2C3D49; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
border-color: var(--accent); outline: none;
|
||||
outline: none; border-color: var(--accent); background: var(--s1); box-shadow: 0 0 0 3px var(--accent-3);
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 66px; }
|
||||
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
|
||||
.field .hint { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 5px; line-height: 1.45; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: var(--muted); font-weight: 600;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr.click { cursor: pointer; }
|
||||
tbody tr.click:hover { background: var(--surface-2); }
|
||||
td.num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.fieldrow { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-3); }
|
||||
.fieldrow.three { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
|
||||
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
|
||||
.err {
|
||||
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
|
||||
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
|
||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
||||
background: var(--bad-2); color: var(--bad);
|
||||
border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent);
|
||||
border-radius: var(--r-sm); padding: 9px 11px; font-size: 12.5px; line-height: 1.45; margin-bottom: var(--sp-4);
|
||||
}
|
||||
.note { color: var(--muted); font-size: 12.5px; }
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
.err svg { flex: none; margin-top: 1px; }
|
||||
|
||||
/* --------------------------------------------------------------- login --- */
|
||||
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
|
||||
.login .box { width: 100%; max-width: 380px; }
|
||||
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
|
||||
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
|
||||
.login form { background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 20px; }
|
||||
.login .btn { width: 100%; margin-top: 6px; }
|
||||
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
|
||||
text-align: center; line-height: 1.5; }
|
||||
/* The second way out of the setup screen: a shop with no head office. Styled
|
||||
quieter than the form above it because linking is still the common case,
|
||||
but present, because for a single-till shop it is the only one that works. */
|
||||
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
|
||||
border-top: 1px solid var(--line-soft); }
|
||||
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
|
||||
.note { color: var(--ink-3); font-size: 12px; line-height: 1.5; }
|
||||
.note.warn { color: var(--warn); }
|
||||
.note.bad { color: var(--bad); }
|
||||
.lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; }
|
||||
.sm { font-size: 12px; }
|
||||
.lbl, .key { color: var(--ink-3); font-size: 11.5px; }
|
||||
.grow { flex: 1; }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
|
||||
/* ================================================================= gate == */
|
||||
|
||||
/* Login and Setup: the first thing anybody sees, and previously a grey box on
|
||||
a grey field. One soft light behind the card gives the window a centre and
|
||||
costs nothing — it is a static gradient, not an animation. */
|
||||
.login {
|
||||
height: 100%; display: grid; place-items: center; padding: var(--sp-6); overflow: auto;
|
||||
background: radial-gradient(900px 480px at 50% -10%, #10303A 0%, transparent 62%), var(--bg);
|
||||
}
|
||||
.login .box {
|
||||
width: 100%; max-width: 396px; background: var(--s1); border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg); padding: var(--sp-7); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.login .mark { width: 44px; height: 44px; margin-bottom: var(--sp-4); display: grid; place-items: center; }
|
||||
.login h1 { font-family: var(--font-display); font-size: 21px; font-weight: 600; letter-spacing: -.022em; }
|
||||
.login .lead { margin: 6px 0 var(--sp-5); }
|
||||
.login .btn { width: 100%; justify-content: center; margin-top: var(--sp-1); }
|
||||
.login .foot { font-size: 11.5px; color: var(--ink-3); line-height: 1.55; margin-top: var(--sp-5); padding-top: var(--sp-4); border-top: 1px solid var(--line-2); }
|
||||
.login .alt { margin-top: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.login .alt .btn { margin-top: 0; }
|
||||
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
|
||||
font: inherit; font-size: 12.5px; color: var(--accent);
|
||||
text-decoration: underline; text-underline-offset: 3px; }
|
||||
.linkbtn:hover { color: var(--ink); }
|
||||
.login .note { margin: 0; }
|
||||
|
||||
/* ---------------------------------------------------------------- live --- */
|
||||
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
||||
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
overflow: hidden; }
|
||||
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
|
||||
.feed .cap { display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
|
||||
/* =============================================================== drawer == */
|
||||
|
||||
.events { list-style: none; max-height: 420px; overflow-y: auto; }
|
||||
.events li { display: flex; gap: 9px; align-items: baseline;
|
||||
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.events li:last-child { border-bottom: 0; }
|
||||
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
|
||||
flex: none; }
|
||||
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
|
||||
background: var(--surface-2); color: var(--muted); }
|
||||
.tag.new { background: #17364f; color: #86c2ec; }
|
||||
.tag.seen { background: #14301f; color: #7fcb9c; }
|
||||
.tag.miss { background: #3a1c1a; color: #eb9a92; }
|
||||
.drawer { position: fixed; inset: 0; z-index: 40; background: rgb(4 8 11 / .6); display: flex; justify-content: flex-end; animation: fade .16s ease; }
|
||||
@keyframes fade { from { opacity: 0 } to { opacity: 1 } }
|
||||
.drawer .sheet {
|
||||
width: min(540px, 100%); height: 100%; overflow: auto;
|
||||
background: var(--s1); border-left: 1px solid var(--line); box-shadow: var(--shadow-lg);
|
||||
animation: slidein-r .2s cubic-bezier(.2,.8,.3,1);
|
||||
}
|
||||
@keyframes slidein-r { from { transform: translateX(16px); opacity: .6 } to { transform: none; opacity: 1 } }
|
||||
.drawer .sheethead {
|
||||
position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); background: var(--s1); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.drawer .sheethead h2 { font-family: var(--font-display); font-size: 17px; font-weight: 600; letter-spacing: -.015em; }
|
||||
.drawer .sheetbody { padding: var(--sp-5); }
|
||||
.drawer .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
|
||||
.drawer .close:hover { background: var(--s2); color: var(--ink); }
|
||||
|
||||
/* -------------------------------------------------------------- charts --- */
|
||||
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
|
||||
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
|
||||
gap: 2px; min-width: 0; }
|
||||
.bars .seg { border-radius: 2px 2px 0 0; }
|
||||
.bars .seg.ret { background: var(--accent); }
|
||||
.bars .seg.new { background: #2f6f81; }
|
||||
.axis { display: flex; justify-content: space-between; color: var(--muted);
|
||||
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
|
||||
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
|
||||
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
|
||||
margin-right: 5px; vertical-align: -1px; }
|
||||
.avatar {
|
||||
width: 44px; height: 44px; border-radius: var(--r-sm); flex: none; display: grid; place-items: center;
|
||||
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
|
||||
font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* --------------------------------------------------------------- drawer -- */
|
||||
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
|
||||
display: flex; justify-content: flex-end; z-index: 30; }
|
||||
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
|
||||
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
|
||||
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
|
||||
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
|
||||
/* Close lives in the sticky header (.who) now. Positioned against the fixed
|
||||
overlay it stayed put while the sheet scrolled underneath it, printing the
|
||||
button on top of whatever happened to be at the top of the viewport. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* -- customer record ---------------------------------------------------- */
|
||||
/* Full-bleed sticky header: a customer record is long enough to scroll, and
|
||||
both the name and the way out have to stay reachable. The negative margins
|
||||
cancel the panel's padding so the background covers the full width. */
|
||||
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
|
||||
align-items: flex-start; background: var(--surface);
|
||||
margin: -20px -22px 18px; padding: 20px 22px 14px;
|
||||
border-bottom: 1px solid var(--line-soft); }
|
||||
.who .grow { flex: 1; min-width: 0; }
|
||||
.who h3 { margin-bottom: 2px; }
|
||||
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
|
||||
object-fit: cover; background: var(--ground);
|
||||
border: 1px solid var(--line); }
|
||||
.avatar.none { display: grid; place-items: center; color: var(--muted);
|
||||
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
|
||||
/* Cameras and arrivals side by side, the same height, each scrolling its own
|
||||
content. Left to itself the arrivals panel shrank to fit two cards and left
|
||||
a hole beside a tall camera tile - the layout looked broken precisely when
|
||||
the shop was quiet, which is most of the time. */
|
||||
.live-split {
|
||||
display: grid; gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
min-height: 420px;
|
||||
}
|
||||
.live-split > .panel { max-height: 62vh; }
|
||||
@media (max-width: 1100px) {
|
||||
.live-split { grid-template-columns: minmax(0, 1fr); }
|
||||
.live-split > .panel { max-height: none; }
|
||||
}
|
||||
|
||||
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
|
||||
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
|
||||
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.timeline li:last-child { border-bottom: 0; }
|
||||
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
|
||||
flex: none; min-width: 108px; }
|
||||
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Arrivals alone on the Live screen: one column, capped so a long day scrolls
|
||||
inside the panel rather than pushing the metrics off the bottom. */
|
||||
.arrivals-panel { max-height: 64vh; margin-bottom: var(--sp-4); }
|
||||
.arrivals-panel .arrivals { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--sp-2); }
|
||||
|
||||
/* Visually separated from Save: this is the one control in the sheet that
|
||||
cannot be undone, and it must not read as just another button in a row. */
|
||||
.danger-zone { margin-top: 22px; border-color: #4a2823; }
|
||||
.danger-zone > h3 { color: var(--bad); }
|
||||
.danger-zone .note { margin-bottom: 10px; }
|
||||
/* =============================================================== cameras == */
|
||||
|
||||
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
|
||||
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
|
||||
margin-bottom: 12px; }
|
||||
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
|
||||
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); margin-bottom: 5px; }
|
||||
.confirm .lbl.bad { color: var(--bad); }
|
||||
.confirm ul { list-style: none; font-size: 12.5px; }
|
||||
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
|
||||
.confirm li::before { content: '·'; position: absolute; left: 2px;
|
||||
color: var(--muted); }
|
||||
.confirm .row { display: flex; gap: 8px; }
|
||||
.pagehead { display: flex; justify-content: space-between; align-items: flex-end; gap: var(--sp-4); }
|
||||
.page > header.pagehead { margin-bottom: var(--sp-5); }
|
||||
|
||||
.camgrid { display: grid; gap: var(--sp-4); grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); }
|
||||
.camcard { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; }
|
||||
.camview { position: relative; aspect-ratio: 16 / 9; background: #05090C; }
|
||||
.camview img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.camview .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
|
||||
.camview .pill.over { position: absolute; top: 10px; right: 10px; backdrop-filter: blur(6px); }
|
||||
.cambody { padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.camtitle { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--sp-3); }
|
||||
.camtitle h3 { font-family: var(--font-display); font-size: 16px; font-weight: 600; letter-spacing: -.012em; text-transform: none; color: var(--ink); margin: 0 0 2px; }
|
||||
.camtitle .note { font-size: 12px; }
|
||||
.camactions { display: flex; gap: 6px; flex: none; }
|
||||
.camproof { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-3); border-radius: var(--r); background: var(--s2); border: 1px solid var(--line-2); }
|
||||
.camproof .note { font-size: 12px; line-height: 1.45; }
|
||||
@media (max-width: 640px) { .camproof { grid-template-columns: 1fr; } }
|
||||
|
||||
.empty.tall { padding: var(--sp-8) var(--sp-6); }
|
||||
.empty.tall .btn { margin-top: var(--sp-3); }
|
||||
|
||||
/* The sheet is a form that reads top to bottom: a sentence saying what is
|
||||
needed, three short sections, the result of the test, the actions. */
|
||||
.drawer .sheet { width: min(600px, 100%); }
|
||||
.sheetbody .lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; margin-bottom: var(--sp-5); }
|
||||
.formsection { margin-bottom: var(--sp-5); }
|
||||
.formsection h4 { font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 var(--sp-3); padding-bottom: 6px; border-bottom: 1px solid var(--line-2); }
|
||||
.field .hint { font-style: normal; }
|
||||
.fieldrow { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.fieldrow .field.narrow { max-width: 140px; }
|
||||
.fieldrow:has(.field.narrow) { grid-template-columns: minmax(0, 1fr) 140px; }
|
||||
.field input.mono { font-family: var(--font-mono); font-size: 12.5px; }
|
||||
.sheetactions { display: flex; gap: var(--sp-2); justify-content: flex-end; padding-top: var(--sp-4); border-top: 1px solid var(--line-2); margin-top: var(--sp-2); }
|
||||
.testresult { display: flex; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border-radius: var(--r); margin-bottom: var(--sp-4); border: 1px solid; font-size: 13px; }
|
||||
.testresult.ok { background: var(--ok-2); border-color: color-mix(in srgb, var(--ok) 30%, transparent); color: var(--ok); }
|
||||
.testresult.bad { background: var(--bad-2); border-color: color-mix(in srgb, var(--bad) 30%, transparent); color: var(--bad); }
|
||||
.testresult b { display: block; }
|
||||
.testresult span { display: block; color: var(--ink-2); margin-top: 2px; }
|
||||
.testresult img { width: 100%; margin-top: var(--sp-3); border-radius: var(--r-sm); border: 1px solid var(--line); display: block; }
|
||||
|
||||
/* Placement check: two numbered steps, then a verdict box in the tone of the answer. */
|
||||
.steps { list-style: none; counter-reset: step; margin: 0 0 var(--sp-5); padding: 0; display: grid; gap: var(--sp-3); }
|
||||
.steps li { counter-increment: step; position: relative; padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; border-radius: var(--r); border: 1px solid var(--line-2); background: var(--s2); opacity: .55; }
|
||||
.steps li.now, .steps li.done { opacity: 1; }
|
||||
.steps li::before { content: counter(step); position: absolute; left: 16px; top: 14px; width: 24px; height: 24px; border-radius: 50%;
|
||||
display: grid; place-items: center; font-size: 12px; font-weight: 600; background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.steps li.now::before { background: var(--accent); color: #041014; border-color: transparent; }
|
||||
.steps li.done::before { content: '✓'; background: var(--ok-2); color: var(--ok); }
|
||||
.steps b { display: block; font-size: 14px; }
|
||||
.steps span { display: block; font-size: 12.5px; color: var(--ink-2); line-height: 1.5; margin-top: 2px; }
|
||||
.progress { height: 4px; background: var(--s3); border-radius: 2px; overflow: hidden; margin-top: var(--sp-3); }
|
||||
.progress > div { height: 100%; background: var(--accent); transition: width .4s linear; }
|
||||
.verdict { padding: var(--sp-4); border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); }
|
||||
.verdict.ok { border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-2); }
|
||||
.verdict.warn { border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: var(--warn-2); }
|
||||
.verdict.bad { border-color: color-mix(in srgb, var(--bad) 35%, transparent); background: var(--bad-2); }
|
||||
.verdict-head { display: flex; align-items: center; gap: var(--sp-2); font-size: 15px; }
|
||||
.verdict.ok .verdict-head { color: var(--ok); } .verdict.warn .verdict-head { color: var(--warn); } .verdict.bad .verdict-head { color: var(--bad); }
|
||||
.verdict ul { margin: var(--sp-3) 0 0 18px; font-size: 13px; color: var(--ink); line-height: 1.5; }
|
||||
.verdict ul li { margin-bottom: 5px; }
|
||||
.verdict .note { margin-top: var(--sp-3); }
|
||||
.spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite; display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg) } }
|
||||
|
||||
/* ============================================================== assistant == */
|
||||
|
||||
.helpbtn { display: flex; align-items: center; gap: 9px; margin: 0 var(--sp-3) var(--sp-3); padding: 9px 10px; border-radius: var(--r);
|
||||
border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; font-size: 12.5px; font-weight: 550; text-align: left; }
|
||||
.helpbtn img { width: 18px; height: 18px; object-fit: contain; }
|
||||
.helpbtn span { flex: 1; }
|
||||
.helpbtn:hover, .helpbtn[aria-pressed="true"] { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: var(--accent-3); }
|
||||
|
||||
.helper { width: 380px; height: 100%; display: flex; flex-direction: column; min-height: 0;
|
||||
background: var(--s1); border-left: 1px solid var(--line); animation: slidein-r .2s cubic-bezier(.2,.8,.3,1); }
|
||||
.helperhead { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4); border-bottom: 1px solid var(--line); }
|
||||
.helperhead img { width: 26px; height: 26px; object-fit: contain; }
|
||||
.helperhead div { flex: 1; min-width: 0; }
|
||||
.helperhead b { display: block; font-family: var(--font-display); font-size: 14.5px; }
|
||||
.helperhead span { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 1px; }
|
||||
.helperhead .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
|
||||
.helperhead .close:hover { background: var(--s2); color: var(--ink); }
|
||||
.helperbody { flex: 1; overflow: auto; padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.helperintro p { color: var(--ink-2); font-size: 13px; line-height: 1.55; margin-bottom: var(--sp-3); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { border: 1px solid var(--line); background: var(--s2); color: var(--ink); border-radius: 99px; padding: 6px 11px; font-size: 12px; cursor: pointer; text-align: left; }
|
||||
.chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.chip:disabled { opacity: .5; cursor: default; }
|
||||
.helpernote { display: flex; gap: var(--sp-3); padding: var(--sp-3); border-radius: var(--r); background: var(--warn-2); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); color: var(--warn); font-size: 12.5px; }
|
||||
.helpernote b { display: block; } .helpernote span { display: block; color: var(--ink-2); margin-top: 2px; line-height: 1.45; }
|
||||
.turn { display: flex; flex-direction: column; gap: 4px; }
|
||||
.turn.user { align-items: flex-end; }
|
||||
.bubble { max-width: 92%; padding: 10px 13px; border-radius: 14px; font-size: 13.5px; line-height: 1.5; white-space: pre-wrap; }
|
||||
.turn.user .bubble { background: var(--accent); color: #041014; border-bottom-right-radius: 4px; }
|
||||
.turn.assistant .bubble { background: var(--s2); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
|
||||
.turn .used { font-size: 11px; color: var(--ink-3); padding: 0 4px; }
|
||||
.bubble.thinking { display: flex; gap: 4px; padding: 12px 14px; }
|
||||
.bubble.thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); animation: blink 1.2s infinite ease-in-out; }
|
||||
.bubble.thinking span:nth-child(2) { animation-delay: .2s } .bubble.thinking span:nth-child(3) { animation-delay: .4s }
|
||||
@keyframes blink { 0%, 80%, 100% { opacity: .25 } 40% { opacity: 1 } }
|
||||
.helperask { display: flex; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--line); }
|
||||
.helperask input { flex: 1; min-width: 0; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r); padding: 10px 12px; font-size: 13.5px; color: var(--ink); }
|
||||
.helperask input:focus { outline: none; border-color: var(--accent); }
|
||||
.helperask .btn { padding: 0 12px; }
|
||||
.quickhelp { margin-top: var(--sp-5); border-top: 1px solid var(--line-2); padding-top: var(--sp-4); }
|
||||
.quickhelp dt { font-size: 12.5px; font-weight: 600; margin-top: var(--sp-3); }
|
||||
.quickhelp dd { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; margin: 3px 0 0; }
|
||||
@media (max-width: 1100px) { .helper { position: fixed; right: 0; top: 0; bottom: 0; z-index: 30; box-shadow: var(--shadow-lg); } }
|
||||
|
||||
.search { display: flex; align-items: center; gap: 8px; background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: 0 12px; height: 36px; width: min(340px, 100%); color: var(--ink-3); }
|
||||
.search input { flex: 1; min-width: 0; background: none; border: 0; outline: none; color: var(--ink); font-size: 13.5px; }
|
||||
.search:focus-within { border-color: var(--accent); }
|
||||
.sheethead.who { align-items: center; gap: var(--sp-3); }
|
||||
.sheethead.who .grow { flex: 1; min-width: 0; }
|
||||
.sheethead.who .note { margin-top: 2px; font-size: 12px; }
|
||||
.sheethead .close { font-size: 14px; line-height: 1; }
|
||||
.formsection .field:last-child { margin-bottom: 0; }
|
||||
.formsection.dangerzone { margin-top: var(--sp-6); padding: var(--sp-4); border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent); border-radius: var(--r); background: var(--bad-2); }
|
||||
.formsection.dangerzone h4 { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 30%, transparent); }
|
||||
tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
|
||||
|
||||
/* Loya's door: fixed to the top-right of the content area, on every screen. */
|
||||
.loya-fab { position: fixed; top: var(--sp-4); right: var(--sp-5); z-index: 20;
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 14px 8px 10px; border-radius: 99px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); background: var(--s1); color: var(--ink);
|
||||
box-shadow: var(--shadow-lg); cursor: pointer; font-size: 13px; font-weight: 600; }
|
||||
.loya-fab img { width: 20px; height: 20px; object-fit: contain; }
|
||||
.loya-fab:hover { background: var(--accent-3); border-color: var(--accent); }
|
||||
|
||||
/* Camera finder: the pick-list that replaces "type an IP address". */
|
||||
.finder { display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border: 1px dashed var(--line); border-radius: var(--r); background: var(--s2); }
|
||||
.finder p { font-size: 13px; color: var(--ink-2); line-height: 1.5; margin: 0; }
|
||||
.finder.busy { flex-direction: row; align-items: center; color: var(--ink-2); font-size: 13px; border-style: solid; }
|
||||
.foundlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.foundlist li button { width: 100%; display: flex; align-items: center; gap: var(--sp-3); padding: 10px 12px; border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; }
|
||||
.foundlist li button:hover { border-color: var(--accent); }
|
||||
.foundlist li.picked button { border-color: var(--accent); background: var(--accent-3); }
|
||||
.foundlist .mono { font-family: var(--font-mono); font-size: 12.5px; min-width: 120px; }
|
||||
.foundlist .what { flex: 1; color: var(--ink-2); }
|
||||
.foundlist li.rescan button { width: auto; border: 0; background: none; padding: 4px 0; color: var(--ink-3); }
|
||||
|
||||
/* Welcome: two paths, each a card. */
|
||||
.login .box.wide { max-width: 640px; }
|
||||
.choices { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); margin-top: var(--sp-2); }
|
||||
@media (max-width: 720px) { .choices { grid-template-columns: 1fr; } }
|
||||
.choice { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; text-align: left; padding: var(--sp-4);
|
||||
border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; }
|
||||
.choice:hover { border-color: var(--accent); background: var(--accent-3); }
|
||||
.choice b { font-size: 14px; }
|
||||
.choice span { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; }
|
||||
.choice em { font-family: var(--font-mono); font-style: normal; font-size: 11.5px; }
|
||||
.choice svg { color: var(--accent); }
|
||||
.login .foot em { font-style: normal; color: var(--ink-2); }
|
||||
.linkbtn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* Getting started */
|
||||
.starter { margin-bottom: var(--sp-4); }
|
||||
.starter .panelhead { padding: var(--sp-3) var(--sp-4); }
|
||||
.starter .steps { margin: 0; padding: var(--sp-3) var(--sp-4) 0; }
|
||||
.starter .steps.compact li { padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; }
|
||||
.starter .steps li .btn { margin-top: var(--sp-2); }
|
||||
.starter .note { padding: var(--sp-3) var(--sp-4); font-size: 12px; }
|
||||
.btn.ghost { background: none; border-color: transparent; color: var(--ink-3); }
|
||||
.btn.ghost:hover { color: var(--ink); }
|
||||
|
||||
/* Viewer mode: this PC has no engine, so the screens show the company's own
|
||||
data from head office. Informational, not an error - it is the ordinary
|
||||
state of a laptop away from a shop, and styling it red would train people
|
||||
to ignore the red that means something. */
|
||||
.viewing {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 12px 14px; margin-bottom: 14px;
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
color: var(--ink-2); font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
.viewing b { color: var(--ink); font-weight: 600; }
|
||||
.viewing svg { flex: none; margin-top: 2px; color: var(--accent); }
|
||||
|
||||
/* Watch live sits over the picture, opposite the connection pill. It is on
|
||||
the tile rather than in the button row because it is about the picture, and
|
||||
because the row it would otherwise join is hidden on a remote camera. */
|
||||
.camview .btn.watch {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
border-color: rgba(255, 255, 255, .25);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.camview .btn.watch:hover { background: rgba(0, 0, 0, .72); }
|
||||
.camview .btn.watch.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
71
desktop/frontend/src/ui/icons.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// One icon set, drawn rather than typed.
|
||||
//
|
||||
// The navigation used to be text characters — ◉ ☺ ▢ — which render in whatever
|
||||
// the system decides, sit on the text baseline instead of optical centre, and
|
||||
// cannot take a stroke weight. On a shop PC that is the difference between
|
||||
// software somebody trusts with their customers and something that looks
|
||||
// improvised.
|
||||
//
|
||||
// All of these are 24-unit grid, 1.6 stroke, currentColor, no fill. That means
|
||||
// one icon works on every surface and in every state without a second copy.
|
||||
|
||||
const base = {
|
||||
width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke: 'currentColor', strokeWidth: 1.6,
|
||||
strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true', focusable: 'false',
|
||||
}
|
||||
|
||||
function Svg({ size, children, ...rest }) {
|
||||
return <svg {...base} {...rest} width={size ?? base.width} height={size ?? base.height}>{children}</svg>
|
||||
}
|
||||
|
||||
export const Live = p => (
|
||||
<Svg {...p}><circle cx="12" cy="12" r="3.2" /><path d="M5.6 5.6a9 9 0 0 0 0 12.8M18.4 18.4a9 9 0 0 0 0-12.8" /></Svg>
|
||||
)
|
||||
export const People = p => (
|
||||
<Svg {...p}><circle cx="9" cy="8.5" r="3.2" /><path d="M2.8 19.5a6.4 6.4 0 0 1 12.4 0" /><path d="M16.5 6.2a3.2 3.2 0 0 1 0 6.1M18 19.5a6 6 0 0 0-1.6-4" /></Svg>
|
||||
)
|
||||
export const Camera = p => (
|
||||
<Svg {...p}><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /></Svg>
|
||||
)
|
||||
export const Search = p => (
|
||||
<Svg {...p}><circle cx="11" cy="11" r="6.4" /><path d="M15.8 15.8 20.5 20.5" /></Svg>
|
||||
)
|
||||
export const Plus = p => (<Svg {...p}><path d="M12 5.5v13M5.5 12h13" /></Svg>)
|
||||
export const Close = p => (<Svg {...p}><path d="M6.5 6.5l11 11M17.5 6.5l-11 11" /></Svg>)
|
||||
export const Check = p => (<Svg {...p}><path d="M5 12.8l4.4 4.2L19 7" /></Svg>)
|
||||
export const Play = p => (<Svg {...p}><path d="M8 5.6v12.8L18.5 12z" /></Svg>)
|
||||
export const Stop = p => (<Svg {...p}><rect x="7" y="7" width="10" height="10" rx="1.6" /></Svg>)
|
||||
export const Warning = p => (
|
||||
<Svg {...p}><path d="M12 4.6 21 19.4H3z" /><path d="M12 10v4.1" /><path d="M12 17.1v.01" /></Svg>
|
||||
)
|
||||
export const Signal = p => (
|
||||
<Svg {...p}><path d="M5 19.4v-4.2M10.3 19.4v-7.6M15.7 19.4v-11M21 19.4V4.6" /></Svg>
|
||||
)
|
||||
export const Cloud = p => (
|
||||
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 .3 9.4z" /></Svg>
|
||||
)
|
||||
export const CloudOff = p => (
|
||||
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35 6.2 6.2 0 0 1 2-3.2M10.6 5.2A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 1.9 7.6" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
|
||||
)
|
||||
export const Shield = p => (
|
||||
<Svg {...p}><path d="M12 3.8 19.4 6.6v5.2c0 4.2-3 7.4-7.4 8.4-4.4-1-7.4-4.2-7.4-8.4V6.6z" /></Svg>
|
||||
)
|
||||
export const Link = p => (
|
||||
<Svg {...p}><path d="M10.2 13.8a3.6 3.6 0 0 0 5.2 0l2.8-2.8a3.7 3.7 0 0 0-5.2-5.2l-1.3 1.3" /><path d="M13.8 10.2a3.6 3.6 0 0 0-5.2 0l-2.8 2.8a3.7 3.7 0 0 0 5.2 5.2l1.3-1.3" /></Svg>
|
||||
)
|
||||
export const Logout = p => (
|
||||
<Svg {...p}><path d="M14.4 7.6V5.4H4.6v13.2h9.8v-2.2" /><path d="M10 12h9.4M16.4 8.8 19.8 12l-3.4 3.2" /></Svg>
|
||||
)
|
||||
export const Back = p => (<Svg {...p}><path d="M14.6 5.6 8 12l6.6 6.4" /></Svg>)
|
||||
export const Chevron = p => (<Svg {...p}><path d="M9.4 5.6 16 12l-6.6 6.4" /></Svg>)
|
||||
export const Dot = p => (<Svg {...p}><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none" /></Svg>)
|
||||
|
||||
// Drawn for the empty states rather than an apologetic sentence in grey.
|
||||
export const NoCamera = p => (
|
||||
<Svg {...p} strokeWidth="1.2"><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
|
||||
)
|
||||
export const NoFaces = p => (
|
||||
<Svg {...p} strokeWidth="1.2"><circle cx="12" cy="9" r="3.4" /><path d="M5.4 20a6.8 6.8 0 0 1 13.2 0" /></Svg>
|
||||
)
|
||||
123
desktop/frontend/src/views/Assistant.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import logo from '../assets/loyaly-mark.png'
|
||||
|
||||
// The help. A conversation with the head-office assistant, which answers from
|
||||
// the shop's own data and knows how the product is set up - so "why is nobody
|
||||
// being recognised" and "how do I add my camera" are both answered here, by
|
||||
// the same thing, without leaving the app.
|
||||
//
|
||||
// The history lives in this component and is resent whole; nothing is stored
|
||||
// anywhere. A PC running on its own has no head office to ask and is told so.
|
||||
const SUGGESTED = [
|
||||
'Is my shop working right now?',
|
||||
'How do I add my camera?',
|
||||
'Why has nobody been recognised today?',
|
||||
'Who came in this morning?',
|
||||
]
|
||||
|
||||
export default function Assistant({ session, onClose }) {
|
||||
const [turns, setTurns] = useState([])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const scroller = useRef(null)
|
||||
const standalone = session?.standalone
|
||||
|
||||
useEffect(() => { scroller.current?.scrollTo({ top: 1e9, behavior: 'smooth' }) }, [turns, busy])
|
||||
|
||||
async function ask(text) {
|
||||
const q = (text ?? draft).trim()
|
||||
if (!q || busy) return
|
||||
const history = [...turns, { role: 'user', text: q }]
|
||||
setTurns(history); setDraft(''); setBusy(true); setError(null)
|
||||
try {
|
||||
const a = await api.ask(history.map(t => ({ role: t.role, text: t.text })))
|
||||
setTurns([...history, { role: 'assistant', text: a.text, used: a.used ?? [] }])
|
||||
} catch (e) {
|
||||
const m = message(e)
|
||||
// Her only failure that is not hers: the login is gone. Say what to do,
|
||||
// not "session expired" - the shell returns to Login within seconds.
|
||||
setError(/session expired|unauthori[sz]ed/i.test(m)
|
||||
? 'You’re signed out of head office, so I can’t look anything up. Sign in again and ask me once more.'
|
||||
: m)
|
||||
setTurns(turns) // the question stays in the box, not in the transcript
|
||||
setDraft(q)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="helper" role="dialog" aria-label="Loya">
|
||||
<header className="helperhead">
|
||||
<img src={logo} alt="" />
|
||||
<div>
|
||||
<b>Loya</b>
|
||||
<span>Your Behavision buddy — knows your cameras, your customers and your numbers.</span>
|
||||
</div>
|
||||
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
|
||||
</header>
|
||||
|
||||
<div className="helperbody" ref={scroller}>
|
||||
{standalone && (
|
||||
<div className="helpernote">
|
||||
<Icon.CloudOff size={16} />
|
||||
<div>
|
||||
<b>This PC runs on its own</b>
|
||||
<span>Loya lives at head office. Link this PC to a shop to talk to her; the basics are below.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{turns.length === 0 && (
|
||||
<div className="helperintro">
|
||||
<div className="turn assistant"><div className="bubble">
|
||||
{greeting(session)} Ask me anything about {session?.site_name || 'this shop'} — what’s happening now, or how to set something up.
|
||||
</div></div>
|
||||
<div className="chips">
|
||||
{SUGGESTED.map(s => <button key={s} className="chip" onClick={() => ask(s)} disabled={busy || standalone}>{s}</button>)}
|
||||
</div>
|
||||
{standalone && <QuickHelp />}
|
||||
</div>
|
||||
)}
|
||||
{turns.map((t, i) => (
|
||||
<div key={i} className={`turn ${t.role}`}>
|
||||
<div className="bubble">{t.text}</div>
|
||||
{t.used?.length > 0 && <span className="used">Looked at: {t.used.join(', ')}</span>}
|
||||
</div>
|
||||
))}
|
||||
{busy && <div className="turn assistant"><div className="bubble thinking"><span /><span /><span /></div></div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
</div>
|
||||
|
||||
<form className="helperask" onSubmit={e => { e.preventDefault(); ask() }}>
|
||||
<input value={draft} onChange={e => setDraft(e.target.value)} disabled={busy || standalone}
|
||||
placeholder={standalone ? 'Link this PC to head office to talk to Loya' : 'Ask Loya…'} />
|
||||
<button className="btn primary" disabled={busy || standalone || !draft.trim()} aria-label="Ask"><Icon.Chevron size={16} /></button>
|
||||
</form>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function greeting(session) {
|
||||
const h = new Date().getHours()
|
||||
const part = h < 12 ? 'Morning' : h < 17 ? 'Afternoon' : 'Evening'
|
||||
const name = session?.user?.full_name?.split(' ')[0]
|
||||
return name ? `${part}, ${name}.` : `${part}.`
|
||||
}
|
||||
|
||||
// What a PC with no head office can still be told. Static on purpose: there is
|
||||
// nobody to ask, and a chat box that always fails is worse than a short list.
|
||||
function QuickHelp() {
|
||||
return (
|
||||
<dl className="quickhelp">
|
||||
<dt>Adding a camera</dt>
|
||||
<dd>Cameras → Add camera. The address is on a label on the camera; pick the make and the stream path fills itself in. Test, then save.</dd>
|
||||
<dt>Proving it works</dt>
|
||||
<dd>Press Check placement and walk past the camera like a customer for 25 seconds. Only “good” means it can recognise faces — otherwise move it to head height, facing the way people approach.</dd>
|
||||
<dt>Nothing showing on Live</dt>
|
||||
<dd>Check the camera is Connected and Proven. A camera aimed from above or the side streams fine and recognises nobody.</dd>
|
||||
<dt>Linking to head office later</dt>
|
||||
<dd>Use the link button at the bottom of the sidebar and type an installation code from head office. Nothing recorded here is lost.</dd>
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +1,92 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
|
||||
|
||||
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '',
|
||||
max_width: 1280 }
|
||||
// The camera screen is a picture, not a settings table.
|
||||
//
|
||||
// The first version was a table of id / rtsp url / status with three buttons
|
||||
// per row - the view a developer wants. A camera is a thing you look at, so
|
||||
// the live picture is the card, the state sits over it, and the one line that
|
||||
// matters is under it: whether anyone has PROVED this camera can recognise a
|
||||
// face, which is a different claim from "connected" and is the gap a site gets
|
||||
// signed off through.
|
||||
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '', max_width: 1280 }
|
||||
|
||||
export default function Cameras() {
|
||||
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [check, setCheck] = useState(null)
|
||||
const cams = data ?? []
|
||||
// Every camera is remote or none is: this list comes from the engine on
|
||||
// loopback, and when that is unreachable the whole list comes from head
|
||||
// office instead. A remote camera is on a network this computer cannot
|
||||
// reach, so the picture is the shop PC's last snapshot and the buttons that
|
||||
// would talk to the camera are not offered - one that cannot work is worse
|
||||
// than one that is absent.
|
||||
const remote = cams.some(c => c.remote)
|
||||
const streams = useStreamURLs(remote ? [] : cams)
|
||||
// ONE camera at a time, and that is a cost decision rather than a layout
|
||||
// one. A remote view makes the shop computer upload frames for as long as
|
||||
// somebody is watching, so a grid that all went live at once would put an
|
||||
// estate's worth of cameras on the wire because somebody opened a page.
|
||||
const [watching, setWatching] = useState(null)
|
||||
const [watchURL, setWatchURL] = useState('')
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
if (!watching) { setWatchURL(''); return }
|
||||
api.remoteStreamURL(watching).then(u => { if (alive) setWatchURL(u || '') })
|
||||
.catch(() => { if (alive) setWatchURL('') })
|
||||
return () => { alive = false }
|
||||
}, [watching])
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm(`Remove camera "${id}"? Recognition from it stops immediately.`)) return
|
||||
try { await api.deleteCamera(id); reload() } catch (e) { alert(message(e)) }
|
||||
async function remove(cam) {
|
||||
if (!confirm(`Remove ${cam.id}? Recognition from it stops immediately.`)) return
|
||||
try { await api.deleteCamera(cam.id); reload() } catch (e) { alert(message(e)) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<header className="pagehead">
|
||||
<div>
|
||||
<h2>Cameras</h2>
|
||||
<p>Add a camera, check it can see faces properly, then it starts working.</p>
|
||||
<p>{remote
|
||||
? 'The cameras across your shops, as the shop computers last reported them.'
|
||||
: 'Add a camera, then prove it can see faces with a walk-past. Only then is it working.'}</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
Add camera
|
||||
</button>
|
||||
{!remote && <button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
<Icon.Plus size={15} />Add camera
|
||||
</button>}
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
{remote && <div className="viewing">
|
||||
<b>Viewing your shops from here.</b> These cameras are wired to the shop
|
||||
computers, so they are set up and checked there. Each tile shows that
|
||||
camera's most recent frame; <b>Watch live</b> asks the shop computer to
|
||||
send video for as long as you are looking.
|
||||
</div>}
|
||||
|
||||
<div className="card">
|
||||
{cams.length === 0
|
||||
? <div className="empty">No cameras yet.</div>
|
||||
: <div className="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Address</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{cams.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.id}</td>
|
||||
<td className="mono">{c.url}</td>
|
||||
<td>
|
||||
{c.connected === undefined
|
||||
? <span className="pill"><i className="dot idle" />stopped</span>
|
||||
: c.connected
|
||||
? <span className="pill ok"><i className="dot ok" />live</span>
|
||||
: <span className="pill bad"><i className="dot bad" />offline</span>}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn sm" onClick={() => setCheck(c.id)}>
|
||||
Check placement
|
||||
</button>{' '}
|
||||
<button className="btn sm" onClick={() => setEditing(c)}>Edit</button>{' '}
|
||||
<button className="btn sm danger" onClick={() => remove(c.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{cams.length === 0
|
||||
? <div className="panel">
|
||||
<div className="empty tall">
|
||||
<Icon.NoCamera size={40} />
|
||||
<b>No cameras yet</b>
|
||||
<p>Add the camera by its address. Most cameras print it on a label underneath, or show it in their own app.</p>
|
||||
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}><Icon.Plus size={15} />Add your first camera</button>
|
||||
</div>
|
||||
</div>
|
||||
: <div className="camgrid">
|
||||
{cams.map(c => (
|
||||
<CameraCard key={c.id} cam={c}
|
||||
stream={watching === c.id ? watchURL : streams[c.id]}
|
||||
watching={watching === c.id}
|
||||
onWatch={() => setWatching(watching === c.id ? null : c.id)}
|
||||
onEdit={() => setEditing(c)} onCheck={() => setCheck(c.id)} onRemove={() => remove(c)} />
|
||||
))}
|
||||
</div>}
|
||||
|
||||
{editing && <CameraSheet cam={editing} onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); reload() }} />}
|
||||
@@ -72,10 +95,101 @@ export default function Cameras() {
|
||||
)
|
||||
}
|
||||
|
||||
function CameraCard({ cam, stream, watching, onWatch, onEdit, onCheck, onRemove }) {
|
||||
// Three states, not two, and the third is why `connected` is a pointer on
|
||||
// the wire: null means no shop computer has reported on this camera yet,
|
||||
// which reads as waiting rather than as a fault to go and investigate.
|
||||
const conn = cam.remote
|
||||
// Four states, decided once by the server. `stale` is the one that was
|
||||
// missing: the shop computer reports nothing when it cannot reach its own
|
||||
// engine, so its last report used to sit there reading Connected -
|
||||
// measured at 34 minutes on the live estate.
|
||||
? ({ connected: { tone: 'ok', label: 'Connected' },
|
||||
not_connecting: { tone: 'bad', label: 'Not connecting' },
|
||||
stale: { tone: 'warn', label: 'Not reporting' } }[cam.state]
|
||||
|| { tone: 'idle', label: 'Waiting for the shop computer' })
|
||||
: cam.connected === undefined || cam.connected === null
|
||||
? { tone: 'idle', label: 'Engine stopped' }
|
||||
: cam.connected ? { tone: 'ok', label: 'Connected' }
|
||||
: { tone: 'bad', label: 'Not connecting' }
|
||||
// The last placement verdict, so "proven" survives closing the sheet. Only
|
||||
// `good` is a pass: marginal means half the visitors are silently discarded.
|
||||
// Never asked for a remote camera: that answer lives on the shop computer,
|
||||
// and polling loopback for it here only produces an error every 15 seconds.
|
||||
const { data: last } = usePolled(
|
||||
() => cam.remote ? Promise.resolve(null) : api.placementResult(cam.id), 15000, [cam.id])
|
||||
const proof = !last || last.running || !last.verdict || last.verdict === 'starting'
|
||||
? { tone: 'miss', label: 'Not yet proven', text: 'Walk past it once and Behavision will tell you if the placement works.' }
|
||||
: last.verdict === 'good'
|
||||
? { tone: 'seen', label: 'Proven', text: last.headline || 'Faces recognised on a walk-past.' }
|
||||
: { tone: 'miss', label: 'Not proven', text: last.headline || 'Move the camera and check again.' }
|
||||
const shot = cam.snapshot?.available ? cam.snapshot.url : ''
|
||||
return (
|
||||
<article className="camcard">
|
||||
<div className="camview">
|
||||
{stream || shot
|
||||
? <img src={stream || shot} alt={cam.id} />
|
||||
: <div className="placeholder"><Icon.NoCamera size={34} /></div>}
|
||||
<span className={`pill ${conn.tone === 'idle' ? '' : conn.tone} over`}><i className={`dot ${conn.tone}`} />{conn.label}</span>
|
||||
{cam.remote && <button className={`btn sm watch ${watching ? 'on' : ''}`} onClick={onWatch}>
|
||||
<Icon.Play size={13} />{watching ? 'Stop watching' : 'Watch live'}
|
||||
</button>}
|
||||
</div>
|
||||
<div className="cambody">
|
||||
<div className="camtitle">
|
||||
<div>
|
||||
<h3>{cam.label || cam.camera_id || cam.id}</h3>
|
||||
<span className="mono note">{cam.remote
|
||||
? cam.site || cam.site_slug || ''
|
||||
: `${cam.host || cam.url}${cam.path ? ` · ${cam.path}` : ''}`}</span>
|
||||
</div>
|
||||
{!cam.remote && <div className="camactions">
|
||||
<button className="btn sm" onClick={onEdit}>Edit</button>
|
||||
<button className="btn sm danger" onClick={onRemove}>Remove</button>
|
||||
</div>}
|
||||
</div>
|
||||
{cam.remote
|
||||
? <div className="camproof">
|
||||
<span className="note">{cam.state_note
|
||||
? cam.state_note
|
||||
: watching
|
||||
? 'Live from the shop computer. It uploads only while you watch.'
|
||||
: cam.snapshot?.available
|
||||
? 'Last picture from the shop computer. Watch live to see it now.'
|
||||
: cam.snapshot?.reason || 'No picture yet from the shop computer.'}</span>
|
||||
</div>
|
||||
: <div className="camproof">
|
||||
<span className={`tag ${proof.tone}`}>{proof.label}</span>
|
||||
<span className="note">{proof.text}</span>
|
||||
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
|
||||
</div>}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// Stream URLs are fetched once per camera and left alone: reassigning an
|
||||
// MJPEG <img> src restarts the stream, so rebuilding them on every poll makes
|
||||
// every feed flicker forever.
|
||||
function useStreamURLs(cams) {
|
||||
const [urls, setUrls] = useState({})
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const missing = cams.filter(c => !(c.id in urls))
|
||||
if (!missing.length) return
|
||||
;(async () => {
|
||||
const add = {}
|
||||
for (const c of missing) { try { add[c.id] = await api.streamURL(c.id) } catch { add[c.id] = '' } }
|
||||
if (alive) setUrls(u => ({ ...u, ...add }))
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [cams.map(c => c.id).join('|')]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return urls
|
||||
}
|
||||
|
||||
function CameraSheet({ cam, onClose, onSaved }) {
|
||||
const isNew = !cam.id
|
||||
const [f, setF] = useState({ ...BLANK, ...cam, password: '',
|
||||
path: cam.path || (isNew ? MAKES[0].path : '') })
|
||||
const [f, setF] = useState({ ...BLANK, ...cam, password: '', path: cam.path || (isNew ? MAKES[0].path : '') })
|
||||
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
|
||||
const [test, setTest] = useState(null)
|
||||
const [busy, setBusy] = useState(null)
|
||||
@@ -114,93 +228,137 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
catch (e) { setError(message(e)) } finally { setBusy(null) }
|
||||
}
|
||||
|
||||
const chosen = makeById(make)
|
||||
|
||||
// The camera is picked from a scan of the shop's network rather than typed.
|
||||
// Nobody knows their camera's address; the sticker is under the camera and
|
||||
// the menu is different in every make's app. The scan names ONVIF cameras
|
||||
// and lists anything with the RTSP port open; picking one fills the
|
||||
// address and, when the make is recognisable, the stream path too.
|
||||
const [scan, setScan] = useState(null) // null | 'busy' | {cameras, networks} | {error}
|
||||
async function findCameras() {
|
||||
setScan('busy')
|
||||
try { setScan(await api.discoverCameras()) } catch (e) { setScan({ error: message(e) }) }
|
||||
}
|
||||
function pick(c) {
|
||||
const m = c.make ? makeById(c.make) : null
|
||||
setF(prev => ({ ...prev, host: c.host, path: m?.path || prev.path,
|
||||
id: prev.id || (m ? '' : ''), }))
|
||||
if (m) setMake(m.id)
|
||||
setTest(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<button className="btn sm close" onClick={onClose}>Close</button>
|
||||
<h3>{isNew ? 'Add camera' : cam.id}</h3>
|
||||
<p className="note" style={{ marginBottom: 18 }}>
|
||||
Test the connection before saving — a wrong address is the most common mistake.
|
||||
</p>
|
||||
<form onSubmit={save}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input value={f.id} onChange={set('id')} disabled={!isNew}
|
||||
placeholder="entrance" required autoComplete="off" />
|
||||
</label>
|
||||
{/* The highest-value field on this form. The address and the
|
||||
password are on a label or in the installer's notes; the RTSP
|
||||
path is not written anywhere a shop owner would look, and getting
|
||||
it wrong produces "could not open stream", which reads like a
|
||||
password problem and is not. */}
|
||||
<label className="field"><span>Make of camera</span>
|
||||
<select value={make} onChange={chooseMake}>
|
||||
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{makeById(make).note && (
|
||||
<p className="note" style={{ marginTop: -8, marginBottom: 12 }}>
|
||||
{makeById(make).note}
|
||||
</p>
|
||||
)}
|
||||
<div className="fieldrow">
|
||||
<label className="field"><span>Camera address</span>
|
||||
<input value={f.host} onChange={set('host')} placeholder="192.168.0.138"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
<label className="field"><span>Port</span>
|
||||
<input value={f.port} onChange={set('port')} inputMode="numeric"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field"><span>Stream path</span>
|
||||
<input value={f.path} onChange={set('path')} placeholder="/ch0_0.264"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
<div className="fieldrow">
|
||||
{/* A text input next to a password input is a sign-in form as far
|
||||
as the webview is concerned, so without this the browser offers
|
||||
the operator's own Behavision email as the camera's username -
|
||||
which fails with a message about credentials that points at the
|
||||
camera. "off" alone is frequently ignored; a non-login name and
|
||||
new-password on the secret are what actually work. */}
|
||||
<label className="field"><span>Username</span>
|
||||
<input value={f.username} onChange={set('username')}
|
||||
name="camera-account" autoComplete="off" />
|
||||
</label>
|
||||
<label className="field"><span>Password</span>
|
||||
<input type="password" value={f.password} onChange={set('password')}
|
||||
name="camera-secret" autoComplete="new-password"
|
||||
placeholder={cam.has_password ? '(unchanged)' : ''} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="sheet">
|
||||
<div className="sheethead">
|
||||
<h2>{isNew ? 'Add a camera' : `Edit ${cam.id}`}</h2>
|
||||
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
|
||||
</div>
|
||||
<form className="sheetbody" onSubmit={save} autoComplete="off">
|
||||
<p className="lead">Three things from the camera: its address, its make, and its password. Test before you save — a wrong address is the most common mistake.</p>
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<button type="button" className="btn" onClick={runTest} disabled={!!busy}>
|
||||
{busy === 'test' ? 'Connecting…' : 'Test connection'}
|
||||
</button>
|
||||
<button className="btn primary" disabled={!!busy || !f.id}>
|
||||
{busy === 'save' ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{isNew && (
|
||||
<section className="formsection">
|
||||
<h4>Find it</h4>
|
||||
{scan === null && (
|
||||
<div className="finder">
|
||||
<p>Behavision can look for cameras on this shop’s network.</p>
|
||||
<button type="button" className="btn primary" onClick={findCameras}><Icon.Search size={14} />Find cameras on this network</button>
|
||||
</div>
|
||||
)}
|
||||
{scan === 'busy' && <div className="finder busy"><span className="spinner" />Looking on the network… a few seconds.</div>}
|
||||
{scan?.error && <div className="err"><Icon.Warning size={15} />{scan.error}</div>}
|
||||
{scan?.cameras && (
|
||||
scan.cameras.length === 0
|
||||
? <div className="finder">
|
||||
<p>Nothing answered on {scan.networks?.join(', ') || 'this network'}. The camera may be on a different network, switched off, or not yet connected — check its cable and power, then try again. You can still type its address below.</p>
|
||||
<button type="button" className="btn" onClick={findCameras}>Try again</button>
|
||||
</div>
|
||||
: <ul className="foundlist">
|
||||
{scan.cameras.map(c => (
|
||||
<li key={c.host} className={f.host === c.host ? 'picked' : ''}>
|
||||
<button type="button" onClick={() => pick(c)}>
|
||||
<span className="mono">{c.host}</span>
|
||||
<span className="what">{c.name || (c.rtsp ? 'Streams video (RTSP)' : 'Answers ONVIF')}</span>
|
||||
{c.make && <span className="tag seen">{makeById(c.make).label}</span>}
|
||||
{f.host === c.host && <Icon.Check size={16} />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="rescan"><button type="button" className="btn sm" onClick={findCameras}>Scan again</button></li>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="formsection">
|
||||
<h4>The camera</h4>
|
||||
{isNew && (
|
||||
<label className="field"><span>Name</span>
|
||||
<input value={f.id} onChange={set('id')} placeholder="entrance" required autoFocus />
|
||||
<em className="hint">Short, no spaces. It names this camera everywhere and cannot be changed later.</em>
|
||||
</label>
|
||||
)}
|
||||
<div className="fieldrow">
|
||||
<label className="field"><span>Address</span>
|
||||
<input value={f.host} onChange={set('host')} placeholder="192.168.1.20" inputMode="decimal" />
|
||||
<em className="hint">On a label on the camera, or in its own app under “network”.</em>
|
||||
</label>
|
||||
<label className="field narrow"><span>Port</span>
|
||||
<input value={f.port} onChange={set('port')} inputMode="numeric" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="formsection">
|
||||
<h4>The stream</h4>
|
||||
<label className="field"><span>Make of camera</span>
|
||||
<select value={make} onChange={chooseMake}>
|
||||
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
{chosen.note && <em className="hint">{chosen.note}</em>}
|
||||
</label>
|
||||
<label className="field"><span>Stream path</span>
|
||||
<input className="mono" value={f.path} onChange={set('path')} placeholder="/Streaming/Channels/101" />
|
||||
<em className="hint">Filled in from the make. Change it only if the camera’s own app says something else.</em>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="formsection">
|
||||
<h4>Sign-in to the camera</h4>
|
||||
<div className="fieldrow">
|
||||
<label className="field"><span>Username</span>
|
||||
<input name="rtsp-account" autoComplete="off" value={f.username} onChange={set('username')} placeholder="admin" />
|
||||
</label>
|
||||
<label className="field"><span>Password</span>
|
||||
<input type="password" name="rtsp-secret" autoComplete="new-password" value={f.password}
|
||||
onChange={set('password')} placeholder={cam.has_password ? '(unchanged)' : ''} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{test && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{test.ok
|
||||
? <>
|
||||
<p style={{ color: 'var(--ok)', fontSize: 13 }}>
|
||||
Connected — {test.width}×{test.height}
|
||||
</p>
|
||||
{test.snapshot && (
|
||||
<img alt="Camera preview" style={{ width: '100%', marginTop: 8,
|
||||
borderRadius: 6, border: '1px solid var(--line)' }}
|
||||
src={`data:image/jpeg;base64,${test.snapshot}`} />
|
||||
)}
|
||||
</>
|
||||
: <div className="err">{test.error}</div>}
|
||||
</div>
|
||||
test.ok
|
||||
? <div className="testresult ok">
|
||||
<Icon.Check size={16} />
|
||||
<div>
|
||||
<b>Connected — {test.width}×{test.height}{test.codec ? ` · ${String(test.codec).toUpperCase()}` : ''}</b>
|
||||
{test.snapshot && <img alt="Camera preview" src={`data:image/jpeg;base64,${test.snapshot}`} />}
|
||||
</div>
|
||||
</div>
|
||||
: <div className="testresult bad"><Icon.Warning size={16} /><div><b>Could not connect</b><span>{test.error}</span></div></div>
|
||||
)}
|
||||
|
||||
<div className="sheetactions">
|
||||
<button type="button" className="btn" onClick={runTest} disabled={!!busy || !f.host}>
|
||||
{busy === 'test' ? 'Connecting…' : 'Test connection'}
|
||||
</button>
|
||||
<button className="btn primary" disabled={!!busy || !f.id || !f.host}>
|
||||
{busy === 'save' ? 'Saving…' : isNew ? 'Add camera' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,7 +366,7 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
}
|
||||
|
||||
// The commissioning wizard. This is what stops a site being signed off with a
|
||||
// camera that recognises nobody — the failure that otherwise shows up weeks
|
||||
// camera that recognises nobody - the failure that otherwise shows up weeks
|
||||
// later as a footfall report that was always zero.
|
||||
function PlacementSheet({ id, onClose }) {
|
||||
const [state, setState] = useState({ verdict: 'starting', advice: [] })
|
||||
@@ -233,45 +391,54 @@ function PlacementSheet({ id, onClose }) {
|
||||
return () => { alive = false; clearInterval(timer.current) }
|
||||
}, [id])
|
||||
|
||||
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad',
|
||||
no_faces: 'warn', inconclusive: 'warn' }[state.verdict]
|
||||
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad', no_faces: 'warn',
|
||||
inconclusive: 'warn', no_completed_passes: 'warn' }[state.verdict]
|
||||
const pct = state.seconds ? Math.min(100, (state.elapsed / state.seconds) * 100) : 0
|
||||
const running = state.running || state.verdict === 'starting'
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<button className="btn sm close" onClick={onClose}>Close</button>
|
||||
<h3>Placement check — {id}</h3>
|
||||
<p className="note" style={{ marginBottom: 18 }}>
|
||||
Walk past the camera the way a customer would, a few times.
|
||||
</p>
|
||||
<div className="sheet">
|
||||
<div className="sheethead">
|
||||
<h2>Check placement · {id}</h2>
|
||||
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
|
||||
</div>
|
||||
<div className="sheetbody">
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
<ol className="steps">
|
||||
<li className={running ? 'now' : 'done'}>
|
||||
<b>Walk past the camera</b>
|
||||
<span>The way a customer would — in through the door, not looking at the lens. Two or three times, for about 25 seconds.</span>
|
||||
{running && <div className="progress"><div style={{ width: `${pct}%` }} /></div>}
|
||||
</li>
|
||||
<li className={running ? '' : 'now'}>
|
||||
<b>Behavision judges what it saw</b>
|
||||
<span>Not “were the frames sharp”, but “did a person walking past produce a face worth recognising”.</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ fontSize: 15, fontWeight: 600,
|
||||
color: tone ? `var(--${tone})` : 'var(--ink)' }}>
|
||||
{state.headline || 'Starting…'}
|
||||
</div>
|
||||
{state.running && (
|
||||
<div style={{ height: 5, background: 'var(--surface-2)', borderRadius: 3,
|
||||
overflow: 'hidden', margin: '12px 0' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)',
|
||||
transition: 'width .4s linear' }} />
|
||||
<div className={`verdict ${tone ?? ''}`}>
|
||||
<div className="verdict-head">
|
||||
{running ? <span className="spinner" /> : tone === 'ok' ? <Icon.Check size={18} /> : <Icon.Warning size={18} />}
|
||||
<b>{state.headline || 'Watching…'}</b>
|
||||
</div>
|
||||
{state.advice?.length > 0 && (
|
||||
<ul>{state.advice.map((a, i) => <li key={i}>{a}</li>)}</ul>
|
||||
)}
|
||||
{state.quality?.n > 0 && (
|
||||
<p className="note">
|
||||
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen · median quality {state.quality.p50} ·
|
||||
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% too poor to use
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!running && (
|
||||
<div className="sheetactions">
|
||||
<button className="btn" onClick={onClose}>Close</button>
|
||||
<button className="btn primary" onClick={() => { setState({ verdict: 'starting', advice: [] }); api.startPlacement(id, 25).then(setState).catch(e => setError(message(e))) }}>Run again</button>
|
||||
</div>
|
||||
)}
|
||||
{state.advice?.length > 0 && (
|
||||
<ul style={{ margin: '12px 0 0 18px', fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{state.advice.map((a, i) => <li key={i} style={{ marginBottom: 5 }}>{a}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{state.quality?.n > 0 && (
|
||||
<p className="note" style={{ marginTop: 12 }}>
|
||||
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen ·
|
||||
median quality {state.quality.p50} ·
|
||||
gate {state.gate} ·
|
||||
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% below it
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,28 +54,28 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<div className="who">
|
||||
<div className="sheet">
|
||||
<div className="sheethead who">
|
||||
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
|
||||
customerRef={customer.ref}
|
||||
onBroken={() => setPhoto({ available: false,
|
||||
reason: 'The photo could not be loaded.' })} />
|
||||
<div className="grow">
|
||||
<h3>{customer.full_name || customer.label}</h3>
|
||||
<h2>{customer.full_name || customer.label}</h2>
|
||||
<p className="note">
|
||||
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
|
||||
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
|
||||
{shown && !shown.available && shown.reason && ` · ${shown.reason}`}
|
||||
</p>
|
||||
{shown && !shown.available && shown.reason &&
|
||||
<p className="note">{shown.reason}</p>}
|
||||
</div>
|
||||
<button type="button" className="btn sm" onClick={onClose}>Close</button>
|
||||
<button type="button" className="close" onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save}>
|
||||
<form className="sheetbody" onSubmit={save}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Customer details</h3>
|
||||
<section className="formsection">
|
||||
<h4>Customer details</h4>
|
||||
<label className="field">
|
||||
<span>Full name</span>
|
||||
<input value={f.full_name} onChange={set('full_name')} autoFocus />
|
||||
@@ -108,10 +108,10 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
<textarea value={f.notes} onChange={set('notes')}
|
||||
placeholder="Preferences, sizes, anything worth remembering" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Purchase (optional)</h3>
|
||||
<section className="formsection">
|
||||
<h4>Purchase (optional)</h4>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Amount</span>
|
||||
@@ -126,10 +126,10 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
</div>
|
||||
<p className="note">Leave the amount blank if they did not buy anything —
|
||||
a visit without a sale is still worth recording.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<h3>Consent</h3>
|
||||
<section className="formsection">
|
||||
<h4>Consent</h4>
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
|
||||
@@ -141,23 +141,23 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
Recorded with the date and who collected it. They can withdraw it
|
||||
at any time, which erases their face data.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Visits</h3>
|
||||
<section className="formsection">
|
||||
<h4>Visits</h4>
|
||||
<VisitHistory customer={customer} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="sheetactions">
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn primary" disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
{canErase && (
|
||||
<div className="card danger-zone">
|
||||
<h3>At the customer's request</h3>
|
||||
<section className="formsection dangerzone">
|
||||
<h4>At the customer's request</h4>
|
||||
{erasing
|
||||
? <EraseCustomer customer={customer}
|
||||
onCancel={() => setErasing(false)}
|
||||
@@ -173,7 +173,7 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
Erase this customer…
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -30,16 +30,31 @@ export function useCustomerPhoto(id) {
|
||||
|
||||
// No photo is the normal case — images are off by default — so this renders
|
||||
// initials, not an error.
|
||||
export default function CustomerPhoto({ photo, name, onBroken }) {
|
||||
export default function CustomerPhoto({ photo, name, customerRef, onBroken }) {
|
||||
if (photo?.available) {
|
||||
return <img className="avatar" src={photo.url} alt={`Photo of ${name}`}
|
||||
onError={onBroken} />
|
||||
}
|
||||
const initials = String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map(w => w[0].toUpperCase()).join('') || '?'
|
||||
return (
|
||||
<div className="avatar none" role="img" aria-label={`No photo of ${name}`}>
|
||||
<span>{initials}</span>
|
||||
<span>{avatarText(name, customerRef)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Initials of a name a human typed; the NUMBER for a customer the system named
|
||||
// itself. Taking the first letter of each word of "Visitor 13" gives "V1" —
|
||||
// which is also what "Visitor 10" and "Visitor 15" give, so three different
|
||||
// people wear the same badge, and it reads as the V-1 reference for a fourth.
|
||||
// Same fix as the web app's arrivals feed; the two must not disagree.
|
||||
//
|
||||
// customerRef, not `ref`: React reserves that prop name and it would never
|
||||
// reach this component.
|
||||
function avatarText(name, customerRef) {
|
||||
const auto = /^Visitor (\d+)$/.exec(String(name || '').trim())
|
||||
if (auto) return auto[1]
|
||||
const n = /^V-(\d+)$/.exec(String(customerRef || ''))
|
||||
if (n) return n[1]
|
||||
return String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map(w => w[0].toUpperCase()).join('') || '?'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtDate } from '../hooks.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import CustomerForm from './CustomerForm.jsx'
|
||||
|
||||
// The customer database, and the form staff fill in when someone walks in.
|
||||
@@ -15,34 +16,32 @@ export default function Customers({ session }) {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Customers</h2>
|
||||
<p>Everyone this business has recognised. Fill in details once and they
|
||||
are known at every store.</p>
|
||||
<header className="pagehead">
|
||||
<div>
|
||||
<h2>Customers</h2>
|
||||
<p>Everyone this business has recognised. Fill in details once and they are known at every store.</p>
|
||||
</div>
|
||||
<label className="search">
|
||||
<Icon.Search size={15} />
|
||||
<input placeholder="Search by name or phone" value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</label>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="Known people" value={rows.length || '—'} />
|
||||
<Stat label="With details" value={named || '—'}
|
||||
sub={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
|
||||
<Stat label="Returning" value={rows.filter(r => r.visit_count > 1).length || '—'} />
|
||||
<Stat label="With consent" value={rows.filter(r => r.has_consent).length || '—'} />
|
||||
<div className="metrics" style={{ marginBottom: 'var(--sp-4)' }}>
|
||||
<Metric k="Known people" v={rows.length || '—'} />
|
||||
<Metric k="With details" v={named || '—'} s={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
|
||||
<Metric k="Returning" v={rows.filter(r => r.visit_count > 1).length || '—'} />
|
||||
<Metric k="With consent" v={rows.filter(r => r.has_consent).length || '—'} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 12 }}>
|
||||
<input className="field" style={{ flex: 1, margin: 0, background: 'var(--ground)',
|
||||
border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px' }}
|
||||
placeholder="Search by name or phone"
|
||||
value={query} onChange={e => setQuery(e.target.value)} />
|
||||
<button className="btn" onClick={reload}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
{rows.length === 0
|
||||
? <div className="empty">
|
||||
No customers yet. They appear here the first time a camera sees them.
|
||||
? <div className="empty tall">
|
||||
<Icon.NoFaces size={34} />
|
||||
<b>No customers yet</b>
|
||||
<p>They appear here the first time a camera recognises them. Click one to add a name and phone number.</p>
|
||||
</div>
|
||||
: <div className="tablewrap">
|
||||
<table>
|
||||
@@ -56,7 +55,14 @@ export default function Customers({ session }) {
|
||||
<tbody>
|
||||
{rows.map(c => (
|
||||
<tr key={c.id} className="click" onClick={() => setSelected(c)}>
|
||||
<td>{c.full_name || <span className="note">{c.label}</span>}</td>
|
||||
<td>
|
||||
{c.full_name || <span className="note">{c.label}</span>}
|
||||
{/* Only beside a name a human typed: the auto label
|
||||
already IS the number ("Visitor 13"), so showing
|
||||
both reads as two identifiers for one person. */}
|
||||
{c.full_name && c.ref &&
|
||||
<span className="note"> · {c.ref}</span>}
|
||||
</td>
|
||||
<td className="mono">{c.phone || '—'}</td>
|
||||
<td className="num">{c.visit_count}</td>
|
||||
<td>{fmtDate(c.first_seen_at)}</td>
|
||||
@@ -82,11 +88,12 @@ export default function Customers({ session }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub }) {
|
||||
function Metric({ k, v, s }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3><div className="value">{value}</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
<div className="metric">
|
||||
<div className="metric-k">{k}</div>
|
||||
<div className="metric-v">{v}</div>
|
||||
{s && <div className="metric-s">{s}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,192 +1,275 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtTime } from '../hooks.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
|
||||
// What is happening right now. The first screen a shop manager opens, so it
|
||||
// answers "is it working" before it answers anything else.
|
||||
export default function Live() {
|
||||
// The shop floor screen.
|
||||
//
|
||||
// Rebuilt around what somebody standing at the counter is actually here for:
|
||||
// WHO JUST WALKED IN. The previous version led with four large stat boxes and
|
||||
// left arrivals as a thin list of "person.seen" rows in the corner — the least
|
||||
// actionable content taking the most space, and the product's whole reason for
|
||||
// existing rendered as a log.
|
||||
//
|
||||
// Now: a status strip that answers "is this working" in one line, and arrivals
|
||||
// as cards big enough to recognise a customer from while looking up at them.
|
||||
// No camera picture here - the person at the counter is not watching CCTV,
|
||||
// and a live video tile costs CPU the recognition pipeline needs. The picture
|
||||
// lives on the Cameras screen, where it is a setup tool.
|
||||
export default function Live({ onNavigate }) {
|
||||
const { data, error } = usePolled(() => api.live(), 3000)
|
||||
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
|
||||
const cams = useCameraFeeds()
|
||||
|
||||
const cameras = data?.stats?.cameras ?? []
|
||||
const gallery = data?.stats?.gallery ?? {}
|
||||
const events = data?.events ?? []
|
||||
// No engine on THIS PC, so everything below came from head office. It has to
|
||||
// be said rather than implied: a laptop in a hotel showing "2 cameras live"
|
||||
// without this line is claiming to watch a shop it cannot see.
|
||||
const viewing = data?.viewing === true
|
||||
|
||||
// fraction_below_gate is the number that decides a site: what share of the
|
||||
// faces this camera saw were too poor to enrol. Surfaced here rather than
|
||||
// buried, because a high value looks exactly like "a quiet day".
|
||||
const worst = cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
// faces this camera saw were too poor to enrol. Surfaced rather than buried,
|
||||
// because a high value looks exactly like "a quiet day".
|
||||
const worst = viewing
|
||||
? (data?.stats?.fraction_below_gate ?? 0)
|
||||
: cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
// Viewing: the server already summed these across the estate.
|
||||
const up = viewing ? (data?.stats?.cameras_up ?? 0) : cameras.filter(c => c.connected).length
|
||||
|
||||
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
|
||||
const freshest = useFreshest(arrivals[0])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Live</h2>
|
||||
<p>Cameras, recent detections, and whether this site is recognising people.</p>
|
||||
<p>{viewing
|
||||
? 'Your shops, as head office sees them.'
|
||||
: 'Who is in the shop, and whether it is reaching head office.'}</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="People known" value={gallery.identities ?? '—'} />
|
||||
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
|
||||
<Stat label="Cameras live"
|
||||
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
|
||||
<Stat label="Below quality gate"
|
||||
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
||||
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
||||
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
|
||||
: 'Share of faces too poor to enrol'} />
|
||||
{viewing && (
|
||||
<div className="viewing">
|
||||
<Icon.Cloud size={15} />
|
||||
<span><b>Viewing your shops from here.</b> This computer is not watching
|
||||
any cameras itself — everything below is what your shop PCs reported.
|
||||
To recognise people on this machine, it has to be on the same network
|
||||
as a camera.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
|
||||
|
||||
{/* Getting Started walks somebody through setting up a camera on THIS
|
||||
PC — not what a viewer is doing, and not something they could finish
|
||||
from here. */}
|
||||
{!viewing && <GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />}
|
||||
|
||||
<div className="panel arrivals-panel">
|
||||
<div className="panelhead">
|
||||
<h3>Who just walked in</h3>
|
||||
{arrivals.length > 0 && <span className="note">{arrivals.length} today</span>}
|
||||
</div>
|
||||
<div className="panelbody flush">
|
||||
{arrivals.length === 0
|
||||
? <div className="empty">
|
||||
<Icon.NoFaces size={34} />
|
||||
<b>Nobody yet</b>
|
||||
<p>Customers appear here the moment a camera recognises a face.</p>
|
||||
</div>
|
||||
: <div className="arrivals">
|
||||
{arrivals.slice(0, 30).map((e, i) => (
|
||||
<Arrival key={`${e.ts}-${i}`} e={e} fresh={i === 0 && freshest} />
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pipeline pipe={pipe} />
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Cameras</h3>
|
||||
{cameras.length === 0
|
||||
? <div className="empty">No cameras yet. Add one in Cameras.</div>
|
||||
: <div className="feeds">
|
||||
{cameras.map(c => (
|
||||
<div className="feed" key={c.camera_id}>
|
||||
{cams[c.camera_id]
|
||||
? <img src={cams[c.camera_id]} alt={c.camera_id} />
|
||||
: <div style={{ aspectRatio: '16/9' }} />}
|
||||
<div className="cap">
|
||||
<span>{c.camera_id}</span>
|
||||
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
|
||||
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
|
||||
{c.connected ? 'live' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Recent detections</h3>
|
||||
{events.length === 0
|
||||
? <div className="empty">Nothing detected yet.</div>
|
||||
: <ul className="events">
|
||||
{events.map((e, i) => <EventRow key={i} e={e} />)}
|
||||
</ul>}
|
||||
</div>
|
||||
<div className="metrics" style={{ marginTop: 'var(--sp-4)' }}>
|
||||
<Metric k="People known" v={gallery.identities ?? '—'} />
|
||||
<Metric k="Sightings" v={gallery.sightings ?? '—'} />
|
||||
<Metric k="Cameras live" v={cameras.length ? `${up}/${cameras.length}` : '—'}
|
||||
tone={!cameras.length ? null : up === 0 ? 'bad' : up < cameras.length ? 'warn' : 'ok'} />
|
||||
<Metric k="Faces too poor to use"
|
||||
v={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
||||
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
||||
s={worst > 0.5 ? 'Most visitors are being missed — move the camera'
|
||||
: 'Share of faces below the enrolment gate'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Whether anything is actually reaching head office. Without this the app can
|
||||
// look perfectly healthy while every detection piles up on disk unsent — which
|
||||
// is exactly what it did before the bridge existed.
|
||||
function Pipeline({ pipe }) {
|
||||
// The first five minutes, as a checklist that ticks itself.
|
||||
//
|
||||
// Before this the Live screen after install was "Nobody yet" over a row of
|
||||
// dashes, with an amber "No cameras" in the far corner. Nothing said what to
|
||||
// do next. This says the three things, in order, and each step reads its own
|
||||
// state from the engine: recognition ready, a camera added, the camera
|
||||
// proven by a walk-past. It disappears on its own once someone has actually
|
||||
// been recognised, because at that point the product has explained itself.
|
||||
function GettingStarted({ cameras, arrivals, onNavigate }) {
|
||||
const { data: eng } = usePolled(() => api.engineStatus(), 4000)
|
||||
const [hidden, setHidden] = useState(() => { try { return localStorage.getItem('bv.gettingStarted') === 'done' } catch { return false } })
|
||||
const hasCamera = cameras.length > 0
|
||||
const anyUp = cameras.some(c => c.connected)
|
||||
const { data: checks } = usePolled(async () => {
|
||||
const out = {}
|
||||
for (const c of cameras) { try { out[c.camera_id] = await api.placementResult(c.camera_id) } catch { /* not yet */ } }
|
||||
return out
|
||||
}, 10000, [cameras.map(c => c.camera_id).join('|')])
|
||||
const proven = Object.values(checks ?? {}).some(r => r && !r.running && r.verdict === 'good')
|
||||
const recognised = arrivals.length > 0
|
||||
|
||||
if (hidden || recognised) return null
|
||||
const engineReady = Boolean(eng?.reachable && eng?.recognition_model)
|
||||
const progress = eng?.progress
|
||||
|
||||
const steps = [
|
||||
{ done: engineReady, now: !engineReady,
|
||||
title: engineReady ? `Recognition ready (${eng.recognition_model})` : progress ? `Downloading ${progress.what}… ${progress.percent}%` : 'Starting recognition…',
|
||||
text: engineReady ? null : 'First start downloads about 275 MB of recognition models. A few minutes on a normal connection; nothing to do meanwhile.' },
|
||||
{ done: hasCamera && anyUp, now: engineReady && !(hasCamera && anyUp),
|
||||
title: hasCamera ? (anyUp ? 'Camera connected' : 'Camera added — not connecting yet') : 'Add your camera',
|
||||
text: hasCamera ? (anyUp ? null : 'Check its password and stream path under Cameras → Edit.') : 'Behavision can find it on the network; you type only its password.',
|
||||
action: hasCamera ? null : { label: 'Add camera', go: 'cameras' } },
|
||||
{ done: proven, now: hasCamera && anyUp && !proven,
|
||||
title: proven ? 'Camera proven — it can recognise faces' : 'Walk past the camera',
|
||||
text: proven ? null : 'Run Check placement and walk past like a customer for 25 seconds. Only a “good” verdict means it will recognise people.',
|
||||
action: hasCamera && anyUp && !proven ? { label: 'Check placement', go: 'cameras' } : null },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="panel starter">
|
||||
<div className="panelhead">
|
||||
<h3>Getting started</h3>
|
||||
<button className="btn sm ghost" onClick={() => { try { localStorage.setItem('bv.gettingStarted', 'done') } catch {} ; setHidden(true) }}>Hide</button>
|
||||
</div>
|
||||
<ol className="steps compact">
|
||||
{steps.map((st, i) => (
|
||||
<li key={i} className={st.done ? 'done' : st.now ? 'now' : ''}>
|
||||
<b>{st.title}</b>
|
||||
{st.text && <span>{st.text}</span>}
|
||||
{st.action && <button className="btn sm primary" onClick={() => onNavigate?.(st.action.go)}>{st.action.label}</button>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="note">The moment a customer is recognised, this list goes away.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// One customer, big enough to match against the person in front of you.
|
||||
function Arrival({ e, fresh }) {
|
||||
const isNew = e.type === 'person.new'
|
||||
const name = e.data?.label || 'Unrecognised'
|
||||
const bits = [e.data?.gender, e.data?.age ?? e.data?.age_range, e.data?.emotion].filter(Boolean)
|
||||
const sim = typeof e.data?.similarity === 'number' ? e.data.similarity : null
|
||||
|
||||
return (
|
||||
<article className={`arrival ${isNew ? 'is-new' : 'is-seen'} ${fresh ? 'fresh' : ''}`}>
|
||||
<div className="avatar">{avatarText(name)}</div>
|
||||
<div className="who">
|
||||
<div className="name">{name}</div>
|
||||
<div className="meta">
|
||||
{e.camera_id}
|
||||
{bits.length > 0 && <> · {bits.join(', ')}</>}
|
||||
{sim !== null && !isNew && <> · match {sim.toFixed(2)}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="right">
|
||||
<span className={`tag ${isNew ? 'new' : 'seen'}`}>{isNew ? 'new' : 'returning'}</span>
|
||||
<span className="when">{fmtTime(e.ts)}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// "Visitor 13" must show 13, not V1 — initials() would give the same two
|
||||
// characters to Visitor 10, 13 and 15, and read as the reference V-1 for a
|
||||
// fourth person. Found by looking at the screen, not by a test.
|
||||
function avatarText(name) {
|
||||
const auto = /^Visitor (\d+)$/.exec(String(name).trim())
|
||||
if (auto) return auto[1]
|
||||
const words = String(name).trim().split(/\s+/).filter(Boolean)
|
||||
if (!words.length) return '?'
|
||||
return (words[0][0] + (words[1]?.[0] ?? '')).toUpperCase()
|
||||
}
|
||||
|
||||
// One line, above everything, answering the question every other screen is a
|
||||
// detail of: is this shop working, and is anything leaving it.
|
||||
function PipelineStrip({ pipe, cameras, up }) {
|
||||
if (!pipe) return null
|
||||
// A PC set up on its own is not "not linked yet" — nothing is coming, and
|
||||
// saying so with an idle dot beside a count of zero reads as a fault.
|
||||
|
||||
// A PC set up on its own is not "not linked yet" — nothing is coming, and an
|
||||
// idle dot beside a count of zero reads as a fault.
|
||||
if (pipe.standalone) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 10, alignItems: 'center' }}>
|
||||
<i className="dot ok" />
|
||||
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
|
||||
<span className="note">Recognition and customers stay here.</span>
|
||||
<div className="statusbar">
|
||||
<span className="item"><i className="dot ok" /><b>Running on this PC only</b></span>
|
||||
<span className="sep" />
|
||||
<span className="item note">Recognition and customers stay here.</span>
|
||||
<span className="grow" />
|
||||
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stuck = pipe.claimed && !pipe.broker_up
|
||||
const tone = !pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'
|
||||
const text = !pipe.claimed ? 'Not linked to head office'
|
||||
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
|
||||
<strong style={{ fontSize: 13 }}>
|
||||
{!pipe.claimed ? 'Not linked to head office'
|
||||
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
|
||||
</strong>
|
||||
<div className="statusbar">
|
||||
<span className="item">
|
||||
{pipe.broker_up ? <Icon.Cloud size={15} /> : <Icon.CloudOff size={15} />}
|
||||
<i className={`dot ${tone}`} /><b>{text}</b>
|
||||
</span>
|
||||
<span className="note">{pipe.accepted} recorded today</span>
|
||||
<span className="sep" />
|
||||
<span className="item note">{pipe.accepted} recorded today</span>
|
||||
{pipe.queued > 0 && (
|
||||
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
|
||||
{pipe.queued} waiting to send
|
||||
</span>
|
||||
<span className={`item note ${stuck ? 'warn' : ''}`}>{pipe.queued} waiting to send</span>
|
||||
)}
|
||||
{pipe.dropped > 0 && (
|
||||
<span className="note" style={{ color: 'var(--bad)' }}>
|
||||
{pipe.dropped} lost — this PC was offline too long
|
||||
</span>
|
||||
<span className="item note bad"><Icon.Warning size={14} />{pipe.dropped} lost — this PC was offline too long</span>
|
||||
)}
|
||||
<span className="grow" />
|
||||
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ e }) {
|
||||
const cls = e.type === 'person.new' ? 'new'
|
||||
: e.type === 'person.seen' ? 'seen'
|
||||
: e.type === 'person.missed' ? 'miss' : ''
|
||||
const age = e.data?.age ?? e.data?.age_range
|
||||
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
|
||||
function Metric({ k, v, s, tone }) {
|
||||
return (
|
||||
<li>
|
||||
<span className="when">{fmtTime(e.ts)}</span>
|
||||
<span className={`tag ${cls}`}>{label(e.type)}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
{e.data?.label || e.camera_id}
|
||||
{extra && <span className="note"> · {extra}</span>}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// The event names are internal; a shop manager should not have to learn them.
|
||||
function label(type) {
|
||||
return {
|
||||
'person.new': 'new',
|
||||
'person.seen': 'returning',
|
||||
'person.missed': 'missed',
|
||||
'camera.up': 'camera up',
|
||||
'camera.down': 'camera down',
|
||||
'identity.merged': 'merged',
|
||||
}[type] ?? type
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub, tone }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3>
|
||||
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
<div className={`metric ${tone ?? ''}`}>
|
||||
<div className="metric-k">{k}</div>
|
||||
<div className="metric-v">{v}</div>
|
||||
{s && <div className="metric-s">{s}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stream URLs are fetched once per camera and then left alone: reassigning an
|
||||
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
|
||||
// make every feed flicker permanently.
|
||||
function useCameraFeeds() {
|
||||
const [urls, setUrls] = useState({})
|
||||
const { data } = usePolled(() => api.cameras(), 10000)
|
||||
// True for a few seconds after a genuinely new arrival, so the top card can
|
||||
// announce itself once. Keyed on the timestamp rather than the array, which
|
||||
// changes identity on every poll.
|
||||
function useFreshest(top) {
|
||||
const [fresh, setFresh] = useState(false)
|
||||
const seen = useRef(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const next = {}
|
||||
for (const cam of data ?? []) {
|
||||
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
|
||||
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
|
||||
}
|
||||
const changed = Object.keys(next).length !== Object.keys(urls).length ||
|
||||
Object.keys(next).some(k => next[k] !== urls[k])
|
||||
if (!cancelled && changed) setUrls(next)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
return urls
|
||||
if (!top || top.ts === seen.current) return
|
||||
const first = seen.current === null
|
||||
seen.current = top.ts
|
||||
if (first) return // do not flash the whole list on mount
|
||||
setFresh(true)
|
||||
const id = setTimeout(() => setFresh(false), 1200)
|
||||
return () => clearTimeout(id)
|
||||
}, [top?.ts])
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import logo from '../assets/loyaly-mark.png'
|
||||
|
||||
// The gate. Nothing else in the app is reachable until this succeeds, because
|
||||
// the broker credentials and the customer database both live behind it.
|
||||
@@ -24,10 +26,11 @@ export default function Login({ onDone }) {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">Sign in to connect this PC to your store.</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input type="email" value={email} autoComplete="username" required
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import logo from '../assets/loyaly-mark.png'
|
||||
|
||||
// Linking this PC to a shop — the first thing that happens on a new install,
|
||||
// and until now the one thing the app could not do.
|
||||
@@ -13,7 +15,6 @@ export default function Setup({ onDone, onCancel }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [alone, setAlone] = useState(false)
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
@@ -38,15 +39,69 @@ export default function Setup({ onDone, onCancel }) {
|
||||
}
|
||||
}
|
||||
|
||||
// First launch: a choice, not a code box. Two thirds of the people who
|
||||
// open this have no idea what an installation code is; the other third has
|
||||
// one in their hand. Both must see their own path in the first second.
|
||||
const [path, setPath] = useState(onCancel ? 'code' : null)
|
||||
|
||||
if (path === null) {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box wide">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>Welcome to Behavision</h1>
|
||||
<p className="lead">
|
||||
This PC will watch your shop’s cameras and recognise returning customers.
|
||||
First, one question: is this shop managed from a head office?
|
||||
</p>
|
||||
<div className="choices">
|
||||
<button type="button" className="choice" onClick={() => setPath('code')}>
|
||||
<Icon.Cloud size={22} />
|
||||
<b>Yes — I have an installation code</b>
|
||||
<span>Head office gave you a code like <em>ABCDEF-123456-…</em>. This PC joins that shop and gets its cameras from there.</span>
|
||||
</button>
|
||||
<button type="button" className="choice" onClick={() => setPath('alone')}>
|
||||
<Icon.Shield size={22} />
|
||||
<b>No — set up on this PC only</b>
|
||||
<span>Cameras, customers and recognition stay on this PC. Nothing is sent anywhere. You can link to a head office later.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (path === 'alone') {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>On this PC only</h1>
|
||||
<p className="lead">
|
||||
Behavision will run entirely here. Next you’ll add your camera — it can find it on the network for you — and walk past it once so it can prove it works.
|
||||
</p>
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<button type="button" className="btn primary" disabled={!!busy} onClick={standalone}>
|
||||
{busy === 'alone' ? 'Setting up…' : 'Continue'}
|
||||
</button>
|
||||
<div className="alt">
|
||||
<button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>{onCancel ? 'Link to head office' : 'Join your shop'}</h1>
|
||||
<p className="lead">
|
||||
Type the installation code for this shop. You only do this once.
|
||||
Type the installation code head office gave you. It works once, and this PC becomes that shop.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<label className="field">
|
||||
<span>Installation code</span>
|
||||
{/* Uppercase and letter-spaced because the code arrives read aloud
|
||||
@@ -64,38 +119,12 @@ export default function Setup({ onDone, onCancel }) {
|
||||
</button>
|
||||
</form>
|
||||
<p className="foot">
|
||||
The code works once. Ask whoever manages your shops for it — they can
|
||||
create one from the Behavision platform, under the shop.
|
||||
Don’t have one? Whoever runs head office creates it under the shop: <em>Shops → the shop → Set up a shop PC</em>.
|
||||
</p>
|
||||
|
||||
{/* The second way out of this screen, and the reason it exists.
|
||||
Recognition, the cameras and this shop's own gallery all run on
|
||||
this PC and need no server, so a shop with one till and no head
|
||||
office was being blocked from adding a camera until somebody
|
||||
issued it a code — the software refusing to do the thing it is
|
||||
for. Linking later is still one click away, and it keeps the
|
||||
visits already recorded here. */}
|
||||
<div className="alt">
|
||||
{onCancel
|
||||
? <button type="button" className="linkbtn" onClick={onCancel}>
|
||||
Not now — go back
|
||||
</button>
|
||||
: !alone
|
||||
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
|
||||
No head office — set this PC up on its own
|
||||
</button>
|
||||
: <>
|
||||
<p className="note">
|
||||
This PC will watch its cameras and recognise returning
|
||||
customers on its own. Nothing is sent anywhere. You can link
|
||||
it to head office later without losing anything recorded
|
||||
here.
|
||||
</p>
|
||||
<button type="button" className="btn" disabled={!!busy}
|
||||
onClick={standalone}>
|
||||
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
|
||||
</button>
|
||||
</>}
|
||||
? <button type="button" className="linkbtn" onClick={onCancel}>Not now — go back</button>
|
||||
: <button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,16 +14,34 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.10.2 // indirect
|
||||
github.com/labstack/gommon v0.4.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
||||
github.com/leaanthony/gosod v1.0.3 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/samber/lo v1.38.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.6 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
||||
@@ -20,6 +21,7 @@ github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN
|
||||
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
@@ -30,6 +32,7 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
||||
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
@@ -42,6 +45,7 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
@@ -50,6 +54,8 @@ github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
||||
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
@@ -92,3 +98,5 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -2,28 +2,32 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// iconFor renders the tray icon at run time rather than embedding four PNGs.
|
||||
// iconFor renders the tray icon at run time: the Loyaly mark with a state
|
||||
// dot in the corner. Green, amber, red or grey is the only thing a taskbar
|
||||
// conveys at this size, and the mark is what makes it OURS among a row of
|
||||
// other icons - a plain coloured circle read as a generic status light.
|
||||
//
|
||||
// A 16x16 filled circle is all the taskbar shows at this size, and generating
|
||||
// it means the four states cannot drift apart visually or have one file go
|
||||
// missing from a build.
|
||||
// The mark is embedded once at 128px and scaled down here, so the four
|
||||
// states cannot drift apart and no file can go missing from a build.
|
||||
//
|
||||
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
|
||||
// to a temp file and, on Windows, hands the path to LoadImageW with
|
||||
// IMAGE_ICON|LR_LOADFROMFILE — which decodes .ico and nothing else. A PNG
|
||||
// IMAGE_ICON|LR_LOADFROMFILE - which decodes .ico and nothing else. A PNG
|
||||
// there returns 0, systray logs "unable to set icon", and the product ships
|
||||
// with no tray icon at all: the one control surface a shop manager has.
|
||||
func iconFor(state string) []byte {
|
||||
img := circle(colorFor(state))
|
||||
img := trayImage(colorFor(state))
|
||||
if runtime.GOOS == "windows" {
|
||||
return encodeICO(img)
|
||||
}
|
||||
@@ -46,30 +50,93 @@ func colorFor(state string) color.RGBA {
|
||||
}
|
||||
}
|
||||
|
||||
const iconSize = 16
|
||||
// 32px rather than 16: Windows shows 16 at 100% scaling and 24 at 150%, and
|
||||
// scaling a 32 down looks right at both, where a 16 scaled up looks like 2005.
|
||||
const iconSize = 32
|
||||
|
||||
func circle(c color.RGBA) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
const r = 6.5
|
||||
cx, cy := float64(iconSize)/2-0.5, float64(iconSize)/2-0.5
|
||||
//go:embed tray-logo.png
|
||||
var trayLogoPNG []byte
|
||||
|
||||
var (
|
||||
trayLogoOnce sync.Once
|
||||
trayLogo *image.RGBA
|
||||
)
|
||||
|
||||
// logo is the embedded mark, decoded once and box-filtered down to iconSize.
|
||||
// A box filter rather than nearest-neighbour: 128->32 is an exact 4x4 average
|
||||
// and nearest would drop three pixels in four, which shreds the thin outline.
|
||||
func logo() *image.RGBA {
|
||||
trayLogoOnce.Do(func() {
|
||||
src, err := png.Decode(bytes.NewReader(trayLogoPNG))
|
||||
if err != nil {
|
||||
trayLogo = image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
return
|
||||
}
|
||||
b := src.Bounds()
|
||||
f := b.Dx() / iconSize
|
||||
out := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
for y := 0; y < iconSize; y++ {
|
||||
for x := 0; x < iconSize; x++ {
|
||||
var r, g, bl, a uint64
|
||||
for dy := 0; dy < f; dy++ {
|
||||
for dx := 0; dx < f; dx++ {
|
||||
// Premultiplied so transparent pixels do not drag the
|
||||
// colour of the edge towards black.
|
||||
pr, pg, pb, pa := src.At(b.Min.X+x*f+dx, b.Min.Y+y*f+dy).RGBA()
|
||||
r += uint64(pr)
|
||||
g += uint64(pg)
|
||||
bl += uint64(pb)
|
||||
a += uint64(pa)
|
||||
}
|
||||
}
|
||||
n := uint64(f * f)
|
||||
out.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(r / n >> 8), G: uint8(g / n >> 8), B: uint8(bl / n >> 8), A: uint8(a / n >> 8),
|
||||
})
|
||||
}
|
||||
}
|
||||
trayLogo = out
|
||||
})
|
||||
return trayLogo
|
||||
}
|
||||
|
||||
// trayImage is the mark with a state dot over its bottom-right corner, ringed
|
||||
// so it reads against both the yellow of the mark and a dark taskbar.
|
||||
func trayImage(c color.RGBA) *image.RGBA {
|
||||
base := logo()
|
||||
img := image.NewRGBA(base.Bounds())
|
||||
copy(img.Pix, base.Pix)
|
||||
const r = 6.0
|
||||
cx, cy := float64(iconSize)-r-0.5, float64(iconSize)-r-0.5
|
||||
ring := color.RGBA{R: 0x11, G: 0x14, B: 0x18, A: 0xFF}
|
||||
for y := 0; y < iconSize; y++ {
|
||||
for x := 0; x < iconSize; x++ {
|
||||
dx, dy := float64(x)-cx, float64(y)-cy
|
||||
d := dx*dx + dy*dy
|
||||
switch {
|
||||
case d <= (r-1)*(r-1):
|
||||
case d <= (r-1.5)*(r-1.5):
|
||||
img.SetRGBA(x, y, c)
|
||||
case d <= r*r:
|
||||
img.SetRGBA(x, y, ring)
|
||||
case d <= (r+1)*(r+1):
|
||||
// One-pixel feathered edge; a hard-aliased circle looks broken
|
||||
// next to every other icon in the tray.
|
||||
a := uint8(float64(c.A) * (r*r - d) / (r*r - (r-1)*(r-1)))
|
||||
img.SetRGBA(x, y, color.RGBA{R: c.R, G: c.G, B: c.B, A: a})
|
||||
a := uint8(255 * ((r+1)*(r+1) - d) / ((r+1)*(r+1) - r*r))
|
||||
bg := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, blend(bg, ring, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func blend(under, over color.RGBA, a uint8) color.RGBA {
|
||||
fa := float64(a) / 255
|
||||
mix := func(u, o uint8) uint8 { return uint8(float64(u)*(1-fa) + float64(o)*fa) }
|
||||
ua := float64(under.A)/255*(1-fa) + fa
|
||||
return color.RGBA{R: mix(under.R, over.R), G: mix(under.G, over.G), B: mix(under.B, over.B), A: uint8(ua * 255)}
|
||||
}
|
||||
|
||||
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
|
||||
//
|
||||
// Vista and later also accept a PNG stored inside the .ico container, which
|
||||
@@ -94,8 +161,8 @@ func encodeICO(img *image.RGBA) []byte {
|
||||
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
|
||||
b.WriteByte(byte(w))
|
||||
b.WriteByte(byte(h))
|
||||
b.WriteByte(0) // palette size: none
|
||||
b.WriteByte(0) // reserved
|
||||
b.WriteByte(0) // palette size: none
|
||||
b.WriteByte(0) // reserved
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
|
||||
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
|
||||
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// nothing — and nothing on a Mac could notice. These tests are the substitute
|
||||
// for the Windows box we do not have.
|
||||
func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||
b := encodeICO(circle(colorFor("ok")))
|
||||
b := encodeICO(trayImage(colorFor("ok")))
|
||||
if len(b) < 22 {
|
||||
t.Fatalf("far too short: %d bytes", len(b))
|
||||
}
|
||||
@@ -48,12 +48,13 @@ func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||
|
||||
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
|
||||
want := colorFor("error") // red: distinguishable from B and G if swapped
|
||||
img := circle(want)
|
||||
img := trayImage(want)
|
||||
b := encodeICO(img)
|
||||
// Centre of the circle, which is solid fill. Bottom-up means image row
|
||||
// iconSize/2 lands at DIB row iconSize/2-1 counting from the start.
|
||||
row := iconSize - 1 - iconSize/2
|
||||
i := 22 + 40 + (row*iconSize+iconSize/2)*4
|
||||
// Centre of the state dot, which is solid fill. Bottom-up means image row
|
||||
// y lands at DIB row iconSize-1-y counting from the start.
|
||||
x, y := iconSize-6-1, iconSize-6-1
|
||||
row := iconSize - 1 - y
|
||||
i := 22 + 40 + (row*iconSize+x)*4
|
||||
got := b[i : i+4]
|
||||
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
|
||||
t.Errorf("centre pixel = % x, want % x (BGRA)",
|
||||
|
||||
@@ -9,10 +9,12 @@ package cloud
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -38,6 +40,19 @@ type Client struct {
|
||||
// single-use refresh token.
|
||||
refreshMu sync.Mutex
|
||||
onRefresh func(Session)
|
||||
|
||||
// Camera snapshots already fetched, keyed by camera id. The Cameras screen
|
||||
// polls every 8 seconds and a snapshot is ~90 KB, so re-fetching one that
|
||||
// has not changed would put megabytes an hour on the wire to redraw the
|
||||
// same picture - the same trap the web app's useAuthedImage avoids by
|
||||
// keying on the url rather than the object around it.
|
||||
shotMu sync.Mutex
|
||||
shots map[string]cachedShot
|
||||
}
|
||||
|
||||
type cachedShot struct {
|
||||
at string // the server's snapshot_at; a new one is a new picture
|
||||
uri string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -107,8 +122,16 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
||||
}
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
// The refresh token is gone too, so this really is a sign-in, not a
|
||||
// transient failure. Report it as such so the UI shows the login sheet
|
||||
// rather than an error dialog.
|
||||
// transient failure. Forget the session - in memory AND on disk, through
|
||||
// the same callback that persists rotations - so the app goes back to
|
||||
// Login instead of showing "session expired" on every screen until
|
||||
// somebody finds Sign out. Seen on a PC that had been claimed against a
|
||||
// demo head office and then re-claimed against the real one: the old
|
||||
// login sat there, dead, for the whole session.
|
||||
c.Clear()
|
||||
if c.onRefresh != nil {
|
||||
c.onRefresh(Session{})
|
||||
}
|
||||
return ErrUnauthorized
|
||||
}
|
||||
return c.send(ctx, method, path, raw, out)
|
||||
@@ -179,9 +202,16 @@ func (c *Client) send(ctx context.Context, method, path string, raw []byte, out
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
|
||||
return errTokenExpired
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
case resp.StatusCode == http.StatusUnauthorized && tok != "":
|
||||
// A 401 on a call we sent a session with: the session is the problem.
|
||||
return ErrUnauthorized
|
||||
case resp.StatusCode >= 400:
|
||||
// Every other 4xx/5xx - including a 401 on a call that carried NO
|
||||
// session, such as redeeming an installation code - is about the
|
||||
// request, and the server wrote its message for exactly this moment.
|
||||
// Mapping those to "session expired" told an installer their session
|
||||
// had lapsed on a screen where they had never signed in, and hid
|
||||
// "That installation code is not valid" behind it.
|
||||
msg := e.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
|
||||
@@ -393,6 +423,11 @@ type Photo struct {
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason"`
|
||||
// Auth is set by the server when the URL is one of its own endpoints and
|
||||
// needs this session's bearer, rather than a presigned object-store link
|
||||
// that carries its own signature. It never reaches the front end - see
|
||||
// VisitorImage, which resolves it here.
|
||||
Auth bool `json:"auth"`
|
||||
}
|
||||
|
||||
// VisitorImage fetches a short-lived signed link to this customer's photo.
|
||||
@@ -413,9 +448,91 @@ func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
|
||||
return Photo{}, err
|
||||
}
|
||||
out.Available = out.URL != ""
|
||||
|
||||
// A deployment with no object storage serves the photo from the API itself,
|
||||
// which means a RELATIVE url that needs this session's bearer. Neither
|
||||
// works in the window: a webview <img> resolves a relative src against
|
||||
// wails://, not against the cloud, and it cannot send an Authorization
|
||||
// header at all - so handing it straight through renders a broken picture
|
||||
// on exactly the deployments that have just started storing photos.
|
||||
//
|
||||
// Fetched here and passed as a data: URI. The alternative is a local proxy
|
||||
// inside this process holding the session, which is a second authenticated
|
||||
// surface on the shop PC to get wrong. One photo per sheet, ~90 KB, and the
|
||||
// server already records the read where the link was handed out.
|
||||
if out.Available && out.Auth {
|
||||
data, err := c.fetchImage(ctx, out.URL)
|
||||
if err != nil {
|
||||
// The record itself is worth far more than the picture, so this is
|
||||
// an absence with a reason rather than a failure that blanks the
|
||||
// customer - the same rule the whole image path follows.
|
||||
return Photo{Reason: "That photo could not be loaded."}, nil
|
||||
}
|
||||
out.URL = data
|
||||
out.Auth = false
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetchImage reads an image this server holds itself and returns a data: URI.
|
||||
//
|
||||
// Deliberately not routed through send(): that decodes JSON into `out`, and
|
||||
// these are bytes. It shares the token and the expiry retry, because a sheet
|
||||
// opened twelve hours after the last one must not show a broken photo.
|
||||
func (c *Client) fetchImage(ctx context.Context, path string) (string, error) {
|
||||
body, err := c.imageBytes(ctx, path)
|
||||
if errors.Is(err, errTokenExpired) {
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
return "", rerr
|
||||
}
|
||||
body, err = c.imageBytes(ctx, path)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body), nil
|
||||
}
|
||||
|
||||
// maxPhotoBytes bounds what will be pulled into memory and then base64'd into
|
||||
// the window. Face crops are ~20 KB and a camera still ~100 KB; anything near
|
||||
// this is a different file or a fault, and a shop PC should not spend its
|
||||
// memory finding that out.
|
||||
const maxPhotoBytes = 4 << 20
|
||||
|
||||
func (c *Client) imageBytes(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
_ = json.Unmarshal(body, &e)
|
||||
if e.Error == "token_expired" {
|
||||
return nil, errTokenExpired
|
||||
}
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("photo: %s", resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, maxPhotoBytes))
|
||||
}
|
||||
|
||||
// ForgetVisitor erases a customer: face template, photo and profile.
|
||||
//
|
||||
// Irreversible by design — a soft-deleted face template is a retained
|
||||
@@ -427,6 +544,27 @@ func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
|
||||
"/api/visitors/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
// AssistantTurn is one message in the help conversation. The browser holds
|
||||
// the history and resends it; nothing is stored server-side.
|
||||
type AssistantTurn struct {
|
||||
Role string `json:"role"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// AssistantAnswer is the reply, and the names of what it looked at - shown to
|
||||
// the user, because an assistant that silently ran a camera check would be
|
||||
// alarming and naming what it consulted makes a wrong answer traceable.
|
||||
type AssistantAnswer struct {
|
||||
Text string `json:"text"`
|
||||
Used []string `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
// Ask puts a question to the head-office assistant as this signed-in user.
|
||||
func (c *Client) Ask(ctx context.Context, history []AssistantTurn) (AssistantAnswer, error) {
|
||||
var out AssistantAnswer
|
||||
return out, c.do(ctx, http.MethodPost, "/api/assistant", map[string]any{"history": history}, &out)
|
||||
}
|
||||
|
||||
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
|
||||
var out []Visit
|
||||
return out, c.do(ctx, http.MethodGet,
|
||||
@@ -457,7 +595,10 @@ func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
// Ref is the customer number - "V-42" - and is what staff say to each
|
||||
// other. It is accepted anywhere this customer's id is.
|
||||
Ref string `json:"ref"`
|
||||
Label string `json:"label"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
@@ -502,3 +643,182 @@ func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
|
||||
"items": items, "source": "manual", "notes": notes,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- viewing --
|
||||
//
|
||||
// A PC with no engine of its own is not broken, it is a VIEWER: somebody
|
||||
// signed in on a laptop away from the shop. Everything below reads head
|
||||
// office so those screens have something true to show instead of "engine not
|
||||
// reachable", which is an accurate sentence and a useless one when the reader
|
||||
// was never expecting an engine on that machine.
|
||||
|
||||
// Arrival is one visit as the estate's feed reports it, across every shop -
|
||||
// not just this PC's. `GET /api/visits`.
|
||||
type Arrival struct {
|
||||
VisitID string `json:"visit_id"`
|
||||
VisitRef string `json:"visit_ref"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Site string `json:"site"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
CameraID string `json:"camera_id"`
|
||||
VisitorID string `json:"visitor_id"`
|
||||
Ref string `json:"ref"`
|
||||
Label string `json:"label"`
|
||||
IsNew bool `json:"is_new_visitor"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
Image Photo `json:"image"`
|
||||
}
|
||||
|
||||
// RemoteCamera is a camera as HEAD OFFICE knows it. Deliberately not the same
|
||||
// type the local engine returns: this one can never be edited from here (the
|
||||
// shop PC on that LAN is the only thing that can reach it) and it carries a
|
||||
// snapshot rather than a stream.
|
||||
type RemoteCamera struct {
|
||||
ID string `json:"id"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Site string `json:"site"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected *bool `json:"connected"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
// State is the server's single answer - connected / not_connecting /
|
||||
// waiting / stale - and the screen renders that rather than deciding
|
||||
// again from Connected. Two places deciding one fact is how a shop came
|
||||
// out labelled Working, in green, above "2 of 3 cameras not connecting".
|
||||
State string `json:"state"`
|
||||
StateNote string `json:"state_note"`
|
||||
Snapshot Photo `json:"snapshot"`
|
||||
SnapshotAt string `json:"snapshot_at"`
|
||||
}
|
||||
|
||||
// Arrivals reads the estate's recent visits, newest last.
|
||||
func (c *Client) Arrivals(ctx context.Context, limit int) ([]Arrival, error) {
|
||||
var out struct {
|
||||
Arrivals []Arrival `json:"arrivals"`
|
||||
}
|
||||
if err := c.send(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/visits?limit=%d", limit), nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Arrivals, nil
|
||||
}
|
||||
|
||||
// RemoteCameras lists every camera head office knows about for this company.
|
||||
func (c *Client) RemoteCameras(ctx context.Context) ([]RemoteCamera, error) {
|
||||
var out []RemoteCamera
|
||||
if err := c.send(ctx, http.MethodGet, "/api/cameras", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Snapshot = c.resolveShot(ctx, out[i].ID, out[i].SnapshotAt, out[i].Snapshot)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveShot turns a camera snapshot into something the window can render.
|
||||
//
|
||||
// Same problem VisitorImage has and the same answer: a deployment with no
|
||||
// object storage serves the picture from the API itself, so the url is
|
||||
// relative and needs this session's bearer. A webview <img> can supply
|
||||
// neither - it resolves a relative src against wails:// and cannot set a
|
||||
// header - so the bytes are fetched here and passed as a data: URI.
|
||||
//
|
||||
// A failure is an absence with a reason, never an error. Whether the camera is
|
||||
// CONNECTED is the answer this screen exists to give; the photograph is
|
||||
// decoration, and blanking the card because a picture would not load would
|
||||
// hide the part that matters.
|
||||
func (c *Client) resolveShot(ctx context.Context, camID, at string, p Photo) Photo {
|
||||
if !p.Available || !p.Auth || p.URL == "" {
|
||||
return p
|
||||
}
|
||||
c.shotMu.Lock()
|
||||
hit, ok := c.shots[camID]
|
||||
c.shotMu.Unlock()
|
||||
if ok && hit.at == at && at != "" {
|
||||
p.URL, p.Auth = hit.uri, false
|
||||
return p
|
||||
}
|
||||
uri, err := c.fetchImage(ctx, p.URL)
|
||||
if err != nil {
|
||||
return Photo{Reason: "That camera's picture could not be loaded."}
|
||||
}
|
||||
c.shotMu.Lock()
|
||||
if c.shots == nil {
|
||||
c.shots = map[string]cachedShot{}
|
||||
}
|
||||
c.shots[camID] = cachedShot{at: at, uri: uri}
|
||||
c.shotMu.Unlock()
|
||||
p.URL, p.Auth = uri, false
|
||||
return p
|
||||
}
|
||||
|
||||
// CameraLive opens head office's live relay for one camera and returns the
|
||||
// live SSE response for the caller to read and close.
|
||||
//
|
||||
// A response rather than frames, because the consumer is the app's own
|
||||
// loopback relay: it re-emits these frames as MJPEG so an <img> can show them,
|
||||
// and buffering the stream through a channel here would only add a place for
|
||||
// frames to queue. A stale frame is worthless - the only one worth having is
|
||||
// the newest - which is the whole reason LiveHub drops rather than queues.
|
||||
//
|
||||
// There is no client timeout on this request. A live view is endless by
|
||||
// design and any deadline would cut the picture off mid-shift; the context is
|
||||
// what ends it, when the viewer navigates away.
|
||||
func (c *Client) CameraLive(ctx context.Context, cameraID string) (*http.Response, error) {
|
||||
resp, err := c.liveOnce(ctx, cameraID)
|
||||
if errors.Is(err, errTokenExpired) {
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
resp, err = c.liveOnce(ctx, cameraID)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (c *Client) liveOnce(ctx context.Context, cameraID string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
c.Base+"/api/cameras/"+url.PathEscape(cameraID)+"/live", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
if tok == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
|
||||
// c.http has a 30 s timeout, which covers the whole response and would
|
||||
// therefore sever a working live view every thirty seconds - the same
|
||||
// trap that made the server set WriteTimeout to zero for its own SSE
|
||||
// endpoint. A dedicated client, with the dial bounded instead.
|
||||
hc := &http.Client{Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
resp.Body.Close()
|
||||
_ = json.Unmarshal(body, &e)
|
||||
if e.Error == "token_expired" {
|
||||
return nil, errTokenExpired
|
||||
}
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("live view: %s", resp.Status)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -137,3 +138,43 @@ func TestVisitorIDIsPathEscaped(t *testing.T) {
|
||||
t.Errorf("path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Redeeming an installation code is the one call a fresh PC makes before it
|
||||
// has any session. When the server refuses it - wrong code, wrong head office -
|
||||
// it answers 401 with a message written for the installer. That message must
|
||||
// reach them: "session expired" on a screen where nobody has signed in sent a
|
||||
// real installer looking for a login problem that did not exist.
|
||||
func TestARefusedInstallationCodeSaysWhyNotSessionExpired(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Errorf("enrol must not carry a session, got %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
fail(w, http.StatusUnauthorized, "bad_token",
|
||||
"That installation code is not valid. Ask for a new one.")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := New(srv.URL) // deliberately no session
|
||||
|
||||
_, err := c.Bootstrap(context.Background(), "KWFH5S-EH46LT-EE4X47-OSOH7D")
|
||||
if err == nil {
|
||||
t.Fatal("a refused code must be an error")
|
||||
}
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a refused code is not a session problem, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "installation code is not valid") {
|
||||
t.Fatalf("the server's own words should reach the installer, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other side of the same rule: a 401 on a call that DID carry a session is
|
||||
// a session problem, and must still read as one.
|
||||
func TestARejectedSessionStillReadsAsSessionExpired(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusUnauthorized, "unauthorized", "Sign in again.")
|
||||
})
|
||||
err := c.do(context.Background(), http.MethodGet, "/api/auth/me", nil, nil)
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a 401 with a session should be ErrUnauthorized, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
agentconfig "github.com/loyaly/behavision-agent/pkg/config"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -20,7 +21,11 @@ type Client struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
http *http.Client
|
||||
// Creds re-reads the engine's generated credential when one is rejected.
|
||||
// On a first run the app starts the engine, and the engine writes that
|
||||
// file seconds later - after the app has already looked for it.
|
||||
Creds *agentconfig.Creds
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(base, user, password string) *Client {
|
||||
@@ -48,8 +53,12 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.User != "" {
|
||||
req.SetBasicAuth(c.User, c.Password)
|
||||
user, pass := c.User, c.Password
|
||||
if c.Creds != nil {
|
||||
user, pass = c.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -105,6 +114,12 @@ func (c *Client) DeleteCamera(ctx context.Context, id string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/api/cameras/"+id, nil, nil)
|
||||
}
|
||||
|
||||
// DiscoverCameras asks the engine to scan the shop's network. A few seconds.
|
||||
func (c *Client) DiscoverCameras(ctx context.Context) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodGet, "/api/cameras/discover", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) TestCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)
|
||||
|
||||
@@ -12,10 +12,12 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
runtime2 "runtime"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
@@ -27,10 +29,38 @@ func main() {
|
||||
app := NewApp()
|
||||
tray := newTray(app)
|
||||
|
||||
// One process per PC, enforced by the OS rather than by hoping.
|
||||
//
|
||||
// The window hides to the tray on close, so the ordinary next thing a shop
|
||||
// assistant does is double-click the desktop shortcut again to get it
|
||||
// back. Without this lock that started a SECOND complete copy: a second
|
||||
// tray icon, a second engine supervisor on the same SQLite WAL and the
|
||||
// same port - the "start twice" failure the agent package exists to
|
||||
// prevent, on the one binary that never had the guard. Seen on a Windows
|
||||
// install as a row of Behavision icons in the tray. A second launch now
|
||||
// only brings the existing window to the front, which is what the person
|
||||
// wanted in the first place.
|
||||
var ctxRef context.Context
|
||||
single := &options.SingleInstanceLock{
|
||||
UniqueId: "ai.loyaly.behavision.desktop",
|
||||
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
|
||||
if ctxRef != nil {
|
||||
// The same four calls the tray uses, and for the same reasons:
|
||||
// this runs on Wails' own listener goroutine rather than the
|
||||
// window's thread, and the launching process holds the
|
||||
// foreground, so without the flip the window comes back behind
|
||||
// it. Double-clicking the desktop icon while it is already
|
||||
// running is the single most common way anyone reaches this.
|
||||
go openWindow(ctxRef)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Behavision",
|
||||
Width: 1280,
|
||||
Height: 820,
|
||||
SingleInstanceLock: single,
|
||||
Title: "Behavision",
|
||||
Width: 1280,
|
||||
Height: 820,
|
||||
// Small enough to still be usable on a cramped shop-counter monitor.
|
||||
MinWidth: 1024,
|
||||
MinHeight: 640,
|
||||
@@ -38,20 +68,48 @@ func main() {
|
||||
// Closing the window hides it rather than quitting: the engine must
|
||||
// keep recognising after a shop assistant clicks the X, and the tray
|
||||
// is where they get the window back.
|
||||
HideWindowOnClose: true,
|
||||
// Windows hides on close because the tray is how the window comes
|
||||
// back and how recognition is stopped. macOS has no tray here (see
|
||||
// tray_run_darwin.go), so hiding would leave a running engine with no
|
||||
// window, no tray and no way to reach either - force-quit or nothing.
|
||||
// Closing the window therefore quits, which also stops the engine
|
||||
// through OnShutdown. Same rule as the tray's Quit: never leave it
|
||||
// watching with no visible control.
|
||||
HideWindowOnClose: runtime2.GOOS == "windows",
|
||||
OnStartup: func(ctx context.Context) {
|
||||
ctxRef = ctx
|
||||
app.startup(ctx)
|
||||
tray.start(ctx)
|
||||
},
|
||||
OnBeforeClose: func(ctx context.Context) bool {
|
||||
runtime.Hide(ctx)
|
||||
return true // prevent the close
|
||||
if runtime2.GOOS != "windows" {
|
||||
return false // let it close, and OnShutdown stops the engine
|
||||
}
|
||||
// WindowHide, not Hide. They are different calls on Windows -
|
||||
// WindowHide locks the OS thread for the Win32 work and Hide does
|
||||
// not - and the tray's reopen uses WindowShow, so hiding through
|
||||
// the other one leaves the pair mismatched. Same call, opposite
|
||||
// direction.
|
||||
runtime.WindowHide(ctx)
|
||||
return true // prevent the close; the tray is how it comes back
|
||||
},
|
||||
OnShutdown: func(ctx context.Context) {
|
||||
tray.stop()
|
||||
app.proxy.stop()
|
||||
app.StopEngine()
|
||||
},
|
||||
Bind: []any{app},
|
||||
// This block has to EXIST, not merely be empty. Wails computes
|
||||
// `zoomable = !Mac.DisableZoom` inside `if frontendOptions.Mac !=
|
||||
// nil`, and the variable defaults to 0 - so leaving Mac unset does not
|
||||
// mean "defaults", it means the green maximise button is created dead.
|
||||
// There was a Windows block and no Mac one, so the window could not be
|
||||
// zoomed on macOS and nothing anywhere said why.
|
||||
Mac: &mac.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
DisableZoom: false,
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
|
||||
BIN
desktop/rsrc_windows_amd64.syso
Normal file
297
desktop/stream_proxy.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package main
|
||||
|
||||
// streamProxy serves the engine's camera feeds to this app's own webview
|
||||
// without putting a credential in the page.
|
||||
//
|
||||
// What this replaces: StreamURL used to build
|
||||
// http://user:pass@127.0.0.1:8010/api/cameras/<id>/stream.mjpeg and hand it
|
||||
// to an <img>, with a comment saying the credentials were inline "so an <img>
|
||||
// tag can load it". It cannot. Chromium strips credentials from subresource
|
||||
// URLs and has since M59, and WebView2 is Chromium - so on the one platform
|
||||
// this product ships to, every camera tile on the shop floor renders as a
|
||||
// broken image. Measured against the same running engine: the app's Go-side
|
||||
// calls returned stats and people while an <img> on the very same URL failed,
|
||||
// and curl proved the URL itself answered 200. The engine was never the
|
||||
// problem; the browser was throwing the password away before it asked.
|
||||
//
|
||||
// So the password stays on this side of the process boundary. The webview
|
||||
// asks this loopback listener, the listener attaches Basic auth and relays
|
||||
// the engine's bytes back unchanged. It is the same reasoning the head-office
|
||||
// web app already follows in Shot.jsx, where an <img> equally cannot carry a
|
||||
// session and the bytes are fetched and handed over as an object URL.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A camera id reaches this from the engine and from a person typing into the
|
||||
// Add Camera form. Validated rather than interpolated: without this a `..`
|
||||
// would climb out of the two paths below and turn a camera relay into a proxy
|
||||
// for any engine endpoint, with the credential helpfully attached.
|
||||
var safeCameraIDChars = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
|
||||
|
||||
// safeCameraID is the character check AND the two names that pass it and still
|
||||
// mean something to a path resolver.
|
||||
//
|
||||
// The pattern allows `.` because real camera ids contain them - which means it
|
||||
// also allows exactly `.` and `..`, and `/api/cameras/../stream.mjpeg` is not
|
||||
// the endpoint anyone intended. The id can never contain a slash (the path is
|
||||
// split on them before we get here), so these two strings are the entire
|
||||
// remaining traversal surface. Found by the test, not by reading the regex.
|
||||
func safeCameraID(id string) bool {
|
||||
if id == "." || id == ".." {
|
||||
return false
|
||||
}
|
||||
return safeCameraIDChars.MatchString(id)
|
||||
}
|
||||
|
||||
type streamProxy struct {
|
||||
mu sync.RWMutex
|
||||
ln net.Listener
|
||||
srv *http.Server
|
||||
client *http.Client
|
||||
token string
|
||||
target string // engine origin, e.g. http://127.0.0.1:8010
|
||||
user string
|
||||
pass string
|
||||
|
||||
// Opens head office's live relay for one camera. Set on a computer that
|
||||
// is signed in, whether or not an engine runs here - which is the whole
|
||||
// point: watching a camera in another building is precisely the case
|
||||
// where there is no engine on this machine to ask.
|
||||
live func(ctx context.Context, cameraID string) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||
|
||||
// start binds a loopback listener and begins relaying. Calling it again while
|
||||
// running is a no-op, so a restarted engine cannot leave two listeners behind.
|
||||
func (p *streamProxy) start(base, user, pass string) error {
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
base = "http://" + base
|
||||
}
|
||||
if _, err := url.Parse(base); err != nil {
|
||||
return fmt.Errorf("engine base %q: %w", base, err)
|
||||
}
|
||||
if err := p.bind(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.target = strings.TrimRight(base, "/")
|
||||
p.user, p.pass = user, pass
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchRemote makes the relay able to serve head office's live view, and
|
||||
// binds it if nothing else has.
|
||||
//
|
||||
// Separate from start() because the two are independent: a shop PC has both
|
||||
// an engine and a session, an owner's laptop has only a session, and a PC
|
||||
// still being set up has only an engine. Folding them together would mean a
|
||||
// computer with no engine could not watch a camera at all - which is the one
|
||||
// computer most likely to be trying to.
|
||||
func (p *streamProxy) watchRemote(fn func(context.Context, string) (*http.Response, error)) error {
|
||||
if err := p.bind(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.live = fn
|
||||
return nil
|
||||
}
|
||||
|
||||
// bind starts the loopback listener once. Calling it again while running is a
|
||||
// no-op, so neither a restarted engine nor a second sign-in can leave two
|
||||
// listeners behind.
|
||||
func (p *streamProxy) bind() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.srv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The engine's own credential exists precisely so that the live face feed
|
||||
// is never served open - CLAUDE.md is explicit that an unauthenticated
|
||||
// listener would expose it. An unauthenticated loopback relay would hand
|
||||
// that same feed to any other process on this PC, which on a shop counter
|
||||
// is not a theoretical set. A per-run token, minted here and given only to
|
||||
// this app's own webview, keeps the relay as private as the engine is.
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return fmt.Errorf("proxy token: %w", err)
|
||||
}
|
||||
|
||||
// Port 0: the OS picks a free one. A fixed port would collide with
|
||||
// whatever else a shop PC happens to be running, and the failure would be
|
||||
// "the cameras stopped working" with nothing pointing at the cause.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream proxy listen: %w", err)
|
||||
}
|
||||
|
||||
p.ln = ln
|
||||
p.token = hex.EncodeToString(raw)
|
||||
// No client timeout: an MJPEG stream is endless by design and any deadline
|
||||
// would cut the picture off mid-shift. The request context ends it when
|
||||
// the webview navigates away or the tile is replaced.
|
||||
p.client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
srv := &http.Server{Handler: http.HandlerFunc(p.handle)}
|
||||
p.srv = srv
|
||||
|
||||
// srv and ln are captured, not read off the struct inside the goroutine:
|
||||
// stop() sets both to nil, so a serve loop that reached for them after a
|
||||
// quick start/stop would dereference nil and take the whole app down. The
|
||||
// test that stops the relay found exactly that.
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *streamProxy) stop() {
|
||||
p.mu.Lock()
|
||||
srv, ln := p.srv, p.ln
|
||||
p.srv, p.ln, p.token, p.live = nil, nil, "", nil
|
||||
p.mu.Unlock()
|
||||
if srv != nil {
|
||||
_ = srv.Close()
|
||||
}
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// urlFor returns the loopback URL for one camera resource, or "" when the
|
||||
// proxy is not running so the caller can fall back.
|
||||
func (p *streamProxy) urlFor(cameraID, file string) string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.ln == nil || p.token == "" || !safeCameraID(cameraID) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("http://%s/s/%s/%s/%s",
|
||||
p.ln.Addr().String(), p.token, cameraID, file)
|
||||
}
|
||||
|
||||
func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.RLock()
|
||||
token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client
|
||||
liveFn := p.live
|
||||
p.mu.RUnlock()
|
||||
if token == "" || client == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// /s/<token>/<camera>/<file>
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||
if len(parts) != 4 || parts[0] != "s" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Constant time: the token is the only thing standing between another
|
||||
// local process and a live view of customers' faces.
|
||||
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 {
|
||||
// 404 rather than 403. There is nothing here to tell an unwelcome
|
||||
// caller they have found the right door with the wrong key.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cameraID := parts[2]
|
||||
if !safeCameraID(cameraID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// An allow-list, not a prefix match. Everything else the engine serves -
|
||||
// the identity list, the gallery, erasure - stays unreachable through here
|
||||
// even for a caller holding the token.
|
||||
//
|
||||
// frame.jpg is listed although no screen asks for one yet. It is reachable
|
||||
// only through urlFor, which is internal, so it adds no bound API nobody
|
||||
// calls; it is here so that adding a still later is a change to a screen
|
||||
// rather than a change to the one file where a mistake is a credentialed
|
||||
// proxy onto the biometric API.
|
||||
// Head office's relay, not the engine. The two are different machines and
|
||||
// different credentials, so this returns rather than falling through.
|
||||
if parts[3] == "live.mjpeg" {
|
||||
if liveFn == nil {
|
||||
http.Error(w, "not signed in to head office", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
p.relayRemote(w, r, cameraID, liveFn)
|
||||
return
|
||||
}
|
||||
|
||||
var enginePath string
|
||||
switch parts[3] {
|
||||
case "stream.mjpeg":
|
||||
enginePath = "/api/cameras/" + cameraID + "/stream.mjpeg"
|
||||
case "frame.jpg":
|
||||
enginePath = "/api/cameras/" + cameraID + "/frame.jpg"
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target+enginePath, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "bad upstream request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// frame.jpg takes width and quality; the engine re-encodes on demand.
|
||||
req.URL.RawQuery = r.URL.RawQuery
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, "engine unreachable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for _, h := range []string{"Content-Type", "Cache-Control", "Pragma", "Expires"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
w.Header().Set(h, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
// Copied by hand rather than with io.Copy so every chunk is flushed. An
|
||||
// MJPEG stream never ends, so anything buffered waiting for a full buffer
|
||||
// is a tile that stays blank forever - which is the same symptom as the
|
||||
// bug this file exists to fix, and would look like it had not worked.
|
||||
flusher, _ := w.(http.Flusher)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||
return // webview went away
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
296
desktop/stream_proxy_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeEngine stands in for the Python engine: it demands Basic auth exactly as
|
||||
// the real one does when a credential is configured, and records what it was
|
||||
// asked for.
|
||||
type fakeEngine struct {
|
||||
*httptest.Server
|
||||
gotPath string
|
||||
gotUser string
|
||||
gotPass string
|
||||
hadAuth bool
|
||||
}
|
||||
|
||||
func newFakeEngine(t *testing.T, body string) *fakeEngine {
|
||||
t.Helper()
|
||||
f := &fakeEngine{}
|
||||
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.gotPath = r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
f.gotPath += "?" + r.URL.RawQuery
|
||||
}
|
||||
f.gotUser, f.gotPass, f.hadAuth = r.BasicAuth()
|
||||
if !f.hadAuth {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="behavision"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
t.Cleanup(f.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func startProxy(t *testing.T, engine string, user, pass string) *streamProxy {
|
||||
t.Helper()
|
||||
p := newStreamProxy()
|
||||
if err := p.start(engine, user, pass); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
t.Cleanup(p.stop)
|
||||
return p
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (int, string) {
|
||||
t.Helper()
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := c.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// The whole point: the webview gets a URL it can actually load, and the
|
||||
// password stays behind. A credential in the src is both unloadable in a
|
||||
// Chromium webview and readable by anything that can see the DOM.
|
||||
func TestTheCameraURLCarriesNoPassword(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "hunter2-the-real-one")
|
||||
|
||||
u := p.urlFor("cam2", "stream.mjpeg")
|
||||
if u == "" {
|
||||
t.Fatal("no url while the proxy is running")
|
||||
}
|
||||
if strings.Contains(u, "hunter2-the-real-one") || strings.Contains(u, "behavision:") {
|
||||
t.Fatalf("credential leaked into the tile URL: %s", u)
|
||||
}
|
||||
if !strings.HasPrefix(u, "http://127.0.0.1:") {
|
||||
t.Fatalf("relay must be loopback only, got %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRelayAttachesTheCredentialItself(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frame-bytes")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
|
||||
code, body := get(t, p.urlFor("cam2", "stream.mjpeg"))
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("want 200 through the relay, got %d", code)
|
||||
}
|
||||
if body != "frame-bytes" {
|
||||
t.Fatalf("body not relayed unchanged: %q", body)
|
||||
}
|
||||
if !engine.hadAuth || engine.gotUser != "behavision" || engine.gotPass != "s3cret" {
|
||||
t.Fatalf("engine did not receive the credential: auth=%v user=%q",
|
||||
engine.hadAuth, engine.gotUser)
|
||||
}
|
||||
if engine.gotPath != "/api/cameras/cam2/stream.mjpeg" {
|
||||
t.Fatalf("wrong upstream path: %s", engine.gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// The token is what keeps every other process on a shop PC from opening a live
|
||||
// view of customers' faces, now that the relay itself has no password.
|
||||
func TestAnotherProcessCannotGuessItsWayIn(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
addr := p.ln.Addr().String()
|
||||
|
||||
for _, bad := range []string{"", "0", strings.Repeat("a", 64), "wrong-token"} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/cam2/stream.mjpeg", addr, bad)
|
||||
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||
t.Fatalf("token %q got %d, want 404", bad, code)
|
||||
}
|
||||
}
|
||||
if engine.hadAuth {
|
||||
t.Fatal("a rejected request still reached the engine")
|
||||
}
|
||||
}
|
||||
|
||||
// A camera id is interpolated into the upstream path, so it has to be a camera
|
||||
// id and not a way to walk to a different endpoint with the credential
|
||||
// attached.
|
||||
func TestACameraIdCannotClimbOutOfItsPath(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
addr := p.ln.Addr().String()
|
||||
|
||||
for _, bad := range []string{"..", "%2e%2e", "cam2/../../api/identities", "cam 2", ""} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/%s/stream.mjpeg", addr, p.token, bad)
|
||||
code, _ := get(t, url)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("camera id %q got %d, want 404", bad, code)
|
||||
}
|
||||
}
|
||||
if strings.Contains(engine.gotPath, "identities") {
|
||||
t.Fatalf("reached a non-camera endpoint: %s", engine.gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the two files a tile needs. The engine also serves the identity list and
|
||||
// the erasure endpoint; holding the token must not open those.
|
||||
func TestOnlyTheTwoCameraFilesAreReachable(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
addr := p.ln.Addr().String()
|
||||
|
||||
for _, bad := range []string{"identities", "stats", "commission", "stream.mjpeg.bak"} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, bad)
|
||||
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||
t.Fatalf("file %q got %d, want 404", bad, code)
|
||||
}
|
||||
}
|
||||
|
||||
for _, good := range []string{"stream.mjpeg", "frame.jpg"} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, good)
|
||||
if code, _ := get(t, url); code != http.StatusOK {
|
||||
t.Fatalf("file %q got %d, want 200", good, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frame.jpg takes width and quality - the engine re-encodes on demand, and a
|
||||
// relay that dropped the query would silently serve full-size frames.
|
||||
func TestTheQueryStringSurvivesTheRelay(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
|
||||
url := p.urlFor("cam2", "frame.jpg") + "?width=640&quality=70"
|
||||
if code, _ := get(t, url); code != http.StatusOK {
|
||||
t.Fatalf("got %d", code)
|
||||
}
|
||||
if !strings.Contains(engine.gotPath, "width=640") ||
|
||||
!strings.Contains(engine.gotPath, "quality=70") {
|
||||
t.Fatalf("query dropped: %s", engine.gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// An engine that is not running must read as a bad gateway, not as a hang. A
|
||||
// blank tile that never resolves is the symptom this whole file exists to end.
|
||||
func TestAnEngineThatIsDownFailsQuickly(t *testing.T) {
|
||||
// Port 1 on loopback: nothing listens, and the connection is refused
|
||||
// rather than dropped, so this is fast and deterministic.
|
||||
p := startProxy(t, "http://127.0.0.1:1", "behavision", "s3cret")
|
||||
|
||||
done := make(chan int, 1)
|
||||
go func() { code, _ := get(t, p.urlFor("cam2", "stream.mjpeg")); done <- code }()
|
||||
select {
|
||||
case code := <-done:
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("want 502, got %d", code)
|
||||
}
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatal("a dead engine left the request hanging")
|
||||
}
|
||||
}
|
||||
|
||||
// Stopping must actually free the port, or a restarted engine leaves listeners
|
||||
// behind for the life of the process.
|
||||
func TestStoppingReleasesEverything(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := newStreamProxy()
|
||||
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
url := p.urlFor("cam2", "stream.mjpeg")
|
||||
if code, _ := get(t, url); code != http.StatusOK {
|
||||
t.Fatalf("want 200 before stop, got %d", code)
|
||||
}
|
||||
|
||||
p.stop()
|
||||
|
||||
if got := p.urlFor("cam2", "stream.mjpeg"); got != "" {
|
||||
t.Fatalf("still handing out URLs after stop: %s", got)
|
||||
}
|
||||
c := &http.Client{Timeout: 3 * time.Second}
|
||||
if resp, err := c.Get(url); err == nil {
|
||||
resp.Body.Close()
|
||||
t.Fatal("listener still accepting after stop")
|
||||
}
|
||||
}
|
||||
|
||||
// start twice must not leave two listeners, which is what a restarted engine
|
||||
// would otherwise cause.
|
||||
func TestStartingTwiceIsANoOp(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "u", "p")
|
||||
|
||||
first := p.urlFor("cam2", "stream.mjpeg")
|
||||
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||
t.Fatalf("second start: %v", err)
|
||||
}
|
||||
if second := p.urlFor("cam2", "stream.mjpeg"); second != first {
|
||||
t.Fatalf("second start moved the relay: %s -> %s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// Against the real engine, which the unit tests above deliberately do not
|
||||
// touch. Skipped unless TEST_ENGINE_URL is set, the same rule the server's
|
||||
// live store tests follow: the suite must stay runnable with no services.
|
||||
//
|
||||
// TEST_ENGINE_URL=http://127.0.0.1:8010 \
|
||||
// TEST_ENGINE_USER=... TEST_ENGINE_PASS=... go test ./desktop/ -run Live
|
||||
//
|
||||
// It exists because everything above proves the relay against a fake that
|
||||
// agrees with me. Only a real engine proves the thing that was actually
|
||||
// broken: that a multipart MJPEG stream arrives through the relay in pieces,
|
||||
// rather than being buffered into a tile that never paints.
|
||||
func TestLiveRelayCarriesRealMJPEGFrames(t *testing.T) {
|
||||
base := os.Getenv("TEST_ENGINE_URL")
|
||||
if base == "" {
|
||||
t.Skip("set TEST_ENGINE_URL to run the live relay test")
|
||||
}
|
||||
cam := os.Getenv("TEST_ENGINE_CAMERA")
|
||||
if cam == "" {
|
||||
cam = "cam2"
|
||||
}
|
||||
p := startProxy(t, base, os.Getenv("TEST_ENGINE_USER"), os.Getenv("TEST_ENGINE_PASS"))
|
||||
|
||||
url := p.urlFor(cam, "stream.mjpeg")
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("relay returned %d - the credential did not reach the engine", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart") {
|
||||
t.Fatalf("not a stream: Content-Type %q", ct)
|
||||
}
|
||||
|
||||
// Read until two JPEG start markers have gone past. One proves it opened;
|
||||
// two prove it is still delivering, which is the difference between a
|
||||
// working tile and a single frozen frame.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
var seen, total int
|
||||
buf := make([]byte, 16*1024)
|
||||
for seen < 2 && time.Now().Before(deadline) {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
total += n
|
||||
seen += strings.Count(string(buf[:n]), "\xff\xd8\xff")
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen < 2 {
|
||||
t.Fatalf("only %d JPEG frames in %d bytes - the relay is not streaming", seen, total)
|
||||
}
|
||||
t.Logf("relayed %d frames in %d bytes with no credential in the URL", seen, total)
|
||||
}
|
||||
149
desktop/stream_remote.go
Normal file
@@ -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
@@ -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
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jpg is a byte sequence that is not valid JPEG and does not need to be: what
|
||||
// is under test is that the bytes arrive intact and framed, not that a decoder
|
||||
// likes them.
|
||||
var jpg = []byte{0xFF, 0xD8, 'h', 'e', 'l', 'l', 'o', 0xFF, 0xD9}
|
||||
|
||||
// sseServer answers head office's live endpoint with `pushes` frames and then
|
||||
// ends the response, which is what the server's five-minute cap does.
|
||||
func sseServer(t *testing.T, frames int, hits *int32) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(hits, 1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fl, _ := w.(http.Flusher)
|
||||
// Registered, nothing being pushed yet. Nothing may be drawn for it.
|
||||
fmt.Fprint(w, "event: waiting\ndata: \n\n")
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
for i := 0; i < frames; i++ {
|
||||
fmt.Fprintf(w, "event: frame\ndata: %s\n\n",
|
||||
base64.StdEncoding.EncodeToString(jpg))
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func openerFor(srv *httptest.Server) func(context.Context, string) (*http.Response, error) {
|
||||
return func(ctx context.Context, cam string) (*http.Response, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/live/"+cam, nil)
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point: base64 frames over SSE are not something an <img> can show,
|
||||
// and a multipart MJPEG stream is. Without this the app could only ever show a
|
||||
// still, on exactly the computers that cannot reach the camera any other way.
|
||||
func TestRemoteFramesReachTheWebviewAsMJPEG(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 3, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
u := p.urlFor("cam2", "live.mjpeg")
|
||||
if u == "" {
|
||||
t.Fatal("no relay url; the proxy did not bind")
|
||||
}
|
||||
|
||||
// The relay reconnects for as long as the viewer is there, so the read is
|
||||
// bounded by us rather than by the stream ending - exactly as an <img>
|
||||
// would behave.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/x-mixed-replace") {
|
||||
t.Fatalf("Content-Type = %q, an <img> will not treat that as a stream", ct)
|
||||
}
|
||||
|
||||
// Read the first three frames' worth and stop; the relay would otherwise
|
||||
// go on reconnecting forever, which is the behaviour being relied on.
|
||||
want := append([]byte(fmt.Sprintf("--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
|
||||
mjpegBoundary, len(jpg))), jpg...)
|
||||
got := make([]byte, 0, 4096)
|
||||
buf := make([]byte, 512)
|
||||
for len(got) < 3*len(want) {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
got = append(got, buf[:n]...)
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if n := bytes.Count(got, []byte("--"+mjpegBoundary)); n < 3 {
|
||||
t.Fatalf("got %d frames in %d bytes, want at least 3", n, len(got))
|
||||
}
|
||||
if !bytes.Contains(got, want) {
|
||||
t.Errorf("a frame was not framed as expected:\n%q", got[:min(len(got), 300)])
|
||||
}
|
||||
// `waiting` is a real state - head office has us registered and the shop
|
||||
// computer has not started pushing - and there is nothing to draw for it.
|
||||
// Emitting an empty part would blank a tile that already had a picture.
|
||||
if bytes.Contains(got, []byte("Content-Length: 0")) {
|
||||
t.Error("an empty frame was written for a waiting event")
|
||||
}
|
||||
}
|
||||
|
||||
// The server caps one push at five minutes so a tab left open for a week
|
||||
// cannot leave a shop uploading for a week. Reconnecting is therefore a normal
|
||||
// event, and doing it here rather than in the page is what lets the <img>
|
||||
// survive the cap - it never sees the stream end.
|
||||
func TestTheRelayReconnectsWhenHeadOfficeEndsAPush(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 1, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.urlFor("cam2", "live.mjpeg"), nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Two frames means two pushes, because each push carries exactly one.
|
||||
seen, buf := 0, make([]byte, 256)
|
||||
acc := make([]byte, 0, 2048)
|
||||
for seen < 2 {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
acc = append(acc, buf[:n]...)
|
||||
seen = bytes.Count(acc, []byte("--"+mjpegBoundary))
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen < 2 {
|
||||
t.Fatalf("got %d frames across reconnects, want 2", seen)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got < 2 {
|
||||
t.Errorf("head office was asked %d times, want at least 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Signed out, the relay must not pretend. There is no fallback URL to offer
|
||||
// either: the head-office endpoint needs this session's bearer, which an <img>
|
||||
// cannot send - so a tile that silently failed would be the only alternative.
|
||||
func TestTheRelayRefusesWhenNobodyIsSignedIn(t *testing.T) {
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(nil); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
resp, err := http.Get(p.urlFor("cam2", "live.mjpeg"))
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The relay is credentialed - it is a path to a live view of a shop floor -
|
||||
// and the token is the only thing standing between another local process and
|
||||
// it. live.mjpeg must be behind exactly the same door as the engine routes.
|
||||
func TestTheRemoteRouteIsBehindTheSameToken(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 1, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
// The right shape, the wrong value.
|
||||
parts := strings.Split(p.urlFor("cam2", "live.mjpeg"), "/")
|
||||
parts[4] = strings.Repeat("0", len(parts[4]))
|
||||
bad := strings.Join(parts, "/")
|
||||
|
||||
resp, err := http.Get(bad)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404 - and 404 rather than 403, because there is nothing here to tell an unwelcome caller they found the right door", resp.StatusCode)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) != 0 {
|
||||
t.Error("a request with the wrong token still made the shop computer upload")
|
||||
}
|
||||
}
|
||||
BIN
desktop/tray-logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |