feat: Raspberry Pi 4 (aarch64) target #110
13 changed files with 664 additions and 69 deletions
|
|
@ -87,19 +87,40 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
|
||||
const { validator: valConfig, dispenser: dispConfig } = config
|
||||
|
||||
// Create hardware instances
|
||||
const dispenser: BillDispenser = hal.createDispenser(dispConfig.type, {
|
||||
device: dispConfig.device,
|
||||
})
|
||||
// Start dispenser (optional — mirrors the validator handling below).
|
||||
//
|
||||
// A cash-in-only machine is a legitimate configuration: the Raspberry Pi
|
||||
// reference build has a bill acceptor and no dispenser at all. This used to
|
||||
// create and init the dispenser unconditionally, so a missing device threw
|
||||
// and aborted the WHOLE of initializeHal — taking the validator with it,
|
||||
// even though the validator was present and working. The Pi bring-up hit
|
||||
// exactly that: "cannot open /dev/ttyDispenser-not-fitted", then an endless
|
||||
// renderer-reload loop, with a perfectly good acceptor on ttyValidator0.
|
||||
//
|
||||
// The validator has been optional since it was written; the asymmetry was
|
||||
// the bug.
|
||||
let dispenser: BillDispenser | null = null
|
||||
|
||||
// Initialize dispenser. `dispenserInitData` is `let` because
|
||||
// `setCassettes` swaps it in to re-init with a new layout (also used by
|
||||
// the on-error re-init path at dispenseCash).
|
||||
// `dispenserInitData` is `let` because `setCassettes` swaps it in to re-init
|
||||
// with a new layout (also used by the on-error re-init path at dispenseCash).
|
||||
let dispenserInitData = {
|
||||
fiatCode: valConfig.fiatCode,
|
||||
cassettes: dispConfig.cassettes,
|
||||
}
|
||||
await dispenser.init(dispenserInitData)
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs')
|
||||
if (dispConfig.device && fs.existsSync(dispConfig.device)) {
|
||||
dispenser = hal.createDispenser(dispConfig.type, { device: dispConfig.device })
|
||||
await dispenser.init(dispenserInitData)
|
||||
console.log('[HAL] Dispenser started')
|
||||
} else {
|
||||
console.log('[HAL] Dispenser device not found, running cash-in only')
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[HAL] Dispenser failed to start, running cash-in only:', err)
|
||||
dispenser = null
|
||||
}
|
||||
console.log('[HAL] Dispenser initialized')
|
||||
|
||||
// Start validator (optional — proceed without if device is missing or fails)
|
||||
|
|
@ -251,6 +272,17 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
dispenseCash: async (amounts): Promise<DispenseResult> => {
|
||||
console.log('[HAL] Dispensing:', amounts)
|
||||
|
||||
// Cash-in-only machine: refuse the ask rather than throwing a null
|
||||
// dereference into the renderer's dispense path.
|
||||
if (!dispenser) {
|
||||
return {
|
||||
bills: [],
|
||||
cassettes: [],
|
||||
dispensed: false,
|
||||
error: 'No dispenser fitted on this machine — cash-out unavailable',
|
||||
}
|
||||
}
|
||||
|
||||
// Re-initialize dispenser if it was closed after a previous error
|
||||
if (!dispenser.initialized) {
|
||||
console.log('[HAL] Dispenser not initialized, re-initializing...')
|
||||
|
|
@ -391,6 +423,12 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
count: c.count ?? 0,
|
||||
}))
|
||||
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
|
||||
// Without a dispenser the layout is still worth recording (the operator
|
||||
// config consumer keeps calling this), but there is nothing to re-init.
|
||||
if (!dispenser) {
|
||||
console.log('[HAL] Cassettes recorded; no dispenser fitted, nothing to re-init')
|
||||
return
|
||||
}
|
||||
// Close + re-init the dispenser so its internal per-bay state matches
|
||||
// the new layout. Errors here surface to the caller (operator-config
|
||||
// consumer) — the renderer can decide whether to retry.
|
||||
|
|
@ -407,7 +445,7 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
return new Promise<void>((resolve) => {
|
||||
validator?.disable()
|
||||
validator?.lightOff()
|
||||
dispenser.close()
|
||||
dispenser?.close()
|
||||
if (validator) {
|
||||
validator.close((err?: Error) => {
|
||||
if (err) console.error('[HAL] Validator close error:', err)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ import type { HalConfig, CassetteConfig } from '@/services/hal'
|
|||
/**
|
||||
* Supported machine models
|
||||
*/
|
||||
export type MachineModel = 'sintra' | 'tejo' | 'douro' | 'gaia' | 'batm3' | 'custom'
|
||||
export type MachineModel =
|
||||
| 'sintra'
|
||||
| 'tejo'
|
||||
| 'douro'
|
||||
| 'gaia'
|
||||
| 'batm3'
|
||||
| 'rpi4'
|
||||
| 'rpi5'
|
||||
| 'custom'
|
||||
|
||||
/**
|
||||
* Full device configuration
|
||||
|
|
@ -28,7 +36,10 @@ export interface DeviceConfig {
|
|||
/** Bill validator configuration */
|
||||
validator: {
|
||||
/** Validator protocol type */
|
||||
type: 'id003' | 'ebds'
|
||||
// 'apex' = Pyramid Apex RS-232. The driver landed with the Pi 5 work
|
||||
// (packages/hal ValidatorType) but these app-side unions were never
|
||||
// widened, so no machine could actually be configured to use it.
|
||||
type: 'id003' | 'ebds' | 'apex'
|
||||
/** Serial device path(s) */
|
||||
device: string | string[]
|
||||
}
|
||||
|
|
@ -117,6 +128,52 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
|
|||
* - Dispenser: Fujitsu F56
|
||||
* Note: Device paths may vary - verify on hardware
|
||||
*/
|
||||
/**
|
||||
* Raspberry Pi 4 / CM4 reference build (aarch64).
|
||||
* - Validator: Pyramid Apex 7600 over RS-232, via a USB-serial adapter
|
||||
* - Dispenser: NONE WIRED YET — cash-in only
|
||||
*
|
||||
* The device path is the udev symlink raspberry-pi-4.nix creates for an
|
||||
* FTDI bridge. Swap to ttyValidator1 (CP210x) or ttyValidator2 (CH340) to
|
||||
* match the adapter actually fitted; `ls -l /dev/ttyValidator*` after
|
||||
* plugging it in will say which one appeared.
|
||||
*
|
||||
* The dispenser block is a placeholder, not a claim. DispenserType has no
|
||||
* 'none' variant and DeviceConfig requires the field, so it points at a
|
||||
* path that does not exist and carries no cassettes. Cash-out is not
|
||||
* available on this board until real hardware and a real path land here.
|
||||
*/
|
||||
rpi4: {
|
||||
model: 'rpi4',
|
||||
validator: {
|
||||
type: 'apex',
|
||||
device: '/dev/ttyValidator0',
|
||||
},
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
device: '/dev/ttyDispenser-not-fitted',
|
||||
cassettes: [],
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Raspberry Pi 5 reference build (aarch64). Same shape as rpi4 — same
|
||||
* validator, same absent dispenser, same udev symlinks from
|
||||
* raspberry-pi-5.nix.
|
||||
*/
|
||||
rpi5: {
|
||||
model: 'rpi5',
|
||||
validator: {
|
||||
type: 'apex',
|
||||
device: '/dev/ttyValidator0',
|
||||
},
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
device: '/dev/ttyDispenser-not-fitted',
|
||||
cassettes: [],
|
||||
},
|
||||
},
|
||||
|
||||
gaia: {
|
||||
model: 'gaia',
|
||||
validator: {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export interface CassetteConfig {
|
|||
|
||||
export interface HalConfig {
|
||||
validator: {
|
||||
type: 'id003' | 'ebds'
|
||||
type: 'id003' | 'ebds' | 'apex'
|
||||
device: string | string[]
|
||||
fiatCode: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ deploy/nixos/
|
|||
├── hardware/
|
||||
│ ├── douro.nix # Dell OptiPlex 9030 AIO (stock Douro motherboard; SATA SSD, eGalax touch)
|
||||
│ ├── batm3.nix # GeneralBytes BATM3 chassis with a Dell OptiPlex 9030 AIO grafted in (custom mod; WireGuard wired in)
|
||||
│ └── upboard.nix # Aaeon UP Board (Sintra + tejo; eMMC root via sdhci-acpi + mmc_block)
|
||||
│ ├── upboard.nix # Aaeon UP Board (Sintra + tejo; eMMC root via sdhci-acpi + mmc_block)
|
||||
│ ├── raspberry-pi-5.nix # Raspberry Pi 5 (aarch64) — DIY build; board glue on top of nixos-hardware
|
||||
│ └── raspberry-pi-4.nix # Raspberry Pi 4 (aarch64) — same, previous-generation board
|
||||
├── udev/
|
||||
│ └── 99-bitspire-hardware.rules # additional udev rules (loaded via configuration.nix)
|
||||
├── provision-atm.sh # Push LNbits credentials to a deployed ATM via SSH
|
||||
|
|
@ -36,6 +38,11 @@ Each ATM model has two flake outputs:
|
|||
|
||||
Models: `douro`, `tejo`, `sintra`, `batm3`.
|
||||
|
||||
The Raspberry Pi boards (`rpi5`, `rpi4`) are aarch64 and follow a different
|
||||
pipeline — SD-card image instead of GPT disk image, U-Boot instead of
|
||||
systemd-boot, and they need an aarch64 builder. See
|
||||
[`docs/raspberry-pi-setup.md`](../../docs/raspberry-pi-setup.md).
|
||||
|
||||
```bash
|
||||
# Build a Sintra disk image
|
||||
nix build .#disk-image-sintra
|
||||
|
|
|
|||
181
deploy/nixos/hardware/raspberry-pi-4.nix
Normal file
181
deploy/nixos/hardware/raspberry-pi-4.nix
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# Raspberry Pi 4 hardware module (aarch64).
|
||||
#
|
||||
# The Pi 4 twin of raspberry-pi-5.nix. Kernel, firmware, bootloader and device
|
||||
# tree come from the nixos-hardware `raspberry-pi-4` module (paired with this
|
||||
# file in flake.nix's piBoards); here we set only the bitSpire-specific
|
||||
# hardware glue: serial for the bill validators, the kiosk display driver, and
|
||||
# no-suspend. The wiring notes in raspberry-pi-5.nix apply unchanged — same
|
||||
# validators over USB-serial, same QR scanner, same touchscreen options.
|
||||
#
|
||||
# What differs from the Pi 5:
|
||||
# - GPU/KMS: the Pi 5 module enables vc4/v3d modesetting by default; on the
|
||||
# Pi 4 it is an opt-in (`fkms-3d`) that also injects the CMA + vc4 device
|
||||
# tree overlays. Without it X falls back to the plain framebuffer and
|
||||
# Electron renders in software.
|
||||
# - Memory: 4 GB is the floor for Electron + the kiosk; 8 GB is comfortable.
|
||||
# The shared Pi runtime's MemoryMax=2G leaves headroom on either.
|
||||
# - No PCIe (the Pi 5's NVMe path); boot/root is SD or USB-SATA only.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
# aarch64 target. (The flake instantiates this config with aarch64 pkgs; this
|
||||
# line documents/asserts it.)
|
||||
nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
|
||||
|
||||
# Mainline kernel, NOT the Raspberry Pi vendor one.
|
||||
#
|
||||
# nixos-hardware's raspberry-pi/4 module mkDefaults boot.kernelPackages to
|
||||
# the vendor kernel (linux-rpi, via common/kernel.nix). That kernel is in no
|
||||
# binary cache — Hydra does not build nixos-hardware's overlays, and it is
|
||||
# not in aiolabs.cachix.org either — so every Pi compiles a kernel from
|
||||
# source, on an SD card, and recompiles on every bump. The first bring-up
|
||||
# attempt spent hours on `CC [M] fs/overlayfs/inode.o` before anyone noticed
|
||||
# what it was doing.
|
||||
#
|
||||
# Mainline aarch64 kernels are cached, and mainline demonstrably boots a
|
||||
# Pi 4: it is what the stock NixOS aarch64 SD image runs. The vendor kernel's
|
||||
# Pi-specific patches buy nothing this kiosk needs — display, USB serial and
|
||||
# WiFi are all mainline, and vc4/v3d KMS has been mainline for years.
|
||||
#
|
||||
# Watch the graphics path when changing this. fkms-3d below is a
|
||||
# nixos-hardware overlay built around the vendor kernel's firmware-KMS route;
|
||||
# the mainline equivalent is full KMS (vc4-kms-v3d). If X ends up on the
|
||||
# framebuffer with Electron rendering in software, that overlay is where to
|
||||
# look — not the kernel choice, which is worth keeping either way.
|
||||
boot.kernelPackages = pkgs.linuxPackages;
|
||||
|
||||
# Bootloader: the aarch64 sd-image uses the extlinux-compatible generator;
|
||||
# nixos-hardware's rpi4 module wires the firmware/u-boot. No systemd-boot.
|
||||
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.
|
||||
# cma=256M: the vc4 display pipeline allocates its framebuffer from the
|
||||
# contiguous memory area, and the default reservation on this board is 32MiB
|
||||
# with about 11MiB free. A 3840x1080 framebuffer is ~16.6MB before double
|
||||
# buffering, so X got as far as picking the mode and then died:
|
||||
#
|
||||
# Output HDMI-1 using initial mode 3840x1080 +0+0
|
||||
# (EE) AddScreen/ScreenInit failed for driver 0
|
||||
#
|
||||
# nixos-hardware's fkms-3d injected a CMA overlay alongside the display one;
|
||||
# this replaces that half of it. 256M is generous for any panel an ATM will
|
||||
# carry and trivial against 4-8GB of RAM.
|
||||
boot.kernelParams = [ "console=tty0" "cma=256M" ];
|
||||
|
||||
# ── REQUIRES A MANUAL STEP ON THE FIRMWARE PARTITION ────────────────
|
||||
# This board boots the FIRMWARE's vendor DTB, not the DTBs NixOS builds.
|
||||
# Confirmed on the CM4: the live device tree carries __symbols__ and the
|
||||
# mainline DTBs in dtbs-filtered do not, and U-Boot found no FDTDIR match for
|
||||
# compatible "raspberrypi,4-compute-module" so it passed the firmware's DTB
|
||||
# through. That means hardware.deviceTree.overlays cannot reach the running
|
||||
# device tree, and the display has to be enabled by the firmware instead.
|
||||
#
|
||||
# In the vendor DTB every display node (hvs, gpu, all pixelvalves, both hdmi)
|
||||
# ships `disabled`. So /boot/firmware/config.txt needs:
|
||||
#
|
||||
# dtoverlay=vc4-kms-v3d,noaudio
|
||||
#
|
||||
# and /boot/firmware/overlays/ needs to be populated from raspberrypifw --
|
||||
# the NixOS sd-image writes the DTBs there but NOT the overlays, so the
|
||||
# directory ships empty and the dtoverlay line fails silently. Copy the whole
|
||||
# directory (2MB, 356 files); copying only vc4-kms-v3d.dtbo is not enough
|
||||
# because the firmware remaps that to vc4-kms-v3d-pi4.dtbo on this board.
|
||||
#
|
||||
# `noaudio` is required, not cosmetic. With HDMI audio enabled vc4_hdmi cannot
|
||||
# register its PCM component, returns -517 (EPROBE_DEFER) forever, and the DRM
|
||||
# device never registers -- so X finds no card at all. We removed the audio
|
||||
# stack anyway, so there is nothing to lose.
|
||||
#
|
||||
# This is a reflash-losing manual step and it should be folded into the image
|
||||
# builder. Tracked as a follow-up; noted here so the next person does not
|
||||
# rediscover it from a blank screen.
|
||||
|
||||
# Kiosk display: mainline full KMS, NOT nixos-hardware's fkms-3d.
|
||||
#
|
||||
# fkms-3d applies the rpi4-cma-overlay and rpi4-vc4-fkms-v3d-overlay device
|
||||
# tree overlays. Those target nodes that exist in the Raspberry Pi VENDOR
|
||||
# kernel's DTBs and not in mainline's, so with the mainline kernel above the
|
||||
# overlay step fails outright:
|
||||
#
|
||||
# Applying overlay rpi4-vc4-fkms-v3d-overlay
|
||||
# libfdt.FdtException: pylibfdt error -1: FDT_ERR_NOTFOUND
|
||||
#
|
||||
# It is also unnecessary. "fkms" is FIRMWARE KMS, the older route where the
|
||||
# VideoCore firmware owns the display and Linux drives it at arm's length.
|
||||
# Mainline does full KMS instead, and mainline's own bcm2711-rpi-4-b.dtb
|
||||
# already describes the hardware — it carries brcm,bcm2711-vc5 and
|
||||
# brcm,2711-v3d nodes, checked with dtc. The vc4 and v3d drivers bind to
|
||||
# those directly with no overlay involved.
|
||||
#
|
||||
# fkms-3d used to set services.xserver.videoDrivers as a side effect. Nothing
|
||||
# needs to replace it: the shared configuration.nix already declares
|
||||
# modesetting, which is the correct driver for full KMS and what the x86
|
||||
# machines use. Setting it again here only produced a duplicate entry.
|
||||
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
|
||||
# pcscd MUST be enabled, and not because this board has a card reader.
|
||||
#
|
||||
# The app constructs @pokusew/pcsclite at startup. That calls
|
||||
# SCardEstablishContext(), which calls SCardCheckDaemonAvailability(), which
|
||||
# — when there is no pcscd to find — BUSY-LOOPS in fstatat64 at ~92% CPU
|
||||
# instead of returning an error. It runs on Electron's main thread, before
|
||||
# the BrowserWindow is created, so the window never appears and the panel
|
||||
# stays white forever. Nothing is logged, nothing throws, and V8's own
|
||||
# inspector cannot be serviced because the thread never yields: CDP
|
||||
# Debugger.pause and Profiler.stop both hang. It took a native gdb backtrace
|
||||
# to see it at all:
|
||||
#
|
||||
# #0 fstatat64 libc
|
||||
# #1 SCardCheckDaemonAvailability libpcsclite
|
||||
# #2 SCardEstablishContext libpcsclite
|
||||
# #3 PCSCLite::PCSCLite() pcsclite.node
|
||||
#
|
||||
# The x86 machines never hit this because upboard.nix and batm3.nix both
|
||||
# enable pcscd for their actual readers. This module did not, which is the
|
||||
# entire difference. A running pcscd with no reader attached just idles, so
|
||||
# this is cheap insurance rather than a claim about the hardware.
|
||||
services.pcscd.enable = true;
|
||||
|
||||
# pcscd gates client access via polkit; without a rule the `bitspire` service
|
||||
# user is "Rejected unauthorized PC/SC client". Same wiring as upboard.nix.
|
||||
security.polkit.extraConfig = ''
|
||||
polkit.addRule(function(action, subject) {
|
||||
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||
subject.user == "bitspire") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
'';
|
||||
|
||||
# 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.
|
||||
# Identical to the Pi 5 module — same adapters, same bridges. If two adapters
|
||||
# of the SAME chip are used, disambiguate by KERNELS/serial instead — tune
|
||||
# during bring-up.
|
||||
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"
|
||||
'';
|
||||
}
|
||||
145
docs/raspberry-pi-setup.md
Normal file
145
docs/raspberry-pi-setup.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Deploying bitSpire to a Raspberry Pi (4 or 5)
|
||||
|
||||
The DIY reference build: a Raspberry Pi, a bill validator on USB-serial, a
|
||||
touchscreen and a QR scanner. Both boards share one runtime in `flake.nix`
|
||||
(`piBaseModules`) and differ only in their hardware glue module:
|
||||
|
||||
| Board | `nixosConfigurations` | Flashable image | Hardware glue |
|
||||
|---|---|---|---|
|
||||
| Raspberry Pi 5 | `rpi5-installed`, `rpi5-image` | `packages.aarch64-linux.sd-image-rpi5` | `deploy/nixos/hardware/raspberry-pi-5.nix` |
|
||||
| Raspberry Pi 4 | `rpi4-installed`, `rpi4-image` | `packages.aarch64-linux.sd-image-rpi4` | `deploy/nixos/hardware/raspberry-pi-4.nix` |
|
||||
|
||||
`<board>-image` is what you flash; `<board>-installed` is what a running Pi
|
||||
rebuilds itself against afterwards. They share every module except the root
|
||||
filesystem declaration and the SD-image builder — see the comments above
|
||||
`mkPiInstalled` / `mkPiImage` in `flake.nix` for why they're split.
|
||||
|
||||
**Status:** the Pi 4 target is evaluation-verified (it instantiates, and its
|
||||
configuration differs from the Pi 5's only in the expected board-specific
|
||||
places) but has not yet been booted on hardware. Treat the first bring-up as
|
||||
exactly that.
|
||||
|
||||
## What's different from the x86 fleet
|
||||
|
||||
- **aarch64.** Any Pi build needs an aarch64 builder — a Pi itself, an ARM
|
||||
box, or `boot.binfmt.emulatedSystems = [ "aarch64-linux" ]` on an x86 host.
|
||||
The Electron app closure will not build on plain x86.
|
||||
- **Boot.** Raspberry Pi firmware + U-Boot + extlinux (from `nixos-hardware`),
|
||||
not systemd-boot. The image is an SD-card image, not a GPT disk image.
|
||||
- **No `determinate`, no `atm-tui`** in the Pi runtime yet (neither ships an
|
||||
aarch64 package). Add them when they do.
|
||||
- **Cachix is pre-wired** (`aiolabs.cachix.org` in `nix.settings`), so an
|
||||
in-place rebuild substitutes the heavy closure rather than compiling on the
|
||||
Pi — provided the closure was pushed there first.
|
||||
|
||||
## Board differences that matter
|
||||
|
||||
| | Pi 5 | Pi 4 |
|
||||
|---|---|---|
|
||||
| SoC / device tree | BCM2712 (`bcm2712*-rpi-*.dtb`) | BCM2711 (`bcm2711-rpi-4*.dtb`) |
|
||||
| Kiosk GPU / KMS | vc4/v3d on by default | opt-in via `hardware.raspberry-pi."4".fkms-3d` (the glue module enables it; it also injects the CMA + vc4 overlays) |
|
||||
| RAM | 4–16 GB | 4 GB is the floor for Electron; 8 GB is comfortable |
|
||||
| Root storage | SD, USB, or NVMe over PCIe | SD or USB only |
|
||||
| Power | 5 V / 5 A USB-C PD | 5 V / 3 A; Electron startup is the peak |
|
||||
|
||||
Everything else — validator serial symlinks, `console=tty0` keeping the GPIO
|
||||
UART free, no-suspend, the bitspire service — is identical between the two
|
||||
glue modules by design. Keep it that way: a change to one almost certainly
|
||||
belongs in the other.
|
||||
|
||||
## 1. Build the image
|
||||
|
||||
On (or via) an aarch64 builder:
|
||||
|
||||
```bash
|
||||
nix build .#packages.aarch64-linux.sd-image-rpi4 # or sd-image-rpi5
|
||||
ls result/sd-image/
|
||||
# → nixos-image-sd-card-<version>-aarch64-linux.img.zst
|
||||
```
|
||||
|
||||
## 2. Flash
|
||||
|
||||
```bash
|
||||
lsblk -f # identify the SD card — NOT your main disk
|
||||
zstd -dc result/sd-image/*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
```
|
||||
|
||||
The image carries two labelled partitions the installed config expects:
|
||||
`FIRMWARE` (vfat, Pi firmware + U-Boot) and `NIXOS_SD` (ext4 root). The root
|
||||
partition grows to fill the card on first boot.
|
||||
|
||||
## 3. First boot
|
||||
|
||||
Insert the card, connect Ethernet and power. The Pi boots into the kiosk with
|
||||
no pairing, so the screen shows the pairing wizard — with a camera it waits
|
||||
for a QR; without one it says so and tells you to provision `VITE_SPIRE_SEED`
|
||||
instead. It also comes up with sshd and password auth enabled, same as the
|
||||
x86 installed configs — this is the provisioning window.
|
||||
|
||||
Provision exactly as for a Sintra — from the dev box, with the spire seed
|
||||
minted by spirekeeper:
|
||||
|
||||
```bash
|
||||
SPIRE_SEED='spire-seed:v1:…' bash deploy/nixos/provision-atm.sh <pi-ip> 22
|
||||
```
|
||||
|
||||
That writes `/var/lib/bitspire/.env` and restarts the service; the seed
|
||||
carries the relay and the LNbits transport pubkey, so nothing else is needed.
|
||||
Alternatively, show the seed's QR to the machine's camera and let the
|
||||
on-screen wizard do the same thing. Verify with:
|
||||
|
||||
```bash
|
||||
ssh bitspire@<pi-ip> 'journalctl -u bitspire -n 50 --no-pager | grep "\["'
|
||||
# expect [Signer] Pairing to bunker … then [Lightning] LNbits client initialized
|
||||
```
|
||||
|
||||
The dev-only `VITE_ATM_PRIVATE_KEY` fallback works here too for a bench
|
||||
setup without a bunker — see `provision-atm.sh`'s header for the variables.
|
||||
|
||||
## 4. Updating in place
|
||||
|
||||
Once flashed, never re-flash for a software update. The `-installed` target
|
||||
is the aarch64 twin of the fleet's rebuild ritual:
|
||||
|
||||
```bash
|
||||
sudo nixos-rebuild switch --flake \
|
||||
"git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#rpi4-installed"
|
||||
```
|
||||
|
||||
(Pi 5: `#rpi5-installed`.) With the closure on cachix this is a download, not
|
||||
a build. If it starts compiling Electron on the Pi, the closure wasn't pushed
|
||||
— stop, build on the aarch64 builder, `cachix push aiolabs`, retry.
|
||||
|
||||
## 5. Wiring the peripherals
|
||||
|
||||
- **Bill validator** — over a USB-serial adapter. The glue modules give
|
||||
stable symlinks so `.env` never depends on enumeration order:
|
||||
FTDI → `/dev/ttyValidator0`, CP210x → `/dev/ttyValidator1`,
|
||||
CH340 → `/dev/ttyValidator2`. Two adapters of the *same* chip need
|
||||
disambiguating by `KERNELS`/serial in the udev rule — do that at bring-up.
|
||||
A validator wired straight to the GPIO UART (pins 14/15) also works: the
|
||||
kernel console is pinned to `tty0` precisely so that UART stays free.
|
||||
- **QR scanner** — USB HID keyboard-emulation, no configuration.
|
||||
- **Touchscreen** — DSI or HDMI. X runs on the Pi's KMS driver.
|
||||
- **Serial console for debugging** — there isn't one by default (see above).
|
||||
Use SSH, or temporarily add `console=ttyAMA0,115200` to
|
||||
`boot.kernelParams` in the glue module.
|
||||
|
||||
## Hardware notes for 24/7 operation
|
||||
|
||||
- **Storage.** SD cards have finite write endurance; for anything beyond a
|
||||
bench build put root on a USB-SATA SSD (both boards) or NVMe (Pi 5).
|
||||
`state.db` and the journal are the writers.
|
||||
- **Thermal.** Both boards throttle under sustained load without cooling.
|
||||
Heatsinks at minimum; the official active cooler for the Pi 5.
|
||||
- **Power.** Brown-outs on Electron startup look like random reboots. Use the
|
||||
official supply or one rated above the board's peak, never a hub.
|
||||
- **Swap.** The runtime provisions a 2 GB swapfile so memory pressure degrades
|
||||
instead of hard-freezing. On a 4 GB Pi 4 that is not optional.
|
||||
|
||||
## Related
|
||||
|
||||
- `deploy/nixos/README.md` — the x86 fleet pipeline this mirrors
|
||||
- `docs/machine-installation.md` — why images rather than `nixos-install`
|
||||
- `deploy/nixos/hardware/raspberry-pi-{4,5}.nix` — the per-board glue
|
||||
- `flake.nix` — `piBoards`, `piBaseModules`, `mkPiInstalled`, `mkPiImage`
|
||||
37
flake.nix
37
flake.nix
|
|
@ -308,14 +308,30 @@
|
|||
fiatCode = fiatCodeForModel.${machineModel} or "USD";
|
||||
};
|
||||
|
||||
# Supported Pi boards, keyed by machine model. Each pairs the
|
||||
# nixos-hardware board module (kernel, firmware, bootloader, device tree)
|
||||
# with our own hardware glue (validator serial, kiosk display, no-suspend).
|
||||
# Everything else in the Pi runtime is board-agnostic.
|
||||
piBoards = {
|
||||
rpi5 = {
|
||||
hardware = nixos-hardware.nixosModules.raspberry-pi-5;
|
||||
glue = ./deploy/nixos/hardware/raspberry-pi-5.nix;
|
||||
};
|
||||
rpi4 = {
|
||||
hardware = nixos-hardware.nixosModules.raspberry-pi-4;
|
||||
glue = ./deploy/nixos/hardware/raspberry-pi-4.nix;
|
||||
};
|
||||
};
|
||||
|
||||
# Shared module list (everything EXCEPT the root fs and the sd-image
|
||||
# builder). atm-app/fiatCode are threaded in so both products share one
|
||||
# evaluated app closure.
|
||||
piBaseModules = { machineModel, atm-app, fiatCode }: [
|
||||
nixos-hardware.nixosModules.raspberry-pi-5
|
||||
piBaseModules = { machineModel, atm-app, fiatCode }:
|
||||
let board = piBoards.${machineModel}; in [
|
||||
board.hardware
|
||||
./deploy/nixos/configuration.nix
|
||||
./deploy/nixos/bitspire-atm.nix
|
||||
./deploy/nixos/hardware/raspberry-pi-5.nix
|
||||
board.glue
|
||||
({ config, lib, pkgs, pkgs-unstable, ... }: {
|
||||
services.bitspire = {
|
||||
enable = true;
|
||||
|
|
@ -471,6 +487,12 @@
|
|||
rpi5-installed = mkPiInstalled "rpi5";
|
||||
rpi5-image = mkPiImage "rpi5";
|
||||
|
||||
# Raspberry Pi 4 (aarch64) — same runtime and products as rpi5, on the
|
||||
# previous-generation board (see deploy/nixos/hardware/raspberry-pi-4.nix
|
||||
# for what differs). 4 GB minimum for Electron; 8 GB comfortable.
|
||||
rpi4-installed = mkPiInstalled "rpi4";
|
||||
rpi4-image = mkPiImage "rpi4";
|
||||
|
||||
# USB-bootable variant of batm3-installed. This is the config the
|
||||
# flashed USB stick actually runs — distinct fs labels so stage-1 can't
|
||||
# latch the internal drive, nofail /boot, no growPartition, autoUpgrade
|
||||
|
|
@ -685,13 +707,16 @@
|
|||
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
|
||||
};
|
||||
|
||||
# ── Packages (aarch64-linux — Raspberry Pi 5 build) ───────────
|
||||
# Flashable SD image for the Pi 5. Build on an aarch64 builder (native Pi
|
||||
# / arm box / `boot.binfmt` emulation on this x86 host):
|
||||
# ── Packages (aarch64-linux — Raspberry Pi builds) ────────────
|
||||
# Flashable SD images for the Pi boards. Build on an aarch64 builder
|
||||
# (native Pi / arm box / `boot.binfmt` emulation on this x86 host):
|
||||
# nix build .#packages.aarch64-linux.sd-image-rpi5
|
||||
# nix build .#packages.aarch64-linux.sd-image-rpi4
|
||||
packages.aarch64-linux = {
|
||||
sd-image-rpi5 = self.nixosConfigurations.rpi5-image.config.system.build.sdImage;
|
||||
atm-app-rpi5 = mkAtmAppAarch64 { model = "rpi5"; fiatCode = "USD"; };
|
||||
sd-image-rpi4 = self.nixosConfigurations.rpi4-image.config.system.build.sdImage;
|
||||
atm-app-rpi4 = mkAtmAppAarch64 { model = "rpi4"; fiatCode = "USD"; };
|
||||
};
|
||||
}
|
||||
//
|
||||
|
|
|
|||
|
|
@ -172,6 +172,27 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
cp -rL "$bcpp_store/node_modules/@serialport/bindings-cpp/prebuilds" $out/node_modules/@serialport/bindings-cpp/prebuilds
|
||||
cp "$bcpp_store/node_modules/@serialport/bindings-cpp/package.json" $out/node_modules/@serialport/bindings-cpp/package.json
|
||||
|
||||
# bindings-cpp ships prebuilds for every platform it supports: android,
|
||||
# win32, darwin, and linux for several arches in both glibc and musl. Keep
|
||||
# only the one this system can actually load.
|
||||
#
|
||||
# This is load-bearing on aarch64, not just tidiness. On x86_64 autoPatchelf
|
||||
# skipped the foreign prebuilds because their ELF architecture did not match
|
||||
# the host. On aarch64 the android-arm64 prebuild IS the host architecture,
|
||||
# so autoPatchelf tries to patch it and fails hunting for Android's
|
||||
# liblog.so and libc++_shared.so, which do not exist on NixOS. First Pi
|
||||
# build died exactly there.
|
||||
#
|
||||
# Pruning rather than extending autoPatchelfIgnoreMissingDeps: teaching
|
||||
# autoPatchelf to tolerate a binary we never load, for a platform we do not
|
||||
# target, is the wrong shape of fix. The musl entry in that list below is
|
||||
# the same problem solved the other way, and is now redundant.
|
||||
keep_prebuild=${if pkgs.stdenv.hostPlatform.isAarch64 then "linux-arm64" else "linux-x64"}
|
||||
find $out/node_modules/@serialport/bindings-cpp/prebuilds -mindepth 1 -maxdepth 1 \
|
||||
! -name "$keep_prebuild" -exec rm -rf {} +
|
||||
rm -f $out/node_modules/@serialport/bindings-cpp/prebuilds/*/*.musl.node
|
||||
echo "serialport prebuilds kept: $(ls $out/node_modules/@serialport/bindings-cpp/prebuilds)/$(ls $out/node_modules/@serialport/bindings-cpp/prebuilds/"$keep_prebuild")"
|
||||
|
||||
copy_pnpm_pkg @serialport/bindings-interface $out/node_modules/@serialport/bindings-interface
|
||||
copy_pnpm_pkg @serialport/binding-mock $out/node_modules/@serialport/binding-mock
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
parseStatus,
|
||||
parseResponse,
|
||||
} from '../apex-rs232.js'
|
||||
import { denomForChannel } from '../denominations.js'
|
||||
|
||||
// Cassette-present bit; OR it into event bytes so parseStatus doesn't short to
|
||||
// 'stackerOpen'.
|
||||
|
|
@ -20,6 +21,27 @@ describe('Apex RS-232 protocol', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('computeChecksum spans the frame, not a fixed range', () => {
|
||||
it('matches the two reset frames the spec spells out literally', () => {
|
||||
// Rev G gives these verbatim, checksum included, so they are the only
|
||||
// ground truth available for the XOR range without hardware.
|
||||
const a = Buffer.from([0x02, 0x08, 0x61, 0x7f, 0x7f, 0x7f, 0x03, 0x16])
|
||||
const b = Buffer.from([0x02, 0x08, 0x60, 0x7f, 0x7f, 0x7f, 0x03, 0x17])
|
||||
expect(computeChecksum(a)).toBe(0x16)
|
||||
expect(computeChecksum(b)).toBe(0x17)
|
||||
})
|
||||
|
||||
it('covers the six data bytes of an 11-byte reply', () => {
|
||||
// The reply is longer than the host frame. A checksum hardcoded to the
|
||||
// host range silently mis-validates every reply the acceptor sends.
|
||||
const reply = [0x02, 0x0b, 0x20, 0x01, 0x10, 0x00, 0x00, 0x12, 0x34, 0x03, 0x00]
|
||||
let want = 0
|
||||
for (let i = 1; i <= 8; i++) want ^= reply[i] as number
|
||||
reply[10] = want
|
||||
expect(computeChecksum(Buffer.from(reply))).toBe(want)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFrame', () => {
|
||||
it('lays out the 8-byte poll frame with the ACK bit and checksum', () => {
|
||||
const f = buildFrame(0, 0x7f, 0x00)
|
||||
|
|
@ -32,6 +54,14 @@ describe('Apex RS-232 protocol', () => {
|
|||
expect(f[7]).toBe(0x66)
|
||||
})
|
||||
|
||||
it('carries escrow, stack and return in the command byte', () => {
|
||||
// Rev G BYTE 1: bit 4 escrow enable, bit 5 stack, bit 6 return. Escrow
|
||||
// is an enable held across polls, so it rides alongside the action bit.
|
||||
expect(buildFrame(0, 0x7f, 0x10)[4]).toBe(0x10)
|
||||
expect(buildFrame(0, 0x7f, 0x30)[4]).toBe(0x30)
|
||||
expect(buildFrame(0, 0x7f, 0x50)[4]).toBe(0x50)
|
||||
})
|
||||
|
||||
it('sets the stack command bit (0x20) in the command byte', () => {
|
||||
const f = buildFrame(0, 0x7f, 0x20)
|
||||
expect(f[4]).toBe(0x20)
|
||||
|
|
@ -82,11 +112,14 @@ describe('Apex RS-232 protocol', () => {
|
|||
})
|
||||
|
||||
describe('parseResponse', () => {
|
||||
const usd = [1, 5, 10, 20, 50, 100]
|
||||
const resolve = (ch: number) => usd[ch - 1] ?? null
|
||||
// Resolve through the real module, not a hand-copied array. The previous
|
||||
// local copy duplicated the shipping table's off-by-one and so asserted
|
||||
// the bug instead of catching it.
|
||||
const resolve = (ch: number) => denomForChannel('USD', ch)
|
||||
|
||||
it('resolves the escrowed note denomination from the credit channel', () => {
|
||||
// state=escrowed, event=present, credit=channel 4 ($20)
|
||||
// state=escrowed, event=present, credit=channel 4. Per spec Rev G the
|
||||
// USD channel order is $1 $2 $5 $10 $20 $50 $100, so channel 4 is $10.
|
||||
const frame = Buffer.from([
|
||||
0x02,
|
||||
0x0b,
|
||||
|
|
@ -102,7 +135,7 @@ describe('Apex RS-232 protocol', () => {
|
|||
])
|
||||
const r = parseResponse(frame, resolve)
|
||||
expect(r.status).toBe('billsRead')
|
||||
expect(r.bill?.denomination).toBe(20)
|
||||
expect(r.bill?.denomination).toBe(10)
|
||||
})
|
||||
|
||||
it('returns no bill when no channel is credited', () => {
|
||||
|
|
@ -124,8 +157,9 @@ describe('Apex RS-232 protocol', () => {
|
|||
expect(r.bill).toBeUndefined()
|
||||
})
|
||||
|
||||
it('yields a null denomination for an unmapped channel', () => {
|
||||
// channel 7 not present in the 6-entry USD table
|
||||
it('maps the top channel to the largest note', () => {
|
||||
// Channel 7 is $100. It read as unmapped while the table omitted $2,
|
||||
// which is exactly the shift this test now pins down.
|
||||
const frame = Buffer.from([
|
||||
0x02,
|
||||
0x0b,
|
||||
|
|
@ -140,7 +174,25 @@ describe('Apex RS-232 protocol', () => {
|
|||
0x00,
|
||||
])
|
||||
const r = parseResponse(frame, resolve)
|
||||
expect(r.bill?.denomination).toBeNull()
|
||||
expect(r.bill?.denomination).toBe(100)
|
||||
})
|
||||
|
||||
it('pins the whole USD channel order from the spec', () => {
|
||||
// Rev G, BYTE 2 bits 3-5: 001=$1 010=$2 011=$5 100=$10 101=$20
|
||||
// 110=$50 111=$100. A note credited at the wrong value is silent and
|
||||
// costs real money, so the full mapping is asserted rather than sampled.
|
||||
expect([1, 2, 3, 4, 5, 6, 7].map((ch) => denomForChannel('USD', ch))).toEqual([
|
||||
1, 2, 5, 10, 20, 50, 100,
|
||||
])
|
||||
})
|
||||
|
||||
it('has no denomination for channel 0, which means no note', () => {
|
||||
expect(denomForChannel('USD', 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('has no denomination when the currency is unknown', () => {
|
||||
expect(denomForChannel(null, 3)).toBeNull()
|
||||
expect(denomForChannel('ZZZ', 3)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,23 +6,27 @@
|
|||
*
|
||||
* Implemented from Pyramid's PUBLIC protocol facts only — the wire format,
|
||||
* bit masks and serial parameters documented in Pyramid's "RS-232 Serial
|
||||
* Interface Specification" (https://pyramidacceptors.com/pdf/RS_232.pdf) and
|
||||
* mirrored by their published integrator samples. No third-party (or
|
||||
* lamassu-machine) source is copied; the byte layout below is a functional
|
||||
* Interface Specification", document RS_232, Rev G 12/03/14. No third-party
|
||||
* (or lamassu-machine) source is copied; the byte layout below is a functional
|
||||
* spec, re-expressed for bitSpire under AGPL.
|
||||
*
|
||||
* The interface is Mars/MEI GL5-compatible, which is why it looks so much like
|
||||
* the EBDS driver next door. The acceptor is a pure slave: it answers polls and
|
||||
* never speaks first. Polls must not fall more than 5s apart or the acceptor
|
||||
* may dump an escrowed note and stop accepting until the host resumes.
|
||||
*
|
||||
* Frame (host → acceptor), fixed 8 bytes:
|
||||
* [0] STX 0x02
|
||||
* [1] LEN 0x08
|
||||
* [2] CTRL 0x10 | ack (ack toggles 0↔1 every message)
|
||||
* [3] ENA denomination enable bitmask (0x7F = all, 0x00 = none)
|
||||
* [4] CMD 0x00 base; | 0x20 stacks the escrowed note
|
||||
* [5] RSVD 0x00
|
||||
* [2] CTRL msg type 1 (master) in bits 4-6, ack in bit 0 (toggles every message)
|
||||
* [3] ENA BYTE 0 — per-note enable bits: bit 0 = note 1 … bit 6 = note 7
|
||||
* [4] CMD BYTE 1 — bit 4 escrow enable, bit 5 stack, bit 6 return
|
||||
* [5] RSVD BYTE 2 — reserved, 0x00
|
||||
* [6] ETX 0x03
|
||||
* [7] CHK XOR of bytes [1..5]
|
||||
* [7] CHK XOR of all bytes except STX, ETX and itself
|
||||
*
|
||||
* Frame (acceptor → host), length-prefixed like the host frame; the fields
|
||||
* this driver consumes:
|
||||
* Frame (acceptor → host), 11 bytes — STX, LEN, CTRL, six data bytes, ETX,
|
||||
* CHK. The fields this driver consumes:
|
||||
* [3] STATE bits 1=idling 2=accepting 4=escrowed 8=stacking
|
||||
* 16=stacked 32=returning 64=returned
|
||||
* [4] EVENT bits 0x01=cheated 0x02=rejected 0x04=jammed
|
||||
|
|
@ -39,8 +43,11 @@ const STX = 0x02
|
|||
const ETX = 0x03
|
||||
const HOST_FRAME_LEN = 0x08
|
||||
|
||||
// Host command byte (frame[4])
|
||||
const CMD_STACK = 0x20
|
||||
// Host command byte (frame[4]) — spec Rev G, "Data Fields for Messages Sent
|
||||
// By the Master", BYTE 1.
|
||||
const CMD_ESCROW = 0x10 // bit 4: set to 1 to ENABLE escrow mode
|
||||
const CMD_STACK = 0x20 // bit 5: stack the escrowed note
|
||||
const CMD_RETURN = 0x40 // bit 6: return the escrowed note
|
||||
|
||||
// Response STATE byte (frame[3]) bit masks
|
||||
const STATE_IDLING = 0x01
|
||||
|
|
@ -90,10 +97,18 @@ export interface ApexRs232Config {
|
|||
// Pure functions — checksum, frame building, response parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** XOR checksum over bytes [1..5] (LEN through RSVD), matching the host frame. */
|
||||
export function computeChecksum(frame: number[] | Buffer): number {
|
||||
/**
|
||||
* XOR checksum over every byte except STX, ETX and the checksum itself — spec
|
||||
* Rev G: "calculated on all bytes (except: STX, ETX and the checksum byte
|
||||
* itself)". For the 8-byte host frame that is bytes 1..5; for the 11-byte
|
||||
* reply it is bytes 1..8, which is why this is derived from the length rather
|
||||
* than hardcoded. Confirmed against the two reset frames the spec spells out
|
||||
* literally (02 08 61 7f 7f 7f 03 16 and 02 08 60 7f 7f 7f 03 17).
|
||||
*/
|
||||
export function computeChecksum(frame: number[] | Buffer, length?: number): number {
|
||||
const n = length ?? frame.length
|
||||
let cs = 0x00
|
||||
for (let i = 1; i <= 5; i++) cs ^= frame[i] ?? 0
|
||||
for (let i = 1; i <= n - 3; i++) cs ^= frame[i] ?? 0
|
||||
return cs
|
||||
}
|
||||
|
||||
|
|
@ -278,18 +293,25 @@ export class ApexRs232 extends EventEmitter {
|
|||
|
||||
/** Send one poll, carrying the current mask + latched escrow action. */
|
||||
poll(): void {
|
||||
// 'return' is expressed by disabling all channels while a note is escrowed,
|
||||
// which makes the acceptor hand the note back (Apex has no distinct return
|
||||
// opcode). 'stack' asserts CMD_STACK. Both are re-asserted until the device
|
||||
// leaves escrow. NOTE: verify the return-by-disable behaviour on the 7600
|
||||
// during bench bring-up; some firmware returns only on escrow timeout.
|
||||
let enableByte = this.enabledMask
|
||||
let cmdByte = 0x00
|
||||
if (this.pendingAction === 'stack') cmdByte = CMD_STACK
|
||||
else if (this.pendingAction === 'return') enableByte = 0x00
|
||||
// Escrow is asserted on EVERY poll. It is an enable bit, not a one-shot:
|
||||
// with it clear the acceptor never stops at escrow, so the host is never
|
||||
// offered the stack/return decision and notes are banked before anything
|
||||
// has validated them. This driver's whole FSM is built around that
|
||||
// decision point.
|
||||
//
|
||||
// Stack and return are the spec's own bits. An earlier version expressed
|
||||
// return by zeroing the enable mask, on the assumption that the Apex had
|
||||
// no return opcode; it has one, and disabling channels mid-escrow is not
|
||||
// what it means.
|
||||
//
|
||||
// Both are re-asserted until the device leaves escrow, so a single dropped
|
||||
// frame cannot strand a note.
|
||||
let cmdByte = CMD_ESCROW
|
||||
if (this.pendingAction === 'stack') cmdByte |= CMD_STACK
|
||||
else if (this.pendingAction === 'return') cmdByte |= CMD_RETURN
|
||||
|
||||
this.ack ^= 0x01
|
||||
this.serial?.write(buildFrame(this.ack, enableByte, cmdByte))
|
||||
this.serial?.write(buildFrame(this.ack, this.enabledMask, cmdByte))
|
||||
}
|
||||
|
||||
stack(): void {
|
||||
|
|
@ -331,13 +353,20 @@ export class ApexRs232 extends EventEmitter {
|
|||
this.poll()
|
||||
return
|
||||
}
|
||||
// Checksum is validated leniently: a mismatch is logged once but the frame
|
||||
// is still parsed. Pyramid's published host samples don't verify the reply
|
||||
// checksum, and the exact XOR range for the *reply* isn't confirmable from
|
||||
// the (scanned) spec — so we don't want a wrong assumption to blackhole
|
||||
// every otherwise-valid frame. Tighten to a hard drop once verified on hw.
|
||||
if (frame[len - 1] !== computeChecksum(frame)) {
|
||||
console.warn('[APEX] reply checksum mismatch (parsing anyway pending hw verification)')
|
||||
// The XOR range is now confirmed from the spec, so a mismatch is a hard
|
||||
// drop rather than the previous parse-anyway. A corrupted frame carries a
|
||||
// denomination field, and crediting a note from a frame we know is damaged
|
||||
// is the one outcome worth refusing outright. The raw bytes are logged so
|
||||
// a systematic framing error is still diagnosable rather than silent.
|
||||
const want = computeChecksum(frame, len)
|
||||
if (frame[len - 1] !== want) {
|
||||
console.warn(
|
||||
`[APEX] reply checksum mismatch: got ${frame[len - 1]?.toString(16)} ` +
|
||||
`want ${want.toString(16)} — frame dropped: ${frame.toString('hex')}`
|
||||
)
|
||||
this.emit('badFrame')
|
||||
this.poll()
|
||||
return
|
||||
}
|
||||
|
||||
const result = parseResponse(frame, this.denomForChannel)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,29 @@
|
|||
/**
|
||||
* Pyramid Apex Denomination Tables
|
||||
*
|
||||
* The Apex RS-232 reply reports a 1-based CREDIT CHANNEL (1..7), not a value —
|
||||
* the channel→value mapping is fixed by the bill dataset programmed into the
|
||||
* acceptor's firmware for its country. The arrays below are ascending value
|
||||
* lists indexed by (channel - 1); they MUST match the dataset flashed on your
|
||||
* specific Apex 7600, or a credited note will be booked at the wrong value.
|
||||
* Verify against the unit's configuration card during bring-up.
|
||||
* Indexed by (credit channel - 1). The channel is NOT an index into "the notes
|
||||
* this unit happens to have enabled" — it is a fixed protocol constant.
|
||||
* Pyramid's RS-232 spec (Rev G, "Data Fields for Messages sent by the Slave",
|
||||
* BYTE 2 bits 3-5) fixes the USD mapping:
|
||||
*
|
||||
* Defaults follow Pyramid's standard datasets (US = $1/$5/$10/$20/$50/$100;
|
||||
* $2 channel omitted as it's rarely enabled).
|
||||
* 001 = $1 010 = $2 011 = $5 100 = $10
|
||||
* 101 = $20 110 = $50 111 = $100
|
||||
*
|
||||
* $2 occupies channel 2 whether or not the unit accepts $2 notes. An earlier
|
||||
* version of this table omitted it as "rarely enabled", which shifted every
|
||||
* larger note down one slot: a $5 credited as $10, a $20 as $50, a $50 as
|
||||
* $100, and a $100 as nothing at all. Never drop an unused channel from these
|
||||
* arrays — pad it instead.
|
||||
*
|
||||
* For non-USD the spec says only "Foreign currencies are in sequential order
|
||||
* as note 1-7", so channel N is the Nth note type of whatever dataset is
|
||||
* flashed on the unit. The orderings below are the conventional ascending sets
|
||||
* and are UNVERIFIED against a real configuration card. Check the card before
|
||||
* a machine takes money in any of them.
|
||||
*/
|
||||
|
||||
export const denominations: Record<string, number[]> = {
|
||||
USD: [1, 5, 10, 20, 50, 100],
|
||||
USD: [1, 2, 5, 10, 20, 50, 100],
|
||||
EUR: [5, 10, 20, 50, 100, 200, 500],
|
||||
GBP: [5, 10, 20, 50],
|
||||
CAD: [5, 10, 20, 50, 100],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ export class ApexValidator extends EventEmitter implements BillValidator {
|
|||
constructor(config: ValidatorConfig) {
|
||||
super()
|
||||
this.config = config
|
||||
// Seed from config, as id003 effectively does by threading config.fiatCode
|
||||
// into its rs232 config. Nothing in the app calls setFiatCode(), so a
|
||||
// driver that relies on it alone resolves every credit channel to null and
|
||||
// rejects every note. setFiatCode() stays available as a later override.
|
||||
this.fiatCode = config.fiatCode ?? config.rs232.fiatCode ?? null
|
||||
this._throttledError = throttle((err: Error) => this.emit('error', err), 2000)
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +81,20 @@ export class ApexValidator extends EventEmitter implements BillValidator {
|
|||
this.fsm.on('billsAccepted', () => this.emit('billsAccepted'))
|
||||
this.fsm.on('billsRead', (bill: { denomination: number | null; code: string }) => {
|
||||
if (!bill.denomination) {
|
||||
console.log('[APEX] Bill rejected: unsupported/unmapped channel')
|
||||
// Say WHICH of the two causes this is. "unmapped channel" alone reads
|
||||
// as a hardware/dataset mismatch and sent us looking at DIP switches
|
||||
// when the real cause was a null fiat code rejecting every note.
|
||||
if (!this.fiatCode) {
|
||||
console.error(
|
||||
'[APEX] Bill rejected: no fiat code set on the driver, so NO channel ' +
|
||||
'can resolve to a value. Every note will be returned until this is fixed.'
|
||||
)
|
||||
} else {
|
||||
console.log(
|
||||
`[APEX] Bill rejected: channel ${bill.code || '?'} is not mapped in the ` +
|
||||
`${this.fiatCode} dataset — check the acceptor's configuration card`
|
||||
)
|
||||
}
|
||||
this.rs232?.reject()
|
||||
return
|
||||
}
|
||||
|
|
@ -92,6 +110,13 @@ export class ApexValidator extends EventEmitter implements BillValidator {
|
|||
this.fsm.on('standby', () => this.emit('standby'))
|
||||
this.fsm.on('error', (err: Error) => this.emit('error', err))
|
||||
|
||||
if (!this.fiatCode) {
|
||||
console.error(
|
||||
'[APEX] starting with NO fiat code — denomination lookup will return null ' +
|
||||
'for every credit channel and the acceptor will reject every note.'
|
||||
)
|
||||
}
|
||||
|
||||
this.rs232.open((err) => {
|
||||
if (err) return cb(err)
|
||||
this.rs232!.reset() // start disabled
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ export class EbdsValidator extends EventEmitter implements BillValidator {
|
|||
constructor(config: ValidatorConfig) {
|
||||
super()
|
||||
this.config = config
|
||||
// Seed from config, as id003 effectively does by threading config.fiatCode
|
||||
// into its rs232 config. Nothing in the app calls setFiatCode(), so a
|
||||
// driver that relies on it alone resolves every credit channel to null and
|
||||
// rejects every note. setFiatCode() stays available as a later override.
|
||||
this.fiatCode = config.fiatCode ?? config.rs232.fiatCode ?? null
|
||||
this._throttledError = throttle((err: Error) => this.emit('error', err), 2000)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue