bitspire/apps/machine/electron/hal-service.ts
Padreug ea4c1f406b fix(machine): make the dispenser optional, as the validator already was
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.
2026-09-29 18:16:41 +02:00

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