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": {
|
"dependencies": {
|
||||||
"@lamassu/clink": "workspace:*",
|
"@lamassu/clink": "workspace:*",
|
||||||
|
"@lamassu/hal": "workspace:*",
|
||||||
"@lamassu/lightning": "workspace:*",
|
"@lamassu/lightning": "workspace:*",
|
||||||
"@lamassu/nostr-client": "workspace:*",
|
"@lamassu/nostr-client": "workspace:*",
|
||||||
"@lamassu/state-machine": "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,
|
type ATMMachine,
|
||||||
} from '@lamassu/state-machine'
|
} from '@lamassu/state-machine'
|
||||||
import { initializeLightningServices } from '@/services/lightning'
|
import { initializeLightningServices } from '@/services/lightning'
|
||||||
|
import type { HalConfig, HalServices } from '@/services/hal'
|
||||||
import type { LightningPubClient } from '@lamassu/lightning'
|
import type { LightningPubClient } from '@lamassu/lightning'
|
||||||
import type { CLINKClient } from '@lamassu/clink'
|
import type { CLINKClient } from '@lamassu/clink'
|
||||||
|
|
||||||
|
|
@ -97,6 +98,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
)
|
)
|
||||||
const lightningPub = ref<LightningPubClient | null>(null)
|
const lightningPub = ref<LightningPubClient | null>(null)
|
||||||
const clinkClient = ref<CLINKClient | null>(null)
|
const clinkClient = ref<CLINKClient | null>(null)
|
||||||
|
const halServices = ref<HalServices | null>(null)
|
||||||
const isPayingInvoice = ref(false)
|
const isPayingInvoice = ref(false)
|
||||||
const isRequestingDebit = ref(false)
|
const isRequestingDebit = ref(false)
|
||||||
const paymentError = ref<string | null>(null)
|
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]) {
|
function send(event: Parameters<NonNullable<typeof actor.value>['send']>[0]) {
|
||||||
if (!actor.value) {
|
if (!actor.value) {
|
||||||
console.error('[ATM] Cannot send event: machine not initialized')
|
console.error('[ATM] Cannot send event: machine not initialized')
|
||||||
|
|
@ -384,6 +463,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
debugMode,
|
debugMode,
|
||||||
useLiveServices,
|
useLiveServices,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
|
halServices,
|
||||||
isPayingInvoice,
|
isPayingInvoice,
|
||||||
isRequestingDebit,
|
isRequestingDebit,
|
||||||
paymentError,
|
paymentError,
|
||||||
|
|
@ -399,6 +479,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// Actions
|
// Actions
|
||||||
initialize,
|
initialize,
|
||||||
initializeWithLightning,
|
initializeWithLightning,
|
||||||
|
initializeWithHal,
|
||||||
send,
|
send,
|
||||||
selectCashIn,
|
selectCashIn,
|
||||||
selectCashOut,
|
selectCashOut,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ export default defineConfig({
|
||||||
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
|
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
|
||||||
// Produce sourcemaps for debug builds
|
// Produce sourcemaps for debug builds
|
||||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
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
|
// Prevent Vite from clearing the screen
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
|
|
|
||||||
3
lamassu-next/pnpm-lock.yaml
generated
3
lamassu-next/pnpm-lock.yaml
generated
|
|
@ -26,6 +26,9 @@ importers:
|
||||||
'@lamassu/clink':
|
'@lamassu/clink':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/clink
|
version: link:../../packages/clink
|
||||||
|
'@lamassu/hal':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/hal
|
||||||
'@lamassu/lightning':
|
'@lamassu/lightning':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/lightning
|
version: link:../../packages/lightning
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue