HAL glue (electron/hal-service.ts and the renderer-side services/hal.ts): dispenseConfirmed is Σ(denomination × dispensed) === Σ(denomination × requested), computed on value. The driver's tagged error is carried through as errorCode / rawCode / errorClass / human; pre-dispense inventory refusals are errorClass 'inventory' so they route to outOfCash rather than the fault screen. The manual-dispense command result keeps its wire key `dispensed` (spirekeeper's poller reads it) and gains the new fields alongside. Cash-out hold: state-store persists it in meta as one JSON value beside countsUncertainSince, idempotent on set (the first fault's `since` is kept); IPC get/set/clear through preload. The store persists the hold the moment the machine sets it and restores it into the machine on boot. A recount clears it in the store (same gesture that clears counts- uncertain); operator-config also honours a new resume_cash_out op — not a cassette op, split off before applyOperatorCassetteOps, and honoured only when stamped after the hold began so a re-delivered old resume cannot clear a fresh fault. Either release calls back into the store, which sends CASH_OUT_RELEASED. The cassettes-state document carries cash_out_held_since / _reason / _code (additive, like counts_uncertain_since); the availability beacon reports cash_out false while held; the idle Sell button is disabled with the reason. Store watcher: dispenseFault and outOfCash both record dispense_error / partial (the customer has paid either way). A report of zero dispensed WITH a hardware error now sets countsUncertainSince instead of being trusted as zero — a note stopped in the transport completes neither counter (sintra 2026-10-09: bay read 66, held 65, one in the transport). Fault screen: both terminal states show "your payment went through", amount paid, per-denomination dispensed, the txid as QR and text, the payment hash (threaded from the settlement watch through PAYMENT_RECEIVED) and the time, with "keep this reference" and an acknowledge button. The raw dispenser code is not shown; it travels in the report.
494 lines
18 KiB
TypeScript
494 lines
18 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, DispenseErrorClass } from '@bitSpire/hal'
|
||
import { isDispenseError } 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
|
||
/**
|
||
* Fires on the validator's stacked-confirmation (`billsValid`) — the
|
||
* bill physically reached the stacker. This is the CREDIT event; it is
|
||
* NOT emitted at stack-command time (a stack can still fail/return).
|
||
*/
|
||
onBillInserted: (denomination: number) => void
|
||
onBillRejected: (reason: string) => void
|
||
onError: (error: string) => void
|
||
}
|
||
|
||
export interface DispenseResult {
|
||
bills: { denomination: number; dispensed: number; rejected: number }[]
|
||
/**
|
||
* Σ(denomination × dispensed) === Σ(denomination × requested). Computed
|
||
* here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes
|
||
* the state machine to `complete`.
|
||
*/
|
||
dispenseConfirmed: boolean
|
||
/** Human message when not confirmed */
|
||
error?: string
|
||
/** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */
|
||
errorCode?: string
|
||
/** Driver-native code, e.g. '78 42' */
|
||
rawCode?: string
|
||
/** terminal | recoverable | inventory — see @bitSpire/hal error-codes */
|
||
errorClass?: DispenseErrorClass
|
||
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,
|
||
}))
|
||
|
||
// Escrow / in-flight bookkeeping (legacy brain.js `billsRead` interlock):
|
||
// `escrowDenomination` = bill held in escrow awaiting a stack/reject
|
||
// decision; `inFlightDenomination` = stack commanded, awaiting the
|
||
// validator's `billsValid` stacked-confirmation. onBillInserted (the
|
||
// credit event) fires only on that confirmation.
|
||
let escrowDenomination: number | null = null
|
||
let inFlightDenomination: number | null = null
|
||
|
||
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)
|
||
escrowDenomination = data.denomination
|
||
callbacks.onBillRead?.(data.denomination)
|
||
} else if (decision) {
|
||
inFlightDenomination = data.denomination
|
||
validator.stack()
|
||
} 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()
|
||
}
|
||
})
|
||
|
||
// Stacked-confirmation → the credit event.
|
||
validator.on('billsValid', () => {
|
||
if (inFlightDenomination === null) {
|
||
console.warn('[HAL] billsValid with no bill in flight — ignoring')
|
||
return
|
||
}
|
||
const denomination = inFlightDenomination
|
||
inFlightDenomination = null
|
||
console.log('[HAL] Bill stacked (confirmed):', denomination)
|
||
callbacks.onBillInserted(denomination)
|
||
})
|
||
|
||
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
|
||
// Covers both an escrow refusal and a failed/returned stack —
|
||
// either way nothing was credited and nothing is in flight.
|
||
escrowDenomination = null
|
||
inFlightDenomination = 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: () => {
|
||
// If a note is sitting in escrow when we disable (inactivity timeout,
|
||
// cancel, or leaving the insert screen), return it to the customer.
|
||
// Disabling alone does NOT release an escrowed note on EBDS — it would
|
||
// be stranded in the transport until the next power cycle.
|
||
if (escrowDenomination !== null) {
|
||
console.log('[HAL] Returning escrowed bill on disable:', escrowDenomination)
|
||
escrowDenomination = null
|
||
validator?.reject()
|
||
}
|
||
validator?.disable()
|
||
validator?.lightOff()
|
||
},
|
||
|
||
stackBill: () => {
|
||
if (escrowDenomination === null) {
|
||
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
|
||
return
|
||
}
|
||
inFlightDenomination = escrowDenomination
|
||
escrowDenomination = null
|
||
validator?.stack()
|
||
},
|
||
rejectBill: () => {
|
||
escrowDenomination = null
|
||
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
|
||
}
|
||
// Nothing has been asked of the hardware in either refusal below:
|
||
// errorClass 'inventory' routes to outOfCash, not the fault screen.
|
||
if (!matched) {
|
||
return {
|
||
bills: amounts.map((a) => ({
|
||
denomination: a.denomination,
|
||
dispensed: 0,
|
||
rejected: 0,
|
||
})),
|
||
dispenseConfirmed: false,
|
||
error: `No cassette loaded with denomination: ${denomination}`,
|
||
errorCode: 'NoCassetteForDenomination',
|
||
errorClass: 'inventory',
|
||
}
|
||
}
|
||
if (remaining > 0) {
|
||
return {
|
||
bills: amounts.map((a) => ({
|
||
denomination: a.denomination,
|
||
dispensed: 0,
|
||
rejected: 0,
|
||
})),
|
||
dispenseConfirmed: false,
|
||
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`,
|
||
errorCode: 'InsufficientInventory',
|
||
errorClass: 'inventory',
|
||
}
|
||
}
|
||
}
|
||
|
||
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())
|
||
|
||
// ADR-005 §3: confirmation is VALUE equality — what left the bays is
|
||
// worth exactly what was asked — not a count, and not the driver's
|
||
// opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination
|
||
// × dispensed)`); our previous count-based check was only equivalent
|
||
// while every bay dispensed its own denomination.
|
||
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
|
||
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
|
||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||
const dispenseConfirmed = requestedValue === dispensedValue
|
||
|
||
if (result.error) {
|
||
const e = result.error
|
||
const info = isDispenseError(e)
|
||
? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human }
|
||
: {
|
||
// Unreachable by type (drivers always tag), kept as a defensive
|
||
// fallback for a driver that slips an untagged Error through.
|
||
errorCode: (e as Error).name || 'DispenseError',
|
||
rawCode: undefined,
|
||
errorClass: 'terminal' as const,
|
||
human: (e as Error).message,
|
||
}
|
||
console.error(
|
||
`[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}`
|
||
)
|
||
// A dispensed value of zero WITH an error is not evidence that nothing
|
||
// left the bay — a note stopped in the transport completes neither
|
||
// counter (sintra, 2026-10-09). The store reads this combination and
|
||
// flags counts unverified; we just report faithfully here.
|
||
return {
|
||
bills,
|
||
cassettes: cassetteResults,
|
||
dispenseConfirmed: false,
|
||
error: info.human,
|
||
errorCode: info.errorCode,
|
||
rawCode: info.rawCode,
|
||
errorClass: info.errorClass,
|
||
}
|
||
}
|
||
|
||
// Wait for customer to take bills
|
||
if (totalDispensed > 0) {
|
||
await dispenser.waitForBillsRemoved()
|
||
console.log('[HAL] Bills removed by customer')
|
||
}
|
||
|
||
if (!dispenseConfirmed) {
|
||
// Short with no hardware error — the dispenser simply gave less.
|
||
console.warn(`[HAL] Dispense short with no error: requested ${requestedValue}, dispensed ${dispensedValue}`)
|
||
return {
|
||
bills,
|
||
cassettes: cassetteResults,
|
||
dispenseConfirmed: false,
|
||
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
|
||
errorCode: 'DispenseShort',
|
||
errorClass: 'inventory',
|
||
}
|
||
}
|
||
|
||
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
|
||
},
|
||
|
||
/**
|
||
* 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(', ')
|
||
)
|
||
const previousBays = bays
|
||
const previousInitData = dispenserInitData
|
||
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. close() now resolves only once the port is really closed,
|
||
// so the re-open below cannot race it (aiolabs/bitspire#118).
|
||
//
|
||
// On failure, roll the in-memory layout back. This reverses an earlier
|
||
// deliberate choice to keep the new bays "even if the dispenser re-init
|
||
// is slow / fails": with the re-init failing every time on douro, the app
|
||
// kept a layout the device had never taken and the operator-config
|
||
// consumer went on to publish a cassettes-state event advertising it. A
|
||
// subsequent dispense would then pick bays by a layout the hardware does
|
||
// not share. Better to surface the failure and stay truthful about what
|
||
// the device is actually running.
|
||
try {
|
||
await dispenser.close()
|
||
await dispenser.init(dispenserInitData)
|
||
} catch (err) {
|
||
bays = previousBays
|
||
dispenserInitData = previousInitData
|
||
console.error('[HAL] Dispenser re-init failed; keeping the previous cassette layout:', err)
|
||
throw err
|
||
}
|
||
console.log('[HAL] Dispenser re-initialized with new cassettes')
|
||
},
|
||
|
||
cleanup: async () => {
|
||
validator?.disable()
|
||
validator?.lightOff()
|
||
await dispenser.close()
|
||
if (!validator) return
|
||
await new Promise<void>((resolve) => {
|
||
validator?.close((err?: Error) => {
|
||
if (err) console.error('[HAL] Validator close error:', err)
|
||
resolve()
|
||
})
|
||
})
|
||
},
|
||
}
|
||
}
|