Cash-in stalled on the batm3: a note reached escrow and was read, but the acceptor never stacked or returned it, and the customer was never credited. Root cause: EBDS carries the stack/return decision as bits in the omnibus *poll* command, but the driver sent stack()/reject() as a single one-shot frame while a free-running 100ms poller kept sending plain polls. The lone stack frame races/collides with the poller (or its ack desyncs), gets dropped, and the device holds the note in escrow indefinitely. - ebds-rs232: latch the escrow decision (`pendingAction`) into the poll command byte and re-assert it on every poll until the device leaves escrow (cleared in _process when `!escrowed`). A dropped frame is now simply retried on the next poll. - hal-service: return an escrowed note on disableValidator() — disable alone does not release it on EBDS, so an inactivity timeout / cancel previously stranded the bill in the transport (observed on the batm3). - atm store: stringify the `[ATM] Sending event` / `[ATM] State` logs — they were printing `[object Object]`, which blinded the cash-in trace. Verified: hal builds, machine app typechecks. Hardware behaviour to be confirmed on the batm3 (no unit tests exist for this serial driver). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
422 lines
15 KiB
TypeScript
422 lines
15 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
|
||
/**
|
||
* 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 }[]
|
||
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,
|
||
}))
|
||
|
||
// 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
|
||
}
|
||
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()
|
||
}
|
||
})
|
||
},
|
||
}
|
||
}
|