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:
parent
30a2eb2199
commit
c98f126ba7
180 changed files with 2695 additions and 9587 deletions
116
packages/hal/src/dispensers/f56/bills.ts
Normal file
116
packages/hal/src/dispensers/f56/bills.ts
Normal 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
|
||||
78
packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts
Normal file
78
packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts
Normal 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
|
||||
349
packages/hal/src/dispensers/f56/f56-fsm.ts
Normal file
349
packages/hal/src/dispensers/f56/f56-fsm.ts
Normal 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
|
||||
254
packages/hal/src/dispensers/f56/f56-rs232.ts
Normal file
254
packages/hal/src/dispensers/f56/f56-rs232.ts
Normal 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,
|
||||
}
|
||||
106
packages/hal/src/dispensers/f56/index.ts
Normal file
106
packages/hal/src/dispensers/f56/index.ts
Normal 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
|
||||
27
packages/hal/src/dispensers/index.ts
Normal file
27
packages/hal/src/dispensers/index.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
134
packages/hal/src/dispensers/mock.rs
Normal file
134
packages/hal/src/dispensers/mock.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
125
packages/hal/src/dispensers/mod.rs
Normal file
125
packages/hal/src/dispensers/mod.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
55
packages/hal/src/dispensers/traits.rs
Normal file
55
packages/hal/src/dispensers/traits.rs
Normal 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
91
packages/hal/src/error.rs
Normal 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
56
packages/hal/src/index.ts
Normal 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
94
packages/hal/src/lib.rs
Normal 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
206
packages/hal/src/types.ts
Normal 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
|
||||
49
packages/hal/src/utils/crc.ts
Normal file
49
packages/hal/src/utils/crc.ts
Normal 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
|
||||
}
|
||||
281
packages/hal/src/validators/id003/id003-fsm.ts
Normal file
281
packages/hal/src/validators/id003/id003-fsm.ts
Normal 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
|
||||
359
packages/hal/src/validators/id003/id003-rs232.ts
Normal file
359
packages/hal/src/validators/id003/id003-rs232.ts
Normal 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
|
||||
312
packages/hal/src/validators/id003/index.ts
Normal file
312
packages/hal/src/validators/id003/index.ts
Normal 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
|
||||
27
packages/hal/src/validators/index.ts
Normal file
27
packages/hal/src/validators/index.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
147
packages/hal/src/validators/mock.rs
Normal file
147
packages/hal/src/validators/mock.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
packages/hal/src/validators/mod.rs
Normal file
119
packages/hal/src/validators/mod.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
73
packages/hal/src/validators/traits.rs
Normal file
73
packages/hal/src/validators/traits.rs
Normal 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>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue