fix(hal): EBDS escrow stack/return latch + return-on-disable

Cash-in stalled on the batm3: a note reached escrow and was read, but the
acceptor never stacked or returned it, and the customer was never
credited. Root cause: EBDS carries the stack/return decision as bits in
the omnibus *poll* command, but the driver sent stack()/reject() as a
single one-shot frame while a free-running 100ms poller kept sending
plain polls. The lone stack frame races/collides with the poller (or its
ack desyncs), gets dropped, and the device holds the note in escrow
indefinitely.

- ebds-rs232: latch the escrow decision (`pendingAction`) into the poll
  command byte and re-assert it on every poll until the device leaves
  escrow (cleared in _process when `!escrowed`). A dropped frame is now
  simply retried on the next poll.
- hal-service: return an escrowed note on disableValidator() — disable
  alone does not release it on EBDS, so an inactivity timeout / cancel
  previously stranded the bill in the transport (observed on the batm3).
- atm store: stringify the `[ATM] Sending event` / `[ATM] State` logs —
  they were printing `[object Object]`, which blinded the cash-in trace.

Verified: hal builds, machine app typechecks. Hardware behaviour to be
confirmed on the batm3 (no unit tests exist for this serial driver).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-07-30 00:40:06 +02:00
commit 4d0e42f289
3 changed files with 46 additions and 9 deletions

View file

@ -221,6 +221,15 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
}, },
disableValidator: () => { disableValidator: () => {
// If a note is sitting in escrow when we disable (inactivity timeout,
// cancel, or leaving the insert screen), return it to the customer.
// Disabling alone does NOT release an escrowed note on EBDS — it would
// be stranded in the transport until the next power cycle.
if (escrowDenomination !== null) {
console.log('[HAL] Returning escrowed bill on disable:', escrowDenomination)
escrowDenomination = null
validator?.reject()
}
validator?.disable() validator?.disable()
validator?.lightOff() validator?.lightOff()
}, },

View file

@ -458,7 +458,7 @@ export const useAtmStore = defineStore('atm', () => {
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => { actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
const prevSnapshot = snapshot.value const prevSnapshot = snapshot.value
snapshot.value = newSnapshot snapshot.value = newSnapshot
console.log('[ATM] State:', newSnapshot.value) console.log('[ATM] State:', JSON.stringify(newSnapshot.value))
// Detect transition into a complete state // Detect transition into a complete state
const state = newSnapshot.value const state = newSnapshot.value
@ -1374,7 +1374,7 @@ export const useAtmStore = defineStore('atm', () => {
console.error('[ATM] Cannot send event: machine not initialized') console.error('[ATM] Cannot send event: machine not initialized')
return return
} }
console.log('[ATM] Sending event:', event) console.log('[ATM] Sending event:', event.type, JSON.stringify(event))
actor.value.send(event) actor.value.send(event)
} }

View file

@ -313,6 +313,13 @@ export class EbdsRs232 extends EventEmitter {
private serial: SerialPort | null = null private serial: SerialPort | null = null
private ack: number = 0x0 private ack: number = 0x0
private enabledDenominations: number = 0x00 private enabledDenominations: number = 0x00
// Latched escrow decision. In EBDS the stack/return choice is NOT a one-shot
// message — it's carried as bits in the omnibus poll command, and the device
// holds the escrowed note until a poll asserts stack or return. We keep the
// action set and re-assert it on every poll until the device leaves escrow
// (cleared in _process), so a single dropped/collided frame no longer strands
// the note in escrow forever.
private pendingAction: 'none' | 'stack' | 'return' = 'none'
private lastStatusFlags: string | null = null private lastStatusFlags: string | null = null
private firmwareLogged: boolean = false private firmwareLogged: boolean = false
@ -405,23 +412,38 @@ export class EbdsRs232 extends EventEmitter {
// -- Commands (Appendix D, Controller Message) --------------------------- // -- Commands (Appendix D, Controller Message) ---------------------------
/** Send an Omnibus poll command with current denomination mask */ /**
* Command byte 1 for the omnibus poll, encoding any latched escrow action.
* `stack` (0x3f) and `return` (0x5f) differ from the plain poll (0x1b) only
* in the stack/return bits; while an action is latched every poll re-asserts
* it until the device acts.
*/
private commandByte(): number {
if (this.pendingAction === 'stack') return 0x3f
if (this.pendingAction === 'return') return 0x5f
return 0x1b
}
/** Send an Omnibus poll command with the current mask + latched action */
poll(): void { poll(): void {
this._dispatch([this.enabledDenominations, 0x1b, 0x10]) this._dispatch([this.enabledDenominations, this.commandByte(), 0x10])
} }
/** Stack the bill currently in escrow */ /** Latch "stack the escrowed note"; re-asserted each poll until it takes. */
stack(): void { stack(): void {
this._dispatch([this.enabledDenominations, 0x3f, 0x10]) this.pendingAction = 'stack'
this.poll()
} }
/** Reject/return the bill currently in escrow */ /** Latch "return the escrowed note"; re-asserted each poll until it takes. */
reject(): void { reject(): void {
this._dispatch([this.enabledDenominations, 0x5f, 0x10]) this.pendingAction = 'return'
this.poll()
} }
/** Send initial setup command (disable all, reset state) */ /** Send initial setup command (disable all, reset state) */
reset(): void { reset(): void {
this.pendingAction = 'none'
this._dispatch([0x00, 0x1b, 0x10]) this._dispatch([0x00, 0x1b, 0x10])
} }
@ -470,7 +492,13 @@ export class EbdsRs232 extends EventEmitter {
validatePacket(packet) validatePacket(packet)
const result = interpret(packet) const result = interpret(packet)
if (result) { if (result) {
if (result.destructedData) this._logStatusOnChange(result.destructedData) if (result.destructedData) {
// Clear a latched stack/return once the device has left escrow — it
// is now stacking/returning/idle, so we must stop asserting the
// action or it would leak onto the next note.
if (!result.destructedData[0].escrowed) this.pendingAction = 'none'
this._logStatusOnChange(result.destructedData)
}
this.emit('message', result) this.emit('message', result)
} }
} catch (ex) { } catch (ex) {