From 4bd1718491ee9c5868333f1b0c31d986a79dd263 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Sat, 19 Sep 2026 13:51:01 +0530 Subject: [PATCH] A demo build can claim a real shop instead of running on its own The first demo sealed the office cameras into the package and ran the PC standalone - a copy of the product with no head office. The bundle can now carry an installation code instead: setup redeems it exactly as the app's Setup screen does, the PC joins the shop, and its cameras arrive from head office on the first sync. The demo then IS the product - login, Loya, head office - not a local imitation of it. The code is single-use, so one bundle is one install. release.sh ships the bundle with DEMO_PACK=. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- agent/cmd/behavision-demo-pack/main.go | 41 +++++++++----- agent/cmd/behavision-setup/main.go | 78 +++++++++++++++++++++++--- agent/pkg/demo/bundle.go | 26 +++++++++ release.sh | 8 +++ 4 files changed, 130 insertions(+), 23 deletions(-) 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}'