fix(availability): use DB inventory for broadcasts instead of state machine context

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) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-04-01 17:33:44 -04:00
commit a773842e74
2 changed files with 62 additions and 52 deletions

View file

@ -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 }
}

View file

@ -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<Record<number, number>>({})
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<number, number> => {
return context.value?.inventory ?? {}
})
const fiatSymbol = computed(() => {
const symbols: Record<string, string> = { 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,