diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 2ec863b..5453fea 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -87,19 +87,40 @@ export async function initializeHal(config: HalConfig): Promise { const { validator: valConfig, dispenser: dispConfig } = config - // Create hardware instances - const dispenser: BillDispenser = hal.createDispenser(dispConfig.type, { - device: dispConfig.device, - }) + // Start dispenser (optional — mirrors the validator handling below). + // + // A cash-in-only machine is a legitimate configuration: the Raspberry Pi + // reference build has a bill acceptor and no dispenser at all. This used to + // create and init the dispenser unconditionally, so a missing device threw + // and aborted the WHOLE of initializeHal — taking the validator with it, + // even though the validator was present and working. The Pi bring-up hit + // exactly that: "cannot open /dev/ttyDispenser-not-fitted", then an endless + // renderer-reload loop, with a perfectly good acceptor on ttyValidator0. + // + // The validator has been optional since it was written; the asymmetry was + // the bug. + let dispenser: BillDispenser | null = null - // Initialize dispenser. `dispenserInitData` is `let` because - // `setCassettes` swaps it in to re-init with a new layout (also used by - // the on-error re-init path at dispenseCash). + // `dispenserInitData` is `let` because `setCassettes` swaps it in to re-init + // with a new layout (also used by the on-error re-init path at dispenseCash). let dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes: dispConfig.cassettes, } - await dispenser.init(dispenserInitData) + + try { + const fs = await import('node:fs') + if (dispConfig.device && fs.existsSync(dispConfig.device)) { + dispenser = hal.createDispenser(dispConfig.type, { device: dispConfig.device }) + await dispenser.init(dispenserInitData) + console.log('[HAL] Dispenser started') + } else { + console.log('[HAL] Dispenser device not found, running cash-in only') + } + } catch (err) { + console.warn('[HAL] Dispenser failed to start, running cash-in only:', err) + dispenser = null + } console.log('[HAL] Dispenser initialized') // Start validator (optional — proceed without if device is missing or fails) @@ -251,6 +272,17 @@ export async function initializeHal(config: HalConfig): Promise { dispenseCash: async (amounts): Promise => { console.log('[HAL] Dispensing:', amounts) + // Cash-in-only machine: refuse the ask rather than throwing a null + // dereference into the renderer's dispense path. + if (!dispenser) { + return { + bills: [], + cassettes: [], + dispensed: false, + error: 'No dispenser fitted on this machine — cash-out unavailable', + } + } + // Re-initialize dispenser if it was closed after a previous error if (!dispenser.initialized) { console.log('[HAL] Dispenser not initialized, re-initializing...') @@ -391,6 +423,12 @@ export async function initializeHal(config: HalConfig): Promise { count: c.count ?? 0, })) dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes } + // Without a dispenser the layout is still worth recording (the operator + // config consumer keeps calling this), but there is nothing to re-init. + if (!dispenser) { + console.log('[HAL] Cassettes recorded; no dispenser fitted, nothing to re-init') + return + } // Close + re-init the dispenser so its internal per-bay state matches // the new layout. Errors here surface to the caller (operator-config // consumer) — the renderer can decide whether to retry. @@ -407,7 +445,7 @@ export async function initializeHal(config: HalConfig): Promise { return new Promise((resolve) => { validator?.disable() validator?.lightOff() - dispenser.close() + dispenser?.close() if (validator) { validator.close((err?: Error) => { if (err) console.error('[HAL] Validator close error:', err) diff --git a/apps/machine/src/config/device.ts b/apps/machine/src/config/device.ts index cffdaa3..9a06c9a 100644 --- a/apps/machine/src/config/device.ts +++ b/apps/machine/src/config/device.ts @@ -15,7 +15,15 @@ import type { HalConfig, CassetteConfig } from '@/services/hal' /** * Supported machine models */ -export type MachineModel = 'sintra' | 'tejo' | 'douro' | 'gaia' | 'batm3' | 'custom' +export type MachineModel = + | 'sintra' + | 'tejo' + | 'douro' + | 'gaia' + | 'batm3' + | 'rpi4' + | 'rpi5' + | 'custom' /** * Full device configuration @@ -28,7 +36,10 @@ export interface DeviceConfig { /** Bill validator configuration */ validator: { /** Validator protocol type */ - type: 'id003' | 'ebds' + // 'apex' = Pyramid Apex RS-232. The driver landed with the Pi 5 work + // (packages/hal ValidatorType) but these app-side unions were never + // widened, so no machine could actually be configured to use it. + type: 'id003' | 'ebds' | 'apex' /** Serial device path(s) */ device: string | string[] } @@ -117,6 +128,52 @@ export const MACHINE_PRESETS: Record-image` is what you flash; `-installed` is what a running Pi +rebuilds itself against afterwards. They share every module except the root +filesystem declaration and the SD-image builder — see the comments above +`mkPiInstalled` / `mkPiImage` in `flake.nix` for why they're split. + +**Status:** the Pi 4 target is evaluation-verified (it instantiates, and its +configuration differs from the Pi 5's only in the expected board-specific +places) but has not yet been booted on hardware. Treat the first bring-up as +exactly that. + +## What's different from the x86 fleet + +- **aarch64.** Any Pi build needs an aarch64 builder — a Pi itself, an ARM + box, or `boot.binfmt.emulatedSystems = [ "aarch64-linux" ]` on an x86 host. + The Electron app closure will not build on plain x86. +- **Boot.** Raspberry Pi firmware + U-Boot + extlinux (from `nixos-hardware`), + not systemd-boot. The image is an SD-card image, not a GPT disk image. +- **No `determinate`, no `atm-tui`** in the Pi runtime yet (neither ships an + aarch64 package). Add them when they do. +- **Cachix is pre-wired** (`aiolabs.cachix.org` in `nix.settings`), so an + in-place rebuild substitutes the heavy closure rather than compiling on the + Pi — provided the closure was pushed there first. + +## Board differences that matter + +| | Pi 5 | Pi 4 | +|---|---|---| +| SoC / device tree | BCM2712 (`bcm2712*-rpi-*.dtb`) | BCM2711 (`bcm2711-rpi-4*.dtb`) | +| Kiosk GPU / KMS | vc4/v3d on by default | opt-in via `hardware.raspberry-pi."4".fkms-3d` (the glue module enables it; it also injects the CMA + vc4 overlays) | +| RAM | 4–16 GB | 4 GB is the floor for Electron; 8 GB is comfortable | +| Root storage | SD, USB, or NVMe over PCIe | SD or USB only | +| Power | 5 V / 5 A USB-C PD | 5 V / 3 A; Electron startup is the peak | + +Everything else — validator serial symlinks, `console=tty0` keeping the GPIO +UART free, no-suspend, the bitspire service — is identical between the two +glue modules by design. Keep it that way: a change to one almost certainly +belongs in the other. + +## 1. Build the image + +On (or via) an aarch64 builder: + +```bash +nix build .#packages.aarch64-linux.sd-image-rpi4 # or sd-image-rpi5 +ls result/sd-image/ +# → nixos-image-sd-card--aarch64-linux.img.zst +``` + +## 2. Flash + +```bash +lsblk -f # identify the SD card — NOT your main disk +zstd -dc result/sd-image/*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync +``` + +The image carries two labelled partitions the installed config expects: +`FIRMWARE` (vfat, Pi firmware + U-Boot) and `NIXOS_SD` (ext4 root). The root +partition grows to fill the card on first boot. + +## 3. First boot + +Insert the card, connect Ethernet and power. The Pi boots into the kiosk with +no pairing, so the screen shows the pairing wizard — with a camera it waits +for a QR; without one it says so and tells you to provision `VITE_SPIRE_SEED` +instead. It also comes up with sshd and password auth enabled, same as the +x86 installed configs — this is the provisioning window. + +Provision exactly as for a Sintra — from the dev box, with the spire seed +minted by spirekeeper: + +```bash +SPIRE_SEED='spire-seed:v1:…' bash deploy/nixos/provision-atm.sh 22 +``` + +That writes `/var/lib/bitspire/.env` and restarts the service; the seed +carries the relay and the LNbits transport pubkey, so nothing else is needed. +Alternatively, show the seed's QR to the machine's camera and let the +on-screen wizard do the same thing. Verify with: + +```bash +ssh bitspire@ 'journalctl -u bitspire -n 50 --no-pager | grep "\["' +# expect [Signer] Pairing to bunker … then [Lightning] LNbits client initialized +``` + +The dev-only `VITE_ATM_PRIVATE_KEY` fallback works here too for a bench +setup without a bunker — see `provision-atm.sh`'s header for the variables. + +## 4. Updating in place + +Once flashed, never re-flash for a software update. The `-installed` target +is the aarch64 twin of the fleet's rebuild ritual: + +```bash +sudo nixos-rebuild switch --flake \ + "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#rpi4-installed" +``` + +(Pi 5: `#rpi5-installed`.) With the closure on cachix this is a download, not +a build. If it starts compiling Electron on the Pi, the closure wasn't pushed +— stop, build on the aarch64 builder, `cachix push aiolabs`, retry. + +## 5. Wiring the peripherals + +- **Bill validator** — over a USB-serial adapter. The glue modules give + stable symlinks so `.env` never depends on enumeration order: + FTDI → `/dev/ttyValidator0`, CP210x → `/dev/ttyValidator1`, + CH340 → `/dev/ttyValidator2`. Two adapters of the *same* chip need + disambiguating by `KERNELS`/serial in the udev rule — do that at bring-up. + A validator wired straight to the GPIO UART (pins 14/15) also works: the + kernel console is pinned to `tty0` precisely so that UART stays free. +- **QR scanner** — USB HID keyboard-emulation, no configuration. +- **Touchscreen** — DSI or HDMI. X runs on the Pi's KMS driver. +- **Serial console for debugging** — there isn't one by default (see above). + Use SSH, or temporarily add `console=ttyAMA0,115200` to + `boot.kernelParams` in the glue module. + +## Hardware notes for 24/7 operation + +- **Storage.** SD cards have finite write endurance; for anything beyond a + bench build put root on a USB-SATA SSD (both boards) or NVMe (Pi 5). + `state.db` and the journal are the writers. +- **Thermal.** Both boards throttle under sustained load without cooling. + Heatsinks at minimum; the official active cooler for the Pi 5. +- **Power.** Brown-outs on Electron startup look like random reboots. Use the + official supply or one rated above the board's peak, never a hub. +- **Swap.** The runtime provisions a 2 GB swapfile so memory pressure degrades + instead of hard-freezing. On a 4 GB Pi 4 that is not optional. + +## Related + +- `deploy/nixos/README.md` — the x86 fleet pipeline this mirrors +- `docs/machine-installation.md` — why images rather than `nixos-install` +- `deploy/nixos/hardware/raspberry-pi-{4,5}.nix` — the per-board glue +- `flake.nix` — `piBoards`, `piBaseModules`, `mkPiInstalled`, `mkPiImage` diff --git a/flake.nix b/flake.nix index 705fd4c..029a879 100644 --- a/flake.nix +++ b/flake.nix @@ -308,14 +308,30 @@ fiatCode = fiatCodeForModel.${machineModel} or "USD"; }; + # Supported Pi boards, keyed by machine model. Each pairs the + # nixos-hardware board module (kernel, firmware, bootloader, device tree) + # with our own hardware glue (validator serial, kiosk display, no-suspend). + # Everything else in the Pi runtime is board-agnostic. + piBoards = { + rpi5 = { + hardware = nixos-hardware.nixosModules.raspberry-pi-5; + glue = ./deploy/nixos/hardware/raspberry-pi-5.nix; + }; + rpi4 = { + hardware = nixos-hardware.nixosModules.raspberry-pi-4; + glue = ./deploy/nixos/hardware/raspberry-pi-4.nix; + }; + }; + # Shared module list (everything EXCEPT the root fs and the sd-image # builder). atm-app/fiatCode are threaded in so both products share one # evaluated app closure. - piBaseModules = { machineModel, atm-app, fiatCode }: [ - nixos-hardware.nixosModules.raspberry-pi-5 + piBaseModules = { machineModel, atm-app, fiatCode }: + let board = piBoards.${machineModel}; in [ + board.hardware ./deploy/nixos/configuration.nix ./deploy/nixos/bitspire-atm.nix - ./deploy/nixos/hardware/raspberry-pi-5.nix + board.glue ({ config, lib, pkgs, pkgs-unstable, ... }: { services.bitspire = { enable = true; @@ -471,6 +487,12 @@ rpi5-installed = mkPiInstalled "rpi5"; rpi5-image = mkPiImage "rpi5"; + # Raspberry Pi 4 (aarch64) — same runtime and products as rpi5, on the + # previous-generation board (see deploy/nixos/hardware/raspberry-pi-4.nix + # for what differs). 4 GB minimum for Electron; 8 GB comfortable. + rpi4-installed = mkPiInstalled "rpi4"; + rpi4-image = mkPiImage "rpi4"; + # USB-bootable variant of batm3-installed. This is the config the # flashed USB stick actually runs — distinct fs labels so stage-1 can't # latch the internal drive, nofail /boot, no growPartition, autoUpgrade @@ -685,13 +707,16 @@ iso = self.nixosConfigurations.douro.config.system.build.isoImage; }; - # ── Packages (aarch64-linux — Raspberry Pi 5 build) ─────────── - # Flashable SD image for the Pi 5. Build on an aarch64 builder (native Pi - # / arm box / `boot.binfmt` emulation on this x86 host): + # ── Packages (aarch64-linux — Raspberry Pi builds) ──────────── + # Flashable SD images for the Pi boards. Build on an aarch64 builder + # (native Pi / arm box / `boot.binfmt` emulation on this x86 host): # nix build .#packages.aarch64-linux.sd-image-rpi5 + # nix build .#packages.aarch64-linux.sd-image-rpi4 packages.aarch64-linux = { sd-image-rpi5 = self.nixosConfigurations.rpi5-image.config.system.build.sdImage; atm-app-rpi5 = mkAtmAppAarch64 { model = "rpi5"; fiatCode = "USD"; }; + sd-image-rpi4 = self.nixosConfigurations.rpi4-image.config.system.build.sdImage; + atm-app-rpi4 = mkAtmAppAarch64 { model = "rpi4"; fiatCode = "USD"; }; }; } // diff --git a/nix/mkAtmApp.nix b/nix/mkAtmApp.nix index 68a4c37..272bd33 100644 --- a/nix/mkAtmApp.nix +++ b/nix/mkAtmApp.nix @@ -172,6 +172,27 @@ pkgs.stdenv.mkDerivation (finalAttrs: { cp -rL "$bcpp_store/node_modules/@serialport/bindings-cpp/prebuilds" $out/node_modules/@serialport/bindings-cpp/prebuilds cp "$bcpp_store/node_modules/@serialport/bindings-cpp/package.json" $out/node_modules/@serialport/bindings-cpp/package.json + # bindings-cpp ships prebuilds for every platform it supports: android, + # win32, darwin, and linux for several arches in both glibc and musl. Keep + # only the one this system can actually load. + # + # This is load-bearing on aarch64, not just tidiness. On x86_64 autoPatchelf + # skipped the foreign prebuilds because their ELF architecture did not match + # the host. On aarch64 the android-arm64 prebuild IS the host architecture, + # so autoPatchelf tries to patch it and fails hunting for Android's + # liblog.so and libc++_shared.so, which do not exist on NixOS. First Pi + # build died exactly there. + # + # Pruning rather than extending autoPatchelfIgnoreMissingDeps: teaching + # autoPatchelf to tolerate a binary we never load, for a platform we do not + # target, is the wrong shape of fix. The musl entry in that list below is + # the same problem solved the other way, and is now redundant. + keep_prebuild=${if pkgs.stdenv.hostPlatform.isAarch64 then "linux-arm64" else "linux-x64"} + find $out/node_modules/@serialport/bindings-cpp/prebuilds -mindepth 1 -maxdepth 1 \ + ! -name "$keep_prebuild" -exec rm -rf {} + + rm -f $out/node_modules/@serialport/bindings-cpp/prebuilds/*/*.musl.node + echo "serialport prebuilds kept: $(ls $out/node_modules/@serialport/bindings-cpp/prebuilds)/$(ls $out/node_modules/@serialport/bindings-cpp/prebuilds/"$keep_prebuild")" + copy_pnpm_pkg @serialport/bindings-interface $out/node_modules/@serialport/bindings-interface copy_pnpm_pkg @serialport/binding-mock $out/node_modules/@serialport/binding-mock diff --git a/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts b/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts index 95c1607..bafb50d 100644 --- a/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts +++ b/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts @@ -6,6 +6,7 @@ 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'. @@ -20,6 +21,27 @@ 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) @@ -32,6 +54,14 @@ 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) @@ -82,11 +112,14 @@ describe('Apex RS-232 protocol', () => { }) describe('parseResponse', () => { - const usd = [1, 5, 10, 20, 50, 100] - const resolve = (ch: number) => usd[ch - 1] ?? null + // 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) it('resolves the escrowed note denomination from the credit channel', () => { - // state=escrowed, event=present, credit=channel 4 ($20) + // 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. const frame = Buffer.from([ 0x02, 0x0b, @@ -102,7 +135,7 @@ describe('Apex RS-232 protocol', () => { ]) const r = parseResponse(frame, resolve) expect(r.status).toBe('billsRead') - expect(r.bill?.denomination).toBe(20) + expect(r.bill?.denomination).toBe(10) }) it('returns no bill when no channel is credited', () => { @@ -124,8 +157,9 @@ describe('Apex RS-232 protocol', () => { expect(r.bill).toBeUndefined() }) - it('yields a null denomination for an unmapped channel', () => { - // channel 7 not present in the 6-entry USD table + 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. const frame = Buffer.from([ 0x02, 0x0b, @@ -140,7 +174,25 @@ describe('Apex RS-232 protocol', () => { 0x00, ]) const r = parseResponse(frame, resolve) - expect(r.bill?.denomination).toBeNull() + 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() }) }) }) diff --git a/packages/hal/src/validators/apex/apex-rs232.ts b/packages/hal/src/validators/apex/apex-rs232.ts index d8bcb7e..e8fc12f 100644 --- a/packages/hal/src/validators/apex/apex-rs232.ts +++ b/packages/hal/src/validators/apex/apex-rs232.ts @@ -6,23 +6,27 @@ * * 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 + * 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 * 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 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 + * [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 * [6] ETX 0x03 - * [7] CHK XOR of bytes [1..5] + * [7] CHK XOR of all bytes except STX, ETX and itself * - * Frame (acceptor → host), length-prefixed like the host frame; the fields - * this driver consumes: + * Frame (acceptor → host), 11 bytes — STX, LEN, CTRL, six data bytes, ETX, + * CHK. 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 @@ -39,8 +43,11 @@ const STX = 0x02 const ETX = 0x03 const HOST_FRAME_LEN = 0x08 -// Host command byte (frame[4]) -const CMD_STACK = 0x20 +// 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 // Response STATE byte (frame[3]) bit masks const STATE_IDLING = 0x01 @@ -90,10 +97,18 @@ export interface ApexRs232Config { // 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 { +/** + * 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 let cs = 0x00 - for (let i = 1; i <= 5; i++) cs ^= frame[i] ?? 0 + for (let i = 1; i <= n - 3; i++) cs ^= frame[i] ?? 0 return cs } @@ -278,18 +293,25 @@ export class ApexRs232 extends EventEmitter { /** 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 + // 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 this.ack ^= 0x01 - this.serial?.write(buildFrame(this.ack, enableByte, cmdByte)) + this.serial?.write(buildFrame(this.ack, this.enabledMask, cmdByte)) } stack(): void { @@ -331,13 +353,20 @@ export class ApexRs232 extends EventEmitter { 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)') + // 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 } const result = parseResponse(frame, this.denomForChannel) diff --git a/packages/hal/src/validators/apex/denominations.ts b/packages/hal/src/validators/apex/denominations.ts index 9874da4..95410ed 100644 --- a/packages/hal/src/validators/apex/denominations.ts +++ b/packages/hal/src/validators/apex/denominations.ts @@ -1,19 +1,29 @@ /** * 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. + * 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: * - * Defaults follow Pyramid's standard datasets (US = $1/$5/$10/$20/$50/$100; - * $2 channel omitted as it's rarely enabled). + * 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. */ export const denominations: Record = { - USD: [1, 5, 10, 20, 50, 100], + USD: [1, 2, 5, 10, 20, 50, 100], EUR: [5, 10, 20, 50, 100, 200, 500], GBP: [5, 10, 20, 50], CAD: [5, 10, 20, 50, 100], diff --git a/packages/hal/src/validators/apex/index.ts b/packages/hal/src/validators/apex/index.ts index 463ee06..5d98c03 100644 --- a/packages/hal/src/validators/apex/index.ts +++ b/packages/hal/src/validators/apex/index.ts @@ -48,6 +48,11 @@ export class ApexValidator extends EventEmitter implements BillValidator { constructor(config: ValidatorConfig) { super() this.config = config + // Seed from config, as id003 effectively does by threading config.fiatCode + // into its rs232 config. Nothing in the app calls setFiatCode(), so a + // driver that relies on it alone resolves every credit channel to null and + // rejects every note. setFiatCode() stays available as a later override. + this.fiatCode = config.fiatCode ?? config.rs232.fiatCode ?? null this._throttledError = throttle((err: Error) => this.emit('error', err), 2000) } @@ -76,7 +81,20 @@ export class ApexValidator extends EventEmitter implements BillValidator { 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') + // Say WHICH of the two causes this is. "unmapped channel" alone reads + // as a hardware/dataset mismatch and sent us looking at DIP switches + // when the real cause was a null fiat code rejecting every note. + if (!this.fiatCode) { + console.error( + '[APEX] Bill rejected: no fiat code set on the driver, so NO channel ' + + 'can resolve to a value. Every note will be returned until this is fixed.' + ) + } else { + console.log( + `[APEX] Bill rejected: channel ${bill.code || '?'} is not mapped in the ` + + `${this.fiatCode} dataset — check the acceptor's configuration card` + ) + } this.rs232?.reject() return } @@ -92,6 +110,13 @@ export class ApexValidator extends EventEmitter implements BillValidator { this.fsm.on('standby', () => this.emit('standby')) this.fsm.on('error', (err: Error) => this.emit('error', err)) + if (!this.fiatCode) { + console.error( + '[APEX] starting with NO fiat code — denomination lookup will return null ' + + 'for every credit channel and the acceptor will reject every note.' + ) + } + this.rs232.open((err) => { if (err) return cb(err) this.rs232!.reset() // start disabled diff --git a/packages/hal/src/validators/ebds/index.ts b/packages/hal/src/validators/ebds/index.ts index 44dd540..52a749b 100644 --- a/packages/hal/src/validators/ebds/index.ts +++ b/packages/hal/src/validators/ebds/index.ts @@ -53,6 +53,11 @@ export class EbdsValidator extends EventEmitter implements BillValidator { constructor(config: ValidatorConfig) { super() this.config = config + // Seed from config, as id003 effectively does by threading config.fiatCode + // into its rs232 config. Nothing in the app calls setFiatCode(), so a + // driver that relies on it alone resolves every credit channel to null and + // rejects every note. setFiatCode() stays available as a later override. + this.fiatCode = config.fiatCode ?? config.rs232.fiatCode ?? null this._throttledError = throttle((err: Error) => this.emit('error', err), 2000) }