ADR-005 rollout step 1: value-confirmed dispense, fault screens, cash-out latch, dispense-report outbox #123

Merged
padreug merged 6 commits from feat/dispense-outcome into dev 2026-10-10 19:54:56 +00:00
9 changed files with 354 additions and 16 deletions
Showing only changes of commit c70d43523c - Show all commits

feat(hal): dispense error taxonomy, F56 decode table, and the first HAL tests (ADR-005 §7)

Every dispenser now returns a tagged DispenseError: errorCode (the
family name, e.g. F56DispenseError), rawCode (driver-native, '78 42'),
errorClass (terminal | recoverable | inventory) and a human decode. The
class is what the state machine routes on: terminal latches cash-out off,
recoverable shows the fault screen but stays in service, inventory means
nothing was asked of the hardware.

The F56 table is built empirically and from the Fujitsu F56-BDU Error
Code List, seeded with sintra's 78 42 (note stopped at the cassette exit,
terminal) and the Tejo's 82 00 (long-bill reject, recoverable), plus the
83/84/86 00 checks and the 85 0n / B5 .. families. An unknown code fails
SAFE — terminal — so an unfamiliar fault latches rather than letting the
next customer pay into it. f56-rs232 surfaces the raw code structurally
instead of only inside the message string.

Drops the borrowed statusCode 570 from the F56 driver: lamassu-server
read 570 as "insufficient funds", so a jam told operators to refill full
cassettes (their 34ba9203 fix). Puloon gets the same contract with every
fault terminal until it has a decode table.

packages/hal had no tests at all (ADR-005 finding 10). Adds the first
two: the decode table, and the bill-length table — every window [hi, lo]
sane, and GTQ/USD(/HNL when added) sharing one window for what is
physically the same 156 mm note. That second test would have caught the
GTQ fault months ago.
Padreug 2026-10-10 21:25:51 +02:00

View file

@ -0,0 +1,60 @@
/**
* Dispense error taxonomy shared by every dispenser driver (ADR-005 §7).
*
* Three fields travel with a failed dispense, mirroring the shape
* lamassu-server kept on cash_out_txs — `error` (human), `error_code`
* (the error's NAME), plus the boolean the caller computes on value:
*
* errorCode the family, e.g. 'F56DispenseError' — stable, machine-readable
* rawCode the driver-native code, e.g. '78 42' — for the decode table
* errorClass how the caller should route it (below)
* human what an operator will find when they open the machine
*
* errorClass decides what the state machine does next:
*
* terminal the transport path is compromised (jam, motor, diverter,
* sensor, comm timeout). Retrying from another bay would jam
* too, and re-initialising does not move a stuck note. The
* machine latches cash-out off until an operator clears it.
* recoverable this bay or this note (pick failure, length/thickness
* reject, bill-end). The customer-facing fault screen still
* shows — they have paid — but the machine stays in service.
* inventory nothing was asked of the hardware: the request could not be
* met from the bays. Not a fault; routes to the out-of-cash
* screen, and nothing was charged beyond what was dispensed.
*
* No code here is borrowed from another layer's vocabulary. lamassu tagged
* every F56 fault with statusCode 570, which its server read as "insufficient
* funds" — a jam told the operator to refill a cassette that was not empty.
*/
export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory'
export interface DispenseErrorInfo {
errorCode: string
rawCode?: string
errorClass: DispenseErrorClass
human: string
}
/** An Error carrying the taxonomy. Drivers return these from `dispense()`. */
export interface DispenseError extends Error, DispenseErrorInfo {}
/** Stamp the taxonomy onto an existing Error without losing its stack. */
export function tagDispenseError(error: Error, info: DispenseErrorInfo): DispenseError {
const tagged = error as DispenseError
tagged.name = info.errorCode
tagged.errorCode = info.errorCode
tagged.rawCode = info.rawCode
tagged.errorClass = info.errorClass
tagged.human = info.human
return tagged
}
export function isDispenseError(err: unknown): err is DispenseError {
return (
err instanceof Error &&
typeof (err as Partial<DispenseError>).errorCode === 'string' &&
typeof (err as Partial<DispenseError>).errorClass === 'string'
)
}

View file

@ -0,0 +1,64 @@
/**
* The F56 bill table is a list of [hi, lo] accept windows in millimetres per
* denomination, sent to the BDU at initialise. It was carried byte for byte
* from lamassu with nothing over it, and a wrong GTQ window produced a
* production fault (5/5 notes rejected, error 82 00) for weeks. These tests
* are the "no value table without a test" rule from ADR-005 finding 10.
*/
import { describe, it, expect } from 'vitest'
import { bills } from '../bills.js'
/**
* Every quetzal, dollar and lempira note is 156 × 67 mm. HNL is in lamassu's
* 37-currency table but not (yet) in ours — include it only if present so the
* invariant holds the day it is added.
*/
const SAME_PHYSICAL_NOTE = (['USD', 'GTQ', 'HNL'] as const).filter((c) => c in bills)
describe('F56 bill table', () => {
const currencies = Object.keys(bills)
it('has entries', () => {
expect(currencies.length).toBeGreaterThan(0)
})
it.each(currencies)('%s: every window is [hi, lo] with hi > lo and a plausible note length', (cur) => {
const data = bills[cur]!
expect(typeof data.thickness).toBe('number')
expect(typeof data.polymer).toBe('boolean')
for (const [denom, window] of Object.entries(data.lengths)) {
const [hi, lo] = window
expect(hi, `${cur} ${denom} hi`).toBeGreaterThan(lo)
// Real banknotes are roughly 110–180 mm long; a window outside that
// means a typo, not a note.
expect(lo, `${cur} ${denom} lo`).toBeGreaterThanOrEqual(100)
expect(hi, `${cur} ${denom} hi`).toBeLessThanOrEqual(190)
// A window narrower than ±5 rejects real notes on sensor noise; wider
// than ±15 stops catching offset double-picks.
expect(hi - lo, `${cur} ${denom} width`).toBeGreaterThanOrEqual(10)
expect(hi - lo, `${cur} ${denom} width`).toBeLessThanOrEqual(30)
}
})
it('GTQ, USD and HNL — physically the same 156 mm note — share one window', () => {
const windows = SAME_PHYSICAL_NOTE.map((cur) => {
const lengths = bills[cur]!.lengths
const all = Object.values(lengths).map(([hi, lo]) => `${hi}-${lo}`)
return { cur, distinct: [...new Set(all)] }
})
for (const w of windows) {
expect(w.distinct, `${w.cur} has one window for all denominations`).toHaveLength(1)
}
const usd = windows.find((w) => w.cur === 'USD')!.distinct[0]
for (const w of windows) {
expect(w.distinct[0], `${w.cur} matches USD (lamassu b1cc3622)`).toBe(usd)
}
})
it('the 156 mm window is 146–166 (±10)', () => {
const [hi, lo] = bills.USD!.lengths[20]!
expect(hi).toBe(166)
expect(lo).toBe(146)
expect((hi + lo) / 2).toBe(156)
})
})

View file

