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:
Patrick Mulligan 2026-02-22 20:01:26 -05:00
commit 350e7a8974
7 changed files with 556 additions and 8 deletions

View file

@ -15,7 +15,7 @@ import type { HalConfig, CassetteConfig } from '@/services/hal'
/** /**
* Supported machine models * Supported machine models
*/ */
export type MachineModel = 'sintra' | 'gaia' | 'custom' export type MachineModel = 'sintra' | 'douro' | 'gaia' | 'custom'
/** /**
* Full device configuration * Full device configuration
@ -35,7 +35,7 @@ export interface DeviceConfig {
/** Bill dispenser configuration */ /** Bill dispenser configuration */
dispenser: { dispenser: {
/** Dispenser model */ /** Dispenser model */
type: 'f56' type: 'f56' | 'puloon'
/** Serial device path */ /** Serial device path */
device: string device: string
/** Cassette configuration */ /** 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 * Lamassu Gaia
* - Validator: JCM iVIZION (ID003 protocol) * - Validator: JCM iVIZION (ID003 protocol)
@ -165,7 +183,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
const dispenserDevice = import.meta.env.VITE_LAMASSU_DISPENSER_DEVICE const dispenserDevice = import.meta.env.VITE_LAMASSU_DISPENSER_DEVICE
if (dispenserDevice) { if (dispenserDevice) {
overrides.dispenser = { overrides.dispenser = {
type: 'f56', type: MACHINE_PRESETS[model].dispenser.type,
device: dispenserDevice, device: dispenserDevice,
cassettes: [], cassettes: [],
} }
@ -180,7 +198,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
overrides.dispenser.cassettes = cassettes overrides.dispenser.cassettes = cassettes
} else { } else {
overrides.dispenser = { overrides.dispenser = {
type: 'f56', type: MACHINE_PRESETS[model].dispenser.type,
device: MACHINE_PRESETS[model].dispenser.device, device: MACHINE_PRESETS[model].dispenser.device,
cassettes, cassettes,
} }

View file

@ -28,7 +28,7 @@ export interface HalConfig {
fiatCode: string fiatCode: string
} }
dispenser: { dispenser: {
type: 'f56' type: 'f56' | 'puloon'
device: string device: string
cassettes: CassetteConfig[] cassettes: CassetteConfig[]
} }

View file

@ -5,22 +5,26 @@
*/ */
export { F56Dispenser } from './f56/index.js' export { F56Dispenser } from './f56/index.js'
export { PuloonDispenser } from './puloon/index.js'
export type { DispenserConfig, BillDispenser, DispenserInitData, DispenseResult } from '../types.js' export type { DispenserConfig, BillDispenser, DispenserInitData, DispenseResult } from '../types.js'
import { F56Dispenser } from './f56/index.js' import { F56Dispenser } from './f56/index.js'
import { PuloonDispenser } from './puloon/index.js'
import type { DispenserConfig, BillDispenser } from '../types.js' import type { DispenserConfig, BillDispenser } from '../types.js'
export type DispenserType = 'f56' export type DispenserType = 'f56' | 'puloon'
/** /**
* Create a bill dispenser instance * Create a bill dispenser instance
* @param type Dispenser type (e.g., 'f56') * @param type Dispenser type (e.g., 'f56', 'puloon')
* @param config Dispenser configuration * @param config Dispenser configuration
*/ */
export function createDispenser(type: DispenserType, config: DispenserConfig): BillDispenser { export function createDispenser(type: DispenserType, config: DispenserConfig): BillDispenser {
switch (type) { switch (type) {
case 'f56': case 'f56':
return F56Dispenser.factory(config) return F56Dispenser.factory(config)
case 'puloon':
return PuloonDispenser.factory(config)
default: default:
throw new Error(`Unknown dispenser type: ${type}`) throw new Error(`Unknown dispenser type: ${type}`)
} }

View 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]
}

View 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

View 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))
}
}

View file

@ -35,7 +35,12 @@
export { Id003, createValidator, type ValidatorType } from './validators/index.js' export { Id003, createValidator, type ValidatorType } from './validators/index.js'
// Dispensers // Dispensers
export { F56Dispenser, createDispenser, type DispenserType } from './dispensers/index.js' export {
F56Dispenser,
PuloonDispenser,
createDispenser,
type DispenserType,
} from './dispensers/index.js'
// Types // Types
export type { export type {