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"
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773222311,
|
"lastModified": 1773222311,
|
||||||
|
|
@ -482,6 +500,19 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"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": {
|
"locked": {
|
||||||
"lastModified": 1779467186,
|
"lastModified": 1779467186,
|
||||||
"narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=",
|
"narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=",
|
||||||
|
|
@ -503,7 +534,8 @@
|
||||||
"determinate": "determinate",
|
"determinate": "determinate",
|
||||||
"devenv": "devenv",
|
"devenv": "devenv",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs_3",
|
"nixos-hardware": "nixos-hardware",
|
||||||
|
"nixpkgs": "nixpkgs_4",
|
||||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||||
"rust-overlay": "rust-overlay"
|
"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";
|
url = "git+ssh://forgejo@git.atitlan.io/aiolabs/atm-tui.git";
|
||||||
inputs.nixpkgs.follows = "nixpkgs-unstable";
|
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
|
let
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
|
|
||||||
|
|
@ -59,6 +62,24 @@
|
||||||
src = self;
|
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
|
# Fiat code per machine model
|
||||||
fiatCodeForModel = {
|
fiatCodeForModel = {
|
||||||
douro = "GTQ";
|
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
|
in
|
||||||
{
|
{
|
||||||
# ── NixOS Configurations (top-level, not per-system) ──────────
|
# ── NixOS Configurations (top-level, not per-system) ──────────
|
||||||
|
|
@ -293,6 +462,15 @@
|
||||||
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
|
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
|
||||||
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.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
|
# USB-bootable variant of batm3-installed. This is the config the
|
||||||
# flashed USB stick actually runs — distinct fs labels so stage-1 can't
|
# flashed USB stick actually runs — distinct fs labels so stage-1 can't
|
||||||
# latch the internal drive, nofail /boot, no growPartition, autoUpgrade
|
# latch the internal drive, nofail /boot, no growPartition, autoUpgrade
|
||||||
|
|
@ -506,6 +684,15 @@
|
||||||
# Backwards compat
|
# Backwards compat
|
||||||
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
|
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) ───────────────────
|
# ── 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 { Id003 } from './id003/index.js'
|
||||||
export { EbdsValidator } from './ebds/index.js'
|
export { EbdsValidator } from './ebds/index.js'
|
||||||
|
export { ApexValidator } from './apex/index.js'
|
||||||
export type { ValidatorConfig, BillValidator, BillData } from '../types.js'
|
export type { ValidatorConfig, BillValidator, BillData } from '../types.js'
|
||||||
|
|
||||||
import { Id003 } from './id003/index.js'
|
import { Id003 } from './id003/index.js'
|
||||||
import { EbdsValidator } from './ebds/index.js'
|
import { EbdsValidator } from './ebds/index.js'
|
||||||
|
import { ApexValidator } from './apex/index.js'
|
||||||
import type { ValidatorConfig, BillValidator } from '../types.js'
|
import type { ValidatorConfig, BillValidator } from '../types.js'
|
||||||
|
|
||||||
export type ValidatorType = 'id003' | 'ebds'
|
export type ValidatorType = 'id003' | 'ebds' | 'apex'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a bill validator instance
|
* 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
|
* @param config Validator configuration
|
||||||
*/
|
*/
|
||||||
export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator {
|
export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator {
|
||||||
|
|
@ -25,6 +27,8 @@ export function createValidator(type: ValidatorType, config: ValidatorConfig): B
|
||||||
return Id003.factory(config)
|
return Id003.factory(config)
|
||||||
case 'ebds':
|
case 'ebds':
|
||||||
return EbdsValidator.factory(config)
|
return EbdsValidator.factory(config)
|
||||||
|
case 'apex':
|
||||||
|
return ApexValidator.factory(config)
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown validator type: ${type}`)
|
throw new Error(`Unknown validator type: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue