Files
Suriyakumarvijayanayagam 8f07dee048 The engine stopped updating, and said "installed" every time
The SSL fix shipped and did not reach the machine it was written for. That
log said so, one line above the tick:

  behavision is already installed with the same version as the provided
  wheel. Use --force-reinstall to force an installation of the wheel.
   [ok] Engine and dependencies installed

and the traceback below it still pointed at model_assets.py line 59,
urllib.request.urlretrieve - code the fix had deleted.

Two frozen literals caused it: version = "1.1.0" in pyproject.toml and
__version__ = "1.0.0" in behavision/__init__.py. They disagreed with each
other and neither tracked a release, so every release built
behavision-1.1.0-py3-none-any.whl and pip install --upgrade on a machine that
already had 1.1.0 is a no-op. The comment beside that call claimed the
opposite.

The shape of the damage is what makes it bad. The Go binaries - app, agent,
setup tool - are rebuilt every release and updated normally. So a shop PC ran
a current app supervising an engine several releases old, and nothing said
which: /api/health reported the model, the paths, the cameras and the gallery,
and no version at all.

- One version, in the package, read by pyproject through
  [tool.setuptools.dynamic]. In a checkout it reads 0.0.0+dev: a
  plausible-looking number on a developer's /api/health is worse than none.
- pip install --force-reinstall --no-deps <wheel>, after the ordinary
  --upgrade. --upgrade settles the dependencies; the second call guarantees our
  own code is the code in the folder. --no-deps keeps it cheap - forcing the
  dependencies too would re-download ~300 MB every run. A rebuild at an
  unchanged version is the ordinary case while developing, so this must not
  rely on the version moving.
- release.sh stamps the tag: v0.5.6-demo -> 0.5.6+demo, valid PEP 440. Into a
  copy of the line, reverted in a trap, so the tree is never left dirty.

/api/health reports version now. Without it there is no way to tell a shop PC
three releases behind from a current one, which is how this survived several
releases.

Reproduced end to end before fixing, against real wheels on Python 3.12: two
builds of the same version, --upgrade leaves the old code in place and prints
the same sentence the colleague's Mac printed, --force-reinstall --no-deps
replaces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 17:30:24 +05:30

862 lines
31 KiB
Go

// 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 {
// TWO calls, and the second is the one that matters.
//
// `--upgrade` alone is not an upgrade when the version has not moved:
// pip skips the wheel and says so, one line above this program
// printing "[ok] Engine and dependencies installed". The wheel version
// was a frozen literal for several releases, so every engine fix in
// them silently failed to reach any machine that had run setup once -
// while the Go binaries beside it, rebuilt every release, updated
// normally. Half the product current, half of it months old, and
// nothing saying which.
//
// release.sh stamps the tag into the version now, so the versions do
// differ. This does not rely on that: a rebuild at the same version is
// the ordinary case while developing, and "installed" has to mean the
// code in this folder either way.
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
"installing the engine"); err != nil {
return err
}
// --no-deps so this is our own package only: the call above has
// already settled the dependencies, and forcing those too would
// re-download ~300 MB of numpy, OpenCV and onnxruntime every run.
return stream(exec.Command(vpy, "-m", "pip", "install",
"--force-reinstall", "--no-deps", wheels[0]),
"installing the engine")
}
tmp, err := os.MkdirTemp("", "behavision-src-")
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)
}