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:
Padreug 2026-05-30 22:40:51 +02:00
commit 41f9412524
6 changed files with 232 additions and 107 deletions

View file

@ -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<HalInstance> {
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<number, number> = {}
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<HalInstance> {
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<HalInstance> {
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<HalInstance> {
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> => {
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