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:
- Which QR goes on each unit, and where?
- How is it wired — at the factory, and when bolted onto a vending kiosk?
- 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

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

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/cabinets → online: 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

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 |
|---|---|
TPQBEEPA0100000311 → PB-311 |
![]() |
TPQBEEPA0100000312 → PB-312 |
![]() |
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

The flow, as the code does it (app/routes/powerbank.py,
app/powerbank/rentals.py, app/powerbank/cabinet.py):
- 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. - Rent now →
POST /rent/{code}/start. A rental is created (PENDING), a cookie ties it to this phone, and a Stripe Checkout Session is minted withcapture_method=manualfor the hold — the non-return fee grossed up by tax. The customer is redirected to Stripe. - Customer approves → Stripe sends
checkout.session.completed. On a manual-capture sessionpayment_statusisunpaid: that is the success state, nothing has been taken.settle_authorized()marks the rentalAUTHORIZED, queues the eject (so a pod dying mid-eject cannot strand it) and tries inline. - Gateway → cabinet:
BWasks the cabinet to pick a rentable bank (charge ≥ minimum, brand prefix ok); the reply names slot, bank id and charge.BRconfirms and the lock releases (orFBdrives the named slot if the 30-second confirmation window closed). The cabinet'sFBeject report withresult=1means the bank physically left →ACTIVE. The meter starts now, not at payment. Results100(not taken),110(jam) or0(none eligible) release the hold and the page says so. - 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. - 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 understreams.powerbankRentalsin 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

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

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

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.

