by Intelliverse X

Changing a machine's Wi-Fi

The constraint that shapes everything

The reason anyone changes a cabinet's wireless network is that the current one stopped working: the venue swapped its router, rotated the PSK, or changed ISP. So the machine is offline at the exact moment the change is needed, and a design that pushes credentials down from the cloud fails in the only case that matters.

The primary path is therefore local and needs no connectivity. The cloud-queued path exists for the machines that are still reachable — a proactive move before a venue's cutover, or a cabinet whose LTE modem is holding it up while its Wi-Fi is broken.

Hardware assumption

ZHZN/CSM cabinets have a touchscreen and a Wi-Fi radio; the agent (zhzn-vending-kiosk/agent) is headless Python driving that screen through the kiosk browser. Nothing in the fleet reports a camera or a barcode scanner — the factory acceptance suite tests screen, and equipment.json carries ageCamera: null — so the machine cannot scan a QR. The code therefore travels machine → phone, never the other way.

Confirm before rollout: this assumes the radio can run in AP mode concurrently with (or instead of) station mode on the shipping image. If a given hardware revision cannot raise a SoftAP, the offline path for that revision degrades to typing SSID and passphrase directly on the cabinet's touchscreen after the service-code unlock — same gate, same revert, no QR.

Offline path (primary)

  1. Technician opens the cabinet's local admin screen (the agent's loopback server, localctl.py).
  2. They enter the machine's 8-digit service code. It is HMAC-SHA256(fleet secret, "kioskx-service-code::<machineCode>") truncated to 8 digits — derived, never stored, so a disconnected machine can verify it. Per-serial, so one venue's code opens nothing else. Five wrong attempts locks the screen for five minutes.
  3. The agent raises a temporary access point KioskX-Setup-<last6> and displays a standard WIFI: join QR for it. The AP passphrase is likewise derived, so Operator X can render the identical QR when the cabinet's screen is dead.
  4. The phone joins and opens http://192.168.4.1:8770/provision?t=<token>. The token is signed, single-use, serial-bound and valid for 10 minutes.
  5. The technician picks the venue's network from a list the machine scanned and types its passphrase into a page the machine itself serves. The credentials go phone → machine and stop there.

The network list, and the AP-versus-scan constraint

Typing the SSID by hand was the original design and it was the wrong one. An SSID must match byte for byte, including case and trailing spaces, and venues run families of near-identical names — TasteofKorea, TasteofKorea-5G, TasteofKorea_Guest. A typo does not error: the machine fails to associate, reverts after 90 seconds, and the technician cannot tell whether the name or the passphrase was wrong. That is a wasted truck roll.

A scan from the machine's own radio also answers a question the technician's phone cannot: what the cabinet can see from where it physically stands. A cabinet behind a metal door in a back corridor routinely cannot reach the 5 GHz access point showing four bars at the counter. The list is therefore a signal survey from the only position that matters.

Why the scan happens before the access point goes up

A station-mode scan sweeps channels. On a single-radio chipset whose driver advertises #channels <= 1 in its nl80211 interface combinations, leaving the AP's channel to sweep tears the access point down — and that access point is what the technician's phone is on and what serves this page. A naive "scan now" button disconnects the person using it, mid-provisioning.

So the ordering is fixed:

service code accepted
  -> scan, while the radio is still in station mode and free
  -> raise the setup AP
  -> serve the page from the cached scan

agent/network.py:begin_local_provisioning does the scan first, and the page is served from that cache without touching the radio again.

The capability is probed, not assumed. ZHZN_WIFI_PHY_CAPS_CMD (default iw phy) is parsed by network.parse_phy_capability, which looks for an interface combination permitting AP and managed together with total >= 2 and #channels >= 2. Anything else — including an unparseable or missing answer — counts as "cannot", because guessing wrong costs a technician their session at a cabinet. To see what a given hardware revision actually reports:

