diff --git a/packages/hal/src/validators/ebds/ebds-fsm.ts b/packages/hal/src/validators/ebds/ebds-fsm.ts index ea4b4a8..e48f041 100644 --- a/packages/hal/src/validators/ebds/ebds-fsm.ts +++ b/packages/hal/src/validators/ebds/ebds-fsm.ts @@ -11,16 +11,48 @@ */ import { EventEmitter } from 'node:events' -import type { ParseResult } from './ebds-rs232.js' +import type { DestructedData, ParseResult } from './ebds-rs232.js' // MEI's internal escrow grace expires around 5s. We want any escrow that // sits open for >500ms (5× our 100ms poll cadence) to surface in field // logs so we can spot drift toward the autonomous-return failure mode. const ESCROW_WATCHDOG_WARN_MS = 500 +/** + * Recognise the "phantom escrow at boot" signature: MEI reports a bill in + * escrow while its post-boot handshake (powerup flag) hasn't completed. + * + * Observed on Austin batm3 2026-06-01: the unit had been in this state + * since 2026-05-25, refusing all subsequent bills because MEI thought a + * (long-removed) bill was waiting in escrow for a stack/reject decision. + * + * Guards exclude every state where issuing a reject would be unsafe: + * - `jammed` / `stalled` / `failure` / `transportOpen` — physical issue, + * reject's motor sequence could grind debris and worsen damage + * - `stackerFull` — escrowed bill could be a real customer bill we owe + * credit for; returning it would drop money on the floor + * - `powerup` MUST be set — this is the precise post-boot-stuck + * signature. A normal in-progress transaction has powerup already + * cleared by the time a bill reaches escrow. + */ +function isPhantomEscrowSignature(dd: DestructedData | null | undefined): boolean { + if (!dd) return false + const [byte0, byte1, byte2, byte3] = dd + return ( + byte0.escrowed && + byte2.powerup && + !byte1.jammed && + !byte3.stalled && + !byte2.failure && + !byte2.transportOpen && + !byte1.stackerFull + ) +} + export class EbdsFsm extends EventEmitter { private currentStatus: string | null = null private escrowOpenedAt: number | null = null + private firstMessageProcessed: boolean = false constructor() { super() @@ -32,9 +64,25 @@ export class EbdsFsm extends EventEmitter { /** Process a parsed EBDS message and emit status change events */ process(result: ParseResult, fiatCode: string | null): void { - const { status, bill } = result + const { status, bill, destructedData } = result if (!status) return + // First-message phantom-escrow heuristic. Runs once per service start. + // If MEI is reporting the post-boot-stuck signature, fire a reject to + // clear the stale state before our FSM transitions into billsRead and + // emits spurious billsAccepted/billsRead events upstream. + if (!this.firstMessageProcessed) { + this.firstMessageProcessed = true + if (isPhantomEscrowSignature(destructedData)) { + console.warn( + '[EBDS] phantom escrow at boot (escrowed + powerup, no error flags). ' + + 'Auto-rejecting to clear stale state — no transaction in flight.' + ) + this.emit('reject') + return + } + } + // Ignore duplicate statuses (EBDS polls continuously) if (this.currentStatus === status) return // Blank stacks happen on power up or cassette re-insertion — ignore @@ -135,5 +183,6 @@ export class EbdsFsm extends EventEmitter { reset(): void { this.currentStatus = null this.escrowOpenedAt = null + this.firstMessageProcessed = false } }