feat(docker): add dev.sh with auto-funding and ATM app setup

- Add dev.sh script for managing regtest development environment
- Implement cmd_fund to fund ATM app owner via Lightning.Pub API
- Add --fund flag to cmd_up for automatic funding on startup
- Update setup_atm_app to write VITE_APP_ID to machine .env
- Fix Electron IPC to pass appId and extensionApiUrl to renderer
- Restructure repo from nested lamassu-next/ to root

The dev.sh script now supports:
- ./dev.sh up --fund  # Start regtest and auto-fund ATM
- ./dev.sh fund       # Fund existing ATM app
- ./dev.sh status     # Show environment status
- ./dev.sh reset      # Clean restart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-15 14:19:16 -05:00
commit c98f126ba7
180 changed files with 2695 additions and 9587 deletions

50
packages/hal/Cargo.toml Normal file
View file

@ -0,0 +1,50 @@
[package]
name = "lamassu-hal"
version = "0.1.0"
edition = "2021"
description = "Hardware Abstraction Layer for Lamassu ATM"
license = "MIT"
repository = "https://github.com/lamassu/lamassu-next"
[lib]
crate-type = ["cdylib"]
[dependencies]
# napi-rs for Node.js bindings
napi = { version = "2", features = ["async", "tokio_rt"] }
napi-derive = "2"
# Async runtime
tokio = { version = "1", features = ["full"] }
# Serial port communication
tokio-serial = "5"
# Error handling
thiserror = "1"
# Async traits
async-trait = "0.1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[build-dependencies]
napi-build = "2"
[profile.release]
lto = true
strip = true
codegen-units = 1
[features]
default = []
# Enable hardware drivers (for production builds)
hardware = []
# Mock-only build (for testing/development)
mock-only = []

5
packages/hal/build.rs Normal file
View file

@ -0,0 +1,5 @@
extern crate napi_build;
fn main() {
napi_build::setup();
}

57
packages/hal/package.json Normal file
View file

@ -0,0 +1,57 @@
{
"name": "@lamassu/hal",
"version": "0.1.0",
"description": "Hardware Abstraction Layer for Lamassu ATM devices",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./validators": {
"import": "./dist/validators/index.js",
"types": "./dist/validators/index.d.ts"
},
"./dispensers": {
"import": "./dist/dispensers/index.js",
"types": "./dist/dispensers/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist"
},
"dependencies": {
"lodash-es": "^4.17.21",
"serialport": "^12.0.0"
},
"devDependencies": {
"@types/lodash-es": "^4.17.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^2.0.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"files": [
"dist",
"src"
],
"keywords": [
"lamassu",
"atm",
"hardware",
"bill-validator",
"bill-dispenser",
"id003",
"f56",
"fujitsu"
],
"license": "MIT"
}

View file

@ -0,0 +1,116 @@
/**
* Bill dimension data for F56 dispenser
*
* Each currency has:
* - thickness: Paper thickness setting
* - lengths: Map of denomination -> [length1, length2] bytes
* - polymer: Whether bills are polymer (affects ODR setting)
*/
export interface BillConfig {
thickness: number
lengths: Record<number, [number, number]>
polymer: boolean
}
export const bills: Record<string, BillConfig> = {
AED: {
thickness: 0x0c,
lengths: {
5: [0x99, 0x85],
10: [0x9d, 0x89],
20: [0x9f, 0x8b],
50: [0xa1, 0x8d],
100: [0xa5, 0x91],
200: [0xa7, 0x93],
500: [0xa9, 0x95],
1000: [0xad, 0x99],
},
polymer: false,
},
AUD: {
thickness: 0x0d,
lengths: {
5: [0x8c, 0x78],
10: [0x93, 0x7f],
20: [0x9a, 0x86],
50: [0xa1, 0x8d],
100: [0xa8, 0x94],
},
polymer: true,
},
CAD: {
thickness: 0x0d,
lengths: {
5: [0xa2, 0x8e],
10: [0xa2, 0x8e],
20: [0xa2, 0x8e],
50: [0xa2, 0x8e],
100: [0xa2, 0x8e],
},
polymer: true,
},
CHF: {
thickness: 0x0d,
lengths: {
10: [0x85, 0x71],
20: [0x8c, 0x78],
50: [0x93, 0x7f],
100: [0x9a, 0x86],
200: [0xa1, 0x8d],
1000: [0xa8, 0x94],
},
polymer: false,
},
EUR: {
thickness: 0x0c,
lengths: {
5: [0x82, 0x6e],
10: [0x89, 0x75],
20: [0x8f, 0x7b],
50: [0x96, 0x82],
100: [0x9d, 0x89],
200: [0xa3, 0x8f],
500: [0xaa, 0x96],
},
polymer: false,
},
GBP: {
thickness: 0x0d,
lengths: {
5: [0x91, 0x7d],
10: [0x98, 0x84],
20: [0x95, 0x81],
50: [0xa6, 0x92],
},
polymer: true,
},
MXN: {
thickness: 0x0c,
lengths: {
20: [0x7d, 0x73],
50: [0x82, 0x78],
100: [0x89, 0x7f],
200: [0x90, 0x86],
500: [0x97, 0x8d],
1000: [0x9e, 0x94],
},
polymer: true,
},
USD: {
thickness: 0x0d,
lengths: {
1: [0xa6, 0x92],
2: [0xa6, 0x92],
5: [0xa6, 0x92],
10: [0xa6, 0x92],
20: [0xa6, 0x92],
50: [0xa6, 0x92],
100: [0xa6, 0x92],
},
polymer: false,
},
// Add more currencies as needed...
}
export default bills

View file

@ -0,0 +1,78 @@
/**
* F56 Data-Level State Machine
*
* Higher-level FSM that manages request/response flow for F56 commands.
*/
import { EventEmitter } from 'node:events'
const RESPONSE_TIMEOUT = 40000
class F56DLevelFsm extends EventEmitter {
private _state: string = 'Idle'
private timerId: ReturnType<typeof setTimeout> | null = null
get state(): string {
return this._state
}
private transition(to: string): void {
this.clearTimer()
this._state = to
this.onEnter(to)
}
private onEnter(state: string): void {
if (state === 'WaitForStatus' || state === 'WaitForResponse') {
this.startTimer()
}
}
private startTimer(): void {
this.clearTimer()
this.timerId = setTimeout(() => this.handle('timeout'), RESPONSE_TIMEOUT)
}
private clearTimer(): void {
if (this.timerId) {
clearTimeout(this.timerId)
this.timerId = null
}
}
private conclude(status: string, data?: Buffer): void {
this.emit('status', status, data)
this.transition('Idle')
}
handle(event: string, data?: Buffer): void {
switch (this._state) {
case 'Idle':
if (event === 'waitForResponse') {
this.transition('WaitForStatus')
}
break
case 'WaitForStatus':
if (event === 'timeout') {
this.conclude('Transmission Timeout')
} else if (event === 'transmissionError') {
this.conclude('TransmissionError')
} else if (event === 'transmissionComplete') {
this.transition('WaitForResponse')
}
break
case 'WaitForResponse':
if (event === 'timeout') {
this.conclude('Response Timeout')
} else if (event === 'frame') {
this.conclude('Response', data)
}
break
}
}
}
export const dLevelFsm = new F56DLevelFsm()
export default dLevelFsm

View file

@ -0,0 +1,349 @@
/**
* F56 Protocol State Machine
*
* Handles the low-level DLE/STX/ETX framing for Fujitsu F53/F56 dispensers.
*/
import { EventEmitter } from 'node:events'
import { compute as computeCrc } from '../../utils/crc.js'
const STX = 0x02
const ETX = 0x03
const ENQ = 0x05
const ACK = 0x06
const NAK = 0x15
const DLE = 0x10
const DLE_STX = Buffer.from([DLE, STX])
const DLE_ACK = Buffer.from([DLE, ACK])
const DLE_NAK = Buffer.from([DLE, NAK])
const DLE_ETX = Buffer.from([DLE, ETX])
const DLE_ENQ = Buffer.from([DLE, ENQ])
const DATA_STATES = ['DataLength', 'DataBody', 'CRC']
const CONTROL_MAP: Record<number, string> = {
0x02: 'STX',
0x03: 'ETX',
0x05: 'ENQ',
0x06: 'ACK',
0x15: 'NAK',
0x10: 'DLE',
}
type StateHandler = string | ((byte?: number) => void)
class F56Fsm extends EventEmitter {
private _state: string = 'Idle'
private timerId: ReturnType<typeof setTimeout> | null = null
private retryDleAckCount: number = 0
private retryAckCount: number = 0
private transmitData: Buffer | null = null
private dataLengthBuf: Buffer = Buffer.alloc(2)
private dataLengthPointer: number = 0
private data: Buffer = Buffer.alloc(0)
private dataPointer: number = 0
private crc: Buffer = Buffer.alloc(2)
private crcPointer: number = 0
get state(): string {
return this._state
}
private transition(to: string): void {
this._state = to
this.onEnter(to)
}
private onEnter(state: string): void {
switch (state) {
case 'Idle':
this.retryDleAckCount = 0
this.retryAckCount = 0
this.transmitData = null
this.dataLengthBuf = Buffer.alloc(2)
break
case 'ENQ':
case 'DLE_STX':
case 'STX':
case 'DataLength':
case 'DataBody':
case 'DLE_ETX':
case 'ETX':
case 'CRC':
case 'DLE_ACK':
case 'ACK':
case 'DLE_ACK_2':
case 'ACK_2':
this.startTimer()
break
case 'DLE_ENQ_T':
this.emit('send', DLE_ENQ)
this.transition('DLE_ACK')
break
case 'Transmit':
this.resetRetry()
this.retryAckCount = 0
if (this.transmitData) {
this.emit('send', this.transmitData)
}
this.transition('DLE_ACK_2')
break
case 'CRC_Check':
this.checkCrc()
break
}
// Additional state-specific initialization
if (state === 'DLE_STX') {
this.dataLengthPointer = 0
}
if (state === 'ACK' || state === 'ACK_2') {
this.retryDleAckCount = 0
}
}
private onExit(): void {
this.clearTimer()
}
private startTimer(): void {
this.clearTimer()
this.timerId = setTimeout(() => this.handle('Timeout'), 5000)
}
private clearTimer(): void {
if (this.timerId) {
clearTimeout(this.timerId)
this.timerId = null
}
}
private resetRetry(): void {
this.retryDleAckCount = 0
}
private nakStx(): void {
this.emit('send', DLE_NAK)
this.transition('DLE_STX')
}
private nakEnq(): void {
this.emit('NAK')
this.transition('Idle')
}
private retryDleAck(): void {
this.retryDleAckCount++
if (this.retryDleAckCount < 3) {
this.transition('DLE_ENQ_T')
return
}
this.emit('status', 'transmissionFailure')
this.transition('Idle')
}
private retryAck(): void {
this.retryAckCount++
if (this.retryAckCount < 3) {
this.transition('DLE_ENQ_T')
return
}
this.emit('status', 'transmissionFailure')
this.transition('Idle')
}
private retryDleAck2(): void {
this.retryDleAckCount++
if (this.retryDleAckCount < 3) {
this.transition('Transmit')
return
}
this.emit('status', 'transmissionFailure')
this.transition('Idle')
}
private retryAck2(): void {
this.retryAckCount++
if (this.retryAckCount < 3) {
this.transition('Transmit')
return
}
this.emit('status', 'transmissionFailure')
this.transition('Idle')
}
private checkCrc(): void {
const buf = Buffer.concat([this.dataLengthBuf, this.data, DLE_ETX])
const computedCrc = computeCrc(buf)
if (this.crc.readUInt16LE(0) === computedCrc) {
this.emit('send', DLE_ACK)
this.emit('frame', this.data)
this.transition('Idle')
return
}
console.log('DEBUG2: CRC failure')
this.nakStx()
}
handle(event: string, byte?: number): void {
this.onExit()
switch (this._state) {
case 'Idle':
if (event === 'Send' && byte !== undefined) {
// byte is actually the data buffer index, handled in tx()
}
if (event === 'DLE') this.transition('ENQ')
if (event === 'LineError') this.nakEnq()
break
case 'ENQ':
if (event === 'ENQ') {
this.emit('send', DLE_ACK)
this.transition('DLE_STX')
} else if (event === 'Timeout' || event === 'LineError') {
this.nakEnq()
} else {
this.transition('Idle')
}
break
case 'DLE_STX':
if (event === 'DLE') this.transition('STX')
else if (event === 'Timeout') this.transition('Idle')
else if (event === 'LineError') this.nakEnq()
else this.transition('ENQ')
break
case 'STX':
if (event === 'DLE') this.transition('DLE_STX')
else if (event === 'ENQ') {
this.emit('send', DLE_ACK)
this.transition('DLE_STX')
} else if (event === 'STX') this.transition('DataLength')
else this.nakEnq()
break
case 'DataLength':
if (event === 'Timeout' || event === 'LineError') {
this.nakStx()
} else if (event === 'Data' && byte !== undefined) {
this.dataLengthBuf[this.dataLengthPointer++] = byte
if (this.dataLengthPointer === 2) {
const dataLength = this.dataLengthBuf.readUInt16BE(0)
this.data = Buffer.alloc(dataLength)
this.dataPointer = 0
this.crc = Buffer.alloc(2)
this.crcPointer = 0
this.transition('DataBody')
}
}
break
case 'DataBody':
if (event === 'Timeout' || event === 'LineError') {
this.nakStx()
} else if (event === 'Data' && byte !== undefined) {
this.data[this.dataPointer++] = byte
if (this.dataPointer === this.data.length) {
this.transition('DLE_ETX')
}
}
break
case 'DLE_ETX':
if (event === 'DLE') this.transition('ETX')
else this.nakStx()
break
case 'ETX':
if (event === 'ETX') this.transition('CRC')
else this.nakStx()
break
case 'CRC':
if (event === 'Timeout' || event === 'LineError') {
this.nakStx()
} else if (event === 'Data' && byte !== undefined) {
this.crc[this.crcPointer++] = byte
if (this.crcPointer === 2) {
this.transition('CRC_Check')
}
}
break
case 'DLE_ACK':
if (event === 'DLE') this.transition('ACK')
else if (event === 'Timeout' || event === 'LineError') {
this.retryDleAck()
}
break
case 'ACK':
if (event === 'ENQ') this.transition('DLE_ENQ_T')
else if (event === 'ACK') this.transition('Transmit')
else if (event === 'Timeout' || event === 'LineError') {
this.retryAck()
} else {
this.transition('DLE_ACK')
}
break
case 'DLE_ACK_2':
if (event === 'DLE') this.transition('ACK_2')
else if (event === 'Timeout' || event === 'LineError') {
this.retryDleAck2()
}
break
case 'ACK_2':
if (event === 'ENQ') this.transition('Idle')
else if (event === 'ACK') {
this.emit('status', 'transmissionComplete')
this.transition('Idle')
} else if (event === 'NAK' || event === 'Timeout' || event === 'LineError') {
this.retryAck2()
} else {
this.transition('DLE_ACK_2')
}
break
}
}
rx(byte: number): void {
if (DATA_STATES.includes(this._state)) {
this.handle('Data', byte)
return
}
const event = CONTROL_MAP[byte]
if (event) {
this.handle(event)
return
}
console.error('Unknown code: 0x%s', Buffer.from([byte]).toString('hex'))
}
tx(packet: Buffer): void {
this.transmitData = this.buildFrame(packet)
this.transition('DLE_ENQ_T')
}
private buildFrame(data: Buffer): Buffer {
const buf = Buffer.alloc(8 + data.length)
buf.writeUInt16BE(data.length, 2)
DLE_STX.copy(buf)
data.copy(buf, 4)
DLE_ETX.copy(buf, data.length + 4)
const crcInt = computeCrc(buf.subarray(2, data.length + 6))
buf.writeUInt16LE(crcInt, data.length + 6)
return buf
}
}
export const fsm = new F56Fsm()
export default fsm

View file

@ -0,0 +1,254 @@
/**
* F56 RS232 Communication Layer
*
* Handles serial communication for Fujitsu F53/F56 dispensers.
* Protocol: 9600 baud, 8 data bits, even parity, 1 stop bit
*/
import { EventEmitter } from 'node:events'
import { SerialPort } from 'serialport'
import { fsm } from './f56-fsm.js'
import { dLevelFsm } from './f56-dlevel-fsm.js'
import { bills, type BillConfig } from './bills.js'
const SERIAL_OPTIONS = {
baudRate: 9600,
parity: 'even' as const,
dataBits: 8 as const,
stopBits: 1 as const,
autoOpen: false,
}
const FS = 0x1c
const MAX_SUPPORTED_CASSETTES = 4
class F56Rs232Emitter extends EventEmitter {}
const emitter = new F56Rs232Emitter()
let serial: SerialPort | null = null
/**
* Convert number to parity-encoded decimal digits
*/
function parity(x: number): number {
let y = x ^ (x >> 1)
y = y ^ (y >> 2)
y = y ^ (y >> 4)
y = y ^ (y >> 8)
y = y ^ (y >> 16)
return x + (y & 1) * 0x80
}
/**
* Encode a number as two parity-encoded decimal digits
*/
function D(n: number): [number, number] {
let str = n.toString(10)
if (str.length === 1) str = '0' + str
return [parity(str.charCodeAt(0)), parity(str.charCodeAt(1))]
}
/**
* Decode two parity-encoded bytes to a number
*/
function DP(buf: Buffer): number {
const byte0 = buf[0] ?? 0
const byte1 = buf[1] ?? 0
const str = String.fromCharCode(byte0 & 0x7f, byte1 & 0x7f)
return parseInt(str, 10)
}
function prettyHex(buf: Buffer): string {
const pairs: string[] = []
for (let i = 0; i < buf.length; i++) {
pairs.push(buf.subarray(i, i + 1).toString('hex'))
}
return pairs.join(' ')
}
function parse(buf: Buffer): void {
for (const byte of buf) {
fsm.rx(byte)
}
}
export async function create(device: string): Promise<void> {
return new Promise((resolve, reject) => {
const opts = { ...SERIAL_OPTIONS, path: device }
serial = new SerialPort(opts)
serial.open((error) => {
if (error) return reject(error)
serial!.on('data', (data: Buffer) => parse(data))
serial!.on('close', () => emitter.emit('disconnected'))
resolve()
})
})
}
export async function initialize(currency: string, denominations: number[]): Promise<void> {
const billData = bills[currency]
if (!billData) {
throw new Error(`Unsupported currency: ${currency}`)
}
// Validate denominations
for (let i = 0; i < denominations.length; i++) {
const denom = denominations[i]
if (denom !== undefined && !billData.lengths[denom]) {
throw new Error(`Unsupported denomination: ${denom} for fiat code: ${currency}`)
}
}
const ODR = billData.polymer ? 0x40 : 0x00
// Build lengths array
const lengths: number[] = []
for (let i = 0; i < MAX_SUPPORTED_CASSETTES; i++) {
const denom = denominations[i]
if (denom !== undefined && billData.lengths[denom]) {
lengths.push(...billData.lengths[denom])
} else {
lengths.push(0x00, 0x00)
}
}
// Build thicknesses array
const thicknesses = Array(MAX_SUPPORTED_CASSETTES).fill(billData.thickness)
const command = Buffer.from([0x60, 0x02, 0x0d, ODR, ...lengths, ...thicknesses, FS])
const res = await request(command)
if (res[0] === 0xf0) {
const errorCode = res.subarray(3, 5)
throw new Error(`F56 error code: ${prettyHex(errorCode)}`)
}
if (res[1] !== 0x02 || res[2] !== 0x34) {
throw new Error('Invalid F56 response header')
}
}
export interface BillCountResult {
bills: Array<{ dispensed: number; rejected: number }>
error?: Error
}
export async function billCount(counts: number[]): Promise<BillCountResult> {
const actualCounts = Array(MAX_SUPPORTED_CASSETTES)
.fill(0)
.map((_, i) => counts[i] ?? 0)
const ODR = 0xe4
const billCounts = [
...D(actualCounts[0]!),
...D(actualCounts[1]!),
...D(actualCounts[2]!),
...D(actualCounts[3]!),
]
const rejects = [...D(4), ...D(4), ...D(4), ...D(4)]
const retries = [3, 3, 3, 3]
const command = Buffer.from([0x60, 0x03, 0x15, ODR, ...billCounts, ...rejects, ...retries, FS])
const res = await request(command)
if (res[1] !== 0x03 || res[2] !== 0x99) {
throw new Error('Invalid F56 response header')
}
const response: BillCountResult = {
bills: [],
}
for (let i = 0; i < counts.length; i++) {
response.bills.push({
dispensed: DP(res.subarray(0x27 + 2 * i, 0x29 + 2 * i)),
rejected: DP(res.subarray(0x2f + 2 * i, 0x31 + 2 * i)),
})
}
if (res[0] === 0xf0) {
console.log('response', res)
const errorCode = res.subarray(3, 5)
response.error = new Error(`Dispensing, code: ${prettyHex(errorCode)}`)
console.error(`found error code: ${prettyHex(errorCode)}`)
}
return response
}
export async function billsPresent(): Promise<boolean> {
const command = Buffer.from([0x00, 0x01, FS])
const res = await request(command)
if (res[0] === 0xf0) {
const errorCode = res.subarray(3, 5)
console.error(`F56 Error with code ${prettyHex(errorCode)}`)
console.error(prettyHex(res))
throw new Error('F56 Error')
}
const sensorRegister = res.subarray(0x0c, 0x12)
const byte2 = sensorRegister[2] ?? 0
return (byte2 & 0x10) > 0
}
async function request(command: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
if (dLevelFsm.state !== 'Idle') {
const error = new Error("Can't send in state: " + dLevelFsm.state)
;(error as Error & { code: string }).code = 'DLEVEL_FSM_ERROR'
return reject(error)
}
const rs232StatusHandler = (status: string) => dLevelFsm.handle(status)
const rs232FrameHandler = (frame: Buffer) => dLevelFsm.handle('frame', frame)
fsm.on('status', rs232StatusHandler)
fsm.on('frame', rs232FrameHandler)
const statusHandler = (status: string, frame?: Buffer) => {
fsm.off('status', rs232StatusHandler)
fsm.off('frame', rs232FrameHandler)
dLevelFsm.off('status', statusHandler)
if (status === 'Response' && frame) {
return resolve(frame)
}
if (status === 'Response Timeout') {
const error = new Error('Response Timeout')
;(error as Error & { code: string }).code = 'RESPONSE_TIMEOUT'
return reject(error)
}
return reject(new Error(status))
}
dLevelFsm.on('status', statusHandler)
fsm.tx(command)
dLevelFsm.handle('waitForResponse')
fsm.tx(command)
})
}
// Wire up fsm send event to serial write
fsm.on('send', (data: Buffer) => {
serial?.write(data)
})
export function close(): void {
serial?.close()
serial = null
}
export default {
create,
initialize,
billCount,
billsPresent,
close,
}