@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { decodeF56Error, listF56ErrorCodes, normaliseF56Code, F56_ERROR_CODE } from '../error-codes.js'
describe('F56 error-code decode (ADR-005 §7)', () => {
it("decodes sintra's exit jam as terminal", () => {
const info = decodeF56Error('78 42')
expect(info.errorCode).toBe(F56_ERROR_CODE)
expect(info.rawCode).toBe('78 42')
expect(info.errorClass).toBe('terminal')
expect(info.human).toMatch(/cassette exit/i)
})
it("decodes the Tejo's long-bill reject as recoverable", () => {
const info = decodeF56Error('82 00')
expect(info.errorClass).toBe('recoverable')
expect(info.human).toMatch(/length/i)
})
it('decodes parameterised families by first byte', () => {
expect(decodeF56Error('85 03').errorClass).toBe('recoverable')
expect(decodeF56Error('85 03').human).toMatch(/another safe/i)
expect(decodeF56Error('B5 01').errorClass).toBe('terminal')
expect(decodeF56Error('b5 7f').errorClass).toBe('terminal')
})
it('fails SAFE on an unknown code: terminal, named, code preserved', () => {
const info = decodeF56Error('99 99')
expect(info.errorCode).toBe(F56_ERROR_CODE)
expect(info.errorClass).toBe('terminal')
expect(info.rawCode).toBe('99 99')
expect(info.human).toContain('99 99')
})
it('treats a missing frame (serial timeout) as terminal', () => {
const info = decodeF56Error(undefined)
expect(info.errorClass).toBe('terminal')
expect(info.rawCode).toBeUndefined()
})
it('normalises spelling variants to "XX YY"', () => {
expect(normaliseF56Code('7842')).toBe('78 42')
expect(normaliseF56Code('78-42')).toBe('78 42')
expect(normaliseF56Code('78 42')).toBe('78 42')
expect(normaliseF56Code('b5 01')).toBe('B5 01')
expect(decodeF56Error('7842')).toEqual(decodeF56Error('78 42'))
})
it('never borrows another layer\'s code (the lamassu 570 lesson)', () => {
for (const row of listF56ErrorCodes()) {
expect(row).not.toHaveProperty('statusCode')
}
expect(decodeF56Error('78 42')).not.toHaveProperty('statusCode')
})
it('lists every known code for the operator glossary', () => {
const codes = listF56ErrorCodes().map((r) => r.code)
expect(codes).toContain('78 42')
expect(codes).toContain('82 00')
expect(codes).toContain('85 ..')
expect(codes).toContain('B5 ..')
for (const row of listF56ErrorCodes()) {
expect(['terminal', 'recoverable', 'inventory']).toContain(row.errorClass)
expect(row.human.length).toBeGreaterThan(10)
}
})
})

View file

@ -0,0 +1,109 @@
/**
* Fujitsu F53/F56 BDU error-code decode table (ADR-005 §7).
*
* The BDU answers a failed bill-count with an 0xF0 frame whose bytes 3–4 are
* the error code; `f56-rs232.billCount` surfaces them as `rawCode` in the
* form prettyHex produces, e.g. '78 42'. Neither lamassu codebase ever
* decoded these — both collapsed every fault into one opaque string.
*
* Built empirically and from the Fujitsu Frontech F56-BDU Error Code List
* (K3KD03234–K3KD03236-0001, ed. E02). Entries are keyed by the full two-byte
* code, or by the first byte for families where the second byte is a
* parameter (`85 0n` = pick from another safe n; `B5 ..` = reject-box
* overflow). An unknown code fails SAFE: terminal, so an unfamiliar fault
* latches cash-out off rather than letting the next customer pay into it.
*
* Add a row when a code occurs. Each row's `observed` is the first machine
* and date we saw it, so the table doubles as the incident log.
*/
import type { DispenseErrorClass, DispenseErrorInfo } from '../error-codes.js'
export const F56_ERROR_CODE = 'F56DispenseError'
interface F56ErrorEntry {
errorClass: DispenseErrorClass
human: string
/** First observed — machine, date. Empty for spec-only entries. */
observed?: string
}
/** Exact two-byte codes. */
const EXACT: Record<string, F56ErrorEntry> = {
'78 42': {
errorClass: 'terminal',
human: 'Note stopped at the cassette exit — open the unit and clear the transport path',
observed: 'sintra, 2026-10-09',
},
'82 00': {
errorClass: 'recoverable',
human: 'Bill length check failed (long) — note read longer than the configured window',
observed: 'tejo (GTQ), 2026-09-26',
},
'83 00': {
errorClass: 'recoverable',
human: 'Bill length check failed (short)',
},
'84 00': {
errorClass: 'recoverable',
human: 'Bill thickness check failed — possible double pick or damaged note',
},
'86 00': {
errorClass: 'recoverable',
human: 'Bill spacing error — notes too close together on the transport',
},
}
/** Families keyed by the first byte; the second byte is a parameter. */
const FAMILY: Record<string, F56ErrorEntry> = {
'85': {
errorClass: 'recoverable',
human: 'Pick from another safe — the note came from a different cassette than commanded',
},
B5: {
errorClass: 'terminal',
human: 'Reject box overflow — empty the reject tray',
},
}
/** Normalise '78 42', '7842', '78-42', lowercase, etc. to 'XX YY'. */
export function normaliseF56Code(raw: string): string {
const hex = raw.replace(/[^0-9a-fA-F]/g, '').toUpperCase()
if (hex.length !== 4) return raw.trim().toUpperCase()
return `${hex.slice(0, 2)} ${hex.slice(2, 4)}`
}
export function decodeF56Error(rawCode: string | undefined): DispenseErrorInfo {
if (!rawCode) {
// No frame came back at all — serial timeout, port closed, framing error.
// The transport is in an unknown state; treat as terminal.
return {
errorCode: F56_ERROR_CODE,
errorClass: 'terminal',
human: 'Dispenser did not answer — serial timeout or framing error',
}
}
const code = normaliseF56Code(rawCode)
const exact = EXACT[code]
if (exact) {
return { errorCode: F56_ERROR_CODE, rawCode: code, errorClass: exact.errorClass, human: exact.human }
}
const family = FAMILY[code.slice(0, 2)]
if (family) {
return { errorCode: F56_ERROR_CODE, rawCode: code, errorClass: family.errorClass, human: family.human }
}
return {
errorCode: F56_ERROR_CODE,
rawCode: code,
errorClass: 'terminal',
human: `Unrecognised dispenser error ${code} — treat as a jam until decoded`,
}
}
/** For the operator glossary: every known code with its class and meaning. */
export function listF56ErrorCodes(): Array<{ code: string; errorClass: DispenseErrorClass; human: string; observed?: string }> {
return [
...Object.entries(EXACT).map(([code, e]) => ({ code, ...e })),
...Object.entries(FAMILY).map(([code, e]) => ({ code: `${code} ..`, ...e })),
]
}

View file

@ -134,6 +134,8 @@ export async function initialize(currency: string, denominations: number[]): Pro
export interface BillCountResult { export interface BillCountResult {
bills: Array<{ dispensed: number; rejected: number }> bills: Array<{ dispensed: number; rejected: number }>
error?: Error error?: Error
/** BDU error code from bytes 3–4 of an 0xF0 frame, e.g. '78 42'. */
rawCode?: string
} }
export async function billCount(counts: number[]): Promise<BillCountResult> { export async function billCount(counts: number[]): Promise<BillCountResult> {
@ -172,9 +174,10 @@ export async function billCount(counts: number[]): Promise<BillCountResult> {
if (res[0] === 0xf0) { if (res[0] === 0xf0) {
console.log('response', res) console.log('response', res)
const errorCode = res.subarray(3, 5) const rawCode = prettyHex(res.subarray(3, 5))
response.error = new Error(`Dispensing, code: ${prettyHex(errorCode)}`) response.rawCode = rawCode
console.error(`found error code: ${prettyHex(errorCode)}`) response.error = new Error(`Dispensing, code: ${rawCode}`)
console.error(`found error code: ${rawCode}`)
} }
return response return response

View file

@ -8,6 +8,8 @@
*/ */
import * as f56 from './f56-rs232.js' import * as f56 from './f56-rs232.js'
import { decodeF56Error } from './error-codes.js'
import { tagDispenseError, type DispenseError } from '../error-codes.js'
import type { import type {
BillDispenser, BillDispenser,
DispenserConfig, DispenserConfig,
@ -51,23 +53,28 @@ export class F56Dispenser implements BillDispenser {
} }
} }
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> { /**
* On any failure the port is closed so the next attempt re-initialises;
* the error is tagged with the ADR-005 taxonomy (class + decoded meaning)
* so the caller can route it. No statusCode: lamassu's 570 meant
* "insufficient funds" to its server and sent operators to refill full
* cassettes after jams.
*/
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> {
try { try {
const { bills, error } = await f56.billCount(notes) const { bills, error, rawCode } = await f56.billCount(notes)
if (error) { if (error) {
await this.close() await this.close()
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' return { value: bills, error: tagDispenseError(error, decodeF56Error(rawCode)) }
;(error as Error & { statusCode: number }).statusCode = 570
} }
return { value: bills, error } return { value: bills }
} catch (err) { } catch (err) {
await this.close() await this.close()
const error = err as Error const error = err instanceof Error ? err : new Error(String(err))
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' // No frame: serial timeout / framing. decodeF56Error(undefined) → terminal.
;(error as Error & { statusCode: number }).statusCode = 570 return { value: [], error: tagDispenseError(error, decodeF56Error(undefined)) }
return { value: [], error }
} }
} }

View file

@ -14,6 +14,7 @@ import type {
DispenserInitData, DispenserInitData,
DispenseResult, DispenseResult,
} from '../../types.js' } from '../../types.js'
import { tagDispenseError, type DispenseError } from '../error-codes.js'
export class PuloonDispenser implements BillDispenser { export class PuloonDispenser implements BillDispenser {
public type: string = 'Puloon' public type: string = 'Puloon'
@ -46,16 +47,26 @@ export class PuloonDispenser implements BillDispenser {
} }
} }
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> { async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> {
const { bills, error } = await this.device.dispense(notes) const { bills, error } = await this.device.dispense(notes)
if (error) { if (error) {
await this.close() await this.close()
error.name = 'PuloonDispenseError'
console.log('PULOON | dispense error', error) console.log('PULOON | dispense error', error)
// No decode table for the LCDM yet: every fault is terminal until one
// exists, so an unknown Puloon error latches cash-out off (ADR-005 §7).
return {
value: bills,
error: tagDispenseError(error, {
errorCode: 'PuloonDispenseError',
rawCode: (error as Error & { code?: string }).code,
errorClass: 'terminal',
human: `Puloon dispense error: ${error.message}`,
}),
}
} }
return { value: bills, error } return { value: bills }
} }
async close(): Promise<void> { async close(): Promise<void> {

View file

@ -57,5 +57,20 @@ export type {
DispenserFactory, DispenserFactory,
} from './types.js' } from './types.js'
// Dispense error taxonomy (ADR-005 §7)
export {
tagDispenseError,
isDispenseError,
type DispenseError,
type DispenseErrorClass,
type DispenseErrorInfo,
} from './dispensers/error-codes.js'
export {
decodeF56Error,
listF56ErrorCodes,
normaliseF56Code,
F56_ERROR_CODE,
} from './dispensers/f56/error-codes.js'
// Utilities // Utilities
export { compute as computeCrc } from './utils/crc.js' export { compute as computeCrc } from './utils/crc.js'

View file

@ -1,3 +1,5 @@
import type { DispenseError } from './dispensers/error-codes.js'
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
/** /**
@ -176,7 +178,8 @@ export interface BillDispenser {
*/ */
dispense(notes: number[]): Promise<{ dispense(notes: number[]): Promise<{
value: DispenseResult[] value: DispenseResult[]
error?: Error /** Tagged with the ADR-005 taxonomy — see dispensers/error-codes.ts */
error?: DispenseError
}> }>
/** /**