feat(hal/ebds): surface accepting/stacking/returning transient states

parseStatus previously collapsed all in-motion bits into a single derived
status, hiding when the MEI was moving a bill between escrow and the
stacker/mouth. Field journals showed nothing in the window where the
BATM3 tore bills.

Surface the three transient bits between the terminal states and
escrow/standby, route them through EbdsFsm with a Date.now() timestamp,
and emit them as diagnostic events on the validator. No state machine
consumes them; they're for journalctl.

Also: warn when `returning` is observed straight out of escrow with no
host reject() — that signature is the autonomous-return failure mode we
just polled around. Surfacing it gives us field confirmation the 100ms
fix is sufficient (or evidence it isn't).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-19 22:35:30 +02:00
commit fe2bc5d314
3 changed files with 57 additions and 14 deletions

View file

@ -34,9 +34,17 @@ export class EbdsFsm extends EventEmitter {
// Blank stacks happen on power up or cassette re-insertion — ignore // Blank stacks happen on power up or cassette re-insertion — ignore
if (status === 'blankStack') return if (status === 'blankStack') return
const prev = this.currentStatus
this.currentStatus = status this.currentStatus = status
console.log('[EBDS] %d status: %s → %s', Date.now(), prev ?? '(init)', status)
switch (status) { switch (status) {
case 'accepting':
// Transient: bill is being read by the validator head. Diagnostic
// only — useful for measuring time-to-escrow under field load.
this.emit('accepting')
break
case 'billsRead': case 'billsRead':
// Bill is in escrow — verify currency and emit // Bill is in escrow — verify currency and emit
if (bill) { if (bill) {
@ -51,6 +59,26 @@ export class EbdsFsm extends EventEmitter {
} }
break break
case 'stacking':
// Transient: bill is moving from escrow to stacker. Should follow
// a host stack() command — if it precedes one, MEI is moving the
// bill on its own and we should investigate.
this.emit('stacking')
break
case 'returning':
// Transient: bill is moving from escrow back to mouth. Should
// follow a host reject() command — if it precedes one, MEI is
// autonomously returning, which is the BATM3 tearing failure mode.
if (prev === 'billsRead') {
console.warn(
'[EBDS] returning observed straight from escrow with no host reject — ' +
'MEI may be autonomously returning. Watch for torn bills.'
)
}
this.emit('returning')
break
case 'billsValid': case 'billsValid':
// Bill stacked successfully — but ignore if no denomination // Bill stacked successfully — but ignore if no denomination
// (can happen when cashbox is re-inserted) // (can happen when cashbox is re-inserted)

View file

@ -186,21 +186,29 @@ function destructData(data: Buffer): DestructedData | null {
] ]
} }
/** Derive high-level status string from destructed status bytes */ /**
* Derive high-level status string from destructed status bytes.
*
* Terminal states (stacked / returned / cheated / rejected / jammed /
* stackerOpen) take priority over transient states. Transient states
* (`stacking`, `returning`, `accepting`) are surfaced so the FSM can
* time the escrow grace window and so field logs reveal when MEI moves
* a bill on its own (the BATM3 tearing failure mode).
*/
function parseStatus(data: DestructedData): string | null { function parseStatus(data: DestructedData): string | null {
return data[0].stacked // Terminal — bill has reached a resting position
? 'billsValid' if (data[0].stacked) return 'billsValid'
: data[0].escrowed if (data[0].returned || data[1].cheated || data[1].rejected) return 'billsRejected'
? 'billsRead' if (data[1].jammed) return 'jam'
: data[0].returned || data[1].cheated || data[1].rejected if (!data[1].cassetteAttached) return 'stackerOpen'
? 'billsRejected' // Transient — bill is in motion. Surface before `escrowed` so we observe
: data[1].jammed // the MEI moving the bill out of escrow autonomously.
? 'jam' if (data[0].stacking) return 'stacking'
: !data[1].cassetteAttached if (data[0].returning) return 'returning'
? 'stackerOpen' if (data[0].escrowed) return 'billsRead'
: data[0].idling if (data[0].accepting) return 'accepting'
? 'standby' if (data[0].idling) return 'standby'
: null return null
} }
/** Destruct the control byte (§6.4) */ /** Destruct the control byte (§6.4) */

View file

@ -118,6 +118,13 @@ export class EbdsValidator extends EventEmitter implements BillValidator {
this.emit('billsRejected') this.emit('billsRejected')
}) })
// Diagnostic events — surface MEI transport motion so field logs can
// distinguish host-driven movement from autonomous (timeout-induced)
// movement. No state machine consumes these; they're for journals.
this.fsm.on('accepting', () => this.emit('accepting'))
this.fsm.on('stacking', () => this.emit('stacking'))
this.fsm.on('returning', () => this.emit('returning'))
this.fsm.on('stackerOpen', () => { this.fsm.on('stackerOpen', () => {
this.emit('stackerOpen') this.emit('stackerOpen')
}) })