feat(hal): add EBDS bill validator driver for BATM3 support
Port MEI CashFlow SC / BNR Advance EBDS protocol from lamassu-machine to TypeScript HAL. Adds 'batm3' machine model preset (EBDS validator + F56 dispenser). Fixes hardcoded 'id003' validator type in device config overrides so model presets correctly propagate their validator type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2cc0acfdef
commit
71eff33f1e
11 changed files with 814 additions and 13 deletions
|
|
@ -15,7 +15,7 @@ export interface CassetteConfig {
|
|||
|
||||
export interface HalConfig {
|
||||
validator: {
|
||||
type: 'id003'
|
||||
type: 'id003' | 'ebds'
|
||||
device: string | string[]
|
||||
fiatCode: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,6 +390,10 @@ app.whenReady().then(() => {
|
|||
{ denomination: 100, count: 500 },
|
||||
],
|
||||
gaia: [{ denomination: 20, count: 50 }],
|
||||
batm3: [
|
||||
{ denomination: 20, count: 500 },
|
||||
{ denomination: 50, count: 500 },
|
||||
],
|
||||
}
|
||||
seedCassettes = presets[model] || []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import type { HalConfig, CassetteConfig } from '@/services/hal'
|
|||
/**
|
||||
* Supported machine models
|
||||
*/
|
||||
export type MachineModel = 'sintra' | 'tejo' | 'douro' | 'gaia' | 'custom'
|
||||
export type MachineModel = 'sintra' | 'tejo' | 'douro' | 'gaia' | 'batm3' | 'custom'
|
||||
|
||||
/**
|
||||
* Full device configuration
|
||||
|
|
@ -28,7 +28,7 @@ export interface DeviceConfig {
|
|||
/** Bill validator configuration */
|
||||
validator: {
|
||||
/** Validator protocol type */
|
||||
type: 'id003'
|
||||
type: 'id003' | 'ebds'
|
||||
/** Serial device path(s) */
|
||||
device: string | string[]
|
||||
}
|
||||
|
|
@ -130,6 +130,27 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
|
|||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* General Bytes BATM3 (XL variant)
|
||||
* - Validator: MEI BNR Advance or CashFlow SC (EBDS protocol, USB CDC ACM)
|
||||
* - Dispenser: Fujitsu F56 (RS232)
|
||||
*/
|
||||
batm3: {
|
||||
model: 'batm3',
|
||||
validator: {
|
||||
type: 'ebds',
|
||||
device: '/dev/ttyACM0',
|
||||
},
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
device: '/dev/ttyUSB0',
|
||||
cassettes: [
|
||||
{ denomination: 20, count: 500 },
|
||||
{ denomination: 50, count: 500 },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Custom configuration - all values must be provided via env/runtime
|
||||
*/
|
||||
|
|
@ -200,10 +221,10 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
|
|||
|
||||
const overrides: Partial<DeviceConfig> = {}
|
||||
|
||||
// Validator device override
|
||||
// Validator device override (preserve validator type from preset)
|
||||
const validatorDevice = import.meta.env.VITE_LAMASSU_VALIDATOR_DEVICE
|
||||
if (validatorDevice) {
|
||||
overrides.validator = { type: 'id003', device: validatorDevice }
|
||||
overrides.validator = { type: MACHINE_PRESETS[model].validator.type, device: validatorDevice }
|
||||
}
|
||||
|
||||
// Dispenser device override
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export interface CassetteConfig {
|
|||
|
||||
export interface HalConfig {
|
||||
validator: {
|
||||
type: 'id003'
|
||||
type: 'id003' | 'ebds'
|
||||
device: string | string[]
|
||||
fiatCode: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -767,13 +767,18 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
fiatCode.value = runtimeFiatCode
|
||||
|
||||
// Build device config from runtime values
|
||||
const { getDeviceConfig, toHalConfig } = await import('@/config')
|
||||
const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config')
|
||||
const preset = MACHINE_PRESETS[model] ?? MACHINE_PRESETS.sintra
|
||||
const overrides: any = {}
|
||||
if (runtimeConfig.validatorDevice) {
|
||||
overrides.validator = { type: 'id003', device: runtimeConfig.validatorDevice }
|
||||
overrides.validator = { type: preset.validator.type, device: runtimeConfig.validatorDevice }
|
||||
}
|
||||
if (runtimeConfig.dispenserDevice) {
|
||||
overrides.dispenser = { type: 'f56', device: runtimeConfig.dispenserDevice, cassettes: [] }
|
||||
overrides.dispenser = {
|
||||
type: preset.dispenser.type,
|
||||
device: runtimeConfig.dispenserDevice,
|
||||
cassettes: [],
|
||||
}
|
||||
}
|
||||
if (runtimeConfig.cassettes) {
|
||||
try {
|
||||
|
|
@ -781,7 +786,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
if (overrides.dispenser) {
|
||||
overrides.dispenser.cassettes = cassettes
|
||||
} else {
|
||||
overrides.dispenser = { type: 'f56', device: '', cassettes }
|
||||
overrides.dispenser = { type: preset.dispenser.type, device: '', cassettes }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ATM] Failed to parse cassettes:', e)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
*/
|
||||
|
||||
// Validators
|
||||
export { Id003, createValidator, type ValidatorType } from './validators/index.js'
|
||||
export { Id003, EbdsValidator, createValidator, type ValidatorType } from './validators/index.js'
|
||||
|
||||
// Dispensers
|
||||
export {
|
||||
|
|
|
|||
22
packages/hal/src/validators/ebds/denominations.ts
Normal file
22
packages/hal/src/validators/ebds/denominations.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* MEI EBDS Denomination Tables
|
||||
*
|
||||
* Static denomination lists per fiat currency, used for lowestBill/highestBill
|
||||
* when the device hasn't reported denominations via extended data yet.
|
||||
*
|
||||
* Ported from lamassu-machine/lib/mei/denominations.js
|
||||
* Works for both CashFlow SC and BNR Advance (same EBDS protocol).
|
||||
*/
|
||||
|
||||
export const denominations: Record<string, number[]> = {
|
||||
AMD: [50, 100, 500, 1000, 5000, 10000, 20000, 50000, 100000],
|
||||
AUD: [5, 10, 20, 50, 100],
|
||||
CAD: [5, 10, 20, 50, 100],
|
||||
EUR: [5, 10, 20, 50, 100, 200, 500],
|
||||
GBP: [5, 10, 20, 50],
|
||||
GHS: [1, 2, 5, 10, 20, 50],
|
||||
GTQ: [1, 5, 10, 20, 50, 100, 200],
|
||||
IOM: [1, 5, 10, 20, 50],
|
||||
MXN: [20, 50, 100, 200, 500, 1000],
|
||||
USD: [1, 5, 10, 20, 50, 100],
|
||||
}
|
||||
83
packages/hal/src/validators/ebds/ebds-fsm.ts
Normal file
83
packages/hal/src/validators/ebds/ebds-fsm.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* EBDS Status Tracker
|
||||
*
|
||||
* Unlike ID003's complex state machine, the EBDS protocol reports status
|
||||
* directly via bits in the Omnibus Reply. This module deduplicates status
|
||||
* changes and translates them into the BillValidator event interface.
|
||||
*
|
||||
* Status flow:
|
||||
* standby → billsAccepted → billsRead(escrow) → billsValid(stacked)
|
||||
* → billsRejected(returned)
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ParseResult } from './ebds-rs232.js'
|
||||
|
||||
export class EbdsFsm extends EventEmitter {
|
||||
private currentStatus: string | null = null
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
static factory(): EbdsFsm {
|
||||
return new EbdsFsm()
|
||||
}
|
||||
|
||||
/** Process a parsed EBDS message and emit status change events */
|
||||
process(result: ParseResult, fiatCode: string | null): void {
|
||||
const { status, bill } = result
|
||||
if (!status) return
|
||||
|
||||
// Ignore duplicate statuses (EBDS polls continuously)
|
||||
if (this.currentStatus === status) return
|
||||
// Blank stacks happen on power up or cassette re-insertion — ignore
|
||||
if (status === 'blankStack') return
|
||||
|
||||
this.currentStatus = status
|
||||
|
||||
switch (status) {
|
||||
case 'billsRead':
|
||||
// Bill is in escrow — verify currency and emit
|
||||
if (bill) {
|
||||
if (fiatCode && bill.code !== fiatCode) {
|
||||
console.warn('[EBDS] Bill currency mismatch: expected %s, got %s', fiatCode, bill.code)
|
||||
this.emit('reject')
|
||||
return
|
||||
}
|
||||
this.emit('billsAccepted')
|
||||
// Emit billsRead on next tick (matches lamassu-machine behavior)
|
||||
process.nextTick(() => this.emit('billsRead', bill))
|
||||
}
|
||||
break
|
||||
|
||||
case 'billsValid':
|
||||
// Bill stacked successfully — but ignore if no denomination
|
||||
// (can happen when cashbox is re-inserted)
|
||||
if (bill && !bill.denomination) return
|
||||
this.emit('billsValid')
|
||||
break
|
||||
|
||||
case 'billsRejected':
|
||||
this.emit('billsRejected')
|
||||
break
|
||||
|
||||
case 'jam':
|
||||
this.emit('error', new Error('Bill validator jam'))
|
||||
break
|
||||
|
||||
case 'stackerOpen':
|
||||
this.emit('stackerOpen')
|
||||
break
|
||||
|
||||
case 'standby':
|
||||
this.emit('standby')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset status tracking (e.g., after reload) */
|
||||
reset(): void {
|
||||
this.currentStatus = null
|
||||
}
|
||||
}
|
||||
462
packages/hal/src/validators/ebds/ebds-rs232.ts
Normal file
462
packages/hal/src/validators/ebds/ebds-rs232.ts
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
/**
|
||||
* EBDS RS232 Protocol Layer
|
||||
*
|
||||
* Handles serial communication for MEI bill validators using the
|
||||
* Enhanced Bill Data Stream (EBDS) protocol.
|
||||
*
|
||||
* Supports: MEI CashFlow SC, MEI BNR Advance (validator mode)
|
||||
*
|
||||
* Frame format (§6.1.1):
|
||||
* [STX=0x02] [LEN] [CTRL] [DATA 0..n] [ETX=0x03] [CHK]
|
||||
* CHK = XOR of all bytes from LEN through ETX (indices 1 to len-2)
|
||||
*
|
||||
* Serial: 9600 baud, 7 data bits, even parity, 1 stop bit
|
||||
*
|
||||
* Ported from lamassu-machine/lib/mei/cashflow_sc.js
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { SerialPort } from 'serialport'
|
||||
|
||||
const STX = 0x02
|
||||
const ETX = 0x03
|
||||
const ENQ = 0x05
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bit destructuring (§7.1.2 Omnibus Reply)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StatusByte0 {
|
||||
idling: boolean
|
||||
accepting: boolean
|
||||
escrowed: boolean
|
||||
stacking: boolean
|
||||
stacked: boolean
|
||||
returning: boolean
|
||||
returned: boolean
|
||||
}
|
||||
|
||||
interface StatusByte1 {
|
||||
cheated: boolean
|
||||
rejected: boolean
|
||||
jammed: boolean
|
||||
stackerFull: boolean
|
||||
cassetteAttached: boolean
|
||||
paused: boolean
|
||||
calibrating: boolean
|
||||
}
|
||||
|
||||
interface StatusByte2 {
|
||||
powerup: boolean
|
||||
invalidCommand: boolean
|
||||
failure: boolean
|
||||
noteValue: number
|
||||
transportOpen: boolean
|
||||
}
|
||||
|
||||
interface StatusByte3 {
|
||||
stalled: boolean
|
||||
flashDownload: boolean
|
||||
prestack: boolean
|
||||
rawBarcode: boolean
|
||||
deviceCapabilities: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
interface StatusByte4 {
|
||||
modelNumber: number
|
||||
}
|
||||
|
||||
interface StatusByte5 {
|
||||
codeRevision: number
|
||||
}
|
||||
|
||||
export type DestructedData = [
|
||||
StatusByte0,
|
||||
StatusByte1,
|
||||
StatusByte2,
|
||||
StatusByte3,
|
||||
StatusByte4,
|
||||
StatusByte5,
|
||||
]
|
||||
|
||||
export interface ExtendedBillData {
|
||||
index: number
|
||||
code: string
|
||||
base: number
|
||||
sign: number | null
|
||||
exponent: number
|
||||
orientation: number
|
||||
type: number
|
||||
series: number
|
||||
compatibility: number
|
||||
version: number
|
||||
banknoteClassification: number
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
status: string | null
|
||||
bill?: { denomination: number | null; code: string }
|
||||
destructedData?: DestructedData | null
|
||||
}
|
||||
|
||||
export interface EbdsRs232Config {
|
||||
device: string | string[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure functions — checksum, frame parsing, status interpretation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** XOR checksum over bytes[1] through bytes[len-3] (LEN through last DATA) */
|
||||
function computeChecksum(frame: number[] | Buffer): number {
|
||||
let cs = 0x00
|
||||
for (let i = 1; i < frame.length - 2; i++) {
|
||||
cs = (frame[i] ?? 0) ^ cs
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
function validatePacket(frame: Buffer): void {
|
||||
if (frame[0] !== STX) throw new Error('No STX present')
|
||||
const frameLength = frame.length
|
||||
if (frame[1] !== frameLength) throw new Error("Frame lengths don't match")
|
||||
if (frame[frameLength - 2] !== ETX) throw new Error('No ETX present')
|
||||
const checksum = computeChecksum(frame)
|
||||
if (frame[frameLength - 1] !== checksum) throw new Error('Bad checksum')
|
||||
}
|
||||
|
||||
function getNth(n: number): (b: number) => boolean {
|
||||
return (b: number) => Boolean((b >> n) & 0b1)
|
||||
}
|
||||
|
||||
function getNths(f: number, t: number): (b: number) => number {
|
||||
const n = t - f
|
||||
const mask = (0b1 << n) - 1
|
||||
return (b: number) => (b >> f) & mask
|
||||
}
|
||||
|
||||
/** Parse 6 status bytes from Omnibus Reply data (§7.1.2) */
|
||||
function destructData(data: Buffer): DestructedData | null {
|
||||
if (data.length < 6) return null
|
||||
|
||||
const b0 = data[0] ?? 0
|
||||
const b1 = data[1] ?? 0
|
||||
const b2 = data[2] ?? 0
|
||||
const b3 = data[3] ?? 0
|
||||
const b4 = data[4] ?? 0
|
||||
const b5 = data[5] ?? 0
|
||||
|
||||
return [
|
||||
{
|
||||
idling: getNth(0)(b0),
|
||||
accepting: getNth(1)(b0),
|
||||
escrowed: getNth(2)(b0),
|
||||
stacking: getNth(3)(b0),
|
||||
stacked: getNth(4)(b0),
|
||||
returning: getNth(5)(b0),
|
||||
returned: getNth(6)(b0),
|
||||
},
|
||||
{
|
||||
cheated: getNth(0)(b1),
|
||||
rejected: getNth(1)(b1),
|
||||
jammed: getNth(2)(b1),
|
||||
stackerFull: getNth(3)(b1),
|
||||
cassetteAttached: getNth(4)(b1),
|
||||
paused: getNth(5)(b1),
|
||||
calibrating: getNth(6)(b1),
|
||||
},
|
||||
{
|
||||
powerup: getNth(0)(b2),
|
||||
invalidCommand: getNth(1)(b2),
|
||||
failure: getNth(2)(b2),
|
||||
noteValue: getNths(3, 6)(b2),
|
||||
transportOpen: getNth(6)(b2),
|
||||
},
|
||||
{
|
||||
stalled: getNth(0)(b3),
|
||||
flashDownload: getNth(1)(b3),
|
||||
prestack: getNth(2)(b3),
|
||||
rawBarcode: getNth(3)(b3),
|
||||
deviceCapabilities: getNth(4)(b3),
|
||||
disabled: getNth(5)(b3),
|
||||
},
|
||||
{ modelNumber: getNths(0, 7)(b4) },
|
||||
{ codeRevision: getNths(0, 7)(b5) },
|
||||
]
|
||||
}
|
||||
|
||||
/** Derive high-level status string from destructed status bytes */
|
||||
function parseStatus(data: DestructedData): string | null {
|
||||
return data[0].stacked
|
||||
? 'billsValid'
|
||||
: data[0].escrowed
|
||||
? 'billsRead'
|
||||
: data[0].returned || data[1].cheated || data[1].rejected
|
||||
? 'billsRejected'
|
||||
: data[1].jammed
|
||||
? 'jam'
|
||||
: !data[1].cassetteAttached
|
||||
? 'stackerOpen'
|
||||
: data[0].idling
|
||||
? 'standby'
|
||||
: null
|
||||
}
|
||||
|
||||
/** Destruct the control byte (§6.4) */
|
||||
function destructCtlByte(ctl: number): { ack: number; devType: number; msgType: number } {
|
||||
return {
|
||||
ack: ctl & 0b1,
|
||||
devType: (ctl >> 1) & 0b111,
|
||||
msgType: (ctl >> 4) & 0b111,
|
||||
}
|
||||
}
|
||||
|
||||
/** Destruct a raw frame into its parts (§6.1.1) */
|
||||
function destructFrame(
|
||||
frame: Buffer
|
||||
): { ctl: { ack: number; devType: number; msgType: number }; data: Buffer } | null {
|
||||
if (frame.length < 5) return null
|
||||
const len = frame[1] ?? 0
|
||||
if (frame.length !== len) return null
|
||||
const ctl = destructCtlByte(frame[2] ?? 0)
|
||||
// Data bytes sit between CTRL and ETX (indices 3 .. len-3)
|
||||
const data = frame.subarray(3, len - 2)
|
||||
return { ctl, data }
|
||||
}
|
||||
|
||||
/** Parse standard Omnibus Reply (msgType=0b010, §6.4.3) */
|
||||
function parseStandard(destructed: { data: Buffer }): ParseResult | null {
|
||||
const dd = destructData(destructed.data)
|
||||
const status = dd ? parseStatus(dd) : null
|
||||
return { status, destructedData: dd }
|
||||
}
|
||||
|
||||
/** Parse extended Omnibus Reply with bill info (msgType=0b111, §7.5.2) */
|
||||
function parseExtended(destructed: { data: Buffer }): ParseResult | null {
|
||||
const EXTENDED_OFFSET = 7
|
||||
const rawData = destructed.data
|
||||
|
||||
const msgSubType = rawData[0]
|
||||
if (msgSubType !== 0x02) return null
|
||||
|
||||
const extendedData = rawData.subarray(EXTENDED_OFFSET, EXTENDED_OFFSET + 18)
|
||||
const statusData = rawData.subarray(1, EXTENDED_OFFSET)
|
||||
|
||||
const code = extendedData.subarray(1, 4).toString('ascii')
|
||||
const base = parseInt(extendedData.subarray(4, 7).toString('ascii'), 10)
|
||||
const signByte = extendedData[7]
|
||||
const sign = signByte === 0x2b ? +1 : signByte === 0x2d ? -1 : null
|
||||
const exponent = parseInt(extendedData.subarray(8, 10).toString('ascii'), 10)
|
||||
|
||||
// A "blank stack" happens on power up or cassette re-insertion
|
||||
const blankStack = code === '\x00\x00\x00' || sign === null || isNaN(base) || isNaN(exponent)
|
||||
|
||||
const dd = destructData(statusData)
|
||||
|
||||
if (blankStack) {
|
||||
return { status: 'blankStack', bill: { denomination: null, code }, destructedData: dd }
|
||||
}
|
||||
|
||||
const status = dd ? parseStatus(dd) : null
|
||||
const denomination = base * Math.pow(10, sign * exponent)
|
||||
return { status, bill: { denomination, code }, destructedData: dd }
|
||||
}
|
||||
|
||||
/** Interpret a complete frame, dispatching to standard or extended parser */
|
||||
function interpret(frame: Buffer): ParseResult | null {
|
||||
const destructed = destructFrame(frame)
|
||||
if (!destructed) return null
|
||||
|
||||
const { msgType } = destructed.ctl
|
||||
|
||||
if (msgType === 0b010) return parseStandard(destructed)
|
||||
if (msgType === 0b111) return parseExtended(destructed)
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EbdsRs232 class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class EbdsRs232 extends EventEmitter {
|
||||
private buf: Buffer = Buffer.alloc(0)
|
||||
private config: EbdsRs232Config
|
||||
private serial: SerialPort | null = null
|
||||
private ack: number = 0x0
|
||||
private enabledDenominations: number = 0x00
|
||||
|
||||
constructor(config: EbdsRs232Config) {
|
||||
super()
|
||||
this.config = config
|
||||
}
|
||||
|
||||
static factory(config: EbdsRs232Config): EbdsRs232 {
|
||||
return new EbdsRs232(config)
|
||||
}
|
||||
|
||||
// -- Serial connection ---------------------------------------------------
|
||||
|
||||
private async _open(device: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
path: device,
|
||||
baudRate: 9600,
|
||||
parity: 'even' as const,
|
||||
dataBits: 7 as const,
|
||||
stopBits: 1 as const,
|
||||
autoOpen: false,
|
||||
rtscts: false,
|
||||
}
|
||||
|
||||
const serial = new SerialPort(options)
|
||||
this.serial = serial
|
||||
|
||||
serial.on('error', (err) => this.emit('error', err))
|
||||
serial.on('open', (err?: Error | null) => {
|
||||
if (err) return reject(err)
|
||||
serial.on('readable', () => {
|
||||
const data = serial.read() as Buffer | null
|
||||
if (data) this._process(data)
|
||||
})
|
||||
serial.on('close', () => this.emit('disconnected'))
|
||||
this.emit('connected')
|
||||
resolve()
|
||||
})
|
||||
|
||||
serial.open()
|
||||
})
|
||||
}
|
||||
|
||||
async open(cb: (err?: Error) => void): Promise<void> {
|
||||
const devices = this.config.device
|
||||
if (!devices) {
|
||||
this.emit('error', new Error('No configured devices.'))
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof devices === 'string') {
|
||||
try {
|
||||
await this._open(devices)
|
||||
cb()
|
||||
} catch (err) {
|
||||
cb(err as Error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Try each device in order
|
||||
for (const device of devices) {
|
||||
try {
|
||||
await this._open(device)
|
||||
cb()
|
||||
return
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
cb(new Error('No configured devices available.'))
|
||||
}
|
||||
|
||||
close(cb: (err?: Error | null) => void): void {
|
||||
this.serial?.close(cb)
|
||||
}
|
||||
|
||||
// -- Enable/disable denomination mask ------------------------------------
|
||||
|
||||
setEnabledDenominations(mask: number): void {
|
||||
this.enabledDenominations = mask
|
||||
}
|
||||
|
||||
getEnabledDenominations(): number {
|
||||
return this.enabledDenominations
|
||||
}
|
||||
|
||||
// -- Commands (Appendix D, Controller Message) ---------------------------
|
||||
|
||||
/** Send an Omnibus poll command with current denomination mask */
|
||||
poll(): void {
|
||||
this._dispatch([this.enabledDenominations, 0x1b, 0x10])
|
||||
}
|
||||
|
||||
/** Stack the bill currently in escrow */
|
||||
stack(): void {
|
||||
this._dispatch([this.enabledDenominations, 0x3f, 0x10])
|
||||
}
|
||||
|
||||
/** Reject/return the bill currently in escrow */
|
||||
reject(): void {
|
||||
this._dispatch([this.enabledDenominations, 0x5f, 0x10])
|
||||
}
|
||||
|
||||
/** Send initial setup command (disable all, reset state) */
|
||||
reset(): void {
|
||||
this._dispatch([0x00, 0x1b, 0x10])
|
||||
}
|
||||
|
||||
// -- Frame building (§6.1.1) --------------------------------------------
|
||||
|
||||
private _buildFrame(data: number[]): Buffer {
|
||||
const length = data.length + 5
|
||||
if (length > 0xff) throw new Error('Data length is too long!')
|
||||
this.ack = ~this.ack & 0b1
|
||||
const ctl = 0x10 | this.ack // §6.4.3
|
||||
const frame = [STX, length, ctl, ...data, ETX, 0x00]
|
||||
frame[frame.length - 1] = computeChecksum(frame)
|
||||
return Buffer.from(frame)
|
||||
}
|
||||
|
||||
private _dispatch(data: number[]): void {
|
||||
const frame = this._buildFrame(data)
|
||||
this.serial?.write(frame)
|
||||
}
|
||||
|
||||
// -- Receive / parse -----------------------------------------------------
|
||||
|
||||
private _process(data: Buffer): void {
|
||||
// Handle bare ENQ (device requesting a poll)
|
||||
if (this.buf.length === 0 && data.length === 1 && data[0] === ENQ) {
|
||||
this.poll()
|
||||
return
|
||||
}
|
||||
|
||||
this.buf = Buffer.concat([this.buf, data])
|
||||
this.buf = this._acquireSync(this.buf)
|
||||
|
||||
// Wait for size byte
|
||||
if (this.buf.length < 2) return
|
||||
|
||||
const responseSize = this.buf[1]
|
||||
if (responseSize === undefined) return
|
||||
|
||||
// Wait for whole packet
|
||||
if (this.buf.length < responseSize) return
|
||||
|
||||
const packet = this.buf.subarray(0, responseSize)
|
||||
this.buf = this.buf.subarray(responseSize)
|
||||
|
||||
try {
|
||||
validatePacket(packet)
|
||||
const result = interpret(packet)
|
||||
if (result) {
|
||||
this.emit('message', result)
|
||||
}
|
||||
} catch (ex) {
|
||||
console.error('[EBDS] Bad frame:', ex)
|
||||
this.emit('badFrame')
|
||||
}
|
||||
}
|
||||
|
||||
private _acquireSync(data: Buffer): Buffer {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i] === STX) {
|
||||
return data.subarray(i)
|
||||
}
|
||||
}
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for testing
|
||||
export { computeChecksum, interpret, destructData, parseStatus }
|
||||
200
packages/hal/src/validators/ebds/index.ts
Normal file
200
packages/hal/src/validators/ebds/index.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* EBDS Bill Validator Driver
|
||||
*
|
||||
* Supports MEI bill validators using the EBDS protocol:
|
||||
* - MEI CashFlow SC (standard BATM3 config)
|
||||
* - MEI BNR Advance (BATM3 XL, validator-only mode)
|
||||
*
|
||||
* Protocol: RS-232, 9600 baud, 7 data bits, even parity, 1 stop bit
|
||||
*
|
||||
* Ported from lamassu-machine/lib/mei/cashflow_sc.js
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { throttle } from 'lodash-es'
|
||||
import { EbdsRs232 } from './ebds-rs232.js'
|
||||
import { EbdsFsm } from './ebds-fsm.js'
|
||||
import { denominations as denominationsTable } from './denominations.js'
|
||||
import type { BillValidator, ValidatorConfig, BillData } from '../../types.js'
|
||||
|
||||
const POLLING_INTERVAL = 10_000
|
||||
|
||||
/**
|
||||
* BigNumber-like interface for bill comparisons
|
||||
*/
|
||||
interface BNLike {
|
||||
lte: (n: number) => boolean
|
||||
gte: (n: number) => boolean
|
||||
toNumber: () => number
|
||||
}
|
||||
|
||||
function BN(n: number): BNLike {
|
||||
return {
|
||||
lte: (other: number) => n <= other,
|
||||
gte: (other: number) => n >= other,
|
||||
toNumber: () => n,
|
||||
}
|
||||
}
|
||||
|
||||
export class EbdsValidator extends EventEmitter implements BillValidator {
|
||||
private config: ValidatorConfig
|
||||
private fiatCode: string | null = null
|
||||
private rs232: EbdsRs232 | null = null
|
||||
private fsm: EbdsFsm | null = null
|
||||
private poller: ReturnType<typeof setInterval> | null = null
|
||||
private _throttledError: (err: Error) => void
|
||||
|
||||
constructor(config: ValidatorConfig) {
|
||||
super()
|
||||
this.config = config
|
||||
this._throttledError = throttle((err: Error) => this.emit('error', err), 2000)
|
||||
}
|
||||
|
||||
static factory(config: ValidatorConfig): EbdsValidator {
|
||||
return new EbdsValidator(config)
|
||||
}
|
||||
|
||||
setFiatCode(fiatCode: string): void {
|
||||
this.fiatCode = fiatCode
|
||||
}
|
||||
|
||||
// EBDS validators don't have controllable lights
|
||||
lightOn(): void {}
|
||||
lightOff(): void {}
|
||||
|
||||
run(cb: (err?: Error) => void): void {
|
||||
this.fsm = EbdsFsm.factory()
|
||||
this.rs232 = EbdsRs232.factory({ device: this.config.rs232.device })
|
||||
|
||||
// -- RS232 events -------------------------------------------------------
|
||||
|
||||
this.rs232.on('message', (result) => {
|
||||
this.fsm?.process(result, this.fiatCode)
|
||||
})
|
||||
|
||||
this.rs232.on('error', (err: Error) => {
|
||||
this._throttledError(err)
|
||||
})
|
||||
|
||||
this.rs232.on('badFrame', () => {
|
||||
// Re-poll on bad frame to re-sync
|
||||
this.rs232?.poll()
|
||||
})
|
||||
|
||||
this.rs232.on('disconnected', () => {
|
||||
this.emit('disconnected')
|
||||
})
|
||||
|
||||
// -- FSM events → BillValidator events ----------------------------------
|
||||
|
||||
this.fsm.on('billsAccepted', () => {
|
||||
this.emit('billsAccepted')
|
||||
})
|
||||
|
||||
this.fsm.on('billsRead', (bill: { denomination: number | null; code: string }) => {
|
||||
if (!bill.denomination) {
|
||||
console.log('[EBDS] Bill rejected: unsupported denomination')
|
||||
this.rs232?.reject()
|
||||
return
|
||||
}
|
||||
const billData: BillData = {
|
||||
denomination: bill.denomination,
|
||||
code: 0, // EBDS doesn't use numeric escrow codes like ID003
|
||||
}
|
||||
this.emit('billsRead', billData)
|
||||
})
|
||||
|
||||
this.fsm.on('billsValid', () => {
|
||||
this.emit('billsValid')
|
||||
})
|
||||
|
||||
this.fsm.on('billsRejected', () => {
|
||||
this.emit('billsRejected')
|
||||
})
|
||||
|
||||
this.fsm.on('stackerOpen', () => {
|
||||
this.emit('stackerOpen')
|
||||
})
|
||||
|
||||
this.fsm.on('standby', () => {
|
||||
this.emit('standby')
|
||||
})
|
||||
|
||||
this.fsm.on('reject', () => {
|
||||
this.rs232?.reject()
|
||||
})
|
||||
|
||||
this.fsm.on('error', (err: Error) => {
|
||||
this.emit('error', err)
|
||||
})
|
||||
|
||||
// -- Open serial and start polling --------------------------------------
|
||||
|
||||
this.rs232.open((err) => {
|
||||
if (err) return cb(err)
|
||||
|
||||
// Initial reset command (disable all denominations)
|
||||
this.rs232!.reset()
|
||||
|
||||
// Start polling
|
||||
this.poller = setInterval(() => this.rs232?.poll(), POLLING_INTERVAL)
|
||||
|
||||
cb()
|
||||
})
|
||||
}
|
||||
|
||||
close(cb: (err?: Error) => void): void {
|
||||
if (this.poller) {
|
||||
clearInterval(this.poller)
|
||||
this.poller = null
|
||||
}
|
||||
this.rs232?.close((err) => {
|
||||
cb(err ?? undefined)
|
||||
})
|
||||
}
|
||||
|
||||
enable(): void {
|
||||
if (!this.rs232) return
|
||||
this.rs232.setEnabledDenominations(0x7f) // 7 bits, all denominations
|
||||
this.rs232.poll()
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
if (!this.rs232) return
|
||||
this.rs232.setEnabledDenominations(0x00)
|
||||
this.rs232.poll()
|
||||
}
|
||||
|
||||
stack(): void {
|
||||
this.rs232?.stack()
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
this.rs232?.reject()
|
||||
}
|
||||
|
||||
lowestBill(fiat: BNLike): BNLike {
|
||||
const bills = this._denominations()
|
||||
if (!bills) return BN(0)
|
||||
const filtered = bills.filter((bill) => fiat.lte(bill))
|
||||
if (filtered.length === 0) return BN(Math.min(...bills))
|
||||
return BN(Math.min(...filtered))
|
||||
}
|
||||
|
||||
highestBill(fiat: BNLike): BNLike {
|
||||
const bills = this._denominations()
|
||||
if (!bills) return BN(-Infinity)
|
||||
const filtered = bills.filter((bill) => fiat.gte(bill))
|
||||
if (filtered.length === 0) return BN(-Infinity)
|
||||
return BN(Math.max(...filtered))
|
||||
}
|
||||
|
||||
hasDenominations(): boolean {
|
||||
return this._denominations() !== null
|
||||
}
|
||||
|
||||
private _denominations(): number[] | null {
|
||||
if (!this.fiatCode) return null
|
||||
return denominationsTable[this.fiatCode] ?? null
|
||||
}
|
||||
}
|
||||
|
|
@ -5,22 +5,26 @@
|
|||
*/
|
||||
|
||||
export { Id003 } from './id003/index.js'
|
||||
export { EbdsValidator } from './ebds/index.js'
|
||||
export type { ValidatorConfig, BillValidator, BillData } from '../types.js'
|
||||
|
||||
import { Id003 } from './id003/index.js'
|
||||
import { EbdsValidator } from './ebds/index.js'
|
||||
import type { ValidatorConfig, BillValidator } from '../types.js'
|
||||
|
||||
export type ValidatorType = 'id003'
|
||||
export type ValidatorType = 'id003' | 'ebds'
|
||||
|
||||
/**
|
||||
* Create a bill validator instance
|
||||
* @param type Validator type (e.g., 'id003')
|
||||
* @param type Validator type (e.g., 'id003', 'ebds')
|
||||
* @param config Validator configuration
|
||||
*/
|
||||
export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator {
|
||||
switch (type) {
|
||||
case 'id003':
|
||||
return Id003.factory(config)
|
||||
case 'ebds':
|
||||
return EbdsValidator.factory(config)
|
||||
default:
|
||||
throw new Error(`Unknown validator type: ${type}`)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue