Smart cassette fallback on dispenser pickup errors #27

Open
opened 2026-06-13 22:02:51 +00:00 by padreug · 0 comments
Owner

Migrated from aiolabs/lamassu-next#27 — opened by @padreug on 2026-02-23.\n\n## Problem

Currently, when a bill dispenser encounters a pickup error (e.g., cassette fails to grab a bill), the machine shows "Out of money" to the customer — even when other cassettes are full and working. This happens because the current lamassu-machine error handling treats any dispense error as terminal: close the serial port, mark as uninitialized, show "out of cash".

This is a poor UX. A customer who just deposited cash sees "out of money" when the ATM could have fulfilled their withdrawal from a different cassette.

Current behavior (lamassu-machine)

  1. puloonrs232.js dispatches a single dispense command with [count_cassette1, count_cassette2, ...]
  2. On any error (pickup, jam, timeout), the raw error code is returned
  3. puloon-dispenser.js calls this.close() — serial port closed, initialized = false
  4. brain.js sees the error, stores it in the transaction log, shows "Out of cash" screen
  5. On the next dispense attempt, brain.js re-initializes the dispenser (200ms delay)

No retry or fallback logic exists.

Proposed behavior

On a recoverable dispense error (pickup error, bill-end on one cassette), the dispenser should attempt to fulfill the remaining amount from other cassettes before giving up.

How it works

The Puloon dispense response already tells us per-cassette dispensed and rejected counts. So on error we know:

  • Which cassette(s) failed
  • How many bills were already successfully dispensed
  • The remaining amount needed

Retry strategy:

  1. Dispense command fails on cassette N with a recoverable error (pickup, bill-end)
  2. Calculate remaining amount: requested - already_dispensed
  3. Check if another cassette has the same denomination → retry from that cassette
  4. If no same-denomination cassette, recompute bill combinations from remaining working cassettes to reach the target amount
  5. Only show "Out of money" after exhausting all cassettes/combinations

Recoverable vs terminal errors

Recoverable (worth retrying from another cassette):

  • Pickup error — cassette failed to grab a bill
  • Bill-end — cassette is empty
  • Counting error — bill tracking lost

Terminal (don't retry, hardware needs attention):

  • JAM — bill stuck in transport path, will block all cassettes
  • Motor stop — mechanical failure
  • Diverter/SOL sensor error — shared transport hardware failure
  • Dispensing timeout — shared path blocked

Scope

  • Puloon (Douro): 2 cassettes — if both have the same denomination, can fall back
  • F56 (Tejo): up to 4 cassettes — much more benefit, can recombine denominations
  • Both dispensers: the BillDispenser.dispense() interface already returns per-cassette results, so the retry logic can live in a shared layer above the driver

Where it fits

The retry logic should live in the state machine / ATM store layer (not inside the driver), since:

  • It needs knowledge of all cassette configurations and denominations
  • It needs to decide whether to retry or give up
  • The driver just executes single dispense commands and reports results

Example scenario (Tejo, 4 cassettes)

Config: Cassette 1 = $5, Cassette 2 = $20, Cassette 3 = $50, Cassette 4 = $100

Customer requests $120 cash-out. Optimal combo: 1x$100 + 1x$20.

  1. Dispense command: [0, 1, 0, 1] (1 from cassette 2, 1 from cassette 4)
  2. Cassette 4 ($100) pickup error — 0 dispensed from cassette 4, 1 dispensed from cassette 2
  3. Remaining: $100 needed
  4. Recompute from working cassettes: 2x$50 from cassette 3
  5. New dispense command: [0, 0, 2, 0]
  6. Success → customer gets $120 ($20 + $50 + $50)

Without this feature, step 2 would show "Out of money" despite cassettes 1, 2, 3 all being full.

> _Migrated from [aiolabs/lamassu-next#27](https://git.atitlan.io/aiolabs/lamassu-next/issues/27) — opened by @padreug on 2026-02-23._\n\n## Problem Currently, when a bill dispenser encounters a pickup error (e.g., cassette fails to grab a bill), the machine shows "Out of money" to the customer — even when other cassettes are full and working. This happens because the current lamassu-machine error handling treats any dispense error as terminal: close the serial port, mark as uninitialized, show "out of cash". This is a poor UX. A customer who just deposited cash sees "out of money" when the ATM could have fulfilled their withdrawal from a different cassette. ## Current behavior (lamassu-machine) 1. `puloonrs232.js` dispatches a single dispense command with `[count_cassette1, count_cassette2, ...]` 2. On any error (pickup, jam, timeout), the raw error code is returned 3. `puloon-dispenser.js` calls `this.close()` — serial port closed, `initialized = false` 4. `brain.js` sees the error, stores it in the transaction log, shows "Out of cash" screen 5. On the _next_ dispense attempt, brain.js re-initializes the dispenser (200ms delay) No retry or fallback logic exists. ## Proposed behavior On a recoverable dispense error (pickup error, bill-end on one cassette), the dispenser should attempt to fulfill the remaining amount from other cassettes before giving up. ### How it works The Puloon dispense response already tells us per-cassette `dispensed` and `rejected` counts. So on error we know: - Which cassette(s) failed - How many bills were already successfully dispensed - The remaining amount needed **Retry strategy:** 1. Dispense command fails on cassette N with a recoverable error (pickup, bill-end) 2. Calculate remaining amount: `requested - already_dispensed` 3. Check if another cassette has the same denomination → retry from that cassette 4. If no same-denomination cassette, recompute bill combinations from remaining working cassettes to reach the target amount 5. Only show "Out of money" after exhausting all cassettes/combinations ### Recoverable vs terminal errors **Recoverable** (worth retrying from another cassette): - Pickup error — cassette failed to grab a bill - Bill-end — cassette is empty - Counting error — bill tracking lost **Terminal** (don't retry, hardware needs attention): - JAM — bill stuck in transport path, will block all cassettes - Motor stop — mechanical failure - Diverter/SOL sensor error — shared transport hardware failure - Dispensing timeout — shared path blocked ### Scope - **Puloon** (Douro): 2 cassettes — if both have the same denomination, can fall back - **F56** (Tejo): up to 4 cassettes — much more benefit, can recombine denominations - **Both dispensers**: the `BillDispenser.dispense()` interface already returns per-cassette results, so the retry logic can live in a shared layer above the driver ### Where it fits The retry logic should live in the **state machine / ATM store layer** (not inside the driver), since: - It needs knowledge of all cassette configurations and denominations - It needs to decide whether to retry or give up - The driver just executes single dispense commands and reports results ## Example scenario (Tejo, 4 cassettes) Config: Cassette 1 = $5, Cassette 2 = $20, Cassette 3 = $50, Cassette 4 = $100 Customer requests $120 cash-out. Optimal combo: 1x$100 + 1x$20. 1. Dispense command: `[0, 1, 0, 1]` (1 from cassette 2, 1 from cassette 4) 2. Cassette 4 ($100) pickup error — 0 dispensed from cassette 4, 1 dispensed from cassette 2 3. Remaining: $100 needed 4. Recompute from working cassettes: 2x$50 from cassette 3 5. New dispense command: `[0, 0, 2, 0]` 6. Success → customer gets $120 ($20 + $50 + $50) Without this feature, step 2 would show "Out of money" despite cassettes 1, 2, 3 all being full.
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/bitspire#27
No description provided.