Compare commits

..

No commits in common. "76f3c2ff9d5c2160ad69120b61ae5e9a01902b9b" and "dd542eda88b197dfe89779c9f60cfd0be3c3566d" have entirely different histories.

3 changed files with 48 additions and 139 deletions

View file

@ -6,7 +6,6 @@ import {
parseStatus,
parseResponse,
} from '../apex-rs232.js'
import { denomForChannel } from '../denominations.js'
// Cassette-present bit; OR it into event bytes so parseStatus doesn't short to
// 'stackerOpen'.
@ -21,27 +20,6 @@ describe('Apex RS-232 protocol', () => {
})
})
describe('computeChecksum spans the frame, not a fixed range', () => {
it('matches the two reset frames the spec spells out literally', () => {
// Rev G gives these verbatim, checksum included, so they are the only
// ground truth available for the XOR range without hardware.
const a = Buffer.from([0x02, 0x08, 0x61, 0x7f, 0x7f, 0x7f, 0x03, 0x16])
const b = Buffer.from([0x02, 0x08, 0x60, 0x7f, 0x7f, 0x7f, 0x03, 0x17])
expect(computeChecksum(a)).toBe(0x16)
expect(computeChecksum(b)).toBe(0x17)
})
it('covers the six data bytes of an 11-byte reply', () => {
// The reply is longer than the host frame. A checksum hardcoded to the
// host range silently mis-validates every reply the acceptor sends.
const reply = [0x02, 0x0b, 0x20, 0x01, 0x10, 0x00, 0x00, 0x12, 0x34, 0x03, 0x00]
let want = 0
for (let i = 1; i <= 8; i++) want ^= reply[i] as number
reply[10] = want
expect(computeChecksum(Buffer.from(reply))).toBe(want)
})
})
describe('buildFrame', () => {
it('lays out the 8-byte poll frame with the ACK bit and checksum', () => {
const f = buildFrame(0, 0x7f, 0x00)
@ -54,14 +32,6 @@ describe('Apex RS-232 protocol', () => {
expect(f[7]).toBe(0x66)
})
it('carries escrow, stack and return in the command byte', () => {
// Rev G BYTE 1: bit 4 escrow enable, bit 5 stack, bit 6 return. Escrow
// is an enable held across polls, so it rides alongside the action bit.
expect(buildFrame(0, 0x7f, 0x10)[4]).toBe(0x10)
expect(buildFrame(0, 0x7f, 0x30)[4]).toBe(0x30)
expect(buildFrame(0, 0x7f, 0x50)[4]).toBe(0x50)
})
it('sets the stack command bit (0x20) in the command byte', () => {
const f = buildFrame(0, 0x7f, 0x20)
expect(f[4]).toBe(0x20)
@ -112,14 +82,11 @@ describe('Apex RS-232 protocol', () => {
})
describe('parseResponse', () => {
// Resolve through the real module, not a hand-copied array. The previous
// local copy duplicated the shipping table's off-by-one and so asserted
// the bug instead of catching it.
const resolve = (ch: number) => denomForChannel('USD', ch)
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. Per spec Rev G the
// USD channel order is $1 $2 $5 $10 $20 $50 $100, so channel 4 is $10.
// state=escrowed, event=present, credit=channel 4 ($20)
const frame = Buffer.from([
0x02,
0x0b,
@ -135,7 +102,7 @@ describe('Apex RS-232 protocol', () => {
])
const r = parseResponse(frame, resolve)
expect(r.status).toBe('billsRead')
expect(r.bill?.denomination).toBe(10)
expect(r.bill?.denomination).toBe(20)
})
it('returns no bill when no channel is credited', () => {
@ -157,9 +124,8 @@ describe('Apex RS-232 protocol', () => {
expect(r.bill).toBeUndefined()
})
it('maps the top channel to the largest note', () => {
// Channel 7 is $100. It read as unmapped while the table omitted $2,
// which is exactly the shift this test now pins down.
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,
@ -174,25 +140,7 @@ describe('Apex RS-232 protocol', () => {
0x00,
])
const r = parseResponse(frame, resolve)
expect(r.bill?.denomination).toBe(100)
})
it('pins the whole USD channel order from the spec', () => {
// Rev G, BYTE 2 bits 3-5: 001=$1 010=$2 011=$5 100=$10 101=$20
// 110=$50 111=$100. A note credited at the wrong value is silent and
// costs real money, so the full mapping is asserted rather than sampled.
expect([1, 2, 3, 4, 5, 6, 7].map((ch) => denomForChannel('USD', ch))).toEqual([
1, 2, 5, 10, 20, 50, 100,
])
})
it('has no denomination for channel 0, which means no note', () => {
expect(denomForChannel('USD', 0)).toBeNull()
})
it('has no denomination when the currency is unknown', () => {
expect(denomForChannel(null, 3)).toBeNull()
expect(denomForChannel('ZZZ', 3)).toBeNull()
expect(r.bill?.denomination).toBeNull()
})
})
})

View file

@ -6,27 +6,23 @@
*
* 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", document RS_232, Rev G 12/03/14. No third-party
* (or lamassu-machine) source is copied; the byte layout below is a functional
* 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.
*
* The interface is Mars/MEI GL5-compatible, which is why it looks so much like
* the EBDS driver next door. The acceptor is a pure slave: it answers polls and
* never speaks first. Polls must not fall more than 5s apart or the acceptor
* may dump an escrowed note and stop accepting until the host resumes.
*
* Frame (host → acceptor), fixed 8 bytes:
* [0] STX 0x02
* [1] LEN 0x08
* [2] CTRL msg type 1 (master) in bits 4-6, ack in bit 0 (toggles every message)
* [3] ENA BYTE 0 — per-note enable bits: bit 0 = note 1 … bit 6 = note 7
* [4] CMD BYTE 1 — bit 4 escrow enable, bit 5 stack, bit 6 return
* [5] RSVD BYTE 2 — reserved, 0x00
* [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 all bytes except STX, ETX and itself
* [7] CHK XOR of bytes [1..5]
*
* Frame (acceptor → host), 11 bytes — STX, LEN, CTRL, six data bytes, ETX,
* CHK. The fields this driver consumes:
* 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
@ -43,11 +39,8 @@ const STX = 0x02
const ETX = 0x03
const HOST_FRAME_LEN = 0x08
// Host command byte (frame[4]) — spec Rev G, "Data Fields for Messages Sent
// By the Master", BYTE 1.
const CMD_ESCROW = 0x10 // bit 4: set to 1 to ENABLE escrow mode
const CMD_STACK = 0x20 // bit 5: stack the escrowed note
const CMD_RETURN = 0x40 // bit 6: return the escrowed note
// Host command byte (frame[4])
const CMD_STACK = 0x20
// Response STATE byte (frame[3]) bit masks
const STATE_IDLING = 0x01
@ -97,18 +90,10 @@ export interface ApexRs232Config {
// Pure functions — checksum, frame building, response parsing
// ---------------------------------------------------------------------------
/**
* XOR checksum over every byte except STX, ETX and the checksum itself — spec
* Rev G: "calculated on all bytes (except: STX, ETX and the checksum byte
* itself)". For the 8-byte host frame that is bytes 1..5; for the 11-byte
* reply it is bytes 1..8, which is why this is derived from the length rather
* than hardcoded. Confirmed against the two reset frames the spec spells out
* literally (02 08 61 7f 7f 7f 03 16 and 02 08 60 7f 7f 7f 03 17).
*/
export function computeChecksum(frame: number[] | Buffer, length?: number): number {
const n = length ?? frame.length
/** 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 <= n - 3; i++) cs ^= frame[i] ?? 0
for (let i = 1; i <= 5; i++) cs ^= frame[i] ?? 0
return cs
}
@ -293,25 +278,18 @@ export class ApexRs232 extends EventEmitter {
/** Send one poll, carrying the current mask + latched escrow action. */
poll(): void {
// Escrow is asserted on EVERY poll. It is an enable bit, not a one-shot:
// with it clear the acceptor never stops at escrow, so the host is never
// offered the stack/return decision and notes are banked before anything
// has validated them. This driver's whole FSM is built around that
// decision point.
//
// Stack and return are the spec's own bits. An earlier version expressed
// return by zeroing the enable mask, on the assumption that the Apex had
// no return opcode; it has one, and disabling channels mid-escrow is not
// what it means.
//
// Both are re-asserted until the device leaves escrow, so a single dropped
// frame cannot strand a note.
let cmdByte = CMD_ESCROW
if (this.pendingAction === 'stack') cmdByte |= CMD_STACK
else if (this.pendingAction === 'return') cmdByte |= CMD_RETURN
// '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, this.enabledMask, cmdByte))
this.serial?.write(buildFrame(this.ack, enableByte, cmdByte))
}
stack(): void {
@ -353,20 +331,13 @@ export class ApexRs232 extends EventEmitter {
this.poll()
return
}
// The XOR range is now confirmed from the spec, so a mismatch is a hard
// drop rather than the previous parse-anyway. A corrupted frame carries a
// denomination field, and crediting a note from a frame we know is damaged
// is the one outcome worth refusing outright. The raw bytes are logged so
// a systematic framing error is still diagnosable rather than silent.
const want = computeChecksum(frame, len)
if (frame[len - 1] !== want) {
console.warn(
`[APEX] reply checksum mismatch: got ${frame[len - 1]?.toString(16)} ` +
`want ${want.toString(16)} — frame dropped: ${frame.toString('hex')}`
)
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)

View file

@ -1,29 +1,19 @@
/**
* Pyramid Apex Denomination Tables
*
* Indexed by (credit channel - 1). The channel is NOT an index into "the notes
* this unit happens to have enabled" — it is a fixed protocol constant.
* Pyramid's RS-232 spec (Rev G, "Data Fields for Messages sent by the Slave",
* BYTE 2 bits 3-5) fixes the USD mapping:
* 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.
*
* 001 = $1 010 = $2 011 = $5 100 = $10
* 101 = $20 110 = $50 111 = $100
*
* $2 occupies channel 2 whether or not the unit accepts $2 notes. An earlier
* version of this table omitted it as "rarely enabled", which shifted every
* larger note down one slot: a $5 credited as $10, a $20 as $50, a $50 as
* $100, and a $100 as nothing at all. Never drop an unused channel from these
* arrays — pad it instead.
*
* For non-USD the spec says only "Foreign currencies are in sequential order
* as note 1-7", so channel N is the Nth note type of whatever dataset is
* flashed on the unit. The orderings below are the conventional ascending sets
* and are UNVERIFIED against a real configuration card. Check the card before
* a machine takes money in any of them.
* 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, 2, 5, 10, 20, 50, 100],
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],