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
862 lines
31 KiB
Go
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)
|
|
}
|