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

@ -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<HalServi
connectValidator: (callbacks: ValidatorCallbacks) => {
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 {

View file

@ -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<ATMServices, 'dispenseCash' | 'getInventory'> = {
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<NonNullable<typeof actor.value>['send']>[0]) {

View file

@ -38,6 +38,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
}
}