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)
- Technician opens the cabinet's local admin screen (the agent's loopback
server,
localctl.py). - 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. - The agent raises a temporary access point
KioskX-Setup-<last6>and displays a standardWIFI: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. - 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. - 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):
docs/evidence/wifi-provisioning/machine-provision-page.pngdocs/evidence/wifi-provisioning/machine-provision-weak-selected.pngdocs/evidence/wifi-provisioning/machine-provision-remembered.png
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.