Compare commits
2 Commits
92b12bcb1c
...
v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e1dcf7050 | |||
| 92573e9067 |
@@ -33,6 +33,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/loyaly/behavision-agent/pkg/config"
|
"github.com/loyaly/behavision-agent/pkg/config"
|
||||||
|
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,6 +82,17 @@ func run() error {
|
|||||||
vpy := venvPython(venv)
|
vpy := venvPython(venv)
|
||||||
step("Virtual environment", 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
|
// --upgrade so re-running after a new release replaces the engine rather
|
||||||
// than leaving the old one in place and reporting success.
|
// than leaving the old one in place and reporting success.
|
||||||
if err := pipInstall(vpy, src); err != nil {
|
if err := pipInstall(vpy, src); err != nil {
|
||||||
@@ -237,13 +249,81 @@ func pipInstall(vpy, src string) error {
|
|||||||
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", src),
|
|
||||||
|
// 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")
|
"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 {
|
func runEngine(vpy string, args ...string) error {
|
||||||
full := append([]string{"-m", "behavision"}, args...)
|
full := append([]string{"-m", "behavision"}, args...)
|
||||||
return stream(exec.Command(vpy, full...), "running the engine")
|
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
|
// writeConfig records how to start the engine, in the same file and through
|
||||||
@@ -272,6 +352,7 @@ func smokeTest(vpy string) error {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
||||||
|
cmd.Env = engine.ChildEnv("")
|
||||||
var log strings.Builder
|
var log strings.Builder
|
||||||
cmd.Stdout, cmd.Stderr = &log, &log
|
cmd.Stdout, cmd.Stderr = &log, &log
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ func cmdRun() error {
|
|||||||
// nothing - the URL was returned, logged and even exposed on the
|
// nothing - the URL was returned, logged and even exposed on the
|
||||||
// desktop's status object, and never actually given to the engine.
|
// desktop's status object, and never actually given to the engine.
|
||||||
// A claimed shop PC published heartbeats and zero visits.
|
// 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
|
return cmd
|
||||||
},
|
},
|
||||||
LogWriter: logFile,
|
LogWriter: logFile,
|
||||||
|
|||||||
41
agent/pkg/engine/env.go
Normal file
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
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
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// be told again. Without it the engine recognised people and the
|
// be told again. Without it the engine recognised people and the
|
||||||
// bridge received nothing: a claimed shop PC published heartbeats
|
// bridge received nothing: a claimed shop PC published heartbeats
|
||||||
// and zero visits.
|
// and zero visits.
|
||||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
|
cmd.Env = agentengine.ChildEnv(a.webhookURL())
|
||||||
return cmd
|
return cmd
|
||||||
},
|
},
|
||||||
LogWriter: logFile,
|
LogWriter: logFile,
|
||||||
|
|||||||
113
installer/INSTALL.txt
Normal file
113
installer/INSTALL.txt
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
Behavision — installing on a shop PC
|
||||||
|
====================================
|
||||||
|
|
||||||
|
This is a source install. It needs Python and a working internet connection
|
||||||
|
once, at setup. After that the shop PC runs on its own.
|
||||||
|
|
||||||
|
|
||||||
|
WHAT YOU NEED FIRST
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Python 3.10 or newer.
|
||||||
|
|
||||||
|
https://www.python.org/downloads/windows/
|
||||||
|
|
||||||
|
On the very first screen of the Python installer, tick
|
||||||
|
"Add python.exe to PATH". If you miss it, setup cannot find Python and
|
||||||
|
you will have to run the Python installer again.
|
||||||
|
|
||||||
|
|
||||||
|
SETTING UP
|
||||||
|
----------
|
||||||
|
|
||||||
|
1. Unzip this whole folder somewhere permanent — for example
|
||||||
|
C:\Behavision. Keep the files together; behavision-setup.exe looks for
|
||||||
|
the engine-src folder next to itself.
|
||||||
|
|
||||||
|
2. Double-click behavision-setup.exe
|
||||||
|
|
||||||
|
It will:
|
||||||
|
- find your Python and check it is new enough
|
||||||
|
- build a private Python environment under
|
||||||
|
C:\ProgramData\Behavision\runtime
|
||||||
|
- install the recognition engine and its libraries (from the wheel
|
||||||
|
in engine-src; the folder you unzipped is never written to)
|
||||||
|
- download the recognition models (a few hundred megabytes)
|
||||||
|
- start the engine once to prove it works
|
||||||
|
|
||||||
|
This takes several minutes. Leave the window open until it says Done.
|
||||||
|
If anything fails it prints why, and running it again is safe.
|
||||||
|
|
||||||
|
3. Double-click Behavision.exe
|
||||||
|
|
||||||
|
The window opens and an icon appears in the system tray, next to the
|
||||||
|
clock. Right-click the tray icon to open the window again, or to stop
|
||||||
|
recognition.
|
||||||
|
|
||||||
|
|
||||||
|
CONNECTING IT TO HEAD OFFICE
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
The first screen asks for an installation code. Ask whoever manages your
|
||||||
|
shops — they create one from the Behavision platform, under the shop.
|
||||||
|
|
||||||
|
No head office? Choose "set this PC up on its own" on the same screen.
|
||||||
|
Recognition, the cameras and the customer list all work locally; nothing is
|
||||||
|
sent anywhere.
|
||||||
|
|
||||||
|
|
||||||
|
ADDING A CAMERA
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Cameras → Add. You need the camera's address on the shop network, its
|
||||||
|
username and password. Choose your camera's make from the list and the
|
||||||
|
stream path is filled in for you — that is the field nobody can look up.
|
||||||
|
|
||||||
|
Press "Test" before saving. Then press "Check placement" and walk past the
|
||||||
|
camera a few times. It will tell you whether the camera can actually
|
||||||
|
recognise faces from where it is mounted, which is not the same question as
|
||||||
|
whether it is connected.
|
||||||
|
|
||||||
|
Camera placement matters more than camera quality. Aim for roughly head
|
||||||
|
height, facing the direction people walk in. A camera high in a corner
|
||||||
|
looking down, or pointing at a bright window or glass door, will connect
|
||||||
|
perfectly and recognise almost nobody.
|
||||||
|
|
||||||
|
|
||||||
|
WHERE THINGS LIVE
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
C:\ProgramData\Behavision\ database, logs, camera list, models
|
||||||
|
C:\ProgramData\Behavision\runtime the engine's own Python
|
||||||
|
|
||||||
|
Everything the software writes is under ProgramData. The folder you unzipped
|
||||||
|
is never written to, so you can keep it on a shared drive.
|
||||||
|
|
||||||
|
|
||||||
|
STOPPING IT
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Right-click the tray icon and choose Quit. That stops recognition as well —
|
||||||
|
leaving it running with no visible control would be worse than stopping it.
|
||||||
|
|
||||||
|
Closing the window does NOT stop recognition. The window hides and the tray
|
||||||
|
icon stays, because a shop assistant clicking X should not switch the shop's
|
||||||
|
footfall counting off for the rest of the day.
|
||||||
|
|
||||||
|
|
||||||
|
IF SOMETHING IS WRONG
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
"No Python 3.10 or newer was found"
|
||||||
|
Python is missing, too old, or was installed without the
|
||||||
|
"Add python.exe to PATH" tick. Reinstall Python with that ticked.
|
||||||
|
|
||||||
|
Setup fails while installing libraries
|
||||||
|
Almost always no internet, or a proxy in the way. The error printed
|
||||||
|
just above the failure says which.
|
||||||
|
|
||||||
|
The window opens but says the engine is not running
|
||||||
|
Run behavision-setup.exe again; it will report what is missing.
|
||||||
|
|
||||||
|
Logs
|
||||||
|
C:\ProgramData\Behavision\engine.log
|
||||||
Reference in New Issue
Block a user