feat(hal): add Puloon bill dispenser driver for Douro ATM
Port the Puloon RS232 dispenser protocol from lamassu-machine to the lamassu-next HAL package. Adds Douro machine preset with Puloon dispenser on /dev/ttyS1 and ID003 validator on /dev/ttyS0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
658c3c2b4d
commit
350e7a8974
7 changed files with 556 additions and 8 deletions
|
|
@ -15,7 +15,7 @@ import type { HalConfig, CassetteConfig } from '@/services/hal'
|
|||
/**
|
||||
* Supported machine models
|
||||
*/
|
||||
export type MachineModel = 'sintra' | 'gaia' | 'custom'
|
||||
export type MachineModel = 'sintra' | 'douro' | 'gaia' | 'custom'
|
||||
|
||||
/**
|
||||
* Full device configuration
|
||||
|
|
@ -35,7 +35,7 @@ export interface DeviceConfig {
|
|||
/** Bill dispenser configuration */
|
||||
dispenser: {
|
||||
/** Dispenser model */
|
||||
type: 'f56'
|
||||
type: 'f56' | 'puloon'
|
||||
/** Serial device path */
|
||||
device: string
|
||||
/** Cassette configuration */
|
||||
|
|
@ -66,6 +66,24 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
|
|||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Lamassu Douro
|
||||
* - Validator: JCM iVIZION (ID003 protocol) on /dev/ttyS0
|
||||
* - Dispenser: Puloon on /dev/ttyS1
|
||||
*/
|
||||
douro: {
|
||||
model: 'douro',
|
||||
validator: {
|
||||
type: 'id003',
|
||||
device: '/dev/ttyS0',
|
||||
},
|
||||
dispenser: {
|
||||
type: 'puloon',
|
||||
device: '/dev/ttyS1',
|
||||
cassettes: [{ denomination: 20, count: 50 }],
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Lamassu Gaia
|
||||
* - Validator: JCM iVIZION (ID003 protocol)
|
||||
|
|
@ -165,7 +183,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
|
|||
const dispenserDevice = import.meta.env.VITE_LAMASSU_DISPENSER_DEVICE
|
||||
if (dispenserDevice) {
|
||||
overrides.dispenser = {
|
||||
type: 'f56',
|
||||
type: MACHINE_PRESETS[model].dispenser.type,
|
||||
device: dispenserDevice,
|
||||
cassettes: [],
|
||||
}
|
||||
|
|
@ -180,7 +198,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
|
|||
overrides.dispenser.cassettes = cassettes
|
||||
} else {
|
||||
overrides.dispenser = {
|
||||
type: 'f56',
|
||||
type: MACHINE_PRESETS[model].dispenser.type,
|
||||
device: MACHINE_PRESETS[model].dispenser.device,
|
||||
cassettes,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export interface HalConfig {
|
|||
fiatCode: string
|
||||
}
|
||||
dispenser: {
|
||||
type: 'f56'
|
||||
type: 'f56' | 'puloon'
|
||||
device: string
|
||||
cassettes: CassetteConfig[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,22 +5,26 @@
|
|||
*/
|
||||
|
||||
export { F56Dispenser } from './f56/index.js'
|
||||
export { PuloonDispenser } from './puloon/index.js'
|
||||
export type { DispenserConfig, BillDispenser, DispenserInitData, DispenseResult } from '../types.js'
|
||||
|
||||
import { F56Dispenser } from './f56/index.js'
|
||||
import { PuloonDispenser } from './puloon/index.js'
|
||||
import type { DispenserConfig, BillDispenser } from '../types.js'
|
||||
|
||||
export type DispenserType = 'f56'
|
||||
export type DispenserType = 'f56' | 'puloon'
|
||||
|
||||
/**
|
||||
* Create a bill dispenser instance
|
||||
* @param type Dispenser type (e.g., 'f56')
|
||||
* @param type Dispenser type (e.g., 'f56', 'puloon')
|
||||
* @param config Dispenser configuration
|
||||
*/
|
||||
export function createDispenser(type: DispenserType, config: DispenserConfig): BillDispenser {
|
||||
switch (type) {
|
||||
case 'f56':
|
||||
return F56Dispenser.factory(config)
|
||||
case 'puloon':
|
||||
return PuloonDispenser.factory(config)
|
||||
default:
|
||||
throw new Error(`Unknown dispenser type: ${type}`)
|
||||
}
|
||||
|
|
|
|||
48
packages/hal/src/dispensers/puloon/bills.ts
Normal file
48
packages/hal/src/dispensers/puloon/bills.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Puloon Bill Length Data
|
||||
*
|
||||
* Maps fiat currency codes to bill denomination lengths (in mm).
|
||||
* Used to configure the Puloon dispenser's bill detection for each cassette.
|
||||
*/
|
||||
|
||||
/** Bill lengths by currency (fiatCode → denomination → length in mm) */
|
||||
export const billLengths: Record<string, Record<number, number>> = {
|
||||
AUD: { 5: 130, 10: 137, 20: 144, 50: 151, 100: 158 },
|
||||
BBD: { 2: 150, 5: 150, 10: 150, 20: 150, 50: 150, 100: 150 },
|
||||
CAD: { 5: 152, 10: 152, 20: 152, 50: 152, 100: 152 },
|
||||
CHF: { 10: 126, 20: 137, 50: 148, 100: 159, 200: 170, 1000: 181 },
|
||||
DKK: { 50: 125, 100: 135, 200: 145, 500: 155, 1000: 165 },
|
||||
EUR: { 5: 120, 10: 127, 20: 133, 50: 140, 100: 147, 200: 153, 500: 160 },
|
||||
GBP: { 5: 135, 10: 142, 20: 149, 50: 156 },
|
||||
HKD: { 10: 134, 20: 143, 50: 148, 100: 153, 500: 158, 1000: 163 },
|
||||
HUF: { 200: 154, 500: 154, 1000: 154, 2000: 154, 5000: 154, 10000: 154, 20000: 154 },
|
||||
ILS: { 20: 129, 50: 136, 100: 143, 200: 150 },
|
||||
JMD: { 50: 145, 100: 145, 500: 145, 1000: 145, 5000: 145 },
|
||||
JPY: { 1000: 150, 2000: 154, 5000: 156, 10000: 160 },
|
||||
KZT: { 200: 126, 500: 130, 1000: 134, 2000: 139, 5000: 144, 10000: 155, 20000: 155 },
|
||||
MXN: { 20: 120, 50: 127, 100: 134, 200: 141, 500: 148, 1000: 155 },
|
||||
MYR: { 1: 120, 5: 135, 10: 140, 20: 145, 50: 145, 100: 150 },
|
||||
NZD: { 5: 135, 10: 140, 20: 145, 50: 150, 100: 155 },
|
||||
PHP: { 20: 160, 50: 160, 100: 160, 200: 160, 500: 160, 1000: 160 },
|
||||
PLN: { 10: 120, 20: 126, 50: 132, 100: 138, 200: 144, 500: 150 },
|
||||
SGD: { 2: 126, 5: 133, 10: 141, 50: 156, 100: 162, 1000: 170 },
|
||||
TWD: { 100: 145, 200: 150, 500: 155, 1000: 160, 2000: 165 },
|
||||
UAH: { 1: 118, 2: 118, 5: 118, 10: 124, 20: 130, 50: 136, 100: 142, 200: 148, 500: 154 },
|
||||
USD: { 1: 156, 5: 156, 10: 156, 20: 156, 50: 156, 100: 156 },
|
||||
VND: { 10000: 132, 20000: 136, 50000: 140, 100000: 144, 200000: 148, 500000: 152 },
|
||||
ZAR: { 10: 128, 20: 134, 50: 140, 100: 146, 200: 152 },
|
||||
}
|
||||
|
||||
/** Slit width factor for encoding bill lengths to Puloon hardware format */
|
||||
export const SLIT = 0.785
|
||||
|
||||
/**
|
||||
* Encode a bill length (mm) into Puloon's 2-nibble format.
|
||||
* Returns [high, low] bytes, each offset by 0x30.
|
||||
*/
|
||||
export function encodeBillLength(lengthMm: number): [number, number] {
|
||||
const adjusted = Math.floor(lengthMm / SLIT)
|
||||
const high = Math.floor(adjusted / 16) + 0x30
|
||||
const low = (adjusted % 16) + 0x30
|
||||
return [high, low]
|
||||
}
|
||||
85
packages/hal/src/dispensers/puloon/index.ts
Normal file
85
packages/hal/src/dispensers/puloon/index.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Puloon Bill Dispenser Driver
|
||||
*
|
||||
* Supports Puloon bill dispensers (2 cassettes).
|
||||
* Used in: Lamassu Douro
|
||||
*
|
||||
* Protocol: RS-232, 9600 baud, 8 data bits, even parity, 1 stop bit
|
||||
*/
|
||||
|
||||
import { PuloonRs232 } from './puloon-rs232.js'
|
||||
import type {
|
||||
BillDispenser,
|
||||
DispenserConfig,
|
||||
DispenserInitData,
|
||||
DispenseResult,
|
||||
} from '../../types.js'
|
||||
|
||||
export class PuloonDispenser implements BillDispenser {
|
||||
public type: string = 'Puloon'
|
||||
public initialized: boolean = false
|
||||
private initializing: boolean = false
|
||||
private device: PuloonRs232
|
||||
|
||||
constructor(config: DispenserConfig) {
|
||||
this.device = new PuloonRs232(config.device)
|
||||
}
|
||||
|
||||
static factory(config: DispenserConfig): PuloonDispenser {
|
||||
return new PuloonDispenser(config)
|
||||
}
|
||||
|
||||
async init(data: DispenserInitData): Promise<void> {
|
||||
if (this.initializing || this.initialized) return
|
||||
|
||||
this.initializing = true
|
||||
|
||||
try {
|
||||
await this.device.create()
|
||||
await this.device.initialize(data.cassettes, data.fiatCode)
|
||||
this.initialized = true
|
||||
this.initializing = false
|
||||
console.log('INFO Puloon Connected')
|
||||
} catch (err) {
|
||||
this.initializing = false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> {
|
||||
try {
|
||||
const { bills, error } = await this.device.dispense(notes)
|
||||
|
||||
if (error) {
|
||||
this.close()
|
||||
;(error as Error & { name: string; statusCode: number }).name = 'PuloonDispenseError'
|
||||
;(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 = 'PuloonDispenseError'
|
||||
;(error as Error & { statusCode: number }).statusCode = 570
|
||||
return { value: [], error }
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.device.close()
|
||||
this.initialized = false
|
||||
}
|
||||
|
||||
/** Puloon has no exit sensor — always returns true */
|
||||
async billsPresent(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Puloon has no exit sensor — always returns true */
|
||||
async waitForBillsRemoved(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default PuloonDispenser
|
||||
388
packages/hal/src/dispensers/puloon/puloon-rs232.ts
Normal file
388
packages/hal/src/dispensers/puloon/puloon-rs232.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
/**
|
||||
* Puloon RS232 Communication Layer
|
||||
*
|
||||
* Handles serial communication for Puloon bill dispensers.
|
||||
* Protocol: 9600 baud, 8 data bits, even parity, 1 stop bit
|
||||
*
|
||||
* Frame format:
|
||||
* Send: [EOT=0x04] [ID=0x30] [STX=0x02] [CMD] [params...] [ETX=0x03] [BCC]
|
||||
* Recv: [SOH=0x01] [ID=0x30] [STX=0x02] [CMD] [ERR] [data...] [ETX=0x03] [BCC]
|
||||
* BCC = XOR of all bytes from first byte through ETX
|
||||
*
|
||||
* Used in: Lamassu Douro
|
||||
*/
|
||||
|
||||
import { SerialPort } from 'serialport'
|
||||
import { billLengths, encodeBillLength } from './bills.js'
|
||||
import type { CassetteConfig, DispenseResult } from '../../types.js'
|
||||
|
||||
const PAUSE_BETWEEN_DISPENSES = 200
|
||||
|
||||
const ACK = 0x06
|
||||
const NAK = 0x15
|
||||
const EOT = 0x04
|
||||
const SOH = 0x01
|
||||
const ID = 0x30
|
||||
const STX = 0x02
|
||||
|
||||
const COMMANDS: Record<number, { name: string; responseParameters: number }> = {
|
||||
0x44: { name: 'reset', responseParameters: 0 },
|
||||
0x52: { name: 'dispense', responseParameters: 22 },
|
||||
0x50: { name: 'status', responseParameters: 18 },
|
||||
0x5f: { name: 'billLengths', responseParameters: 8 },
|
||||
0x5e: { name: 'setBillLengths', responseParameters: 0 },
|
||||
0x67: { name: 'getSerialNumber', responseParameters: 1 },
|
||||
}
|
||||
|
||||
type State = 'idle' | 'waitAck' | 'waitResponse' | 'waitEOT'
|
||||
|
||||
interface DispenseResponse {
|
||||
code: number
|
||||
name: string
|
||||
err: number | null
|
||||
bills?: DispenseResult[]
|
||||
serialNumber?: number
|
||||
}
|
||||
|
||||
/** Compute BCC (XOR checksum) over a frame */
|
||||
function computeBcc(frame: number[]): number {
|
||||
let bcc = 0x00
|
||||
for (const byte of frame) {
|
||||
bcc = byte ^ bcc
|
||||
}
|
||||
return bcc
|
||||
}
|
||||
|
||||
/** Build a command frame with EOT, ID, STX, command, params, ETX, BCC */
|
||||
function buildFrame(commandCode: number, parameters: number[]): Buffer {
|
||||
const frame = [EOT, ID, STX, commandCode, ...parameters, 0x03]
|
||||
const bcc = computeBcc(frame)
|
||||
frame.push(bcc)
|
||||
return Buffer.from(frame)
|
||||
}
|
||||
|
||||
/** Find SOH byte index in buffer */
|
||||
function findSohIndex(buffer: Buffer): number {
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
if (buffer[i] === SOH) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** Parse a response frame from the buffer */
|
||||
function parseFrame(buffer: Buffer): DispenseResponse | null {
|
||||
const sohIndex = findSohIndex(buffer)
|
||||
if (sohIndex === -1) throw new Error('no SOH')
|
||||
|
||||
const frame = buffer.subarray(sohIndex)
|
||||
|
||||
// Need at least the command code
|
||||
if (frame.length < 4) return null
|
||||
|
||||
if (frame[1] !== ID || frame[2] !== STX) throw new Error('invalid frame')
|
||||
const commandCode = frame[3]!
|
||||
const command = COMMANDS[commandCode]
|
||||
if (!command) {
|
||||
throw new Error('unsupported command: 0x' + commandCode.toString(16))
|
||||
}
|
||||
if (frame.length < command.responseParameters + 7) return null
|
||||
|
||||
const rawErrorCode = (frame[4] ?? 0) - 0x20
|
||||
const errorCode = rawErrorCode === 0 ? null : rawErrorCode
|
||||
const res: DispenseResponse = {
|
||||
code: commandCode,
|
||||
name: command.name,
|
||||
err: errorCode,
|
||||
}
|
||||
|
||||
if (command.name === 'dispense') {
|
||||
res.bills = [
|
||||
{ dispensed: (frame[6] ?? 0) - 0x20, rejected: (frame[7] ?? 0) - 0x20 },
|
||||
{ dispensed: (frame[9] ?? 0) - 0x20, rejected: (frame[10] ?? 0) - 0x20 },
|
||||
]
|
||||
}
|
||||
|
||||
if (command.name === 'getSerialNumber') {
|
||||
res.serialNumber = (frame[5] ?? 0) - 0x20
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
export class PuloonRs232 {
|
||||
private serial: SerialPort | null = null
|
||||
private device: string
|
||||
private buffer: Buffer = Buffer.from([])
|
||||
private state: State = 'idle'
|
||||
private response: DispenseResponse | null = null
|
||||
private serialNumber: number = 0
|
||||
private retryTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private resolveResponse: ((res: DispenseResponse) => void) | null = null
|
||||
private rejectResponse: ((err: Error) => void) | null = null
|
||||
/** Count of responses received (used for reset which sends 2) */
|
||||
private responseCount: number = 0
|
||||
private expectedResponses: number = 1
|
||||
|
||||
constructor(device: string) {
|
||||
this.device = device
|
||||
}
|
||||
|
||||
/** Open the serial port */
|
||||
async create(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
console.log('INFO Puloon device: ' + this.device)
|
||||
|
||||
const serial = new SerialPort({
|
||||
path: this.device,
|
||||
baudRate: 9600,
|
||||
parity: 'even',
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
})
|
||||
|
||||
this.serial = serial
|
||||
|
||||
serial.on('error', (err) => {
|
||||
console.error('PULOON | Serial error:', err.message)
|
||||
})
|
||||
|
||||
serial.on('open', () => {
|
||||
console.log('INFO puloon connected')
|
||||
serial.on('readable', () => {
|
||||
const data = serial.read() as Buffer | null
|
||||
if (data) this.process(data)
|
||||
})
|
||||
serial.on('close', () => {
|
||||
console.log('PULOON | Disconnected')
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
|
||||
serial.on('error', (err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Initialize the dispenser: reset, read serial number, configure bill lengths */
|
||||
async initialize(cassettes: CassetteConfig[], fiatCode: string): Promise<void> {
|
||||
// Reset sends 2 responses (before and after motor reset)
|
||||
await this.sendWithMultipleResponses(buildFrame(0x44, []), 2, true)
|
||||
|
||||
// Get serial number
|
||||
const snRes = await this.send(buildFrame(0x67, []), true)
|
||||
this.serialNumber = snRes.serialNumber ?? 0
|
||||
|
||||
// Set bill lengths
|
||||
const data = this.buildBillLengthData(cassettes, fiatCode)
|
||||
await this.send(buildFrame(0x5e, data), true)
|
||||
}
|
||||
|
||||
/** Dispense bills, batching in groups of max 20 per command */
|
||||
async dispense(notes: number[]): Promise<{ bills: DispenseResult[]; error?: Error }> {
|
||||
// Build batches of max 20 bills per command
|
||||
const batches: number[][] = []
|
||||
let remaining = [...notes]
|
||||
|
||||
while (true) {
|
||||
const current = remaining.map((n) => Math.min(20, n))
|
||||
remaining = remaining.map((n, i) => n - current[i]!)
|
||||
|
||||
if (Math.max(...current) === 0) break
|
||||
batches.push(current)
|
||||
}
|
||||
|
||||
let aggregated: DispenseResult[] = [
|
||||
{ dispensed: 0, rejected: 0 },
|
||||
{ dispensed: 0, rejected: 0 },
|
||||
]
|
||||
let error: Error | undefined
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
if (error) break
|
||||
|
||||
const batch = batches[i]!
|
||||
|
||||
// Pause between batches
|
||||
if (i > 0) {
|
||||
await this.delay(PAUSE_BETWEEN_DISPENSES)
|
||||
}
|
||||
|
||||
this.serialNumber += 1
|
||||
const dispenseParams = [
|
||||
0x20 + batch[0]!,
|
||||
0x20 + batch[1]!,
|
||||
0x20,
|
||||
0x20,
|
||||
0x20,
|
||||
0x20,
|
||||
0x20 + this.serialNumber,
|
||||
]
|
||||
|
||||
console.log('PULOON | CMD -> dispense')
|
||||
const res = await this.send(buildFrame(0x52, dispenseParams), false)
|
||||
|
||||
if (res.bills) {
|
||||
aggregated = aggregated.map((a, j) => ({
|
||||
dispensed: a.dispensed + (res.bills![j]?.dispensed ?? 0),
|
||||
rejected: a.rejected + (res.bills![j]?.rejected ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
if (res.err) {
|
||||
error = new Error(`Dispensing, code: ${res.err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { bills: aggregated, error }
|
||||
}
|
||||
|
||||
/** Close the serial port */
|
||||
close(): void {
|
||||
if (this.serial) {
|
||||
setTimeout(() => {
|
||||
this.serial?.close()
|
||||
this.serial = null
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a command and wait for a single response */
|
||||
private send(command: Buffer, withRetry: boolean): Promise<DispenseResponse> {
|
||||
return new Promise<DispenseResponse>((resolve, reject) => {
|
||||
this.resolveResponse = resolve
|
||||
this.rejectResponse = reject
|
||||
this.responseCount = 0
|
||||
this.expectedResponses = 1
|
||||
this.setState('waitAck')
|
||||
this.serial!.write(command)
|
||||
|
||||
if (withRetry) {
|
||||
this.retryTimeout = setTimeout(() => this.serial!.write(command), 5000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Send a command that expects multiple responses (e.g. reset) */
|
||||
private sendWithMultipleResponses(
|
||||
command: Buffer,
|
||||
count: number,
|
||||
withRetry: boolean
|
||||
): Promise<DispenseResponse> {
|
||||
return new Promise<DispenseResponse>((resolve, reject) => {
|
||||
this.resolveResponse = resolve
|
||||
this.rejectResponse = reject
|
||||
this.responseCount = 0
|
||||
this.expectedResponses = count
|
||||
this.setState('waitAck')
|
||||
this.serial!.write(command)
|
||||
|
||||
if (withRetry) {
|
||||
this.retryTimeout = setTimeout(() => this.serial!.write(command), 5000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Process incoming serial data through the state machine */
|
||||
private process(data: Buffer): void {
|
||||
if (data.length === 0) return
|
||||
|
||||
// Ignore spurious 0xff bytes in non-response states
|
||||
if (
|
||||
(this.state === 'idle' || this.state === 'waitAck' || this.state === 'waitEOT') &&
|
||||
data[0] === 0xff
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.state === 'waitAck') {
|
||||
if (data[0] === ACK) {
|
||||
if (this.retryTimeout) {
|
||||
clearTimeout(this.retryTimeout)
|
||||
this.retryTimeout = null
|
||||
}
|
||||
this.setState('waitResponse')
|
||||
this.processRemaining(data, 1)
|
||||
return
|
||||
}
|
||||
if (data[0] === NAK) {
|
||||
this.setState('idle')
|
||||
this.rejectResponse?.(new Error('NAK received from Puloon'))
|
||||
this.rejectResponse = null
|
||||
this.resolveResponse = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state === 'waitEOT') {
|
||||
if (data[0] === EOT) {
|
||||
this.responseCount++
|
||||
if (this.responseCount >= this.expectedResponses) {
|
||||
this.setState('idle')
|
||||
const response = this.response
|
||||
this.response = null
|
||||
this.resolveResponse?.(response!)
|
||||
this.resolveResponse = null
|
||||
this.rejectResponse = null
|
||||
} else {
|
||||
// More responses expected (e.g. reset sends 2), go back to waitAck
|
||||
this.setState('waitAck')
|
||||
}
|
||||
this.processRemaining(data, 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.buffer = Buffer.concat([this.buffer, data])
|
||||
const response = parseFrame(this.buffer)
|
||||
if (response === null) return
|
||||
this.response = response
|
||||
this.buffer = Buffer.from([])
|
||||
|
||||
this.serial!.write(Buffer.from([ACK]))
|
||||
this.setState('waitEOT')
|
||||
}
|
||||
|
||||
/** Process remaining bytes after consuming the first byte */
|
||||
private processRemaining(data: Buffer, offset: number): void {
|
||||
const remaining = data.subarray(offset)
|
||||
if (remaining.length > 0) {
|
||||
process.nextTick(() => this.process(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: State): void {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
/** Build bill length configuration data for setBillLengths command */
|
||||
private buildBillLengthData(cassettes: CassetteConfig[], fiatCode: string): number[] {
|
||||
if (cassettes.length > 4) throw new Error('Too many cassettes')
|
||||
|
||||
const currencyLengths = billLengths[fiatCode]
|
||||
if (!currencyLengths) throw new Error('Unsupported fiatCode: ' + fiatCode)
|
||||
|
||||
// Initialize all 4 cassette slots with default (0x30, 0x30)
|
||||
const data = [0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30]
|
||||
|
||||
for (let i = 0; i < cassettes.length; i++) {
|
||||
const cassette = cassettes[i]!
|
||||
const billLength = currencyLengths[cassette.denomination]
|
||||
if (!billLength) {
|
||||
throw new Error(
|
||||
'Unsupported denomination: ' + cassette.denomination + ' for fiatCode: ' + fiatCode
|
||||
)
|
||||
}
|
||||
const [high, low] = encodeBillLength(billLength)
|
||||
const index = i * 2
|
||||
data[index] = high
|
||||
data[index + 1] = low
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,12 @@
|
|||
export { Id003, createValidator, type ValidatorType } from './validators/index.js'
|
||||
|
||||
// Dispensers
|
||||
export { F56Dispenser, createDispenser, type DispenserType } from './dispensers/index.js'
|
||||
export {
|
||||
F56Dispenser,
|
||||
PuloonDispenser,
|
||||
createDispenser,
|
||||
type DispenserType,
|
||||
} from './dispensers/index.js'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue