feat: Raspberry Pi 5 (aarch64) target + Pyramid Apex RS-232 bill validator #87

Open
padreug wants to merge 4 commits from feat/rpi5-apex-hal into dev
9 changed files with 1094 additions and 4 deletions

View 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
View file

@ -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
View file

@ -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) ───────────────────

View file

@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest'
import {
computeChecksum,
buildFrame,
creditChannel,
parseStatus,
parseResponse,
} from '../apex-rs232.js'
// Cassette-present bit; OR it into event bytes so parseStatus doesn't short to
// 'stackerOpen'.
const PRESENT = 0x10
describe('Apex RS-232 protocol', () => {
describe('computeChecksum', () => {
it('XORs bytes 1..5 (matches the Pyramid reference poll frame)', () => {
// 02 08 10 7F 00 00 03 -> checksum 0x67
const frame = [0x02, 0x08, 0x10, 0x7f, 0x00, 0x00, 0x03, 0x00]
expect(computeChecksum(frame)).toBe(0x67)
})
})
describe('buildFrame', () => {
it('lays out the 8-byte poll frame with the ACK bit and checksum', () => {
const f = buildFrame(0, 0x7f, 0x00)
expect([...f]).toEqual([0x02, 0x08, 0x10, 0x7f, 0x00, 0x00, 0x03, 0x67])
})
it('sets the ACK bit in the control byte and recomputes the checksum', () => {
const f = buildFrame(1, 0x7f, 0x00)
expect(f[2]).toBe(0x11)
expect(f[7]).toBe(0x66)
})
it('sets the stack command bit (0x20) in the command byte', () => {
const f = buildFrame(0, 0x7f, 0x20)
expect(f[4]).toBe(0x20)
expect(f[7]).toBe(computeChecksum([...f]))
})
it('masks the enable byte to a single note channel', () => {
const f = buildFrame(0, 0x01, 0x00)
expect(f[3]).toBe(0x01)
})
})
describe('creditChannel', () => {
it('extracts the 1-based channel from bits 3..5', () => {
expect(creditChannel(0x00)).toBe(0)
expect(creditChannel(0x08)).toBe(1)
expect(creditChannel(0x28)).toBe(5)
expect(creditChannel(0x38)).toBe(7)
})
})
describe('parseStatus', () => {
it('maps each state bit to its status', () => {
expect(parseStatus(0x01, PRESENT)).toBe('standby')
expect(parseStatus(0x02, PRESENT)).toBe('accepting')
expect(parseStatus(0x04, PRESENT)).toBe('billsRead')
expect(parseStatus(0x08, PRESENT)).toBe('stacking')
expect(parseStatus(0x10, PRESENT)).toBe('billsValid')
expect(parseStatus(0x20, PRESENT)).toBe('returning')
expect(parseStatus(0x40, PRESENT)).toBe('billsRejected')
})
it('reports stackerOpen when the cassette-present bit is clear', () => {
expect(parseStatus(0x01, 0x00)).toBe('stackerOpen')
expect(parseStatus(0x10, 0x00)).toBe('stackerOpen') // even mid-stack
})
it('prioritises jam and reject events over motion', () => {
expect(parseStatus(0x08, PRESENT | 0x04)).toBe('jam')
expect(parseStatus(0x04, PRESENT | 0x02)).toBe('billsRejected') // rejected
expect(parseStatus(0x04, PRESENT | 0x01)).toBe('billsRejected') // cheated
})
it('prefers a terminal stacked state over a combined idling bit', () => {
// 0x11 = stacked | idling
expect(parseStatus(0x11, PRESENT)).toBe('billsValid')
})
})
describe('parseResponse', () => {
const usd = [1, 5, 10, 20, 50, 100]
const resolve = (ch: number) => usd[ch - 1] ?? null
it('resolves the escrowed note denomination from the credit channel', () => {
// state=escrowed, event=present, credit=channel 4 ($20)
const frame = Buffer.from([
0x02,
0x0b,
0x10,
0x04,
PRESENT,
0x20,
0x00,
0x00,
0x00,
0x03,
0x00,
])
const r = parseResponse(frame, resolve)
expect(r.status).toBe('billsRead')
expect(r.bill?.denomination).toBe(20)
})
it('returns no bill when no channel is credited', () => {
const frame = Buffer.from([
0x02,
0x0b,
0x10,
0x01,
PRESENT,
0x00,
0x00,
0x00,
0x00,
0x03,
0x00,
])
const r = parseResponse(frame, resolve)
expect(r.status).toBe('standby')
expect(r.bill).toBeUndefined()
})
it('yields a null denomination for an unmapped channel', () => {
// channel 7 not present in the 6-entry USD table
const frame = Buffer.from([
0x02,
0x0b,
0x10,
0x10,
PRESENT,
0x38,
0x00,
0x00,
0x00,
0x03,
0x00,
])
const r = parseResponse(frame, resolve)
expect(r.bill?.denomination).toBeNull()
})
})
})

