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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-25 18:13:45 -05:00
commit 0ea9640f49
9 changed files with 102 additions and 18 deletions

View file

@ -104,7 +104,10 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
dispenser: {
type: 'puloon',
device: '/dev/ttyS1',
cassettes: [{ denomination: 20, count: 50 }],
cassettes: [
{ denomination: 100, count: 50 },
{ denomination: 200, count: 50 },
],
},
},

View file

@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import {
createATMMachine,
createActor,
@ -132,8 +132,9 @@ export const useAtmStore = defineStore('atm', () => {
// State
const actor = ref<ActorRefFrom<ATMMachine> | null>(null)
const snapshot = ref<SnapshotFrom<ATMMachine> | 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<string, string> = { 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,

View file

@ -45,6 +45,7 @@ declare global {
halDisableValidator: () => Promise<void>
halStackBill: () => Promise<void>
halRejectBill: () => Promise<void>
halGetInventory: () => Promise<Record<number, number>>
halCleanup: () => Promise<void>
onHalBillRead: (callback: (denomination: number) => void) => void
onHalBillInserted: (callback: (denomination: number) => void) => void

View file

@ -214,7 +214,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
<CardHeader class="text-center">
<CardTitle>Insert Cash</CardTitle>
<CardDescription v-if="context">
Rate: {{ formatSats(context.exchangeRate) }} sats/$
Rate: {{ formatSats(context.exchangeRate) }} sats/{{ atmStore.fiatSymbol }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
@ -223,7 +223,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
<div class="mb-2 flex justify-between">
<span class="text-muted-foreground">Inserted:</span>
<span class="text-2xl font-bold text-green-400"
>${{ context.fiatAmount / 100 }}</span
>{{ atmStore.fiatSymbol }}{{ context.fiatAmount / 100 }}</span
>
</div>
<div class="flex justify-between">
@ -267,7 +267,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
:disabled="!canAcceptBill(denom)"
@click="simulateBill(denom)"
>
${{ denom }}
{{ atmStore.fiatSymbol }}{{ denom }}
</Button>
</div>
</AlertDescription>
@ -308,7 +308,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
{{ formatSats(context?.satsAmount || 0) }} sats
</p>
<p class="text-sm text-muted-foreground">
${{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}
{{ atmStore.fiatSymbol }}{{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}
</p>
</div>

View file

@ -124,7 +124,10 @@ function formatFiat(cents: number): string {
<CardHeader class="text-center">
<CardTitle>Select Cash Amount</CardTitle>
<CardDescription v-if="context">
Rate: <Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
Rate:
<Badge variant="secondary"
>{{ formatSats(context.exchangeRate) }} sats/{{ atmStore.fiatSymbol }}</Badge
>
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
@ -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"
>
<span class="text-xl font-bold text-green-400 sm:text-2xl">${{ denom }}</span>
<span class="text-xl font-bold text-green-400 sm:text-2xl"
>{{ atmStore.fiatSymbol }}{{ denom }}</span
>
<div class="flex items-center gap-3">
<Button
variant="outline"
@ -166,7 +171,7 @@ function formatFiat(cents: number): string {
<div class="rounded-lg bg-white/10 p-6 text-center">
<p class="text-sm text-muted-foreground">You will receive</p>
<p class="text-4xl font-bold text-green-400">
${{ context ? formatFiat(context.fiatAmount) : '0.00' }}
{{ atmStore.fiatSymbol }}{{ context ? formatFiat(context.fiatAmount) : '0.00' }}
</p>
<p class="mt-2 text-sm text-muted-foreground">
You pay:
@ -226,7 +231,10 @@ function formatFiat(cents: number): string {
<CardTitle>Scan to Pay</CardTitle>
<CardDescription v-if="context">
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to
receive <Badge class="bg-green-600">${{ formatFiat(context.fiatAmount) }}</Badge>
receive
<Badge class="bg-green-600"
>{{ atmStore.fiatSymbol }}{{ formatFiat(context.fiatAmount) }}</Badge
>
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
@ -296,7 +304,9 @@ function formatFiat(cents: number): string {
<CardTitle class="text-green-400">Cash Ready!</CardTitle>
<p v-if="context">
Please take your
<Badge class="bg-green-600 text-lg">${{ formatFiat(context.fiatAmount) }}</Badge>
<Badge class="bg-green-600 text-lg"
>{{ atmStore.fiatSymbol }}{{ formatFiat(context.fiatAmount) }}</Badge
>
</p>
<Button
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"

View file

@ -84,6 +84,14 @@ function handleCashOut() {
</div>
</div>
<!-- Balance -->
<div v-if="atmStore.balanceSats !== null" class="text-center">
<p class="text-sm text-muted-foreground">Available Balance</p>
<p class="text-xl font-bold text-primary sm:text-2xl">
{{ atmStore.balanceSats.toLocaleString() }} sats
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-4 sm:flex-row sm:gap-6 md:gap-8">
<!-- Buy Bitcoin -->