From 16d235079f6d0a43325cfc7a65ce24492713bbd1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 16 Aug 2026 21:22:47 +0200 Subject: [PATCH] feat(hal): add Pyramid Apex RS-232 bill-validator driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `apex` validator for Pyramid Technologies Apex-series acceptors (Apex 5000/7000/7600) on their RS-232 interface, for the Raspberry Pi 5 build. Implemented from Pyramid's PUBLIC protocol facts (RS-232 Serial Interface Specification + their published integrator samples) — not ported from lamassu-machine or any licensed source, so it stays inside this repo's provenance boundary and AGPL. - apex-rs232.ts: 8-byte poll frame (STX/len/ctrl+ACK-toggle/enable/cmd/ rsvd/ETX/XOR-checksum), reply parsing (state+event+credit bytes), escrow stack/return latching re-asserted until the note leaves escrow. - apex-fsm.ts: status tracker → BillValidator events (mirrors the EBDS tracker; dedupes continuous polling; escrow-latency watchdog). - denominations.ts: per-fiat channel→value table (channel order must match the acceptor's programmed dataset — verify on the unit). - index.ts: ApexValidator implementing BillValidator; wired into the createValidator factory as ValidatorType 'apex'. - 13 unit tests for checksum, frame build, status priority, denom map. Bench-verify on real hardware before trusting: the reply checksum range (parsed leniently for now) and return-by-disable escrow behaviour are flagged in-code as needing confirmation on the 7600. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW --- .../apex/__tests__/apex-rs232.test.ts | 146 +++++++ packages/hal/src/validators/apex/apex-fsm.ts | 105 +++++ .../hal/src/validators/apex/apex-rs232.ts | 363 ++++++++++++++++++ .../hal/src/validators/apex/denominations.ts | 30 ++ packages/hal/src/validators/apex/index.ts | 155 ++++++++ packages/hal/src/validators/index.ts | 8 +- 6 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts create mode 100644 packages/hal/src/validators/apex/apex-fsm.ts create mode 100644 packages/hal/src/validators/apex/apex-rs232.ts create mode 100644 packages/hal/src/validators/apex/denominations.ts create mode 100644 packages/hal/src/validators/apex/index.ts diff --git a/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts b/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts new file mode 100644 index 0000000..95c1607 --- /dev/null +++ b/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest' +import { + computeChecksum, + buildFrame, + creditChannel, + parseStatus, + parseResponse, +} from '../apex-rs232.js' + +// Cassette-present bit; OR it into event bytes so parseStatus doesn't short to +// 'stackerOpen'. +const PRESENT = 0x10 + +describe('Apex RS-232 protocol', () => { + describe('computeChecksum', () => { + it('XORs bytes 1..5 (matches the Pyramid reference poll frame)', () => { + // 02 08 10 7F 00 00 03 -> checksum 0x67 + const frame = [0x02, 0x08, 0x10, 0x7f, 0x00, 0x00, 0x03, 0x00] + expect(computeChecksum(frame)).toBe(0x67) + }) + }) + + describe('buildFrame', () => { + it('lays out the 8-byte poll frame with the ACK bit and checksum', () => { + const f = buildFrame(0, 0x7f, 0x00) + expect([...f]).toEqual([0x02, 0x08, 0x10, 0x7f, 0x00, 0x00, 0x03, 0x67]) + }) + + it('sets the ACK bit in the control byte and recomputes the checksum', () => { + const f = buildFrame(1, 0x7f, 0x00) + expect(f[2]).toBe(0x11) + expect(f[7]).toBe(0x66) + }) + + it('sets the stack command bit (0x20) in the command byte', () => { + const f = buildFrame(0, 0x7f, 0x20) + expect(f[4]).toBe(0x20) + expect(f[7]).toBe(computeChecksum([...f])) + }) + + it('masks the enable byte to a single note channel', () => { + const f = buildFrame(0, 0x01, 0x00) + expect(f[3]).toBe(0x01) + }) + }) + + describe('creditChannel', () => { + it('extracts the 1-based channel from bits 3..5', () => { + expect(creditChannel(0x00)).toBe(0) + expect(creditChannel(0x08)).toBe(1) + expect(creditChannel(0x28)).toBe(5) + expect(creditChannel(0x38)).toBe(7) + }) + }) + + describe('parseStatus', () => { + it('maps each state bit to its status', () => { + expect(parseStatus(0x01, PRESENT)).toBe('standby') + expect(parseStatus(0x02, PRESENT)).toBe('accepting') + expect(parseStatus(0x04, PRESENT)).toBe('billsRead') + expect(parseStatus(0x08, PRESENT)).toBe('stacking') + expect(parseStatus(0x10, PRESENT)).toBe('billsValid') + expect(parseStatus(0x20, PRESENT)).toBe('returning') + expect(parseStatus(0x40, PRESENT)).toBe('billsRejected') + }) + + it('reports stackerOpen when the cassette-present bit is clear', () => { + expect(parseStatus(0x01, 0x00)).toBe('stackerOpen') + expect(parseStatus(0x10, 0x00)).toBe('stackerOpen') // even mid-stack + }) + + it('prioritises jam and reject events over motion', () => { + expect(parseStatus(0x08, PRESENT | 0x04)).toBe('jam') + expect(parseStatus(0x04, PRESENT | 0x02)).toBe('billsRejected') // rejected + expect(parseStatus(0x04, PRESENT | 0x01)).toBe('billsRejected') // cheated + }) + + it('prefers a terminal stacked state over a combined idling bit', () => { + // 0x11 = stacked | idling + expect(parseStatus(0x11, PRESENT)).toBe('billsValid') + }) + }) + + describe('parseResponse', () => { + const usd = [1, 5, 10, 20, 50, 100] + const resolve = (ch: number) => usd[ch - 1] ?? null + + it('resolves the escrowed note denomination from the credit channel', () => { + // state=escrowed, event=present, credit=channel 4 ($20) + const frame = Buffer.from([ + 0x02, + 0x0b, + 0x10, + 0x04, + PRESENT, + 0x20, + 0x00, + 0x00, + 0x00, + 0x03, + 0x00, + ]) + const r = parseResponse(frame, resolve) + expect(r.status).toBe('billsRead') + expect(r.bill?.denomination).toBe(20) + }) + + it('returns no bill when no channel is credited', () => { + const frame = Buffer.from([ + 0x02, + 0x0b, + 0x10, + 0x01, + PRESENT, + 0x00, + 0x00, + 0x00, + 0x00, + 0x03, + 0x00, + ]) + const r = parseResponse(frame, resolve) + expect(r.status).toBe('standby') + expect(r.bill).toBeUndefined() + }) + + it('yields a null denomination for an unmapped channel', () => { + // channel 7 not present in the 6-entry USD table + const frame = Buffer.from([ + 0x02, + 0x0b, + 0x10, + 0x10, + PRESENT, + 0x38, + 0x00, + 0x00, + 0x00, + 0x03, + 0x00, + ]) + const r = parseResponse(frame, resolve) + expect(r.bill?.denomination).toBeNull() + }) + }) +}) diff --git a/packages/hal/src/validators/apex/apex-fsm.ts b/packages/hal/src/validators/apex/apex-fsm.ts new file mode 100644 index 0000000..250030b --- /dev/null +++ b/packages/hal/src/validators/apex/apex-fsm.ts @@ -0,0 +1,105 @@ +/** + * Apex Status Tracker + * + * Like EBDS, the Apex RS-232 protocol reports status directly via bits in each + * reply, so there's no complex command/response state machine — just dedupe + * status changes and translate them into the BillValidator event interface. + * + * Status flow: + * standby → accepting → billsRead(escrow) → billsValid(stacked) + * → billsRejected(returned) + */ + +import { EventEmitter } from 'node:events' +import type { ApexParseResult } from './apex-rs232.js' + +// A note shouldn't sit in escrow long; warn if the host's stack/return +// decision lags, which on most acceptors risks an autonomous timeout-return. +const ESCROW_WATCHDOG_WARN_MS = 500 + +export class ApexFsm extends EventEmitter { + private currentStatus: string | null = null + private escrowOpenedAt: number | null = null + + static factory(): ApexFsm { + return new ApexFsm() + } + + /** Process a parsed Apex reply and emit status-change events. */ + process(result: ApexParseResult): void { + const { status, bill } = result + if (!status) return + if (this.currentStatus === status) return // dedupe continuous polling + + const prev = this.currentStatus + this.currentStatus = status + console.log('[APEX] %d status: %s → %s', Date.now(), prev ?? '(init)', status) + + if (prev === 'billsRead' && this.escrowOpenedAt !== null) { + const elapsed = Date.now() - this.escrowOpenedAt + this.escrowOpenedAt = null + if (elapsed > ESCROW_WATCHDOG_WARN_MS) { + console.warn( + '[APEX] escrow held %dms before %s — host decision latency high', + elapsed, + status + ) + } + } + + switch (status) { + case 'accepting': + this.emit('accepting') + break + + case 'billsRead': + // Bill in escrow. Apex doesn't report currency, so there's no + // per-note currency check here (the enable mask already gates which + // channels the acceptor will escrow). + this.escrowOpenedAt = Date.now() + this.emit('billsAccepted') + // Match EBDS: emit billsRead on the next tick. + process.nextTick(() => this.emit('billsRead', bill ?? { denomination: null, code: '' })) + break + + case 'stacking': + this.emit('stacking') + break + + case 'returning': + if (prev === 'billsRead') { + console.warn( + '[APEX] returning straight from escrow with no host reject — watch for autonomous return' + ) + } + this.emit('returning') + break + + case 'billsValid': + if (bill && !bill.denomination) return // stacked with no denom (e.g. cashbox reinsert) + this.emit('billsValid') + break + + case 'billsRejected': + this.emit('billsRejected') + break + + case 'jam': + this.emit('error', new Error('Bill validator jam')) + break + + case 'stackerOpen': + this.emit('stackerOpen') + break + + case 'standby': + this.emit('standby') + break + } + } + + reset(): void { + this.currentStatus = null + this.escrowOpenedAt = null + } +} diff --git a/packages/hal/src/validators/apex/apex-rs232.ts b/packages/hal/src/validators/apex/apex-rs232.ts new file mode 100644 index 0000000..d8bcb7e --- /dev/null +++ b/packages/hal/src/validators/apex/apex-rs232.ts @@ -0,0 +1,363 @@ +/** + * Pyramid Apex RS-232 Protocol Layer + * + * Serial communication for Pyramid Technologies Apex-series bill acceptors + * (Apex 5000 / 7000 / 7600) running their RS-232 interface. + * + * Implemented from Pyramid's PUBLIC protocol facts only — the wire format, + * bit masks and serial parameters documented in Pyramid's "RS-232 Serial + * Interface Specification" (https://pyramidacceptors.com/pdf/RS_232.pdf) and + * mirrored by their published integrator samples. No third-party (or + * lamassu-machine) source is copied; the byte layout below is a functional + * spec, re-expressed for bitSpire under AGPL. + * + * Frame (host → acceptor), fixed 8 bytes: + * [0] STX 0x02 + * [1] LEN 0x08 + * [2] CTRL 0x10 | ack (ack toggles 0↔1 every message) + * [3] ENA denomination enable bitmask (0x7F = all, 0x00 = none) + * [4] CMD 0x00 base; | 0x20 stacks the escrowed note + * [5] RSVD 0x00 + * [6] ETX 0x03 + * [7] CHK XOR of bytes [1..5] + * + * Frame (acceptor → host), length-prefixed like the host frame; the fields + * this driver consumes: + * [3] STATE bits 1=idling 2=accepting 4=escrowed 8=stacking + * 16=stacked 32=returning 64=returned + * [4] EVENT bits 0x01=cheated 0x02=rejected 0x04=jammed + * 0x08=stacker-full 0x10=cassette-present + * [5] CREDIT denomination channel = (byte & 0x38) >> 3 (1..7, 0=none) + * + * Serial: 9600 baud, 7 data bits, even parity, 1 stop bit. + */ + +import { EventEmitter } from 'node:events' +import { SerialPort } from 'serialport' + +const STX = 0x02 +const ETX = 0x03 +const HOST_FRAME_LEN = 0x08 + +// Host command byte (frame[4]) +const CMD_STACK = 0x20 + +// Response STATE byte (frame[3]) bit masks +const STATE_IDLING = 0x01 +const STATE_ACCEPTING = 0x02 +const STATE_ESCROWED = 0x04 +const STATE_STACKING = 0x08 +const STATE_STACKED = 0x10 +const STATE_RETURNING = 0x20 +const STATE_RETURNED = 0x40 + +// Response EVENT byte (frame[4]) bit masks +const EVENT_CHEATED = 0x01 +const EVENT_REJECTED = 0x02 +const EVENT_JAMMED = 0x04 +const EVENT_STACKER_FULL = 0x08 +const EVENT_CASSETTE_PRESENT = 0x10 + +// Plausibility bounds for the response length byte, used only to resync a +// desynced stream — a real reply is short (≈8–16 bytes). +const RESP_LEN_MIN = 6 +const RESP_LEN_MAX = 32 + +export type ApexStatus = + | 'standby' + | 'accepting' + | 'billsRead' + | 'stacking' + | 'returning' + | 'billsValid' + | 'billsRejected' + | 'jam' + | 'stackerOpen' + +export interface ApexParseResult { + status: ApexStatus | null + /** Populated when a denomination channel is present (escrow / stacked). */ + bill?: { denomination: number | null; code: string } + /** Truthy status flags, for on-change diagnostic logging. */ + flags: string +} + +export interface ApexRs232Config { + device: string | string[] +} + +// --------------------------------------------------------------------------- +// Pure functions — checksum, frame building, response parsing +// --------------------------------------------------------------------------- + +/** XOR checksum over bytes [1..5] (LEN through RSVD), matching the host frame. */ +export function computeChecksum(frame: number[] | Buffer): number { + let cs = 0x00 + for (let i = 1; i <= 5; i++) cs ^= frame[i] ?? 0 + return cs +} + +/** + * Build the 8-byte host poll/command frame. + * @param ack current ACK bit (0 or 1) + * @param enableByte denomination enable bitmask + * @param cmdByte command bits (e.g. CMD_STACK) + */ +export function buildFrame(ack: number, enableByte: number, cmdByte: number): Buffer { + const frame = [ + STX, + HOST_FRAME_LEN, + 0x10 | (ack & 0x01), + enableByte & 0xff, + cmdByte & 0xff, + 0x00, + ETX, + 0x00, + ] + frame[7] = computeChecksum(frame) + return Buffer.from(frame) +} + +/** Channel index (1..7, 0 = none) of the credited note in the CREDIT byte. */ +export function creditChannel(creditByte: number): number { + return (creditByte & 0x38) >> 3 +} + +/** + * Derive a high-level status from the STATE + EVENT bytes. Terminal outcomes + * (stacked / returned / cheated / rejected / jam / stacker-open) win over the + * transient in-motion states so a late-arriving reply can't mask a result. + */ +export function parseStatus(state: number, event: number): ApexStatus | null { + // Cassette (stacker box) removed — surfaces before anything transactional. + if (!(event & EVENT_CASSETTE_PRESENT)) return 'stackerOpen' + if (event & EVENT_JAMMED) return 'jam' + // Terminal resting states + if (state & STATE_STACKED) return 'billsValid' + if (state & STATE_RETURNED || event & (EVENT_CHEATED | EVENT_REJECTED)) return 'billsRejected' + // Transient — bill in motion. Surface before `escrowed`. + if (state & STATE_STACKING) return 'stacking' + if (state & STATE_RETURNING) return 'returning' + if (state & STATE_ESCROWED) return 'billsRead' + if (state & STATE_ACCEPTING) return 'accepting' + if (state & STATE_IDLING) return 'standby' + return null +} + +function summarizeFlags(state: number, event: number, credit: number): string { + const f: string[] = [] + if (state & STATE_IDLING) f.push('idling') + if (state & STATE_ACCEPTING) f.push('accepting') + if (state & STATE_ESCROWED) f.push('escrowed') + if (state & STATE_STACKING) f.push('stacking') + if (state & STATE_STACKED) f.push('stacked') + if (state & STATE_RETURNING) f.push('returning') + if (state & STATE_RETURNED) f.push('returned') + if (event & EVENT_CHEATED) f.push('cheated') + if (event & EVENT_REJECTED) f.push('rejected') + if (event & EVENT_JAMMED) f.push('jammed') + if (event & EVENT_STACKER_FULL) f.push('stackerFull') + if (!(event & EVENT_CASSETTE_PRESENT)) f.push('cassetteMissing') + const ch = creditChannel(credit) + if (ch) f.push(`channel=${ch}`) + return f.join(', ') || '(none)' +} + +/** + * Parse a complete acceptor frame. `denomForChannel` maps a 1-based credit + * channel to a fiat value (null if the channel isn't configured). + */ +export function parseResponse( + frame: Buffer, + denomForChannel: (channel: number) => number | null +): ApexParseResult { + const state = frame[3] ?? 0 + const event = frame[4] ?? 0 + const credit = frame[5] ?? 0 + const status = parseStatus(state, event) + const flags = summarizeFlags(state, event, credit) + + const channel = creditChannel(credit) + if (channel > 0) { + return { status, bill: { denomination: denomForChannel(channel), code: '' }, flags } + } + return { status, flags } +} + +// --------------------------------------------------------------------------- +// ApexRs232 class +// --------------------------------------------------------------------------- + +export class ApexRs232 extends EventEmitter { + private buf: Buffer = Buffer.alloc(0) + private config: ApexRs232Config + private serial: SerialPort | null = null + private ack = 0x0 + private enabledMask = 0x00 + // Latched escrow decision. Like EBDS, the stack/return choice isn't a + // one-shot: the note sits in escrow until a poll asserts it, so we re-assert + // every poll until the device leaves escrow (cleared in _process). A single + // dropped frame then can't strand the note. + private pendingAction: 'none' | 'stack' | 'return' = 'none' + private lastFlags: string | null = null + private denomForChannel: (channel: number) => number | null = () => null + + constructor(config: ApexRs232Config) { + super() + this.config = config + } + + static factory(config: ApexRs232Config): ApexRs232 { + return new ApexRs232(config) + } + + /** Provide the channel→denomination resolver (fiat-dependent). */ + setDenomResolver(fn: (channel: number) => number | null): void { + this.denomForChannel = fn + } + + // -- Serial connection --------------------------------------------------- + + private async _open(device: string): Promise { + return new Promise((resolve, reject) => { + const serial = new SerialPort({ + path: device, + baudRate: 9600, + parity: 'even' as const, + dataBits: 7 as const, + stopBits: 1 as const, + autoOpen: false, + rtscts: false, + }) + this.serial = serial + + serial.on('error', (err) => this.emit('error', err)) + serial.on('open', (err?: Error | null) => { + if (err) return reject(err) + serial.on('readable', () => { + const data = serial.read() as Buffer | null + if (data) this._process(data) + }) + serial.on('close', () => this.emit('disconnected')) + this.emit('connected') + resolve() + }) + + serial.open() + }) + } + + async open(cb: (err?: Error) => void): Promise { + const devices = this.config.device + if (!devices) { + this.emit('error', new Error('No configured devices.')) + return + } + const list = typeof devices === 'string' ? [devices] : devices + for (const device of list) { + try { + await this._open(device) + cb() + return + } catch { + continue + } + } + cb(new Error('No configured devices available.')) + } + + close(cb: (err?: Error | null) => void): void { + this.serial?.close(cb) + } + + // -- Enable / commands --------------------------------------------------- + + setEnabledDenominations(mask: number): void { + this.enabledMask = mask & 0xff + } + + /** Send one poll, carrying the current mask + latched escrow action. */ + poll(): void { + // 'return' is expressed by disabling all channels while a note is escrowed, + // which makes the acceptor hand the note back (Apex has no distinct return + // opcode). 'stack' asserts CMD_STACK. Both are re-asserted until the device + // leaves escrow. NOTE: verify the return-by-disable behaviour on the 7600 + // during bench bring-up; some firmware returns only on escrow timeout. + let enableByte = this.enabledMask + let cmdByte = 0x00 + if (this.pendingAction === 'stack') cmdByte = CMD_STACK + else if (this.pendingAction === 'return') enableByte = 0x00 + + this.ack ^= 0x01 + this.serial?.write(buildFrame(this.ack, enableByte, cmdByte)) + } + + stack(): void { + this.pendingAction = 'stack' + this.poll() + } + + reject(): void { + this.pendingAction = 'return' + this.poll() + } + + /** Disable all denominations and clear any latched escrow action. */ + reset(): void { + this.pendingAction = 'none' + this.enabledMask = 0x00 + this.poll() + } + + // -- Receive / parse ----------------------------------------------------- + + private _process(data: Buffer): void { + this.buf = this._acquireSync(Buffer.concat([this.buf, data])) + if (this.buf.length < 2) return + + const len = this.buf[1] ?? 0 + if (len < RESP_LEN_MIN || len > RESP_LEN_MAX) { + // Implausible length byte — drop the STX we synced on and resync. + this.buf = this._acquireSync(this.buf.subarray(1)) + return + } + if (this.buf.length < len) return // wait for the whole frame + + const frame = this.buf.subarray(0, len) + this.buf = this.buf.subarray(len) + + if (frame[len - 2] !== ETX) { + this.emit('badFrame') + this.poll() + return + } + // Checksum is validated leniently: a mismatch is logged once but the frame + // is still parsed. Pyramid's published host samples don't verify the reply + // checksum, and the exact XOR range for the *reply* isn't confirmable from + // the (scanned) spec — so we don't want a wrong assumption to blackhole + // every otherwise-valid frame. Tighten to a hard drop once verified on hw. + if (frame[len - 1] !== computeChecksum(frame)) { + console.warn('[APEX] reply checksum mismatch (parsing anyway pending hw verification)') + } + + const result = parseResponse(frame, this.denomForChannel) + + // Clear a latched stack/return once the note has left escrow, so it can't + // leak onto the next note. + const escrowed = (frame[3] ?? 0) & STATE_ESCROWED + if (!escrowed) this.pendingAction = 'none' + + if (result.flags !== this.lastFlags) { + console.log(`[APEX] status: ${this.lastFlags ?? '(initial)'} → ${result.flags}`) + this.lastFlags = result.flags + } + this.emit('message', result) + } + + private _acquireSync(data: Buffer): Buffer { + for (let i = 0; i < data.length; i++) { + if (data[i] === STX) return data.subarray(i) + } + return Buffer.alloc(0) + } +} diff --git a/packages/hal/src/validators/apex/denominations.ts b/packages/hal/src/validators/apex/denominations.ts new file mode 100644 index 0000000..9874da4 --- /dev/null +++ b/packages/hal/src/validators/apex/denominations.ts @@ -0,0 +1,30 @@ +/** + * Pyramid Apex Denomination Tables + * + * The Apex RS-232 reply reports a 1-based CREDIT CHANNEL (1..7), not a value — + * the channel→value mapping is fixed by the bill dataset programmed into the + * acceptor's firmware for its country. The arrays below are ascending value + * lists indexed by (channel - 1); they MUST match the dataset flashed on your + * specific Apex 7600, or a credited note will be booked at the wrong value. + * Verify against the unit's configuration card during bring-up. + * + * Defaults follow Pyramid's standard datasets (US = $1/$5/$10/$20/$50/$100; + * $2 channel omitted as it's rarely enabled). + */ + +export const denominations: Record = { + USD: [1, 5, 10, 20, 50, 100], + EUR: [5, 10, 20, 50, 100, 200, 500], + GBP: [5, 10, 20, 50], + CAD: [5, 10, 20, 50, 100], + AUD: [5, 10, 20, 50, 100], + MXN: [20, 50, 100, 200, 500], + GTQ: [1, 5, 10, 20, 50, 100, 200], +} + +/** Resolve a 1-based credit channel to a fiat value, or null if unmapped. */ +export function denomForChannel(fiatCode: string | null, channel: number): number | null { + if (!fiatCode || channel < 1) return null + const table = denominations[fiatCode] + return table?.[channel - 1] ?? null +} diff --git a/packages/hal/src/validators/apex/index.ts b/packages/hal/src/validators/apex/index.ts new file mode 100644 index 0000000..463ee06 --- /dev/null +++ b/packages/hal/src/validators/apex/index.ts @@ -0,0 +1,155 @@ +/** + * Pyramid Apex Bill Validator Driver + * + * Supports Pyramid Technologies Apex-series acceptors (Apex 5000 / 7000 / 7600) + * on their RS-232 interface. Set the acceptor to RS-232 mode via its DIP / + * configuration card. + * + * Protocol: RS-232, 9600 baud, 7 data bits, even parity, 1 stop bit. + * + * Written from Pyramid's public RS-232 protocol facts (see apex-rs232.ts) — + * not ported from any licensed source. + */ + +import { EventEmitter } from 'node:events' +import { throttle } from 'lodash-es' +import { ApexRs232 } from './apex-rs232.js' +import { ApexFsm } from './apex-fsm.js' +import { denominations as denominationsTable, denomForChannel } from './denominations.js' +import type { BillValidator, ValidatorConfig, BillData } from '../../types.js' + +// Apex is host-polled; match the id003/ebds 100ms cadence so we observe escrow +// well inside the acceptor's grace window. +const POLLING_INTERVAL = 100 + +// All 7 credit channels enabled. Per-channel gating is handled upstream by the +// state machine (via lowest/highestBill), so the driver enables the full mask +// and relies on the acceptor's dataset for which notes exist. +const ALL_CHANNELS = 0x7f + +interface BNLike { + lte: (n: number) => boolean + gte: (n: number) => boolean + toNumber: () => number +} + +function BN(n: number): BNLike { + return { lte: (o: number) => n <= o, gte: (o: number) => n >= o, toNumber: () => n } +} + +export class ApexValidator extends EventEmitter implements BillValidator { + private config: ValidatorConfig + private fiatCode: string | null = null + private rs232: ApexRs232 | null = null + private fsm: ApexFsm | null = null + private poller: ReturnType | null = null + private _throttledError: (err: Error) => void + + constructor(config: ValidatorConfig) { + super() + this.config = config + this._throttledError = throttle((err: Error) => this.emit('error', err), 2000) + } + + static factory(config: ValidatorConfig): ApexValidator { + return new ApexValidator(config) + } + + setFiatCode(fiatCode: string): void { + this.fiatCode = fiatCode + } + + // Apex has no host-controllable insertion light. + lightOn(): void {} + lightOff(): void {} + + run(cb: (err?: Error) => void): void { + this.fsm = ApexFsm.factory() + this.rs232 = ApexRs232.factory({ device: this.config.rs232.device }) + this.rs232.setDenomResolver((channel) => denomForChannel(this.fiatCode, channel)) + + this.rs232.on('message', (result) => this.fsm?.process(result)) + this.rs232.on('error', (err: Error) => this._throttledError(err)) + this.rs232.on('badFrame', () => this.rs232?.poll()) + this.rs232.on('disconnected', () => this.emit('disconnected')) + + this.fsm.on('billsAccepted', () => this.emit('billsAccepted')) + this.fsm.on('billsRead', (bill: { denomination: number | null; code: string }) => { + if (!bill.denomination) { + console.log('[APEX] Bill rejected: unsupported/unmapped channel') + this.rs232?.reject() + return + } + const billData: BillData = { denomination: bill.denomination, code: 0 } + this.emit('billsRead', billData) + }) + this.fsm.on('billsValid', () => this.emit('billsValid')) + this.fsm.on('billsRejected', () => this.emit('billsRejected')) + this.fsm.on('accepting', () => this.emit('accepting')) + this.fsm.on('stacking', () => this.emit('stacking')) + this.fsm.on('returning', () => this.emit('returning')) + this.fsm.on('stackerOpen', () => this.emit('stackerOpen')) + this.fsm.on('standby', () => this.emit('standby')) + this.fsm.on('error', (err: Error) => this.emit('error', err)) + + this.rs232.open((err) => { + if (err) return cb(err) + this.rs232!.reset() // start disabled + this.poller = setInterval(() => this.rs232?.poll(), POLLING_INTERVAL) + cb() + }) + } + + close(cb: (err?: Error) => void): void { + if (this.poller) { + clearInterval(this.poller) + this.poller = null + } + this.rs232?.close((err) => cb(err ?? undefined)) + } + + enable(): void { + if (!this.rs232) return + this.rs232.setEnabledDenominations(ALL_CHANNELS) + this.rs232.poll() + } + + disable(): void { + if (!this.rs232) return + this.rs232.setEnabledDenominations(0x00) + this.rs232.poll() + } + + stack(): void { + this.rs232?.stack() + } + + reject(): void { + this.rs232?.reject() + } + + lowestBill(fiat: BNLike): BNLike { + const bills = this._denominations() + if (!bills) return BN(0) + const filtered = bills.filter((b) => fiat.lte(b)) + if (filtered.length === 0) return BN(Math.min(...bills)) + return BN(Math.min(...filtered)) + } + + highestBill(fiat: BNLike): BNLike { + const bills = this._denominations() + if (!bills) return BN(-Infinity) + const filtered = bills.filter((b) => fiat.gte(b)) + if (filtered.length === 0) return BN(-Infinity) + return BN(Math.max(...filtered)) + } + + hasDenominations(): boolean { + return this._denominations() !== null + } + + private _denominations(): number[] | null { + if (!this.fiatCode) return null + return denominationsTable[this.fiatCode] ?? null + } +} diff --git a/packages/hal/src/validators/index.ts b/packages/hal/src/validators/index.ts index a56be61..7b868a8 100644 --- a/packages/hal/src/validators/index.ts +++ b/packages/hal/src/validators/index.ts @@ -6,17 +6,19 @@ export { Id003 } from './id003/index.js' export { EbdsValidator } from './ebds/index.js' +export { ApexValidator } from './apex/index.js' export type { ValidatorConfig, BillValidator, BillData } from '../types.js' import { Id003 } from './id003/index.js' import { EbdsValidator } from './ebds/index.js' +import { ApexValidator } from './apex/index.js' import type { ValidatorConfig, BillValidator } from '../types.js' -export type ValidatorType = 'id003' | 'ebds' +export type ValidatorType = 'id003' | 'ebds' | 'apex' /** * Create a bill validator instance - * @param type Validator type (e.g., 'id003', 'ebds') + * @param type Validator type (e.g., 'id003', 'ebds', 'apex') * @param config Validator configuration */ export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator { @@ -25,6 +27,8 @@ export function createValidator(type: ValidatorType, config: ValidatorConfig): B return Id003.factory(config) case 'ebds': return EbdsValidator.factory(config) + case 'apex': + return ApexValidator.factory(config) default: throw new Error(`Unknown validator type: ${type}`) }