by Intelliverse X

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>.

  1. 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.
  2. Pay — two first-rental forms, same meter afterwards:
  3. Phone (Stripe) — hosted Checkout, capture_method=manual. Always on.
  4. 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).
  5. Take the power bank — the status page names the slot.
  6. 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:

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:

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

  1. Point its firmware at the gateway (host/port, or the MQTT broker).
  2. Confirm it appears: GET /api/v1/powerbank/cabinetsonline: true, slots populated.
  3. Prove a slot without a card: POST /api/v1/powerbank/cabinets/{id}/eject with {"slot": 1}. This never creates a rental.
  4. 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.
  5. Bind it to the operator's machine record if the default is not right: POST /api/v1/powerbank/cabinets/{id}/bind with {"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/summarystreams.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:

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:

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