From a773842e74d9840967aeefc5aba7359c18fbc044 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 1 Apr 2026 17:33:44 -0400 Subject: [PATCH] fix(availability): use DB inventory for broadcasts instead of state machine context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The availability broadcast was reading inventory from the XState context, which is only populated during cash-out transitions. On fresh boot or idle, context.inventory is empty, so the broadcast falsely reported cash_level: "none" even when cassettes had bills. - Add persistedInventory ref loaded from SQLite on startup - Reload after every transaction (persistTransaction → reloadPersistedInventory) - Pass persistedInventory to useAvailabilityBroadcast instead of context - Also detect cash_level changes in the debounce (not just boolean flips) - Remove unused inventory computed (UI reads context.inventory directly) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composables/useAvailabilityBroadcast.ts | 74 +++++++++---------- apps/machine/src/stores/atm.ts | 40 ++++++---- 2 files changed, 62 insertions(+), 52 deletions(-) diff --git a/apps/machine/src/composables/useAvailabilityBroadcast.ts b/apps/machine/src/composables/useAvailabilityBroadcast.ts index dfcf5a5..1fc2189 100644 --- a/apps/machine/src/composables/useAvailabilityBroadcast.ts +++ b/apps/machine/src/composables/useAvailabilityBroadcast.ts @@ -8,16 +8,21 @@ * Event format: * kind: 30078 * tags: [["d", "atm-availability"]] - * content: {"cash_in":true,"cash_out":true,"fiat":"USD","model":"sintra"} - * - * No exact amounts are revealed — just boolean per direction. + * content: {"cash_in":true,"cash_out":true,"cash_level":"good","fiat":"USD","model":"sintra"} */ import { watch, type Ref } from 'vue' import { useDebounceFn } from '@vueuse/core' import type { NostrClient, MachineIdentity } from '@lamassu/nostr-client' import { createSignedEvent } from '@lamassu/nostr-client' -import type { ATMAvailability } from '@/types/state' + +type CashLevel = 'none' | 'low' | 'good' | 'full' + +interface AvailabilitySnapshot { + cashIn: boolean + cashOut: boolean + cashLevel: CashLevel +} interface UseAvailabilityBroadcastOptions { nostrClient: NostrClient @@ -35,21 +40,9 @@ interface UseAvailabilityBroadcastOptions { export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) { const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options - let lastPublished: ATMAvailability | null = null + let lastSnapshot: AvailabilitySnapshot | null = null - function computeAvailability(): ATMAvailability { - // cash_out: machine has at least one bill in any cassette - const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0) - const cashOut = totalBills > 0 - - // cash_in: Lightning.Pub has sats balance to sell - const cashIn = (balanceSats.value ?? 0) > 0 - - return { cashIn, cashOut } - } - - /** Cash level: none / low / good / full based on total bills */ - function cashLevel(): string { + function computeCashLevel(): CashLevel { const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0) if (totalBills === 0) return 'none' if (totalBills < 20) return 'low' @@ -57,11 +50,25 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption return 'full' } - async function publish(avail: ATMAvailability) { + function computeSnapshot(): AvailabilitySnapshot { + const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0) + return { + cashOut: totalBills > 0, + cashIn: (balanceSats.value ?? 0) > 0, + cashLevel: computeCashLevel(), + } + } + + function hasChanged(a: AvailabilitySnapshot, b: AvailabilitySnapshot | null): boolean { + if (!b) return true + return a.cashIn !== b.cashIn || a.cashOut !== b.cashOut || a.cashLevel !== b.cashLevel + } + + async function publish(snap: AvailabilitySnapshot) { const content = JSON.stringify({ - cash_in: avail.cashIn, - cash_out: avail.cashOut, - cash_level: cashLevel(), + cash_in: snap.cashIn, + cash_out: snap.cashOut, + cash_level: snap.cashLevel, fiat: fiatCode, model, }) @@ -75,7 +82,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption try { await nostrClient.publish(event) - lastPublished = avail + lastSnapshot = snap console.log('[Availability] Published:', content) } catch (e) { console.warn('[Availability] Failed to publish:', e) @@ -83,16 +90,11 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption } const debouncedPublish = useDebounceFn(() => { - const avail = computeAvailability() - // Only publish if availability actually changed - if ( - !lastPublished || - avail.cashIn !== lastPublished.cashIn || - avail.cashOut !== lastPublished.cashOut - ) { - publish(avail) + const snap = computeSnapshot() + if (hasChanged(snap, lastSnapshot)) { + publish(snap) } - }, 5000) // 5s debounce to avoid spamming + }, 5000) // Watch reactive sources watch( @@ -104,21 +106,19 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption ) // Heartbeat: republish every 5 minutes so monitors know the machine is alive. - // If the latest Kind 30078 event is older than ~10 minutes, the machine is offline. const heartbeatInterval = setInterval( () => { - publish(computeAvailability()) + publish(computeSnapshot()) }, 5 * 60 * 1000 ) // Publish immediately on setup - const initial = computeAvailability() - publish(initial) + publish(computeSnapshot()) function stop() { clearInterval(heartbeatInterval) } - return { computeAvailability, stop } + return { computeSnapshot, stop } } diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 33da582..3b6ac50 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -286,6 +286,23 @@ export const useAtmStore = defineStore('atm', () => { // Cleanup function for LNURL sessions (set after Lightning init) let lnurlCleanupFn: (() => void) | null = null + /** + * Persisted inventory loaded from SQLite — the source of truth for + * availability broadcasts. Loaded once at init and refreshed after + * every dispense / operator cassette change. Unlike the state-machine + * context inventory (which is only populated during a cash-out flow), + * this is always available. + */ + const persistedInventory = ref>({}) + + async function reloadPersistedInventory() { + const inv = await loadInventoryFromDb() + if (Object.keys(inv).length > 0) { + persistedInventory.value = inv + console.log('[ATM] Persisted inventory updated:', inv) + } + } + /** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */ function detectNetworkFromInvoice(invoice: string) { if (network.value) return // already detected @@ -312,10 +329,6 @@ export const useAtmStore = defineStore('atm', () => { return snapshot.value?.context ?? null }) - const inventory = computed((): Record => { - return context.value?.inventory ?? {} - }) - const fiatSymbol = computed(() => { const symbols: Record = { USD: '$', GTQ: 'Q', EUR: '€', GBP: '£', MXN: '$' } return symbols[fiatCode.value] || fiatCode.value @@ -419,7 +432,7 @@ export const useAtmStore = defineStore('atm', () => { bills, cassettes: dr?.cassettes, error: dr?.error ?? ctx.error, - }) + }).then(() => reloadPersistedInventory()) } } @@ -449,7 +462,7 @@ export const useAtmStore = defineStore('atm', () => { bills, cassettes: dr?.cassettes, error: dr?.error, - }) + }).then(() => reloadPersistedInventory()) } } @@ -1218,13 +1231,7 @@ export const useAtmStore = defineStore('atm', () => { * Called after operator sets cassettes or after transactions. */ async function refreshInventory() { - const inv = await loadInventoryFromDb() - if (actor.value && Object.keys(inv).length > 0) { - // Directly update the actor's context with new inventory - // XState v5: send a custom event that the machine can handle - // For now, we update via the next getInventory call on transition - console.log('[ATM] Inventory refreshed:', inv) - } + await reloadPersistedInventory() } function toggleDebug() { @@ -1258,13 +1265,16 @@ export const useAtmStore = defineStore('atm', () => { /** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */ let stopAvailabilityBroadcast: (() => void) | null = null - function startAvailabilityBroadcast(nostrClient: any, identity: any, model: string) { + async function startAvailabilityBroadcast(nostrClient: any, identity: any, model: string) { if (stopAvailabilityBroadcast) return + // Ensure persisted inventory is loaded before first broadcast + await reloadPersistedInventory() + const { stop } = useAvailabilityBroadcast({ nostrClient, identity, - inventory, + inventory: persistedInventory, balanceSats, fiatCode: fiatCode.value, model,