Merge branch 'feat/batm3-support'

This commit is contained in:
Patrick Mulligan 2026-03-23 20:41:57 -04:00
commit 98b17073d8
15 changed files with 838 additions and 16 deletions

View file

@ -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
} }

View file

@ -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] || []
} }

View file

@ -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

View file

@ -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
} }

View file

@ -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)

View file

@ -9,6 +9,7 @@
# bash build-iso.sh douro # Bay Trail, kernel 5.15, GTQ # bash build-iso.sh douro # Bay Trail, kernel 5.15, GTQ
# bash build-iso.sh tejo # UP4000, kernel 6.6, GTQ # bash build-iso.sh tejo # UP4000, kernel 6.6, GTQ
# bash build-iso.sh sintra # UPBoard, kernel 6.6, EUR # bash build-iso.sh sintra # UPBoard, kernel 6.6, EUR
# bash build-iso.sh batm3 # General Bytes BATM3, EBDS validator, USD
set -euo pipefail set -euo pipefail
MODEL="${1:-}" MODEL="${1:-}"
@ -17,13 +18,14 @@ if [ -z "$MODEL" ]; then
echo " douro - Bay Trail Atom, kernel 5.15, eDP panel, GTQ" echo " douro - Bay Trail Atom, kernel 5.15, eDP panel, GTQ"
echo " tejo - UP4000/UPBoard, kernel 6.6, GTQ" echo " tejo - UP4000/UPBoard, kernel 6.6, GTQ"
echo " sintra - UPBoard, kernel 6.6, EUR" echo " sintra - UPBoard, kernel 6.6, EUR"
echo " batm3 - General Bytes BATM3, EBDS validator, USD"
exit 1 exit 1
fi fi
case "$MODEL" in case "$MODEL" in
douro|tejo|sintra) ;; douro|tejo|sintra|batm3) ;;
*) *)
echo "ERROR: Unknown model '$MODEL'. Use 'douro', 'tejo', or 'sintra'." echo "ERROR: Unknown model '$MODEL'. Use 'douro', 'tejo', 'sintra', or 'batm3'."
exit 1 exit 1
;; ;;
esac esac

View file

@ -18,6 +18,7 @@ let
douro = "GTQ"; douro = "GTQ";
tejo = "GTQ"; tejo = "GTQ";
sintra = "EUR"; sintra = "EUR";
batm3 = "USD";
}.${machineModel} or "USD"; }.${machineModel} or "USD";
# .env template — runtime secrets are provisioned later via provision-atm.sh. # .env template — runtime secrets are provisioned later via provision-atm.sh.
@ -86,6 +87,11 @@ in
"usbserial" # USB-to-serial adapters (ttyUSB0/1 for validator/printer) "usbserial" # USB-to-serial adapters (ttyUSB0/1 for validator/printer)
"ftdi_sio" # FTDI USB serial (common in ATM peripherals) "ftdi_sio" # FTDI USB serial (common in ATM peripherals)
"cp210x" # CP210x USB serial (alternative adapter) "cp210x" # CP210x USB serial (alternative adapter)
] ++ lib.optionals (machineModel == "batm3") [
"cdc_acm" # USB CDC ACM for MEI BNR Advance validator
"usbserial" # USB-to-serial for F56 dispenser adapter
"ftdi_sio" # FTDI USB serial (common RS232 adapter)
"cp210x" # CP210x USB serial (alternative adapter)
]; ];
kernelParams = [ kernelParams = [
@ -207,6 +213,11 @@ in
KERNEL=="ttyS[0-9]*", MODE="0666" KERNEL=="ttyS[0-9]*", MODE="0666"
KERNEL=="ttyUSB[0-9]*", MODE="0666" KERNEL=="ttyUSB[0-9]*", MODE="0666"
KERNEL=="ttyACM[0-9]*", MODE="0666" KERNEL=="ttyACM[0-9]*", MODE="0666"
'' + lib.optionalString (machineModel == "batm3") ''
# ── BATM3 MEI BNR Advance USB CDC ACM ──────────────────────────────
# Stable symlink for the bill validator (vendor 0x0bed = MEI)
SUBSYSTEM=="tty", ATTRS{idVendor}=="0bed", SYMLINK+="ttyValidator"
'' + lib.optionalString (machineModel == "tejo") '' '' + lib.optionalString (machineModel == "tejo") ''
# ── Tejo serial port symlinks ────────────────────────────────────── # ── Tejo serial port symlinks ──────────────────────────────────────

View file

@ -39,14 +39,18 @@ case "$target" in
sintra) sintra)
build_and_push "sintra live ISO" "iso-sintra" build_and_push "sintra live ISO" "iso-sintra"
;; ;;
batm3)
build_and_push "batm3 live ISO" "iso-batm3"
;;
all) all)
build_and_push "douro-installed toplevel" "nixosConfigurations.douro-installed.config.system.build.toplevel" build_and_push "douro-installed toplevel" "nixosConfigurations.douro-installed.config.system.build.toplevel"
build_and_push "douro live ISO" "iso-douro" build_and_push "douro live ISO" "iso-douro"
build_and_push "tejo live ISO" "iso-tejo" build_and_push "tejo live ISO" "iso-tejo"
build_and_push "sintra live ISO" "iso-sintra" build_and_push "sintra live ISO" "iso-sintra"
build_and_push "batm3 live ISO" "iso-batm3"
;; ;;
*) *)
echo "Usage: $0 [douro|douro-live|tejo|sintra|all]" echo "Usage: $0 [douro|douro-live|tejo|sintra|batm3|all]"
exit 1 exit 1
;; ;;
esac esac

