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) {