Seen on the demo PC: Start did nothing and Stop stayed grey. The supervisor's engine had failed because a second engine already held port 8010, and the tray reported that as nothing at all. The supervisor now keeps the engine's last lines and turns the known ones into a sentence - 'port 8010 is already in use - another Behavision or its engine is still running', 'run behavision-setup again' - which the tray and the window show. Tray clicks no longer run on the menu loop, so a stop that waits for the process cannot make the menu look dead. Two ways that second process came to exist are closed: setup refuses to run while Behavision.exe or the agent is up, and the app watches agent.json so a claim made underneath it - which rotates the API token - is picked up instead of leaving camera sync refused until a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
637 lines
21 KiB
Go
637 lines
21 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
|
|
|
|
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 != "" {
|
|
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()
|
|
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.
|
|
// 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"}})
|
|
}
|
|
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 <folder>` makes setuptools
|
|
// write behavision.egg-info INTO that folder, and the folder is read-only
|
|
// whenever the release was unzipped somewhere sensible - Program Files, or
|
|
// the shared drive INSTALL.txt says is fine. Found by running this in a
|
|
// container with the source mounted read-only: "could not create
|
|
// 'behavision.egg-info': Read-only file system". Falling back to source
|
|
// copies it somewhere writable first, for the same reason.
|
|
if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 {
|
|
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
|
|
"installing the engine")
|
|
}
|
|
tmp, err := os.MkdirTemp("", "behavision-src-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
if err := copyTree(src, tmp); err != nil {
|
|
return fmt.Errorf("staging the engine source: %w", err)
|
|
}
|
|
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp),
|
|
"installing the engine")
|
|
}
|
|
|
|
// seedConfig puts the shipped default.yaml where the engine will look for it,
|
|
// and leaves an existing one alone.
|
|
func seedConfig(src, state string) error {
|
|
dst := filepath.Join(state, "config", "default.yaml")
|
|
if _, err := os.Stat(dst); err == nil {
|
|
return nil
|
|
}
|
|
from := filepath.Join(src, "config", "default.yaml")
|
|
b, err := os.ReadFile(from)
|
|
if err != nil {
|
|
return fmt.Errorf("the release is missing config/default.yaml: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(dst, b, 0o644)
|
|
}
|
|
|
|
// copyTree copies a source tree, skipping the caches a checkout accumulates.
|
|
func copyTree(from, to string) error {
|
|
return filepath.WalkDir(from, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(from, path)
|
|
if d.IsDir() {
|
|
if d.Name() == "__pycache__" || strings.HasSuffix(d.Name(), ".egg-info") {
|
|
return filepath.SkipDir
|
|
}
|
|
return os.MkdirAll(filepath.Join(to, rel), 0o755)
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(to, rel), b, 0o644)
|
|
})
|
|
}
|
|
|
|
// runEngine runs the engine exactly as the app will later: same interpreter,
|
|
// same environment. In particular ChildEnv sets BEHAVISION_DATA_DIR, without
|
|
// which a pip-installed engine decides its state lives in site-packages and
|
|
// downloads the models to a place the app never looks.
|
|
func runEngine(vpy string, args ...string) error {
|
|
full := append([]string{"-m", "behavision"}, args...)
|
|
cmd := exec.Command(vpy, full...)
|
|
cmd.Env = engine.ChildEnv("")
|
|
return stream(cmd, "running the engine")
|
|
}
|
|
|
|
// writeConfig records how to start the engine, in the same file and through
|
|
// the same type the app reads, so the two cannot disagree about it.
|
|
func writeConfig(vpy string) error {
|
|
path := paths.AgentConfig()
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
return fmt.Errorf("reading %s: %w", path, err)
|
|
}
|
|
// An absolute path: the app resolves a relative EngineExe against its own
|
|
// install root under Program Files, and the interpreter is not there.
|
|
cfg.EngineExe = vpy
|
|
cfg.EngineArgs = []string{"-m", "behavision", "run"}
|
|
if cfg.APIBase == "" {
|
|
cfg.APIBase = "http://127.0.0.1:8010"
|
|
}
|
|
return cfg.Save(path)
|
|
}
|
|
|
|
// smokeTest starts the engine exactly as the app will and waits for its API to
|
|
// answer. Any reply counts, including 401: the engine invents its own
|
|
// credential when none is configured, and a refusal proves it is serving.
|
|
func smokeTest(vpy string, demoCams []demo.Camera) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
|
cmd.Env = engine.ChildEnv("")
|
|
var log strings.Builder
|
|
cmd.Stdout, cmd.Stderr = &log, &log
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}()
|
|
|
|
client := &http.Client{Timeout: 3 * time.Second}
|
|
deadline := time.Now().Add(75 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
resp, err := client.Get("http://127.0.0.1:8010/api/health")
|
|
if err == nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
if demoCams == nil {
|
|
return nil
|
|
}
|
|
// Through the engine's own Add Camera, not written to its file:
|
|
// the store is what applies DPAPI to the password on Windows, so
|
|
// this is how the credential ends up encrypted on disk rather
|
|
// than sitting in cameras.json for anyone who can read
|
|
// ProgramData.
|
|
return addCameras(demoCams)
|
|
}
|
|
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
|
break
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
|
|
tail(log.String(), 15))
|
|
}
|
|
|
|
func tail(s string, n int) string {
|
|
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
|
if len(lines) > n {
|
|
lines = lines[len(lines)-n:]
|
|
}
|
|
return " " + strings.Join(lines, "\n ")
|
|
}
|
|
|
|
// stream runs a command and shows its output. Shown, not swallowed: pip failing
|
|
// on a missing build tool prints exactly what is wrong, and hiding that leaves
|
|
// the operator with "setup failed" and nothing to act on.
|
|
func stream(cmd *exec.Cmd, what string) error {
|
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("%s failed: %w", what, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pause keeps the window open. Double-clicked from Explorer, a console program
|
|
// that finishes closes instantly and the operator sees nothing at all -
|
|
// success and failure look identical.
|
|
func pause() {
|
|
if runtime.GOOS != "windows" {
|
|
return
|
|
}
|
|
fmt.Print(" Press Enter to close. ")
|
|
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
|
}
|
|
|
|
// unlockDemo returns the sealed cameras a demo release ships, or nil when this
|
|
// is not a demo release. Asks for the unlock code on the console; three tries,
|
|
// because a code is read down a phone and typed by hand.
|
|
func unlockDemo(src string) (*demo.Payload, error) {
|
|
sealed, err := os.ReadFile(filepath.Join(src, "demo-cameras.enc"))
|
|
if err != nil {
|
|
return nil, nil // not a demo release
|
|
}
|
|
fmt.Println()
|
|
fmt.Println(" This is a demo release with the cameras already set up.")
|
|
fmt.Println(" It needs the unlock code you were given.")
|
|
fmt.Println()
|
|
in := bufio.NewReader(os.Stdin)
|
|
for attempt := 1; attempt <= 3; attempt++ {
|
|
fmt.Print(" Unlock code: ")
|
|
line, _ := in.ReadString('\n')
|
|
plain, err := demo.Open(line, sealed)
|
|
if err == nil {
|
|
payload, err := demo.Decode(plain)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the bundle unlocked but did not parse: %w", err)
|
|
}
|
|
fmt.Println()
|
|
return &payload, nil
|
|
}
|
|
fmt.Printf(" %v\n", err)
|
|
}
|
|
return nil, errors.New("no valid unlock code after three tries. Check it " +
|
|
"with whoever gave you this release and run setup again")
|
|
}
|
|
|
|
// claimShop redeems the installation code sealed in the bundle: the same call
|
|
// the app's Setup screen and `behavision-agent claim` make, so the PC ends up
|
|
// in exactly the state a customer's would - broker login, API token, the
|
|
// broker's CA on disk - and head office pushes its cameras down on the first
|
|
// sync.
|
|
func claimShop(code, base string) (string, error) {
|
|
if base == "" {
|
|
base = "https://mcp.loyaly.ai"
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
b, err := enrol.Claim(ctx, base, code)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path := paths.AgentConfig()
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
cfg.ClientID = b.ClientSlug
|
|
cfg.SiteID = b.SiteSlug
|
|
cfg.SiteName = b.SiteName
|
|
cfg.BrokerURL = b.MQTTURL
|
|
cfg.BrokerUsername = b.MQTTUser
|
|
cfg.BrokerPassword = b.MQTTPass
|
|
cfg.AgentToken = b.AgentToken
|
|
cfg.CloudBase = base
|
|
cfg.Standalone = false
|
|
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
|
|
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
cfg.BrokerCAFile = caPath
|
|
if err := cfg.Save(path); err != nil {
|
|
return "", err
|
|
}
|
|
return b.SiteName, nil
|
|
}
|
|
|
|
// addCameras posts each demo camera to the running engine, with the credential
|
|
// the engine generated for itself on first start.
|
|
func addCameras(cams []demo.Camera) error {
|
|
user, pass, err := engineCredential()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
for _, c := range cams {
|
|
if c.Port == 0 {
|
|
c.Port = 554
|
|
}
|
|
body, _ := json.Marshal(c)
|
|
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8010/api/cameras",
|
|
bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if user != "" {
|
|
req.SetBasicAuth(user, pass)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("adding camera %s: %w", c.ID, err)
|
|
}
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
resp.Body.Close()
|
|
// 409 is "already there" - a re-run of setup, which is allowed.
|
|
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusConflict {
|
|
return fmt.Errorf("adding camera %s: %s: %s", c.ID, resp.Status,
|
|
strings.TrimSpace(string(msg)))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// engineCredential reads the Basic credential the engine wrote on its first
|
|
// start. Empty when the engine is configured without one.
|
|
func engineCredential() (string, string, error) {
|
|
b, err := os.ReadFile(paths.APICredentials())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", "", nil
|
|
}
|
|
return "", "", err
|
|
}
|
|
var user, pass string
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
if v, ok := strings.CutPrefix(line, "username="); ok {
|
|
user = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := strings.CutPrefix(line, "password="); ok {
|
|
pass = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return user, pass, nil
|
|
}
|
|
|
|
// markStandalone records that this PC runs on its own, through the same
|
|
// config type the app reads.
|
|
func markStandalone() error {
|
|
path := paths.AgentConfig()
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Standalone = true
|
|
return cfg.Save(path)
|
|
}
|