View file

@ -57,6 +57,7 @@
douro = "GTQ"; douro = "GTQ";
tejo = "GTQ"; tejo = "GTQ";
sintra = "EUR"; sintra = "EUR";
batm3 = "USD";
}; };
lib = nixpkgs.lib; lib = nixpkgs.lib;
@ -213,6 +214,7 @@
douro = mkLiveConfig "douro"; douro = mkLiveConfig "douro";
tejo = mkLiveConfig "tejo"; tejo = mkLiveConfig "tejo";
sintra = mkLiveConfig "sintra"; sintra = mkLiveConfig "sintra";
batm3 = mkLiveConfig "batm3";
# Backwards compat # Backwards compat
lamassu-live-douro = mkLiveConfig "douro"; lamassu-live-douro = mkLiveConfig "douro";
@ -237,11 +239,13 @@
atm-app-douro = mkAtmApp { model = "douro"; fiatCode = "GTQ"; }; atm-app-douro = mkAtmApp { model = "douro"; fiatCode = "GTQ"; };
atm-app-tejo = mkAtmApp { model = "tejo"; fiatCode = "GTQ"; }; atm-app-tejo = mkAtmApp { model = "tejo"; fiatCode = "GTQ"; };
atm-app-sintra = mkAtmApp { model = "sintra"; fiatCode = "EUR"; }; atm-app-sintra = mkAtmApp { model = "sintra"; fiatCode = "EUR"; };
atm-app-batm3 = mkAtmApp { model = "batm3"; fiatCode = "USD"; };
# ISO images # ISO images
iso-douro = self.nixosConfigurations.douro.config.system.build.isoImage; iso-douro = self.nixosConfigurations.douro.config.system.build.isoImage;
iso-tejo = self.nixosConfigurations.tejo.config.system.build.isoImage; iso-tejo = self.nixosConfigurations.tejo.config.system.build.isoImage;
iso-sintra = self.nixosConfigurations.sintra.config.system.build.isoImage; iso-sintra = self.nixosConfigurations.sintra.config.system.build.isoImage;
iso-batm3 = self.nixosConfigurations.batm3.config.system.build.isoImage;
# Raw disk images (dd-able to mSATA/eMMC, proper GPT + ESP) # Raw disk images (dd-able to mSATA/eMMC, proper GPT + ESP)
disk-image-douro = import (nixpkgs + "/nixos/lib/make-disk-image.nix") { disk-image-douro = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {

View file

@ -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 {

View 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],
}

View 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
}
}

View 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 }

View 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
}
}

View file

@ -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}`)
} }