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:
|
* Event format:
|
||||||
* kind: 30078
|
* kind: 30078
|
||||||
* tags: [["d", "atm-availability"]]
|
* tags: [["d", "atm-availability"]]
|
||||||
* content: {"cash_in":true,"cash_out":true,"fiat":"USD","model":"sintra"}
|
* content: {"cash_in":true,"cash_out":true,"cash_level":"good","fiat":"USD","model":"sintra"}
|
||||||
*
|
|
||||||
* No exact amounts are revealed — just boolean per direction.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { watch, type Ref } from 'vue'
|
import { watch, type Ref } from 'vue'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
import type { NostrClient, MachineIdentity } from '@lamassu/nostr-client'
|
import type { NostrClient, MachineIdentity } from '@lamassu/nostr-client'
|
||||||
import { createSignedEvent } 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 {
|
interface UseAvailabilityBroadcastOptions {
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
|
|
@ -35,21 +40,9 @@ interface UseAvailabilityBroadcastOptions {
|
||||||
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
|
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
|
||||||
const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options
|
const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options
|
||||||
|
|
||||||
let lastPublished: ATMAvailability | null = null
|
let lastSnapshot: AvailabilitySnapshot | null = null
|
||||||
|
|
||||||
function computeAvailability(): ATMAvailability {
|
function computeCashLevel(): CashLevel {
|
||||||
// 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 {
|
|
||||||
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
|
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
|
||||||
if (totalBills === 0) return 'none'
|
if (totalBills === 0) return 'none'
|
||||||
if (totalBills < 20) return 'low'
|
if (totalBills < 20) return 'low'
|
||||||
|
|
@ -57,11 +50,25 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
||||||
return 'full'
|
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({
|
const content = JSON.stringify({
|
||||||
cash_in: avail.cashIn,
|
cash_in: snap.cashIn,
|
||||||
cash_out: avail.cashOut,
|
cash_out: snap.cashOut,
|
||||||
cash_level: cashLevel(),
|
cash_level: snap.cashLevel,
|
||||||
fiat: fiatCode,
|
fiat: fiatCode,
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
|
|
@ -75,7 +82,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await nostrClient.publish(event)
|
await nostrClient.publish(event)
|
||||||
lastPublished = avail
|
lastSnapshot = snap
|
||||||
console.log('[Availability] Published:', content)
|
console.log('[Availability] Published:', content)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Availability] Failed to publish:', e)
|
console.warn('[Availability] Failed to publish:', e)
|
||||||
|
|
@ -83,16 +90,11 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
||||||
}
|
}
|
||||||
|
|
||||||
const debouncedPublish = useDebounceFn(() => {
|
const debouncedPublish = useDebounceFn(() => {
|
||||||
const avail = computeAvailability()
|
const snap = computeSnapshot()
|
||||||
// Only publish if availability actually changed
|
if (hasChanged(snap, lastSnapshot)) {
|
||||||
if (
|
publish(snap)
|
||||||
!lastPublished ||
|
|
||||||
avail.cashIn !== lastPublished.cashIn ||
|
|
||||||
avail.cashOut !== lastPublished.cashOut
|
|
||||||
) {
|
|
||||||
publish(avail)
|
|
||||||
}
|
}
|
||||||
}, 5000) // 5s debounce to avoid spamming
|
}, 5000)
|
||||||
|
|
||||||
// Watch reactive sources
|
// Watch reactive sources
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -104,21 +106,19 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
||||||
)
|
)
|
||||||
|
|
||||||
// Heartbeat: republish every 5 minutes so monitors know the machine is alive.
|
// 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(
|
const heartbeatInterval = setInterval(
|
||||||
() => {
|
() => {
|
||||||
publish(computeAvailability())
|
publish(computeSnapshot())
|
||||||
},
|
},
|
||||||
5 * 60 * 1000
|
5 * 60 * 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
// Publish immediately on setup
|
// Publish immediately on setup
|
||||||
const initial = computeAvailability()
|
publish(computeSnapshot())
|
||||||
publish(initial)
|
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
clearInterval(heartbeatInterval)
|
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)
|
// Cleanup function for LNURL sessions (set after Lightning init)
|
||||||
let lnurlCleanupFn: (() => void) | null = null
|
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) */
|
/** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */
|
||||||
function detectNetworkFromInvoice(invoice: string) {
|
function detectNetworkFromInvoice(invoice: string) {
|
||||||
if (network.value) return // already detected
|
if (network.value) return // already detected
|
||||||
|
|
@ -312,10 +329,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
return snapshot.value?.context ?? null
|
return snapshot.value?.context ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const inventory = computed((): Record<number, number> => {
|
|
||||||
return context.value?.inventory ?? {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const fiatSymbol = computed(() => {
|
const fiatSymbol = computed(() => {
|
||||||
const symbols: Record<string, string> = { USD: '$', GTQ: 'Q', EUR: '€', GBP: '£', MXN: '$' }
|
const symbols: Record<string, string> = { USD: '$', GTQ: 'Q', EUR: '€', GBP: '£', MXN: '$' }
|
||||||
return symbols[fiatCode.value] || fiatCode.value
|
return symbols[fiatCode.value] || fiatCode.value
|
||||||
|
|
@ -419,7 +432,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
bills,
|
bills,
|
||||||
cassettes: dr?.cassettes,
|
cassettes: dr?.cassettes,
|
||||||
error: dr?.error ?? ctx.error,
|
error: dr?.error ?? ctx.error,
|
||||||
})
|
}).then(() => reloadPersistedInventory())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,7 +462,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
bills,
|
bills,
|
||||||
cassettes: dr?.cassettes,
|
cassettes: dr?.cassettes,
|
||||||
error: dr?.error,
|
error: dr?.error,
|
||||||
})
|
}).then(() => reloadPersistedInventory())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1218,13 +1231,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
* Called after operator sets cassettes or after transactions.
|
* Called after operator sets cassettes or after transactions.
|
||||||
*/
|
*/
|
||||||
async function refreshInventory() {
|
async function refreshInventory() {
|
||||||
const inv = await loadInventoryFromDb()
|
await reloadPersistedInventory()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDebug() {
|
function toggleDebug() {
|
||||||
|
|
@ -1258,13 +1265,16 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
/** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */
|
/** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */
|
||||||
let stopAvailabilityBroadcast: (() => void) | null = null
|
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
|
if (stopAvailabilityBroadcast) return
|
||||||
|
|
||||||
|
// Ensure persisted inventory is loaded before first broadcast
|
||||||
|
await reloadPersistedInventory()
|
||||||
|
|
||||||
const { stop } = useAvailabilityBroadcast({
|
const { stop } = useAvailabilityBroadcast({
|
||||||
nostrClient,
|
nostrClient,
|
||||||
identity,
|
identity,
|
||||||
inventory,
|
inventory: persistedInventory,
|
||||||
balanceSats,
|
balanceSats,
|
||||||
fiatCode: fiatCode.value,
|
fiatCode: fiatCode.value,
|
||||||
model,
|
model,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue