diff --git a/agent/cmd/behavision-demo-pack/main.go b/agent/cmd/behavision-demo-pack/main.go index f3a20dc..1100489 100644 --- a/agent/cmd/behavision-demo-pack/main.go +++ b/agent/cmd/behavision-demo-pack/main.go @@ -19,25 +19,34 @@ import ( ) func main() { - in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password)") + in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password) - the PC then runs on its own") + enrolCode := flag.String("enrol-code", "", "an installation code from head office - the PC then claims that shop and gets its cameras from there") + cloud := flag.String("cloud", "https://mcp.loyaly.ai", "head office, with -enrol-code") out := flag.String("out", "demo-cameras.enc", "sealed bundle to write") flag.Parse() - if *in == "" { - fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack -cameras cameras.json [-out demo-cameras.enc]") + if *in == "" && *enrolCode == "" { + fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack (-cameras cameras.json | -enrol-code CODE [-cloud URL]) [-out demo-cameras.enc]") os.Exit(2) } - raw, err := os.ReadFile(*in) - if err != nil { - die("read cameras: %v", err) + var payload demo.Payload + if *in != "" { + raw, err := os.ReadFile(*in) + if err != nil { + die("read cameras: %v", err) + } + if err := json.Unmarshal(raw, &payload.Cameras); err != nil { + die("cameras.json: %v", err) + } + if len(payload.Cameras) == 0 { + die("no cameras in %s", *in) + } } - var cams []demo.Camera - if err := json.Unmarshal(raw, &cams); err != nil { - die("cameras.json: %v", err) - } - if len(cams) == 0 { - die("no cameras in %s", *in) + payload.EnrolCode = *enrolCode + if *enrolCode != "" { + payload.CloudBase = *cloud } + cams := payload.Cameras for i, c := range cams { switch { case c.ID == "": @@ -50,7 +59,7 @@ func main() { } // Re-marshal so only the fields the engine accepts travel, in a stable // shape, whatever extra keys the input happened to carry. - plain, err := json.Marshal(cams) + plain, err := json.Marshal(payload) if err != nil { die("marshal: %v", err) } @@ -67,7 +76,11 @@ func main() { die("write: %v", err) } - fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed)) + if payload.EnrolCode != "" { + fmt.Printf("\n sealed an installation code for %s into %s (%d bytes)\n\n", payload.CloudBase, *out, len(sealed)) + } else { + fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed)) + } fmt.Printf(" unlock code: %s\n\n", code) fmt.Println(" Shown once. Give it to whoever runs behavision-setup, by voice") fmt.Println(" or message - not in the same place as the zip.") diff --git a/agent/cmd/behavision-setup/main.go b/agent/cmd/behavision-setup/main.go index 836cce9..b9a8386 100644 --- a/agent/cmd/behavision-setup/main.go +++ b/agent/cmd/behavision-setup/main.go @@ -38,6 +38,7 @@ import ( "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" ) @@ -76,12 +77,19 @@ func run() error { // 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. - demoCams, err := unlockDemo(src) + bundle, err := unlockDemo(src) if err != nil { return err } - if demoCams != nil { - step("Demo cameras", fmt.Sprintf("%d unlocked", len(demoCams))) + 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() @@ -132,9 +140,21 @@ func run() error { return fmt.Errorf("the engine installed but would not start: %w", err) } step("Engine starts and answers", "verified") - if demoCams != nil { + if len(demoCams) > 0 { step("Demo cameras", "added to the engine") - // No head office in a demo. Without this the app opens on "type an + } + 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 @@ -446,7 +466,7 @@ func pause() { // 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.Camera, error) { +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 @@ -461,12 +481,12 @@ func unlockDemo(src string) ([]demo.Camera, error) { line, _ := in.ReadString('\n') plain, err := demo.Open(line, sealed) if err == nil { - var cams []demo.Camera - if err := json.Unmarshal(plain, &cams); 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 cams, nil + return &payload, nil } fmt.Printf(" %v\n", err) } @@ -474,6 +494,46 @@ func unlockDemo(src string) ([]demo.Camera, error) { "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 + 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 { diff --git a/agent/pkg/demo/bundle.go b/agent/pkg/demo/bundle.go index 8193962..7f32a68 100644 --- a/agent/pkg/demo/bundle.go +++ b/agent/pkg/demo/bundle.go @@ -20,6 +20,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base32" + "encoding/json" "errors" "fmt" "strings" @@ -29,6 +30,31 @@ import ( // told apart from corruption instead of failing as "authentication failed". const magic = "BVDEMO1\n" +// Payload is what a sealed bundle carries. Two demo shapes exist: +// +// - cameras only: the PC runs on its own with these cameras (the first +// demo build); +// - an enrolment code: the PC claims a real shop at head office and gets +// its cameras from there, exactly as a customer install would, so the +// demo exercises the whole product rather than a local copy of it. The +// code is single-use, so one bundle is one install. +// +// A bundle from the first build is a bare JSON array; Decode accepts both. +type Payload struct { + Cameras []Camera `json:"cameras,omitempty"` + EnrolCode string `json:"enrol_code,omitempty"` + CloudBase string `json:"cloud_base,omitempty"` +} + +// Decode reads either payload shape. +func Decode(plain []byte) (Payload, error) { + var p Payload + if len(plain) > 0 && plain[0] == '[' { + return p, json.Unmarshal(plain, &p.Cameras) + } + return p, json.Unmarshal(plain, &p) +} + // Camera is one entry as the engine's Add Camera endpoint accepts it. type Camera struct { ID string `json:"id"` diff --git a/release.sh b/release.sh index c5e9b32..312b8e7 100755 --- a/release.sh +++ b/release.sh @@ -3,6 +3,10 @@ # # ./release.sh v0.4.2 build dist/Behavision-v0.4.2-windows-x64.zip and publish # PUBLISH=0 ./release.sh v0.4.2 build only +# DEMO_PACK=demo-cameras.enc ./release.sh v0.4.4-demo +# a demo build: the sealed bundle from +# behavision-demo-pack ships in engine-src, +# and setup asks for its unlock code # # The package is a SOURCE install: the Go binaries are cross-compiled here, the # engine ships as a pure-Python wheel and behavision-setup.exe builds a venv on @@ -46,6 +50,10 @@ cp pyproject.toml requirements.txt "$STAGE/engine-src/" mkdir -p "$STAGE/engine-src/config" && cp config/default.yaml "$STAGE/engine-src/config/" rsync -a --exclude '__pycache__' behavision/ "$STAGE/engine-src/behavision/" cp installer/INSTALL.txt installer/run-with-lan-head-office.cmd "$STAGE/" +if [ -n "${DEMO_PACK:-}" ]; then + case "$TAG" in *-demo*) ;; *) echo "a DEMO_PACK build must be tagged -demo" >&2; exit 1;; esac + cp "$DEMO_PACK" "$STAGE/engine-src/demo-cameras.enc" && echo " demo bundle: $(basename "$DEMO_PACK")" +fi step "4. Package" rm -f "$ZIP" && (cd dist && zip -qr "$(basename "$ZIP")" Behavision) && ls -la "$ZIP" | awk '{print " " $5 " bytes " $9}'