diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 2d21019..6d5ed79 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -15,7 +15,7 @@ export interface CassetteConfig { export interface HalConfig { validator: { - type: 'id003' + type: 'id003' | 'ebds' device: string | string[] fiatCode: string } diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 14db403..fb3570d 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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] || [] } diff --git a/apps/machine/src/config/device.ts b/apps/machine/src/config/device.ts index 54d2db0..23e0953 100644 --- a/apps/machine/src/config/device.ts +++ b/apps/machine/src/config/device.ts @@ -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 = {} - // 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 diff --git a/apps/machine/src/services/hal.ts b/apps/machine/src/services/hal.ts index 22f2778..129739d 100644 --- a/apps/machine/src/services/hal.ts +++ b/apps/machine/src/services/hal.ts @@ -23,7 +23,7 @@ export interface CassetteConfig { export interface HalConfig { validator: { - type: 'id003' + type: 'id003' | 'ebds' device: string | string[] fiatCode: string } diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 39aea18..b8c93f2 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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) diff --git a/packages/hal/src/index.ts b/packages/hal/src/index.ts index 2887a2b..abc1bea 100644 --- a/packages/hal/src/index.ts +++ b/packages/hal/src/index.ts @@ -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 { diff --git a/packages/hal/src/validators/ebds/denominations.ts b/packages/hal/src/validators/ebds/denominations.ts new file mode 100644 index 0000000..e7707a3 --- /dev/null +++ b/packages/hal/src/validators/ebds/denominations.ts @@ -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 = { + 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], +} diff --git a/packages/hal/src/validators/ebds/ebds-fsm.ts b/packages/hal/src/validators/ebds/ebds-fsm.ts new file mode 100644 index 0000000..2813329 --- /dev/null +++ b/packages/hal/src/validators/ebds/ebds-fsm.ts @@ -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 + } +} diff --git a/packages/hal/src/validators/ebds/ebds-rs232.ts b/packages/hal/src/validators/ebds/ebds-rs232.ts new file mode 100644 index 0000000..d94750f --- /dev/null +++ b/packages/hal/src/validators/ebds/ebds-rs232.ts @@ -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 { + 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 { + 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 } diff --git a/packages/hal/src/validators/ebds/index.ts b/packages/hal/src/validators/ebds/index.ts new file mode 100644 index 0000000..9ae78b7 --- /dev/null +++ b/packages/hal/src/validators/ebds/index.ts @@ -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 | 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 + } +} diff --git a/packages/hal/src/validators/index.ts b/packages/hal/src/validators/index.ts index 0588aec..a56be61 100644 --- a/packages/hal/src/validators/index.ts +++ b/packages/hal/src/validators/index.ts @@ -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}`) }