# ───────────────────────────────────────────────────────────────────────────── # /etc/zhzn-agent.env — reference configuration for the Python ZHZN/CSM agent # ───────────────────────────────────────────────────────────────────────────── # # This file is for the Linux/Python agent, not the ZHZN Android APK. Follow # docs/zhzn-android-install.md for an Android head unit running the APK. # Credential and local-setup notes were checked against Python source # 8344f108ac1ae2a68b5ccdc7466bda059b58a3ce; an older installed agent needs review. # Defaults and required placeholders below come from these sources: # # * zhzn-vending-kiosk/agent/config.py — the agent's own env contract # * zhzn-vending-kiosk/agent/network.py — the Wi-Fi provisioning constants # # Other values come from the cloud rather than this file: see # "Pulled from the cloud" at the bottom. # # Lines that are COMMENTED OUT are already correct at their default and only # need touching for a non-standard cabinet. Lines that are LIVE must be set: # the agent will not work without them. # # Copy to /etc/zhzn-agent.env on the head unit, chmod 600, chown root:root. # It can hold bootstrap and per-cabinet credentials plus the kiosk UI's # dispense token. Never copy a configured file or state directory between units. # # Verified against docs/zhzn-factory-install.md and enforced by # tests/test_zhzn_first_install_runbooks.py, which fails if any default below # drifts away from the code. # ── 1. Which cloud, and how the machine authenticates to it ────────────────── # config.py:31 — DEFAULT IS "direct", WHICH IS WRONG FOR THIS FLEET. # "direct" makes the agent call the Intelliverse product/user services itself # with a Cognito kiosk_user session. "kioskx" points it at the kiosk-x device # gateway (/zhzn/*), bootstrapping with the fleet secret and then using its # per-cabinet credential after enrolment. This is the supported path and # the one the whole lifecycle test exercises. Always set this explicitly. ZHZN_CLOUD=kioskx # config.py:32-33 — the gateway host. Default is the production cloud; override # only for a factory bench pointed at a staging deployment. #ZHZN_KIOSKX_BASE_URL=https://kiosk-x.intelli-verse-x.ai # config.py — THE BOOTSTRAP FLEET SECRET. An un-enrolled cabinet uses the # `secret` header. Enrolment requires an authorized, bounded arm or a trusted # existing device binding. Once enrolled, the cabinet uses its own device key; # the gateway refuses the shared secret for that cabinet. Preserve the agent's # credential/witness state across updates and do not copy it to another unit. # The shared secret still reaches un-enrolled cabinets; protect it and follow # docs/zhzn-device-credentials.md and the first-install runbooks. # # PLACEHOLDER. Replace with the value delivered out of band by the platform # team for a bootstrap installation. This is not a real working credential. ZHZN_DEVICE_SECRET=REPLACE_ME_WITH_THE_FLEET_SECRET # agent/config.py:Config.enrol — default on. With no key, the agent requests # /zhzn/enrol under the platform's arming/binding and admission rules. Do not # mistake registration or an online heartbeat for successful enrolment. #ZHZN_ENROL=1 # agent/config.py:Config.device_credential — default empty. Optional per-unit # keyId:deviceKey provided by an authorized enrolment; never a fleet-wide value. #ZHZN_DEVICE_CREDENTIAL= # agent/config.py:Config.device_credential_file — optional rescue-file location. #ZHZN_DEVICE_CREDENTIAL_FILE=/etc/zhzn-agent-device.key # agent/device_key.py:resolve — precedence is state_dir/device.key, then # ZHZN_DEVICE_CREDENTIAL, then the rescue file. Persisted rotation takes # precedence over a factory seed. Do not delete state to retry enrolment. # See docs/zhzn-device-credentials.md and the factory/field enrolment checks. # # A provisioned per-cabinet key can authenticate cloud polling with an empty # ZHZN_DEVICE_SECRET. Local Wi-Fi setup is different: the current Python agent # derives its service code from the nonempty shared secret and machine code. # Empty/whitespace secret => local /provision returns 403 without radio changes. # A key-only unit needs supported service-code provisioning before local setup; # this template does not define an independent local service-PIN variable. # ZHZN_LOCAL_TOKEN below is a separate dispense bearer, not the Wi-Fi code. # config.py:63 — the hardware serial this cabinet reports. Empty means the # agent derives one (provision.py:67-90: /etc/machine-id, else a generated # GEN-… value persisted to the state dir). A factory should ALWAYS set this # explicitly to the serial on the cabinet's plate, because a derived serial # does not match the paperwork and cannot be pre-mapped. # # ONE constraint, enforced everywhere: letters, digits and hyphens only, 8 to 32 # characters (app/machine_serial.py:39-49). The device gateway enforces the # operator-claim rule at first contact, so a plate stamped VM_2026_1 is refused # on its very first register with a message naming every fault — at the bench, # where the plate can still be re-stamped, rather than in a venue weeks later # where the cabinet is simply unclaimable. ZHZN_SERIAL_NUMBER=REPLACE_ME_WITH_THE_CABINET_SERIAL # config.py:22 — an explicit machine code, which SKIPS bootstrap entirely # (provision.py:172-173 treats the env var's presence as identity source # "env"). Leave unset: the point of first boot is that the cloud assigns this. #ZHZN_MACHINE_ID= # config.py:62 — only used by the "direct" cloud mode's POST /machine/bootstrap # path. Irrelevant when ZHZN_CLOUD=kioskx. Leave unset. #ZHZN_ENROLLMENT_KEY= # Not an env var, but the reason this file must never be copied between cabinets # with its serial filled in: the agent sends a stable per-installation device id # (/etc/machine-id) on register and heartbeat, and the cloud binds the machine # record to the first one it sees. A second cabinet presenting a serial that is # already bound is REFUSED (app/machine_identity.py:122-158) rather than silently # served, because two cabinets on one serial share one planogram, one stock count # and one dispense queue. A cloned golden image with a hard-coded serial is # exactly this failure. A replacement control board is a legitimate case and # needs an admin identity reset first — see the field runbook's RMA section. # ── 2. Where state lives on disk ───────────────────────────────────────────── # config.py:64 — identity.json, networks.json and the serial fallback live # here. identity.json is written 0600 (provision.py:62) and is what makes a # second boot "persisted" rather than a re-registration. #ZHZN_STATE_DIR=/var/lib/zhzn-agent # config.py:138 — the durable dispense-report spool. A vend that happens while # the cloud is unreachable is written here and drained later, which is why a # network outage never loses a paid dispense. #ZHZN_QUEUE_PATH=/var/lib/zhzn-agent/reports.jsonl # config.py:140 — liveness marker refreshed each loop, for systemd/watchdog. #ZHZN_HEALTH_PATH=/run/zhzn-agent/healthy # config.py:78 — the install root the OTA swap replaces. The previous tree is # kept at .old for rollback (ota.py:129-153). #ZHZN_INSTALL_ROOT=/opt/zhzn-vending-kiosk # config.py:170 — empty means log to stdout only, which is right under systemd. #ZHZN_LOG_FILE= # ── 3. Hardware: the dispenser board ───────────────────────────────────────── # config.py:173 — the CSM dispenser COM. /dev/ttyS4 is the vendor demo's port # on the T3568; the physical COM ↔ /dev/ttySN mapping is board-specific and is # one of the items the manufacturers manual marks "confirm with hardware". #ZHZN_SERIAL_PORT=/dev/ttyS4 # config.py:174 — 9600 8N1. Not negotiable: a wrong baud presents as no_ack. #ZHZN_BAUD=9600 # config.py:175 — SH_YAxis (Y-lift gantry, frames carry an XOR check byte) or # SH (spring control board, no check byte — config.py:194-196). A ZHZN cabinet # with a lift is SH_YAxis. #ZHZN_MODULE=SH_YAxis # config.py:177 — send a board Init (0x02) once at startup before the first # dispense. This is what the factory's `vmc` acceptance test observes. #ZHZN_INIT_ON_START=1 # config.py:179-180 — the board can boot slower than the agent, so the serial # open is retried 10 times with a 2s backoff. #ZHZN_SERIAL_OPEN_RETRIES=10 #ZHZN_SERIAL_OPEN_BACKOFF=2 # config.py:184 — slot geometry. aisle = (row-1)*columns_per_row + column # (config.py:199-207). MUST match the physical grid or every purchase lands on # the wrong lane. This is also the one value the cloud overrides: see §7. #ZHZN_COLUMNS_PER_ROW=10 # config.py:187-188 — how long to wait for the board's ACK, and for the # dispense result. 30s covers a full Y-lift travel-and-return. #ZHZN_ACK_TIMEOUT=0.5 #ZHZN_RESULT_TIMEOUT=30 # ── 4. Dispatch: how a paid order reaches this cabinet ─────────────────────── # config.py:113 — kiosk_led (the on-device UI hands the vend over loopback), # remote_led (poll GET /zhzn/pending), or both. "both" is the default and is # what a cabinet with a touchscreen wants. #ZHZN_DISPATCH_MODE=both # config.py:191 — how often the remote queue is polled. #ZHZN_POLL_INTERVAL=3 # config.py:141 — presence heartbeat interval. The operator console's ONLINE # badge is driven by POST /zhzn/heartbeat calling STORE.touch_machine # (app/routes/zhzn.py, heartbeat; app/store.py, Store.touch_machine). #ZHZN_HEARTBEAT_INTERVAL=60 # config.py:120-121 — the loopback control channel the kiosk UI POSTs to, and # the same port the Wi-Fi provisioning page is served on. #ZHZN_LOCAL_HOST=127.0.0.1 #ZHZN_LOCAL_PORT=8770 # config.py:122 — REQUIRED IF THE CABINET HAS A KIOSK UI. The /dispense # endpoint moves physical product, so it is never anonymous: an empty token # makes localctl refuse every dispense outright (localctl.py:137-141). The # lifecycle test proves an unauthenticated dispense gets 401 # (tests/test_zhzn_lifecycle.py:233-241). # # PLACEHOLDER. Generate a fresh random value per cabinet — this is not a fleet # value, and it must never be the fleet secret. ZHZN_LOCAL_TOKEN=REPLACE_ME_WITH_A_PER_MACHINE_RANDOM_TOKEN # config.py:123 — set the kiosk browser app's exact origin so the CORS and # Chrome Private-Network-Access preflight is answered (localctl.py:93-107). # Empty is correct for a non-browser UI. #ZHZN_LOCAL_ALLOWED_ORIGIN= # ── 5. Network / Wi-Fi provisioning ────────────────────────────────────────── # # All radio work shells out, because the fleet spans NetworkManager boxes, bare # wpa_supplicant images and Android hosts (agent/network.py:156-163). # {ssid} / {passphrase} / {security} are substituted shell-quoted. # # EMPTY COMMANDS MEAN THIS BUILD CANNOT CHANGE ITS OWN NETWORK. The agent says # so honestly rather than reporting a change it did not make (config.py:159-160) # — so a cabinet shipped with these blank cannot be re-provisioned in the field # and will need a truck roll when the venue swaps its router. The factory # should fill them for the image it is shipping. # config.py:161-162 — example for a NetworkManager host: #ZHZN_WIFI_CONNECT_CMD=nmcli device wifi connect {ssid} password {passphrase} #ZHZN_WIFI_STATUS_CMD=/usr/local/bin/kioskx-link-status # The scanned network list on the provisioning page. Without this the page falls # back to typing the SSID by hand, which has to match byte for byte against a # venue running TasteofKorea / TasteofKorea-5G / TasteofKorea_Guest — a typo # there costs a truck roll. Accepts a JSON array or nmcli terse output. #ZHZN_WIFI_SCAN_CMD=nmcli -t -f SSID,SIGNAL,FREQ,SECURITY device wifi list # How the agent asks THIS radio whether it can host the setup access point and # scan at the same time. A driver reporting "#channels <= 1" cannot: the sweep # abandons the AP's channel and drops the technician's phone. Probed, never # assumed — an unparseable or missing answer counts as "cannot", so the agent # scans before raising the AP and serves that cache. Check a hardware revision # with: iw phy | sed -n '/valid interface combinations/,/Supported commands/p' #ZHZN_WIFI_PHY_CAPS_CMD=iw phy # config.py:166-167 — the temporary provisioning access point. This is the # OFFLINE path and the only way to reconfigure a cabinet whose venue Wi-Fi has # already stopped working. #ZHZN_SETUP_AP_START_CMD= #ZHZN_SETUP_AP_STOP_CMD= # config.py:168 — the address the setup AP answers on. The provisioning URL the # cabinet displays is http://:/provision?t= # (network.py:449-451). #ZHZN_SETUP_AP_ADDRESS=192.168.4.1 # network.py:58 — the revert deadline. Snapshot the working profile, switch, # prove we can REACH THE CLOUD (not merely that we associated), and on any # failure inside this window put the old profile back. 90s covers a venue AP # behind a slow gateway while keeping the dark window under two minutes. # The cloud publishes the same number so both sides quote one value # (app/machine_network.py:95). #ZHZN_WIFI_VERIFY_TIMEOUT=90 # NOT CONFIGURABLE, listed so the factory and the field can quote them: # network.py:49 provisioning token TTL 600s # network.py:61 reachability poll interval 3s # network.py:66 remembered networks, max 5 # network.py:72 wrong service codes before lockout 5 # network.py:73 lockout duration 300s # ── 6. Agent self-update (OTA) ─────────────────────────────────────────────── # config.py:76 — empty disables OTA. Point it at the gateway's own manifest # endpoint to have the cloud drive agent versions: #ZHZN_OTA_MANIFEST_URL=https://kiosk-x.intelli-verse-x.ai/zhzn/upgrade # config.py:77 — 21600s = 6 hours. #ZHZN_OTA_CHECK_INTERVAL=21600 # THE ONE VALUE THAT MUST NOT BE LEFT BLANK IF OTA IS ON. # The public half of the offline key releases are signed with. Without it the # agent installs nothing at all, which is the safe direction: a digest published # beside an artifact proves the bytes are not corrupt and proves nothing about # who made them, so a cabinet with no key and a permissive fallback would be an # open remote-code-execution channel. Inline value wins; otherwise the file. #ZHZN_OTA_PUBLIC_KEY= #ZHZN_OTA_PUBLIC_KEY_FILE=/etc/zhzn-agent-release.pub # The only location an artifact may be fetched from. The signature stops hostile # code; this stops a tampered offer aiming several hundred cabinets' HTTP # clients at a host of somebody else's choosing. # # LEAVE THIS UNSET on a real cabinet. The value is a contract with two parties # this file cannot see — the backend that validates a published manifest # (`app/config.py`, `ZHZN_AGENT_ARTIFACT_PREFIX`) and the CI job that uploads the # tarball — and setting it here overrides the one they are pinned to, which is # how a cabinet ends up refusing every legitimate release. Narrower than the # publisher and this cabinet stops updating, silently, showing only a version # that never moves. It is the CDN in front of s3://ivx-kiosk-x-docs rather than # the bucket's own endpoint because a cabinet fetches it with no credentials. # The trailing slash is a rule, not a style: the check is a startswith. #ZHZN_OTA_URL_PREFIX=https://media.intelli-verse-x.ai/docs/agent/ # How long the cabinet must have been idle before it may restart itself for an # upgrade. A customer holding a card against the reader is invisible to the # agent until the vend arrives, so the bar is a quiet window, not a quiet # instant. Raise it at a busy venue; the upgrade simply lands a poll later. #ZHZN_OTA_QUIESCE_SECONDS=180 # How long a freshly-swapped version has to reach the cloud before it is judged # a failure and rolled back — the same shape as the Wi-Fi revert window # (network.py), longer because a boot includes serial retries and a first # registration, not just an association. #ZHZN_OTA_HEALTH_TIMEOUT=300 # How many starts a new version gets before the boot guard reverts it. Three # absorbs a transient (a serial port not yet enumerated) without letting a # genuine crash loop run all night. #ZHZN_OTA_MAX_BOOT_ATTEMPTS=3 # Where the boot guard lives. Outside the install root on purpose: recovery code # inside the tree being replaced goes down with it. systemd runs it as # ExecStartPre before every start (deploy/zhzn-agent.service). #ZHZN_OTA_GUARD_PATH=/opt/zhzn-agent-guard/ota_guard.py # config.py:169 — HTTP timeout for gateway calls (the OTA download gets # max(this, 60) — ota.py:118). #ZHZN_HTTP_TIMEOUT=8 # config.py:69 — pull module/grid/interval settings from the cloud at startup. # Explicit env vars above always win (provision.py:31-38, 202-217). #ZHZN_REMOTE_CONFIG=1 # ── 7. Pulled from the cloud, NOT set here ─────────────────────────────────── # # POST /zhzn/register and GET /zhzn/config return the machine's config block. # The gateway's defaults (app/routes/zhzn.py, _config_for), verified live against a # freshly-registered serial: # # module SH_YAxis # columnsPerRow 10 # rows 6 # pollInterval 3 # heartbeatInterval 60 # dispatchMode both # features {"remoteScreen":false,"appAnalytics":false,"autoRescue":true,"photobooth":false,"factoryMode":false,"deviceLogs":false,"voiceShopper":false,"dropSound":true,"ambientAudio":false,"cardTapStorefront":false,"cardTapForceTile":false,"htmlStorefront":false,"testMode":false,"merchOnDemand":true,"figurines":true,"walkupSensor":true,"walkupAlerts":true,"cameraLive":false,"visionWatch":false,"arcadePrintables":true} # machineCode # kioskUiUrl # adminPin # adminPinSource # # The feature values above are for first contact before a compatible client # reports its capability/version. In particular voiceShopper is capability- # derived and can become true on a supported build; per-machine feature # settings remain authoritative. A saved feature/PIN is not evidence that the # cabinet applied it: verify the cabinet's subsequent report and actual UI. # Generated PINs and signed kioskUiUrl values are not shared defaults and must # never be pasted into this template or another cabinet's configuration. # # Per-fleet and per-machine overrides come from the cloud's # KIOSKX_ZHZN_CONFIG JSON ({"default": {...}, "": {...}} — # app/config.py:71), so a cabinet with a 12-wide grid gets columnsPerRow: 12 # from the cloud without anyone editing this file on the box. # Operator hardware overrides then take precedence. Features and the admin PIN # are resolved from their per-machine records, not from fleet-wide JSON.