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:
parent
93bddbfee9
commit
a773842e74
2 changed files with 62 additions and 52 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue