bitspire/apps/machine/electron/hal-service.ts
Patrick Mulligan 4d0e42f289 fix(hal): EBDS escrow stack/return latch + return-on-disable
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>
2026-07-30 00:40:06 +02:00

422 lines
15 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
// 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()
}
})
},
}
}