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:
parent
f2bd38ac61
commit
31c4e88759
1 changed files with 51 additions and 2 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue