fix(machine): republish cassettes-state after dispense + on reload
The cassette-state beacon was published only once at bootstrap, so after a cash-out dispense the operator's view stayed frozen at the bootstrap snapshot (still 20x4/50x7 after dispensing) — the ATM decremented its local HAL counts but never told the operator. Coord 2026-06-21 (post cash-out leg). - operator-config.ts: extract publishCassettesState() (the live, ungated publish) out of the one-shot bootstrap; expose it on OperatorConfigService; also fire it after an operator-config apply (the "on reload" case). - atm.ts: republish after each cash-out dispense (complete + partial), once the decremented counts are persisted. kind-30078 is replaceable (latest wins) and the operator already consumes every update — no operator-side change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3064acf217
commit
762b0def5c
2 changed files with 76 additions and 22 deletions
|
|
@ -56,6 +56,17 @@ export interface OperatorConfigServiceConfig {
|
||||||
export interface OperatorConfigService {
|
export interface OperatorConfigService {
|
||||||
/** Unsubscribe from operator events and free resources. */
|
/** Unsubscribe from operator events and free resources. */
|
||||||
stop(): void
|
stop(): void
|
||||||
|
/**
|
||||||
|
* Republish the current cassette state (kind-30078, replaceable). Call after
|
||||||
|
* a dispense and on a cassette reload so the operator's view tracks reality.
|
||||||
|
* Best-effort — logs and swallows errors.
|
||||||
|
*/
|
||||||
|
publishCassettesState(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOOP_SERVICE: OperatorConfigService = {
|
||||||
|
stop: () => {},
|
||||||
|
publishCassettesState: async () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startOperatorConfigService(
|
export async function startOperatorConfigService(
|
||||||
|
|
@ -63,11 +74,11 @@ export async function startOperatorConfigService(
|
||||||
): Promise<OperatorConfigService> {
|
): Promise<OperatorConfigService> {
|
||||||
if (cfg.operatorPubkeys.length === 0) {
|
if (cfg.operatorPubkeys.length === 0) {
|
||||||
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
|
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
|
||||||
return { stop: () => {} }
|
return NOOP_SERVICE
|
||||||
}
|
}
|
||||||
if (!isElectron || !window.electronAPI) {
|
if (!isElectron || !window.electronAPI) {
|
||||||
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
|
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
|
||||||
return { stop: () => {} }
|
return NOOP_SERVICE
|
||||||
}
|
}
|
||||||
const api = window.electronAPI
|
const api = window.electronAPI
|
||||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||||
|
|
@ -103,6 +114,12 @@ export async function startOperatorConfigService(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
||||||
|
publishCassettesState: () =>
|
||||||
|
publishCassettesState(cfg, api, machineId)
|
||||||
|
.then(() => {})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[OperatorConfig] cassettes-state republish failed:', err)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,29 +210,35 @@ async function handleOperatorConfigEvent(
|
||||||
console.log(
|
console.log(
|
||||||
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
|
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Republish our resulting cassette state so the operator's view reflects the
|
||||||
|
// applied config (the "on cassette reload" case). Different d-tag from the
|
||||||
|
// operator's config event, so no echo loop. Best-effort.
|
||||||
|
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||||
|
await publishCassettesState(cfg, api, machineId).catch((err) =>
|
||||||
|
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybePublishBootstrap(
|
/**
|
||||||
|
* Publish the ATM's current cassette state as a replaceable kind-30078 event
|
||||||
|
* (`bitspire-cassettes-state:<machineId>`), NIP-44-encrypted to the operator.
|
||||||
|
* Replaceable → latest wins; the operator consumes every update. Call after a
|
||||||
|
* dispense and on a cassette reload so the operator view tracks reality, not
|
||||||
|
* the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56).
|
||||||
|
*
|
||||||
|
* NOT gated on the bootstrap flag — this is the live update. Returns whether an
|
||||||
|
* event was published (false when there are no cassettes / no operator).
|
||||||
|
*/
|
||||||
|
async function publishCassettesState(
|
||||||
cfg: OperatorConfigServiceConfig,
|
cfg: OperatorConfigServiceConfig,
|
||||||
api: NonNullable<typeof window.electronAPI>,
|
api: NonNullable<typeof window.electronAPI>,
|
||||||
machineId: string
|
machineId: string
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
const already = await api.getBootstrapPublishedAt()
|
|
||||||
if (already !== null) {
|
|
||||||
console.log('[OperatorConfig] Bootstrap already published at unix', already)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const cassettes = await api.loadCassettes()
|
const cassettes = await api.loadCassettes()
|
||||||
if (cassettes.length === 0) {
|
if (cassettes.length === 0) return false
|
||||||
console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const operatorPubkey = cfg.operatorPubkeys[0]
|
const operatorPubkey = cfg.operatorPubkeys[0]
|
||||||
if (!operatorPubkey) {
|
if (!operatorPubkey) return false
|
||||||
console.log('[OperatorConfig] No operator pubkey — skipping bootstrap')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const positions: Record<string, { denomination: number; count: number }> = {}
|
const positions: Record<string, { denomination: number; count: number }> = {}
|
||||||
for (const c of cassettes) {
|
for (const c of cassettes) {
|
||||||
|
|
@ -235,6 +258,30 @@ async function maybePublishBootstrap(
|
||||||
})
|
})
|
||||||
|
|
||||||
await cfg.nostrClient.publish(event)
|
await cfg.nostrClient.publish(event)
|
||||||
await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
|
console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id })
|
||||||
console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id })
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-boot hello: publish the cassette state once and mark the gate. The
|
||||||
|
* gate (lamassu-next#56) prevents re-emitting the *bootstrap* on every boot;
|
||||||
|
* live updates after dispenses go through `publishCassettesState` directly.
|
||||||
|
*/
|
||||||
|
async function maybePublishBootstrap(
|
||||||
|
cfg: OperatorConfigServiceConfig,
|
||||||
|
api: NonNullable<typeof window.electronAPI>,
|
||||||
|
machineId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const already = await api.getBootstrapPublishedAt()
|
||||||
|
if (already !== null) {
|
||||||
|
console.log('[OperatorConfig] Bootstrap already published at unix', already)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const published = await publishCassettesState(cfg, api, machineId)
|
||||||
|
if (published) {
|
||||||
|
await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
|
||||||
|
console.log('[OperatorConfig] Bootstrap hello-event published')
|
||||||
|
} else {
|
||||||
|
console.log('[OperatorConfig] No cassettes/operator — skipping bootstrap')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -521,7 +521,10 @@ 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())
|
})
|
||||||
|
.then(() => reloadPersistedInventory())
|
||||||
|
// Republish cassette state — a partial dispense changed counts.
|
||||||
|
.then(() => operatorConfigSvc?.publishCassettesState())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,7 +554,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
bills,
|
bills,
|
||||||
cassettes: dr?.cassettes,
|
cassettes: dr?.cassettes,
|
||||||
error: dr?.error,
|
error: dr?.error,
|
||||||
}).then(() => reloadPersistedInventory())
|
})
|
||||||
|
.then(() => reloadPersistedInventory())
|
||||||
|
// Republish cassette state after a cash-out dispense (counts
|
||||||
|
// decremented); harmless no-op echo for a cash-in complete.
|
||||||
|
.then(() => (isCashInTx ? undefined : operatorConfigSvc?.publishCassettesState()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue