diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 2ec863b..5453fea 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -87,19 +87,40 @@ export async function initializeHal(config: HalConfig): Promise { const { validator: valConfig, dispenser: dispConfig } = config - // Create hardware instances - const dispenser: BillDispenser = hal.createDispenser(dispConfig.type, { - device: dispConfig.device, - }) + // 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 - // 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). + // `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) + + 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) @@ -251,6 +272,17 @@ export async function initializeHal(config: HalConfig): Promise { dispenseCash: async (amounts): Promise => { 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...') @@ -391,6 +423,12 @@ export async function initializeHal(config: HalConfig): Promise { 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. @@ -407,7 +445,7 @@ export async function initializeHal(config: HalConfig): Promise { return new Promise((resolve) => { validator?.disable() validator?.lightOff() - dispenser.close() + dispenser?.close() if (validator) { validator.close((err?: Error) => { if (err) console.error('[HAL] Validator close error:', err)