by Intelliverse X

Power bank — the visual manual

Seven pictures that answer the three questions a manufacturer, an installer and a reviewer keep asking about the Qbee / ZHZN charge-bank cabinet:

  1. Which QR goes on each unit, and where?
  2. How is it wired — at the factory, and when bolted onto a vending kiosk?
  3. How does a rental take our payment and make a bank come out?

Every diagram is generated from the code by scripts/render_powerbank_manual.py (command words from app/powerbank/protocol.py, states from app/powerbank/rentals.py, tariff defaults from app/config.py), so the pictures cannot quietly drift from the gateway. The prose behind them lives in powerbank-rental.md (pricing, states, operator API, 2am procedures) and CHARGEBANK_E2E_WIRING.md (bench wiring, factory attach, sign-off matrix). This page is the one you hand to someone who will not read those.

Is it working end to end?

Software: yes. The whole path — phone → Stripe hold → webhook → BW / BR / FB frames → bank out → RS return → metered capture → revenue ledger — runs against the real TCP transport and the cabinet emulator in tests/test_powerbank_e2e.py, with every failure a review found pinned in tests/test_powerbank_review_fixes.py. 181 tests across the power-bank and factory-attach suites pass on main.

Hardware: not yet, and this page cannot claim it. Nobody has run §7 of CHARGEBANK_E2E_WIRING.md — our gateway, our QR, one real Stripe out-and-back — on a physical Qbee cabinet against production Stripe. That is a bench or site run, not a code change. Until it happens the honest status is software wired, crate-close not signed.


1 · What is wired to what

Overview: cabinet ↔ Kiosk-X gateway ↔ Stripe / Nayax / Operator X, customer phone with no app

