initializeHal created and initialised the dispenser unconditionally, so a
missing dispenser device threw and aborted the WHOLE of HAL init — taking
the validator down with it, even when the validator was present and
working.
The Pi bring-up hit exactly that. With a Pyramid Apex correctly wired and
enumerated on /dev/ttyValidator0:
[ATM] Validator device: /dev/ttyValidator0
[ATM] Dispenser device: /dev/ttyDispenser-not-fitted
[Electron] HAL init failed: cannot open /dev/ttyDispenser-not-fitted
[Recovery] Reloading renderer to re-attempt initialization
and round again, forever, with a perfectly good acceptor attached.
The validator has been optional since it was written — it checks the
device exists, catches init failures, and logs "running dispenser-only".
The dispenser had no equivalent. That asymmetry was the bug, not the
placeholder device path that exposed it: a cash-in-only machine is a
legitimate configuration, and the Raspberry Pi reference build is one.
Mirrors the validator's handling exactly: existence check, try/catch,
null on failure, and a log line saying what the machine will do instead
("running cash-in only"). Three call sites then need guarding —
dispenseCash returns a clear "No dispenser fitted on this machine —
cash-out unavailable" rather than dereferencing null, setCassettes still
records the layout but skips the re-init, and cleanup uses an optional
call.
This also removes the sharp edge from the rpi4/rpi5 presets added in the
previous commit. Their dispenser block points at a path that does not
exist because DispenseType has no 'none' variant and DeviceConfig
requires the field. That is still worth fixing properly with a real
'none' variant, but the machine no longer has to care.
460 lines
16 KiB
TypeScript
460 lines
16 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
|
||
|
||
// Start dispenser (optional — mirrors the validator handling below).
|
||
//
|
||
// A cash-in-only machine is a legitimate configuration: the Raspberry Pi
|
||
// reference build has a bill acceptor and no dispenser at all. This used to
|
||
// create and init the dispenser unconditionally, so a missing device threw
|
||
// and aborted the WHOLE of initializeHal — taking the validator with it,
|
||
// even though the validator was present and working. The Pi bring-up hit
|
||
// exactly that: "cannot open /dev/ttyDispenser-not-fitted", then an endless
|
||
// renderer-reload loop, with a perfectly good acceptor on ttyValidator0.
|
||
//
|
||
// The validator has been optional since it was written; the asymmetry was
|
||
// the bug.
|
||
let dispenser: BillDispenser | null = null
|
||
|
||
// `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,
|
||
}
|
||
|
||
try {
|
||
const fs = await import('node:fs')
|
||
if (dispConfig.device && fs.existsSync(dispConfig.device)) {
|
||
dispenser = hal.createDispenser(dispConfig.type, { device: dispConfig.device })
|
||
await dispenser.init(dispenserInitData)
|
||
console.log('[HAL] Dispenser started')
|
||
} else {
|
||
console.log('[HAL] Dispenser device not found, running cash-in only')
|
||
}
|
||
} catch (err) {
|
||
console.warn('[HAL] Dispenser failed to start, running cash-in only:', err)
|
||
dispenser = null
|
||
}
|
||
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)
|
||
|
||
// Cash-in-only machine: refuse the ask rather than throwing a null
|
||
// dereference into the renderer's dispense path.
|
||
if (!dispenser) {
|
||
return {
|
||
bills: [],
|
||
cassettes: [],
|
||
dispensed: false,
|
||
error: 'No dispenser fitted on this machine — cash-out unavailable',
|
||
}
|
||
}
|
||
|
||
// 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 }
|
||
// Without a dispenser the layout is still worth recording (the operator
|
||
// config consumer keeps calling this), but there is nothing to re-init.
|
||
if (!dispenser) {
|
||
console.log('[HAL] Cassettes recorded; no dispenser fitted, nothing to re-init')
|
||
return
|
||
}
|
||
// 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()
|
||
}
|
||
})
|
||
},
|
||
}
|
||
}
|