From f7307c9159f259d775ac684954c0ab16605978e4 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Tue, 27 Jan 2026 13:46:25 -0500 Subject: [PATCH] feat: wire HAL drivers to state machine via service wrapper (#19) - Create apps/machine/src/services/hal.ts bridging @lamassu/hal EventEmitter-based drivers to ATMServices interface - Add initializeWithHal() to ATM store for hardware + Lightning init - Merge HAL services (dispenseCash, getInventory) with Lightning services - Wire validator events to state machine (BILL_INSERTED, BILL_REJECTED) - Exclude @lamassu/hal from Vite browser bundle (Node.js only) - Add @lamassu/hal as dependency to apps/machine Co-Authored-By: Claude Opus 4.5 --- lamassu-next/apps/machine/package.json | 1 + lamassu-next/apps/machine/src/services/hal.ts | 211 ++++++++++++++++++ lamassu-next/apps/machine/src/stores/atm.ts | 81 +++++++ lamassu-next/apps/machine/vite.config.ts | 5 + lamassu-next/pnpm-lock.yaml | 3 + 5 files changed, 301 insertions(+) create mode 100644 lamassu-next/apps/machine/src/services/hal.ts diff --git a/lamassu-next/apps/machine/package.json b/lamassu-next/apps/machine/package.json index ed981f7..ff7099c 100644 --- a/lamassu-next/apps/machine/package.json +++ b/lamassu-next/apps/machine/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@lamassu/clink": "workspace:*", + "@lamassu/hal": "workspace:*", "@lamassu/lightning": "workspace:*", "@lamassu/nostr-client": "workspace:*", "@lamassu/state-machine": "workspace:*", diff --git a/lamassu-next/apps/machine/src/services/hal.ts b/lamassu-next/apps/machine/src/services/hal.ts new file mode 100644 index 0000000..489e115 --- /dev/null +++ b/lamassu-next/apps/machine/src/services/hal.ts @@ -0,0 +1,211 @@ +/** + * HAL Service Wrapper + * + * Bridges @lamassu/hal hardware drivers (EventEmitter-based) to the + * ATMServices interface expected by the XState state machine. + * + * Validator events are pushed into the state machine via callbacks. + * Dispenser operations are exposed as Promise-based ATMServices methods. + * + * NOTE: This module uses dynamic imports for @lamassu/hal because it + * requires Node.js APIs (serialport, node:events) that are unavailable + * in the browser. It will only work in Electron's main process or + * Node.js environments. In the browser, initializeHalServices() will + * throw with a clear error message. + */ + +import type { ATMServices } from '@lamassu/state-machine' + +export interface CassetteConfig { + denomination: number + count?: number +} + +export interface HalConfig { + validator: { + type: 'id003' + device: string | string[] + fiatCode: string + } + dispenser: { + type: 'f56' + device: string + cassettes: CassetteConfig[] + } +} + +export interface ValidatorCallbacks { + onBillInserted: (denomination: number) => void + onBillRejected: (reason: string) => void + onError: (error: string) => void +} + +export interface HalServices { + /** Partial ATMServices for hardware operations (dispenseCash, getInventory) */ + atmServices: Pick + /** Wire validator events to state machine. Call after actor is started. */ + connectValidator: (callbacks: ValidatorCallbacks) => void + /** Enable bill acceptance */ + enableValidator: () => void + /** Disable bill acceptance */ + disableValidator: () => void + /** Stack the bill currently in escrow (accept it) */ + stackBill: () => void + /** Reject the bill currently in escrow (return it) */ + rejectBill: () => void + /** Clean up all hardware connections */ + cleanup: () => Promise +} + +/** + * Initialize HAL hardware and return services for state machine integration. + * + * The validator runs as an EventEmitter - call `connectValidator()` to wire + * its events to the state machine after the actor is started. + * + * The dispenser is Promise-based and exposed via `atmServices.dispenseCash`. + * + * Requires Node.js environment (Electron main process). + */ +export async function initializeHalServices(config: HalConfig): Promise { + // Dynamic import - only works in Node.js (Electron main process) + const hal = await import('@lamassu/hal').catch(() => { + throw new Error( + '[HAL] @lamassu/hal requires Node.js (serialport, node:events). ' + + 'Run in Electron main process, not the browser.' + ) + }) + + const { validator: valConfig, dispenser: dispConfig } = config + + // Create hardware instances + const validator = hal.createValidator(valConfig.type, { + rs232: { device: valConfig.device, fiatCode: valConfig.fiatCode }, + fiatCode: valConfig.fiatCode, + }) + + const dispenser = hal.createDispenser(dispConfig.type, { + device: dispConfig.device, + }) + + // Initialize dispenser + await dispenser.init({ + fiatCode: valConfig.fiatCode, + cassettes: dispConfig.cassettes, + }) + console.log('[HAL] Dispenser initialized') + + // Start validator + await new Promise((resolve, reject) => { + validator.run((err?: Error) => { + if (err) return reject(err) + resolve() + }) + }) + console.log('[HAL] Validator started') + + // Track inventory (decremented on dispense) + const inventory: Record = {} + for (const cassette of dispConfig.cassettes) { + inventory[cassette.denomination] = cassette.count ?? 0 + } + + // Map cassette index to denomination for dispense translation + const cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination) + + const atmServices: Pick = { + dispenseCash: async (amounts) => { + console.log('[HAL] Dispensing:', amounts) + + // Translate { denomination, count }[] to number[] by cassette position + const notes: number[] = new Array(cassetteDenominations.length).fill(0) + for (const { denomination, count } of amounts) { + const idx = cassetteDenominations.indexOf(denomination) + if (idx === -1) { + throw new Error(`No cassette loaded with denomination: ${denomination}`) + } + notes[idx] = count + } + + const result = await dispenser.dispense(notes) + + // Update inventory + for (let i = 0; i < result.value.length; i++) { + const denom = cassetteDenominations[i] + if (denom !== undefined && inventory[denom] !== undefined) { + inventory[denom] -= result.value[i]?.dispensed ?? 0 + } + } + + if (result.error) { + throw result.error + } + + // Wait for customer to take the bills + await dispenser.waitForBillsRemoved() + console.log('[HAL] Bills removed by customer') + }, + + getInventory: async () => { + return { ...inventory } + }, + } + + return { + atmServices, + + connectValidator: (callbacks: ValidatorCallbacks) => { + validator.on('billsRead', (data: { denomination: number | null; code: number }) => { + if (data.denomination !== null) { + // Auto-stack the bill (accept it into the cash box) + validator.stack() + callbacks.onBillInserted(data.denomination) + } else { + console.log('[HAL] Unknown denomination, rejecting. Code: 0x' + data.code.toString(16)) + validator.reject() + } + }) + + validator.on('billsRejected', (data?: { reason: string; code: number | null }) => { + callbacks.onBillRejected(data?.reason ?? 'unknown') + }) + + validator.on('stackerOpen', () => { + callbacks.onError('Stacker open') + }) + + validator.on('error', (err: Error) => { + callbacks.onError(err.message) + }) + + validator.on('disconnected', () => { + callbacks.onError('Validator disconnected') + }) + }, + + enableValidator: () => { + validator.enable() + validator.lightOn() + }, + + disableValidator: () => { + validator.disable() + validator.lightOff() + }, + + stackBill: () => validator.stack(), + rejectBill: () => validator.reject(), + + cleanup: async () => { + return new Promise((resolve) => { + validator.disable() + validator.lightOff() + dispenser.close() + validator.close((err?: Error) => { + if (err) console.error('[HAL] Validator close error:', err) + resolve() + }) + }) + }, + } +} diff --git a/lamassu-next/apps/machine/src/stores/atm.ts b/lamassu-next/apps/machine/src/stores/atm.ts index 7b2de95..c87587a 100644 --- a/lamassu-next/apps/machine/src/stores/atm.ts +++ b/lamassu-next/apps/machine/src/stores/atm.ts @@ -10,6 +10,7 @@ import { type ATMMachine, } from '@lamassu/state-machine' import { initializeLightningServices } from '@/services/lightning' +import type { HalConfig, HalServices } from '@/services/hal' import type { LightningPubClient } from '@lamassu/lightning' import type { CLINKClient } from '@lamassu/clink' @@ -97,6 +98,7 @@ export const useAtmStore = defineStore('atm', () => { ) const lightningPub = ref(null) const clinkClient = ref(null) + const halServices = ref(null) const isPayingInvoice = ref(false) const isRequestingDebit = ref(false) const paymentError = ref(null) @@ -310,6 +312,83 @@ export const useAtmStore = defineStore('atm', () => { } } + /** + * Initialize with real HAL hardware + Lightning services + * + * Connects to physical bill validator and dispenser, then merges + * hardware services with Lightning payment services. + */ + async function initializeWithHal(halConfig: HalConfig) { + connectionStatus.value = 'connecting' + console.log('[ATM] Initializing HAL hardware...') + + try { + // Dynamic import - hal.ts uses Node.js APIs only available in Electron + const { initializeHalServices } = await import('@/services/hal') + const hal = await initializeHalServices(halConfig) + halServices.value = hal + console.log('[ATM] HAL hardware initialized') + + // Initialize Lightning services + const lightning = await initializeLightningServices() + useLiveServices.value = true + connectionStatus.value = 'connected' + lightningPub.value = lightning.lightningPub + clinkClient.value = lightning.clink + + // Wire payment callbacks + lightning.onPaymentReceived((preimage) => { + paymentReceived(preimage) + }) + + lightning.onOfferRequest((request, sender) => { + if (nestedState.value === 'displayingNoffer') { + send({ + type: 'OFFER_REQUEST_RECEIVED', + request: { + eventId: request.offer || 'unknown', + payerPubkey: sender, + amountSats: request.amount_sats, + description: request.description, + }, + }) + } + }) + + // Merge HAL hardware services with Lightning payment services + const mergedServices: ATMServices = { + ...lightning.atmServices, + ...hal.atmServices, // Override dispenseCash and getInventory with real hardware + } + + // Initialize state machine with merged services + initialize(mergedServices) + + // Wire validator events to state machine + hal.connectValidator({ + onBillInserted: (denomination) => { + send({ type: 'BILL_INSERTED', denomination }) + }, + onBillRejected: (reason) => { + send({ type: 'BILL_REJECTED', reason }) + }, + onError: (error) => { + send({ type: 'ERROR', error }) + }, + }) + + console.log('[ATM] Fully initialized with HAL + Lightning') + } catch (error) { + console.error('[ATM] HAL initialization failed:', error) + connectionStatus.value = 'error' + + // Fall back to mock services + console.log('[ATM] Falling back to mock services') + useLiveServices.value = false + initialize(mockServices) + } + } + function send(event: Parameters['send']>[0]) { if (!actor.value) { console.error('[ATM] Cannot send event: machine not initialized') @@ -384,6 +463,7 @@ export const useAtmStore = defineStore('atm', () => { debugMode, useLiveServices, connectionStatus, + halServices, isPayingInvoice, isRequestingDebit, paymentError, @@ -399,6 +479,7 @@ export const useAtmStore = defineStore('atm', () => { // Actions initialize, initializeWithLightning, + initializeWithHal, send, selectCashIn, selectCashOut, diff --git a/lamassu-next/apps/machine/vite.config.ts b/lamassu-next/apps/machine/vite.config.ts index f331db5..557d5cf 100644 --- a/lamassu-next/apps/machine/vite.config.ts +++ b/lamassu-next/apps/machine/vite.config.ts @@ -27,6 +27,11 @@ export default defineConfig({ minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // Produce sourcemaps for debug builds sourcemap: !!process.env.TAURI_ENV_DEBUG, + rollupOptions: { + // @lamassu/hal uses Node.js APIs (serialport, node:events) and will only + // run in Electron's main process. Exclude from browser bundle. + external: ['@lamassu/hal'], + }, }, // Prevent Vite from clearing the screen clearScreen: false, diff --git a/lamassu-next/pnpm-lock.yaml b/lamassu-next/pnpm-lock.yaml index 04ff270..25ef170 100644 --- a/lamassu-next/pnpm-lock.yaml +++ b/lamassu-next/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@lamassu/clink': specifier: workspace:* version: link:../../packages/clink + '@lamassu/hal': + specifier: workspace:* + version: link:../../packages/hal '@lamassu/lightning': specifier: workspace:* version: link:../../packages/lightning