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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-19 13:51:01 +05:30
parent 51b9cb9743
commit 4bd1718491
4 changed files with 130 additions and 23 deletions

View File

@@ -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.")

View File

@@ -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 {

View File

@@ -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"`

View File

@@ -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}'