feat(machine): v1.1 cassette config — position-keyed wire, multi-same-denom HAL
Mirrors satmachineadmin's PR #30 v1.1 commits (df6e8e0..1cebefc). Three load-bearing corrections from the v1.0 implementation: 1. **Wire shape flips from denomination-keyed to position-keyed** (`{positions: {<pos>: {denomination, count}}}`). The original `#56` spec was position-keyed; my `06:40Z` audit-and-flip was wrong on both the load-bearingness of the ATM denom-PK invariant AND on the operational requirement (per-slot denomination must be operator- editable for swap-during-refill). 2. **Drop "one cassette per denomination" invariant.** Real production machines load multiple cassettes with the same denomination for cash-out throughput on a single bill class (4 × $20 cassettes on Tejo/batm3 are normal). NO unique index on denomination. 3. **HAL refactor for per-position state + greedy distribution.** When asked for N of denomination D, iterate matching bays in position order draining greedy until the request is satisfied or all matching bays empty. Surfaces "Insufficient inventory for denomination D: short K" rather than crashing on the first under-stocked bay. Schema migration v8 → v9: rebuild `cassettes` with `position INTEGER PRIMARY KEY`, `denomination INTEGER NOT NULL`, `count INTEGER NOT NULL DEFAULT 0`. SQLite create-copy-drop-rename per the v4→v5 precedent (FKs off during, no data loss). Existing rows backfill column-by-column. `setCassettes()` upserts `ON CONFLICT(position)`. `updateCassetteCount (denomination, delta)` → `updateCassetteCountByPosition(position, delta)` since the dispenser returns per-position results. `getInventory()` boundary stays denomination-keyed (sums across matching bays) for backwards compat with renderer callers. HAL `inventory: Record<denom, count>` + `cassetteDenominations: number[]` collapse into a single `bays: {position, denomination, count}[]` array. Dispense per-bay note assignment + per-bay decrement on result. Bay ordering by position throughout. Operator-config consumer (`operator-config.ts`) flips both the apply direction (`{positions: ...}` parse + validate position-set equality + denom/count int checks, NO denom-uniqueness) and the bootstrap publish direction (position-keyed payload encoding). IPC type signatures updated in `preload.ts` + `types/electron.d.ts` for both the new `OperatorCassettesPayload` shape and the per-position `halReloadCassettes` argument. `atm-tui` schema flip + handler updates land in a separate commit on `aiolabs/atm-tui` (this commit's changes are limited to lamassu-next). Bumping the atm-tui flake input on `deploy/server-deploy` (or the local flake.lock here) after the atm-tui push reaches the sintra closure. 12/12 typecheck, 18/18 state-machine tests, 11/11 clink, 11/11 lnbits, 11/11 nostr-client all green. Design history: `~/dev/coordination/log.md` entries 2026-05-30T06:30Z → 20:55Z. Satmachineadmin counterpart at PR #30. Issue body refreshed. refs: aiolabs/lamassu-next#56, aiolabs/satmachineadmin#29, aiolabs/satmachineadmin PR #30 (commits df6e8e0..1cebefc) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4612ff2155
commit
41f9412524
6 changed files with 232 additions and 107 deletions
|
|
@ -148,7 +148,7 @@ async function handleOperatorConfigEvent(
|
|||
}
|
||||
|
||||
// 4. Decrypt content (NIP-44 v2).
|
||||
let parsed: { denominations: Record<string, { position: number; count: number }> }
|
||||
let parsed: { positions: Record<string, { denomination: number; count: number }> }
|
||||
try {
|
||||
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
|
||||
parsed = JSON.parse(plaintext) as typeof parsed
|
||||
|
|
@ -156,16 +156,18 @@ async function handleOperatorConfigEvent(
|
|||
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.denominations) {
|
||||
console.error('[OperatorConfig] Payload missing `denominations` field')
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.positions) {
|
||||
console.error('[OperatorConfig] Payload missing `positions` field')
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store
|
||||
// function re-validates watermark + denomination key-set equality +
|
||||
// per-entry types inside the SQLite transaction.
|
||||
// function re-validates watermark + position key-set equality +
|
||||
// per-entry types inside the SQLite transaction. Duplicate
|
||||
// denominations across positions are allowed — real machines load
|
||||
// N cassettes of the same denomination for cash-out throughput.
|
||||
const result = await api.applyOperatorCassettesConfig(
|
||||
{ denominations: parsed.denominations },
|
||||
{ positions: parsed.positions },
|
||||
event.created_at
|
||||
)
|
||||
if (!result.applied) {
|
||||
|
|
@ -174,20 +176,24 @@ async function handleOperatorConfigEvent(
|
|||
}
|
||||
|
||||
// 6. Hot-reload the HAL with the new cassette layout so dispense math
|
||||
// picks up the new denomination set. state.db is already updated;
|
||||
// HAL re-init failure means the renderer's persistedInventory may
|
||||
// be ahead of the HAL until next service restart — log loudly but
|
||||
// don't unwind the state.db apply (the operator wants their config
|
||||
// landed; HAL can catch up).
|
||||
// picks up the new per-position mapping. state.db is already updated;
|
||||
// HAL re-init failure means the renderer's persistedInventory may be
|
||||
// ahead of the HAL until next service restart — log loudly but don't
|
||||
// unwind the state.db apply (the operator wants their config landed;
|
||||
// HAL can catch up).
|
||||
const cassettesAfter = await api.loadCassettes()
|
||||
const halResult = await api.halReloadCassettes(
|
||||
cassettesAfter.map((c) => ({ denomination: c.denomination, count: c.count }))
|
||||
cassettesAfter.map((c) => ({
|
||||
position: c.position,
|
||||
denomination: c.denomination,
|
||||
count: c.count,
|
||||
}))
|
||||
)
|
||||
if (!halResult.ok) {
|
||||
console.error('[OperatorConfig] HAL reload failed:', halResult.error)
|
||||
}
|
||||
console.log(
|
||||
`[OperatorConfig] Applied — created_at=${event.created_at}, denominations=${Object.keys(parsed.denominations).join(',')}`
|
||||
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -213,11 +219,11 @@ async function maybePublishBootstrap(
|
|||
return
|
||||
}
|
||||
|
||||
const denominations: Record<string, { position: number; count: number }> = {}
|
||||
const positions: Record<string, { denomination: number; count: number }> = {}
|
||||
for (const c of cassettes) {
|
||||
denominations[String(c.denomination)] = { position: c.position, count: c.count }
|
||||
positions[String(c.position)] = { denomination: c.denomination, count: c.count }
|
||||
}
|
||||
const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { denominations })
|
||||
const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { positions })
|
||||
|
||||
const dTag = atmStateDTag(machineId)
|
||||
const event = createSignedEvent(cfg.identity, {
|
||||
|
|
|
|||
4
apps/machine/src/types/electron.d.ts
vendored
4
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -90,7 +90,7 @@ declare global {
|
|||
getBootstrapPublishedAt: () => Promise<number | null>
|
||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { denominations: Record<string, { position: number; count: number }> },
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
||||
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
||||
|
|
@ -103,7 +103,7 @@ declare global {
|
|||
halRejectBill: () => Promise<void>
|
||||
halGetInventory: () => Promise<Record<number, number>>
|
||||
halReloadCassettes: (
|
||||
cassettes: { denomination: number; count?: number }[]
|
||||
cassettes: { position: number; denomination: number; count?: number }[]
|
||||
) => Promise<{ ok: boolean; error?: string }>
|
||||
halCleanup: () => Promise<void>
|
||||
onHalBillRead: (callback: (denomination: number) => void) => void
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue