From ea4c1f406bc012db8334603c448270b4f5620da7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 18:16:41 +0200 Subject: [PATCH] fix(machine): make the dispenser optional, as the validator already was MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/machine/electron/hal-service.ts | 56 +++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 9 deletions(-) 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)