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'
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue