feat: Raspberry Pi 5 (aarch64) target + Pyramid Apex RS-232 bill validator #87
9 changed files with 1094 additions and 4 deletions
68
deploy/nixos/hardware/raspberry-pi-5.nix
Normal file
68
deploy/nixos/hardware/raspberry-pi-5.nix
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Raspberry Pi 5 hardware module (aarch64).
|
||||
#
|
||||
# The bitSpire equivalent of upboard.nix, but for a Pi 5 instead of the x86
|
||||
# UP Board. Kernel, firmware, GPU and bootloader come from the nixos-hardware
|
||||
# `raspberry-pi-5` module (added alongside this one in flake.nix); here we set
|
||||
# only the bitSpire-specific hardware glue: serial for the bill validators,
|
||||
# the kiosk display driver, and no-suspend.
|
||||
#
|
||||
# Wiring the parts (see docs) to a Pi 5:
|
||||
# - Bill validators (Apex 7600 RS-232, NV10 USB+): easiest via one USB-serial
|
||||
# adapter each → stable /dev/ttyValidator* symlinks below. A GPIO-UART wire
|
||||
# is also supported (primary UART enabled, console kept off it).
|
||||
# - QR scanner: USB HID, no config.
|
||||
# - 7" touchscreen: DSI or HDMI; the vc4/v3d KMS driver (from nixos-hardware)
|
||||
# backs X.
|
||||
# - Boot/root: USB-SATA SSD or the SD card.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
# aarch64 target. (The flake instantiates this config with aarch64 pkgs; this
|
||||
# line documents/asserts it.)
|
||||
nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
|
||||
|
||||
# Bootloader: the aarch64 sd-image uses the extlinux-compatible generator;
|
||||
# nixos-hardware's rpi5 module wires the firmware/u-boot. No systemd-boot
|
||||
# (that's x86/UEFI, as on the UP Board).
|
||||
boot.loader.grub.enable = lib.mkDefault false;
|
||||
boot.loader.generic-extlinux-compatible.enable = lib.mkDefault true;
|
||||
|
||||
# Primary UART (GPIO 14/15) available for a GPIO-wired validator. Keep the
|
||||
# serial console OFF it so the validator owns the line — mirrors upboard.nix
|
||||
# keeping ttyS4 free for the dispenser. USB-serial adapters are unaffected.
|
||||
#
|
||||
# Not mkDefault: kernelParams is list-merged, and only definitions at the
|
||||
# highest priority survive. nixpkgs sets loglevel/lsm at normal priority, so
|
||||
# a mkDefault list here is dropped entirely — and with no console= at all
|
||||
# the kernel falls back to the device tree's stdout-path, i.e. this UART.
|
||||
boot.kernelParams = [ "console=tty0" ];
|
||||
|
||||
# X uses the Pi GPU's kernel modesetting driver (vc4/v3d KMS from
|
||||
# nixos-hardware). Electron renders through it as on the UP Board.
|
||||
services.xserver.videoDrivers = lib.mkDefault [ "modesetting" ];
|
||||
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
|
||||
# Kiosk: never sleep.
|
||||
systemd.targets = {
|
||||
sleep.enable = false;
|
||||
suspend.enable = false;
|
||||
hibernate.enable = false;
|
||||
hybrid-sleep.enable = false;
|
||||
};
|
||||
|
||||
# Stable device symlinks for USB-serial bill-validator adapters, so the ATM
|
||||
# config can point at /dev/ttyValidator0 regardless of enumeration order.
|
||||
# Covers the common bridges (FTDI, Silicon Labs CP210x, WCH CH340). If two
|
||||
# adapters of the SAME chip are used, disambiguate by KERNELS/serial instead —
|
||||
# tune during bring-up. The NV10 USB+ presents its own USB CDC serial; add its
|
||||
# idVendor/idProduct here once known.
|
||||
services.udev.extraRules = lib.mkAfter ''
|
||||
# FTDI (e.g. FT232R) → ttyValidator0
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="ttyValidator0"
|
||||
# Silicon Labs CP210x → ttyValidator1
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="ttyValidator1"
|
||||
# WCH CH340 → ttyValidator2
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="ttyValidator2"
|
||||
'';
|
||||
}
|
||||
34
flake.lock
generated
34
flake.lock
generated
|
|
@ -405,6 +405,24 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixos-hardware": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786867632,
|
||||
"narHash": "sha256-ez+ubZlA1RtdjCB18a6zJ9M4u8qoPDy08EcnsW5M3Xw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixos-hardware",
|
||||
"rev": "ff17823245ab9ff7bcae6acf950bd89cba82c38c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixos-hardware",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1773222311,
|
||||
|
|
@ -482,6 +500,19 @@
|
|||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1767892417,
|
||||
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
|
||||
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1779467186,
|
||||
"narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=",
|
||||
|
|
@ -503,7 +534,8 @@
|
|||
"determinate": "determinate",
|
||||
"devenv": "devenv",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixos-hardware": "nixos-hardware",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
|
|
|
|||
189
flake.nix
189
flake.nix
|
|
@ -36,9 +36,12 @@
|
|||
url = "git+ssh://forgejo@git.atitlan.io/aiolabs/atm-tui.git";
|
||||
inputs.nixpkgs.follows = "nixpkgs-unstable";
|
||||
};
|
||||
|
||||
# Raspberry Pi 5 (aarch64) hardware support for the DIY Pi build.
|
||||
nixos-hardware.url = "github:NixOS/nixos-hardware";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, rust-overlay, devenv, determinate, atm-tui }:
|
||||
outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, rust-overlay, devenv, determinate, atm-tui, nixos-hardware }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
|
||||
|
|
@ -59,6 +62,24 @@
|
|||
src = self;
|
||||
};
|
||||
|
||||
# aarch64 (Raspberry Pi 5) toolchain — a parallel set of pkgs + app
|
||||
# builder for the DIY Pi build. Kept fully separate from the x86 fleet
|
||||
# path so nothing above changes.
|
||||
pkgsAarch64 = import nixpkgs {
|
||||
system = "aarch64-linux";
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
pkgsUnstableAarch64 = import nixpkgs-unstable {
|
||||
system = "aarch64-linux";
|
||||
config.allowUnfree = true;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
mkAtmAppAarch64 = import ./nix/mkAtmApp.nix {
|
||||
pkgs = pkgsAarch64;
|
||||
pkgs-unstable = pkgsUnstableAarch64;
|
||||
src = self;
|
||||
};
|
||||
|
||||
# Fiat code per machine model
|
||||
fiatCodeForModel = {
|
||||
douro = "GTQ";
|
||||
|
|
@ -261,6 +282,154 @@
|
|||
})
|
||||
];
|
||||
};
|
||||
|
||||
# Raspberry Pi 5 (aarch64) DIY build. Two products from one shared runtime:
|
||||
# - mkPiInstalled: the in-place rebuild target. `nixos-rebuild switch
|
||||
# --flake .#rpi5-installed` (or the git+ssh remote form) targets this.
|
||||
# Declares the flashed media's own root fs (NIXOS_SD / FIRMWARE) and
|
||||
# NOTHING image-specific, so a switch on a running Pi never trips over
|
||||
# the sd-image builder.
|
||||
# - mkPiImage: the same runtime + the aarch64 sd-image module, whose
|
||||
# system.build.sdImage is the flashable artifact. The module supplies
|
||||
# its OWN NIXOS_SD/FIRMWARE fileSystems + u-boot firmware, so we must
|
||||
# not re-declare the root fs here (double definition = eval conflict).
|
||||
#
|
||||
# The runtime mirrors mkInstalledConfig (bitspire service, env activation,
|
||||
# electron override, cache substituters) on aarch64 + Pi hardware, but
|
||||
# deliberately drops the x86 fleet machinery for first bring-up: no
|
||||
# determinate, no atm-tui (add once it ships an aarch64 package). Any Pi
|
||||
# build needs an aarch64 builder (native Pi / arm box / binfmt emulation) —
|
||||
# the app closure won't build on x86.
|
||||
mkPiRuntime = machineModel: {
|
||||
atm-app = mkAtmAppAarch64 {
|
||||
model = machineModel;
|
||||
fiatCode = fiatCodeForModel.${machineModel} or "USD";
|
||||
};
|
||||
fiatCode = fiatCodeForModel.${machineModel} or "USD";
|
||||
};
|
||||
|
||||
# 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
|
||||
./deploy/nixos/configuration.nix
|
||||
./deploy/nixos/bitspire-atm.nix
|
||||
./deploy/nixos/hardware/raspberry-pi-5.nix
|
||||
({ config, lib, pkgs, pkgs-unstable, ... }: {
|
||||
services.bitspire = {
|
||||
enable = true;
|
||||
appDir = "${atm-app}";
|
||||
};
|
||||
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeShellScriptBin "fund-atm" ''
|
||||
exec ${pkgs-unstable.nodejs}/bin/node ${atm-app}/dist-electron/fund-atm.bundle.cjs "$@"
|
||||
'')
|
||||
];
|
||||
environment.variables.ATM_DB_PATH = "/var/lib/bitspire/state.db";
|
||||
|
||||
boot.kernel.sysctl."kernel.unprivileged_userns_clone" = 1;
|
||||
security.sudo.wheelNeedsPassword = false;
|
||||
|
||||
# Pull from the aiolabs binary cache so a `nixos-rebuild switch`
|
||||
# (local or the git+ssh remote form) substitutes the heavy aarch64
|
||||
# closure instead of compiling on the Pi. Mirrors the x86 fleet's
|
||||
# nix.settings, minus max-jobs/timeout — the Pi 5 can actually build
|
||||
# locally if it must, so we don't want the 60s watchdog killing a
|
||||
# legitimate first build.
|
||||
nix.settings = {
|
||||
trusted-users = [ "root" "bitspire" ];
|
||||
substituters = [ "https://cache.nixos.org" "https://aiolabs.cachix.org" ];
|
||||
trusted-public-keys = [
|
||||
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
|
||||
"aiolabs.cachix.org-1:PrAjsGU9PE77tFKP2+iO+mgR88c4xv3utM9JmpTblUQ="
|
||||
];
|
||||
};
|
||||
|
||||
# Same first-boot env seed as the x86 installed configs.
|
||||
system.activationScripts.bitspire-env = ''
|
||||
mkdir -p /var/lib/bitspire
|
||||
if [ ! -f /var/lib/bitspire/.env ]; then
|
||||
cp ${pkgs.writeText "bitspire-env-default" (''
|
||||
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||
VITE_LAMASSU_FIAT_CODE=${fiatCode}
|
||||
VITE_SPIRE_SEED=
|
||||
ELECTRON_FORCE_PROD=1
|
||||
DISPLAY=:0
|
||||
'' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
|
||||
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
|
||||
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
|
||||
VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey}
|
||||
'')} /var/lib/bitspire/.env
|
||||
chmod 600 /var/lib/bitspire/.env
|
||||
chown bitspire:bitspire /var/lib/bitspire/.env
|
||||
fi
|
||||
'';
|
||||
|
||||
# Electron runtime override (same flags as the x86 fleet, aarch64
|
||||
# electron). No eDP display-reset here — that's UP-Board-specific;
|
||||
# the Pi drives HDMI/DSI directly.
|
||||
systemd.services.bitspire.serviceConfig = {
|
||||
EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env";
|
||||
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
|
||||
ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-software-rasterizer --enable-logging ${atm-app}";
|
||||
MemoryMax = lib.mkForce "2G";
|
||||
NoNewPrivileges = lib.mkForce false;
|
||||
ProtectSystem = lib.mkForce false;
|
||||
ProtectHome = lib.mkForce false;
|
||||
PrivateTmp = lib.mkForce false;
|
||||
DevicePolicy = lib.mkForce "auto";
|
||||
DeviceAllow = lib.mkForce [ "char-* rw" ];
|
||||
};
|
||||
|
||||
swapDevices = [{ device = "/var/swapfile"; size = 2048; }];
|
||||
boot.tmp.cleanOnBoot = true;
|
||||
services.openssh.settings.PasswordAuthentication = lib.mkForce true;
|
||||
})
|
||||
];
|
||||
|
||||
# In-place rebuild target — declares the flashed media's own filesystems
|
||||
# (the labels mkPiImage's sd-image module writes), no image builder.
|
||||
mkPiInstalled = machineModel:
|
||||
let rt = mkPiRuntime machineModel; in
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "aarch64-linux";
|
||||
specialArgs = {
|
||||
pkgs-unstable = pkgsUnstableAarch64;
|
||||
inherit (rt) atm-app;
|
||||
};
|
||||
modules = (piBaseModules { inherit machineModel; inherit (rt) atm-app fiatCode; }) ++ [
|
||||
{
|
||||
fileSystems."/" = {
|
||||
device = "/dev/disk/by-label/NIXOS_SD";
|
||||
fsType = "ext4";
|
||||
};
|
||||
fileSystems."/boot/firmware" = {
|
||||
device = "/dev/disk/by-label/FIRMWARE";
|
||||
fsType = "vfat";
|
||||
options = [ "nofail" "noauto" ];
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# Flashable SD/USB image — same runtime + the aarch64 sd-image builder,
|
||||
# which brings its own NIXOS_SD/FIRMWARE fileSystems and the u-boot
|
||||
# firmware. Its system.build.sdImage is exposed as
|
||||
# packages.aarch64-linux.sd-image-rpi5.
|
||||
mkPiImage = machineModel:
|
||||
let rt = mkPiRuntime machineModel; in
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "aarch64-linux";
|
||||
specialArgs = {
|
||||
pkgs-unstable = pkgsUnstableAarch64;
|
||||
inherit (rt) atm-app;
|
||||
};
|
||||
modules = (piBaseModules { inherit machineModel; inherit (rt) atm-app fiatCode; }) ++ [
|
||||
(nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
# ── NixOS Configurations (top-level, not per-system) ──────────
|
||||
|
|
@ -293,6 +462,15 @@
|
|||
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
|
||||
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
|
||||
|
||||
# Raspberry Pi 5 (aarch64) DIY build — Apex 7600 / NV10 over USB-serial.
|
||||
# rpi5-installed → in-place rebuild target:
|
||||
# sudo nixos-rebuild switch --flake \
|
||||
# "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=<branch>#rpi5-installed"
|
||||
# rpi5-image → source of the flashable image
|
||||
# (packages.aarch64-linux.sd-image-rpi5).
|
||||
rpi5-installed = mkPiInstalled "rpi5";
|
||||
rpi5-image = mkPiImage "rpi5";
|
||||
|
||||
# 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
|
||||
|
|
@ -506,6 +684,15 @@
|
|||
# Backwards compat
|
||||
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):
|
||||
# nix build .#packages.aarch64-linux.sd-image-rpi5
|
||||
packages.aarch64-linux = {
|
||||
sd-image-rpi5 = self.nixosConfigurations.rpi5-image.config.system.build.sdImage;
|
||||
atm-app-rpi5 = mkAtmAppAarch64 { model = "rpi5"; fiatCode = "USD"; };
|
||||
};
|
||||
}
|
||||
//
|
||||
# ── Dev shells (per-system via flake-utils) ───────────────────
|
||||
|
|
|
|||
146
packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts
Normal file
146
packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
105
packages/hal/src/validators/apex/apex-fsm.ts
Normal file
105
packages/hal/src/validators/apex/apex-fsm.ts
Normal 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
|
||||
}
|
||||
}
|
||||
363
packages/hal/src/validators/apex/apex-rs232.ts
Normal file
363
packages/hal/src/validators/apex/apex-rs232.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
30
packages/hal/src/validators/apex/denominations.ts
Normal file
30
packages/hal/src/validators/apex/denominations.ts
Normal 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
|
||||
}
|
||||
155
packages/hal/src/validators/apex/index.ts
Normal file
155
packages/hal/src/validators/apex/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue