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
|
|
@ -9,6 +9,13 @@
|
||||||
import type { BillValidator, BillDispenser } from '@bitSpire/hal'
|
import type { BillValidator, BillDispenser } from '@bitSpire/hal'
|
||||||
|
|
||||||
export interface CassetteConfig {
|
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
|
denomination: number
|
||||||
count?: number
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
@ -120,15 +127,20 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
validator = null
|
validator = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track inventory. Mutated in-place by dispense + setCassettes;
|
// Per-physical-bay state. Order matches the dispenser's internal note
|
||||||
// `cassetteDenominations` is rebuilt fresh on setCassettes so the
|
// array exactly. Two bays may carry the same denomination (real machines
|
||||||
// dispense lookup at line ~190 picks up the new index.
|
// load N cassettes of one denomination for cash-out throughput) — that's
|
||||||
const inventory: Record<number, number> = {}
|
// why position, not denomination, is the addressable unit. The dispense
|
||||||
for (const cassette of dispConfig.cassettes) {
|
// path below distributes a denomination ask across all matching bays.
|
||||||
inventory[cassette.denomination] = cassette.count ?? 0
|
type BayState = { position: number; denomination: number; count: number }
|
||||||
}
|
let bays: BayState[] = dispConfig.cassettes
|
||||||
|
.slice()
|
||||||
let cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination)
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((c) => ({
|
||||||
|
position: c.position,
|
||||||
|
denomination: c.denomination,
|
||||||
|
count: c.count ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectValidator: (callbacks: ValidatorCallbacks) => {
|
connectValidator: (callbacks: ValidatorCallbacks) => {
|
||||||
|
|
@ -196,10 +208,26 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
console.log('[HAL] Dispenser re-initialized')
|
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) {
|
for (const { denomination, count } of amounts) {
|
||||||
const idx = cassetteDenominations.indexOf(denomination)
|
let remaining = count
|
||||||
if (idx === -1) {
|
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 {
|
return {
|
||||||
bills: amounts.map((a) => ({
|
bills: amounts.map((a) => ({
|
||||||
denomination: a.denomination,
|
denomination: a.denomination,
|
||||||
|
|
@ -210,36 +238,61 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
error: `No cassette loaded with denomination: ${denomination}`,
|
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)
|
const result = await dispenser.dispense(notes)
|
||||||
|
|
||||||
// Build per-cassette results (position-aware, matches result.value ordering)
|
// Build per-bay results — position is the AUTHORITATIVE field, name
|
||||||
const cassetteResults = cassetteDenominations.map((denom, i) => ({
|
// / synthetic position-by-index left for backwards-compat. The
|
||||||
name: `cassette${i + 1}`,
|
// dispenser's `result.value` order matches our `bays` (sorted by
|
||||||
position: i,
|
// position at init / setCassettes time), so index == bay slot.
|
||||||
denomination: denom,
|
const cassetteResults = bays.map((bay, i) => ({
|
||||||
|
name: `cassette${bay.position}`,
|
||||||
|
position: bay.position,
|
||||||
|
denomination: bay.denomination,
|
||||||
provisioned: notes[i] ?? 0,
|
provisioned: notes[i] ?? 0,
|
||||||
dispensed: result.value[i]?.dispensed ?? 0,
|
dispensed: result.value[i]?.dispensed ?? 0,
|
||||||
rejected: result.value[i]?.rejected ?? 0,
|
rejected: result.value[i]?.rejected ?? 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Update inventory based on what was ACTUALLY dispensed (per-cassette)
|
// Decrement bay-local count by what ACTUALLY dispensed from that bay.
|
||||||
for (const c of cassetteResults) {
|
for (let i = 0; i < bays.length; i++) {
|
||||||
if (c.dispensed > 0 && inventory[c.denomination] !== undefined) {
|
const bay = bays[i]!
|
||||||
inventory[c.denomination] -= c.dispensed
|
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)
|
// Build per-denomination aggregate result (for the renderer's
|
||||||
const bills = cassetteResults
|
// existing per-denom callers). Sum across bays sharing a denom.
|
||||||
.filter((c) => c.provisioned > 0)
|
const billsByDenom = new Map<
|
||||||
.map((c) => ({
|
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,
|
denomination: c.denomination,
|
||||||
dispensed: c.dispensed,
|
dispensed: 0,
|
||||||
rejected: c.rejected,
|
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 totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
||||||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||||||
|
|
@ -257,20 +310,36 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
|
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<number, number> = {}
|
||||||
|
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<void> => {
|
setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => {
|
||||||
console.log(
|
console.log(
|
||||||
'[HAL] Hot-reloading cassette layout:',
|
'[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
|
// Rebuild bays first so subsequent dispense calls see the new layout
|
||||||
// new layout even if the dispenser re-init is slow / fails.
|
// even if the dispenser re-init is slow / fails.
|
||||||
for (const k of Object.keys(inventory)) delete inventory[Number(k)]
|
bays = cassettes
|
||||||
for (const cassette of cassettes) {
|
.slice()
|
||||||
inventory[cassette.denomination] = cassette.count ?? 0
|
.sort((a, b) => a.position - b.position)
|
||||||
}
|
.map((c) => ({
|
||||||
cassetteDenominations = cassettes.map((c) => c.denomination)
|
position: c.position,
|
||||||
|
denomination: c.denomination,
|
||||||
|
count: c.count ?? 0,
|
||||||
|
}))
|
||||||
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
|
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
|
||||||
// Close + re-init the dispenser so its internal per-bay state matches
|
// Close + re-init the dispenser so its internal per-bay state matches
|
||||||
// the new layout. Errors here surface to the caller (operator-config
|
// the new layout. Errors here surface to the caller (operator-config
|
||||||
|
|
|
||||||
|
|
@ -401,15 +401,20 @@ let pendingBillDenomination: number | null = null
|
||||||
|
|
||||||
ipcMain.handle('hal:init', async (_event, config) => {
|
ipcMain.handle('hal:init', async (_event, config) => {
|
||||||
try {
|
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()
|
const dbCassettes = loadCassettes()
|
||||||
if (dbCassettes.length > 0) {
|
if (dbCassettes.length > 0) {
|
||||||
config.dispenser.cassettes = dbCassettes.map(
|
config.dispenser.cassettes = dbCassettes
|
||||||
(c: { denomination: number; count: number }) => ({
|
.slice()
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((c) => ({
|
||||||
|
position: c.position,
|
||||||
denomination: c.denomination,
|
denomination: c.denomination,
|
||||||
count: c.count,
|
count: c.count,
|
||||||
})
|
}))
|
||||||
)
|
|
||||||
console.log('[Electron] Using DB cassettes for HAL init:', config.dispenser.cassettes)
|
console.log('[Electron] Using DB cassettes for HAL init:', config.dispenser.cassettes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -526,7 +531,7 @@ ipcMain.handle(
|
||||||
'hal:reload-cassettes',
|
'hal:reload-cassettes',
|
||||||
async (
|
async (
|
||||||
_event,
|
_event,
|
||||||
cassettes: { denomination: number; count?: number }[]
|
cassettes: { position: number; denomination: number; count?: number }[]
|
||||||
): Promise<{ ok: boolean; error?: string }> => {
|
): Promise<{ ok: boolean; error?: string }> => {
|
||||||
if (!halInstance) {
|
if (!halInstance) {
|
||||||
return { ok: false, error: 'HAL not initialized' }
|
return { ok: false, error: 'HAL not initialized' }
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: {
|
payload: {
|
||||||
denominations: Record<string, { position: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
},
|
},
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
): Promise<{ applied: true } | { applied: false; reason: string }> =>
|
): Promise<{ applied: true } | { applied: false; reason: string }> =>
|
||||||
|
|
@ -124,7 +124,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
halRejectBill: (): Promise<void> => ipcRenderer.invoke('hal:reject-bill'),
|
halRejectBill: (): Promise<void> => ipcRenderer.invoke('hal:reject-bill'),
|
||||||
halGetInventory: (): Promise<Record<number, number>> => ipcRenderer.invoke('hal:get-inventory'),
|
halGetInventory: (): Promise<Record<number, number>> => ipcRenderer.invoke('hal:get-inventory'),
|
||||||
halReloadCassettes: (
|
halReloadCassettes: (
|
||||||
cassettes: { denomination: number; count?: number }[]
|
cassettes: { position: number; denomination: number; count?: number }[]
|
||||||
): Promise<{ ok: boolean; error?: string }> =>
|
): Promise<{ ok: boolean; error?: string }> =>
|
||||||
ipcRenderer.invoke('hal:reload-cassettes', cassettes),
|
ipcRenderer.invoke('hal:reload-cassettes', cassettes),
|
||||||
halCleanup: (): Promise<void> => ipcRenderer.invoke('hal:cleanup'),
|
halCleanup: (): Promise<void> => ipcRenderer.invoke('hal:cleanup'),
|
||||||
|
|
@ -195,7 +195,7 @@ declare global {
|
||||||
getBootstrapPublishedAt: () => Promise<number | null>
|
getBootstrapPublishedAt: () => Promise<number | null>
|
||||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { denominations: Record<string, { position: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
||||||
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
||||||
|
|
@ -208,7 +208,7 @@ declare global {
|
||||||
halRejectBill: () => Promise<void>
|
halRejectBill: () => Promise<void>
|
||||||
halGetInventory: () => Promise<Record<number, number>>
|
halGetInventory: () => Promise<Record<number, number>>
|
||||||
halReloadCassettes: (
|
halReloadCassettes: (
|
||||||
cassettes: { denomination: number; count?: number }[]
|
cassettes: { position: number; denomination: number; count?: number }[]
|
||||||
) => Promise<{ ok: boolean; error?: string }>
|
) => Promise<{ ok: boolean; error?: string }>
|
||||||
halCleanup: () => Promise<void>
|
halCleanup: () => Promise<void>
|
||||||
onHalBillRead: (callback: (denomination: number) => void) => void
|
onHalBillRead: (callback: (denomination: number) => void) => void
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '8'
|
const SCHEMA_VERSION = '9'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -52,9 +52,9 @@ export function initDatabase(dbPath?: string): void {
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS cassettes (
|
CREATE TABLE IF NOT EXISTS cassettes (
|
||||||
denomination INTEGER PRIMARY KEY,
|
position INTEGER PRIMARY KEY,
|
||||||
count INTEGER NOT NULL DEFAULT 0,
|
denomination INTEGER NOT NULL,
|
||||||
position INTEGER NOT NULL DEFAULT 0
|
count INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS cashbox (
|
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('INSERT INTO meta (key, value) VALUES (?, ?)').run('bootstrapPublishedAt', '')
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('8', 'schema_version')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('8', 'schema_version')
|
||||||
console.log('[StateStore] Migrated schema v7 → v8 (seeded operator-config meta rows)')
|
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.
|
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||||
|
|
@ -304,7 +337,7 @@ export function markBootstrapPublished(unixTimestamp: number): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OperatorCassettesPayload = {
|
export type OperatorCassettesPayload = {
|
||||||
denominations: Record<string, { position: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApplyResult =
|
export type ApplyResult =
|
||||||
|
|
@ -319,13 +352,17 @@ export type ApplyResult =
|
||||||
*
|
*
|
||||||
* 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt`
|
* 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt`
|
||||||
* (defense-in-depth — caller should have done this too).
|
* (defense-in-depth — caller should have done this too).
|
||||||
* 2. Validates the payload's `denominations` key set is *exactly* the set
|
* 2. Validates the payload's `positions` key set is *exactly* the set of
|
||||||
* of denominations currently in the `cassettes` table.
|
* positions currently in the `cassettes` table. The bay count is
|
||||||
* 3. Validates per-entry `position` is a positive int, `count` is a
|
* hardware-determined and can't be added to or removed from via this
|
||||||
* non-negative int.
|
* path; only the per-bay denomination and count are operator-mutable.
|
||||||
* 4. In a single SQLite transaction: updates `cassettes` rows (PK is
|
* 3. Validates per-entry `denomination` is a positive int, `count` is a
|
||||||
* denomination — only `position` and `count` mutate) AND advances the
|
* non-negative int. **Duplicate denominations across positions are
|
||||||
* `meta.lastKnownConfigCreatedAt` watermark to `eventCreatedAt`.
|
* 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
|
* 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
|
* re-delivered by the relay and the watermark check drops it as already
|
||||||
|
|
@ -347,58 +384,58 @@ export function applyOperatorCassettesConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentRows = db
|
const currentRows = db
|
||||||
.prepare('SELECT denomination FROM cassettes')
|
.prepare('SELECT position FROM cassettes')
|
||||||
.all() as { denomination: number }[]
|
.all() as { position: number }[]
|
||||||
const currentDenoms = new Set(currentRows.map((r) => r.denomination))
|
const currentPositions = new Set(currentRows.map((r) => r.position))
|
||||||
const payloadDenoms = new Set(Object.keys(payload.denominations).map((k) => Number(k)))
|
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k)))
|
||||||
|
|
||||||
if (currentDenoms.size !== payloadDenoms.size) {
|
if (currentPositions.size !== payloadPositions.size) {
|
||||||
return {
|
return {
|
||||||
applied: false,
|
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) {
|
for (const p of currentPositions) {
|
||||||
if (!payloadDenoms.has(d)) {
|
if (!payloadPositions.has(p)) {
|
||||||
return { applied: false, reason: `payload missing denomination ${d}` }
|
return { applied: false, reason: `payload missing position ${p}` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const d of payloadDenoms) {
|
for (const p of payloadPositions) {
|
||||||
if (!currentDenoms.has(d)) {
|
if (!currentPositions.has(p)) {
|
||||||
return { applied: false, reason: `payload includes unknown denomination ${d}` }
|
return { applied: false, reason: `payload includes unknown position ${p}` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [denomKey, entry] of Object.entries(payload.denominations)) {
|
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||||
if (!Number.isInteger(entry.position) || entry.position <= 0) {
|
if (!Number.isInteger(entry.denomination) || entry.denomination <= 0) {
|
||||||
return {
|
return {
|
||||||
applied: false,
|
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) {
|
if (!Number.isInteger(entry.count) || entry.count < 0) {
|
||||||
return {
|
return {
|
||||||
applied: false,
|
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(
|
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 setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||||||
|
|
||||||
const run = db.transaction(() => {
|
const run = db.transaction(() => {
|
||||||
for (const [denomKey, entry] of Object.entries(payload.denominations)) {
|
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||||
updateCassette.run(entry.position, entry.count, Number(denomKey))
|
updateCassette.run(entry.denomination, entry.count, Number(posKey))
|
||||||
}
|
}
|
||||||
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
|
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
|
||||||
})
|
})
|
||||||
|
|
||||||
run()
|
run()
|
||||||
console.log(
|
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 }
|
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(
|
export function setCassettes(
|
||||||
cassettes: { denomination: number; count: number; position?: number }[]
|
cassettes: { denomination: number; count: number; position?: number }[]
|
||||||
|
|
@ -429,14 +469,14 @@ export function setCassettes(
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
const upsert = db.prepare(
|
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(
|
const run = db.transaction(
|
||||||
(rows: { denomination: number; count: number; position?: number }[]) => {
|
(rows: { denomination: number; count: number; position?: number }[]) => {
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const row = rows[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')
|
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,
|
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<number, number> {
|
export function getInventory(): Record<number, number> {
|
||||||
const rows = loadCassettes()
|
const rows = loadCassettes()
|
||||||
const inv: Record<number, number> = {}
|
const inv: Record<number, number> = {}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.count > 0) {
|
if (row.count > 0) {
|
||||||
inv[row.denomination] = row.count
|
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return inv
|
return inv
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ async function handleOperatorConfigEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Decrypt content (NIP-44 v2).
|
// 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 {
|
try {
|
||||||
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
|
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
|
||||||
parsed = JSON.parse(plaintext) as typeof parsed
|
parsed = JSON.parse(plaintext) as typeof parsed
|
||||||
|
|
@ -156,16 +156,18 @@ async function handleOperatorConfigEvent(
|
||||||
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!parsed || typeof parsed !== 'object' || !parsed.denominations) {
|
if (!parsed || typeof parsed !== 'object' || !parsed.positions) {
|
||||||
console.error('[OperatorConfig] Payload missing `denominations` field')
|
console.error('[OperatorConfig] Payload missing `positions` field')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store
|
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store
|
||||||
// function re-validates watermark + denomination key-set equality +
|
// function re-validates watermark + position key-set equality +
|
||||||
// per-entry types inside the SQLite transaction.
|
// 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(
|
const result = await api.applyOperatorCassettesConfig(
|
||||||
{ denominations: parsed.denominations },
|
{ positions: parsed.positions },
|
||||||
event.created_at
|
event.created_at
|
||||||
)
|
)
|
||||||
if (!result.applied) {
|
if (!result.applied) {
|
||||||
|
|
@ -174,20 +176,24 @@ async function handleOperatorConfigEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Hot-reload the HAL with the new cassette layout so dispense math
|
// 6. Hot-reload the HAL with the new cassette layout so dispense math
|
||||||
// picks up the new denomination set. state.db is already updated;
|
// picks up the new per-position mapping. state.db is already updated;
|
||||||
// HAL re-init failure means the renderer's persistedInventory may
|
// HAL re-init failure means the renderer's persistedInventory may be
|
||||||
// be ahead of the HAL until next service restart — log loudly but
|
// ahead of the HAL until next service restart — log loudly but don't
|
||||||
// don't unwind the state.db apply (the operator wants their config
|
// unwind the state.db apply (the operator wants their config landed;
|
||||||
// landed; HAL can catch up).
|
// HAL can catch up).
|
||||||
const cassettesAfter = await api.loadCassettes()
|
const cassettesAfter = await api.loadCassettes()
|
||||||
const halResult = await api.halReloadCassettes(
|
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) {
|
if (!halResult.ok) {
|
||||||
console.error('[OperatorConfig] HAL reload failed:', halResult.error)
|
console.error('[OperatorConfig] HAL reload failed:', halResult.error)
|
||||||
}
|
}
|
||||||
console.log(
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const denominations: Record<string, { position: number; count: number }> = {}
|
const positions: Record<string, { denomination: number; count: number }> = {}
|
||||||
for (const c of cassettes) {
|
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 dTag = atmStateDTag(machineId)
|
||||||
const event = createSignedEvent(cfg.identity, {
|
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>
|
getBootstrapPublishedAt: () => Promise<number | null>
|
||||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { denominations: Record<string, { position: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
||||||
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
||||||
|
|
@ -103,7 +103,7 @@ declare global {
|
||||||
halRejectBill: () => Promise<void>
|
halRejectBill: () => Promise<void>
|
||||||
halGetInventory: () => Promise<Record<number, number>>
|
halGetInventory: () => Promise<Record<number, number>>
|
||||||
halReloadCassettes: (
|
halReloadCassettes: (
|
||||||
cassettes: { denomination: number; count?: number }[]
|
cassettes: { position: number; denomination: number; count?: number }[]
|
||||||
) => Promise<{ ok: boolean; error?: string }>
|
) => Promise<{ ok: boolean; error?: string }>
|
||||||
halCleanup: () => Promise<void>
|
halCleanup: () => Promise<void>
|
||||||
onHalBillRead: (callback: (denomination: number) => void) => void
|
onHalBillRead: (callback: (denomination: number) => void) => void
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue