bitspire/apps/machine/electron/hal-service.ts
Padreug 41f9412524 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>
2026-06-01 19:12:11 +02:00

372 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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()
}
})
},
}
}