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 { 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
|
// 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
|
// 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.
|
// logs so we can spot drift toward the autonomous-return failure mode.
|
||||||
const ESCROW_WATCHDOG_WARN_MS = 500
|
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 {
|
export class EbdsFsm extends EventEmitter {
|
||||||
private currentStatus: string | null = null
|
private currentStatus: string | null = null
|
||||||
private escrowOpenedAt: number | null = null
|
private escrowOpenedAt: number | null = null
|
||||||
|
private firstMessageProcessed: boolean = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
@ -32,9 +64,25 @@ export class EbdsFsm extends EventEmitter {
|
||||||
|
|
||||||
/** Process a parsed EBDS message and emit status change events */
|
/** Process a parsed EBDS message and emit status change events */
|
||||||
process(result: ParseResult, fiatCode: string | null): void {
|
process(result: ParseResult, fiatCode: string | null): void {
|
||||||
const { status, bill } = result
|
const { status, bill, destructedData } = result
|
||||||
if (!status) return
|
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)
|
// Ignore duplicate statuses (EBDS polls continuously)
|
||||||
if (this.currentStatus === status) return
|
if (this.currentStatus === status) return
|
||||||
// Blank stacks happen on power up or cassette re-insertion — ignore
|
// Blank stacks happen on power up or cassette re-insertion — ignore
|
||||||
|
|
@ -135,5 +183,6 @@ export class EbdsFsm extends EventEmitter {
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.currentStatus = null
|
this.currentStatus = null
|
||||||
this.escrowOpenedAt = null
|
this.escrowOpenedAt = null
|
||||||
|
this.firstMessageProcessed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue