feat(hal/ebds): auto-reject phantom escrow at boot

If MEI reports `escrowed` AND `powerup` on the first message after
service start with no error flags (jammed/stalled/failure/
transportOpen/stackerFull), fire a reject to clear stale state
before the FSM emits spurious billsAccepted/billsRead upstream.

Guards exclude every scenario where reject's motor sequence could
do harm: physical jams (`jammed`/`stalled`/`failure`/`transportOpen`)
are left alone for hands-on diagnosis, and a real customer bill in
escrow with `stackerFull` is preserved rather than returned.

Diagnosed on Austin batm3 2026-06-01: unit had been stuck in this
state since 2026-05-25 (six days, three boots) requiring manual
intervention to clear. Self-heals now on next service restart.
This commit is contained in:
Padreug 2026-06-01 15:50:58 +02:00
commit 31c4e88759

View file

@ -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
}
}