Cash-in-only machines never report cassette state, and an empty positions map would corrupt or crash the consumer #48

Open
opened 2026-09-29 17:58:15 +00:00 by padreug · 0 comments
Owner

Found while bringing up a Raspberry Pi 4 with a Pyramid Apex acceptor and no dispenser. Two problems, one in each direction.

The UI waits forever and blames the wrong thing

The cassettes section for the machine shows:

Waiting for the ATM's state event. Power on the ATM and confirm it has reached the configured relay; cassette rows will auto-populate on receipt.

The ATM is powered on and has demonstrably reached the relay — it pulled its fee config over that same transport minutes earlier. The wait is not about connectivity, and the suggested remedy cannot fix it.

Cause is on the bitspire side. The rpi4 preset ships cassettes: [], the cassettes table in state.db stays empty, and publishCassettesState returns early:

const cassettes = await api.loadCassettes()
if (cassettes.length === 0) return false

So nothing is ever published. For a machine with no dispenser that is arguably correct behaviour, but the operator cannot tell it apart from a machine that is offline.

Making the ATM report empty would break this consumer

The obvious fix is for a cash-in-only machine to publish {"positions": {}} so the operator learns it is alive and has no bays. That currently lands on a latent bug.

consume_cassette_state_event in crud.py builds the delete list from the payload keys:

keep = sorted(payload.positions.keys())
placeholders = ", ".join(f":p{i}" for i in range(len(keep)))
await db.execute(
    "DELETE FROM spirekeeper.cassette_configs "
    f"WHERE machine_id = :mid AND position NOT IN ({placeholders})",
    delete_values,
)

With no positions, placeholders is empty and the statement becomes position NOT IN (). That behaves differently per backend, and both are wrong:

  • SQLite accepts the empty list. NOT IN () is true for every row, so every cassette row for that machine is deleted. Verified locally.
  • PostgreSQL rejects it as a syntax error, so the consumer task raises.

PublishCassettesPayload.positions is a plain dict[int, CassettePayloadRow] with no minimum length, so validation does not catch it first.

Suggested shape

  1. Guard the delete so an empty keep list skips the NOT IN clause entirely, rather than relying on the payload never being empty. This is worth doing regardless of the rest, since a shrinking bay count reaching zero hits the same path.
  2. Decide what a dispenser-less machine should report, and make the UI able to say it. Either the ATM publishes an empty positions map and the section renders "no bays fitted, cash-in only", or the machine record carries the capability and the section is hidden for those machines.
  3. Either way the current copy should stop telling the operator to check that the ATM is powered on, since a machine that reached the relay for its fee config plainly is.

Related: #47 covers the operator-side republish heartbeat, which is a different direction of the same conversation.

Found while bringing up a Raspberry Pi 4 with a Pyramid Apex acceptor and no dispenser. Two problems, one in each direction. ## The UI waits forever and blames the wrong thing The cassettes section for the machine shows: > Waiting for the ATM's state event. Power on the ATM and confirm it has reached the configured relay; cassette rows will auto-populate on receipt. The ATM is powered on and has demonstrably reached the relay — it pulled its fee config over that same transport minutes earlier. The wait is not about connectivity, and the suggested remedy cannot fix it. Cause is on the bitspire side. The `rpi4` preset ships `cassettes: []`, the `cassettes` table in `state.db` stays empty, and `publishCassettesState` returns early: ```ts const cassettes = await api.loadCassettes() if (cassettes.length === 0) return false ``` So nothing is ever published. For a machine with no dispenser that is arguably correct behaviour, but the operator cannot tell it apart from a machine that is offline. ## Making the ATM report empty would break this consumer The obvious fix is for a cash-in-only machine to publish `{"positions": {}}` so the operator learns it is alive and has no bays. That currently lands on a latent bug. `consume_cassette_state_event` in `crud.py` builds the delete list from the payload keys: ```python keep = sorted(payload.positions.keys()) placeholders = ", ".join(f":p{i}" for i in range(len(keep))) await db.execute( "DELETE FROM spirekeeper.cassette_configs " f"WHERE machine_id = :mid AND position NOT IN ({placeholders})", delete_values, ) ``` With no positions, `placeholders` is empty and the statement becomes `position NOT IN ()`. That behaves differently per backend, and both are wrong: - **SQLite** accepts the empty list. `NOT IN ()` is true for every row, so every cassette row for that machine is deleted. Verified locally. - **PostgreSQL** rejects it as a syntax error, so the consumer task raises. `PublishCassettesPayload.positions` is a plain `dict[int, CassettePayloadRow]` with no minimum length, so validation does not catch it first. ## Suggested shape 1. Guard the delete so an empty keep list skips the `NOT IN` clause entirely, rather than relying on the payload never being empty. This is worth doing regardless of the rest, since a shrinking bay count reaching zero hits the same path. 2. Decide what a dispenser-less machine should report, and make the UI able to say it. Either the ATM publishes an empty positions map and the section renders "no bays fitted, cash-in only", or the machine record carries the capability and the section is hidden for those machines. 3. Either way the current copy should stop telling the operator to check that the ATM is powered on, since a machine that reached the relay for its fee config plainly is. Related: #47 covers the operator-side republish heartbeat, which is a different direction of the same conversation.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/spirekeeper#48
No description provided.