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.
This commit is contained in:
Padreug 2026-09-29 18:16:41 +02:00
commit ea4c1f406b

View file

@ -87,19 +87,40 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
const { validator: valConfig, dispenser: dispConfig } = config const { validator: valConfig, dispenser: dispConfig } = config
// Create hardware instances // Start dispenser (optional — mirrors the validator handling below).
const dispenser: BillDispenser = hal.createDispenser(dispConfig.type, { //
device: dispConfig.device, // 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 // `dispenserInitData` is `let` because `setCassettes` swaps it in to re-init
// `setCassettes` swaps it in to re-init with a new layout (also used by // with a new layout (also used by the on-error re-init path at dispenseCash).
// the on-error re-init path at dispenseCash).
let dispenserInitData = { let dispenserInitData = {
fiatCode: valConfig.fiatCode, fiatCode: valConfig.fiatCode,
cassettes: dispConfig.cassettes, 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) 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') console.log('[HAL] Dispenser initialized')
// Start validator (optional — proceed without if device is missing or fails) // Start validator (optional — proceed without if device is missing or fails)
@ -251,6 +272,17 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispenseCash: async (amounts): Promise<DispenseResult> => { dispenseCash: async (amounts): Promise<DispenseResult> => {
console.log('[HAL] Dispensing:', amounts) 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 // Re-initialize dispenser if it was closed after a previous error
if (!dispenser.initialized) { if (!dispenser.initialized) {
console.log('[HAL] Dispenser not initialized, re-initializing...') console.log('[HAL] Dispenser not initialized, re-initializing...')
@ -391,6 +423,12 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
count: c.count ?? 0, count: c.count ?? 0,
})) }))
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes } 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 // Close + re-init the dispenser so its internal per-bay state matches
// the new layout. Errors here surface to the caller (operator-config // the new layout. Errors here surface to the caller (operator-config
// consumer) — the renderer can decide whether to retry. // consumer) — the renderer can decide whether to retry.
@ -407,7 +445,7 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
validator?.disable() validator?.disable()
validator?.lightOff() validator?.lightOff()
dispenser.close() dispenser?.close()
if (validator) { if (validator) {
validator.close((err?: Error) => { validator.close((err?: Error) => {
if (err) console.error('[HAL] Validator close error:', err) if (err) console.error('[HAL] Validator close error:', err)