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 {
|
export interface HalConfig {
|
||||||
validator: {
|
validator: {
|
||||||
type: 'id003'
|
type: 'id003' | 'ebds'
|
||||||
device: string | string[]
|
device: string | string[]
|
||||||
fiatCode: string
|
fiatCode: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,10 @@ app.whenReady().then(() => {
|
||||||
{ denomination: 100, count: 500 },
|
{ denomination: 100, count: 500 },
|
||||||
],
|
],
|
||||||
gaia: [{ denomination: 20, count: 50 }],
|
gaia: [{ denomination: 20, count: 50 }],
|
||||||
|
batm3: [
|
||||||
|
{ denomination: 20, count: 500 },
|
||||||
|
{ denomination: 50, count: 500 },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
seedCassettes = presets[model] || []
|
seedCassettes = presets[model] || []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import type { HalConfig, CassetteConfig } from '@/services/hal'
|
||||||
/**
|
/**
|
||||||
* Supported machine models
|
* Supported machine models
|
||||||
*/
|
*/
|
||||||
export type MachineModel = 'sintra' | 'tejo' | 'douro' | 'gaia' | 'custom'
|
export type MachineModel = 'sintra' | 'tejo' | 'douro' | 'gaia' | 'batm3' | 'custom'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full device configuration
|
* Full device configuration
|
||||||
|
|
@ -28,7 +28,7 @@ export interface DeviceConfig {
|
||||||
/** Bill validator configuration */
|
/** Bill validator configuration */
|
||||||
validator: {
|
validator: {
|
||||||
/** Validator protocol type */
|
/** Validator protocol type */
|
||||||
type: 'id003'
|
type: 'id003' | 'ebds'
|
||||||
/** Serial device path(s) */
|
/** Serial device path(s) */
|
||||||
device: string | string[]
|
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
|
* Custom configuration - all values must be provided via env/runtime
|
||||||
*/
|
*/
|
||||||
|
|
@ -200,10 +221,10 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
|
||||||
|
|
||||||
const overrides: Partial<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
|
const validatorDevice = import.meta.env.VITE_LAMASSU_VALIDATOR_DEVICE
|
||||||
if (validatorDevice) {
|
if (validatorDevice) {
|
||||||
overrides.validator = { type: 'id003', device: validatorDevice }
|
overrides.validator = { type: MACHINE_PRESETS[model].validator.type, device: validatorDevice }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispenser device override
|
// Dispenser device override
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export interface CassetteConfig {
|
||||||
|
|
||||||
export interface HalConfig {
|
export interface HalConfig {
|
||||||
validator: {
|
validator: {
|
||||||
type: 'id003'
|
type: 'id003' | 'ebds'
|
||||||
device: string | string[]
|
device: string | string[]
|
||||||
fiatCode: string
|
fiatCode: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -767,13 +767,18 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
fiatCode.value = runtimeFiatCode
|
fiatCode.value = runtimeFiatCode
|
||||||
|
|
||||||
// Build device config from runtime values
|
// 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 = {}
|
const overrides: any = {}
|
||||||
if (runtimeConfig.validatorDevice) {
|
if (runtimeConfig.validatorDevice) {
|
||||||
overrides.validator = { type: 'id003', device: runtimeConfig.validatorDevice }
|
overrides.validator = { type: preset.validator.type, device: runtimeConfig.validatorDevice }
|
||||||
}
|
}
|
||||||
if (runtimeConfig.dispenserDevice) {
|
if (runtimeConfig.dispenserDevice) {
|
||||||
overrides.dispenser = { type: 'f56', device: runtimeConfig.dispenserDevice, cassettes: [] }
|
overrides.dispenser = {
|
||||||
|
type: preset.dispenser.type,
|
||||||
|
device: runtimeConfig.dispenserDevice,
|
||||||
|
cassettes: [],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (runtimeConfig.cassettes) {
|
if (runtimeConfig.cassettes) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -781,7 +786,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
if (overrides.dispenser) {
|
if (overrides.dispenser) {
|
||||||
overrides.dispenser.cassettes = cassettes
|
overrides.dispenser.cassettes = cassettes
|
||||||
} else {
|
} else {
|
||||||
overrides.dispenser = { type: 'f56', device: '', cassettes }
|
overrides.dispenser = { type: preset.dispenser.type, device: '', cassettes }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ATM] Failed to parse cassettes:', e)
|
console.error('[ATM] Failed to parse cassettes:', e)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Validators
|
// Validators
|
||||||
export { Id003, createValidator, type ValidatorType } from './validators/index.js'
|
export { Id003, EbdsValidator, createValidator, type ValidatorType } from './validators/index.js'
|
||||||
|
|
||||||
// Dispensers
|
// Dispensers
|
||||||
export {
|
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 { Id003 } from './id003/index.js'
|
||||||
|
export { EbdsValidator } from './ebds/index.js'
|
||||||
export type { ValidatorConfig, BillValidator, BillData } from '../types.js'
|
export type { ValidatorConfig, BillValidator, BillData } from '../types.js'
|
||||||
|
|
||||||
import { Id003 } from './id003/index.js'
|
import { Id003 } from './id003/index.js'
|
||||||
|
import { EbdsValidator } from './ebds/index.js'
|
||||||
import type { ValidatorConfig, BillValidator } from '../types.js'
|
import type { ValidatorConfig, BillValidator } from '../types.js'
|
||||||
|
|
||||||
export type ValidatorType = 'id003'
|
export type ValidatorType = 'id003' | 'ebds'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a bill validator instance
|
* Create a bill validator instance
|
||||||
* @param type Validator type (e.g., 'id003')
|
* @param type Validator type (e.g., 'id003', 'ebds')
|
||||||
* @param config Validator configuration
|
* @param config Validator configuration
|
||||||
*/
|
*/
|
||||||
export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator {
|
export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'id003':
|
case 'id003':
|
||||||
return Id003.factory(config)
|
return Id003.factory(config)
|
||||||
|
case 'ebds':
|
||||||
|
return EbdsValidator.factory(config)
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown validator type: ${type}`)
|
throw new Error(`Unknown validator type: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue