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:
parent
bb73e13e48
commit
2605c44ef8
7 changed files with 515 additions and 24 deletions
186
apps/machine/electron/hal-service.ts
Normal file
186
apps/machine/electron/hal-service.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
12
apps/machine/src/types/electron.d.ts
vendored
12
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue