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>
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
/**
|
||
* HAL Service for Electron Main Process
|
||
*
|
||
* Bridges @bitSpire/hal hardware drivers to IPC-friendly interfaces.
|
||
* This file is compiled by electron/tsconfig.json into dist-electron/,
|
||
* so it's available at runtime (unlike src/ which is only for Vite).
|
||
*/
|
||
|
||
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
|
||
}
|
||
|
||
export interface HalConfig {
|
||
validator: {
|
||
type: 'id003' | 'ebds'
|
||
device: string | string[]
|
||
fiatCode: string
|
||
}
|
||
dispenser: {
|
||
type: 'f56' | 'puloon'
|
||
device: string
|
||
cassettes: CassetteConfig[]
|
||
}
|
||
}
|
||
|
||
export interface ValidatorCallbacks {
|
||
shouldAcceptBill: (denomination: number) => boolean | 'hold'
|
||
onBillRead?: (denomination: number) => void
|
||
onBillInserted: (denomination: number) => void
|
||
onBillRejected: (reason: string) => void
|
||
onError: (error: string) => void
|
||
}
|
||
|
||
export interface DispenseResult {
|
||
bills: { denomination: number; dispensed: number; rejected: number }[]
|
||
dispensed: boolean
|
||
error?: string
|
||
cassettes?: {
|
||
name: string
|
||
position: number
|
||
denomination: number
|
||
provisioned: number
|
||
dispensed: number
|
||
rejected: number
|
||
}[]
|
||
}
|
||
|
||
export interface HalInstance {
|
||
connectValidator: (callbacks: ValidatorCallbacks) => void
|
||
enableValidator: () => void
|
||
disableValidator: () => void
|
||
stackBill: () => void
|
||
rejectBill: () => void
|
||
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<DispenseResult>
|
||
getInventory: () => Record<number, number>
|
||
/**
|
||
* Hot-reload the cassette layout: rebuild the inventory map +
|
||
* denomination-index, close + re-init the dispenser with the new
|
||
* cassettes. Used by the operator-config consumer (aiolabs/lamassu-next#56)
|
||
* so a new operator-published cassette config takes effect without
|
||
* restarting the bitspire service.
|
||
*/
|
||
setCassettes: (cassettes: CassetteConfig[]) => Promise<void>
|
||
cleanup: () => Promise<void>
|
||
}
|
||
|
||
/**
|
||
* Initialize HAL hardware in the Electron main process.
|
||
*/
|
||
export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||
const hal = await import('@bitSpire/hal')
|
||
|
||
const { validator: valConfig, dispenser: dispConfig } = config
|
||
|
||
// Create hardware instances
|
||
const dispenser: BillDispenser = hal.createDispenser(dispConfig.type, {
|
||
device: dispConfig.device,
|
||
})
|
||
|
||
// Initialize dispenser. `dispenserInitData` is `let` because
|
||
// `setCassettes` swaps it in to re-init with a new layout (also used by
|
||
// the on-error re-init path at dispenseCash).
|
||
let dispenserInitData = {
|
||
fiatCode: valConfig.fiatCode,
|
||
cassettes: dispConfig.cassettes,
|
||
}
|
||
await dispenser.init(dispenserInitData)
|
||
console.log('[HAL] Dispenser initialized')
|
||
|
||
// Start validator (optional — proceed without if device is missing or fails)
|
||
let validator: BillValidator | null = null
|
||
try {
|
||
const fs = await import('node:fs')
|
||
const device = Array.isArray(valConfig.device) ? valConfig.device[0] : valConfig.device
|
||
if (device && fs.existsSync(device)) {
|
||
validator = hal.createValidator(valConfig.type, {
|
||
rs232: { device: valConfig.device, fiatCode: valConfig.fiatCode },
|
||
fiatCode: valConfig.fiatCode,
|
||
})
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
const timeout = setTimeout(() => {
|
||
reject(new Error('Validator init timed out'))
|
||
}, 15000)
|
||
validator!.run((err?: Error) => {
|
||
clearTimeout(timeout)
|
||
if (err) return reject(err)
|
||
resolve()
|
||
})
|
||
})
|
||
console.log('[HAL] Validator started')
|
||
} else {
|
||
console.log('[HAL] Validator device not found, running dispenser-only')
|
||
}
|
||
} catch (err) {
|
||
console.warn('[HAL] Validator failed to start, running dispenser-only:', err)
|
||
validator = null
|
||
}
|
||
|
||
// 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) => {
|
||
if (!validator) {
|
||
console.log('[HAL] No validator — cash-in disabled')
|
||
return
|
||
}
|
||
validator.on('billsRead', (data: { denomination: number | null; code: number }) => {
|
||
if (data.denomination !== null) {
|
||
const decision = callbacks.shouldAcceptBill(data.denomination)
|
||
if (decision === 'hold') {
|
||
console.log('[HAL] Bill in escrow:', data.denomination)
|
||
callbacks.onBillRead?.(data.denomination)
|
||
} else if (decision) {
|
||
validator.stack()
|
||
callbacks.onBillInserted(data.denomination)
|
||
} else {
|
||
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
|
||
validator.reject()
|
||
callbacks.onBillRejected('insufficient_balance')
|
||
}
|
||
} else {
|
||
console.log('[HAL] Unknown denomination, rejecting. Code: 0x' + data.code.toString(16))
|
||
validator.reject()
|
||
}
|
||
})
|
||
|
||
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
|
||
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||
})
|
||
|
||
validator.on('stackerOpen', () => {
|
||
callbacks.onError('Stacker open')
|
||
})
|
||
|
||
validator.on('error', (err: Error) => {
|
||
callbacks.onError(err.message)
|
||
})
|
||
|
||
validator.on('disconnected', () => {
|
||
callbacks.onError('Validator disconnected')
|
||
})
|
||
},
|
||
|
||
enableValidator: () => {
|
||
validator?.enable()
|
||
validator?.lightOn()
|
||
},
|
||
|
||
disableValidator: () => {
|
||
validator?.disable()
|
||
validator?.lightOff()
|
||
},
|
||
|
||
stackBill: () => validator?.stack(),
|
||
rejectBill: () => validator?.reject(),
|
||
|
||
dispenseCash: async (amounts): Promise<DispenseResult> => {
|
||
console.log('[HAL] Dispensing:', amounts)
|
||
|
||
// Re-initialize dispenser if it was closed after a previous error
|
||
if (!dispenser.initialized) {
|
||
console.log('[HAL] Dispenser not initialized, re-initializing...')
|
||
await dispenser.init(dispenserInitData)
|
||
console.log('[HAL] Dispenser re-initialized')
|
||
}
|
||
|
||
// 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) {
|
||
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,
|
||
dispensed: 0,
|
||
rejected: 0,
|
||
})),
|
||
dispensed: false,
|
||
error: `No cassette loaded with denomination: ${denomination}`,
|
||
}
|
||
}
|
||
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-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,
|
||
}))
|
||
|
||
// 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 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: 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)
|
||
|
||
if (result.error) {
|
||
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
|
||
}
|
||
|
||
// Wait for customer to take bills
|
||
if (totalDispensed > 0) {
|
||
await dispenser.waitForBillsRemoved()
|
||
console.log('[HAL] Bills removed by customer')
|
||
}
|
||
|
||
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
|
||
},
|
||
|
||
/**
|
||
* 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) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`)
|
||
.join(', ')
|
||
)
|
||
// 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
|
||
// consumer) — the renderer can decide whether to retry.
|
||
try {
|
||
dispenser.close()
|
||
} catch (err) {
|
||
console.warn('[HAL] Dispenser close during setCassettes raised:', err)
|
||
}
|
||
await dispenser.init(dispenserInitData)
|
||
console.log('[HAL] Dispenser re-initialized with new cassettes')
|
||
},
|
||
|
||
cleanup: async () => {
|
||
return new Promise<void>((resolve) => {
|
||
validator?.disable()
|
||
validator?.lightOff()
|
||
dispenser.close()
|
||
if (validator) {
|
||
validator.close((err?: Error) => {
|
||
if (err) console.error('[HAL] Validator close error:', err)
|
||
resolve()
|
||
})
|
||
} else {
|
||
resolve()
|
||
}
|
||
})
|
||
},
|
||
}
|
||
}
|