// 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 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))) } } 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 /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() 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.") 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. 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"}}) } cands = append(cands, cand{"python3", nil}, cand{"python", nil}) var tried []string for _, c := range cands { exe, err := exec.LookPath(c.exe) if err != nil { continue } 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) if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) { full := exe if len(c.args) > 0 { full = exe + " " + strings.Join(c.args, " ") } return full, "Python " + ver, nil } } msg := "no Python 3.10 or newer was found on this PC.\n\n" + " 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." if len(tried) > 0 { msg += "\n\n Found, but too old: " + strings.Join(tried, ", ") } 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 { if _, err := os.Stat(venvPython(venv)); err == nil { return nil // already built; pip below brings it up to date } exe, args := splitLauncher(py) args = append(args, "-m", "venv", venv) return stream(exec.Command(exe, args...), "creating the virtual environment") } 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 ` 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) }