fix(hal-service): don't keep a cassette layout the dispenser refused

setCassettes updated bays and dispenserInitData before re-initialising the
device, deliberately, so that "subsequent dispense calls see the new layout
even if the dispenser re-init is slow / fails". With the re-init failing
every time on douro, that meant the app kept a layout the hardware had
never taken, the operator-config consumer logged "Applied ops", and a
cassettes-state event went out to the operator advertising it. The douro
spent the afternoon reporting bay1:100x60 bay2:200x0 while the device was
still running the boot-time 100x50/200x50. A dispense in that state picks
bays by a layout the device does not share.

Roll the in-memory layout back when the re-init throws, and let the error
propagate as before. Reversing that earlier choice deliberately: a stale
but honest layout beats a fresh but fictional one when the difference is
which cassette pays out.

Also await dispenser.close() here and in cleanup(), now that close()
reports completion.

Closes #118
This commit is contained in:
Padreug 2026-09-29 23:55:48 +02:00
commit 47d3d0b237

View file

@ -376,12 +376,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => { setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => {
console.log( console.log(
'[HAL] Hot-reloading cassette layout:', '[HAL] Hot-reloading cassette layout:',
cassettes cassettes.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`).join(', ')
.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`)
.join(', ')
) )
// Rebuild bays first so subsequent dispense calls see the new layout const previousBays = bays
// even if the dispenser re-init is slow / fails. const previousInitData = dispenserInitData
bays = cassettes bays = cassettes
.slice() .slice()
.sort((a, b) => a.position - b.position) .sort((a, b) => a.position - b.position)
@ -391,31 +389,40 @@ 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 }
// 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
// the new layout. Errors here surface to the caller (operator-config // new layout. close() now resolves only once the port is really closed,
// consumer) — the renderer can decide whether to retry. // so the re-open below cannot race it (aiolabs/bitspire#118).
//
// On failure, roll the in-memory layout back. This reverses an earlier
// deliberate choice to keep the new bays "even if the dispenser re-init
// is slow / fails": with the re-init failing every time on douro, the app
// kept a layout the device had never taken and the operator-config
// consumer went on to publish a cassettes-state event advertising it. A
// subsequent dispense would then pick bays by a layout the hardware does
// not share. Better to surface the failure and stay truthful about what
// the device is actually running.
try { try {
dispenser.close() await dispenser.close()
} catch (err) {
console.warn('[HAL] Dispenser close during setCassettes raised:', err)
}
await dispenser.init(dispenserInitData) await dispenser.init(dispenserInitData)
} catch (err) {
bays = previousBays
dispenserInitData = previousInitData
console.error('[HAL] Dispenser re-init failed; keeping the previous cassette layout:', err)
throw err
}
console.log('[HAL] Dispenser re-initialized with new cassettes') console.log('[HAL] Dispenser re-initialized with new cassettes')
}, },
cleanup: async () => { cleanup: async () => {
return new Promise<void>((resolve) => {
validator?.disable() validator?.disable()
validator?.lightOff() validator?.lightOff()
dispenser.close() await dispenser.close()
if (validator) { if (!validator) return
validator.close((err?: Error) => { await new Promise<void>((resolve) => {
validator?.close((err?: Error) => {
if (err) console.error('[HAL] Validator close error:', err) if (err) console.error('[HAL] Validator close error:', err)
resolve() resolve()
}) })
} else {
resolve()
}
}) })
}, },
} }