feat: Raspberry Pi 4 (aarch64) target #110

Open
padreug wants to merge 12 commits from feat/rpi4-target into feat/rpi5-apex-hal
13 changed files with 664 additions and 69 deletions

View file

@ -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,
}
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)

View file

@ -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: {

View file

@ -23,7 +23,7 @@ export interface CassetteConfig {
export interface HalConfig {
validator: {
type: 'id003' | 'ebds'
type: 'id003' | 'ebds' | 'apex'
device: string | string[]
fiatCode: string
}

View file

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

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

View file

@ -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"; };
};
}
//

View file

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

View file

@ -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()
})
})
})

View file

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

View file

@ -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],

View file

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

View file

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