diff --git a/lamassu-next/packages/hal/package.json b/lamassu-next/packages/hal/package.json new file mode 100644 index 0000000..a60ac54 --- /dev/null +++ b/lamassu-next/packages/hal/package.json @@ -0,0 +1,57 @@ +{ + "name": "@lamassu/hal", + "version": "0.1.0", + "description": "Hardware Abstraction Layer for Lamassu ATM devices", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./validators": { + "import": "./dist/validators/index.js", + "types": "./dist/validators/index.d.ts" + }, + "./dispensers": { + "import": "./dist/dispensers/index.js", + "types": "./dist/dispensers/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist" + }, + "dependencies": { + "lodash-es": "^4.17.21", + "serialport": "^12.0.0" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.0", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^2.0.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "files": [ + "dist", + "src" + ], + "keywords": [ + "lamassu", + "atm", + "hardware", + "bill-validator", + "bill-dispenser", + "id003", + "f56", + "fujitsu" + ], + "license": "MIT" +} diff --git a/lamassu-next/packages/hal/src/dispensers/f56/bills.ts b/lamassu-next/packages/hal/src/dispensers/f56/bills.ts new file mode 100644 index 0000000..7ed313a --- /dev/null +++ b/lamassu-next/packages/hal/src/dispensers/f56/bills.ts @@ -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 + polymer: boolean +} + +export const bills: Record = { + 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 diff --git a/lamassu-next/packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts b/lamassu-next/packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts new file mode 100644 index 0000000..3ad1ebb --- /dev/null +++ b/lamassu-next/packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts @@ -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 | 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 diff --git a/lamassu-next/packages/hal/src/dispensers/f56/f56-fsm.ts b/lamassu-next/packages/hal/src/dispensers/f56/f56-fsm.ts new file mode 100644 index 0000000..3f33cd9 --- /dev/null +++ b/lamassu-next/packages/hal/src/dispensers/f56/f56-fsm.ts @@ -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 = { + 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 | 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 diff --git a/lamassu-next/packages/hal/src/dispensers/f56/f56-rs232.ts b/lamassu-next/packages/hal/src/dispensers/f56/f56-rs232.ts new file mode 100644 index 0000000..ae233c0 --- /dev/null +++ b/lamassu-next/packages/hal/src/dispensers/f56/f56-rs232.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, +} diff --git a/lamassu-next/packages/hal/src/dispensers/f56/index.ts b/lamassu-next/packages/hal/src/dispensers/f56/index.ts new file mode 100644 index 0000000..182febf --- /dev/null +++ b/lamassu-next/packages/hal/src/dispensers/f56/index.ts @@ -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 { + 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 { + return f56.billsPresent() + } + + async waitForBillsRemoved(): Promise { + 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 diff --git a/lamassu-next/packages/hal/src/dispensers/index.ts b/lamassu-next/packages/hal/src/dispensers/index.ts new file mode 100644 index 0000000..8e7d072 --- /dev/null +++ b/lamassu-next/packages/hal/src/dispensers/index.ts @@ -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}`) + } +} diff --git a/lamassu-next/packages/hal/src/index.ts b/lamassu-next/packages/hal/src/index.ts new file mode 100644 index 0000000..440a84b --- /dev/null +++ b/lamassu-next/packages/hal/src/index.ts @@ -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' diff --git a/lamassu-next/packages/hal/src/types.ts b/lamassu-next/packages/hal/src/types.ts new file mode 100644 index 0000000..0936fea --- /dev/null +++ b/lamassu-next/packages/hal/src/types.ts @@ -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 + + /** + * 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 + + /** + * Wait for customer to remove dispensed bills + * @returns Resolves when bills are removed + */ + waitForBillsRemoved(): Promise +} + +/** + * Factory function signature for validators + */ +export type ValidatorFactory = (config: ValidatorConfig) => BillValidator + +/** + * Factory function signature for dispensers + */ +export type DispenserFactory = (config: DispenserConfig) => BillDispenser diff --git a/lamassu-next/packages/hal/src/utils/crc.ts b/lamassu-next/packages/hal/src/utils/crc.ts new file mode 100644 index 0000000..0dfdaaa --- /dev/null +++ b/lamassu-next/packages/hal/src/utils/crc.ts @@ -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 { + 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 +} diff --git a/lamassu-next/packages/hal/src/validators/id003/id003-fsm.ts b/lamassu-next/packages/hal/src/validators/id003/id003-fsm.ts new file mode 100644 index 0000000..1733bc1 --- /dev/null +++ b/lamassu-next/packages/hal/src/validators/id003/id003-fsm.ts @@ -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 + +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 | 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 diff --git a/lamassu-next/packages/hal/src/validators/id003/id003-rs232.ts b/lamassu-next/packages/hal/src/validators/id003/id003-rs232.ts new file mode 100644 index 0000000..b9cfcd8 --- /dev/null +++ b/lamassu-next/packages/hal/src/validators/id003/id003-rs232.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 | 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 { + 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 { + 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 | 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 = {} + 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 diff --git a/lamassu-next/packages/hal/src/validators/id003/index.ts b/lamassu-next/packages/hal/src/validators/id003/index.ts new file mode 100644 index 0000000..e2c5c18 --- /dev/null +++ b/lamassu-next/packages/hal/src/validators/id003/index.ts @@ -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 | 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 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 | 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 diff --git a/lamassu-next/packages/hal/src/validators/index.ts b/lamassu-next/packages/hal/src/validators/index.ts new file mode 100644 index 0000000..0588aec --- /dev/null +++ b/lamassu-next/packages/hal/src/validators/index.ts @@ -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}`) + } +} diff --git a/lamassu-next/packages/hal/tsconfig.json b/lamassu-next/packages/hal/tsconfig.json new file mode 100644 index 0000000..360920e --- /dev/null +++ b/lamassu-next/packages/hal/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/lamassu-next/pnpm-lock.yaml b/lamassu-next/pnpm-lock.yaml index 3925b19..04ff270 100644 --- a/lamassu-next/pnpm-lock.yaml +++ b/lamassu-next/pnpm-lock.yaml @@ -138,6 +138,28 @@ importers: specifier: ^2.1.0 version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2) + packages/hal: + dependencies: + lodash-es: + specifier: ^4.17.21 + version: 4.17.23 + serialport: + specifier: ^12.0.0 + version: 12.0.0 + devDependencies: + '@types/lodash-es': + specifier: ^4.17.0 + version: 4.17.12 + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2) + packages/lightning: dependencies: '@lamassu/clink': @@ -184,6 +206,9 @@ importers: '@types/node': specifier: ^22.19.7 version: 22.19.7 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 tsx: specifier: ^4.19.0 version: 4.21.0 @@ -193,6 +218,9 @@ importers: vitest: specifier: ^2.1.0 version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2) + ws: + specifier: ^8.19.0 + version: 8.19.0 packages/state-machine: dependencies: @@ -923,6 +951,70 @@ packages: '@scure/bip39@1.2.1': resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + '@serialport/binding-mock@10.2.2': + resolution: {integrity: sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==} + engines: {node: '>=12.0.0'} + + '@serialport/bindings-cpp@12.0.1': + resolution: {integrity: sha512-r2XOwY2dDvbW7dKqSPIk2gzsr6M6Qpe9+/Ngs94fNaNlcTRCV02PfaoDmRgcubpNVVcLATlxSxPTIDw12dbKOg==} + engines: {node: '>=16.0.0'} + + '@serialport/bindings-interface@1.2.2': + resolution: {integrity: sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==} + engines: {node: ^12.22 || ^14.13 || >=16} + + '@serialport/parser-byte-length@12.0.0': + resolution: {integrity: sha512-0ei0txFAj+s6FTiCJFBJ1T2hpKkX8Md0Pu6dqMrYoirjPskDLJRgZGLqoy3/lnU1bkvHpnJO+9oJ3PB9v8rNlg==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-cctalk@12.0.0': + resolution: {integrity: sha512-0PfLzO9t2X5ufKuBO34DQKLXrCCqS9xz2D0pfuaLNeTkyGUBv426zxoMf3rsMRodDOZNbFblu3Ae84MOQXjnZw==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-delimiter@11.0.0': + resolution: {integrity: sha512-aZLJhlRTjSmEwllLG7S4J8s8ctRAS0cbvCpO87smLvl3e4BgzbVgF6Z6zaJd3Aji2uSiYgfedCdNc4L6W+1E2g==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-delimiter@12.0.0': + resolution: {integrity: sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-inter-byte-timeout@12.0.0': + resolution: {integrity: sha512-GnCh8K0NAESfhCuXAt+FfBRz1Cf9CzIgXfp7SdMgXwrtuUnCC/yuRTUFWRvuzhYKoAo1TL0hhUo77SFHUH1T/w==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-packet-length@12.0.0': + resolution: {integrity: sha512-p1hiCRqvGHHLCN/8ZiPUY/G0zrxd7gtZs251n+cfNTn+87rwcdUeu9Dps3Aadx30/sOGGFL6brIRGK4l/t7MuQ==} + engines: {node: '>=8.6.0'} + + '@serialport/parser-readline@11.0.0': + resolution: {integrity: sha512-rRAivhRkT3YO28WjmmG4FQX6L+KMb5/ikhyylRfzWPw0nSXy97+u07peS9CbHqaNvJkMhH1locp2H36aGMOEIA==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-readline@12.0.0': + resolution: {integrity: sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-ready@12.0.0': + resolution: {integrity: sha512-ygDwj3O4SDpZlbrRUraoXIoIqb8sM7aMKryGjYTIF0JRnKeB1ys8+wIp0RFMdFbO62YriUDextHB5Um5cKFSWg==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-regex@12.0.0': + resolution: {integrity: sha512-dCAVh4P/pZrLcPv9NJ2mvPRBg64L5jXuiRxIlyxxdZGH4WubwXVXY/kBTihQmiAMPxbT3yshSX8f2+feqWsxqA==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-slip-encoder@12.0.0': + resolution: {integrity: sha512-0APxDGR9YvJXTRfY+uRGhzOhTpU5akSH183RUcwzN7QXh8/1jwFsFLCu0grmAUfi+fItCkR+Xr1TcNJLR13VNA==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-spacepacket@12.0.0': + resolution: {integrity: sha512-dozONxhPC/78pntuxpz/NOtVps8qIc/UZzdc/LuPvVsqCoJXiRxOg6ZtCP/W58iibJDKPZPAWPGYeZt9DJxI+Q==} + engines: {node: '>=12.0.0'} + + '@serialport/stream@12.0.0': + resolution: {integrity: sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==} + engines: {node: '>=12.0.0'} + '@shocknet/clink-sdk@1.5.4': resolution: {integrity: sha512-YrKR7oFjzUmBhm4p8pT6mP3YC7UdWbIlh5PMXHSwSLfJhL8Ddx91NLIfOHUTcQa1cDf27uvillMFXYrVUMCRuQ==} @@ -1119,6 +1211,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.23': + resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/node@22.19.7': resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} @@ -1238,6 +1336,14 @@ packages: alien-signals@1.0.13: resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1256,6 +1362,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1267,16 +1377,35 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1286,6 +1415,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -1297,6 +1430,12 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.18.4: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} @@ -1342,11 +1481,19 @@ packages: picomatch: optional: true + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -1361,6 +1508,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1435,6 +1586,13 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1462,6 +1620,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1473,6 +1634,13 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-addon-api@7.0.0: + resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} + + node-gyp-build@4.6.0: + resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==} + hasBin: true + nostr-tools@2.15.1: resolution: {integrity: sha512-LpetHDR9ltnkpJDkva/SONgyKBbsoV+5yLB8DWc0/U3lCWGtoWJw6Nbc2vR2Ai67RIQYrBQeZLyMlhwVZRK/9A==} peerDependencies: @@ -1495,12 +1663,28 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-scurry@2.0.1: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} @@ -1528,6 +1712,10 @@ packages: typescript: optional: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1542,11 +1730,23 @@ packages: peerDependencies: vue: ^3.0.0 + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + reka-ui@2.7.0: resolution: {integrity: sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==} peerDependencies: vue: '>= 3.2.0' + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1560,6 +1760,13 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + serialport@12.0.0: + resolution: {integrity: sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==} + engines: {node: '>=16.0.0'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1573,6 +1780,14 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -1792,14 +2007,44 @@ packages: typescript: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xstate@5.25.1: resolution: {integrity: sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + snapshots: '@babel/helper-string-parser@7.27.1': {} @@ -2221,6 +2466,60 @@ snapshots: '@noble/hashes': 1.3.2 '@scure/base': 1.1.1 + '@serialport/binding-mock@10.2.2': + dependencies: + '@serialport/bindings-interface': 1.2.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@serialport/bindings-cpp@12.0.1': + dependencies: + '@serialport/bindings-interface': 1.2.2 + '@serialport/parser-readline': 11.0.0 + debug: 4.3.4 + node-addon-api: 7.0.0 + node-gyp-build: 4.6.0 + transitivePeerDependencies: + - supports-color + + '@serialport/bindings-interface@1.2.2': {} + + '@serialport/parser-byte-length@12.0.0': {} + + '@serialport/parser-cctalk@12.0.0': {} + + '@serialport/parser-delimiter@11.0.0': {} + + '@serialport/parser-delimiter@12.0.0': {} + + '@serialport/parser-inter-byte-timeout@12.0.0': {} + + '@serialport/parser-packet-length@12.0.0': {} + + '@serialport/parser-readline@11.0.0': + dependencies: + '@serialport/parser-delimiter': 11.0.0 + + '@serialport/parser-readline@12.0.0': + dependencies: + '@serialport/parser-delimiter': 12.0.0 + + '@serialport/parser-ready@12.0.0': {} + + '@serialport/parser-regex@12.0.0': {} + + '@serialport/parser-slip-encoder@12.0.0': {} + + '@serialport/parser-spacepacket@12.0.0': {} + + '@serialport/stream@12.0.0': + dependencies: + '@serialport/bindings-interface': 1.2.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + '@shocknet/clink-sdk@1.5.4': dependencies: '@noble/hashes': 1.8.0 @@ -2378,6 +2677,12 @@ snapshots: '@types/estree@1.0.8': {} + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.23 + + '@types/lodash@4.17.23': {} + '@types/node@22.19.7': dependencies: undici-types: 6.21.0 @@ -2547,6 +2852,12 @@ snapshots: alien-signals@1.0.13: {} + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -2561,6 +2872,8 @@ snapshots: cac@6.7.14: {} + camelcase@5.3.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -2575,22 +2888,44 @@ snapshots: dependencies: clsx: 2.1.1 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + csstype@3.2.3: {} de-indent@1.0.2: {} + debug@4.3.4: + dependencies: + ms: 2.1.2 + debug@4.4.3: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-eql@5.0.2: {} defu@6.1.4: {} detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} + + emoji-regex@8.0.0: {} + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 @@ -2696,9 +3031,16 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -2713,6 +3055,8 @@ snapshots: he@1.2.0: {} + is-fullwidth-code-point@3.0.0: {} + jiti@2.6.1: {} lightningcss-android-arm64@1.30.2: @@ -2764,6 +3108,12 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash-es@4.17.23: {} + loupe@3.2.1: {} lru-cache@11.2.4: {} @@ -2786,12 +3136,18 @@ snapshots: minipass@7.1.2: {} + ms@2.1.2: {} + ms@2.1.3: {} muggle-string@0.4.1: {} nanoid@3.3.11: {} + node-addon-api@7.0.0: {} + + node-gyp-build@4.6.0: {} + nostr-tools@2.15.1(typescript@5.9.3): dependencies: '@noble/ciphers': 0.5.3 @@ -2820,10 +3176,22 @@ snapshots: ohash@2.0.11: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} path-browserify@1.0.1: {} + path-exists@4.0.0: {} + path-scurry@2.0.1: dependencies: lru-cache: 11.2.4 @@ -2847,6 +3215,8 @@ snapshots: transitivePeerDependencies: - '@vue/composition-api' + pngjs@5.0.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2859,6 +3229,12 @@ snapshots: dependencies: vue: 3.5.27(typescript@5.9.3) + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + reka-ui@2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)): dependencies: '@floating-ui/dom': 1.7.4 @@ -2876,6 +3252,10 @@ snapshots: - '@vue/composition-api' - typescript + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + resolve-pkg-maps@1.0.0: {} rimraf@6.1.2: @@ -2914,6 +3294,27 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.56.0 fsevents: 2.3.3 + serialport@12.0.0: + dependencies: + '@serialport/binding-mock': 10.2.2 + '@serialport/bindings-cpp': 12.0.1 + '@serialport/parser-byte-length': 12.0.0 + '@serialport/parser-cctalk': 12.0.0 + '@serialport/parser-delimiter': 12.0.0 + '@serialport/parser-inter-byte-timeout': 12.0.0 + '@serialport/parser-packet-length': 12.0.0 + '@serialport/parser-readline': 12.0.0 + '@serialport/parser-ready': 12.0.0 + '@serialport/parser-regex': 12.0.0 + '@serialport/parser-slip-encoder': 12.0.0 + '@serialport/parser-spacepacket': 12.0.0 + '@serialport/stream': 12.0.0 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -2922,6 +3323,16 @@ snapshots: std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + tailwind-merge@3.4.0: {} tailwindcss@4.1.18: {} @@ -3090,9 +3501,40 @@ snapshots: optionalDependencies: typescript: 5.9.3 + which-module@2.0.1: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + ws@8.19.0: {} + xstate@5.25.1: {} + + y18n@4.0.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3