diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 1331d15..27508b0 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -9,6 +9,13 @@ import type { BillValidator, BillDispenser } from '@bitSpire/hal' export interface CassetteConfig { + /** + * Physical bay index (1-based). Position is the addressable unit: + * multiple cassettes may share the same denomination on a single + * machine (real production machines load N cassettes of the same + * denomination for cash-out throughput on a single bill class). + */ + position: number denomination: number count?: number } @@ -120,15 +127,20 @@ export async function initializeHal(config: HalConfig): Promise { validator = null } - // Track inventory. Mutated in-place by dispense + setCassettes; - // `cassetteDenominations` is rebuilt fresh on setCassettes so the - // dispense lookup at line ~190 picks up the new index. - const inventory: Record = {} - for (const cassette of dispConfig.cassettes) { - inventory[cassette.denomination] = cassette.count ?? 0 - } - - let cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination) + // Per-physical-bay state. Order matches the dispenser's internal note + // array exactly. Two bays may carry the same denomination (real machines + // load N cassettes of one denomination for cash-out throughput) — that's + // why position, not denomination, is the addressable unit. The dispense + // path below distributes a denomination ask across all matching bays. + type BayState = { position: number; denomination: number; count: number } + let bays: BayState[] = dispConfig.cassettes + .slice() + .sort((a, b) => a.position - b.position) + .map((c) => ({ + position: c.position, + denomination: c.denomination, + count: c.count ?? 0, + })) return { connectValidator: (callbacks: ValidatorCallbacks) => { @@ -196,10 +208,26 @@ export async function initializeHal(config: HalConfig): Promise { console.log('[HAL] Dispenser re-initialized') } - const notes: number[] = new Array(cassetteDenominations.length).fill(0) + // Build the per-bay note array. For each `{denomination, count}` ask, + // walk every bay whose denomination matches, draining greedy bay by + // bay until the request is satisfied or all matching bays are empty. + // This is the multi-same-denom support: a machine with four $20 bays + // asked for 60 $20s will pull 20 from each of three bays (or whatever + // shape the inventory has), not crash on the first one running short. + const notes: number[] = new Array(bays.length).fill(0) for (const { denomination, count } of amounts) { - const idx = cassetteDenominations.indexOf(denomination) - if (idx === -1) { + let remaining = count + let matched = false + for (let i = 0; i < bays.length && remaining > 0; i++) { + const bay = bays[i]! + if (bay.denomination !== denomination) continue + matched = true + const take = Math.min(remaining, bay.count) + if (take <= 0) continue + notes[i] = (notes[i] ?? 0) + take + remaining -= take + } + if (!matched) { return { bills: amounts.map((a) => ({ denomination: a.denomination, @@ -210,36 +238,61 @@ export async function initializeHal(config: HalConfig): Promise { error: `No cassette loaded with denomination: ${denomination}`, } } - notes[idx] = count + if (remaining > 0) { + return { + bills: amounts.map((a) => ({ + denomination: a.denomination, + dispensed: 0, + rejected: 0, + })), + dispensed: false, + error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`, + } + } } const result = await dispenser.dispense(notes) - // Build per-cassette results (position-aware, matches result.value ordering) - const cassetteResults = cassetteDenominations.map((denom, i) => ({ - name: `cassette${i + 1}`, - position: i, - denomination: denom, + // Build per-bay results — position is the AUTHORITATIVE field, name + // / synthetic position-by-index left for backwards-compat. The + // dispenser's `result.value` order matches our `bays` (sorted by + // position at init / setCassettes time), so index == bay slot. + const cassetteResults = bays.map((bay, i) => ({ + name: `cassette${bay.position}`, + position: bay.position, + denomination: bay.denomination, provisioned: notes[i] ?? 0, dispensed: result.value[i]?.dispensed ?? 0, rejected: result.value[i]?.rejected ?? 0, })) - // Update inventory based on what was ACTUALLY dispensed (per-cassette) - for (const c of cassetteResults) { - if (c.dispensed > 0 && inventory[c.denomination] !== undefined) { - inventory[c.denomination] -= c.dispensed + // Decrement bay-local count by what ACTUALLY dispensed from that bay. + for (let i = 0; i < bays.length; i++) { + const bay = bays[i]! + const dispensed = result.value[i]?.dispensed ?? 0 + if (dispensed > 0) { + bay.count = Math.max(0, bay.count - dispensed) } } - // Build per-denomination result (backward compatible, only requested denoms) - const bills = cassetteResults - .filter((c) => c.provisioned > 0) - .map((c) => ({ + // Build per-denomination aggregate result (for the renderer's + // existing per-denom callers). Sum across bays sharing a denom. + const billsByDenom = new Map< + number, + { denomination: number; dispensed: number; rejected: number } + >() + for (const c of cassetteResults) { + if (c.provisioned <= 0) continue + const acc = billsByDenom.get(c.denomination) ?? { denomination: c.denomination, - dispensed: c.dispensed, - rejected: c.rejected, - })) + dispensed: 0, + rejected: 0, + } + acc.dispensed += c.dispensed + acc.rejected += c.rejected + billsByDenom.set(c.denomination, acc) + } + const bills = Array.from(billsByDenom.values()) const totalRequested = amounts.reduce((s, a) => s + a.count, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) @@ -257,20 +310,36 @@ export async function initializeHal(config: HalConfig): Promise { return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed } }, - getInventory: () => ({ ...inventory }), + /** + * Aggregate inventory keyed by denomination (sum across bays). The + * renderer's "have we got enough $20s" gates use this denom-summed + * view; per-bay detail is internal HAL state. + */ + getInventory: () => { + const inv: Record = {} + for (const bay of bays) { + if (bay.count > 0) inv[bay.denomination] = (inv[bay.denomination] ?? 0) + bay.count + } + return inv + }, setCassettes: async (cassettes: CassetteConfig[]): Promise => { console.log( '[HAL] Hot-reloading cassette layout:', - cassettes.map((c) => `${c.denomination}×${c.count ?? 0}`).join(', ') + cassettes + .map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`) + .join(', ') ) - // Rebuild the local view first so subsequent dispense calls see the - // new layout even if the dispenser re-init is slow / fails. - for (const k of Object.keys(inventory)) delete inventory[Number(k)] - for (const cassette of cassettes) { - inventory[cassette.denomination] = cassette.count ?? 0 - } - cassetteDenominations = cassettes.map((c) => c.denomination) + // Rebuild bays first so subsequent dispense calls see the new layout + // even if the dispenser re-init is slow / fails. + bays = cassettes + .slice() + .sort((a, b) => a.position - b.position) + .map((c) => ({ + position: c.position, + denomination: c.denomination, + count: c.count ?? 0, + })) dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes } // Close + re-init the dispenser so its internal per-bay state matches // the new layout. Errors here surface to the caller (operator-config diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index fb4f9d8..f76cfb2 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -401,15 +401,20 @@ let pendingBillDenomination: number | null = null ipcMain.handle('hal:init', async (_event, config) => { try { - // Override cassette config with DB values (operator may have changed them via atm-tui) + // Override cassette config with DB values (operator may have changed them via atm-tui + // or via an operator-config publish from satmachineadmin). Pass per-position so the + // HAL knows about every bay including duplicates of the same denomination — real + // machines load N cassettes of one denomination for cash-out throughput. const dbCassettes = loadCassettes() if (dbCassettes.length > 0) { - config.dispenser.cassettes = dbCassettes.map( - (c: { denomination: number; count: number }) => ({ + config.dispenser.cassettes = dbCassettes + .slice() + .sort((a, b) => a.position - b.position) + .map((c) => ({ + position: c.position, denomination: c.denomination, count: c.count, - }) - ) + })) console.log('[Electron] Using DB cassettes for HAL init:', config.dispenser.cassettes) } @@ -526,7 +531,7 @@ ipcMain.handle( 'hal:reload-cassettes', async ( _event, - cassettes: { denomination: number; count?: number }[] + cassettes: { position: number; denomination: number; count?: number }[] ): Promise<{ ok: boolean; error?: string }> => { if (!halInstance) { return { ok: false, error: 'HAL not initialized' } diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 524ca06..4ac7306 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -104,7 +104,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), applyOperatorCassettesConfig: ( payload: { - denominations: Record + positions: Record }, eventCreatedAt: number ): Promise<{ applied: true } | { applied: false; reason: string }> => @@ -124,7 +124,7 @@ contextBridge.exposeInMainWorld('electronAPI', { halRejectBill: (): Promise => ipcRenderer.invoke('hal:reject-bill'), halGetInventory: (): Promise> => ipcRenderer.invoke('hal:get-inventory'), halReloadCassettes: ( - cassettes: { denomination: number; count?: number }[] + cassettes: { position: number; denomination: number; count?: number }[] ): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('hal:reload-cassettes', cassettes), halCleanup: (): Promise => ipcRenderer.invoke('hal:cleanup'), @@ -195,7 +195,7 @@ declare global { getBootstrapPublishedAt: () => Promise markBootstrapPublished: (unixTimestamp: number) => Promise applyOperatorCassettesConfig: ( - payload: { denominations: Record }, + payload: { positions: Record }, eventCreatedAt: number ) => Promise<{ applied: true } | { applied: false; reason: string }> getSupportPages: () => Promise<{ id: string; title: string; content: string }[]> @@ -208,7 +208,7 @@ declare global { halRejectBill: () => Promise halGetInventory: () => Promise> halReloadCassettes: ( - cassettes: { denomination: number; count?: number }[] + cassettes: { position: number; denomination: number; count?: number }[] ) => Promise<{ ok: boolean; error?: string }> halCleanup: () => Promise onHalBillRead: (callback: (denomination: number) => void) => void diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index a9ab8f0..11db2ac 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -15,7 +15,7 @@ import fs from 'node:fs' let db: Database.Database | null = null -const SCHEMA_VERSION = '8' +const SCHEMA_VERSION = '9' function getDbPath(): string { const prodDir = '/var/lib/bitspire' @@ -52,9 +52,9 @@ export function initDatabase(dbPath?: string): void { ); CREATE TABLE IF NOT EXISTS cassettes ( - denomination INTEGER PRIMARY KEY, - count INTEGER NOT NULL DEFAULT 0, - position INTEGER NOT NULL DEFAULT 0 + position INTEGER PRIMARY KEY, + denomination INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS cashbox ( @@ -243,6 +243,39 @@ export function initDatabase(dbPath?: string): void { db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('bootstrapPublishedAt', '') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('8', 'schema_version') console.log('[StateStore] Migrated schema v7 → v8 (seeded operator-config meta rows)') + existing.value = '8' + } + + if (existing && existing.value === '8') { + // Migration v8 → v9: rebuild `cassettes` so `position` is the PK, NOT + // `denomination`. Real production machines (Tejo, batm3) load multiple + // cassettes with the same denomination for cash-out throughput on a + // single bill class — the v1.0 schema's denomination-PK silently + // collapsed duplicates and the HAL `indexOf(denomination)` first-match + // semantics meant only one bay of any given denom could ever dispense. + // v9 makes `position` the addressable unit (matches the hardware bay + // layout) and allows `denomination` to repeat across rows. + // + // The cassette-config wire shape (operator → ATM kind-30078) flips + // alongside this from `{denominations: {...}}` → `{positions: {...}}`. + // See aiolabs/lamassu-next#56 + ~/dev/coordination/log.md 2026-05-30 + // entries (06:30Z → 18:45Z) for the design history. + db.pragma('foreign_keys = OFF') + db.exec(` + DROP TABLE IF EXISTS cassettes_new; + CREATE TABLE cassettes_new ( + position INTEGER PRIMARY KEY, + denomination INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO cassettes_new (position, denomination, count) + SELECT position, denomination, count FROM cassettes; + DROP TABLE cassettes; + ALTER TABLE cassettes_new RENAME TO cassettes; + `) + db.pragma('foreign_keys = ON') + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version') + console.log('[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)') } // Defensive: a fresh install at SCHEMA_VERSION skips all migrations. @@ -304,7 +337,7 @@ export function markBootstrapPublished(unixTimestamp: number): void { } export type OperatorCassettesPayload = { - denominations: Record + positions: Record } export type ApplyResult = @@ -319,13 +352,17 @@ export type ApplyResult = * * 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt` * (defense-in-depth — caller should have done this too). - * 2. Validates the payload's `denominations` key set is *exactly* the set - * of denominations currently in the `cassettes` table. - * 3. Validates per-entry `position` is a positive int, `count` is a - * non-negative int. - * 4. In a single SQLite transaction: updates `cassettes` rows (PK is - * denomination — only `position` and `count` mutate) AND advances the - * `meta.lastKnownConfigCreatedAt` watermark to `eventCreatedAt`. + * 2. Validates the payload's `positions` key set is *exactly* the set of + * positions currently in the `cassettes` table. The bay count is + * hardware-determined and can't be added to or removed from via this + * path; only the per-bay denomination and count are operator-mutable. + * 3. Validates per-entry `denomination` is a positive int, `count` is a + * non-negative int. **Duplicate denominations across positions are + * intentionally permitted** — real machines load multiple cassettes + * with the same denomination for cash-out throughput. + * 4. In a single SQLite transaction: updates `cassettes` rows by position + * (denomination + count both mutable per row) AND advances + * `meta.lastKnownConfigCreatedAt` to `eventCreatedAt`. * * Mid-write crashes roll back cleanly; on restart the same event is * re-delivered by the relay and the watermark check drops it as already @@ -347,58 +384,58 @@ export function applyOperatorCassettesConfig( } const currentRows = db - .prepare('SELECT denomination FROM cassettes') - .all() as { denomination: number }[] - const currentDenoms = new Set(currentRows.map((r) => r.denomination)) - const payloadDenoms = new Set(Object.keys(payload.denominations).map((k) => Number(k))) + .prepare('SELECT position FROM cassettes') + .all() as { position: number }[] + const currentPositions = new Set(currentRows.map((r) => r.position)) + const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k))) - if (currentDenoms.size !== payloadDenoms.size) { + if (currentPositions.size !== payloadPositions.size) { return { applied: false, - reason: `denomination count mismatch: state.db has ${currentDenoms.size}, payload has ${payloadDenoms.size}`, + reason: `position count mismatch: state.db has ${currentPositions.size}, payload has ${payloadPositions.size}`, } } - for (const d of currentDenoms) { - if (!payloadDenoms.has(d)) { - return { applied: false, reason: `payload missing denomination ${d}` } + for (const p of currentPositions) { + if (!payloadPositions.has(p)) { + return { applied: false, reason: `payload missing position ${p}` } } } - for (const d of payloadDenoms) { - if (!currentDenoms.has(d)) { - return { applied: false, reason: `payload includes unknown denomination ${d}` } + for (const p of payloadPositions) { + if (!currentPositions.has(p)) { + return { applied: false, reason: `payload includes unknown position ${p}` } } } - for (const [denomKey, entry] of Object.entries(payload.denominations)) { - if (!Number.isInteger(entry.position) || entry.position <= 0) { + for (const [posKey, entry] of Object.entries(payload.positions)) { + if (!Number.isInteger(entry.denomination) || entry.denomination <= 0) { return { applied: false, - reason: `position must be positive int (denomination ${denomKey}, got ${entry.position})`, + reason: `denomination must be positive int (position ${posKey}, got ${entry.denomination})`, } } if (!Number.isInteger(entry.count) || entry.count < 0) { return { applied: false, - reason: `count must be non-negative int (denomination ${denomKey}, got ${entry.count})`, + reason: `count must be non-negative int (position ${posKey}, got ${entry.count})`, } } } const updateCassette = db.prepare( - 'UPDATE cassettes SET position = ?, count = ? WHERE denomination = ?' + 'UPDATE cassettes SET denomination = ?, count = ? WHERE position = ?' ) const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?') const run = db.transaction(() => { - for (const [denomKey, entry] of Object.entries(payload.denominations)) { - updateCassette.run(entry.position, entry.count, Number(denomKey)) + for (const [posKey, entry] of Object.entries(payload.positions)) { + updateCassette.run(entry.denomination, entry.count, Number(posKey)) } setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt') }) run() console.log( - `[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.denominations).length} denominations)` + `[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.positions).length} positions)` ) return { applied: true } } @@ -421,7 +458,10 @@ export function loadCassettes(): CassetteRow[] { } /** - * Upsert cassette counts. Replaces all existing rows. + * Upsert cassette rows. Position is the addressable unit; the same + * denomination may legitimately appear on multiple positions (real + * machines load N cassettes of the same denomination for cash-out + * throughput). */ export function setCassettes( cassettes: { denomination: number; count: number; position?: number }[] @@ -429,14 +469,14 @@ export function setCassettes( if (!db) throw new Error('Database not initialized') const upsert = db.prepare( - 'INSERT INTO cassettes (denomination, count, position) VALUES (?, ?, ?) ON CONFLICT(denomination) DO UPDATE SET count = excluded.count, position = excluded.position' + 'INSERT INTO cassettes (position, denomination, count) VALUES (?, ?, ?) ON CONFLICT(position) DO UPDATE SET denomination = excluded.denomination, count = excluded.count' ) const run = db.transaction( (rows: { denomination: number; count: number; position?: number }[]) => { for (let i = 0; i < rows.length; i++) { const row = rows[i]! - upsert.run(row.denomination, row.count, row.position ?? i + 1) + upsert.run(row.position ?? i + 1, row.denomination, row.count) } } ) @@ -446,26 +486,31 @@ export function setCassettes( } /** - * Increment or decrement a cassette count (e.g., after dispensing). + * Decrement the count for a specific physical bay (position). Use this + * after a successful dispense — the dispenser returns per-position + * results, so the caller already knows which bay drained how many. */ -export function updateCassetteCount(denomination: number, delta: number): void { +export function updateCassetteCountByPosition(position: number, delta: number): void { if (!db) throw new Error('Database not initialized') - db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?').run( + db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?').run( delta, - denomination + position ) } /** - * Get inventory as a denomination -> count map. + * Get aggregate inventory as denomination → total-count-across-positions. + * Used by the renderer / state machine for "do we have enough $20s to + * cover this withdraw" gating where the per-bay breakdown doesn't matter. + * For per-bay state use `loadCassettes()`. */ export function getInventory(): Record { const rows = loadCassettes() const inv: Record = {} for (const row of rows) { if (row.count > 0) { - inv[row.denomination] = row.count + inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count } } return inv diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index 21b9b93..c9b26f8 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -148,7 +148,7 @@ async function handleOperatorConfigEvent( } // 4. Decrypt content (NIP-44 v2). - let parsed: { denominations: Record } + let parsed: { positions: Record } 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 = {} + const positions: Record = {} 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, { diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 0ff2f3b..48aaef4 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -90,7 +90,7 @@ declare global { getBootstrapPublishedAt: () => Promise markBootstrapPublished: (unixTimestamp: number) => Promise applyOperatorCassettesConfig: ( - payload: { denominations: Record }, + payload: { positions: Record }, 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 halGetInventory: () => Promise> halReloadCassettes: ( - cassettes: { denomination: number; count?: number }[] + cassettes: { position: number; denomination: number; count?: number }[] ) => Promise<{ ok: boolean; error?: string }> halCleanup: () => Promise onHalBillRead: (callback: (denomination: number) => void) => void