iw phy | sed -n '/valid interface combinations/,/Supported commands/p'

A combination reading #{ managed } <= 1, #{ AP } <= 1, total <= 2, #channels <= 1 is the single-radio case: no concurrent scan. A phy advertising #channels <= 2 or more can rescan live, and the agent then offers it plainly.

A rescan is still offered either way, because a technician who has just switched the venue's router on needs one. When the radio cannot do it concurrently the button says so, asks for confirmation, and the page recovers rather than dying: the rescan request is fired without waiting for a reply (on a single-radio cabinet that reply never arrives — the socket dies with the AP), rescan_for_page re-raises the access point unconditionally afterwards, and the page polls GET /provision/networks?t=… on a backoff until it answers again. If it never does, the page says to rejoin KioskX-Setup-<last6> and reload, and that nothing on the machine has been changed — which is what stops somebody power-cycling a cabinet mid-job.

What the list shows

Sorted strongest first, one row per SSID per band:

Shown Why
Bars (0–4) plus a word Nobody reads -67 dBm correctly while squatting in front of a cabinet; -67 also looks bigger than -52 to anyone not thinking about the minus sign. The dBm figure is still there, second
Band (2.4 / 5 / 6 GHz) A vending cabinet is a metal enclosure. 2.4 GHz gets out of it; 5 GHz frequently does not. Where the same name is on both bands each row says "also on …", so the choice is deliberate
Security, with open called out Putting a cabinet that takes card payments on a network with no passphrase is a decision someone should make consciously, not because it was the strongest row
too weak here below −75 dBm (WEAK_SIGNAL_DBM) Associating at marginal signal is worse than not connecting: the cabinet joins and then drops out intermittently, which reads as a faulty machine rather than a network problem and so goes unfixed for weeks. −75 is where the margin runs out once you allow the 5–10 dB a person standing in front of the cabinet moves the number
known on a remembered network The network this cabinet has used before is almost always the one the technician wants

Selecting a row fills the form and stops. The passphrase still has to be entered and the submit button is separate: a mis-tap must not start a 90-second revert cycle for a network nobody chose.

The shipped page, captured from the agent's own HTTP server driven through a real browser (the list, a weak network selected, and a remembered one selected):

The console's own version of the list, and the same weak network picked there, are console-change-sheet.png and console-weak-selected.png in that directory. What each capture was taken against is recorded in docs/evidence/wifi-provisioning/README.md.

Hidden SSIDs broadcast no name and can never appear in a scan, and venues hide them more often than anyone expects. Manual entry is therefore kept — under "My network is not in the list (hidden network)", offered as the fallback rather than the default. With scripting off the page degrades to exactly the typed-name form it replaced.

Remembered networks and the skipped passphrase

Re-selecting a remembered network on the cabinet's own page skips passphrase re-entry. That is safe because the passphrase only ever moves inward: the browser names an SSID, the machine looks up its own networks.json profile, and the reply is an outcome string. There is deliberately no call that returns a stored passphrase, and an SSID the machine holds no profile for is refused (not-a-remembered-network) rather than silently tried. A typed passphrase still wins over the saved one, because a rotated PSK is the ordinary reason somebody is standing there.

The console and the operator app cannot skip it: the cloud does not hold venue passphrases and is not going to start. Both surfaces say so where a remembered network is selected, so the difference reads as a design decision rather than a bug.

The online path

The machine reports its most recent scan on POST /zhzn/network/state, so the console and the app can offer previously seen networks for that specific cabinet instead of a blank text box. Neither of them can scan, so both label the list with the check-in it came from and flag it as stale past a day (SCAN_STALE_SECONDS) — an operator choosing from a memory needs to know it is one. Queueing a change to a network the machine last saw below the weak threshold records the dBm figure in the audit row, because six weeks later the symptom is "this cabinet keeps dropping out" and nobody remembers.

Retention

