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.
This commit is contained in:
parent
cbff654856
commit
c70d43523c
9 changed files with 354 additions and 16 deletions
60
packages/hal/src/dispensers/error-codes.ts
Normal file
60
packages/hal/src/dispensers/error-codes.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
64
packages/hal/src/dispensers/f56/__tests__/bills.test.ts
Normal file
64
packages/hal/src/dispensers/f56/__tests__/bills.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
109
packages/hal/src/dispensers/f56/error-codes.ts
Normal file
109
packages/hal/src/dispensers/f56/error-codes.ts
Normal 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 })),
|
||||
]
|
||||
}
|
||||
|
|
@ -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<BillCountResult> {
|
||||
|
|
@ -172,9 +174,10 @@ export async function billCount(counts: number[]): Promise<BillCountResult> {
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}>
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue