diff --git a/packages/hal/src/dispensers/error-codes.ts b/packages/hal/src/dispensers/error-codes.ts new file mode 100644 index 0000000..8da360f --- /dev/null +++ b/packages/hal/src/dispensers/error-codes.ts @@ -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).errorCode === 'string' && + typeof (err as Partial).errorClass === 'string' + ) +} diff --git a/packages/hal/src/dispensers/f56/__tests__/bills.test.ts b/packages/hal/src/dispensers/f56/__tests__/bills.test.ts new file mode 100644 index 0000000..1ed588e --- /dev/null +++ b/packages/hal/src/dispensers/f56/__tests__/bills.test.ts @@ -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) + }) +}) diff --git a/packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts b/packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts new file mode 100644 index 0000000..f7d7375 --- /dev/null +++ b/packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts @@ -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) + } + }) +}) diff --git a/packages/hal/src/dispensers/f56/error-codes.ts b/packages/hal/src/dispensers/f56/error-codes.ts new file mode 100644 index 0000000..2b6af4d --- /dev/null +++ b/packages/hal/src/dispensers/f56/error-codes.ts @@ -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 = { + '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 = { + '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 })), + ] +} diff --git a/packages/hal/src/dispensers/f56/f56-rs232.ts b/packages/hal/src/dispensers/f56/f56-rs232.ts index 24e1d60..397f4e7 100644 --- a/packages/hal/src/dispensers/f56/f56-rs232.ts +++ b/packages/hal/src/dispensers/f56/f56-rs232.ts @@ -134,6 +134,8 @@ export async function initialize(currency: string, denominations: number[]): Pro export interface BillCountResult { bills: Array<{ dispensed: number; rejected: number }> 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 { @@ -172,9 +174,10 @@ export async function billCount(counts: number[]): Promise { if (res[0] === 0xf0) { console.log('response', res) - const errorCode = res.subarray(3, 5) - response.error = new Error(`Dispensing, code: ${prettyHex(errorCode)}`) - console.error(`found error code: ${prettyHex(errorCode)}`) + const rawCode = prettyHex(res.subarray(3, 5)) + response.rawCode = rawCode + response.error = new Error(`Dispensing, code: ${rawCode}`) + console.error(`found error code: ${rawCode}`) } return response diff --git a/packages/hal/src/dispensers/f56/index.ts b/packages/hal/src/dispensers/f56/index.ts index c64882d..52a5c03 100644 --- a/packages/hal/src/dispensers/f56/index.ts +++ b/packages/hal/src/dispensers/f56/index.ts @@ -8,6 +8,8 @@ */ import * as f56 from './f56-rs232.js' +import { decodeF56Error } from './error-codes.js' +import { tagDispenseError, type DispenseError } from '../error-codes.js' import type { BillDispenser, 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 { - const { bills, error } = await f56.billCount(notes) + const { bills, error, rawCode } = await f56.billCount(notes) if (error) { await this.close() - ;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' - ;(error as Error & { statusCode: number }).statusCode = 570 + return { value: bills, error: tagDispenseError(error, decodeF56Error(rawCode)) } } - return { value: bills, error } + return { value: bills } } catch (err) { await this.close() - const error = err as Error - ;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' - ;(error as Error & { statusCode: number }).statusCode = 570 - return { value: [], error } + const error = err instanceof Error ? err : new Error(String(err)) + // No frame: serial timeout / framing. decodeF56Error(undefined) → terminal. + return { value: [], error: tagDispenseError(error, decodeF56Error(undefined)) } } } diff --git a/packages/hal/src/dispensers/puloon/index.ts b/packages/hal/src/dispensers/puloon/index.ts index f065fd4..034c3ce 100644 --- a/packages/hal/src/dispensers/puloon/index.ts +++ b/packages/hal/src/dispensers/puloon/index.ts @@ -14,6 +14,7 @@ import type { DispenserInitData, DispenseResult, } from '../../types.js' +import { tagDispenseError, type DispenseError } from '../error-codes.js' export class PuloonDispenser implements BillDispenser { 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) if (error) { await this.close() - error.name = 'PuloonDispenseError' 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 { diff --git a/packages/hal/src/index.ts b/packages/hal/src/index.ts index dca91f5..46a76ed 100644 --- a/packages/hal/src/index.ts +++ b/packages/hal/src/index.ts @@ -57,5 +57,20 @@ export type { DispenserFactory, } 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 export { compute as computeCrc } from './utils/crc.js' diff --git a/packages/hal/src/types.ts b/packages/hal/src/types.ts index bf0615f..96c21dc 100644 --- a/packages/hal/src/types.ts +++ b/packages/hal/src/types.ts @@ -1,3 +1,5 @@ +import type { DispenseError } from './dispensers/error-codes.js' + import { EventEmitter } from 'node:events' /** @@ -176,7 +178,8 @@ export interface BillDispenser { */ dispense(notes: number[]): Promise<{ value: DispenseResult[] - error?: Error + /** Tagged with the ADR-005 taxonomy — see dispensers/error-codes.ts */ + error?: DispenseError }> /**