One scan per machine — the latest — overwritten on every report, never appended to. Keeping the current one is what makes the console's list possible. Keeping a history would build, without anyone asking, a dated record of the wireless environment of every venue on every route; that is a surveillance dataset, not an operations feature. No scanned network's BSSID is ever sent or stored — a BSSID is a third party's access-point MAC and is precisely the field that makes a list of them geolocatable. (The one BSSID the machine does report is the access point it is itself associated to, on the machine's own network record; a single cabinet's current AP is not a survey of the venue, but nothing reads that field today and it would be no loss to drop it.) A scan is capped at 30 entries and dropped after 30 days (SCAN_RETENTION_SECONDS), because a month-old list is not a useful answer to "what can this cabinet see" in any case.

The list is served only to an authenticated provisioning session: on the machine it sits behind the same signed, single-use, serial-bound token as the change itself (reading it deliberately does not burn the nonce), and in the cloud it rides inside the machine's network record, which is already refused across operators.

The QR contains no venue credential at all — only a join code for the machine's own setup network. That removes the "someone photographed the screen and got the site's Wi-Fi password" problem outright rather than mitigating it.

Online path (secondary)

POST /api/v1/machines/{machineNo}/network/changes parks a change; the agent pulls it on GET /zhzn/network and reports the outcome to POST /zhzn/network/confirm. A change nobody collected within 24 hours expires: a cabinet that has been dark for a day is very likely dark because of its network, and day-old instructions would be the second change it cannot recover from.

Revert on failure

Wrong credentials do not fail loudly. They take the cabinet off the air and leave nobody able to tell it anything, so an apply is never a one-way door:

snapshot the working profile
  -> switch
  -> prove we can reach the cloud (not merely that we associated)
  -> on any failure inside 90s, put the old profile back

Reachability, not association. The headline failure is a venue that changed ISP, which produces a perfectly associated radio with no route to us.

Why 90 seconds (ZHZN_WIFI_VERIFY_TIMEOUT): association plus DHCP is normally under 15s; a venue AP behind a slow gateway can take 30–45s. Ninety clears that with headroom while keeping the cabinet's dark window under two minutes — short enough that the technician standing there does not conclude they bricked it and start power-cycling.

A machine with no previous profile reports failed with "needs someone on site" rather than a cheerful reverted that would send nobody.

Security model

Property How
Admin gate Re-type the console admin password (routes/account_security.check_password — the existing store, not a new one) for a 5-minute ticket bound to one machine
Local gate Derived 8-digit per-machine service code, checked offline, throttled
Token HMAC-SHA256 over machine\|issuedAt\|nonce\|purpose, signature checked first
TTL 10 minutes
Single use Nonce burned on redeem, both cloud- and machine-side
Serial binding machine-mismatch refusal — a QR from cabinet A cannot touch cabinet B
Masking passphraseMasked everywhere; clear text only from POST .../network/reveal, which needs the password again and writes its own audit row
Logging The passphrase is never logged. ShellRadio discards stderr precisely because it echoes the command line
Tenancy Ownership read live from STORE.machines; ?operatorEmail= runs through _resolve_operator, so it cannot widen an operator's access. Someone else's machine is indistinguishable from a missing one
Scan list Behind the provisioning token on the machine and behind machine ownership in the cloud. Never served unauthenticated on the SoftAP, even though joining the SoftAP already requires the service code
Third-party privacy No BSSIDs sent or stored; latest scan only, never a history; 30 entries, 30 days
Audit Who, when, from which surface, which SSID, and the outcome the machine reported

Deliberately out of scope

Encrypting the queued passphrase to a per-device key. There is no per-device keypair in the fleet today — the ZHZN gateway's whole model is one shared fleet secret with no per-machine credential. Encrypting to the fleet secret would protect against nothing the transport does not already cover. The right fix is device identity, which is a larger change than this feature.

Ethernet and LTE APN provisioning. Reported and displayed, not settable.