From 4d0e42f2898c67e690630e5f83d88079ac7bd884 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 30 Jul 2026 00:40:06 +0200 Subject: [PATCH] fix(hal): EBDS escrow stack/return latch + return-on-disable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/machine/electron/hal-service.ts | 9 ++++ apps/machine/src/stores/atm.ts | 4 +- .../hal/src/validators/ebds/ebds-rs232.ts | 42 +++++++++++++++---- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 72f3480..2ec863b 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -221,6 +221,15 @@ export async function initializeHal(config: HalConfig): Promise { }, 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?.lightOff() }, diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index a770dae..7119500 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -458,7 +458,7 @@ export const useAtmStore = defineStore('atm', () => { actor.value.subscribe((newSnapshot: SnapshotFrom) => { const prevSnapshot = snapshot.value snapshot.value = newSnapshot - console.log('[ATM] State:', newSnapshot.value) + console.log('[ATM] State:', JSON.stringify(newSnapshot.value)) // Detect transition into a complete state const state = newSnapshot.value @@ -1374,7 +1374,7 @@ export const useAtmStore = defineStore('atm', () => { console.error('[ATM] Cannot send event: machine not initialized') return } - console.log('[ATM] Sending event:', event) + console.log('[ATM] Sending event:', event.type, JSON.stringify(event)) actor.value.send(event) } diff --git a/packages/hal/src/validators/ebds/ebds-rs232.ts b/packages/hal/src/validators/ebds/ebds-rs232.ts index 6673a25..07820b1 100644 --- a/packages/hal/src/validators/ebds/ebds-rs232.ts +++ b/packages/hal/src/validators/ebds/ebds-rs232.ts @@ -313,6 +313,13 @@ export class EbdsRs232 extends EventEmitter { private serial: SerialPort | null = null private ack: number = 0x0 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 firmwareLogged: boolean = false @@ -405,23 +412,38 @@ export class EbdsRs232 extends EventEmitter { // -- 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 { - 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 { - 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 { - this._dispatch([this.enabledDenominations, 0x5f, 0x10]) + this.pendingAction = 'return' + this.poll() } /** Send initial setup command (disable all, reset state) */ reset(): void { + this.pendingAction = 'none' this._dispatch([0x00, 0x1b, 0x10]) } @@ -470,7 +492,13 @@ export class EbdsRs232 extends EventEmitter { validatePacket(packet) const result = interpret(packet) 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) } } catch (ex) {