View file

@ -0,0 +1,106 @@
/**
* F56 Bill Dispenser Driver
*
* Supports Fujitsu F53/F56 bill dispensers.
* Used in: Lamassu Sintra, Sintra Forte, Tejo
*
* Protocol: RS-232, 9600 baud, 8 data bits, even parity, 1 stop bit
*/
import * as f56 from './f56-rs232.js'
import type {
BillDispenser,
DispenserConfig,
DispenserInitData,
DispenseResult,
} from '../../types.js'
export class F56Dispenser implements BillDispenser {
public type: string = 'F56'
public initialized: boolean = false
private initializing: boolean = false
private device: string
private fiatCode: string = ''
public dispenseLimit: number = 20
constructor(config: DispenserConfig) {
this.device = config.device
}
static factory(config: DispenserConfig): F56Dispenser {
return new F56Dispenser(config)
}
async init(data: DispenserInitData): Promise<void> {
if (this.initializing || this.initialized) return
this.initializing = true
this.fiatCode = data.fiatCode
const denominations = data.cassettes.map((c) => c.denomination)
try {
await f56.create(this.device)
await f56.initialize(this.fiatCode, denominations)
this.initialized = true
this.initializing = false
console.log('INFO F56 Connected')
} catch (err) {
this.initializing = false
throw err
}
}
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> {
try {
const { bills, error } = await f56.billCount(notes)
if (error) {
this.close()
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError'
;(error as Error & { statusCode: number }).statusCode = 570
}
return { value: bills, error }
} catch (err) {
this.close()
const error = err as Error
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError'
;(error as Error & { statusCode: number }).statusCode = 570
return { value: [], error }
}
}
close(): void {
f56.close()
this.initialized = false
}
async billsPresent(): Promise<boolean> {
return f56.billsPresent()
}
async waitForBillsRemoved(): Promise<boolean> {
return new Promise((resolve, reject) => {
let retries = 3
const interval = setInterval(() => {
this.billsPresent()
.then((billsArePresent) => {
if (!billsArePresent) {
clearInterval(interval)
resolve(true)
}
})
.catch((err: Error & { code?: string }) => {
if (err.code === 'DLEVEL_FSM_ERROR') return
if (err.code === 'RESPONSE_TIMEOUT' && retries-- > 0) return
clearInterval(interval)
reject(err)
})
}, 1000)
})
}
}
export default F56Dispenser

View file

@ -0,0 +1,27 @@
/**
* Bill Dispenser Drivers
*
* Factory for creating bill dispenser instances based on device type.
*/
export { F56Dispenser } from './f56/index.js'
export type { DispenserConfig, BillDispenser, DispenserInitData, DispenseResult } from '../types.js'
import { F56Dispenser } from './f56/index.js'
import type { DispenserConfig, BillDispenser } from '../types.js'
export type DispenserType = 'f56'
/**
* Create a bill dispenser instance
* @param type Dispenser type (e.g., 'f56')
* @param config Dispenser configuration
*/
export function createDispenser(type: DispenserType, config: DispenserConfig): BillDispenser {
switch (type) {
case 'f56':
return F56Dispenser.factory(config)
default:
throw new Error(`Unknown dispenser type: ${type}`)
}
}

View file

@ -0,0 +1,134 @@
//! Mock bill dispenser for testing
use async_trait::async_trait;
use crate::error::DispenserError;
use super::traits::{BillDispenser, CassetteStatus};
/// Mock bill dispenser for development and testing
pub struct MockDispenser {
connected: bool,
cassettes: Vec<CassetteStatus>,
bills_at_exit: bool,
}
impl MockDispenser {
/// Create a new mock dispenser with default cassettes
pub fn new() -> Self {
Self {
connected: false,
cassettes: vec![
CassetteStatus {
denomination: 20,
count: 500,
capacity: 500,
},
CassetteStatus {
denomination: 50,
count: 200,
capacity: 200,
},
],
bills_at_exit: false,
}
}
/// Create with custom cassette configuration
pub fn with_cassettes(cassettes: Vec<CassetteStatus>) -> Self {
Self {
connected: false,
cassettes,
bills_at_exit: false,
}
}
}
impl Default for MockDispenser {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl BillDispenser for MockDispenser {
fn driver_name(&self) -> &'static str {
"mock"
}
async fn connect(&mut self) -> Result<(), DispenserError> {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
self.connected = true;
tracing::info!("MockDispenser connected");
Ok(())
}
async fn disconnect(&mut self) -> Result<(), DispenserError> {
self.connected = false;
tracing::info!("MockDispenser disconnected");
Ok(())
}
async fn get_cassette_status(&self) -> Result<Vec<CassetteStatus>, DispenserError> {
Ok(self.cassettes.clone())
}
async fn dispense(&mut self, denomination: u32, count: u32) -> Result<u32, DispenserError> {
if !self.connected {
return Err(DispenserError::ConnectionFailed("Not connected".into()));
}
// Find cassette with this denomination
let cassette = self
.cassettes
.iter_mut()
.find(|c| c.denomination == denomination)
.ok_or_else(|| {
DispenserError::HardwareError(format!(
"No cassette for denomination {}",
denomination
))
})?;
// Check if we have enough bills
if cassette.count < count {
return Err(DispenserError::InsufficientBills(count, cassette.count));
}
// Simulate dispense time
tokio::time::sleep(tokio::time::Duration::from_millis(count as u64 * 200)).await;
// Update count
cassette.count -= count;
self.bills_at_exit = true;
tracing::info!(
"MockDispenser dispensed {} x ${} bills ({} remaining)",
count,
denomination,
cassette.count
);
Ok(count)
}
async fn reset(&mut self) -> Result<(), DispenserError> {
tracing::info!("MockDispenser reset");
self.bills_at_exit = false;
Ok(())
}
async fn is_ready(&self) -> Result<bool, DispenserError> {
Ok(self.connected && !self.bills_at_exit)
}
async fn bills_present(&self) -> Result<bool, DispenserError> {
Ok(self.bills_at_exit)
}
async fn wait_for_bills_removed(&self) -> Result<(), DispenserError> {
// Simulate customer taking bills
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
tracing::info!("MockDispenser: bills removed");
Ok(())
}
}

View file

@ -0,0 +1,125 @@
//! Bill dispenser drivers
//!
//! This module contains implementations for various bill dispenser protocols:
//! - Puloon LCDM series
//! - Fujitsu F53/F56
//! - Genmega
//! - HCM2 (Hitachi recycler)
//! - GSR50 (recycler)
pub mod traits;
pub mod mock;
// pub mod puloon;
// pub mod f56;
// pub mod genmega;
// pub mod hcm2;
// pub mod gsr50;
pub use traits::*;
pub use mock::MockDispenser;
use napi::bindgen_prelude::*;
use napi_derive::napi;
use crate::{DispenserDriver, error::DispenserError};
/// Wrapper for bill dispenser that exposes napi-rs bindings
#[napi]
pub struct BillDispenserWrapper {
inner: Box<dyn BillDispenser>,
}
#[napi]
impl BillDispenserWrapper {
/// Create a new bill dispenser instance
#[napi(constructor)]
pub fn new(
driver: DispenserDriver,
port: Option<String>,
fiat_code: Option<String>,
) -> Result<Self> {
let _fiat = fiat_code.unwrap_or_else(|| "USD".to_string());
let dispenser: Box<dyn BillDispenser> = match driver {
DispenserDriver::Mock => Box::new(MockDispenser::new()),
// TODO: Implement other drivers
_ => {
return Err(Error::from_reason(format!(
"Driver {:?} not yet implemented. Use Mock for development.",
driver
)))
}
};
Ok(Self { inner: dispenser })
}
/// Get the driver name
#[napi(getter)]
pub fn driver_name(&self) -> String {
self.inner.driver_name().to_string()
}
/// Connect to the dispenser
#[napi]
pub async fn connect(&mut self) -> Result<()> {
self.inner
.connect()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Disconnect from the dispenser
#[napi]
pub async fn disconnect(&mut self) -> Result<()> {
self.inner
.disconnect()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Dispense bills
#[napi]
pub async fn dispense(&mut self, denomination: u32, count: u32) -> Result<u32> {
self.inner
.dispense(denomination, count)
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Reset the dispenser
#[napi]
pub async fn reset(&mut self) -> Result<()> {
self.inner
.reset()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Check if dispenser is ready
#[napi]
pub async fn is_ready(&self) -> Result<bool> {
self.inner
.is_ready()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Check if bills are present at exit
#[napi]
pub async fn bills_present(&self) -> Result<bool> {
self.inner
.bills_present()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Wait for bills to be removed
#[napi]
pub async fn wait_for_bills_removed(&self) -> Result<()> {
self.inner
.wait_for_bills_removed()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
}

View file

@ -0,0 +1,55 @@
//! Bill dispenser trait definitions
use async_trait::async_trait;
use crate::error::DispenserError;
/// Status of a cassette in the dispenser
#[derive(Debug, Clone)]
pub struct CassetteStatus {
/// Bill denomination in this cassette
pub denomination: u32,
/// Current bill count
pub count: u32,
/// Maximum capacity
pub capacity: u32,
}
/// Unified interface for all bill dispensers
///
/// Implementations: Puloon, F56, Genmega, HCM2, GSR50, Mock
#[async_trait]
pub trait BillDispenser: Send + Sync {
/// Get dispenser driver name (for logging/debugging)
fn driver_name(&self) -> &'static str;
/// Connect to the dispenser
async fn connect(&mut self) -> Result<(), DispenserError>;
/// Disconnect from the dispenser
async fn disconnect(&mut self) -> Result<(), DispenserError>;
/// Get status of all cassettes
async fn get_cassette_status(&self) -> Result<Vec<CassetteStatus>, DispenserError>;
/// Dispense bills from a specific cassette
///
/// Returns the number of bills actually dispensed.
async fn dispense(&mut self, denomination: u32, count: u32) -> Result<u32, DispenserError>;
/// Reset the dispenser after a jam or error
async fn reset(&mut self) -> Result<(), DispenserError>;
/// Check if dispenser is ready to dispense
async fn is_ready(&self) -> Result<bool, DispenserError>;
/// Check if bills are present at the exit (for customer to take)
///
/// Not all dispensers support this - some will always return true.
async fn bills_present(&self) -> Result<bool, DispenserError>;
/// Wait for bills to be removed by customer
///
/// Returns immediately for dispensers that don't support detection.
async fn wait_for_bills_removed(&self) -> Result<(), DispenserError>;
}

91
packages/hal/src/error.rs Normal file
View file

@ -0,0 +1,91 @@
//! Error types for the HAL
use thiserror::Error;
/// Errors that can occur during bill validator operations
#[derive(Debug, Error)]
pub enum ValidatorError {
/// Failed to connect to the validator
#[error("Connection failed: {0}")]
ConnectionFailed(String),
/// Communication error during operation
#[error("Communication error: {0}")]
CommunicationError(String),
/// Bill was rejected by the validator
#[error("Bill rejected: {0}")]
BillRejected(String),
/// Stacker is full
#[error("Stacker full")]
StackerFull,
/// General hardware error
#[error("Hardware error: {0}")]
HardwareError(String),
/// Invalid state for requested operation
#[error("Invalid state: {0}")]
InvalidState(String),
/// Operation timed out
#[error("Operation timed out")]
Timeout,
}
/// Errors that can occur during bill dispenser operations
#[derive(Debug, Error)]
pub enum DispenserError {
/// Failed to connect to the dispenser
#[error("Connection failed: {0}")]
ConnectionFailed(String),
/// Communication error during operation
#[error("Communication error: {0}")]
CommunicationError(String),
/// Not enough bills to fulfill request
#[error("Insufficient bills: need {0}, have {1}")]
InsufficientBills(u32, u32),
/// Cassette is empty
#[error("Cassette empty: denomination {0}")]
CassetteEmpty(u32),
/// Bill jam detected
#[error("Bill jam")]
BillJam,
/// General hardware error
#[error("Hardware error: {0}")]
HardwareError(String),
/// Operation timed out
#[error("Operation timed out")]
Timeout,
}
/// Errors that can occur during printer operations
#[derive(Debug, Error)]
pub enum PrinterError {
/// Failed to connect to the printer
#[error("Connection failed: {0}")]
ConnectionFailed(String),
/// Communication error during operation
#[error("Communication error: {0}")]
CommunicationError(String),
/// Printer is out of paper
#[error("Out of paper")]
OutOfPaper,
/// Paper jam detected
#[error("Paper jam")]
PaperJam,
/// General hardware error
#[error("Hardware error: {0}")]
HardwareError(String),
}

56
packages/hal/src/index.ts Normal file
View file

@ -0,0 +1,56 @@
/**
* @lamassu/hal - Hardware Abstraction Layer
*
* Provides drivers for Lamassu ATM hardware devices:
* - Bill validators (JCM iVIZION via ID003 protocol)
* - Bill dispensers (Fujitsu F53/F56)
*
* @example
* ```typescript
* import { createValidator, createDispenser } from '@lamassu/hal'
*
* // Create a bill validator
* const validator = createValidator('id003', {
* rs232: { device: '/dev/ttyUSB0' },
* fiatCode: 'USD'
* })
*
* // Create a bill dispenser
* const dispenser = createDispenser('f56', {
* device: '/dev/ttyUSB1'
* })
*
* // Initialize dispenser
* await dispenser.init({
* fiatCode: 'USD',
* cassettes: [
* { denomination: 20 },
* { denomination: 100 }
* ]
* })
* ```
*/
// Validators
export { Id003, createValidator, type ValidatorType } from './validators/index.js'
// Dispensers
export { F56Dispenser, createDispenser, type DispenserType } from './dispensers/index.js'
// Types
export type {
BillValidator,
BillDispenser,
ValidatorConfig,
DispenserConfig,
DispenserInitData,
CassetteConfig,
DispenseResult,
BillData,
RejectionData,
ValidatorFactory,
DispenserFactory,
} from './types.js'
// Utilities
export { compute as computeCrc } from './utils/crc.js'

94
packages/hal/src/lib.rs Normal file
View file

@ -0,0 +1,94 @@
//! Lamassu Hardware Abstraction Layer
//!
//! This crate provides Rust implementations of hardware drivers for bill validators,
//! dispensers, printers, and other ATM peripherals. These are exposed to Node.js
//! via napi-rs bindings.
//!
//! # Supported Hardware
//!
//! ## Bill Validators
//! - ID003 (JCM) - Default validator protocol
//! - CCNET (CashCode)
//! - MEI CashFlow SC
//! - MEI BNR Advance
//! - Genmega
//! - HCM2 (Hitachi recycler)
//! - GSR50 (recycler)
//!
//! ## Bill Dispensers
//! - Puloon LCDM series
//! - Fujitsu F53/F56
//! - Genmega
//! - HCM2 (Hitachi recycler)
//! - GSR50 (recycler)
//!
//! ## Printers
//! - Nippon (ESC/POS)
//! - Zebra (ZPL)
//! - Genmega
//!
//! # Usage
//!
//! ```typescript
//! import { BillValidatorWrapper, ValidatorDriver } from '@lamassu/hal'
//!
//! const validator = new BillValidatorWrapper(ValidatorDriver.Id003, '/dev/ttyUSB0', 'USD')
//! await validator.connect()
//! await validator.enable()
//! ```
#![deny(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::all)]
use napi_derive::napi;
pub mod error;
pub mod validators;
pub mod dispensers;
// pub mod printer;
// pub mod scanner;
// pub mod leds;
// pub mod nfc;
/// Supported bill validator drivers
#[napi]
pub enum ValidatorDriver {
/// JCM ID-003 protocol (default)
Id003,
/// CashCode CCNET protocol
Ccnet,
/// MEI CashFlow SC
CashflowSc,
/// MEI BNR Advance
BnrAdvance,
/// Genmega validator
Genmega,
/// Hitachi HCM2 recycler
Hcm2,
/// GSR50 recycler
Gsr50,
/// Mock validator for testing
Mock,
}
/// Supported bill dispenser drivers
#[napi]
pub enum DispenserDriver {
/// Puloon LCDM series
Puloon,
/// Fujitsu F53/F56
F56,
/// Genmega dispenser
Genmega,
/// Hitachi HCM2 recycler
Hcm2,
/// GSR50 recycler
Gsr50,
/// Mock dispenser for testing
Mock,
}
// Re-exports for convenience
pub use validators::BillValidatorWrapper;
pub use dispensers::BillDispenserWrapper;

206
packages/hal/src/types.ts Normal file
View file

@ -0,0 +1,206 @@
import { EventEmitter } from 'node:events'
/**
* Bill denomination data returned when a bill is read
*/
export interface BillData {
/** Denomination value (e.g., 20 for $20) */
denomination: number | null
/** Raw escrow code from the validator */
code: number
}
/**
* Rejection reason data
*/
export interface RejectionData {
/** Human-readable rejection reason */
reason: string
/** Raw rejection code */
code: number | null
}
/**
* Bill validator configuration
*/
export interface ValidatorConfig {
rs232: {
/** Serial device path (e.g., '/dev/ttyUSB0') or array of paths to try */
device: string | string[]
/** Fiat currency code (e.g., 'USD', 'EUR') */
fiatCode?: string
}
/** Fiat currency code */
fiatCode?: string
}
/**
* Bill validator events
*/
export interface ValidatorEvents {
/** Emitted when validator is enabled and ready */
enabled: (data: unknown) => void
/** Emitted when validator enters standby mode */
standby: (data: unknown) => void
/** Emitted when a bill is being accepted (inserted) */
billsAccepted: () => void
/** Emitted when a bill has been read and is in escrow */
billsRead: (data: BillData) => void
/** Emitted when a bill has been validated and stacked */
billsValid: () => void
/** Emitted when a bill has been rejected */
billsRejected: (data?: RejectionData) => void
/** Emitted when a bill is refused (unsupported denomination) */
billRefused: () => void
/** Emitted when the stacker/cash box is opened */
stackerOpen: () => void
/** Emitted on error */
error: (err: Error) => void
/** Emitted when device is disconnected */
disconnected: () => void
}
/**
* Bill validator interface
* Implementations: id003 (JCM), ccnet, mei
*/
export interface BillValidator extends EventEmitter {
/**
* Initialize and start the validator
* @param cb Callback when initialization is complete
*/
run(cb: (err?: Error) => void): void
/**
* Close the connection and clean up
* @param cb Callback when closed
*/
close(cb: (err?: Error) => void): void
/** Enable bill acceptance */
enable(): void
/** Disable bill acceptance */
disable(): void
/** Stack the bill currently in escrow */
stack(): void
/** Reject/return the bill currently in escrow */
reject(): void
/** Turn on the bill insertion light */
lightOn(): void
/** Turn off the bill insertion light */
lightOff(): void
/** Set the fiat currency code */
setFiatCode(fiatCode: string): void
/**
* Get the lowest bill denomination that is >= fiat amount
* @param fiat Amount to compare
*/
lowestBill(fiat: { lte: (n: number) => boolean }): { toNumber: () => number }
/**
* Get the highest bill denomination that is <= fiat amount
* @param fiat Amount to compare
*/
highestBill(fiat: { gte: (n: number) => boolean }): { toNumber: () => number }
/** Check if denominations have been loaded from the device */
hasDenominations(): boolean
}
/**
* Bill dispenser configuration
*/
export interface DispenserConfig {
/** Serial device path */
device: string
}
/**
* Cassette configuration
*/
export interface CassetteConfig {
/** Bill denomination in this cassette */
denomination: number
/** Number of bills loaded (optional) */
count?: number
}
/**
* Dispense result for a single cassette
*/
export interface DispenseResult {
/** Number of bills dispensed */
dispensed: number
/** Number of bills rejected during dispense */
rejected: number
}
/**
* Bill dispenser initialization data
*/
export interface DispenserInitData {
/** Fiat currency code (e.g., 'USD') */
fiatCode: string
/** Cassette configuration */
cassettes: CassetteConfig[]
}
/**
* Bill dispenser interface
* Implementations: f56 (Fujitsu), puloon
*/
export interface BillDispenser {
/** Dispenser type identifier */
type: string
/** Whether the dispenser is initialized */
initialized: boolean
/**
* Initialize the dispenser
* @param data Initialization data including fiat code and cassettes
*/
init(data: DispenserInitData): Promise<void>
/**
* Dispense bills
* @param notes Array of bill counts per cassette [cassette1Count, cassette2Count, ...]
* @returns Dispense results per cassette and any error
*/
dispense(notes: number[]): Promise<{
value: DispenseResult[]
error?: Error
}>
/** Close the connection */
close(): void
/**
* Check if bills are present at the dispense outlet
* @returns true if bills are waiting to be taken
*/
billsPresent(): Promise<boolean>
/**
* Wait for customer to remove dispensed bills
* @returns Resolves when bills are removed
*/
waitForBillsRemoved(): Promise<boolean>
}
/**
* Factory function signature for validators
*/
export type ValidatorFactory = (config: ValidatorConfig) => BillValidator
/**
* Factory function signature for dispensers
*/
export type DispenserFactory = (config: DispenserConfig) => BillDispenser

View file

@ -0,0 +1,49 @@
/**
* CCITT-KERMIT CRC computation
* Used by ID003 and F56 protocols
*
* Based on: http://stackoverflow.com/questions/5059268/c-sharp-crc-implementation
*/
const TABLE: readonly number[] = [
0x0000, 0x1189, 0x2312, 0x329b, 0x4624, 0x57ad, 0x6536, 0x74bf, 0x8c48, 0x9dc1, 0xaf5a, 0xbed3,
0xca6c, 0xdbe5, 0xe97e, 0xf8f7, 0x1081, 0x0108, 0x3393, 0x221a, 0x56a5, 0x472c, 0x75b7, 0x643e,
0x9cc9, 0x8d40, 0xbfdb, 0xae52, 0xdaed, 0xcb64, 0xf9ff, 0xe876, 0x2102, 0x308b, 0x0210, 0x1399,
0x6726, 0x76af, 0x4434, 0x55bd, 0xad4a, 0xbcc3, 0x8e58, 0x9fd1, 0xeb6e, 0xfae7, 0xc87c, 0xd9f5,
0x3183, 0x200a, 0x1291, 0x0318, 0x77a7, 0x662e, 0x54b5, 0x453c, 0xbdcb, 0xac42, 0x9ed9, 0x8f50,
0xfbef, 0xea66, 0xd8fd, 0xc974, 0x4204, 0x538d, 0x6116, 0x709f, 0x0420, 0x15a9, 0x2732, 0x36bb,
0xce4c, 0xdfc5, 0xed5e, 0xfcd7, 0x8868, 0x99e1, 0xab7a, 0xbaf3, 0x5285, 0x430c, 0x7197, 0x601e,
0x14a1, 0x0528, 0x37b3, 0x263a, 0xdecd, 0xcf44, 0xfddf, 0xec56, 0x98e9, 0x8960, 0xbbfb, 0xaa72,
0x6306, 0x728f, 0x4014, 0x519d, 0x2522, 0x34ab, 0x0630, 0x17b9, 0xef4e, 0xfec7, 0xcc5c, 0xddd5,
0xa96a, 0xb8e3, 0x8a78, 0x9bf1, 0x7387, 0x620e, 0x5095, 0x411c, 0x35a3, 0x242a, 0x16b1, 0x0738,
0xffcf, 0xee46, 0xdcdd, 0xcd54, 0xb9eb, 0xa862, 0x9af9, 0x8b70, 0x8408, 0x9581, 0xa71a, 0xb693,
0xc22c, 0xd3a5, 0xe13e, 0xf0b7, 0x0840, 0x19c9, 0x2b52, 0x3adb, 0x4e64, 0x5fed, 0x6d76, 0x7cff,
0x9489, 0x8500, 0xb79b, 0xa612, 0xd2ad, 0xc324, 0xf1bf, 0xe036, 0x18c1, 0x0948, 0x3bd3, 0x2a5a,
0x5ee5, 0x4f6c, 0x7df7, 0x6c7e, 0xa50a, 0xb483, 0x8618, 0x9791, 0xe32e, 0xf2a7, 0xc03c, 0xd1b5,
0x2942, 0x38cb, 0x0a50, 0x1bd9, 0x6f66, 0x7eef, 0x4c74, 0x5dfd, 0xb58b, 0xa402, 0x9699, 0x8710,
0xf3af, 0xe226, 0xd0bd, 0xc134, 0x39c3, 0x284a, 0x1ad1, 0x0b58, 0x7fe7, 0x6e6e, 0x5cf5, 0x4d7c,
0xc60c, 0xd785, 0xe51e, 0xf497, 0x8028, 0x91a1, 0xa33a, 0xb2b3, 0x4a44, 0x5bcd, 0x6956, 0x78df,
0x0c60, 0x1de9, 0x2f72, 0x3efb, 0xd68d, 0xc704, 0xf59f, 0xe416, 0x90a9, 0x8120, 0xb3bb, 0xa232,
0x5ac5, 0x4b4c, 0x79d7, 0x685e, 0x1ce1, 0x0d68, 0x3ff3, 0x2e7a, 0xe70e, 0xf687, 0xc41c, 0xd595,
0xa12a, 0xb0a3, 0x8238, 0x93b1, 0x6b46, 0x7acf, 0x4854, 0x59dd, 0x2d62, 0x3ceb, 0x0e70, 0x1ff9,
0xf78f, 0xe606, 0xd49d, 0xc514, 0xb1ab, 0xa022, 0x92b9, 0x8330, 0x7bc7, 0x6a4e, 0x58d5, 0x495c,
0x3de3, 0x2c6a, 0x1ef1, 0x0f78,
] as const
/**
* Compute CRC-16 CCITT-KERMIT checksum
* @param buf Array of bytes or Buffer
* @returns 16-bit CRC value
*/
export function compute(buf: ArrayLike<number>): number {
let crc = 0x00
const len = buf.length
for (let i = 0; i < len; i++) {
const byte = buf[i]
if (byte === undefined) continue
crc = (crc >> 8) ^ (TABLE[(crc ^ byte) & 0xff] ?? 0)
}
return crc
}

View file

@ -0,0 +1,281 @@
/**
* ID003 Protocol State Machine
*
* Manages the state transitions for the JCM ID003 bill validator protocol.
* Based on the original lamassu-machine implementation.
*/
import { EventEmitter } from 'node:events'
// Using a simple state machine implementation instead of the contrib library
type StateHandler = (data?: unknown) => void
type TransitionMap = Record<string, string | StateHandler>
const TRANSIENT_TIMEOUT = 60000
interface Id003FsmEvents {
dispatch: (cmd: string, data?: unknown) => void
denominations: () => void
getEnabled: (data: unknown) => void
setEnabled: (data: unknown) => void
ready: () => void
stale: () => void
stuck: () => void
billsAccepted: () => void
billsRead: (data: unknown) => void
billsValid: () => void
billsRejected: (data?: unknown) => void
billRefused: () => void
standby: () => void
stackerOpen: () => void
failure: (args: unknown) => void
powerUp: () => void
error: (err: Error) => void
}
export class Id003Fsm extends EventEmitter {
private _state: string = 'Start'
public disableFlag: boolean = false
private stateTimeout: ReturnType<typeof setTimeout> | null = null
constructor() {
super()
}
static factory(): Id003Fsm {
return new Id003Fsm()
}
get state(): string {
return this._state
}
is(state: string): boolean {
return this._state === state
}
private transition(to: string, event?: string, data?: unknown): void {
const from = this._state
this.clearStateTimeout()
console.log('FSM: %s [ %s -> %s ]', event ?? 'transition', from, to)
this._state = to
this.onEnterState(to, from, data)
}
private clearStateTimeout(): void {
if (this.stateTimeout) {
clearTimeout(this.stateTimeout)
this.stateTimeout = null
}
}
private startTransientTimeout(): void {
this.stateTimeout = setTimeout(() => {
this.emit('stuck')
}, TRANSIENT_TIMEOUT)
}
private onEnterState(state: string, from: string, data?: unknown): void {
switch (state) {
case 'PowerUp':
this.emit('powerUp')
break
case 'Connected':
// Will emit 'ready' on leave
break
case 'Denominations':
this.emit('denominations')
break
case 'GetEnabled':
this.emit('getEnabled', data)
break
case 'SetEnabled':
this.emit('setEnabled', data)
break
case 'Enable':
if (this.disableFlag) {
console.trace('FSM: delayed disable')
this.disableFlag = false
this._dispatch('inhibit')
}
break
case 'Disable':
this.disableFlag = false
if (from === 'Initialize') {
this.emit('standby')
}
break
case 'Initialize':
// Enable interrupt mode after 500ms
setTimeout(() => {
this._dispatch('interruptMode')
}, 500)
break
case 'Accepting':
this.startTransientTimeout()
this.emit('billsAccepted')
break
case 'Rejecting':
this.startTransientTimeout()
console.log('Rejected bill: %s', (data as { reason?: string })?.reason)
this.emit('billsRejected', data)
break
case 'Returning':
this.startTransientTimeout()
this.emit('billsRejected', { reason: 'Returned', code: null })
break
case 'Escrow':
this.emit('billsRead', data)
break
case 'VendValid':
if (from === 'Connected') {
this._dispatch('reset')
return
}
this._dispatch('ack')
this.emit('billsValid')
break
case 'StackerOpen':
this.emit('stackerOpen')
break
case 'Failure':
this.emit('failure', data)
break
case 'Stacking':
case 'Stacked':
this.startTransientTimeout()
break
}
}
private _dispatch(cmd: string): void {
this.emit('dispatch', cmd)
}
// Event handlers called by the RS232 layer
connect(): void {
if (this._state === 'Start') {
this.transition('Connected', 'connect')
} else if (this._state === 'Refresh') {
this.transition('Disable', 'connect')
}
}
badFrame(): void {
this.transition('BadFrame', 'badFrame')
}
powerUp(): void {
this.transition('PowerUp', 'powerUp')
}
powerUpAcceptor(): void {
this.transition('PowerUp', 'powerUpAcceptor')
}
denominations(): void {
this.transition('Denominations', 'denominations')
}
getEnabled(data?: unknown): void {
this.transition('GetEnabled', 'getEnabled', data)
}
setEnabled(data?: unknown): void {
this.transition('SetEnabled', 'setEnabled', data)
}
initialize(): void {
this.transition('Initialize', 'initialize')
}
enable(): void {
this.transition('Enable', 'enable')
}
disable(): void {
this.transition('Disable', 'disable')
}
escrow(data?: unknown): void {
const validFrom = ['Paused', 'Enable', 'Accepting', 'Escrow']
if (validFrom.includes(this._state)) {
this.transition('Escrow', 'escrow', data)
}
}
returning(): void {
const validFrom = ['Escrow', 'Returning', 'Paused']
if (validFrom.includes(this._state)) {
this.transition('Returning', 'returning')
}
}
stacking(): void {
const validFrom = ['Escrow', 'Stacking', 'Paused']
if (validFrom.includes(this._state)) {
this.transition('Stacking', 'stacking')
}
}
vendValid(): void {
const validFrom = ['Connected', 'Escrow', 'Stacking', 'VendValid', 'Paused']
if (validFrom.includes(this._state)) {
this.transition('VendValid', 'vendValid')
}
}
stacked(): void {
const validFrom = ['VendValid', 'Stacked', 'Paused']
if (validFrom.includes(this._state)) {
this.transition('Stacked', 'stacked')
}
}
rejecting(data?: unknown): void {
const validFrom = ['Accepting', 'Rejecting', 'Escrow', 'Stacking', 'Paused']
if (validFrom.includes(this._state)) {
this.transition('Rejecting', 'rejecting', data)
}
}
stackerOpen(): void {
this.transition('StackerOpen', 'stackerOpen')
}
stackerFull(): void {
const validFrom = ['StackerFull', 'Stacked', 'VendValid', 'Paused']
if (validFrom.includes(this._state)) {
this.transition('StackerFull', 'stackerFull')
}
}
accepting(): void {
const validFrom = ['Paused', 'Enable', 'Accepting']
if (validFrom.includes(this._state)) {
this.transition('Accepting', 'accepting')
}
}
failure(args?: unknown): void {
this.transition('Failure', 'failure', args)
}
acceptorJam(): void {
this.transition('AcceptorJam', 'acceptorJam')
}
stackerJam(): void {
this.transition('StackerJam', 'stackerJam')
}
cheated(): void {
this.transition('Cheated', 'cheated')
}
pause(): void {
this.transition('Paused', 'pause')
}
}
export default Id003Fsm

View file

@ -0,0 +1,359 @@
/**
* ID003 RS232 Protocol Layer
*
* Handles serial communication for JCM ID003 bill validators.
* Protocol: 9600 baud, 8 data bits, even parity, 1 stop bit
*/
import { EventEmitter } from 'node:events'
import { SerialPort } from 'serialport'
import * as fs from 'node:fs'
import { compute as computeCrc } from '../../utils/crc.js'
const SYNC = 0xfc
// Command codes
const CMD: Record<string, number[]> = {
denominations: [0x8a],
status: [0x11],
stack: [0x41],
ack: [0x50],
inhibit: [0xc3, 0x01],
unInhibit: [0xc3, 0x00],
reset: [0x40],
reject: [0x43],
enableAll: [0xc0, 0x00, 0x00],
getEnabled: [0x80],
interruptMode: [0xc2, 0x01],
}
// Response codes
const RSP: Record<number, string> = {
0x05: 'enq',
0x40: 'powerUp', // Also powerUpAcceptor
0x1b: 'initialize',
0x1a: 'disable',
0x11: 'enable',
0x12: 'accepting',
0x13: 'escrow',
0x14: 'stacking',
0x15: 'vendValid',
0x16: 'stacked',
0x17: 'rejecting',
0x18: 'returning',
0x43: 'stackerFull',
0x44: 'stackerOpen',
0x45: 'acceptorJam',
0x46: 'stackerJam',
0x47: 'pause',
0x48: 'cheated',
0x49: 'failure',
0x4b: 'invalid',
0x50: 'ack',
0x80: 'getEnabled',
0x88: 'version',
0x8a: 'denominations',
0xc0: 'setEnabled',
0xc2: 'commMode',
0xc3: 'inhibit',
}
// Rejection reason codes
const REJECTION_REASONS: Record<number, string> = {
0x71: 'insertion',
0x72: 'mug',
0x73: 'head',
0x74: 'calibration',
0x75: 'conveying',
0x76: 'discrimination',
0x77: 'photoPattern',
0x78: 'photoLevel',
0x79: 'inhibit',
0x7a: 'unknown',
0x7b: 'operation',
0x7c: 'stacker',
0x7d: 'length',
0x7e: 'photoPattern',
0x7f: 'trueBill',
}
// Currency codes (for multi-currency support)
const CODES: Record<string, number> = {
USD: 0x01,
CAD: 0x08,
}
export interface Id003Rs232Config {
device: string | string[]
fiatCode?: string
}
export class Id003Rs232 extends EventEmitter {
private fiatCode: string | undefined
private buf: Buffer = Buffer.alloc(0)
private config: Id003Rs232Config
private serial: SerialPort | null = null
private _denominations: Record<number, number> | null = null
constructor(config: Id003Rs232Config) {
super()
this.fiatCode = config.fiatCode
this.config = config
}
static factory(config: Id003Rs232Config): Id003Rs232 {
return new Id003Rs232(config)
}
private async _open(device: string): Promise<void> {
return new Promise((resolve, reject) => {
const options = {
path: device,
baudRate: 9600,
parity: 'even' as const,
dataBits: 8 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.lightOff()
resolve()
})
serial.open()
})
}
async open(cb: (err?: Error) => void): Promise<void> {
const devices = this.config.device
if (!devices) {
this.emit('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 {
const stats = fs.statSync(device)
if (!stats.isCharacterDevice()) continue
await this._open(device)
cb()
return
} catch {
continue
}
}
this.emit('error', 'No configured devices available.')
}
send(command: string): void {
const codes = CMD[command]
if (!codes) throw new Error('Invalid command: ' + command)
const length = codes.length + 4
const payload = [SYNC, length, ...codes]
const buf = Buffer.from(payload)
const crc = computeCrc(payload)
const crcBuf = Buffer.alloc(2)
crcBuf.writeUInt16LE(crc, 0)
const outBuf = Buffer.concat([buf, crcBuf], length)
this.serial?.write(outBuf)
}
close(cb: (err?: Error | null) => void): void {
this.serial?.close(cb)
}
lightOn(): void {
this.serial?.set({ rts: true }, (err) => {
if (err) console.log('lightOn failed: %s', err)
})
}
lightOff(): void {
this.serial?.set({ rts: false }, (err) => {
if (err) console.log('lightOff failed: %s', err)
})
}
denominations(): Record<number, number> | null {
return this._denominations
}
private _acquireSync(data: Buffer): Buffer {
for (let i = 0; i < data.length; i++) {
if (data[i] === SYNC) {
return data.subarray(i)
}
}
return Buffer.alloc(0)
}
private _crcVerify(payload: Buffer): void {
const payloadCrc = payload.readUInt16LE(payload.length - 2)
const verify = computeCrc(payload.subarray(0, -2)) === payloadCrc
if (!verify) throw new Error('CRC error')
}
private _parse(packet: Buffer): void {
this._crcVerify(packet)
const data = packet.length === 5 ? null : packet.subarray(3, -2)
const commandCode = packet[2]
if (commandCode !== undefined) {
this._interpret(commandCode, data)
}
}
private _interpret(commandCode: number, rawData: Buffer | null): void {
const command = RSP[commandCode]
if (!command) {
this.emit('unknownCommand', commandCode)
return
}
const data = this._parseData(command, rawData)
this.emit('message', command, data)
}
private _parseData(command: string, rawData: Buffer | null): unknown {
if (!rawData) return null
switch (command) {
case 'escrow':
return this._escrow(rawData)
case 'version':
return this._version(rawData)
case 'rejecting':
return this._rejecting(rawData)
case 'denominations':
return this._setDenominations(rawData)
case 'getEnabled':
case 'setEnabled':
return this._enabled(rawData)
default:
return null
}
}
private _escrow(rawData: Buffer): { denomination: number | null; code: number } {
const fiatCode = rawData[0]
if (fiatCode === undefined) {
return { denomination: null, code: 0 }
}
const denomination = this._denominations?.[fiatCode] ?? null
return { denomination, code: fiatCode }
}
private _rejecting(rawData: Buffer): { reason: string; code: number } {
const code = rawData[0] ?? 0
const reason = REJECTION_REASONS[code] ?? 'unknown'
return { reason, code }
}
private _setDenominations(rawData: Buffer): void {
// Last two bytes are boot version
if (this._denominations) return
const denominations: Record<number, number> = {}
const rawLength = rawData.length
for (let offset = 0; offset < rawLength; offset += 4) {
const escrowCode = rawData[offset]
const countryCode = rawData[offset + 1]
if (escrowCode === undefined || countryCode === undefined) continue
if (
this.fiatCode &&
Object.prototype.hasOwnProperty.call(CODES, this.fiatCode) &&
countryCode !== CODES[this.fiatCode]
) {
console.log('Found a bill not matching the defined fiat code, rejecting...')
this.emit('reject')
}
const denominationInteger = rawData[offset + 2]
if (denominationInteger === undefined || denominationInteger === 0x00) continue
const denominationExponent = rawData[offset + 3] ?? 0
const denomination = denominationInteger * Math.pow(10, denominationExponent)
denominations[escrowCode] = denomination
}
this._denominations = denominations
}
private _enabled(rawData: Buffer): { data1: number; data2: number } {
return { data1: rawData[0] ?? 0, data2: rawData[1] ?? 0 }
}
private _version(rawData: Buffer): { version: Buffer } {
this._crcVerify(rawData)
return { version: rawData.subarray(0, -2) }
}
private _process(data: Buffer): void {
this.buf = Buffer.concat([this.buf, data])
while (this._processPacket()) {
// Continue processing packets
}
}
private _processPacket(): boolean {
if (this.buf.length === 0) return false
this.buf = this._acquireSync(this.buf)
// Wait for size byte
if (this.buf.length < 2) return false
const responseSize = this.buf[1]
if (responseSize === undefined) return false
// Wait for whole packet
if (this.buf.length < responseSize) return false
const packet = this.buf.subarray(0, responseSize)
this.buf = this.buf.subarray(responseSize)
try {
this._parse(packet)
} catch (ex) {
console.dir(ex)
if (ex instanceof Error) {
console.log(ex.stack)
}
this.emit('badFrame')
return false
}
return true
}
}
export default Id003Rs232

View file

@ -0,0 +1,312 @@
/**
* ID003 Bill Validator Driver
*
* Supports JCM bill validators using the ID003 protocol.
* Used by: JCM iVIZION, JCM UBA, JCM iPRO
*
* Protocol: RS-232, 9600 baud, 8 data bits, even parity, 1 stop bit
*/
import { EventEmitter } from 'node:events'
import { throttle } from 'lodash-es'
import { Id003Rs232 } from './id003-rs232.js'
import { Id003Fsm } from './id003-fsm.js'
import type { BillValidator, ValidatorConfig, BillData } from '../../types.js'
const POLLING_INTERVAL = 100
const IGNORE_RESPONSES = ['ack', 'inhibit', 'commMode', 'enq']
/**
* BigNumber-like interface for bill comparisons
*/
interface BNLike {
lte: (n: number) => boolean
gte: (n: number) => boolean
toNumber: () => number
}
/**
* Simple BigNumber wrapper
*/
function BN(n: number): BNLike {
return {
lte: (other: number) => n <= other,
gte: (other: number) => n >= other,
toNumber: () => n,
}
}
export class Id003 extends EventEmitter implements BillValidator {
private initialized: boolean = false
private pollingInterval: ReturnType<typeof setInterval> | null = null
private config: ValidatorConfig
private fiatCode: string | null = null
private _throttledError: (err: Error) => void
private _startupCallback: ((err?: Error) => void) | null = null
private rs232: Id003Rs232 | null = null
private id003Fsm: Id003Fsm | null = null
private disablePolling: boolean = false
constructor(config: ValidatorConfig) {
super()
this.config = config
this._throttledError = throttle((err: Error) => this.emit('error', err), 2000)
}
static factory(config: ValidatorConfig): Id003 {
return new Id003(config)
}
setFiatCode(fiatCode: string): void {
this.fiatCode = fiatCode
}
lightOn(): void {
if (this.rs232) {
console.log('lightOn')
this.rs232.lightOn()
}
}
lightOff(): void {
if (this.rs232) {
console.log('lightOff')
this.rs232.lightOff()
}
}
run(cb: (err?: Error) => void): void {
this._startupCallback = cb
this.id003Fsm = Id003Fsm.factory()
const config = this.config
const rs232Config = {
...config.rs232,
fiatCode: config.fiatCode,
}
this.disablePolling = false
this.rs232 = Id003Rs232.factory(rs232Config)
// RS232 event handlers
this.rs232.on('message', (cmd: string, data: unknown) => {
if (cmd === 'invalid') {
console.log('ERROR: invalid command')
if (this.disablePolling) this._send('status')
return
}
if (!IGNORE_RESPONSES.includes(cmd) && this.id003Fsm) {
// Call the appropriate FSM method
const fsmMethod = (this.id003Fsm as unknown as Record<string, (data?: unknown) => void>)[
cmd
]
if (typeof fsmMethod === 'function') {
fsmMethod.call(this.id003Fsm, data)
}
}
if (cmd === 'commMode') {
this._disablePolling()
}
if (cmd === 'enq') {
this._send('status')
}
})
this.rs232.on('unknownCommand', (code: number) => {
throw new Error('unknown code: ' + code.toString(16))
})
this.rs232.on('error', (err: Error) => {
this._throttledError(err)
})
this.rs232.on('badFrame', () => {
this.id003Fsm?.badFrame()
if (this.disablePolling) this._send('status')
})
// FSM event handlers
this.id003Fsm.on('dispatch', (cmd: string) => {
this._send(cmd)
})
this.id003Fsm.on('denominations', () => {
this._send('reset')
})
this.id003Fsm.on('getEnabled', (data: unknown) => {
this._send('enableAll')
this.emit('enabled', data)
})
this.id003Fsm.on('setEnabled', (data: unknown) => {
if (this._startupCallback) {
this._startupCallback()
this._startupCallback = null
}
this.emit('enabled', data)
this.emit('standby', data)
})
this.id003Fsm.on('ready', () => {
this._send('denominations')
})
this.id003Fsm.on('stale', () => {
this._send('reset')
})
this.id003Fsm.on('stuck', () => {
this.emit('error', new Error('Bill validator stuck'))
})
this.id003Fsm.on('billsAccepted', () => {
this.emit('billsAccepted')
})
this.id003Fsm.on('billsRead', (data: unknown) => {
const billData = data as BillData
if (!billData.denomination) {
console.log(
'bill rejected: unsupported denomination. Code: 0x%s',
billData.code.toString(16)
)
this._send('reject')
return
}
this.emit('billsRead', data)
})
this.id003Fsm.on('billsValid', () => {
this.emit('billsValid')
})
this.id003Fsm.on('billsRejected', (data?: unknown) => {
this.emit('billsRejected', data)
})
this.id003Fsm.on('billRefused', () => {
this.emit('billRefused')
})
this.id003Fsm.on('standby', () => {
this._send('getEnabled')
})
this.id003Fsm.on('stackerOpen', () => {
this.emit('stackerOpen')
})
this.id003Fsm.on('failure', (args: unknown) => {
this.emit('error', args)
})
// Open serial connection
this.rs232.open((err?: Error) => {
if (err) {
if (this._startupCallback) {
this._startupCallback(err)
}
return
}
this._startPolling()
this.id003Fsm?.connect()
})
}
close(cb: (err?: Error) => void): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
}
this.rs232?.close((err) => {
if (err) console.log(err)
cb(err ?? undefined)
})
}
enable(): void {
if (this.id003Fsm) {
this.id003Fsm.disableFlag = false
}
this._send('unInhibit')
}
disable(): void {
// If the run command is not executed id003Fsm will be undefined
if (!this.id003Fsm || this.id003Fsm.is('Disable')) return
if (this.id003Fsm.is('Enable')) {
this._send('inhibit')
} else {
this.id003Fsm.disableFlag = true
}
}
stack(): void {
this._send('stack')
}
reject(): void {
this._send('reject')
}
lowestBill(fiat: BNLike): BNLike {
const bills = Object.values(this._denominations() ?? {})
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 = Object.values(this._denominations() ?? {})
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(): Record<number, number> | null {
return this.rs232?.denominations() ?? null
}
private _send(command: string): void {
if (this.disablePolling) {
this.rs232?.send(command)
return
}
this._stopPolling()
// Timeout to prevent interleaved commands
setTimeout(() => {
this.rs232?.send(command)
if (!this.disablePolling) {
this._startPolling()
}
}, POLLING_INTERVAL)
}
private _startPolling(): void {
this._stopPolling()
this.pollingInterval = setInterval(() => {
this.rs232?.send('status')
}, POLLING_INTERVAL)
}
private _stopPolling(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = null
}
}
private _disablePolling(): void {
this.disablePolling = true
this._stopPolling()
}
}
export default Id003

View file

@ -0,0 +1,27 @@
/**
* Bill Validator Drivers
*
* Factory for creating bill validator instances based on device type.
*/
export { Id003 } from './id003/index.js'
export type { ValidatorConfig, BillValidator, BillData } from '../types.js'
import { Id003 } from './id003/index.js'
import type { ValidatorConfig, BillValidator } from '../types.js'
export type ValidatorType = 'id003'
/**
* Create a bill validator instance
* @param type Validator type (e.g., 'id003')
* @param config Validator configuration
*/
export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator {
switch (type) {
case 'id003':
return Id003.factory(config)
default:
throw new Error(`Unknown validator type: ${type}`)
}
}

View file

@ -0,0 +1,147 @@
//! Mock bill validator for testing
use async_trait::async_trait;
use tokio::sync::broadcast;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::ValidatorError;
use super::traits::{BillValidator, BillEvent, BillEventType};
/// Mock bill validator for development and testing
pub struct MockValidator {
connected: bool,
enabled: bool,
escrowed_bill: Option<u32>,
bill_count: u32,
event_tx: broadcast::Sender<BillEvent>,
fiat_code: String,
}
impl MockValidator {
/// Create a new mock validator
pub fn new() -> Self {
let (event_tx, _) = broadcast::channel(16);
Self {
connected: false,
enabled: false,
escrowed_bill: None,
bill_count: 0,
event_tx,
fiat_code: "USD".to_string(),
}
}
/// Simulate a bill being inserted (for testing)
pub fn simulate_bill(&mut self, denomination: u32) {
if self.enabled {
self.escrowed_bill = Some(denomination);
let _ = self.event_tx.send(BillEvent {
denomination,
currency: self.fiat_code.clone(),
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
event_type: BillEventType::Inserted,
});
}
}
fn current_timestamp(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
}
impl Default for MockValidator {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl BillValidator for MockValidator {
fn driver_name(&self) -> &'static str {
"mock"
}
async fn connect(&mut self) -> Result<(), ValidatorError> {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
self.connected = true;
tracing::info!("MockValidator connected");
Ok(())
}
async fn disconnect(&mut self) -> Result<(), ValidatorError> {
self.connected = false;
self.enabled = false;
tracing::info!("MockValidator disconnected");
Ok(())
}
async fn enable(&mut self) -> Result<(), ValidatorError> {
if !self.connected {
return Err(ValidatorError::ConnectionFailed("Not connected".into()));
}
self.enabled = true;
tracing::info!("MockValidator enabled");
Ok(())
}
async fn disable(&mut self) -> Result<(), ValidatorError> {
self.enabled = false;
tracing::info!("MockValidator disabled");
Ok(())
}
async fn accept(&mut self) -> Result<(), ValidatorError> {
match self.escrowed_bill.take() {
Some(denomination) => {
self.bill_count += 1;
let _ = self.event_tx.send(BillEvent {
denomination,
currency: self.fiat_code.clone(),
timestamp: self.current_timestamp(),
event_type: BillEventType::Stacked,
});
tracing::info!("MockValidator accepted ${}", denomination);
Ok(())
}
None => Err(ValidatorError::InvalidState("No bill escrowed".into())),
}
}
async fn reject(&mut self) -> Result<(), ValidatorError> {
match self.escrowed_bill.take() {
Some(denomination) => {
let _ = self.event_tx.send(BillEvent {
denomination,
currency: self.fiat_code.clone(),
timestamp: self.current_timestamp(),
event_type: BillEventType::Rejected,
});
tracing::info!("MockValidator rejected ${}", denomination);
Ok(())
}
None => Err(ValidatorError::InvalidState("No bill escrowed".into())),
}
}
async fn get_bill_count(&self) -> Result<u32, ValidatorError> {
Ok(self.bill_count)
}
fn subscribe(&self) -> broadcast::Receiver<BillEvent> {
self.event_tx.subscribe()
}
async fn run(&mut self) -> Result<(), ValidatorError> {
// Mock validator doesn't need continuous polling
// In real implementation, this would poll the hardware
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
}
}

View file

@ -0,0 +1,119 @@
//! Bill validator drivers
//!
//! This module contains implementations for various bill validator protocols:
//! - ID003 (JCM) - Default protocol
//! - CCNET (CashCode)
//! - MEI CashFlow SC
//! - MEI BNR Advance
//! - Genmega
//! - HCM2 (Hitachi recycler)
//! - GSR50 (recycler)
pub mod traits;
pub mod mock;
// pub mod id003;
// pub mod ccnet;
// pub mod mei_cashflow;
// pub mod genmega;
// pub mod hcm2;
// pub mod gsr50;
pub use traits::*;
pub use mock::MockValidator;
use napi::bindgen_prelude::*;
use napi_derive::napi;
use crate::{ValidatorDriver, error::ValidatorError};
/// Wrapper for bill validator that exposes napi-rs bindings
#[napi]
pub struct BillValidatorWrapper {
inner: Box<dyn BillValidator>,
}
#[napi]
impl BillValidatorWrapper {
/// Create a new bill validator instance
#[napi(constructor)]
pub fn new(
driver: ValidatorDriver,
port: Option<String>,
fiat_code: Option<String>,
) -> Result<Self> {
let _fiat = fiat_code.unwrap_or_else(|| "USD".to_string());
let validator: Box<dyn BillValidator> = match driver {
ValidatorDriver::Mock => Box::new(MockValidator::new()),
// TODO: Implement other drivers
_ => {
return Err(Error::from_reason(format!(
"Driver {:?} not yet implemented. Use Mock for development.",
driver
)))
}
};
Ok(Self { inner: validator })
}
/// Get the driver name
#[napi(getter)]
pub fn driver_name(&self) -> String {
self.inner.driver_name().to_string()
}
/// Connect to the validator
#[napi]
pub async fn connect(&mut self) -> Result<()> {
self.inner
.connect()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Disconnect from the validator
#[napi]
pub async fn disconnect(&mut self) -> Result<()> {
self.inner
.disconnect()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Enable bill acceptance
#[napi]
pub async fn enable(&mut self) -> Result<()> {
self.inner
.enable()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Disable bill acceptance
#[napi]
pub async fn disable(&mut self) -> Result<()> {
self.inner
.disable()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Accept the currently escrowed bill
#[napi]
pub async fn accept(&mut self) -> Result<()> {
self.inner
.accept()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
/// Reject the currently escrowed bill
#[napi]
pub async fn reject(&mut self) -> Result<()> {
self.inner
.reject()
.await
.map_err(|e| Error::from_reason(e.to_string()))
}
}

View file

@ -0,0 +1,73 @@
//! Bill validator trait definitions
use async_trait::async_trait;
use tokio::sync::broadcast;
use crate::error::ValidatorError;
/// Event types for bill validator operations
#[derive(Debug, Clone)]
pub enum BillEventType {
/// Bill detected and validated
Inserted,
/// Bill accepted into stacker
Accepted,
/// Bill rejected (returned to customer)
Rejected,
/// Bill successfully stacked
Stacked,
/// Bill jammed
Jammed,
}
/// Event emitted by bill validators
#[derive(Debug, Clone)]
pub struct BillEvent {
/// Denomination of the bill
pub denomination: u32,
/// Currency code (e.g., "USD")
pub currency: String,
/// Unix timestamp
pub timestamp: u64,
/// Type of event
pub event_type: BillEventType,
}
/// Unified interface for all bill validators
///
/// Implementations: ID003, CCNET, MEI CashFlow, MEI BNR, Genmega, HCM2, GSR50, Mock
#[async_trait]
pub trait BillValidator: Send + Sync {
/// Get validator driver name (for logging/debugging)
fn driver_name(&self) -> &'static str;
/// Connect to the validator
async fn connect(&mut self) -> Result<(), ValidatorError>;
/// Disconnect from the validator
async fn disconnect(&mut self) -> Result<(), ValidatorError>;
/// Enable bill acceptance
async fn enable(&mut self) -> Result<(), ValidatorError>;
/// Disable bill acceptance
async fn disable(&mut self) -> Result<(), ValidatorError>;
/// Accept the currently held bill into stacker
async fn accept(&mut self) -> Result<(), ValidatorError>;
/// Reject the currently held bill
async fn reject(&mut self) -> Result<(), ValidatorError>;
/// Get current bill count in stacker (if supported)
async fn get_bill_count(&self) -> Result<u32, ValidatorError>;
/// Subscribe to bill events
fn subscribe(&self) -> broadcast::Receiver<BillEvent>;
/// Run the validator state machine (poll for events)
///
/// Most validators need continuous polling. This method should be
/// called in a separate task and will run indefinitely.
async fn run(&mut self) -> Result<(), ValidatorError>;
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}