feat(machine): value-confirmed dispense, cash-out hold, fault screens, counts-uncertain on zero-with-error (ADR-005 §3–§5)

HAL glue (electron/hal-service.ts and the renderer-side services/hal.ts):
dispenseConfirmed is Σ(denomination × dispensed) === Σ(denomination ×
requested), computed on value. The driver's tagged error is carried
through as errorCode / rawCode / errorClass / human; pre-dispense
inventory refusals are errorClass 'inventory' so they route to outOfCash
rather than the fault screen. The manual-dispense command result keeps
its wire key `dispensed` (spirekeeper's poller reads it) and gains the
new fields alongside.

Cash-out hold: state-store persists it in meta as one JSON value beside
countsUncertainSince, idempotent on set (the first fault's `since` is
kept); IPC get/set/clear through preload. The store persists the hold the
moment the machine sets it and restores it into the machine on boot. A
recount clears it in the store (same gesture that clears counts-
uncertain); operator-config also honours a new resume_cash_out op — not a
cassette op, split off before applyOperatorCassetteOps, and honoured only
when stamped after the hold began so a re-delivered old resume cannot
clear a fresh fault. Either release calls back into the store, which
sends CASH_OUT_RELEASED. The cassettes-state document carries
cash_out_held_since / _reason / _code (additive, like
counts_uncertain_since); the availability beacon reports cash_out false
while held; the idle Sell button is disabled with the reason.

Store watcher: dispenseFault and outOfCash both record dispense_error /
partial (the customer has paid either way). A report of zero dispensed
WITH a hardware error now sets countsUncertainSince instead of being
trusted as zero — a note stopped in the transport completes neither
counter (sintra 2026-10-09: bay read 66, held 65, one in the transport).

Fault screen: both terminal states show "your payment went through",
amount paid, per-denomination dispensed, the txid as QR and text, the
payment hash (threaded from the settlement watch through PAYMENT_RECEIVED)
and the time, with "keep this reference" and an acknowledge button. The
raw dispenser code is not shown; it travels in the report.
This commit is contained in:
Padreug 2026-10-10 21:25:51 +02:00
commit 888870d01a
12 changed files with 469 additions and 72 deletions

View file

@ -6,7 +6,8 @@
* so it's available at runtime (unlike src/ which is only for Vite).
*/
import type { BillValidator, BillDispenser } from '@bitSpire/hal'
import type { BillValidator, BillDispenser, DispenseErrorClass } from '@bitSpire/hal'
import { isDispenseError } from '@bitSpire/hal'
export interface CassetteConfig {
/**
@ -48,8 +49,20 @@ export interface ValidatorCallbacks {
export interface DispenseResult {
bills: { denomination: number; dispensed: number; rejected: number }[]
dispensed: boolean
/**
* Σ(denomination × dispensed) === Σ(denomination × requested). Computed
* here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes
* the state machine to `complete`.
*/
dispenseConfirmed: boolean
/** Human message when not confirmed */
error?: string
/** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */
errorCode?: string
/** Driver-native code, e.g. '78 42' */
rawCode?: string
/** terminal | recoverable | inventory — see @bitSpire/hal error-codes */
errorClass?: DispenseErrorClass
cassettes?: {
name: string
position: number
@ -277,6 +290,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
notes[i] = (notes[i] ?? 0) + take
remaining -= take
}
// Nothing has been asked of the hardware in either refusal below:
// errorClass 'inventory' routes to outOfCash, not the fault screen.
if (!matched) {
return {
bills: amounts.map((a) => ({
@ -284,8 +299,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0,
rejected: 0,
})),
dispensed: false,
dispenseConfirmed: false,
error: `No cassette loaded with denomination: ${denomination}`,
errorCode: 'NoCassetteForDenomination',
errorClass: 'inventory',
}
}
if (remaining > 0) {
@ -295,8 +312,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0,
rejected: 0,
})),
dispensed: false,
dispenseConfirmed: false,
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`,
errorCode: 'InsufficientInventory',
errorClass: 'inventory',
}
}
}
@ -344,11 +363,44 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
}
const bills = Array.from(billsByDenom.values())
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
// ADR-005 §3: confirmation is VALUE equality — what left the bays is
// worth exactly what was asked — not a count, and not the driver's
// opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination
// × dispensed)`); our previous count-based check was only equivalent
// while every bay dispensed its own denomination.
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
const dispenseConfirmed = requestedValue === dispensedValue
if (result.error) {
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
const e = result.error
const info = isDispenseError(e)
? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human }
: {
// Unreachable by type (drivers always tag), kept as a defensive
// fallback for a driver that slips an untagged Error through.
errorCode: (e as Error).name || 'DispenseError',
rawCode: undefined,
errorClass: 'terminal' as const,
human: (e as Error).message,
}
console.error(
`[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}`
)
// A dispensed value of zero WITH an error is not evidence that nothing
// left the bay — a note stopped in the transport completes neither
// counter (sintra, 2026-10-09). The store reads this combination and
// flags counts unverified; we just report faithfully here.
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: info.human,
errorCode: info.errorCode,
rawCode: info.rawCode,
errorClass: info.errorClass,
}
}
// Wait for customer to take bills
@ -357,7 +409,20 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
console.log('[HAL] Bills removed by customer')
}
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
if (!dispenseConfirmed) {
// Short with no hardware error — the dispenser simply gave less.
console.warn(`[HAL] Dispense short with no error: requested ${requestedValue}, dispensed ${dispensedValue}`)
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
errorCode: 'DispenseShort',
errorClass: 'inventory',
}
}
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
},
/**