fix(machine): republish cassettes-state after dispense + on reload #65

Merged
padreug merged 1 commit from cassette-state-republish into dev 2026-06-22 09:56:40 +00:00
2 changed files with 76 additions and 22 deletions
Showing only changes of commit 762b0def5c - Show all commits

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>
Padreug 2026-06-22 11:08:29 +02:00 • committed by padreug

View file

@ -56,6 +56,17 @@ export interface OperatorConfigServiceConfig {
export interface OperatorConfigService {
/** Unsubscribe from operator events and free resources. */
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(
@ -63,11 +74,11 @@ export async function startOperatorConfigService(
): Promise<OperatorConfigService> {
if (cfg.operatorPubkeys.length === 0) {
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
return { stop: () => {} }
return NOOP_SERVICE
}
if (!isElectron || !window.electronAPI) {
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
return { stop: () => {} }
return NOOP_SERVICE
}
const api = window.electronAPI
const machineId = cfg.machineId ?? cfg.signer.pubkey
@ -103,6 +114,12 @@ export async function startOperatorConfigService(
return {
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(
`[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,
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
}
): Promise<boolean> {
const cassettes = await api.loadCassettes()
if (cassettes.length === 0) {
console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap')
return
}
if (cassettes.length === 0) return false
const operatorPubkey = cfg.operatorPubkeys[0]
if (!operatorPubkey) {
console.log('[OperatorConfig] No operator pubkey — skipping bootstrap')
return
}
if (!operatorPubkey) return false
const positions: Record<string, { denomination: number; count: number }> = {}
for (const c of cassettes) {
@ -235,6 +258,30 @@ async function maybePublishBootstrap(
})
await cfg.nostrClient.publish(event)
await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id })
console.log('[OperatorConfig] cassettes-state 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')
}
}

View file

@ -521,7 +521,10 @@ export const useAtmStore = defineStore('atm', () => {
bills,
cassettes: dr?.cassettes,
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,
cassettes: dr?.cassettes,
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()))
}
}