feat: wire HAL drivers to state machine via service wrapper (#19)

- Create apps/machine/src/services/hal.ts bridging @lamassu/hal
  EventEmitter-based drivers to ATMServices interface
- Add initializeWithHal() to ATM store for hardware + Lightning init
- Merge HAL services (dispenseCash, getInventory) with Lightning services
- Wire validator events to state machine (BILL_INSERTED, BILL_REJECTED)
- Exclude @lamassu/hal from Vite browser bundle (Node.js only)
- Add @lamassu/hal as dependency to apps/machine

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-27 13:46:25 -05:00
commit f7307c9159
5 changed files with 301 additions and 0 deletions

View file

@ -15,6 +15,7 @@
},
"dependencies": {
"@lamassu/clink": "workspace:*",
"@lamassu/hal": "workspace:*",
"@lamassu/lightning": "workspace:*",
"@lamassu/nostr-client": "workspace:*",
"@lamassu/state-machine": "workspace:*",

View file

@ -0,0 +1,211 @@
/**
* HAL Service Wrapper
*
* Bridges @lamassu/hal hardware drivers (EventEmitter-based) to the
* ATMServices interface expected by the XState state machine.
*
* Validator events are pushed into the state machine via callbacks.
* Dispenser operations are exposed as Promise-based ATMServices methods.
*
* NOTE: This module uses dynamic imports for @lamassu/hal because it
* requires Node.js APIs (serialport, node:events) that are unavailable
* in the browser. It will only work in Electron's main process or
* Node.js environments. In the browser, initializeHalServices() will
* throw with a clear error message.
*/
import type { ATMServices } from '@lamassu/state-machine'
export interface CassetteConfig {
denomination: number
count?: number
}
export interface HalConfig {
validator: {
type: 'id003'
device: string | string[]
fiatCode: string
}
dispenser: {
type: 'f56'
device: string
cassettes: CassetteConfig[]
}
}
export interface ValidatorCallbacks {
onBillInserted: (denomination: number) => void
onBillRejected: (reason: string) => void
onError: (error: string) => void
}
export interface HalServices {
/** Partial ATMServices for hardware operations (dispenseCash, getInventory) */
atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'>
/** Wire validator events to state machine. Call after actor is started. */
connectValidator: (callbacks: ValidatorCallbacks) => void
/** Enable bill acceptance */
enableValidator: () => void
/** Disable bill acceptance */
disableValidator: () => void
/** Stack the bill currently in escrow (accept it) */
stackBill: () => void
/** Reject the bill currently in escrow (return it) */
rejectBill: () => void
/** Clean up all hardware connections */
cleanup: () => Promise<void>
}
/**
* Initialize HAL hardware and return services for state machine integration.
*
* The validator runs as an EventEmitter - call `connectValidator()` to wire
* its events to the state machine after the actor is started.
*
* The dispenser is Promise-based and exposed via `atmServices.dispenseCash`.
*
* Requires Node.js environment (Electron main process).
*/
export async function initializeHalServices(config: HalConfig): Promise<HalServices> {
// Dynamic import - only works in Node.js (Electron main process)
const hal = await import('@lamassu/hal').catch(() => {
throw new Error(
'[HAL] @lamassu/hal requires Node.js (serialport, node:events). ' +
'Run in Electron main process, not the browser.'
)
})
const { validator: valConfig, dispenser: dispConfig } = config
// Create hardware instances
const validator = hal.createValidator(valConfig.type, {
rs232: { device: valConfig.device, fiatCode: valConfig.fiatCode },
fiatCode: valConfig.fiatCode,
})
const dispenser = 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 (decremented on dispense)
const inventory: Record<number, number> = {}
for (const cassette of dispConfig.cassettes) {
inventory[cassette.denomination] = cassette.count ?? 0
}
// Map cassette index to denomination for dispense translation
const cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination)
const atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
dispenseCash: async (amounts) => {
console.log('[HAL] Dispensing:', amounts)
// Translate { denomination, count }[] to number[] by cassette position
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
}
// Wait for customer to take the bills
await dispenser.waitForBillsRemoved()
console.log('[HAL] Bills removed by customer')
},
getInventory: async () => {
return { ...inventory }
},
}
return {
atmServices,
connectValidator: (callbacks: ValidatorCallbacks) => {
validator.on('billsRead', (data: { denomination: number | null; code: number }) => {
if (data.denomination !== null) {
// Auto-stack the bill (accept it into the cash box)
validator.stack()
callbacks.onBillInserted(data.denomination)
} 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(),
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

@ -10,6 +10,7 @@ import {
type ATMMachine,
} from '@lamassu/state-machine'
import { initializeLightningServices } from '@/services/lightning'
import type { HalConfig, HalServices } from '@/services/hal'
import type { LightningPubClient } from '@lamassu/lightning'
import type { CLINKClient } from '@lamassu/clink'
@ -97,6 +98,7 @@ export const useAtmStore = defineStore('atm', () => {
)
const lightningPub = ref<LightningPubClient | null>(null)
const clinkClient = ref<CLINKClient | null>(null)
const halServices = ref<HalServices | null>(null)
const isPayingInvoice = ref(false)
const isRequestingDebit = ref(false)
const paymentError = ref<string | null>(null)
@ -310,6 +312,83 @@ export const useAtmStore = defineStore('atm', () => {
}
}
/**
* Initialize with real HAL hardware + Lightning services
*
* Connects to physical bill validator and dispenser, then merges
* hardware services with Lightning payment services.
*/
async function initializeWithHal(halConfig: HalConfig) {
connectionStatus.value = 'connecting'
console.log('[ATM] Initializing HAL hardware...')
try {
// Dynamic import - hal.ts uses Node.js APIs only available in Electron
const { initializeHalServices } = await import('@/services/hal')
const hal = await initializeHalServices(halConfig)
halServices.value = hal
console.log('[ATM] HAL hardware initialized')
// 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,
},
})
}
})
// Merge HAL hardware services with Lightning payment services
const mergedServices: ATMServices = {
...lightning.atmServices,
...hal.atmServices, // Override dispenseCash and getInventory with real hardware
}
// Initialize state machine with merged services
initialize(mergedServices)
// Wire validator events to state machine
hal.connectValidator({
onBillInserted: (denomination) => {
send({ type: 'BILL_INSERTED', denomination })
},
onBillRejected: (reason) => {
send({ type: 'BILL_REJECTED', reason })
},
onError: (error) => {
send({ type: 'ERROR', error })
},
})
console.log('[ATM] Fully initialized with HAL + Lightning')
} catch (error) {
console.error('[ATM] HAL initialization failed:', error)
connectionStatus.value = 'error'
// Fall back to mock services
console.log('[ATM] Falling back to mock services')
useLiveServices.value = false
initialize(mockServices)
}
}
function send(event: Parameters<NonNullable<typeof actor.value>['send']>[0]) {
if (!actor.value) {
console.error('[ATM] Cannot send event: machine not initialized')
@ -384,6 +463,7 @@ export const useAtmStore = defineStore('atm', () => {
debugMode,
useLiveServices,
connectionStatus,
halServices,
isPayingInvoice,
isRequestingDebit,
paymentError,
@ -399,6 +479,7 @@ export const useAtmStore = defineStore('atm', () => {
// Actions
initialize,
initializeWithLightning,
initializeWithHal,
send,
selectCashIn,
selectCashOut,

View file

@ -27,6 +27,11 @@ export default defineConfig({
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
// Produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_ENV_DEBUG,
rollupOptions: {
// @lamassu/hal uses Node.js APIs (serialport, node:events) and will only
// run in Electron's main process. Exclude from browser bundle.
external: ['@lamassu/hal'],
},
},
// Prevent Vite from clearing the screen
clearScreen: false,

View file

@ -26,6 +26,9 @@ importers:
'@lamassu/clink':
specifier: workspace:*
version: link:../../packages/clink
'@lamassu/hal':
specifier: workspace:*
version: link:../../packages/hal
'@lamassu/lightning':
specifier: workspace:*
version: link:../../packages/lightning