feat(machine): add HAL IPC bridge for real hardware in Electron

HAL hardware drivers (serialport) run in the main process since they
need Node.js. The renderer communicates via IPC for all hardware ops.

- hal-service.ts: bridge between HAL drivers and Electron IPC
- main.ts: HAL IPC handlers (init, dispense, validator stack/reject)
- preload.ts: expose HAL API to renderer via contextBridge
- atm.ts: IPC-based production init with validator event wiring
- hal.ts: add 'hold' mode for escrow (async stack/reject decision)
- electron.d.ts: HAL type declarations for window.electronAPI

Bills go to escrow first; the renderer checks balance before accepting.
Falls back to Lightning-only mock mode if HAL init fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-25 17:02:13 -05:00
commit 2605c44ef8
7 changed files with 515 additions and 24 deletions

View file

@ -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<void>
getInventory: () => Record<number, number>
cleanup: () => Promise<void>
}
/**
* Initialize HAL hardware in the Electron main process.
*/
export async function initializeHal(config: HalConfig): Promise<HalInstance> {
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<void>((resolve, reject) => {
validator.run((err?: Error) => {
if (err) return reject(err)
resolve()
})
})
console.log('[HAL] Validator started')
// Track inventory
const inventory: Record<number, number> = {}
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<void>((resolve) => {
validator.disable()
validator.lightOff()
dispenser.close()
validator.close((err?: Error) => {
if (err) console.error('[HAL] Validator close error:', err)
resolve()
})
})
},
}
}

View file

@ -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()

View file

@ -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<any> => ipcRenderer.invoke('hal:dispense', amounts),
halEnableValidator: (): Promise<void> => ipcRenderer.invoke('hal:enable-validator'),
halDisableValidator: (): Promise<void> => ipcRenderer.invoke('hal:disable-validator'),
halStackBill: (): Promise<void> => ipcRenderer.invoke('hal:stack-bill'),
halRejectBill: (): Promise<void> => ipcRenderer.invoke('hal:reject-bill'),
halCleanup: (): Promise<void> => 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<void>
emptyCashbox: () => Promise<void>
// HAL hardware IPC
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
halDispense: (amounts: any) => Promise<any>
halEnableValidator: () => Promise<void>
halDisableValidator: () => Promise<void>
halStackBill: () => Promise<void>
halRejectBill: () => Promise<void>
halCleanup: () => Promise<void>
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
}
}

View file

@ -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"]
}