First install at the factory — ZHZN cabinets
Android head units: use ZHZN Android installation and handover. The installation, networking, credential-file and OTA commands below describe the Python/Linux agent. The ZHZN Android APK implements CSM directly and does not require Python, systemd or the Reyeah vending APK.
For the manufacturer's QA bench. This is the sequence for a ZHZN/CSM cabinet coming off the line: what software goes on it, what identity material it must and must not carry, the ten acceptance tests, and the sign-off that lets it ship. The field half of the story — unboxing at a venue — is Receiving a cabinet.
Everything here is traceable to code. Where a step the runbook logically needs does not exist yet, it is in §9 Not yet automated rather than written as an instruction, because a factory will follow an instruction whether or not the endpoint behind it is real.
Choose the controller before installing. Android ZHZN cabinets use
ai.intelliverse.zhzn.kioskx, which implements the CSMAA…BBprotocol. The Reyeah APK (com.ruiye.jd) is a different application. The Python agent documented below is an alternative for Linux hosts, not an Android prerequisite.
0. What the bench needs before it starts
Three of these are yours to hold. The fourth is not, and it is the one that stops a bench dead with a refusal that looks like a broken cabinet, so check it before you uncrate a batch rather than in the middle of one.
| What | Where it comes from | Without it |
|---|---|---|
Your plant's manufacturer API key (machines:build) |
Kiosk-X, out of band. export KIOSKX_FACTORY_KEY=… — see §4.2 |
Every build, test and sign-off call is 403 |
The fleet secret (ZHZN_DEVICE_SECRET) |
Kiosk-X, out of band, to a named person — §8 | The cabinet cannot register (401), and the bench script cannot make a new serial exist (§7) |
| The release-signing public key | The publishing repo — §2.2 step 4 | The cabinet ships un-upgradable: with no key the agent installs nothing |
| The gateway switched on in the cloud | Deployment config, not yours | See below |
Check bootstrap authentication before starting a fresh batch. A fleet-secret
request with no KIOSKX_ZHZN_DEVICE_SECRET configured on the backend returns
503 ZHZN gateway disabled (no device secret configured)
(app/routes/zhzn.py, _device). A fresh cabinet depending on that bootstrap
path cannot register until the platform configures it. A cabinet already holding
a valid per-device key can still authenticate: key verification precedes the
fleet-secret check. The probe below checks the bootstrap path, not every
authentication mode or the cabinet's enrolment status:
# a deliberately wrong secret: 401 means the gateway is ON and answering
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H "secret: definitely-not-the-fleet-secret" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/zhzn/register" -d '{"equipmentNo": "GATEWAY-PROBE-0001"}'
401 is the answer you want: the gateway is up and refused a bad credential.
503 means bootstrap is unavailable — call Kiosk-X before provisioning fresh
units that need it. (400 Missing equipmentNo and 404 Unknown device also mean the
gateway is up; see §9 of the field runbook
for the full ladder.) The probe serial is never registered, because the secret
is refused before anything is provisioned.
1. Before the cabinet is powered on
1.1 The serial
The serial stamped on the cabinet's plate is the identity for the rest of its
life. One contract governs it everywhere: letters, digits and hyphens only,
8 to 32 characters (app/machine_serial.py:39-49). VM-2026-0001 is fine;
VM_2026_1 is not, and neither is VM-1.
The operator-claim rule is the authoritative one, and the device gateway now
enforces it at first contact rather than being more permissive. That choice is
about where each failure lands: a serial the gateway refuses fails on your
bench, with the cabinet in front of you and the plate not yet riveted on, while
a serial the claim refuses fails weeks later in a venue, in front of somebody
with no authority to fix it. So a cabinet whose plate breaks the rule is turned
away on its very first POST /zhzn/register
(app/routes/zhzn.py, _require_claimable) with a message naming every fault
on the plate at once:
Serial 'VM_1' contains '_' — only letters, digits and hyphens may appear in a
serial an operator can claim; it is 4 characters — the shortest claimable serial
is 8. An operator could never claim it (POST /api/v1/machines/register requires
letters, digits and hyphens only, 8 to 32 characters), so the cabinet would
reach a venue and be impossible to onboard. Re-stamp the plate before this unit
ships.
Cabinets already registered under the old, looser gateway rule keep working — a validation change must not brick a machine that is selling product — so they are reported instead of repaired, and a fleet finds out from the platform rather than from an installer at a venue:
curl -s -H "X-API-Key: $KIOSKX_ADMIN_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/serials/unclaimable" | python3 -m json.tool
Renaming a live cabinet's serial is not offered, because it would detach the
machine from its own sales history, its build record and the plate on its door
(app/machine_serial.py:96-108).
1.2 What the unit must ship with
For a fresh bootstrap installation, set these three values in /etc/zhzn-agent.env.
The annotated reference configuration (text template)
is also in the approved source checkout at docs/reference/zhzn-agent.env.example;
your technician must fill it for this cabinet:
ZHZN_CLOUD=kioskx— the agent's default isdirect(agent/config.py:31), which points it at the Intelliverse services with a Cognito session instead of the device gateway. Always set this explicitly.ZHZN_DEVICE_SECRET=<the fleet secret>— the shared bootstrap credential (app/routes/zhzn.py,_device). It is no longer the cabinet's identity: it supports registration and an authorized enrolment; it cannot authenticate gateway calls for an enrolled cabinet. Enrol the unit at the bench — §1.2.1.ZHZN_SERIAL_NUMBER=<the plate serial>— with this empty the agent derives a serial from/etc/machine-id, or generates aGEN-…value and persists it (agent/provision.py:67-90). A derived serial does not match the paperwork and cannot be pre-mapped, so always set it.
Plus ZHZN_LOCAL_TOKEN, a fresh random value per cabinet, if the unit has a
kiosk UI. The loopback /dispense endpoint moves physical product, so it is
never anonymous: with no token configured, localctl refuses every dispense
(agent/localctl.py:make_handler.Handler._authorized). Never reuse the fleet secret here.
The Python agent reviewed at 8344f108ac1ae2a68b5ccdc7466bda059b58a3ce also reads ZHZN_DEVICE_CREDENTIAL,
ZHZN_DEVICE_CREDENTIAL_FILE (default /etc/zhzn-agent-device.key) and
ZHZN_ENROL (default on). Its persisted state_dir/device.key takes precedence
over the environment seed and rescue file (agent/device_key.py:resolve). A
pre-enrolled unit must receive its own key and matching identity; do not copy
another unit's state. See the enrolment checks below before shipping.
Cloud key authentication and local Wi-Fi unlock are separate. A key-only unit
can poll the cloud, but local /provision refuses an empty or whitespace-only
ZHZN_DEVICE_SECRET with 403. The current offline service code still derives
from that secret and the machine code; it is not ZHZN_LOCAL_TOKEN, and no
independent local service-PIN environment setting is implemented. Have the
platform provision the supported setup credentials before relying on that path.
1.2.1 Enrolling the cabinet: its own credential, not the fleet's
ZHZN_DEVICE_SECRET above is now a bootstrap credential, not the cabinet's
identity. It supports the legacy device channel and an authorized enrolment;
it cannot replace an enrolled cabinet's key. The
design and the reasoning are in
Per-cabinet device credentials; what a bench needs
is short.
Why it matters at a bench. The fleet secret is the same string on every cabinet, and it is in a plain file on a box standing in a shopping centre. While a cabinet is still on it, anyone who reads one cabinet's disk can address every cabinet on the platform — read a competitor's planogram, and open real orders in another operator's book. Enrolling at the bench means the unit leaves the plant already per-cabinet, and gateway calls no longer rely on the fleet secret. Local Wi-Fi code derivation remains a separate use of that secret (§8).
A cabinet that ships un-enrolled still boots and still sells, but it does not enrol itself unattended. The fleet secret no longer buys a credential for a cabinet the cloud has never heard from; somebody who owns the cabinet has to arm it first (see below). Until it enrols, goods and createOrder on that serial still answer on the shared fleet secret — that residual is declared, not a reason to skip the bench enrolment.
Do it here anyway. An arm stops a stranger enrolling the unit out from under you. Enrolling on the bench means the shared secret never spends a day reaching this cabinet's planogram and order book. See the residual in the design.
The bench call, with the same fleet secret already being stamped into the unit's
/etc/zhzn-agent.env:
The window has to be opened first. The fleet secret on its own no longer buys an enrolment for a cabinet the cloud has never heard from — that was the residual in §1.2.3, and it is now closed rather than merely described. Somebody who owns the cabinet has to say this one, now:
DEVICE_ID=$(cat /etc/machine-id)
curl -s -X POST -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
-H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/device-credential/arm" \
-d "{\"reason\": \"ZH-FAT-2026.1 bench run\", \"deviceId\": \"$DEVICE_ID\"}"
The bench is the natural place for this: your key can arm a cabinet nobody owns
yet whose build record names your plant, which is exactly the unit on the
bench, and passing deviceId pins the arm so that only the box in front of you
can spend it. Once a cabinet is in an operator's fleet the arm has to come from
them or from a Kiosk-X admin (§4.2).
The window is 30 minutes by default (ttlMinutes, 24 hours maximum), one
cabinet, one use — the enrolment below spends it. reason is mandatory, because
an arm nobody signed is indistinguishable from the attack it exists to stop. If
the bench run is abandoned, POST .../device-credential/disarm closes the window
rather than leaving it to lapse.
Then the enrolment itself:
# the same machine-id: the enrolment is bound to the box, and reading it here
# means the cabinet cannot later be locked out by somebody else enrolling its
# serial
curl -s -X POST "$KIOSKX_API_BASE/zhzn/enrol" \
-H "secret: $ZHZN_DEVICE_SECRET" -H "Content-Type: application/json" \
-d "{\"equipmentNo\": \"VM-2026-0001\", \"deviceId\": \"$DEVICE_ID\"}" \
| python3 -m json.tool
A 200 carries {keyId, deviceKey, machineCode}. The deviceKey is shown
once and is never stored on the platform — it exists in that response and on
the cabinet, and nowhere else. Write it into the unit before you close the
window:
# keyId:deviceKey, exactly as one string
sudo sh -c 'echo "ZHZN_DEVICE_CREDENTIAL=dk_9f3c…:pQ7…-_A" >> /etc/zhzn-agent.env'
# and the rescue copy, because a wiped state directory cannot enrol again (409)
sudo sh -c 'printf "dk_9f3c…:pQ7…-_A\n" > /etc/zhzn-agent-device.key'
sudo chmod 600 /etc/zhzn-agent-device.key
Both paths are the agent's own defaults (agent/config.py:66-74). The rescue
file is not optional paranoia: the gateway refuses a second enrolment for one
identity, deliberately, because from its side an identity asking to be issued
twice is what a cloned cabinet looks like. Without the file, a board that loses
its state directory needs an admin at the cloud end.
Enrolment does not claim the cabinet into a fleet. It has nothing to do with
POST /api/v1/machines/register, and §1.3
still applies unchanged: a crated cabinet has no operator on it.
1.2.2 How to check the cabinet is enrolled
Do not infer it from the absence of an error. Read it:
curl -s -H "X-API-Key: $KIOSKX_ADMIN_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/device-credentials/fleet" \
| python3 -c 'import json,sys; d=json.load(sys.stdin)["data"]; \
print("still on the fleet secret:", d["onFleetSecret"], \
"| windows open now:", d["armed"], \
"| machines the secret still reaches:", d["reachableOnFleetSecret"]); \
print("enrolment window:", d["enrolmentWindow"]); \
[print(m["machineNo"], m["state"], m.get("keyId"), \
m["enrolmentBinding"], "ARMED" if m["enrolmentArmed"] else "") \
for m in d["machines"]]'
state: "enrolled" with a keyId is the answer you want, and it is what the
whole rest of §1.2.1 is for. fleet-secret means this cabinet is still
addressable with the shared secret and is the work still outstanding — the view
sorts those rows first for that reason
(app/routes/machine_identity.py, device_credential_fleet). revoked means
somebody stopped this cabinet deliberately and it needs an attributed identity
reset before it works again.
The other column is enrolmentBinding, and it has three values rather than two.
device-id is a binding worth something: the cloud knows which physical box
answers for this serial, and an enrolment has to present the same one. unbound
means the cloud has not seen a device id from this cabinet, so an enrolment has
nothing to bind to and needs an arm. self-asserted is the one to actually worry
about — a device id that a fleet-secret call put there, which looks like
protection on a screen and is not, because whoever holds the secret is who
asserted it (app/device_credentials.py, _binding_label).
At the bench, read state and keyId and ignore this column. A cabinet reads
unbound until its first authenticated gateway call, which includes the moment
straight after a correct §1.2.1 run: the arm and the enrolment are both handed a
device id, but what binds the record is the cabinet reporting one for itself
(app/machine_identity.py, note_contact). So every correctly built unit reads
unbound here, and a bench that treats that as "needs arming" will re-arm and
re-enrol every cabinet it builds and collect a 409 for each. It turns device-id
on the first heartbeat, which is a check for after the cabinet is running rather
than one you can make here.
enrolmentBinding earns its place in the field runbook,
on a cabinet that has been beating for a while and still has not enrolled. It
tells you nothing on a unit that enrolled ninety seconds ago.
enrolmentArmed is the blunt one: non-null means the door is open on that cabinet
right now. Before a batch is crated it should be null on every row — the armed
count in the header is the same thing for the whole fleet, and a bench that leaves
it above zero has left windows open on units going into a truck.
reachableOnFleetSecret is not about your batch and is worth understanding before
it alarms somebody. It counts every un-enrolled machine record on the platform
that the gateway would still serve on the shared secret, ZHZN or not, whether or
not it is listed above. It is normally much larger than onFleetSecret, it is not
work the bench can do, and the reason it is published at all is in
the design.
1.2.3 What a 409 means, and what to do about it
A 409 from /zhzn/enrol is not a broken cabinet. It says this serial already
holds a credential, and there are only two ways that happens:
| Which one | How you can tell | What to do |
|---|---|---|
| This unit already enrolled — the bench ran twice, or the agent got there first on its own beat | the credential's keyId matches the one in this unit's /etc/zhzn-agent.env or /etc/zhzn-agent-device.key |
Nothing. The cabinet is enrolled; carry on to the acceptance tests |
| Somebody else enrolled this serial — the residual bootstrap window below | the unit holds no credential at all, or a different keyId |
Escalate to Kiosk-X. An admin clears the binding with POST /api/v1/machines/{no}/identity/reset, which takes a named signer and a reason, and then this unit enrols normally |
What used to cause the second row, and what stops it now. A cabinet that had neither enrolled nor ever reported a device id could be enrolled by anyone holding the fleet secret: there was nothing for the cloud to bind the enrolment to, so the binding check never fired and any claimant was accepted. That locked the real cabinet out and showed up as exactly this 409. It is closed — an enrolment now needs either a device id the cloud already trusts or an arm (§1.2.1), and the arm names whoever made it.
A 409 on a unit that has never enrolled is therefore much more likely to be an innocent duplicate than a theft. It is still not hardware: nothing about the board, the bus, the reader or the wiring produces it, so do not take the cabinet apart looking for a fault that is not in the building.
428 is the refusal the bench will actually meet, and on a new cabinet it is
the first one every time. It means nothing yet proves this claimant is this
cabinet — you skipped the arm in §1.2.1, or the 30-minute window lapsed while the
unit was on the bench. Arm it again and re-run the enrolment; the message names
the endpoint. It is deliberately not a 403, because the agent treats a 403 on
enrolment as revocation and stops asking for good, whereas it retries a 428.
Two further refusals from the same endpoint, so a bench does not confuse them with the 409:
403— either the credential was revoked (a revoked cabinet is never re-issued on request; it needsidentity/reset), or the deployment has closed the enrolment window withKIOSKX_ZHZN_ENROLMENT=closed, at which point a credential comes from an admin rather than from the fleet secret.429— this serial has spent its enrolment budget for the window. Refused attempts count too, deliberately, so a serial being hammered stops being answered. Wait, or ask Kiosk-X.
1.3 What the unit must never ship with
Never ship a cabinet already bound to an operator. Do not call
POST /api/v1/machines/register at the factory. Registration is what claims
a serial into a fleet (app/routes/machines.py:238-245), and once a serial
belongs to an operator, a different operator claiming it gets 409 Machine
number is already registered to another operator
(app/routes/machines.py:265). A cabinet crated for customer B but registered
to customer A cannot be fixed at the venue. It needs an ownership transfer
(app/transfers.py) driven from the platform side, which means the installer
stands next to a machine they cannot onboard. The correct state for a crated
cabinet is: no operator, no register call, nothing but a build record.
The same rule shows up in the build record's read model: access follows whoever
owns the machine in STORE.machines right now
(app/machine_build.py, visible_to), so a mis-registered cabinet also shows
its factory test history to the wrong customer.
Never ship two cabinets with the same identity. Two units answering to one serial is not a cosmetic problem, because the serial is the routing key for everything:
- The gateway resolves every call to one machine code
(
app/routes/zhzn.py,_device), so both cabinets share one machine record. Both heartbeats mark the same record online, so an offline cabinet looks healthy for as long as its twin is alive (app/routes/zhzn.py,heartbeat;app/store.py,Store.touch_machine). - They share one planogram and one stock count (
app/routes/zhzn.py,_slots_for), so a vend on one decrements the other's inventory and restocking is guesswork. - Remote-led dispenses are a per-machine queue leased on poll
(
app/routes/zhzn.py,get_pending). Whichever twin polls first takes the paid dispense, so a customer at cabinet A gets nothing while cabinet B drops a product on the floor in another city. - Both cabinets derive the same service code and setup-AP passphrase, since
both are HMACs of the fleet secret and the machine code
(
app/machine_network.py:178-211). Cabinet A's Wi-Fi code opens cabinet B.
The gateway now refuses the second cabinet. An agent sends a stable
per-installation deviceId (/etc/machine-id is the obvious source) on
register and heartbeat; the first one seen is bound to the machine record,
and a different device presenting the same serial is refused with 409 before
the record is touched at all (app/machine_identity.py:122-158):
Machine VM-2026-0001 is already bound to a different device. … If this is a
replacement control board, an admin must clear the identity first: POST
/api/v1/machines/VM-2026-0001/identity/reset. If it is a second cabinet, its
serial is wrong.
Refusing the newcomer rather than the incumbent is deliberate. The incumbent is the cabinet currently taking money against this record and attributing it correctly; the newcomer is the ambiguous one, and it is the one a human is standing next to — at your bench or on an installation — so the refusal lands where somebody can act on it. The conflict is also written onto the machine record, not just returned to the device, because a device-side error goes into an agent log nobody tails. The QA lead's view of every serial two cabinets are claiming:
curl -s -H "X-API-Key: $KIOSKX_ADMIN_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/identity/conflicts" | python3 -m json.tool
One caveat, stated rather than hidden: an agent build old enough not to send a
deviceId cannot be told apart from its twin by any means available to the
cloud, so no conflict is claimed for those cabinets. The identity read says
duplicateDetection: "unavailable" for them and publishes the number of times a
device has bootstrapped the record beside it — one is normal, five means the
cabinet keeps losing its state or is not one cabinet.
Never put the fleet secret anywhere a customer can read it. See §8 Handling the fleet secret.
2. What software actually goes on the head unit
2.1 The honest answer about "the APK"
There are two different artifacts and only one of them is an APK:
| Reyeah cabinets | ZHZN cabinets | |
|---|---|---|
| On-machine software | Android APK com.ruiye.jd |
Python agent, headless, plus an optional browser storefront |
| Drives the dispenser? | Yes, the APK contains the FF0055… drop frames |
No APK can. The agent drives AA…BB over /dev/ttyS4 |
| Distribution | S3 artifact, adb install first time, OTA after |
Copy the tree onto the box; signed OTA tarball after |
| Version pinning | rollout record: version + sha256 | release registry + rollout, or a per-cabinet pin (§2.5) |
So for a ZHZN cabinet there is no APK sideload step that makes the machine work. If the cabinet has a touchscreen you may also install the kiosk build of the web storefront in a browser, but that is a UI, not the dispenser driver.
There is no pre-flashed factory image in any of these repos. Nothing builds a flashable head-unit image, so the install below is what the factory does by hand (or bakes into its own golden image, which is the factory's own artifact and not versioned by this platform — see §9).
2.2 Installing the agent for the first time
From docs/vending-manufacturers-manual.md §8.2 and agent/config.py:
# 1. Put the tree at the install root the OTA swap expects (agent/config.py:78)
sudo mkdir -p /opt
sudo cp -r zhzn-vending-kiosk /opt/zhzn-vending-kiosk
cd /opt/zhzn-vending-kiosk/agent
sudo pip3 install -r requirements.txt
# 2. State and health directories (agent/config.py:64,138,140)
sudo mkdir -p /var/lib/zhzn-agent /run/zhzn-agent
# 3. The env file — copy the reference and fill the three required values
sudo cp docs/reference/zhzn-agent.env.example /etc/zhzn-agent.env
sudo chown root:root /etc/zhzn-agent.env
sudo chmod 600 /etc/zhzn-agent.env
sudo "$EDITOR" /etc/zhzn-agent.env
# 4. The release-signing PUBLIC key, and the boot guard outside the install
# root. Skip either and this cabinet can never be upgraded remotely: with no
# key the agent installs nothing, and with no guard a version that fails to
# start has nothing to put the working one back. Both are cheap here and a
# truck roll later.
# (the fleet key; deploy/zhzn-agent-release.pub.example says where it comes
# from and how to check its fingerprint)
sudo install -m 0644 zhzn-agent-release.pub /etc/zhzn-agent-release.pub
sudo mkdir -p /opt/zhzn-agent-guard
sudo install -m 0755 /opt/zhzn-vending-kiosk/agent/ota_guard.py \
/opt/zhzn-agent-guard/ota_guard.py
# 5. Run it under systemd with Restart=always — the OTA swap works by exiting
# and being relaunched on the new tree, and ExecStartPre runs the guard
# before every start
sudo cp zhzn-vending-kiosk/deploy/zhzn-agent.service \
/etc/systemd/system/zhzn-agent.service
sudo systemctl daemon-reload
sudo systemctl enable --now zhzn-agent
The public key is the whole trust anchor for remote upgrades. Its private half lives in the publishing repo's Actions secrets and nowhere else — not in the cluster, not in the artifact bucket — so an attacker who owns either of those still cannot make this cabinet run their code. Verify it is the fleet key before you ship a batch against it:
openssl pkey -pubin -in /etc/zhzn-agent-release.pub -outform DER \
| sha256sum # must match the fingerprint on the release key record
Confirm the agent came up and registered itself:
journalctl -u zhzn-agent -n 50 --no-pager
# expect: "zhzn register: serial=<plate serial> -> machine=<machine code>"
That log line is emitted by the cloud (app/routes/zhzn.py, register), so
seeing
its device-side counterpart plus a machineCode in
/var/lib/zhzn-agent/identity.json is the proof the whole chain worked.
If you did not enrol the unit by hand at
§1.2.1, it
does not enrol itself on first contact. Arm it, then it enrols. A unit
that ships with state: "fleet-secret" still boots and still sells; goods
and createOrder on that serial still answer on the shared secret until it
enrols. Confirm the fleet view in
§1.2.2 before the cabinet is crated.
2.3 If the cabinet is a Reyeah unit instead
The Kiosk X vending APK is real, published and versioned, so for completeness:
- Where the installer gets it. The current signed Reyeah build is on
/downloads— today that is 1.2.7, content-addressedhttps://media.intelli-verse-x.ai/downloads/reyeah-vending-kioskx-1.2.7-aaa3d485cbaa.apk(app/routes/downloads.py,REYEAH_APK). Do not install the mutablereyeah-vending-kioskx.apkkey; that is how a later rebuild of the same version stranded a live wave. The ZHZN button on the same page is 1.0.26. - How it is installed the first time.
adb, and the uninstall is not optional — Android refuses to update a factory-signed package with our release certificate:
bash
adb uninstall com.ruiye.jd
adb install -r reyeah-vending-kioskx-1.2.7-aaa3d485cbaa.apk
- How a version is pinned. By the filename (content-addressed per
release) and the rollout record.
POST /api/v1/rolloutscarriesversionandapkSha256, and the offer the device polls atGET /apk/getUpgradeVersioncarries both (app/routes/rollouts.py,app/routes/device.py). The unversioned S3 "latest" pointer is not the pin. - How it is integrity-checked. Two guards, both added after real incidents:
apkUrlmust behttps://— a plaintext URL, notably the S3 website endpoint, is MITM-swappable for a trojaned or downgraded build (app/routes/rollouts.py:86-89). A 64-character hexapkSha256is validated on the way in (app/routes/rollouts.py:90-92) and returned in the offer so the firmware can verify before installing.- The S3
downloads/prefix has exactly one owner per key. The kube-infra docs-publish job explicitly excludesdownloads/*, because a rebuild there once overwrote the live OTA APK with a stale pre-patch build (2026-08-11 19:16 UTC;.github/workflows/kiosk-x-build.yml:418-427and the removed-job note at:503-513). Never re-add an uploader. - The seeded fleet-wide offer. The rollout store is in-memory and the
backend redeploys often, so one canonical offer is re-armed at startup from
KIOSKX_SEED_APK_ROLLOUT_VERSION(default1.2.0,app/config.py:479) with an optional digest fromKIOSKX_SEED_APK_ROLLOUT_SHA256(app/config.py:507). Bump the version env alongside each APK release or physical machines keep being offered the old build.
2.4 What version the cabinet reports back
A ZHZN cabinet reports its own software version to the machine record, on registration and on every heartbeat, so a field update shows up without the cabinet re-registering:
POST /zhzn/register
{"equipmentNo": "VM-2026-0001", "agentVersion": "1.3.1", "vmcVersion": "v23_s26",
"deviceId": "6f1c…"}
| Field on the machine record | Written by | Notes |
|---|---|---|
software.agentVersion |
/zhzn/register, /zhzn/heartbeat |
The Python agent's version (app/store.py, Store.report_agent_version) |
software.platform |
the same calls | zhzn once a cabinet has ever reported; reyeah otherwise |
software.vmcVersion |
the same calls, or /apk/validVmcVersion on Reyeah |
Controller firmware as it is running now |
software.apkVersion |
/apk/apkUp only |
Left null on ZHZN cabinets on purpose |
The agent version deliberately does not land in apkVersion. A ZHZN cabinet
has no APK on it at all, and writing one there would have the console report a
version of software that is not on the box — which is a worse failure than the
blank it replaced, because it looks like an answer. The console reads
software.platform and labels the row "Agent software" for a ZHZN cabinet and
"On-machine app" for a Reyeah one, and the shipped-versus-live comparison now
fires on both halves: controller firmware and on-machine software.
Set ZHZN_SERIAL_NUMBER and let the agent report; there is nothing for the bench
to do here beyond recording shippedFirmware in §5 so there is something to
compare against.
2.5 Agent upgrades after the factory
The version you install above is a starting point, not a commitment. CI builds a signed tarball per release and the cabinet upgrades itself, so a fix does not cost a drive to a venue. What the factory owes the fleet is only the two things in step 4 — the public key and the boot guard. Everything after that is remote.
This section is what a bench operator needs to know about upgrades: that the two files in step 4 are what make them possible at all, and what the cabinet does with an offer. Whoever publishes and aims a release wants Upgrading the agent on ZHZN cabinets, remotely instead — key generation and rotation, building and publishing the artifact, staging a rollout, and how to abort one.
The artifact. zhzn-agent-<version>.tar.gz under
https://media.intelli-verse-x.ai/docs/agent/, built deterministically
so the digest can be reproduced from the tag, plus a .release.json carrying a
detached signature over {version, sha256, size, url, migration, minFrom}.
Publishing is not deploying. A published release is offered to nobody until a rollout names it. That separation is the point: the build that lands on main is never, by itself, the build the fleet installs.
A release whose floor nothing can climb to is refused at publish time.
minFrom is the lowest version a release may be installed over, and it is a
floor the release needs but cannot create for itself: some other release has to
put the cabinet on a rung at or above it first. If no such rung exists, both
answers available at offer time are bad — hand the cabinet an install that is
guaranteed to fail in the venue, or hand it nothing and leave it stuck with no
signal — so the decision was moved to the one moment with a person in it and the
whole release set in view. Two shapes come back 400:
- A floor at or above the release's own version (
2.0.0withminFrom: 2.0.0). The ladder would have to stand the cabinet on a rung in an empty interval, so nothing published later can ever rescue it. Always refused. - A floor with nothing published between it and the release, while something
is already published below the floor (
2.0.0withminFrom: 1.5.0, over a registry holding1.3.0and nothing at or above1.5.0). Those1.3.0cabinets have a rung to stand on and no way across the gap. The message names the floor and the version it stranded; publish the rung first.
What that deliberately still permits, so the check does not become a publishing
outage: no floor at all; a floor at or below the oldest published release, which
is the ordinary case because a floor usually names a build already in the field
rather than a rung the ladder needs to insert; and publishing out of ascending
order — 1.5.0 with minFrom: 1.4.0 goes through before 1.4.0 exists,
because until something is published below the floor "not yet reachable" is a
different claim from "unreachable". CI re-arming the registry after a redeploy
publishes into an empty registry and is never caught by this.
The rule guards the publish path and nothing else: it is not an invariant over
the registry's lifetime. A release that was reachable when it was published
becomes unreachable if the rung beneath it is later removed from the registry —
the peer-sync delete path and a sandbox reset can both do that. That residue
is why the cabinet checks the floor as well; see A cabinet below a release's
floor below.
# what exists, and what the fleet is running
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/agent/releases"
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/agent/fleet"
# prove it on three cabinets first
curl -sX PUT -H "X-API-Key: $ADMIN_KEY" -H 'content-type: application/json' \
-d '{"targetVersion":"1.4.0","phase":"rolling",
"canary":["VM-2026-0001","VM-2026-0002","VM-2026-0003"],"percent":0}' \
"$BASE/api/v1/agent/rollout"
# then widen it
curl -sX PUT -H "X-API-Key: $ADMIN_KEY" -H 'content-type: application/json' \
-d '{"targetVersion":"1.4.0","phase":"rolling","percent":25}' \
"$BASE/api/v1/agent/rollout"
# the brake — cabinets already on the target keep it, nobody else is offered it
curl -sX POST -H "X-API-Key: $ADMIN_KEY" -H 'content-type: application/json' \
-d '{"reason":"canary 0002 is not reporting"}' \
"$BASE/api/v1/agent/rollout/hold"
Pinning, which this runbook already described for the APK, now means the same thing for the agent. A pin exempts one cabinet from the rollout in both directions: it is neither dragged forward nor left behind.
# hold a cabinet where it is, or walk it back down deliberately
curl -sX POST -H "X-API-Key: $ADMIN_KEY" -H 'content-type: application/json' \
-d '{"version":"1.3.0","reason":"1.4.0 stalls lane 7 on rev-B boards"}' \
"$BASE/api/v1/machines/VM-2026-0001/agent/pin"
# and what this one cabinet will be offered next time it polls
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/machines/VM-2026-0001/agent"
A pin below the running version is the deliberate rollback. The automatic revert below catches a build that will not start; only a person can catch one that starts fine and behaves badly.
What the cabinet does with an offer, in the order that matters:
| Step | Refusal here means | Reference |
|---|---|---|
Verify the signature against /etc/zhzn-agent-release.pub |
nothing is downloaded at all | agent/ota.py check_offer |
| Check the URL is under the trusted prefix | a tampered offer cannot aim the fleet elsewhere | same |
Check the signed minFrom against AGENT_VERSION |
a cloud bug that offers a release this cabinet cannot install costs a log line, not a truck roll | same |
| Wait for a quiet window | no restart between taking money and dropping product | agent/agent.py quiescent |
| Check the digest of the downloaded bytes | a truncated or swapped artifact never reaches an extract | agent/ota.py apply_update |
| Extract beside, swap with two renames | a failure before the swap leaves the working tree untouched | same |
| Reach the cloud, or be reverted | a version that cannot come up puts the old one back by itself | agent/ota_guard.py |
The last row is the one worth internalising at a factory bench: a cabinet is never left running a version it could not prove. After three failed starts, or after five minutes of running without reaching the cloud, the previous tree comes back and the machine keeps selling. Exactly one previous tree is kept — these are 8-16 GB boards and a filled disk is its own outage.
A cabinet that never goes quiet. Row three is a real reason a rollout stalls:
a cabinet only restarts for an upgrade with the vend lock free, nothing leased in
the dispatch queue, an empty report spool and three minutes since the last
dispense. A busy machine can decline every offer indefinitely, and the only other
symptom is a version that never moves — the same thing a cabinet outside the
cohort shows. So the cabinet reports what it is declining, for how long and why,
on its heartbeat, and the fleet view carries it per machine plus a deferring
count:
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/agent/fleet" \
| jq '.data | {deferring, machines: [.machines[] | select(.deferring)]}'
There is deliberately no way to make a cabinet restart anyway. A restart between taking money and dropping product either keeps the money or loses the sale, so the remedy for a permanently busy cabinet is a quieter window or a visit — not an override. A row that has been deferring for days is the signal to choose one.
A cabinet below a release's floor. Row three above is the cabinet's own copy
of the minFrom check: it holds its AGENT_VERSION and the floor inside the
verified manifest, so it compares them itself rather than assuming the cloud
did. The floor is read after the signature verifies and never before —
minFrom is inside the signed fields precisely so it is the publisher's
assertion, and a floor read from an unverified offer would turn this safeguard
into a way for anyone who can rewrite an offer to talk a cabinet out of every
upgrade it is ever sent. Comparison is semantic, so 1.10.0 is above 1.9.0,
and a cabinet standing exactly on the floor installs normally — a refusal is
only correct when the floor is genuinely above the running version.
A refusal is not an error and not a crash: the cabinet goes on vending on the
version it has and asks again at the next interval. It reports the refusal on
its heartbeat in otaBlocked, which is a separate field from otaDeferred
and a separate column in the fleet view, because the two need opposite
responses. A deferral ends by itself the first night the venue is quiet, and
waiting is a real remedy. A cabinet below a floor refuses the same offer at 3am
exactly as it does at 3pm, forever, and the only thing that changes it is on
this side — publish the rung it can climb to, or aim the rollout somewhere
within its reach.
# the two stalls, told apart: `blocked` never fixes itself, `deferring` does
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/agent/fleet" \
| jq '.data | {blocked, deferring,
stuck: [.machines[] | select(.blocked)
| {machineNo, agentVersion,
needs: .blocked.minFrom, offered: .blocked.version,
refusals: .blocked.count,
since: .blocked.firstReportedAt}]}'
# and the per-machine read, which shows the live offer beside the refusal
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/machines/VM-2026-0001/agent" \
| jq '.data | {agentVersion, offer: .offer.version, blocked, deferring}'
A row in blocked reads: this cabinet is on agentVersion, it is being offered
blocked.version, and that release will not install below blocked.minFrom.
The fix is a publish or a rollout change, never a visit.
Which duration to trust. count and seconds are measured in the agent
process and restart with it, so a small number means "since this agent last
started", not "since this began". firstReportedAt — on both blocked and
deferring — is held by the cloud from the first beat carrying that block and
kept for as long as the version, and the floor, do not change. That is the
restart-proof answer to "how long has this cabinet been stuck", and it costs the
cabinet nothing: persisting the counter on the machine instead would have put a
durable write on an eMMC board on a path that currently touches nothing, to
recover a number the cloud can hold for free.
3. VMC / MDB pairing
Do this before the acceptance tests, because six of the ten depend on it.
- Dispenser bus. RS232
GND / TXD / RXDfrom the head unit to the CSM dispenser COM. 9600 8N1 (agent/config.py:220-221). The vendor demo uses/dev/ttyS4; the physical COM ↔/dev/ttySNmapping is board-specific and is one of the items the manufacturers manual marks confirm with hardware. - Board type.
ZHZN_MODULE=SH_YAxisfor a Y-lift gantry (frames carry an XOR check byte) orSHfor a spring control board (no check byte) —agent/config.py:222,agent/config.py:241-243. The wrong value presents asno_ack, indistinguishable from a wiring fault, so set it deliberately. - Handshake.
ZHZN_INIT_ON_START=1(the default,agent/config.py:224) makes the agent send one board Init (0x02) at startup before any dispense. A successful Init is what thevmcacceptance test observes; the lifecycle test asserts exactly this (tests/test_zhzn_lifecycle.py:202-203). - Grid geometry.
ZHZN_COLUMNS_PER_ROWmust match the physical grid.aisle = (row - 1) * columns_per_row + column(agent/config.py:246-254), and the cloud derives the same mapping when it serves the planogram (app/routes/zhzn.py,_aisle_to_slot). Get this wrong and every purchase lands on the wrong lane. The cloud's default is 10, whichtests/test_zhzn_lifecycle.py:201asserts a factory-fresh machine receives. - Payment bus. Reserve a second COM for the MDB-RS232 adapter that
carries the Nayax reader — separate from the dispenser COM. Adapter serial
config is 9600 8N1, HEX
(
docs/vending-manufacturers-manual.md§7). On a ZHZN cabinet the adapter is the MDB master, not the VMC. - Y-lift calibration. Run step calibration (
0x03/0x07, saved with the0xFFlane) so aisles land where they should. Per-machine calibration values are hardware, not code.
4. The ten acceptance tests
The set is closed on purpose: a factory that omits the dispense test cannot
quietly sign off a cabinet that never vended
(app/machine_build.py, TEST_CODES and REQUIRED_TESTS). Read the live list
from the API rather than trusting a printout:
export KIOSKX_API_BASE=https://api.kiosk-x.ai
export KIOSKX_FACTORY_KEY=<your plant's manufacturer API key> # never commit this
curl -s -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/build/tests" | python3 -m json.tool
Every call in §4–§6 uses your plant's manufacturer credential, not a full-admin key. See §4.2 The credential your plant holds.
| Code | Bench | Required? | What a pass means on a ZHZN cabinet |
|---|---|---|---|
power |
Power-on self test | yes | Cabinet boots to the storefront unaided after a cold mains cycle. Set the VDC-SEL jumper for auto-power-on after outages, or a venue power cut leaves it dark. |
vmc |
VMC board handshake | yes | The board Init (0x02) is acknowledged on the dispenser COM. |
mdb |
MDB bus enumeration | yes | The MDB-RS232 adapter enumerates the peripherals actually fitted. |
reader |
Card reader / cashless | yes | The reader shows an amount when a sale starts. Cellular "Online" plus a green LED proves the modem only — a reader can be online and completely absent from MDB. |
dispense |
Lane dispense, every lane | yes | Every lane, not a sample. Each lane's aisle number matches (row-1)*columns+column. |
coin |
Coin mechanism | no | not_fitted on a cabinet with no coin mech. |
bill |
Bill validator | no | not_fitted on a cabinet with no validator. |
cooling |
Refrigeration pulldown | no | not_fitted on an ambient cabinet. |
network |
Connectivity (Wi-Fi / LTE) | yes | The cabinet reached the cloud, not merely associated. A registration in the log is the proof. |
screen |
Touchscreen and display | yes | Touch works across the panel. A ZHZN cabinet's screen is load-bearing for field Wi-Fi setup. |
Cash handling and refrigeration are optional on every configuration, because a
card-only ambient cabinet legitimately has no coin mech to test
(app/machine_build.py, REQUIRED_TESTS).
Four result words, and the difference between two of them matters.
| Result | Means | Effect on the sign-off |
|---|---|---|
pass |
The bench ran and the subsystem worked | Satisfies the requirement |
fail |
The bench ran and the subsystem did not work | Blocks the sign-off, by name |
skip |
The subsystem is fitted and was not tested — rig down, container waiting | Still blocks the sign-off if the subsystem is required |
not_fitted |
The cabinet was built without this subsystem | Drops it from the required set for this cabinet |
A subsystem that does not exist has not been skipped, and must not read as
tested. Keeping the two words apart is what stopped a cash-only cabinet from
being signable only by recording a reader result that never happened — the
sign-off has a named signer on it, and a record that forces one false entry has
no claim on being believed about the others.
not_fitted is accepted only for mdb, reader, coin, bill and
cooling (app/machine_build.py, FITTABLE). Those are the line items a real
order
actually specifies away. Anything else — power, vmc, dispense, network,
screen — is refused with 400, because a machine with no dispenser is not a
vending machine and a claim of absence there is a bench trying to skip a bench.
The required set as it applies to the cabinet in front of you is stated on the
record, so you never have to derive it: testSummary.requiredForThisMachine,
alongside notFitted and skipped as separate lists
(app/machine_build.py, required_tests and test_summary).
4.1 Recording each result
One call carries as many results as you have:
curl -s -X POST -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
-H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build/tests" \
-d '{
"suite": "ZH-FAT-2026.1",
"results": [
{"code": "power", "result": "pass", "testedBy": "L. Zhou"},
{"code": "vmc", "result": "pass", "testedBy": "L. Zhou"},
{"code": "mdb", "result": "pass", "testedBy": "L. Zhou"},
{"code": "reader", "result": "pass", "testedBy": "L. Zhou"},
{"code": "dispense", "result": "pass", "testedBy": "L. Zhou",
"detail": "60 of 60 lanes"},
{"code": "network", "result": "pass", "testedBy": "L. Zhou"},
{"code": "screen", "result": "pass", "testedBy": "L. Zhou"},
{"code": "coin", "result": "not_fitted", "detail": "No coin mech fitted"},
{"code": "bill", "result": "not_fitted", "detail": "No validator fitted"},
{"code": "cooling", "result": "pass", "testedBy": "L. Zhou",
"detail": "4C in 41 min"}
]
}'
Rules worth knowing before you use it:
- An unknown code is refused, and the error names the codes it will accept
(
app/machine_build.py,record_tests). A typo does not silently vanish. - A retest supersedes without erasing. Post
dispense: fail, repair the lane, postdispense: pass. The latest result decides the verdict; both rows stay in the trail, which is the record of why the cabinet has a replaced motor (app/machine_build.py,latest_results). - Results cannot be added after the factory has signed. The API returns 409
and tells you to raise a field service record instead, because a new result
would change what was signed (
app/machine_build.py,record_tests). - A correction can move in either direction. A subsystem recorded
not_fittedin error and then found to be fitted goes back to owing a result; both entries stay in the trail. - The operator cannot write these. Recording factory data needs a
manufacturer or full-admin credential; the owner of the cabinet gets 403
(
app/routes/machine_build.py,_require_factory_writer). A build record the owner can edit proves nothing about the factory.
4.2 The credential your plant holds
Your plant is issued a manufacturer API key, not a platform admin key. The distinction is not bureaucratic: a full-admin key reads and writes every operator's data on the platform, and that is not an acceptable thing to email to a third-party factory.
The key carries exactly one scope, machines:build, and is pinned to your
manufacturer name (app/config.py:776-787, app/auth.py:54-79).
What it can do, for serials whose build record names your plant:
PUT /api/v1/machines/{no}/build— write the build record. Yourmanufactureris stamped on it from the key, so you cannot label a cabinet as another plant's build. The same PUT carries crate shipping (shipping.carrier,trackingNumber, ship-to). That is the manufacturer.kiosk-x.ai contract: there is no second API. The operator and the super-admin Factory tab read the same record.POST /api/v1/machines/{no}/build/tests— post bench results.POST /api/v1/machines/{no}/build/signoff— take the factory QA signature.GET /api/v1/machines/build/testsandGET …/{no}/build— read the catalog and your own cabinets' records.POST /api/v1/machines/{no}/device-credential/armand.../disarm— open and close a cabinet's enrolment window, which is what §1.2.1 needs. Bounded to a cabinet nobody owns yet: once a serial is in an operator's fleet the arm has to come from them, so a leaked factory key buys cabinets with no books on them rather than somebody's revenue. Where a build record already names a manufacturer it has to be yours (app/routes/machine_identity.py,_may_arm).
What it cannot do, each of which is covered by an explicit test in
tests/test_factory_credential.py:
- Read any operator's books, expenses, P&L, payouts or balance sheet.
- List operators, or list machines across the fleet.
- Claim a machine into a fleet (
POST /api/v1/machines/register). - Touch another manufacturer's cabinet, or relabel a cabinet as another manufacturer's build.
- Sign off a serial no build record names your plant for.
- Arm a cabinet that is already in an operator's fleet.
- See or write the
acquisitionblock — what the operator paid for the cabinet is stripped from your read of the record entirely. - Accept delivery on the operator's behalf.
- Reset a machine's device identity.
Set it as KIOSKX_FACTORY_KEY; the bench script reads that variable first and
falls back to KIOSKX_ADMIN_KEY only for platform-side use.
5. The build record
What was built, by whom, on what. Keyed by serial so it survives the purchase
order being archived (app/machine_build.py, _blank).
The cabinet has to exist as a machine record first. The build record hangs off
one, so PUT …/build on a serial the platform has never seen is a 404, not a
create — and so are …/build/tests and …/build/signoff. Power the unit on and
let it make its own POST /zhzn/register
(§2.2) before
you write the record; that is what auto-provisions the row
(app/store.py, Store.auto_provision_machine). On a normal bench this happens
by itself, because the agent is installed long before the paperwork is written —
it bites a plant that records paperwork first, or reruns against a fresh serial.
§7's script makes the register call for
you when the serial is unknown.
Do not reach for POST /api/v1/machines/register to make the row appear.
That is the operator claim, and §1.3 is
why.
curl -s -X PUT -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
-H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build" \
-d '{
"manufacturer": "zhzn",
"model": "T3568-6x10",
"orderRef": "PO-2026-0412",
"batchRef": "ZH-2026-04",
"boardRevision": "T3568-rev-C",
"factory": "Ningbo line 2",
"builtAt": "2026-04-02",
"shippedAt": "2026-04-05",
"shippedFirmware": {"vmcVersion": "v23_s26", "apkVersion": "1.3.0"}
}'
| Field | Notes |
|---|---|
manufacturer |
Closed set: hotbox, reyeah, zhzn (app/machine_build.py, MANUFACTURERS). Anything else is refused with the list. |
model, orderRef, batchRef |
The batch is how a bad component is traced across a run. Fill it even when it feels redundant. |
boardRevision |
The head-unit / controller revision. This is what the board manufacturer asks for when a cashless parameter question comes up. |
factory, builtAt, shippedAt |
Free-text factory/line and ISO dates. |
shippedFirmware.vmcVersion |
Controller firmware as shipped. Deliberately distinct from the machine's live software block, and the gap between them is how a field update is proven (app/machine_build.py, upsert_build). |
shippedFirmware.apkVersion |
On-machine software version as shipped. On a ZHZN cabinet, record the agent version (agent/version.py AGENT_VERSION, 1.3.0 at the time of writing). The cabinet now reports its own agent version from the field (see §2.4), so this value is the other half of a comparison that actually fires: the console shows it as "Agent software at ship" against "Agent software now". |
Only the keys you send are written, so a later call reporting just a ship date
does not blank the board revision (app/routes/machine_build.py, BuildIn).
The same PUT also accepts an acquisition block — cost, funding, supplier,
useful life (app/routes/machine_build.py, AcquisitionIn). A factory does
not fill it. That is what the operator paid for the cabinet and how it
depreciates on
their books, and it is on this endpoint only because both live on one record.
Sending it from the line would write a purchase price the factory does not know.
Because the write is a partial update, omitting it leaves any existing
acquisition data untouched.
The record's state is derived, not stored: recorded → testing →
signed_off → accepted (app/machine_build.py, _view). It appears in the
operator console as the Build & factory QA card
(kiosk-x-operator/components/MachineBuildCard.tsx), which is where the
operator sees your test results and the failure text you wrote.
6. Factory QA sign-off
curl -s -X POST -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
-H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build/signoff" \
-d '{"by": "L. Zhou", "role": "Line QA", "note": "Crated for freight"}'
It is blocked, deliberately, in three cases. A signature on an incomplete
suite is worse than no signature, because it looks like assurance
(app/machine_build.py, factory_signoff):
| Refusal | Status | Message |
|---|---|---|
| A required test has no result on file | 409 | Cannot sign off: no result recorded for dispense, screen — it names them, so you know which bench the cabinet goes back to |
Any subsystem's latest result is fail |
409 | Cannot sign off: failing reader |
| Already signed | 409 | Already signed off by <name> on <date> — sign-offs are append-only; a second one would rewrite who vouched for the cabinet |
Plus 400 if by is blank: a sign-off needs the name of the person signing
(app/machine_build.py, factory_signoff).
Check readiness before you try. testSummary.readyForSignoff is the same
precondition the guard uses, stated once so the API, the console and the guard
cannot disagree (app/machine_build.py, test_summary):
curl -s -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["testSummary"])'
What the factory does not do is accept the machine. Operator acceptance is a
separate signature that only the operator's own credential can make — a
full admin attempting it gets 403 (app/routes/machine_build.py, post_accept).
Your sign-off is the precondition for theirs (app/machine_build.py,
operator_acceptance).
Fleet view of everything still open. This one is platform-side: it spans
operators, so it needs machines:read and a manufacturer key does not carry it
(app/routes/machine_build.py, build_summary). Ask Kiosk-X for it, or read
your own cabinets one at a time.
curl -s -H "X-API-Key: $KIOSKX_ADMIN_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/build/summary" \
| python3 -c 'import json,sys; d=json.load(sys.stdin)["data"]; \
print("by state:", d["byState"]); print("failing:", d["failing"]); \
print("awaiting acceptance:", d["awaitingAcceptance"])'
7. The whole bench run as one command
scripts/factory_provision_machine.py does §4–§6 in the order the API demands,
and prints the rule that fired if a step is refused. It carries no secrets: the
key comes from the environment.
export KIOSKX_API_BASE=https://api.kiosk-x.ai
export KIOSKX_FACTORY_KEY=<your plant’s manufacturer API key>
python3 scripts/factory_provision_machine.py VM-2026-0001 \
--manufacturer zhzn --model T3568-6x10 \
--order-ref PO-2026-0412 --batch-ref ZH-2026-04 \
--board-revision T3568-rev-C --factory "Ningbo line 2" \
--built-at 2026-04-02 --shipped-at 2026-04-05 \
--vmc-version v23_s26 --apk-version 1.3.0 \
--pass-all --not-fitted coin --not-fitted bill --suite ZH-FAT-2026.1 \
--signed-by "L. Zhou" --role "Line QA" --note "Crated for freight" \
--signoff
A cabinet that failed a bench, recorded honestly and not signed:
python3 scripts/factory_provision_machine.py VM-2026-0002 \
--manufacturer zhzn --pass-all \
--fail dispense="Lane 7 motor stalls" \
--signed-by "L. Zhou" --signoff
# → NOT READY TO SHIP: failing: dispense — repair and retest
# → exit 1, and the sign-off is never attempted
Then after the repair, the retest and the signature — --pass names only the
bench that ran again, and the later result supersedes the failure:
python3 scripts/factory_provision_machine.py VM-2026-0002 \
--pass dispense --signed-by "L. Zhou" --role "Line QA" --signoff
The script never invents a pass. Without --pass-all only the subsystems you
name are reported, and the sign-off then fails the required-test guard, which is
the correct outcome. Feed real bench output with --results-file when the rig
can produce JSON.
--not-fitted and --skip are different claims and the script keeps them
apart: --not-fitted coin says this cabinet has no coin mech, while --skip
coin says it has one the bench did not run. Only mdb, reader, coin,
bill and cooling may be declared absent, and the script refuses anything
else before it writes a single record.
This was verified end to end against a local API on 2026-08-30: a build
record written, an incomplete suite correctly refusing the sign-off, a retest
superseding a failure, a full-green sign-off accepted, a second signature
refused with Already signed off by L. Zhou, and operator acceptance flipping
the record to accepted.
7.1 Enrolling from the bench script
--enrol does §1.2.1
as part of the same run: register the serial if the cloud has never seen it, arm
the cabinet, then enrol it. The three are one flag because the order matters and
getting it wrong produces a 404 or a 428 that looks like a fault.
python3 scripts/factory_provision_machine.py VM-2026-0001 \
--manufacturer zhzn --enrol --device-id "$(cat /etc/machine-id)" \
--pass-all --signed-by "L. Zhou" --role "Line QA" --signoff
Pass --device-id when the script runs on the head unit, which is the usual
case: the arm is then pinned to that box and cannot be spent by anything else,
even inside its window.
The script prints the keyId:deviceKey pair once, and the two lines that write
it into the unit and its rescue file. Nothing on the platform can print it again
— that is the point of it — so a run whose output is lost means an
identity/reset at the cloud end, not a second enrolment.
If the enrolment is refused the script says which rule fired and carries on with
the build record rather than aborting the bench. A 428 there means the arm did
not take or had already lapsed; re-run with --enrol alone.
A cabinet crated un-enrolled is still shippable, but it no longer enrols
itself off the fleet secret when it reaches a venue — somebody has to arm it in
the field (field runbook §4.2). That is the cost of
--enrol being opt-in, and it is the argument for using it on every unit.
8. Handling the fleet secret
ZHZN_DEVICE_SECRET is the fleet bootstrap secret. Against a cabinet that
has enrolled (§1.2.1)
it cannot authenticate gateway calls — _device() will not accept it for a machine that holds an
active credential — but against a cabinet that has not, it is still the entire
device credential, and it is one string that covers every un-enrolled cabinet on
the platform. The gateway risks below concern un-enrolled cabinets. Local Wi-Fi
codes remain derived from the shared secret even after enrolment when that
secret is still provisioned on the unit; enrolment does not rotate those codes.
What someone with the fleet secret can do to an un-enrolled cabinet, using only a serial:
- Register a machine that does not exist.
POST /zhzn/registerwith any well-formed serial auto-provisions a machine record (app/routes/zhzn.py,register;app/store.py,Store.auto_provision_machine), becauseKIOSKX_AUTO_PROVISIONdefaults to true (app/config.py:100). - Read any machine's planogram, pricing and stock levels —
GET /zhzn/goods(app/routes/zhzn.py,get_goods). - Forge dispense outcomes.
POST /zhzn/reportis forwarded to the live Intelliverse endpoints and, when the report carries anorderId, settles the order — which on a failure releases stock and triggers the refund path (app/routes/zhzn.py,reportand_settle_local_order). A forged failure is a forged refund. - Fake presence.
POST /zhzn/heartbeatmarks any machine online, so a dark cabinet can be made to look healthy (app/routes/zhzn.py,heartbeat). - Compute every cabinet's Wi-Fi service code and setup-AP passphrase. Both
are HMACs of the fleet secret and the machine code
(
app/machine_network.py:161-211), and the agent verifies them offline. With the secret, anyone standing at any cabinet in the fleet can unlock its local admin screen and mint a valid provisioning token (agent/network.py:124-151) — meaning move that cabinet onto a network of their choosing. - Steal queued venue Wi-Fi passphrases.
GET /zhzn/networkreturns a queued change's passphrase in clear over the device channel (app/routes/machine_network.py:237-247). - Read the planogram of, and open real orders on, any cabinet that has not
enrolled.
/zhzn/goodsand/zhzn/createOrderare served to the fleet secret for any un-enrolled machine — product names, prices, stock levels, and orders that land on that cabinet's operator's books. No enrolment is involved, so the arm does not close it. This is the residual the migration leaves open per cabinet, and it closes for good the first time that cabinet enrols, which is the argument for §1.2.1 happening on the bench. - It can no longer enrol a cabinet out from under its owner. That needed a cabinet with no device id on file and now needs an arm as well, which is signed and short-lived. The 409 in §1.2.3 is what it used to look like.
Rotating it rotates every service code and setup-AP passphrase in the fleet, so a rotation invalidates every printed or cached code at once.
Delivering it to a factory. Out of band, to a named person, never in the
same channel as the build paperwork or the serial list, and never in a file that
gets committed. Prefer a per-factory secret if the deployment supports more than
one, so a compromise at one plant does not cover the whole fleet. The reference
configuration ships the literal string REPLACE_ME_WITH_THE_FLEET_SECRET
precisely so a half-provisioned unit is obvious rather than subtly wrong.
The bootstrap path fails closed. With no KIOSKX_ZHZN_DEVICE_SECRET
configured, a fleet-secret request returns 503 ZHZN gateway disabled (no device
secret configured) (app/routes/zhzn.py, _device). Valid per-device keys are
checked first and do not require that fleet secret. An invalid key is refused
without falling back to the shared secret. On the legacy path, a wrong secret
is 401, and a valid secret with no equipmentNo is 400
(tests/test_zhzn_lifecycle.py:336-343).
Consider KIOSKX_ZHZN_STRICT_MAP=true (app/config.py:91) for a production
fleet. In allowlist mode only serials present in KIOSKX_ZHZN_MACHINE_MAP may
register, so a leaked secret can no longer conjure new machines — it can only
impersonate ones you already own.
9. What is not yet automated
Real gaps. Each is something this runbook would otherwise want to tell you to do, and cannot.
- No pre-flashed image, and no image versioning. Nothing in these repos
builds a flashable head-unit image. A factory that bakes a golden image is
producing an artifact this platform does not track, so two "identical"
batches can differ silently. Note that a cloned golden image with a
hard-coded
ZHZN_SERIAL_NUMBERis now caught by duplicate-identity detection (§1.3) rather than silently sharing a machine record — but only for agent builds that report adeviceId. - No API adds a serial→machine-code mapping.
KIOSKX_ZHZN_MACHINE_MAPis deployment config (app/config.py:68), so a serial that must map to a different machine code needs a config change and redeploy. An unmapped serial uses itself as the machine code, which is why pre-staged codes work without one. - No manufacturer webhook. A manufacturer credential now exists (§4.2), so a factory writes its own records over the normal API. What still does not exist is a push channel: nothing lets a plant's MES post a batch of results without holding an API key and driving the three calls itself.
- Identity binding depends on the agent volunteering a
deviceId. Older agent builds send none, and for those cabinets the cloud cannot tell an incumbent from a twin, so duplicate detection reportsduplicateDetection: "unavailable"rather than a clean bill of health. A per-machine device credential is supported by the reviewed Python agent; enrolment and the fleet checks in §1.2 are required to establish that a particular cabinet is using it. Older installed builds need separate review.
Next: Receiving a cabinet for installation · Vending manufacturers manual · Changing a machine's Wi-Fi · APK rollouts