diff --git a/apps/machine/src/config/device.ts b/apps/machine/src/config/device.ts index 6efcccf..4a6c108 100644 --- a/apps/machine/src/config/device.ts +++ b/apps/machine/src/config/device.ts @@ -15,7 +15,7 @@ import type { HalConfig, CassetteConfig } from '@/services/hal' /** * Supported machine models */ -export type MachineModel = 'sintra' | '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> = { + 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] +} diff --git a/packages/hal/src/dispensers/puloon/index.ts b/packages/hal/src/dispensers/puloon/index.ts new file mode 100644 index 0000000..8f1ff15 --- /dev/null +++ b/packages/hal/src/dispensers/puloon/index.ts @@ -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 { + 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 { + return true + } + + /** Puloon has no exit sensor — always returns true */ + async waitForBillsRemoved(): Promise { + return true + } +} + +export default PuloonDispenser diff --git a/packages/hal/src/dispensers/puloon/puloon-rs232.ts b/packages/hal/src/dispensers/puloon/puloon-rs232.ts new file mode 100644 index 0000000..e057b73 --- /dev/null +++ b/packages/hal/src/dispensers/puloon/puloon-rs232.ts @@ -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 = { + 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 | 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 { + return new Promise((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 { + // 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 { + return new Promise((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 { + return new Promise((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 { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} diff --git a/packages/hal/src/index.ts b/packages/hal/src/index.ts index 440a84b..2887a2b 100644 --- a/packages/hal/src/index.ts +++ b/packages/hal/src/index.ts @@ -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 {