View file

@ -0,0 +1,105 @@
/**
* Apex Status Tracker
*
* Like EBDS, the Apex RS-232 protocol reports status directly via bits in each
* reply, so there's no complex command/response state machine — just dedupe
* status changes and translate them into the BillValidator event interface.
*
* Status flow:
* standby → accepting → billsRead(escrow) → billsValid(stacked)
* → billsRejected(returned)
*/
import { EventEmitter } from 'node:events'
import type { ApexParseResult } from './apex-rs232.js'
// A note shouldn't sit in escrow long; warn if the host's stack/return
// decision lags, which on most acceptors risks an autonomous timeout-return.
const ESCROW_WATCHDOG_WARN_MS = 500
export class ApexFsm extends EventEmitter {
private currentStatus: string | null = null
private escrowOpenedAt: number | null = null
static factory(): ApexFsm {
return new ApexFsm()
}
/** Process a parsed Apex reply and emit status-change events. */
process(result: ApexParseResult): void {
const { status, bill } = result
if (!status) return
if (this.currentStatus === status) return // dedupe continuous polling
const prev = this.currentStatus
this.currentStatus = status
console.log('[APEX] %d status: %s → %s', Date.now(), prev ?? '(init)', status)
if (prev === 'billsRead' && this.escrowOpenedAt !== null) {
const elapsed = Date.now() - this.escrowOpenedAt
this.escrowOpenedAt = null
if (elapsed > ESCROW_WATCHDOG_WARN_MS) {
console.warn(
'[APEX] escrow held %dms before %s — host decision latency high',
elapsed,
status
)
}
}
switch (status) {
case 'accepting':
this.emit('accepting')
break
case 'billsRead':
// Bill in escrow. Apex doesn't report currency, so there's no
// per-note currency check here (the enable mask already gates which
// channels the acceptor will escrow).
this.escrowOpenedAt = Date.now()
this.emit('billsAccepted')
// Match EBDS: emit billsRead on the next tick.
process.nextTick(() => this.emit('billsRead', bill ?? { denomination: null, code: '' }))
break
case 'stacking':
this.emit('stacking')
break
case 'returning':
if (prev === 'billsRead') {
console.warn(
'[APEX] returning straight from escrow with no host reject — watch for autonomous return'
)
}
this.emit('returning')
break
case 'billsValid':
if (bill && !bill.denomination) return // stacked with no denom (e.g. cashbox reinsert)
this.emit('billsValid')
break
case 'billsRejected':
this.emit('billsRejected')
break
case 'jam':
this.emit('error', new Error('Bill validator jam'))
break
case 'stackerOpen':
this.emit('stackerOpen')
break
case 'standby':
this.emit('standby')
break
}
}
reset(): void {
this.currentStatus = null
this.escrowOpenedAt = null
}
}

View file

@ -0,0 +1,363 @@
/**
* Pyramid Apex RS-232 Protocol Layer
*
* Serial communication for Pyramid Technologies Apex-series bill acceptors
* (Apex 5000 / 7000 / 7600) running their RS-232 interface.
*
* Implemented from Pyramid's PUBLIC protocol facts only — the wire format,
* bit masks and serial parameters documented in Pyramid's "RS-232 Serial
* Interface Specification" (https://pyramidacceptors.com/pdf/RS_232.pdf) and
* mirrored by their published integrator samples. No third-party (or
* lamassu-machine) source is copied; the byte layout below is a functional
* spec, re-expressed for bitSpire under AGPL.
*
* Frame (host → acceptor), fixed 8 bytes:
* [0] STX 0x02
* [1] LEN 0x08
* [2] CTRL 0x10 | ack (ack toggles 0↔1 every message)
* [3] ENA denomination enable bitmask (0x7F = all, 0x00 = none)
* [4] CMD 0x00 base; | 0x20 stacks the escrowed note
* [5] RSVD 0x00
* [6] ETX 0x03
* [7] CHK XOR of bytes [1..5]
*
* Frame (acceptor → host), length-prefixed like the host frame; the fields
* this driver consumes:
* [3] STATE bits 1=idling 2=accepting 4=escrowed 8=stacking
* 16=stacked 32=returning 64=returned
* [4] EVENT bits 0x01=cheated 0x02=rejected 0x04=jammed
* 0x08=stacker-full 0x10=cassette-present
* [5] CREDIT denomination channel = (byte & 0x38) >> 3 (1..7, 0=none)
*
* Serial: 9600 baud, 7 data bits, even parity, 1 stop bit.
*/
import { EventEmitter } from 'node:events'
import { SerialPort } from 'serialport'
const STX = 0x02
const ETX = 0x03
const HOST_FRAME_LEN = 0x08
// Host command byte (frame[4])
const CMD_STACK = 0x20
// Response STATE byte (frame[3]) bit masks
const STATE_IDLING = 0x01
const STATE_ACCEPTING = 0x02
const STATE_ESCROWED = 0x04
const STATE_STACKING = 0x08
const STATE_STACKED = 0x10
const STATE_RETURNING = 0x20
const STATE_RETURNED = 0x40
// Response EVENT byte (frame[4]) bit masks
const EVENT_CHEATED = 0x01
const EVENT_REJECTED = 0x02
const EVENT_JAMMED = 0x04
const EVENT_STACKER_FULL = 0x08
const EVENT_CASSETTE_PRESENT = 0x10
// Plausibility bounds for the response length byte, used only to resync a
// desynced stream — a real reply is short (≈8–16 bytes).
const RESP_LEN_MIN = 6
const RESP_LEN_MAX = 32
export type ApexStatus =
| 'standby'
| 'accepting'
| 'billsRead'
| 'stacking'
| 'returning'
| 'billsValid'
| 'billsRejected'
| 'jam'
| 'stackerOpen'
export interface ApexParseResult {
status: ApexStatus | null
/** Populated when a denomination channel is present (escrow / stacked). */
bill?: { denomination: number | null; code: string }
/** Truthy status flags, for on-change diagnostic logging. */
flags: string
}
export interface ApexRs232Config {
device: string | string[]
}
// ---------------------------------------------------------------------------
// Pure functions — checksum, frame building, response parsing
// ---------------------------------------------------------------------------
/** XOR checksum over bytes [1..5] (LEN through RSVD), matching the host frame. */
export function computeChecksum(frame: number[] | Buffer): number {
let cs = 0x00
for (let i = 1; i <= 5; i++) cs ^= frame[i] ?? 0
return cs
}
/**
* Build the 8-byte host poll/command frame.
* @param ack current ACK bit (0 or 1)
* @param enableByte denomination enable bitmask
* @param cmdByte command bits (e.g. CMD_STACK)
*/
export function buildFrame(ack: number, enableByte: number, cmdByte: number): Buffer {
const frame = [
STX,
HOST_FRAME_LEN,
0x10 | (ack & 0x01),
enableByte & 0xff,
cmdByte & 0xff,
0x00,
ETX,
0x00,
]
frame[7] = computeChecksum(frame)
return Buffer.from(frame)
}
/** Channel index (1..7, 0 = none) of the credited note in the CREDIT byte. */
export function creditChannel(creditByte: number): number {
return (creditByte & 0x38) >> 3
}
/**
* Derive a high-level status from the STATE + EVENT bytes. Terminal outcomes
* (stacked / returned / cheated / rejected / jam / stacker-open) win over the
* transient in-motion states so a late-arriving reply can't mask a result.
*/
export function parseStatus(state: number, event: number): ApexStatus | null {
// Cassette (stacker box) removed — surfaces before anything transactional.
if (!(event & EVENT_CASSETTE_PRESENT)) return 'stackerOpen'
if (event & EVENT_JAMMED) return 'jam'
// Terminal resting states
if (state & STATE_STACKED) return 'billsValid'
if (state & STATE_RETURNED || event & (EVENT_CHEATED | EVENT_REJECTED)) return 'billsRejected'
// Transient — bill in motion. Surface before `escrowed`.
if (state & STATE_STACKING) return 'stacking'
if (state & STATE_RETURNING) return 'returning'
if (state & STATE_ESCROWED) return 'billsRead'
if (state & STATE_ACCEPTING) return 'accepting'
if (state & STATE_IDLING) return 'standby'
return null
}
function summarizeFlags(state: number, event: number, credit: number): string {
const f: string[] = []
if (state & STATE_IDLING) f.push('idling')
if (state & STATE_ACCEPTING) f.push('accepting')
if (state & STATE_ESCROWED) f.push('escrowed')
if (state & STATE_STACKING) f.push('stacking')
if (state & STATE_STACKED) f.push('stacked')
if (state & STATE_RETURNING) f.push('returning')
if (state & STATE_RETURNED) f.push('returned')
if (event & EVENT_CHEATED) f.push('cheated')
if (event & EVENT_REJECTED) f.push('rejected')
if (event & EVENT_JAMMED) f.push('jammed')
if (event & EVENT_STACKER_FULL) f.push('stackerFull')
if (!(event & EVENT_CASSETTE_PRESENT)) f.push('cassetteMissing')
const ch = creditChannel(credit)
if (ch) f.push(`channel=${ch}`)
return f.join(', ') || '(none)'
}
/**
* Parse a complete acceptor frame. `denomForChannel` maps a 1-based credit
* channel to a fiat value (null if the channel isn't configured).
*/
export function parseResponse(
frame: Buffer,
denomForChannel: (channel: number) => number | null
): ApexParseResult {
const state = frame[3] ?? 0
const event = frame[4] ?? 0
const credit = frame[5] ?? 0
const status = parseStatus(state, event)
const flags = summarizeFlags(state, event, credit)
const channel = creditChannel(credit)
if (channel > 0) {
return { status, bill: { denomination: denomForChannel(channel), code: '' }, flags }
}
return { status, flags }
}
// ---------------------------------------------------------------------------
// ApexRs232 class
// ---------------------------------------------------------------------------
export class ApexRs232 extends EventEmitter {
private buf: Buffer = Buffer.alloc(0)
private config: ApexRs232Config
private serial: SerialPort | null = null
private ack = 0x0
private enabledMask = 0x00
// Latched escrow decision. Like EBDS, the stack/return choice isn't a
// one-shot: the note sits in escrow until a poll asserts it, so we re-assert
// every poll until the device leaves escrow (cleared in _process). A single
// dropped frame then can't strand the note.
private pendingAction: 'none' | 'stack' | 'return' = 'none'
private lastFlags: string | null = null
private denomForChannel: (channel: number) => number | null = () => null
constructor(config: ApexRs232Config) {
super()
this.config = config
}
static factory(config: ApexRs232Config): ApexRs232 {
return new ApexRs232(config)
}
/** Provide the channel→denomination resolver (fiat-dependent). */
setDenomResolver(fn: (channel: number) => number | null): void {
this.denomForChannel = fn
}
// -- Serial connection ---------------------------------------------------
private async _open(device: string): Promise<void> {
return new Promise((resolve, reject) => {
const serial = new SerialPort({
path: device,
baudRate: 9600,
parity: 'even' as const,
dataBits: 7 as const,
stopBits: 1 as const,
autoOpen: false,
rtscts: false,
})
this.serial = serial
serial.on('error', (err) => this.emit('error', err))
serial.on('open', (err?: Error | null) => {
if (err) return reject(err)
serial.on('readable', () => {
const data = serial.read() as Buffer | null
if (data) this._process(data)
})
serial.on('close', () => this.emit('disconnected'))
this.emit('connected')
resolve()
})
serial.open()
})
}
async open(cb: (err?: Error) => void): Promise<void> {
const devices = this.config.device
if (!devices) {
this.emit('error', new Error('No configured devices.'))
return
}
const list = typeof devices === 'string' ? [devices] : devices
for (const device of list) {
try {
await this._open(device)
cb()
return
} catch {
continue
}
}
cb(new Error('No configured devices available.'))
}
close(cb: (err?: Error | null) => void): void {
this.serial?.close(cb)
}
// -- Enable / commands ---------------------------------------------------
setEnabledDenominations(mask: number): void {
this.enabledMask = mask & 0xff
}
/** Send one poll, carrying the current mask + latched escrow action. */
poll(): void {
// 'return' is expressed by disabling all channels while a note is escrowed,
// which makes the acceptor hand the note back (Apex has no distinct return
// opcode). 'stack' asserts CMD_STACK. Both are re-asserted until the device
// leaves escrow. NOTE: verify the return-by-disable behaviour on the 7600
// during bench bring-up; some firmware returns only on escrow timeout.
let enableByte = this.enabledMask
let cmdByte = 0x00
if (this.pendingAction === 'stack') cmdByte = CMD_STACK
else if (this.pendingAction === 'return') enableByte = 0x00
this.ack ^= 0x01
this.serial?.write(buildFrame(this.ack, enableByte, cmdByte))
}
stack(): void {
this.pendingAction = 'stack'
this.poll()
}
reject(): void {
this.pendingAction = 'return'
this.poll()
}
/** Disable all denominations and clear any latched escrow action. */
reset(): void {
this.pendingAction = 'none'
this.enabledMask = 0x00
this.poll()
}
// -- Receive / parse -----------------------------------------------------
private _process(data: Buffer): void {
this.buf = this._acquireSync(Buffer.concat([this.buf, data]))
if (this.buf.length < 2) return
const len = this.buf[1] ?? 0
if (len < RESP_LEN_MIN || len > RESP_LEN_MAX) {
// Implausible length byte — drop the STX we synced on and resync.
this.buf = this._acquireSync(this.buf.subarray(1))
return
}
if (this.buf.length < len) return // wait for the whole frame
const frame = this.buf.subarray(0, len)
this.buf = this.buf.subarray(len)
if (frame[len - 2] !== ETX) {
this.emit('badFrame')
this.poll()
return
}
// Checksum is validated leniently: a mismatch is logged once but the frame
// is still parsed. Pyramid's published host samples don't verify the reply
// checksum, and the exact XOR range for the *reply* isn't confirmable from
// the (scanned) spec — so we don't want a wrong assumption to blackhole
// every otherwise-valid frame. Tighten to a hard drop once verified on hw.
if (frame[len - 1] !== computeChecksum(frame)) {
console.warn('[APEX] reply checksum mismatch (parsing anyway pending hw verification)')
}
const result = parseResponse(frame, this.denomForChannel)
// Clear a latched stack/return once the note has left escrow, so it can't
// leak onto the next note.
const escrowed = (frame[3] ?? 0) & STATE_ESCROWED
if (!escrowed) this.pendingAction = 'none'
if (result.flags !== this.lastFlags) {
console.log(`[APEX] status: ${this.lastFlags ?? '(initial)'} → ${result.flags}`)
this.lastFlags = result.flags
}
this.emit('message', result)
}
private _acquireSync(data: Buffer): Buffer {
for (let i = 0; i < data.length; i++) {
if (data[i] === STX) return data.subarray(i)
}
return Buffer.alloc(0)
}
}

View file

@ -0,0 +1,30 @@
/**
* Pyramid Apex Denomination Tables
*
* The Apex RS-232 reply reports a 1-based CREDIT CHANNEL (1..7), not a value —
* the channel→value mapping is fixed by the bill dataset programmed into the
* acceptor's firmware for its country. The arrays below are ascending value
* lists indexed by (channel - 1); they MUST match the dataset flashed on your
* specific Apex 7600, or a credited note will be booked at the wrong value.
* Verify against the unit's configuration card during bring-up.
*
* Defaults follow Pyramid's standard datasets (US = $1/$5/$10/$20/$50/$100;
* $2 channel omitted as it's rarely enabled).
*/
export const denominations: Record<string, number[]> = {
USD: [1, 5, 10, 20, 50, 100],
EUR: [5, 10, 20, 50, 100, 200, 500],
GBP: [5, 10, 20, 50],
CAD: [5, 10, 20, 50, 100],
AUD: [5, 10, 20, 50, 100],
MXN: [20, 50, 100, 200, 500],
GTQ: [1, 5, 10, 20, 50, 100, 200],
}
/** Resolve a 1-based credit channel to a fiat value, or null if unmapped. */
export function denomForChannel(fiatCode: string | null, channel: number): number | null {
if (!fiatCode || channel < 1) return null
const table = denominations[fiatCode]
return table?.[channel - 1] ?? null
}

View file

@ -0,0 +1,155 @@
/**
* Pyramid Apex Bill Validator Driver
*
* Supports Pyramid Technologies Apex-series acceptors (Apex 5000 / 7000 / 7600)
* on their RS-232 interface. Set the acceptor to RS-232 mode via its DIP /
* configuration card.
*
* Protocol: RS-232, 9600 baud, 7 data bits, even parity, 1 stop bit.
*
* Written from Pyramid's public RS-232 protocol facts (see apex-rs232.ts) —
* not ported from any licensed source.
*/
import { EventEmitter } from 'node:events'
import { throttle } from 'lodash-es'
import { ApexRs232 } from './apex-rs232.js'
import { ApexFsm } from './apex-fsm.js'
import { denominations as denominationsTable, denomForChannel } from './denominations.js'
import type { BillValidator, ValidatorConfig, BillData } from '../../types.js'
// Apex is host-polled; match the id003/ebds 100ms cadence so we observe escrow
// well inside the acceptor's grace window.
const POLLING_INTERVAL = 100
// All 7 credit channels enabled. Per-channel gating is handled upstream by the
// state machine (via lowest/highestBill), so the driver enables the full mask
// and relies on the acceptor's dataset for which notes exist.
const ALL_CHANNELS = 0x7f
interface BNLike {
lte: (n: number) => boolean
gte: (n: number) => boolean
toNumber: () => number
}
function BN(n: number): BNLike {
return { lte: (o: number) => n <= o, gte: (o: number) => n >= o, toNumber: () => n }
}
export class ApexValidator extends EventEmitter implements BillValidator {
private config: ValidatorConfig
private fiatCode: string | null = null
private rs232: ApexRs232 | null = null
private fsm: ApexFsm | null = null
private poller: ReturnType<typeof setInterval> | null = null
private _throttledError: (err: Error) => void
constructor(config: ValidatorConfig) {
super()
this.config = config
this._throttledError = throttle((err: Error) => this.emit('error', err), 2000)
}
static factory(config: ValidatorConfig): ApexValidator {
return new ApexValidator(config)
}
setFiatCode(fiatCode: string): void {
this.fiatCode = fiatCode
}
// Apex has no host-controllable insertion light.
lightOn(): void {}
lightOff(): void {}
run(cb: (err?: Error) => void): void {
this.fsm = ApexFsm.factory()
this.rs232 = ApexRs232.factory({ device: this.config.rs232.device })
this.rs232.setDenomResolver((channel) => denomForChannel(this.fiatCode, channel))
this.rs232.on('message', (result) => this.fsm?.process(result))
this.rs232.on('error', (err: Error) => this._throttledError(err))
this.rs232.on('badFrame', () => this.rs232?.poll())
this.rs232.on('disconnected', () => this.emit('disconnected'))
this.fsm.on('billsAccepted', () => this.emit('billsAccepted'))
this.fsm.on('billsRead', (bill: { denomination: number | null; code: string }) => {
if (!bill.denomination) {
console.log('[APEX] Bill rejected: unsupported/unmapped channel')
this.rs232?.reject()
return
}
const billData: BillData = { denomination: bill.denomination, code: 0 }
this.emit('billsRead', billData)
})
this.fsm.on('billsValid', () => this.emit('billsValid'))
this.fsm.on('billsRejected', () => this.emit('billsRejected'))
this.fsm.on('accepting', () => this.emit('accepting'))
this.fsm.on('stacking', () => this.emit('stacking'))
this.fsm.on('returning', () => this.emit('returning'))
this.fsm.on('stackerOpen', () => this.emit('stackerOpen'))
this.fsm.on('standby', () => this.emit('standby'))
this.fsm.on('error', (err: Error) => this.emit('error', err))
this.rs232.open((err) => {
if (err) return cb(err)
this.rs232!.reset() // start disabled
this.poller = setInterval(() => this.rs232?.poll(), POLLING_INTERVAL)
cb()
})
}
close(cb: (err?: Error) => void): void {
if (this.poller) {
clearInterval(this.poller)
this.poller = null
}
this.rs232?.close((err) => cb(err ?? undefined))
}
enable(): void {
if (!this.rs232) return
this.rs232.setEnabledDenominations(ALL_CHANNELS)
this.rs232.poll()
}
disable(): void {
if (!this.rs232) return
this.rs232.setEnabledDenominations(0x00)
this.rs232.poll()
}
stack(): void {
this.rs232?.stack()
}
reject(): void {
this.rs232?.reject()
}
lowestBill(fiat: BNLike): BNLike {
const bills = this._denominations()
if (!bills) return BN(0)
const filtered = bills.filter((b) => fiat.lte(b))
if (filtered.length === 0) return BN(Math.min(...bills))
return BN(Math.min(...filtered))
}
highestBill(fiat: BNLike): BNLike {
const bills = this._denominations()
if (!bills) return BN(-Infinity)
const filtered = bills.filter((b) => fiat.gte(b))
if (filtered.length === 0) return BN(-Infinity)
return BN(Math.max(...filtered))
}
hasDenominations(): boolean {
return this._denominations() !== null
}
private _denominations(): number[] | null {
if (!this.fiatCode) return null
return denominationsTable[this.fiatCode] ?? null
}
}

View file

@ -6,17 +6,19 @@
export { Id003 } from './id003/index.js' export { 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}`)
} }