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()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue