feat(hal): add Pyramid Apex RS-232 bill-validator driver

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
This commit is contained in:
Padreug 2026-08-16 21:22:47 +02:00
commit 16d235079f
6 changed files with 805 additions and 2 deletions

View file

@ -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()
})
})
})

View file

@ -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
}
}

View file

@ -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<void> {
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<void> {
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)
}
}

View file

@ -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<string, number[]> = {
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
}

View file

@ -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<typeof setInterval> | 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
}
}

View file

@ -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}`)
}