From 0ea9640f491fe2d1cb8db36e2d9942c5a400d5c6 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 25 Feb 2026 18:13:45 -0500 Subject: [PATCH] fix(machine): production hardware fixes for Douro cash-out - Fix cassette denominations: Douro uses Q100/Q200, not Q20 - Add hal:get-inventory IPC so renderer can read HAL cassette inventory - Add balance fetch/display to HAL+IPC init path and idle screen - Enable/disable bill validator via watch on nested state transitions - Pass fiatCode to state machine context (was hardcoded to USD) - Preserve currency across state machine resetContext - Add CANCEL handler to dispenseError state (was stuck) - Fix remaining hardcoded $ symbols in CashInView Co-Authored-By: Claude Opus 4.6 --- apps/machine/electron/main.ts | 5 +++ apps/machine/electron/preload.ts | 2 + apps/machine/src/config/device.ts | 5 ++- apps/machine/src/stores/atm.ts | 62 +++++++++++++++++++++++--- apps/machine/src/types/electron.d.ts | 1 + apps/machine/src/views/CashInView.vue | 8 ++-- apps/machine/src/views/CashOutView.vue | 20 ++++++--- apps/machine/src/views/IdleView.vue | 8 ++++ packages/state-machine/src/machine.ts | 9 +++- 9 files changed, 102 insertions(+), 18 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 423124b..ac7fa39 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -214,6 +214,11 @@ ipcMain.handle('hal:reject-bill', () => { } }) +ipcMain.handle('hal:get-inventory', () => { + if (!halInstance) return {} + return halInstance.getInventory() +}) + ipcMain.handle('hal:cleanup', async () => { if (halInstance) { await halInstance.cleanup() diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 9273f0a..8f1f1a2 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -57,6 +57,7 @@ contextBridge.exposeInMainWorld('electronAPI', { halDisableValidator: (): Promise => ipcRenderer.invoke('hal:disable-validator'), halStackBill: (): Promise => ipcRenderer.invoke('hal:stack-bill'), halRejectBill: (): Promise => ipcRenderer.invoke('hal:reject-bill'), + halGetInventory: (): Promise> => ipcRenderer.invoke('hal:get-inventory'), halCleanup: (): Promise => ipcRenderer.invoke('hal:cleanup'), // HAL event listeners (main process → renderer) @@ -106,6 +107,7 @@ declare global { halDisableValidator: () => Promise halStackBill: () => Promise halRejectBill: () => Promise + halGetInventory: () => Promise> halCleanup: () => Promise onHalBillRead: (callback: (denomination: number) => void) => void onHalBillInserted: (callback: (denomination: number) => void) => void diff --git a/apps/machine/src/config/device.ts b/apps/machine/src/config/device.ts index 4a61043..54d2db0 100644 --- a/apps/machine/src/config/device.ts +++ b/apps/machine/src/config/device.ts @@ -104,7 +104,10 @@ export const MACHINE_PRESETS: Record { // State const actor = ref | null>(null) const snapshot = ref | null>(null) - // Always on until real hardware bill validator is connected + // Show mock bill simulator when no real hardware const debugMode = ref(true) + const fiatCode = ref('USD') const useLiveServices = ref(false) const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>( 'disconnected' @@ -164,6 +165,11 @@ export const useAtmStore = defineStore('atm', () => { return snapshot.value?.context ?? null }) + const fiatSymbol = computed(() => { + const symbols: Record = { USD: '$', GTQ: 'Q', EUR: '€', GBP: '£', MXN: '$' } + return symbols[fiatCode.value] || fiatCode.value + }) + const isIdle = computed(() => currentState.value === 'idle') const isCashIn = computed(() => { @@ -189,7 +195,7 @@ export const useAtmStore = defineStore('atm', () => { // Actions function initialize(services: ATMServices = mockServices) { atmServicesRef = services - const machine = createATMMachine(services) + const machine = createATMMachine(services, { currency: fiatCode.value }) actor.value = createActor(machine) // Track previous state to detect transitions into 'complete' @@ -620,7 +626,8 @@ export const useAtmStore = defineStore('atm', () => { // 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' + const runtimeFiatCode = runtimeConfig.fiatCode || 'USD' + fiatCode.value = runtimeFiatCode // Build device config from runtime values const { getDeviceConfig, toHalConfig } = await import('@/config') @@ -644,7 +651,7 @@ export const useAtmStore = defineStore('atm', () => { } } - const devConfig = getDeviceConfig(model, fiatCode, overrides) + const devConfig = getDeviceConfig(model, runtimeFiatCode, overrides) console.log('[ATM] Machine model:', devConfig.model) console.log('[ATM] Fiat currency:', devConfig.fiatCode) console.log('[ATM] Validator device:', devConfig.validator.device) @@ -685,6 +692,21 @@ export const useAtmStore = defineStore('atm', () => { lightningPub.value = lightning.lightningPub clinkClient.value = lightning.clink + // Fetch initial balance and subscribe to live updates + try { + const { balanceSats: initial } = await lightning.lightningPub.getBalance() + balanceSats.value = initial + console.log('[ATM] Initial balance:', initial, 'sats') + } catch (e) { + console.warn('[ATM] Failed to fetch initial balance:', e) + } + + stopBalanceWatch?.() + stopBalanceWatch = lightning.lightningPub.watchBalance((newBalance) => { + balanceSats.value = newBalance + console.log('[ATM] Balance updated:', newBalance, 'sats') + }) + // Wire payment callbacks lightning.onPaymentReceived((preimage) => { paymentReceived(preimage) @@ -711,8 +733,20 @@ export const useAtmStore = defineStore('atm', () => { await api.halDispense(amounts) }, getInventory: async () => { + // Priority: DB inventory > HAL hardware inventory > empty const fresh = await loadInventoryFromDb() - return Object.keys(fresh).length > 0 ? fresh : {} + if (Object.keys(fresh).length > 0) return fresh + // Fall back to HAL's cassette-based inventory + try { + const halInv = await api.halGetInventory() + if (Object.keys(halInv).length > 0) { + console.log('[ATM] Using HAL hardware inventory:', halInv) + return halInv + } + } catch (e) { + console.warn('[ATM] Failed to get HAL inventory:', e) + } + return {} }, } @@ -768,6 +802,20 @@ export const useAtmStore = defineStore('atm', () => { send({ type: 'ERROR', error }) }) + // Watch state transitions to enable/disable validator hardware + watch(nestedState, (newState, oldState) => { + if (newState === 'insertingBills' && oldState !== 'insertingBills') { + console.log('[ATM] Enabling bill validator') + api.halEnableValidator() + } + if (oldState === 'insertingBills' && newState !== 'insertingBills') { + console.log('[ATM] Disabling bill validator') + api.halDisableValidator() + } + }) + + // Real hardware connected — disable mock bill simulator + debugMode.value = false console.log('[ATM] Fully initialized with HAL (IPC) + Lightning') } catch (error) { console.error('[ATM] HAL initialization failed:', error) @@ -871,6 +919,7 @@ export const useAtmStore = defineStore('atm', () => { actor, snapshot, debugMode, + fiatCode, useLiveServices, connectionStatus, balanceSats, @@ -882,6 +931,7 @@ export const useAtmStore = defineStore('atm', () => { // Computed currentState, context, + fiatSymbol, isIdle, isCashIn, isCashOut, diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index b959d03..89643b1 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -45,6 +45,7 @@ declare global { halDisableValidator: () => Promise halStackBill: () => Promise halRejectBill: () => Promise + halGetInventory: () => Promise> halCleanup: () => Promise onHalBillRead: (callback: (denomination: number) => void) => void onHalBillInserted: (callback: (denomination: number) => void) => void diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue index 1b13faa..8495208 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -214,7 +214,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque Insert Cash - Rate: {{ formatSats(context.exchangeRate) }} sats/$ + Rate: {{ formatSats(context.exchangeRate) }} sats/{{ atmStore.fiatSymbol }} @@ -223,7 +223,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
Inserted: ${{ context.fiatAmount / 100 }}{{ atmStore.fiatSymbol }}{{ context.fiatAmount / 100 }}
@@ -267,7 +267,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque :disabled="!canAcceptBill(denom)" @click="simulateBill(denom)" > - ${{ denom }} + {{ atmStore.fiatSymbol }}{{ denom }}
@@ -308,7 +308,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque {{ formatSats(context?.satsAmount || 0) }} sats

- ${{ ((context?.fiatAmount || 0) / 100).toFixed(2) }} + {{ atmStore.fiatSymbol }}{{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}

diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 2c6948f..62bed0a 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -124,7 +124,10 @@ function formatFiat(cents: number): string { Select Cash Amount - Rate: {{ formatSats(context.exchangeRate) }} sats/$ + Rate: + {{ formatSats(context.exchangeRate) }} sats/{{ atmStore.fiatSymbol }} @@ -135,7 +138,9 @@ function formatFiat(cents: number): string { :key="denom" class="flex items-center justify-between rounded-lg bg-white/5 px-4 py-3 sm:p-4" > - ${{ denom }} + {{ atmStore.fiatSymbol }}{{ denom }}
+ +
+

Available Balance

+

+ {{ atmStore.balanceSats.toLocaleString() }} sats +

+
+
diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index 286b622..45b72ce 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -17,7 +17,10 @@ import { /** * Create the ATM state machine with injected services */ -export function createATMMachine(services: Partial = {}) { +export function createATMMachine( + services: Partial = {}, + options?: { currency?: string } +) { return setup({ types: { context: {} as ATMContext, @@ -153,6 +156,7 @@ export function createATMMachine(services: Partial = {}) { actions: { resetContext: assign(({ context }) => ({ ...initialContext, + currency: context.currency, inventory: context.inventory, cashInSessionId: null, })), @@ -372,7 +376,7 @@ export function createATMMachine(services: Partial = {}) { }).createMachine({ id: 'atm', initial: 'idle', - context: initialContext, + context: { ...initialContext, ...(options?.currency ? { currency: options.currency } : {}) }, states: { idle: { entry: 'resetContext', @@ -637,6 +641,7 @@ export function createATMMachine(services: Partial = {}) { target: 'dispensingCash', actions: 'incrementRetry', }, + CANCEL: '#atm.idle', }, }, error: {