Nothing runs on the cabinet. It opens a socket (TCP or MQTT) and exchanges {CMD,…,CRC16} ASCII frames; app/powerbank/ in the gateway is the device software. The phone talks HTTPS to /rent/*, Stripe holds and captures, Operator X reads /api/v1/powerbank/*.


2 · Manufacturer wiring

Wiring: standalone cabinet (mains → PSU → lock board, modem → TCP/MQTT → internal NLB) and side-car bolted to a ZHZN kiosk with a cloud bind

Two builds, one rule: the charge bank never rides the vending harness. It is not an MDB peripheral and does not speak Reyeah FF 00 55 or ZHZN AA…BB. It has its own PSU and its own modem, and its firmware service menu must point at our gateway host/port (or MQTT product key) with the PW that matches KIOSKX_POWERBANK_PW.

When it is bolted to a vending kiosk, nothing is wired between the two computers. The association is a cloud bind — device id → kiosk machineNo — written by the factory tablet through POST /zhzn/factory-attach (diagram 7).

Bench check before the crate closes:

Step Pass looks like
PW on the service menu equals KIOSKX_POWERBANK_PW checksum verifies (crcOk)
Reboot GET /api/v1/powerbank/cabinetsonline: true, slots > 0 within a minute
POST /api/v1/powerbank/cabinets/{id}/eject {"slot": 1} a bank physically comes out, no rental row
Seat it back slot rentable again (charge ≥ KIOSKX_POWERBANK_MIN_CHARGE)

The TCP listener is off by default and, when on, belongs behind an internal NLB restricted to the cabinets' source addresses. The protocol authenticates nothing; the network has to.


3 · The QR sticker — one per cabinet, ours over theirs

QR sticker: cabinet arrives with Qbee QR (crossed out), ships with the Kiosk-X sticker covering it; the 4×6 artwork; the four-step procedure

Cabinets arrive wearing Qbee's own QR, which points at their service. Until it is covered, every customer who scans is paying the vendor, and no API call can fix a sticker.

How many One per cabinet. The device id printed on it must equal the door id the firmware sends in its CN frame (e.g. TPQBEEPA0100000311).
Where Door front, eye level, beside the slots — not on a removable service panel. Fully covering the vendor code.
What it encodes https://<KIOSKX_BASE_URL>/rent/<deviceId>
Artwork GET /rent/{deviceId}/sticker.png — print-ready 4×6 with QR, rate and device id. Bare QR: GET /rent/{deviceId}/qr.png.
Operator pack GET /api/v1/powerbank/stickers (JSON), …/stickers/print (print sheet), …/cabinets/{id}/sticker.png
New cabinet Obtain its own sticker through the authorized Operator pack above. Match the printed device id to this door before applying it. Do not reuse another cabinet's QR.

Historical examples for the onboarded pair; print these only for the matching door ids:

Cabinet Sticker
TPQBEEPA0100000311PB-311 TPQBEEPA0100000311 door sticker
TPQBEEPA0100000312PB-312 TPQBEEPA0100000312 door sticker

Procedure — factory or install: print the sticker for this door id → cover the vendor QR completely → scan with a phone (the page must say Kiosk-X and the rate) → prove one Stripe out-and-back on this exact cabinet.


4 · How a rental takes payment and a bank comes out

Sequence: phone → gateway → Stripe → cabinet. Scan, Rent now, manual-capture hold, webhook, BW/BR/FB frames, ACTIVE, RS return, metered capture, CLOSED

The flow, as the code does it (app/routes/powerbank.py, app/powerbank/rentals.py, app/powerbank/cabinet.py):

  1. Scan the door → GET /rent/{deviceId}. The page shows the hourly rate, the daily cap, the size of the hold and how many banks are in.
  2. Rent nowPOST /rent/{code}/start. A rental is created (PENDING), a cookie ties it to this phone, and a Stripe Checkout Session is minted with capture_method=manual for the hold — the non-return fee grossed up by tax. The customer is redirected to Stripe.
  3. Customer approves → Stripe sends checkout.session.completed. On a manual-capture session payment_status is unpaid: that is the success state, nothing has been taken. settle_authorized() marks the rental AUTHORIZED, queues the eject (so a pod dying mid-eject cannot strand it) and tries inline.
  4. Gateway → cabinet: BW asks the cabinet to pick a rentable bank (charge ≥ minimum, brand prefix ok); the reply names slot, bank id and charge. BR confirms and the lock releases (or FB drives the named slot if the 30-second confirmation window closed). The cabinet's FB eject report with result=1 means the bank physically left → ACTIVE. The meter starts now, not at payment. Results 100 (not taken), 110 (jam) or 0 (none eligible) release the hold and the page says so.
  5. The phone is on /rent/r/{rentalId}, which names the slot and polls /rent/api/{rentalId}. Closing the tab and re-scanning any door returns the customer to the same rental; a second hold is never opened.
  6. Return to any Kiosk-X station. The cabinet sends RS; the meter stops (RETURNED). The leader-elected sweep captures only the metered amount and Stripe releases the rest of the hold → CLOSED, booked under streams.powerbankRentals in the revenue ledger. A return while the cabinet was offline is billed to the last moment we knew it had a link.

Nayax rail (only when the station is factory-attached to a kiosk with a bound, non-provisional Device Number): the page offers Tap the card reader. The tap captures the hold as a sale (an MDB session cannot stay open for days), the Nayax webhook claims the pending rental, the same BW/BR/FB eject follows, and on return the unused slice is refunded in Core (order flagged needsRefund + refundAmountUsd).


5 · Rental states — where the money is at each step

State machine: PENDING → AUTHORIZED → DISPENSING → ACTIVE → RETURNED → CLOSED; VOIDED from PENDING/AUTHORIZED; UNRETURNED from ACTIVE

VOIDED is only reachable before a bank has left the cabinet. Once one is out, the premise of a void is false and the rental must be billed instead: UNRETURNED at the 72-hour deadline, or earlier when the accrual reaches the hardware price. Everything the sweep does is idempotent on the rental id, so a retry is never a double capture.


6 · What the customer pays

Money timeline: grace, hourly rounding, daily cap, conversion to sale at 24h+1s, 72h non-return; the hold bar

Shipped defaults (app/config.py; Operator X can change the door-page numbers without a deploy, and a rental already out keeps the terms it opened with):

Default
Grace before the meter starts 5 min
Per started hour $2.99
Most one started day can cost $19.99
Non-return fee, and the basis of the hold $39.99
Non-return deadline 72 h
Hold actually authorised $39.99 × (1 + KIOSKX_POWERBANK_TAX_RATE)

Two rules keep the schedule defensible: a charge never exceeds the hold, and a rental whose accrual reaches the hardware price converts to a sale — at the defaults that is one second into the second day ($39.98).


7 · Factory: the three attach questions

Factory attach: Q1 power bank (scan door sticker), Q2 photo print, Q3 Nayax Device Number; sign-off gate; finger-sign; field installer checks

On the kiosk's Factory test screen or the paired tablet, before sign-off and before any operator claims the unit. The tablet never types kiosk ids — it answers, then scans, one accessory at a time through POST /zhzn/factory-attach.

# Question Yes → scan Writes
1 Power bank attached? the charge-bank door sticker (vendor QR or /rent/… URL both parse) machine.powerbankDeviceId + cabinet bind — rentals and revenue attribute to this kiosk
2 Photo print attached? a serial if there is one; presence is enough machine.photoPrintAttached (photobooth flag stays operator-controlled)
3 Nayax reader attached? the 16-digit Device Number on the reader — a 9-digit Machine ID is refused machine.nayax.terminalId, provisional: false

attached: false is a complete answer. attached: true with no scan blocks sign-off, as does a Nayax scan that looks like a Machine ID or a bank already bound to another vending kiosk. Do not sign a unit whose side-car still points at the vendor cloud or still wears the vendor QR — neither can be fixed from Operator X.


Regenerating the pictures

python3 scripts/render_powerbank_manual.py     # → docs/powerbank-visual-manual/*.png
python3 scripts/render_powerbank_stickers.py   # → docs/powerbank-stickers/*.png

PIL only, no new dependency. Fonts fall back from Arial to DejaVu, so a render on Linux differs in glyph shapes but not content.