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: {