diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts new file mode 100644 index 0000000..cc0a4e4 --- /dev/null +++ b/apps/machine/electron/hal-service.ts @@ -0,0 +1,186 @@ +/** + * HAL Service for Electron Main Process + * + * Bridges @lamassu/hal hardware drivers to IPC-friendly interfaces. + * This file is compiled by electron/tsconfig.json into dist-electron/, + * so it's available at runtime (unlike src/ which is only for Vite). + */ + +import type { BillValidator, BillDispenser } from '@lamassu/hal' + +export interface CassetteConfig { + denomination: number + count?: number +} + +export interface HalConfig { + validator: { + type: 'id003' + device: string | string[] + fiatCode: string + } + dispenser: { + type: 'f56' | 'puloon' + device: string + cassettes: CassetteConfig[] + } +} + +export interface ValidatorCallbacks { + shouldAcceptBill: (denomination: number) => boolean | 'hold' + onBillRead?: (denomination: number) => void + onBillInserted: (denomination: number) => void + onBillRejected: (reason: string) => void + onError: (error: string) => void +} + +export interface HalInstance { + connectValidator: (callbacks: ValidatorCallbacks) => void + enableValidator: () => void + disableValidator: () => void + stackBill: () => void + rejectBill: () => void + dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise + getInventory: () => Record + cleanup: () => Promise +} + +/** + * Initialize HAL hardware in the Electron main process. + */ +export async function initializeHal(config: HalConfig): Promise { + const hal = await import('@lamassu/hal') + + const { validator: valConfig, dispenser: dispConfig } = config + + // Create hardware instances + const validator: BillValidator = hal.createValidator(valConfig.type, { + rs232: { device: valConfig.device, fiatCode: valConfig.fiatCode }, + fiatCode: valConfig.fiatCode, + }) + + const dispenser: BillDispenser = 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 + const inventory: Record = {} + for (const cassette of dispConfig.cassettes) { + inventory[cassette.denomination] = cassette.count ?? 0 + } + + const cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination) + + return { + connectValidator: (callbacks: ValidatorCallbacks) => { + validator.on('billsRead', (data: { denomination: number | null; code: number }) => { + if (data.denomination !== null) { + const decision = callbacks.shouldAcceptBill(data.denomination) + if (decision === 'hold') { + console.log('[HAL] Bill in escrow:', data.denomination) + callbacks.onBillRead?.(data.denomination) + } else if (decision) { + validator.stack() + callbacks.onBillInserted(data.denomination) + } else { + console.log('[HAL] Bill rejected: insufficient balance for', data.denomination) + validator.reject() + callbacks.onBillRejected('insufficient_balance') + } + } 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(), + + dispenseCash: async (amounts) => { + console.log('[HAL] Dispensing:', amounts) + + 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 + } + + await dispenser.waitForBillsRemoved() + console.log('[HAL] Bills removed by customer') + }, + + getInventory: () => ({ ...inventory }), + + 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/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 3856f3f..423124b 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -20,6 +20,7 @@ import { emptyCashbox, getCashbox, } from './state-store.js' +import { initializeHal, type HalInstance } from './hal-service.js' // ESM equivalent of __dirname const __filename = fileURLToPath(import.meta.url) @@ -128,6 +129,98 @@ ipcMain.handle('state:get-cashbox', () => getCashbox()) ipcMain.handle('state:record-transaction', (_event, tx) => recordTransaction(tx)) ipcMain.handle('state:empty-cashbox', () => emptyCashbox()) +// ============================================================================= +// HAL Hardware IPC Handlers +// HAL runs in the main process (Node.js) because it needs serialport. +// The renderer communicates via IPC for all hardware operations. +// ============================================================================= + +let halInstance: HalInstance | null = null +let pendingBillDenomination: number | null = null + +ipcMain.handle('hal:init', async (_event, config) => { + try { + halInstance = await initializeHal(config) + + // Wire validator events → forward to renderer via IPC + // Bills go to escrow first ('hold' mode); the renderer decides to + // stack or reject by calling hal:stack-bill or hal:reject-bill. + halInstance.connectValidator({ + shouldAcceptBill: (_denomination: number) => { + // Hold in escrow — renderer decides asynchronously + return 'hold' as const + }, + onBillRead: (denomination: number) => { + // Bill is in escrow, notify renderer to decide + pendingBillDenomination = denomination + mainWindow?.webContents.send('hal:bill-read', denomination) + }, + onBillInserted: (denomination: number) => { + mainWindow?.webContents.send('hal:bill-inserted', denomination) + }, + onBillRejected: (reason: string) => { + mainWindow?.webContents.send('hal:bill-rejected', reason) + }, + onError: (error: string) => { + mainWindow?.webContents.send('hal:error', error) + }, + }) + + console.log('[Electron] HAL initialized via IPC') + return { success: true } + } catch (error: any) { + console.error('[Electron] HAL init failed:', error) + return { success: false, error: error.message } + } +}) + +ipcMain.handle( + 'hal:dispense', + async (_event, amounts: { denomination: number; count: number }[]) => { + if (!halInstance) throw new Error('HAL not initialized') + await halInstance.dispenseCash(amounts) + } +) + +ipcMain.handle('hal:wait-for-bills-removed', async () => { + // This is handled inside dispenseCash already + return true +}) + +ipcMain.handle('hal:enable-validator', () => { + if (halInstance) halInstance.enableValidator() +}) + +ipcMain.handle('hal:disable-validator', () => { + if (halInstance) halInstance.disableValidator() +}) + +ipcMain.handle('hal:stack-bill', () => { + if (halInstance) { + const denomination = pendingBillDenomination + pendingBillDenomination = null + halInstance.stackBill() + // Notify renderer that the bill was accepted + if (denomination !== null) { + mainWindow?.webContents.send('hal:bill-inserted', denomination) + } + } +}) + +ipcMain.handle('hal:reject-bill', () => { + if (halInstance) { + pendingBillDenomination = null + halInstance.rejectBill() + } +}) + +ipcMain.handle('hal:cleanup', async () => { + if (halInstance) { + await halInstance.cleanup() + halInstance = null + } +}) + // App lifecycle app.whenReady().then(() => { initDatabase() diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index eacbe06..9273f0a 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -49,6 +49,30 @@ contextBridge.exposeInMainWorld('electronAPI', { }) => ipcRenderer.invoke('state:record-transaction', tx), emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'), + // HAL hardware (runs in main process, exposed via IPC) + halInit: (config: any): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('hal:init', config), + halDispense: (amounts: any): Promise => ipcRenderer.invoke('hal:dispense', amounts), + halEnableValidator: (): Promise => ipcRenderer.invoke('hal:enable-validator'), + halDisableValidator: (): Promise => ipcRenderer.invoke('hal:disable-validator'), + halStackBill: (): Promise => ipcRenderer.invoke('hal:stack-bill'), + halRejectBill: (): Promise => ipcRenderer.invoke('hal:reject-bill'), + halCleanup: (): Promise => ipcRenderer.invoke('hal:cleanup'), + + // HAL event listeners (main process → renderer) + onHalBillRead: (callback: (denomination: number) => void) => { + ipcRenderer.on('hal:bill-read', (_event, denomination) => callback(denomination)) + }, + onHalBillInserted: (callback: (denomination: number) => void) => { + ipcRenderer.on('hal:bill-inserted', (_event, denomination) => callback(denomination)) + }, + onHalBillRejected: (callback: (reason: string) => void) => { + ipcRenderer.on('hal:bill-rejected', (_event, reason) => callback(reason)) + }, + onHalError: (callback: (error: string) => void) => { + ipcRenderer.on('hal:error', (_event, error) => callback(error)) + }, + // Platform info platform: process.platform, }) @@ -75,6 +99,18 @@ declare global { bills: { denomination: number; count: number }[] }) => Promise emptyCashbox: () => Promise + // HAL hardware IPC + halInit: (config: any) => Promise<{ success: boolean; error?: string }> + halDispense: (amounts: any) => Promise + halEnableValidator: () => Promise + halDisableValidator: () => Promise + halStackBill: () => Promise + halRejectBill: () => Promise + halCleanup: () => Promise + onHalBillRead: (callback: (denomination: number) => void) => void + onHalBillInserted: (callback: (denomination: number) => void) => void + onHalBillRejected: (callback: (reason: string) => void) => void + onHalError: (callback: (error: string) => void) => void platform: NodeJS.Platform } } diff --git a/apps/machine/electron/tsconfig.json b/apps/machine/electron/tsconfig.json index 79de887..93dc4bf 100644 --- a/apps/machine/electron/tsconfig.json +++ b/apps/machine/electron/tsconfig.json @@ -9,6 +9,6 @@ "outDir": "../dist-electron", "rootDir": "." }, - "include": ["main.ts", "state-store.ts"], + "include": ["main.ts", "state-store.ts", "hal-service.ts"], "exclude": ["preload.ts"] } diff --git a/apps/machine/src/services/hal.ts b/apps/machine/src/services/hal.ts index 2f1cffd..81c4e98 100644 --- a/apps/machine/src/services/hal.ts +++ b/apps/machine/src/services/hal.ts @@ -35,8 +35,10 @@ export interface HalConfig { } export interface ValidatorCallbacks { - /** Called when a bill is read but not yet accepted. Return true to accept, false to reject. */ - shouldAcceptBill: (denomination: number) => boolean + /** Called when a bill is read but not yet accepted. Return true to accept, false to reject, or 'hold' to keep in escrow for async decision. */ + shouldAcceptBill: (denomination: number) => boolean | 'hold' + /** Called when a bill is held in escrow (shouldAcceptBill returned 'hold'). Call stackBill() or rejectBill() to decide. */ + onBillRead?: (denomination: number) => void onBillInserted: (denomination: number) => void onBillRejected: (reason: string) => void onError: (error: string) => void @@ -159,8 +161,12 @@ export async function initializeHalServices(config: HalConfig): Promise { validator.on('billsRead', (data: { denomination: number | null; code: number }) => { if (data.denomination !== null) { - // Check if we should accept this bill (balance check) - if (callbacks.shouldAcceptBill(data.denomination)) { + const decision = callbacks.shouldAcceptBill(data.denomination) + if (decision === 'hold') { + // Hold in escrow — caller will call stackBill() or rejectBill() + console.log('[HAL] Bill in escrow:', data.denomination) + callbacks.onBillRead?.(data.denomination) + } else if (decision) { validator.stack() callbacks.onBillInserted(data.denomination) } else { diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 058b932..c062020 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -11,7 +11,7 @@ import { } from '@lamassu/state-machine' import { initializeLightningServices } from '@/services/lightning' import type { HalConfig, HalServices } from '@/services/hal' -import { deviceConfig, toHalConfig } from '@/config' +import type { MachineModel } from '@/config' import type { LightningPubClient } from '@lamassu/lightning' import type { CLINKClient } from '@lamassu/clink' import type { TransactionRecord } from '@/types/state' @@ -603,28 +603,186 @@ export const useAtmStore = defineStore('atm', () => { } /** - * Initialize for production using device configuration + * Initialize for production using runtime config from Electron main process. * - * Loads device config from environment variables (with Sintra defaults) - * and initializes HAL hardware + Lightning services. - * - * Environment variables (optional, have sensible defaults): - * - VITE_LAMASSU_MACHINE_MODEL: sintra | gaia | custom - * - VITE_LAMASSU_FIAT_CODE: USD, EUR, etc. - * - VITE_LAMASSU_VALIDATOR_DEVICE: /dev/ttyJ5 - * - VITE_LAMASSU_DISPENSER_DEVICE: /dev/ttyJ7 - * - VITE_LAMASSU_CASSETTES: JSON array of cassette configs + * Reads config via IPC (not import.meta.env, which is compile-time). + * Initializes HAL hardware in the main process via IPC, then wires + * validator events and Lightning services in the renderer. */ async function initializeForProduction() { console.log('[ATM] Initializing for production...') - console.log('[ATM] Machine model:', deviceConfig.model) - console.log('[ATM] Fiat currency:', deviceConfig.fiatCode) - console.log('[ATM] Validator device:', deviceConfig.validator.device) - console.log('[ATM] Dispenser device:', deviceConfig.dispenser.device) - console.log('[ATM] Cassettes:', deviceConfig.dispenser.cassettes) - const halConfig = toHalConfig(deviceConfig) - await initializeWithHal(halConfig) + if (!window.electronAPI) { + throw new Error('initializeForProduction requires Electron environment') + } + const api = window.electronAPI + + // Get runtime config from Electron main process (.env file) + const runtimeConfig = await api.getConfig() + const model = (runtimeConfig.machineModel || 'sintra') as MachineModel + const fiatCode = runtimeConfig.fiatCode || 'USD' + + // Build device config from runtime values + const { getDeviceConfig, toHalConfig } = await import('@/config') + const overrides: any = {} + if (runtimeConfig.validatorDevice) { + overrides.validator = { type: 'id003', device: runtimeConfig.validatorDevice } + } + if (runtimeConfig.dispenserDevice) { + overrides.dispenser = { type: 'f56', device: runtimeConfig.dispenserDevice, cassettes: [] } + } + if (runtimeConfig.cassettes) { + try { + const cassettes = JSON.parse(runtimeConfig.cassettes) + if (overrides.dispenser) { + overrides.dispenser.cassettes = cassettes + } else { + overrides.dispenser = { type: 'f56', device: '', cassettes } + } + } catch (e) { + console.error('[ATM] Failed to parse cassettes:', e) + } + } + + const devConfig = getDeviceConfig(model, fiatCode, overrides) + console.log('[ATM] Machine model:', devConfig.model) + console.log('[ATM] Fiat currency:', devConfig.fiatCode) + console.log('[ATM] Validator device:', devConfig.validator.device) + console.log('[ATM] Dispenser device:', devConfig.dispenser.device) + console.log('[ATM] Cassettes:', devConfig.dispenser.cassettes) + + const halConfig = toHalConfig(devConfig) + await initializeWithHalIpc(halConfig) + } + + /** + * Initialize HAL via IPC (main process) + Lightning services. + * + * HAL hardware runs in the Electron main process (Node.js). + * Validator events are forwarded to the renderer via IPC. + */ + async function initializeWithHalIpc(halConfig: HalConfig) { + if (!window.electronAPI) { + throw new Error('initializeWithHalIpc requires Electron environment') + } + const api = window.electronAPI + + connectionStatus.value = 'connecting' + console.log('[ATM] Initializing HAL via IPC...') + + try { + // Initialize HAL in the main process + const result = await api.halInit(halConfig) + if (!result.success) { + throw new Error(result.error || 'HAL init failed') + } + console.log('[ATM] HAL initialized in main process') + + // 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, + }, + }) + } + }) + + // HAL dispenseCash via IPC + const halAtmServices: Pick = { + dispenseCash: async (amounts) => { + console.log('[ATM] Dispensing via IPC:', amounts) + await api.halDispense(amounts) + }, + getInventory: async () => { + const fresh = await loadInventoryFromDb() + return Object.keys(fresh).length > 0 ? fresh : {} + }, + } + + // Merge HAL hardware services with Lightning payment services + const mergedServices: ATMServices = { + ...lightning.atmServices, + ...halAtmServices, + getInventory: halAtmServices.getInventory, + } + + // Initialize state machine with merged services + initialize(mergedServices) + + // Wire validator events from main process via IPC + api.onHalBillRead((denomination) => { + console.log('[ATM] Bill in escrow:', denomination) + // Check if we should accept this bill + const ctx = context.value + if (!ctx || ctx.exchangeRate === 0) { + // No rate yet, accept anyway + api.halStackBill() + return + } + + // Calculate if this bill would exceed available balance + const newFiatCents = ctx.fiatAmount + denomination * 100 + const newFiatUnits = newFiatCents / 100 + const grossSats = Math.floor(newFiatUnits * ctx.exchangeRate) + const fee = Math.floor(grossSats * ctx.feePercent) + const newSatsAmount = grossSats - fee + + if (newSatsAmount > ctx.availableBalance) { + console.log( + `[ATM] Rejecting $${denomination} bill: would need ${newSatsAmount} sats but only ${ctx.availableBalance} available` + ) + api.halRejectBill() + return + } + + // Accept the bill + api.halStackBill() + }) + + api.onHalBillInserted((denomination) => { + send({ type: 'BILL_INSERTED', denomination }) + }) + + api.onHalBillRejected((reason) => { + send({ type: 'BILL_REJECTED', reason }) + }) + + api.onHalError((error) => { + send({ type: 'ERROR', error }) + }) + + console.log('[ATM] Fully initialized with HAL (IPC) + Lightning') + } catch (error) { + console.error('[ATM] HAL initialization failed:', error) + + // Fall back to Lightning-only mode (real Lightning, mock hardware) + console.log('[ATM] Falling back to Lightning-only mode (mock hardware)') + try { + await initializeWithLightning() + } catch (lightningError) { + console.error('[ATM] Lightning also failed:', lightningError) + connectionStatus.value = 'error' + useLiveServices.value = false + initialize(mockServices) + } + } } function send(event: Parameters['send']>[0]) { diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 097b600..b959d03 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -38,6 +38,18 @@ declare global { bills: { denomination: number; count: number }[] }) => Promise emptyCashbox: () => Promise + // HAL hardware IPC + halInit: (config: any) => Promise<{ success: boolean; error?: string }> + halDispense: (amounts: any) => Promise + halEnableValidator: () => Promise + halDisableValidator: () => Promise + halStackBill: () => Promise + halRejectBill: () => Promise + halCleanup: () => Promise + onHalBillRead: (callback: (denomination: number) => void) => void + onHalBillInserted: (callback: (denomination: number) => void) => void + onHalBillRejected: (callback: (reason: string) => void) => void + onHalError: (callback: (error: string) => void) => void platform: NodeJS.Platform } }