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:
parent
9bfa0d74a2
commit
f7307c9159
5 changed files with 301 additions and 0 deletions
|
|
@ -15,6 +15,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@lamassu/clink": "workspace:*",
|
||||
"@lamassu/hal": "workspace:*",
|
||||
"@lamassu/lightning": "workspace:*",
|
||||
"@lamassu/nostr-client": "workspace:*",
|
||||
"@lamassu/state-machine": "workspace:*",
|
||||
|
|
|
|||
211
lamassu-next/apps/machine/src/services/hal.ts
Normal file
211
lamassu-next/apps/machine/src/services/hal.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
3
lamassu-next/pnpm-lock.yaml
generated
3
lamassu-next/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue