Charge bank end-to-end — wiring, factory attach, and sign-off
This is the page a manufacturer technician, an installer, and an on-call engineer should be able to follow without opening the code. It covers the Qbee / ZHZN sharing power-bank cabinet from the crate to the first paid rental, and the factory initial-setup questions that associate every accessory with the kiosk it is bolted to.
Companion pages:
| Page | What it is for |
|---|---|
powerbank-rental.md |
Pricing, states, operator API, 2am procedures |
| Android manufacturer sign-off | Camera, factory tests, accessory evidence and signatures |
payments-nayax.md |
Nayax cashless on the vending side |
ContentX docs/ops/NAYAX_ZHZN_CARD_ENABLEMENT.md |
Card tile + MDB on a ZHZN vending cabinet (different hardware) |
Two hardware classes must not be mixed up:
| Sharing charge-bank cabinet | ZHZN vending kiosk | |
|---|---|---|
| What it is | Qbee-branded locker of rental power banks | Snack / photo / card-pay cabinet |
| How it talks | Raw TCP or MQTT, {CMD,…,CRC} frames |
Android APK → /zhzn/* HTTPS |
| Identity | Door sticker, e.g. TPQBEEPA0100000311 |
equipmentNo / kiosk id, e.g. 1A8B08520A50 |
| Customer pay | Stripe Checkout on a phone (no app) | Nayax card and/or Scan & Pay |
| Onboarded pair | TPQBEEPA0100000311 → PB-311, …312 → PB-312 |
whatever the APK registers |
A charge bank can live alone (its own machine record) or be bolted onto a vending kiosk. Factory attach is how the second case is recorded.
1. What “wired end to end” means
The path is finished only when all of these are true at once:
- The cabinet has power, a working uplink, and firmware pointed at our gateway (not the vendor’s).
- Its device id is on the onboarded list (or factory-attached to a kiosk) so the listener will accept the socket.
- A Kiosk-X machine record owns it (
machineNo), so revenue and Operator X have someone to attribute. - The door sticker is our QR (
/rent/{deviceId}), not the vendor’s. - Stripe is live, the hold is the taxed non-return fee, and a test rental has gone out and back on that exact cabinet.
- If this unit is a kiosk build: the factory tablet has answered the three attach questions and scanned every “yes” onto this kiosk id.
Until (4), every customer who scans is paying the vendor. Until (2), the cabinet is online from its own point of view and invisible from ours.
2. Physical wiring (manufacturer / bench)
2.1 Charge-bank cabinet (standalone or side-car)
AC mains ──► cabinet PSU ──► lock board + slot rails + banks
└──► 4G / Ethernet modem
│
▼
TCP : KIOSKX_POWERBANK_TCP_PORT (default off)
or MQTT to the configured broker
- Do not put this cabinet on the vending APK’s serial / MDB harness. It
does not speak MDB, Reyeah
FF 00 55, or ZHZNAA…BB. Those are the vending board. This unit opens a socket and sends ASCII frames. - Confirm the PW on the service menu matches
KIOSKX_POWERBANK_PW. Checksum failures with the wrong key look like a dead uplink. - Write down the door id exactly (
TPQBEEPA0100000311). That string is the only identity the protocol has.
2.2 When it is attached to a vending kiosk
The kiosk is a separate computer. Typical factory build:
┌─────────────────────────────────────┐
│ Vending kiosk │
Touch + camera ──►│ Android APK ──HTTPS──► /zhzn/* │
Vend board UART ─►│ │
Nayax VPOS ──MDB─►│ (card path; see Nayax docs) │
Photo printer ───►│ USB / vendor cable │
└──────────────┬──────────────────────┘
│ factory tablet
│ scans stickers
┌──────────────▼──────────────────────┐
│ Qbee charge-bank sidecar │
│ own modem, own TCP/MQTT │
└─────────────────────────────────────┘
The sidecar does not ride the kiosk’s agent process. Association is a
cloud bind: device id → kiosk machineNo. That bind is what factory
attach writes.
2.3 Nayax on the kiosk (not on the charge bank)
Card payment for snacks is the VPOS Touch on the kiosk, MDB to the APK. Charge-bank rentals are Stripe on the shopper’s phone. Do not expect a Nayax tap to eject a power bank.
Two Nayax numbers exist. Only one is bindable:
| What | Example | Bind this? |
|---|---|---|
| Device Number (terminal id) | 4434331225106629 |
Yes — on the reader sticker / Core |
| Machine ID (MoMa record) | 111146311 |
No — webhooks never match it |
The factory tablet refuses a 9-digit scan as “looks like a Machine ID”.
2.4 Photo print
The photobooth is the kiosk camera + ContentX compose pipeline. A physical printer (DNP / similar) is an optional accessory. Factory records fitted / not fitted (and a serial if one exists). It does not turn the photobooth feature flag on — that stays an operator decision after the camera has passed.
3. Cloud wiring
3.1 Environment (gateway)
KIOSKX_POWERBANK_PW=<cabinet PW>
KIOSKX_POWERBANK_TCP_PORT=9500 # 0 = listener off
# or
KIOSKX_POWERBANK_MQTT=1
KIOSKX_POWERBANK_CABINETS=TPQBEEPA0100000313:PB-313:Hotel lobby
# Never in production:
# KIOSKX_POWERBANK_ALLOW_UNKNOWN_DEVICES=1
TCP must sit behind an internal NLB restricted to the cabinets’ source
addresses. The protocol authenticates nothing. See
powerbank-rental.md § “What the TCP port actually exposes”.
Stripe: same keys as Scan & Pay. Checkout uses capture_method=manual. The
authorised amount is NONRETURN_USD × (1 + TAX_RATE), not the bare fee.
3.2 Firmware on the cabinet
Service menu → server host / port (or MQTT product key) → our gateway. Reboot. Within a minute:
GET /api/v1/powerbank/cabinets # machines:read
# online: true, slots populated
If it never appears: wrong host, listener still 0, device id not onboarded,
or CRC/PW mismatch. GET /api/v1/powerbank/health → transport.
3.3 Door QR (step 4 — cannot be done remotely)
The cabinets arrive wearing Qbee's own sticker. Until it is covered, every customer who scans pays the vendor.
| What | URL |
|---|---|
| Customer page (what the sticker must encode) | GET /rent/{deviceId} |
| Bare QR PNG | GET /rent/{deviceId}/qr.png |
| Print-ready 4×6 sticker (QR + rate + device id) | GET /rent/{deviceId}/sticker.png |
| Operator pack (JSON of every owned cabinet) | GET /api/v1/powerbank/stickers |
| Operator print sheet | GET /api/v1/powerbank/stickers/print |
| Operator download | GET /api/v1/powerbank/cabinets/{deviceId}/sticker.png |
Onboarded pair (production KIOSKX_BASE_URL, default https://api.kiosk-x.ai):
| Cabinet | Page | Sticker |
|---|---|---|
TPQBEEPA0100000311 |
/rent/TPQBEEPA0100000311 |
/rent/TPQBEEPA0100000311/sticker.png |
TPQBEEPA0100000312 |
/rent/TPQBEEPA0100000312 |
/rent/TPQBEEPA0100000312/sticker.png |
Print, cover the vendor code, scan with a phone. The page must be ours. Then run one Stripe out-and-back (and a Nayax tap if this unit has a reader).
4. Factory initial setup (the three questions)
Record these facts using the authorized bench workflow before sign-off
and before operator claim. The current ZHZN APK does not have an accessory
scan/write screen. Do not search for a planned tablet control. Match the actual
cabinet and equipmentNo before each write, and read back the saved binding.
4.1 Questions
| # | Question | If no | If yes |
|---|---|---|---|
| 1 | Power bank attached? | {attached: false} — done |
Scan the door sticker (vendor QR or Kiosk-X /rent/… URL). We store deviceId and bind it to this kiosk. |
| 2 | Photo print attached? | {attached: false} |
Mark fitted and record a serial when present. Physical print acceptance is still required before shipping. |
| 3 | Nayax device attached? | {attached: false} |
Scan the Device Number on the reader (16-digit typical). Bind POST-equivalent to this kiosk. |
attached: false is a complete answer. Shipping without accessories is
normal. attached: true with no scan blocks sign-off.
Saying not fitted after a bad scan unbinds that accessory from this
kiosk (power-bank pointer cleared, Nayax returned to a provisional
placeholder, photo-print flag cleared). Do not assume a later
attached: false is a no-op.
A later POST /zhzn/factory-test that repeats only some kinds merges
with scans already on file. Unmentioned kinds stay. Send attached: false
for a kind you want to take back.
4.2 QR scan API (authorized bench tooling)
Use the approved bootstrap authentication before enrolment, or the established device credential/witness after enrolment. A Partner manufacturer key does not replace cabinet authentication on this route. Follow the detailed factory sign-off procedure and verify the same serial.
One accessory at a time (scan → bind → next question):
POST /zhzn/factory-attach
{
"kind": "powerbank" | "photoPrint" | "nayax",
"attached": true,
"scanned": "https://kiosk.example/rent/TPQBEEPA0100000311"
}
scanned may be a raw id, a rent URL, or a query with deviceId /
terminalId. The gateway keeps only the id.
Response (shape that matters on the screen):
{
"code": 200,
"data": {
"machineNo": "1A8B08520A50",
"kind": "powerbank",
"deviceId": "TPQBEEPA0100000311",
"attached": true,
"bound": true,
"attachments": { "...": "full kit so far" },
"canSignOff": false,
"blockers": []
}
}
bound: false + bindError is a hard stop: already on another kiosk, or a
Nayax Machine ID. Do not sign.
Whole kit with the hardware report:
POST /zhzn/factory-test
{
"results": { "camera": "pass", "speaker": "pass", "mic": "pass",
"touch": "pass", "screen": "pass", "board": "pass" },
"ports": {
"board": { "acked": "/dev/ttyS3", "protocol": "csm", "module": "SH" },
"camera": { "id": "0", "facing": "front" }
},
"attachments": {
"powerbank": { "attached": true, "scanned": "TPQBEEPA0100000311", "source": "qr" },
"photoPrint": { "attached": true, "model": "DNP DS620" },
"nayax": { "attached": true, "scanned": "4434331225106629", "source": "qr" }
}
}
Older APKs that send no attachments still sign off — a cabinet with no
kit is not defective. Once a kind is present and attached: true, the scan
is required (photo print excepted).
4.3 What the bind actually writes
| Accessory | Durable record | Runtime effect |
|---|---|---|
| Power bank | machine.powerbankDeviceId + cabinet machine_no |
Rentals and revenue attribute to this kiosk; ejects target this cabinet |
| Nayax | machine.nayax.terminalId, provisional: false |
Card webhooks settle to this kiosk |
| Photo print | machine.photoPrintAttached (+ optional photoPrintDeviceId) |
Provenance only; photobooth flag stays operator-controlled |
On gateway restart, powerbankDeviceId on the machine is rebound so a
sidecar does not fall back to the standalone PB-311 placeholder.
4.4 Sign-off gate (extra blockers)
Existing rules still apply (required tests pass, board port + module, camera id). New ones:
- Power bank attached, no scanned id
- Nayax attached, no scanned id
- Nayax scan looks like a Machine ID (
likelyMachineId) - Bind failed (id already on another vending kiosk)
- Scanned but
boundnever flipped true
A technician cannot finger-sign past these. /zhzn/factory-sign re-checks
the same list.
4.5 After the ocean — field installer
Follow first-time installation: authorized claim, reviewed address match, separate on-site installation declaration, configured payment, actual stock and physical acceptance. Read the saved accessories; their presence does not establish receiving-operator acceptance. Also:
- Confirm
GET /api/v1/machines/{no}/factory-testshows the kit andstage: signed_off. - Confirm
GET /api/v1/powerbank/cabinets/{no}isonline(sidecar pointed at production). - Confirm
GET /api/v1/machines/{no}/nayaxhas the Device Number andprovisional: falseif a reader was fitted. - Replace any remaining vendor QR on the charge-bank door.
5. Customer path (charge bank)
No app.
- Scan door →
/rent/{deviceId}(or the kiosk’s bound cabinet). - Read rate, 24-hour cap, hold, 72-hour return deadline, terms (the numbers Operator X last saved for that station or fleet).
- Tap Rent now → Stripe hold.
- Take the bank from the named slot.
- Return to any Kiosk-X charge-bank station. Meter stops; we capture only the metered amount.
Losing the tab: scan the same door again. The cookie + server guard send them back to the running rental; a second hold is not opened.
6. Operator path
| Need | Call |
|---|---|
| See the sidecar | GET /api/v1/powerbank/cabinets |
| Re-bind after a move | POST /api/v1/powerbank/cabinets/{id}/bind {"machineNo":"…"} |
| Test eject (no money) | POST /api/v1/powerbank/cabinets/{id}/eject {"slot":1} |
| Bind a reader later | POST /api/v1/machines/{no}/nayax {"terminalId":"…"} |
| Factory kit + cert | GET /api/v1/machines/{no}/factory-test |
| Revenue | GET /api/v1/revenue/summary → streams.powerbankRentals |
Ownership: an operator can only bind a cabinet or a reader they own, onto a machine they own. Admin can do both.
7. End-to-end test at the factory (before the crate closes)
Run on the exact unit that will ship, not a lab twin.
| Step | Pass looks like |
|---|---|
| Factory test all-green + ports identified | canSignOff true except kit |
| Q1–Q3 answered; every yes scanned | attachments.*.bound == true |
| Charge-bank firmware → staging/prod gateway | cabinet online, slots > 0 |
| Free eject slot 1 | Bank physically leaves; no rental row. This is POST /api/v1/powerbank/cabinets/{id}/eject with an operator key — the factory tablet cannot do it over /zhzn/*. |
| Seat it back | Slot returns to rentable (charge ≥ min) |
| Phone: scan our QR, authorise, take, return in grace | Status “Returned”; hold released; $0 captured. Needs a phone + live Stripe, not the APK. |
| If Nayax fitted: one $0.00 / $0.01 Core test tap | Webhook lands on this machineNo. Physical tap; there is no /zhzn/selfcheck in this build. |
| If photo print fitted: camera test frame + (optional) test print | Factory camera pass; printer feeds |
| Android handwritten certificate | Actual certificate read back for this serial/run; a queued upload is unfinished. |
| Separate Partner factory signature | Final physical FAT and build evidence reviewed and signed by the authorized manufacturer; retain both factory records before shipping. |
The Android certificate alone is not shipping acceptance. Follow manufacturer acceptance, including all physical tests and both factory signatures. Operator delivery acceptance is a separate receiving milestone.
Do not sign a unit whose charge-bank sidecar is still pointed at the vendor cloud. You cannot fix that sticker or that host from Operator X.
8. Real-world scene matrix (sign-off)
Exercised in code by tests/test_powerbank_*.py,
tests/test_factory_attachments.py, and the review-fix suite. Each row is
a scene a reviewer ran, not a slogan.
| Scene | Expected | Status |
|---|---|---|
| Happy rent → return inside grace | $0 capture, hold released | Pass |
| Return after hours / at daily cap | Capture ≤ hold | Pass |
| Non-return at 72h | Closed as non-return, not “Returned — thank you” | Pass |
| Accrual hits hardware price (~24h+1s) | Converts to sale; meter stops | Pass |
| Tax on (e.g. 8.875%) | Hold grossed up; capture clamped to hold | Pass |
| Double-tap / retry / Back from Stripe | One rental, one hold | Pass |
| Close tab, re-scan door | Resume running rental | Pass |
| Cabinet empty / jam after pay | Hold released; page says nothing came out | Pass |
| Late eject report after we timed out | Adopted as ACTIVE, not discarded | Pass |
| Pod dies mid-dispense | Sweep voids / resolves; hold not frozen forever | Pass |
| Two replicas, stale “authorised” write | Cannot demote ACTIVE | Pass |
| Cabinet reboot (returnState 0) | Slots not offered until re-acked | Pass |
| Stale socket teardown after redial | Live connection kept | Pass |
| Offline return hours later | Billed to last-seen, not reconnect | Pass |
| Unknown device on TCP | Refused | Pass |
| Empty CRC key | Not a universal pass | Pass |
| Factory: no accessories | Sign-off allowed | Pass |
| Factory: “yes” and no scan | Sign-off blocked | Pass |
| Factory: scan rent URL | Id parsed, bound to this kiosk | Pass |
| Factory: same bank on two kiosks | Second bind refused | Pass |
| Factory: Nayax Machine ID | Refused; named as Machine ID | Pass |
| Factory: Nayax Device Number | Bound, provisional: false |
Pass |
| Factory: photo print yes, no serial | Bound; sign-off allowed | Pass |
Open, accepted risk: two shoppers can still pass the last-bank availability check in the same instant. Duplicate-rental guard covers the common double-tap; a true slot reservation is not in this build. Do not put two cabinets on one busy doorway without knowing that.
Operator X may still list PB-311 / PB-312 after a sidecar is
factory-attached to a real kiosk. Rental traffic follows the kiosk bind;
the leftover standalone row is a placeholder, not a second cabinet.
Swarm sign-off (19 Aug 2026): software wiring YES WITH CAVEATS.
Crate-close on a shipping unit NO until §7 is run on that exact
cabinet (our gateway, our QR, one real Stripe out-and-back). Factory
three-question + QR→kiosk association is signed off in
tests/test_factory_attachments.py.
Not signed off on hardware we do not have in this repo: a live Qbee cabinet against production Stripe, and a live VPOS MDB session on the same kiosk. Those need a bench or site run using §7. Software paths for both are covered.
9. When it is wrong
| Symptom | First check |
|---|---|
| Cabinet online locally, missing in API | Onboarded list / factory powerbankDeviceId / TCP port 0 |
| Customer paid, no bank | GET /api/v1/powerbank/health needsAttention; eject queue; online |
| Card tap on kiosk does nothing for a rental | Expected — rentals are Stripe. Card is Nayax on vending. |
| Webhook on the wrong machine | Bound Machine ID instead of Device Number; re-scan |
| Two holds on one phone | Cookie blocked (in-app browser) + they started twice on two cabinets; support from /rent/r/{id} footer |
$39.99 and the page says Returned |
Should not happen after the review fixes; if it does, charge.basis / unreturnedReason on /rent/api/{id} |
Full 2am table: powerbank-rental.md § “When something is wrong”.
10. Implementation map
| Piece | Where |
|---|---|
| Protocol / CRC | app/powerbank/protocol.py |
| Live cabinet | app/powerbank/cabinet.py |
| Money + meter | app/powerbank/rentals.py |
| TCP / MQTT | app/powerbank/transport.py |
| Seed + factory rehydrate | app/powerbank/onboarding.py |
| Customer + operator HTTP | app/routes/powerbank.py |
| Factory questions + QR bind | app/factory_test.py, POST /zhzn/factory-attach |
| Nayax bind | POST /api/v1/machines/{no}/nayax |