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:
parent
3ff86de4ed
commit
888870d01a
12 changed files with 469 additions and 72 deletions
|
|
@ -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 }
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue