bug: dispense result mapping uses wrong index (amounts order vs cassette position) #6

Open
opened 2026-03-22 21:13:53 +00:00 by padreug · 0 comments
Owner

Summary

brain.js maps dispense results by iterating over the requested amounts array and indexing into the hardware result array by the same index. But these two arrays use different ordering:

  • amounts (the input): ordered by whatever the state machine sends (denomination order)
  • result.value (from hardware): always ordered by physical cassette position

If the amounts array and cassette positions don't align (e.g. cassette 0 = $5, cassette 1 = $20, but amounts requests $20 first), the dispensed/rejected counts get swapped between denominations.

Affected Code

lamassu-machine lib/brain.js_physicalDispense and _batchesFinished:

The dispense result from the hardware driver (e.g. F56) returns an array indexed by cassette position. When mapping back to denominations, the code assumes the input amounts array has the same ordering as the cassette positions — which is only true when denominations happen to be requested in cassette order.

Impact

  • Inventory tracking: cassette counts decremented against wrong denomination
  • Operator reporting: dispensed_1/dispensed_2 in cash_out_txs may show swapped counts
  • Error diagnosis: a jam on cassette 1 could appear as a jam on cassette 2 in the DB

In practice this is often masked because many deployments use a single denomination or cassettes happen to be loaded in the same order as the sort. But multi-denomination setups with non-ascending cassette loading will silently misattribute results.

Fix

Map results by cassette position (matching the hardware driver's indexing), then look up which denomination each cassette holds:

// WRONG: amounts[i] != cassette position i
const bills = amounts.map((a, i) => ({
  denomination: a.denomination,
  dispensed: result.value[i]?.dispensed ?? 0,
  rejected: result.value[i]?.rejected ?? 0,
}))

// CORRECT: iterate by cassette position, filter to requested
const cassetteResults = cassetteDenominations.map((denom, i) => ({
  denomination: denom,
  dispensed: result.value[i]?.dispensed ?? 0,
  rejected: result.value[i]?.rejected ?? 0,
}))
const bills = cassetteResults.filter(c => /* was requested */)

Context

Found and fixed in lamassu-next (feat/record-failed-dispense branch) in both apps/machine/src/services/hal.ts and apps/machine/electron/hal-service.ts. The same pattern exists in lamassu-machine's brain.js.

## Summary `brain.js` maps dispense results by iterating over the **requested amounts array** and indexing into the **hardware result array** by the same index. But these two arrays use different ordering: - `amounts` (the input): ordered by whatever the state machine sends (denomination order) - `result.value` (from hardware): always ordered by **physical cassette position** If the amounts array and cassette positions don't align (e.g. cassette 0 = $5, cassette 1 = $20, but amounts requests $20 first), the dispensed/rejected counts get swapped between denominations. ## Affected Code **lamassu-machine** `lib/brain.js` — `_physicalDispense` and `_batchesFinished`: The dispense result from the hardware driver (e.g. F56) returns an array indexed by cassette position. When mapping back to denominations, the code assumes the input amounts array has the same ordering as the cassette positions — which is only true when denominations happen to be requested in cassette order. ## Impact - **Inventory tracking**: cassette counts decremented against wrong denomination - **Operator reporting**: `dispensed_1`/`dispensed_2` in `cash_out_txs` may show swapped counts - **Error diagnosis**: a jam on cassette 1 could appear as a jam on cassette 2 in the DB In practice this is often masked because many deployments use a single denomination or cassettes happen to be loaded in the same order as the sort. But multi-denomination setups with non-ascending cassette loading will silently misattribute results. ## Fix Map results by cassette position (matching the hardware driver's indexing), then look up which denomination each cassette holds: ```js // WRONG: amounts[i] != cassette position i const bills = amounts.map((a, i) => ({ denomination: a.denomination, dispensed: result.value[i]?.dispensed ?? 0, rejected: result.value[i]?.rejected ?? 0, })) // CORRECT: iterate by cassette position, filter to requested const cassetteResults = cassetteDenominations.map((denom, i) => ({ denomination: denom, dispensed: result.value[i]?.dispensed ?? 0, rejected: result.value[i]?.rejected ?? 0, })) const bills = cassetteResults.filter(c => /* was requested */) ``` ## Context Found and fixed in lamassu-next (`feat/record-failed-dispense` branch) in both `apps/machine/src/services/hal.ts` and `apps/machine/electron/hal-service.ts`. The same pattern exists in lamassu-machine's brain.js.
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/lamassu-server#6
No description provided.