Power-bank rental (ZHZN / Qbee sharing cabinets)
A third hardware class in Kiosk-X, alongside the Reyeah /apk/* kiosks and the
ZHZN vending cabinets. It is the first one that does not speak HTTP: the cabinet
firmware opens a raw socket to us and exchanges comma-separated ASCII frames.
There is no Android layer and nowhere to run an agent, so app/powerbank/ is
the device software.
Protocol reference: 共享机柜通信协议标准版(快、慢宝、炫彩灯)v1.51, 2025-07-30.
Factory attach, kiosk association and the physical wiring path:
CHARGEBANK_E2E_WIRING.md.
Two cabinets are onboarded by default, the pair whose ids are printed on their own door stickers:
| Device id | Machine | Slots |
|---|---|---|
TPQBEEPA0100000311 |
PB-311 |
as reported by the cabinet |
TPQBEEPA0100000312 |
PB-312 |
as reported by the cabinet |
What the customer does
No app. The QR on the cabinet door points at https://<base>/rent/<deviceId>.
- Scan — a page stating the hourly rate, the daily cap, the size of the card hold and the non-return fee, plus how many banks the cabinet has.
- Pay — two first-rental forms, same meter afterwards:
- Phone (Stripe) — hosted Checkout,
capture_method=manual. Always on. - Tap the card reader (Nayax) — only when this station has a bound, non-provisional Device Number (factory-attached to a kiosk, or a reader bound onto the station itself). A standalone Qbee locker has no slot for a card; the button stays hidden until that binding exists. The tap captures the hold as a sale (MDB sessions last seconds, not 72 hours).
- Take the power bank — the status page names the slot.
- Return it to any Kiosk-X station — the meter stops. Stripe captures
only the metered amount and releases the rest of the hold. Nayax refunds
the unused slice in Core (the order is flagged
needsRefund+refundAmountUsd). The meter keeps running until the bank is back in a slot.
If the cabinet cannot deliver after payment, the hold is released and the page says so in words. Nothing is ever captured for a bank that did not come out.
Pricing
Environment variables are the published defaults. An operator changes what customers see from Operator X (Money → Sales, or the machine’s Power bank rental card) without a deploy. A rental already out keeps the terms it was opened with.
| Setting | Default | Meaning |
|---|---|---|
KIOSKX_POWERBANK_HOURLY_USD |
2.99 |
Per hour, part hours round up |
KIOSKX_POWERBANK_DAILY_CAP_USD |
19.99 |
Most one started day can cost |
KIOSKX_POWERBANK_GRACE_MINUTES |
5 |
Free window before the meter starts |
KIOSKX_POWERBANK_NONRETURN_USD |
39.99 |
Replacement cost, and the basis of the hold |
KIOSKX_POWERBANK_NONRETURN_HOURS |
72 |
When a rental becomes a non-return |
KIOSKX_POWERBANK_TAX_RATE |
0 |
Sales tax on captures, as a fraction |
Customer-facing contact details, which appear on every page of the flow:
| Setting | Default | Meaning |
|---|---|---|
KIOSKX_POWERBANK_SUPPORT_EMAIL |
support@kiosk-x.ai |
Shown in the footer everywhere |
KIOSKX_POWERBANK_SUPPORT_PHONE |
— | Shown as a tel: link if set |
KIOSKX_POWERBANK_TERMS_URL |
— | Overrides the built-in /rent/terms page |
Set the support address to one a human actually reads before the first cabinet goes live. A customer disputing a $39.99 non-return fee with no way to reach us has exactly one lever left, and it is their bank.
The hold is the non-return fee grossed up by the tax rate, because Stripe
lets us capture less than an authorisation but never more, so it has to cover
the worst case — fee plus tax — from the start. With tax at zero the two numbers
are the same, which is why this was invisible until someone switched tax on:
at 8.875% the worst-case charge is $43.54 against a $39.99 hold, and every
capture over a day old is rejected. tariff()["authorizationUsd"] is the number
actually authorised; nonReturnUsd is the fee itself. They are not the same
field and the difference is the tax.
Two rules exist to keep the schedule defensible rather than merely profitable:
- A charge never exceeds the hold. Quoting above it would promise revenue that cannot be collected.
- A rental that accrues to the hardware price becomes a sale. At the shipped defaults this happens just past 24 hours, not on some distant day: one second into the second day a second daily cap applies, taking the total to $39.98 — a cent under the $39.99 replacement fee. Past that point an honest customer who walks the bank back would pay exactly what someone who kept it pays, which is a fee schedule that punishes the honest one and does not survive a chargeback. So the rental converts: they have paid for the hardware and we stop expecting it back.
Deploying
# Identity + checksum key. This is the cabinet's own PW value.
KIOSKX_POWERBANK_PW=<pw>
# Transport — pick one. MQTT needs no new load balancer.
KIOSKX_POWERBANK_TCP_PORT=9500 # 0 (default) = listener off
KIOSKX_POWERBANK_MQTT=1
# Only rent banks above this charge. The vendor default of 80 is right for a
# per-use fee and wrong for an hourly meter.
KIOSKX_POWERBANK_MIN_CHARGE=50
# Extra cabinets: deviceId:machineNo:Location, comma separated. Adds to the
# two built-in ones; re-declaring one of those overrides its binding.
KIOSKX_POWERBANK_CABINETS=TPQBEEPA0100000313:PB-313:Hotel lobby
The remaining settings have working defaults and are only worth touching for a specific reason:
| Setting | Default | When you would change it |
|---|---|---|
KIOSKX_POWERBANK_TCP_HOST |
0.0.0.0 |
Bind the listener to one interface rather than all of them |
KIOSKX_POWERBANK_MQTT_PRODUCT_KEY |
kioskx-powerbank |
The broker's topic namespace, if it is not ours to choose |
KIOSKX_POWERBANK_BRAND_PREFIX |
— | Only rent banks whose id carries this prefix, for a site with mixed stock |
KIOSKX_POWERBANK_INVENTORY_SECONDS |
180 |
How often a cabinet is asked for a fresh slot report |
KIOSKX_POWERBANK_SWEEP_SECONDS |
20 |
How often holds, captures and queued ejects are worked |
The TCP listener is off by default. It is the only non-HTTP inbound socket this service opens, and one nothing connects to is just attack surface on every pod.
What the TCP port actually exposes
Read this before opening it, because the protocol offers no help.
There is no authentication in the frames. A cabinet asserts its identity by
writing a device id into a CN, and the checksum key is a shared PW printed
in vendor documentation — it authenticates nobody. Anything that can reach the
port and read a device id off a door sticker can claim to be that cabinet,
take over its session, and receive every command we send it.
Three things stand between the port and that:
- The onboarded list. A device id that is not in
KIOSKX_POWERBANK_CABINETSor the built-in pair is refused at attach.KIOSKX_POWERBANK_ALLOW_UNKNOWN_DEVICES=1turns that off; it exists for a bench session with a cabinet whose id nobody has written down yet, and it does not belong in production. KIOSKX_POWERBANK_STRICT_CRC, which rejects money-moving frames whose checksum does not verify. Leave it off during onboarding — the vendor's keying documentation disagrees with its own worked examples, so a cabinet whose PW we guessed wrong would be undiagnosable rather than merely unbillable. Turn it on once every cabinet in the fleet reports a stable observed key.- The network. This is the one that matters. Expose the listener to the
cabinets' source addresses only, via an internal NLB with a restricted
security group — never
0.0.0.0/0. ALoadBalancerservice publishing this port to the internet is the single worst configuration mistake available here, and nothing in the application layer will save you from it.
Prefer MQTT where the site allows it. It needs no new load balancer, and the broker does the authentication the device protocol does not.
Putting a cabinet into service
- Point its firmware at the gateway (host/port, or the MQTT broker).
- Confirm it appears:
GET /api/v1/powerbank/cabinets→online: true, slots populated. - Prove a slot without a card:
POST /api/v1/powerbank/cabinets/{id}/ejectwith{"slot": 1}. This never creates a rental. - Re-sticker the door. The cabinets arrive wearing the vendor's own QR, which
points at their service, not ours. Ours:
GET /rent/{deviceId}/qr.png. - Bind it to the operator's machine record if the default is not right:
POST /api/v1/powerbank/cabinets/{id}/bindwith{"machineNo": "..."}.
Step 4 is the one that is easy to forget and impossible to recover from remotely: until the sticker is replaced, every customer who scans is paying the vendor.
Operator API
All under /api/v1/powerbank, same scopes and machine-ownership rules as every
other command path.
| Endpoint | Scope | Purpose |
|---|---|---|
GET /cabinets |
machines:read |
Fleet, with transport counters |
GET /cabinets/{ref} |
machines:read |
One cabinet, inventory, rent + QR URLs |
POST /cabinets/{ref}/refresh |
machines:write |
Ask for a fresh inventory report |
POST /cabinets/{ref}/bind |
machines:write |
Attach to a machine record |
POST /cabinets/{ref}/eject |
commands:write |
Free test vend, no rental |
POST /cabinets/{ref}/slots/{n}/disable |
machines:write |
Take a slot out of service |
POST /cabinets/{ref}/rules |
machines:write |
Push min-charge / voice settings |
GET /rentals |
orders:read |
Rentals, with a live meter reading |
GET /rentals/{id} |
orders:read |
One rental and its event trail |
GET /tariff |
machines:read |
Defaults + this operator’s override |
PUT /tariff |
machines:write |
Set fleet-wide door-page prices (empty body resets) |
GET/PUT /cabinets/{ref}/tariff |
read / write | Per-station override |
GET /health |
machines:read |
Online cabinets, banks out, sweep state |
{ref} accepts a device id, a QR code or a machine number.
Revenue
Power-bank rentals are a stream in the canonical ledger
(GET /api/v1/revenue/summary → streams.powerbankRentals), and per machine
under transactions.powerbankRentals.
Only settled rentals count, booked at the moment the capture succeeded. A
rental still out is reported separately as accruingUsd and excluded from every
total: it has not been captured, and a returned bank may owe less than the meter
currently reads. nonReturnUsd splits out the part of the revenue that is
replacement fees rather than rental income, because the two mean very different
things about a site.
When something is wrong
GET /api/v1/powerbank/health is the first stop. It is scoped to your own
cabinets, and needsAttention lists rentals whose settlement failed: every
entry is either money we are holding that we should not be, or money we are
owed and did not take.
The sweep retries these on every pass and clears them when Stripe finally accepts, so an entry that appears and vanishes within a minute or two was a transient failure and needs nobody. What needs a human is an entry that persists: that is a failure retrying will not fix — a declined card, a cancelled intent, an authorisation Stripe has already expired — and it will sit there until someone settles it by hand.
| What you see | What it means | What to do |
|---|---|---|
needsAttention entry that persists across passes |
A capture or hold release that retrying will not fix | Open the rental's event trail; settle or refund in the Stripe dashboard against the recorded paymentIntent |
online: false on a cabinet |
No socket, or the uplink is down | Nothing to do remotely; the meter is safe (see below). Check power and signal at the site |
banksRentable: 0, slots full |
Cabinet rebooted and every slot is unconfirmed | Resolved automatically on the next inventory report; if it persists, POST /cabinets/{ref}/refresh |
rentalsUnreturned climbing at one site |
A cabinet is not registering returns | Check the slots physically. Customers are being billed $39.99 for banks they returned |
| A customer says they returned it | Believe them until the event trail says otherwise | The trail records every frame; a return we never received is our fault, not theirs |
A few things that look alarming and are not:
- A rental sitting in
activeon an offline cabinet is fine. Returns during an outage are replayed on reconnect and billed to the last moment we know the cabinet had a link — not to the reconnect — so the customer is not charged for our outage. Do not manually close these. captureState: "simulated"means Stripe is not configured in that environment. In production it is impossible: a rental with no payment intent is flagged for attention instead of being booked as revenue.- Duplicate webhooks are expected. Stripe retries; the idempotency keys make the second delivery a no-op.
What not to do: do not clear a needsAttention entry by editing the store,
and do not close a rental to make a dashboard tidy. An open rental is a live
hold on somebody's card; closing it without settling it strands their money and
destroys the only record of why.
How it is put together
| Module | Responsibility |
|---|---|
protocol.py |
Pure codec. Frames in, dataclasses out. No I/O, no clock. |
cabinet.py |
One live cabinet: inventory, the rent handshake, message ids. |
rentals.py |
Money and time: the lifecycle, the meter, Stripe. |
transport.py |
The sockets — asyncio TCP listener and MQTT binding. |
emulator.py |
A cabinet that answers correctly, for testing without hardware. |
onboarding.py |
Which cabinets exist, and which machine each belongs to. |
The seam between cabinet and rentals exists because the cabinet cannot be
trusted to be the record of a rental. It forgets across a power cycle, it caches
exactly one order id, and it will happily eject a bank for a frame we sent
twice. Anything that decides what a customer owes therefore lives on our side of
that seam.
Rental states
PENDING ──pay──> AUTHORIZED ──eject──> DISPENSING ──> ACTIVE
│ │ │
│ └── nothing came out ─┐ ├── returned ──> RETURNED ──> CLOSED
└── abandoned ────────────────────────> VOIDED └── deadline or ceiling ──> UNRETURNED ──> CLOSED
VOIDED is only reachable from PENDING and AUTHORIZED. Once a bank has
physically left the cabinet the premise of a void — that nothing was delivered —
is false, and the rental must be billed instead.
The background loop runs every KIOSKX_POWERBANK_SWEEP_SECONDS (default 20) and
has two halves that are gated differently:
- The eject drain runs on every pod. Only the pod holding a cabinet's socket can open its door, and that is rarely the pod holding the leader lock, so gating this on leadership makes the queue undrainable in exactly the configuration it exists for.
- The billing sweep is leader-elected, because captures, hold releases and non-return billing must happen once.
Everything in both is idempotent — the Stripe idempotency keys derive from the rental id — so a pass that dies halfway costs a retry, never a double capture.
Cross-replica behaviour
The Stripe webhook and the cabinet's socket routinely land on different pods, so
an authorised rental queues its eject (powerbank_pending_eject, fleet tier) and
whichever pod holds the socket drains it. Rentals themselves are money tier:
they survive a redeploy and a /sandbox/reset alike, because an open rental is a
live hold on a customer's card against a bank physically outside the cabinet.
Because two pods can hold different beliefs about the same rental, mirrored
records are versioned, not last-write-wins: apply_record accepts a peer's
copy only if it ranks higher in the lifecycle, or ranks equal and is newer. A
slow pod replaying "authorised, nothing came out" therefore cannot demote a
rental that another pod has already seen go active — which was how holds got
released on banks that were in a customer's hand.
Testing
pytest tests/test_powerbank_protocol.py # codec vs. the PDF's worked examples
pytest tests/test_powerbank_cabinet.py # session layer over real sockets
pytest tests/test_powerbank_rentals.py # billing + every way a rental ends
pytest tests/test_powerbank_e2e.py # phone → Stripe → hardware → revenue
pytest tests/test_powerbank_review_fixes.py # the faults a review found
Everything but the protocol suite runs the real TCP transport against
emulator.FakeCabinet, so a frame the gateway builds wrongly fails a test
rather than passing a mock built from the same misunderstanding. The emulator
can be told to fail at staging (force_rent_result) or at eject
(force_eject_result) — the latter is the failure that matters, because by
then we are holding the customer's money.
The review suite is worth reading before changing anything in rentals.py. The
other four are single-process, one request at a time, one cabinet, and they call
sweep() directly — a shape that cannot express a second replica, a late frame,
a stale socket or two taps on a button, which is where the money was going. Each
test there names the real-world scene it stands for.
Known protocol quirks
- The checksum key is the
PWvalue verbatim, not padded to six characters as §3 states. The document's own worked examples only verify unpadded.protocol.key_candidates()tries both and the codec records which one matched. - Field counts vary by firmware and by command. Several replies carry an
optional device id, and the spec is internally inconsistent about which.
protocol._best_alignment()scores candidate layouts on whether the result code, slot and charge land in plausible ranges, rather than trusting a count. - Message-id reuse is documented inconsistently for
BRandBW. Correlation is byorderId, not by message id. 移动电源归还状态resets to 0 on reboot, which is one of the reasons the cabinet is not the record of a rental.- Offline returns carry no timestamp. They are billed to the last moment we know the cabinet had a link, so a customer is not charged for our outage.