From 029bb09745e14ff48b25be7142f1f052f8fecd97 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 20 Sep 2026 11:26:02 +0200 Subject: [PATCH 01/12] refactor(deploy): key the Pi build machinery by board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit piBaseModules hardcoded the nixos-hardware raspberry-pi-5 module and our raspberry-pi-5.nix glue, so mkPiInstalled/mkPiImage could only ever produce a Pi 5. Everything else in the Pi runtime is board-agnostic. Introduce `piBoards`, keyed by machine model — the parameter already threaded through both builders — pairing each board's nixos-hardware module with its glue file, and have piBaseModules look the pair up. Same modules in the same order for rpi5, so its evaluated configuration is unchanged (compared on 16 app-independent facets: kernel, params, initrd modules, loader, device tree, video drivers, udev, filesystems, swap, nix settings, sleep targets, service exec/memory, env seed, sshd). No new board yet — that's the next commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4 --- flake.nix | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index 705fd4c..ab640f7 100644 --- a/flake.nix +++ b/flake.nix @@ -308,14 +308,26 @@ 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; + }; + }; + # 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; -- 2.55.0 From 91c6994dd4f20f671f102c3c9a9dbfd349250c71 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 20 Sep 2026 11:26:17 +0200 Subject: [PATCH 02/12] feat(deploy): add aarch64 Raspberry Pi 4 target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pi 4 twin of the Pi 5 build: `rpi4-installed` (in-place rebuild target), `rpi4-image`, and `packages.aarch64-linux.{sd-image-rpi4,atm-app-rpi4}`, all through the board-keyed machinery of the previous commit. The shared runtime is untouched; only the board pair is new. deploy/nixos/hardware/raspberry-pi-4.nix mirrors raspberry-pi-5.nix line for line except where the boards differ: - KMS for the kiosk display is an opt-in on the Pi 4 (`hardware.raspberry-pi."4".fkms-3d`), which also injects the CMA + vc4 device-tree overlays; the Pi 5 gets it by default. Without it X falls back to the framebuffer and Electron renders in software. - fkms-3d sets videoDrivers itself, so the module doesn't. Everything else — extlinux boot, console pinned to tty0 so the GPIO UART is free for a validator, no-suspend, the ttyValidator{0,1,2} udev symlinks — is identical by design. Evaluation-verified only: rpi4-installed/rpi4-image instantiate, and against rpi5 they differ solely in the expected places (bcm2711 device tree, the two fkms overlays, the rpiVersion=4 kernel, no clk-rp1 in initrd, machine model in the env seed). Not yet booted on hardware; the doc says so. docs/raspberry-pi-setup.md covers both boards — build, flash, first boot + provisioning via the spire seed, in-place updates, peripherals — since #87 shipped the Pi 5 without one. It replaces a never-committed Pi 4 sketch (parked on wip/rpi4-sketch) whose flake wiring didn't evaluate and whose provisioning section predated the pairing seed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4 --- deploy/nixos/README.md | 9 +- deploy/nixos/hardware/raspberry-pi-4.nix | 69 +++++++++++ docs/raspberry-pi-setup.md | 145 +++++++++++++++++++++++ flake.nix | 19 ++- 4 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 deploy/nixos/hardware/raspberry-pi-4.nix create mode 100644 docs/raspberry-pi-setup.md diff --git a/deploy/nixos/README.md b/deploy/nixos/README.md index 2bfc333..7dff65a 100644 --- a/deploy/nixos/README.md +++ b/deploy/nixos/README.md @@ -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 diff --git a/deploy/nixos/hardware/raspberry-pi-4.nix b/deploy/nixos/hardware/raspberry-pi-4.nix new file mode 100644 index 0000000..50388e9 --- /dev/null +++ b/deploy/nixos/hardware/raspberry-pi-4.nix @@ -0,0 +1,69 @@ +# 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"; + + # 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. + boot.kernelParams = [ "console=tty0" ]; + + # Kiosk display: vc4/v3d kernel modesetting via the firmware KMS overlay. + # This is the Pi 4's equivalent of the Pi 5's default KMS path — it also + # sets services.xserver.videoDrivers to modesetting (fbdev fallback), so we + # don't set that here. Electron renders through it as on the UP Board. + hardware.raspberry-pi."4".fkms-3d.enable = true; + + hardware.enableRedistributableFirmware = true; + + # Kiosk: never sleep. + systemd.targets = { + sleep.enable = false; + suspend.enable = false; + hibernate.enable = false; + hybrid-sleep.enable = false; + }; + + # Stable device symlinks for USB-serial bill-validator adapters, so the ATM + # config can point at /dev/ttyValidator0 regardless of enumeration order. + # 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" + ''; +} diff --git a/docs/raspberry-pi-setup.md b/docs/raspberry-pi-setup.md new file mode 100644 index 0000000..d6c525f --- /dev/null +++ b/docs/raspberry-pi-setup.md @@ -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` | + +`-image` is what you flash; `-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--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 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@ '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` diff --git a/flake.nix b/flake.nix index ab640f7..029a879 100644 --- a/flake.nix +++ b/flake.nix @@ -317,6 +317,10 @@ 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 @@ -483,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 @@ -697,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"; }; }; } // -- 2.55.0 From 6568618811d08b08f459f3f2b495a8c716c28f9a Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 25 Sep 2026 12:30:25 +0200 Subject: [PATCH 03/12] fix(nix): keep only the serialport prebuild this system can load The first ever aarch64 build of the app died here, on a Raspberry Pi 4: auto-patchelf could not satisfy dependency liblog.so wanted by node_modules/@serialport/bindings-cpp/prebuilds/android-arm64/node.napi.armv8.node auto-patchelf could not satisfy dependency libc++_shared.so wanted by (the same file) @serialport/bindings-cpp ships prebuilds for every platform it supports: android-arm, android-arm64, darwin, linux-arm, linux-arm64, linux-x64 in both glibc and musl, win32-ia32 and win32-x64. installPhase copied the whole directory. On x86_64 that was harmless because autoPatchelf skips ELF files whose architecture does not match the host, so the Android and ARM prebuilds were never touched. On aarch64 the android-arm64 prebuild IS the host architecture, so autoPatchelf picks it up and goes looking for Android's liblog.so and libc++_shared.so, which NixOS does not have. The failure was invisible until someone built for a second architecture. Keep only the prebuild the target can load, selected from stdenv.hostPlatform: linux-arm64 on aarch64, linux-x64 elsewhere, glibc rather than musl. Pruning rather than adding those two libraries to autoPatchelfIgnoreMissingDeps, which would have been the one-line fix. Teaching autoPatchelf to tolerate a binary we never load, for a platform we do not target, leaves the foreign prebuilds in the closure and leaves the same trap set for the next architecture. The existing "libc.musl-x86_64.so.1" entry in that list is this same problem solved the other way; it is now redundant, and is left in place only because this commit is unblocking a machine mid-build and is not the moment to find out whether something else depended on it. Verified on x86_64: the app still builds, ships linux-x64/node.napi.glibc.node alone where it previously carried nine platforms, and comes to 25M. --- nix/mkAtmApp.nix | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/nix/mkAtmApp.nix b/nix/mkAtmApp.nix index 68a4c37..272bd33 100644 --- a/nix/mkAtmApp.nix +++ b/nix/mkAtmApp.nix @@ -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 -- 2.55.0 From b2bf2ffe4b446ecc40c7d7537ffa5b90b10b013d Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 25 Sep 2026 15:27:12 +0200 Subject: [PATCH 04/12] fix(rpi4): use the mainline kernel, not the uncached Pi vendor one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nixos-hardware's raspberry-pi/4 module mkDefaults boot.kernelPackages to the Raspberry Pi vendor kernel (linux-rpi, via common/kernel.nix). That kernel is in no binary cache: Hydra does not build nixos-hardware's overlay kernels, 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. Found the hard way during the first Pi 4 bring-up, which spent hours on building linux-rpi-6.18.39-stable_20260724 (buildPhase): CC [M] fs/overlayfs/inode.o before anyone looked closely enough to notice it was not the app. I had told the operator only the app would build, having checked that Electron and the Pi firmware were cached and never checked the kernel. Mainline aarch64 kernels are cached, and mainline demonstrably boots a Pi 4 — it is what the stock NixOS aarch64 SD image runs, which is how this machine was bootstrapped in the first place. 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. before: linux-rpi-6.18.39-stable_20260724 not cached, hours to build after: linux-6.12.90 cached, downloads Verified the config still evaluates, which also clears nixos-hardware's assertion that the kernel be at least 6.1. Watch the graphics path. fkms-3d 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 lands on the framebuffer with Electron rendering in software, that overlay is where to look, not this kernel choice. rpi5 is left alone deliberately and still carries the vendor kernel, so it will pay the same cost whenever someone first builds it. Mainline Pi 5 support is younger than Pi 4's and the RP1 southbridge needed vendor patches for longer, so that one wants its own check rather than the same change applied on faith. --- deploy/nixos/hardware/raspberry-pi-4.nix | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/deploy/nixos/hardware/raspberry-pi-4.nix b/deploy/nixos/hardware/raspberry-pi-4.nix index 50388e9..a30d22c 100644 --- a/deploy/nixos/hardware/raspberry-pi-4.nix +++ b/deploy/nixos/hardware/raspberry-pi-4.nix @@ -22,6 +22,28 @@ # 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; -- 2.55.0 From 11cc1b88d680f496fd22e534af189bad2673c94b Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 25 Sep 2026 15:45:56 +0200 Subject: [PATCH 05/12] fix(rpi4): drop fkms-3d, mainline does full KMS without an overlay With the mainline kernel from the previous commit, the device-tree overlay step fails outright: Applying overlay rpi4-cma-overlay Applying overlay rpi4-vc4-fkms-v3d-overlay libfdt.FdtException: pylibfdt error -1: FDT_ERR_NOTFOUND nixos-hardware's fkms-3d applies two overlays that patch nodes present in the Raspberry Pi VENDOR kernel's DTBs and absent from mainline's. Predicted when the kernel changed; this is it arriving. It is also the wrong thing to want. "fkms" is FIRMWARE KMS, the older arrangement where the VideoCore firmware owns the display and Linux drives it at arm's length. Mainline does full KMS, and mainline's own bcm2711-rpi-4-b.dtb already describes the hardware: it carries brcm,bcm2711-vc5 and brcm,2711-v3d nodes, confirmed by decompiling the DTB with dtc. The vc4 and v3d drivers bind to those directly, no overlay involved. So the overlay was not providing capability, it was translating for a kernel we no longer use. hardware.deviceTree.overlays is now empty, so there is nothing left for the overlay builder to fail on. Verified by evaluating the config. No replacement needed for videoDrivers either. fkms-3d used to set it as a side effect, but the shared configuration.nix already declares modesetting, which is correct for full KMS and is what the x86 machines use. Setting it again here just produced ["modesetting" "modesetting"]. Still unproven on hardware: whether X comes up on vc4 rather than falling back to a framebuffer. That is the next thing to read out of /var/log/X.0.log once the machine boots, and it is now a minutes-long iteration rather than a kernel compile per attempt. --- deploy/nixos/hardware/raspberry-pi-4.nix | 26 +++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/deploy/nixos/hardware/raspberry-pi-4.nix b/deploy/nixos/hardware/raspberry-pi-4.nix index a30d22c..8fc469b 100644 --- a/deploy/nixos/hardware/raspberry-pi-4.nix +++ b/deploy/nixos/hardware/raspberry-pi-4.nix @@ -59,11 +59,27 @@ # the kernel falls back to the device tree's stdout-path, i.e. this UART. boot.kernelParams = [ "console=tty0" ]; - # Kiosk display: vc4/v3d kernel modesetting via the firmware KMS overlay. - # This is the Pi 4's equivalent of the Pi 5's default KMS path — it also - # sets services.xserver.videoDrivers to modesetting (fbdev fallback), so we - # don't set that here. Electron renders through it as on the UP Board. - hardware.raspberry-pi."4".fkms-3d.enable = true; + # 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; -- 2.55.0 From 8917b8b96726da04c4d539247106556234721c54 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 25 Sep 2026 18:02:31 +0200 Subject: [PATCH 06/12] fix(rpi4): raise CMA to 256M, document the firmware-partition step Two findings from the first Pi 4 (actually a CM4) bring-up, chased from a white screen to hardware-accelerated X. CMA. The vc4 display pipeline allocates its framebuffer from the contiguous memory area, and the default reservation here is 32MiB with ~11MiB free. The attached panel is 3840x1080, whose framebuffer is ~16.6MB before double buffering, so X picked 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 its display one; cma=256M replaces that half. This part is declarative, since kernelParams reach the extlinux APPEND line. The display itself is NOT declarative, and that is the uncomfortable part. This board boots the FIRMWARE's vendor DTB, not the DTBs NixOS builds: the live device tree carries __symbols__ and mainline's do not, and U-Boot found no FDTDIR match for compatible "raspberrypi,4-compute-module" so it passed the firmware's DTB straight through. hardware.deviceTree.overlays therefore cannot reach the running tree at all, which is also why the earlier fkms-3d removal fixed a build error without fixing the display. In the vendor DTB every display node ships disabled, so config.txt needs `dtoverlay=vc4-kms-v3d,noaudio` and /boot/firmware/overlays/ has to be populated from raspberrypifw. Three traps in that one line, each of which failed silently: - the NixOS sd-image writes the DTBs to the firmware partition but NOT the overlays, so the directory ships empty and dtoverlay= does nothing - copying only vc4-kms-v3d.dtbo is insufficient; the firmware remaps that to vc4-kms-v3d-pi4.dtbo on this board, so the whole directory goes - without noaudio, vc4_hdmi cannot register its PCM component, returns -517 (EPROBE_DEFER) forever, and the DRM device never registers, so X finds no card. We deleted the audio stack anyway. Result on the machine: card1 is vc4-drm with HDMI-A-1 connected, card2 is v3d, and X reports glamor X acceleration enabled on V3D 4.2.14.0 against swrast before. The manual steps are written into the module so the next person does not rediscover them from a blank screen, but they are lost on a reflash and belong in the image builder. Follow-up. --- deploy/nixos/hardware/raspberry-pi-4.nix | 41 +++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/deploy/nixos/hardware/raspberry-pi-4.nix b/deploy/nixos/hardware/raspberry-pi-4.nix index 8fc469b..f32bf69 100644 --- a/deploy/nixos/hardware/raspberry-pi-4.nix +++ b/deploy/nixos/hardware/raspberry-pi-4.nix @@ -57,7 +57,46 @@ # highest priority survive. nixpkgs sets loglevel/lsm at normal priority, so # a mkDefault list here is dropped entirely — and with no console= at all # the kernel falls back to the device tree's stdout-path, i.e. this UART. - boot.kernelParams = [ "console=tty0" ]; + # 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. # -- 2.55.0 From 2572a14c61c8389712c7a6cc2aba1c93552f1e8f Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 25 Sep 2026 22:09:36 +0200 Subject: [PATCH 07/12] =?UTF-8?q?fix(rpi4):=20enable=20pcscd=20=E2=80=94?= =?UTF-8?q?=20without=20it=20the=20kiosk=20hangs=20before=20drawing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the white screen. Not graphics, not the bundle, not pairing. The app constructs @pokusew/pcsclite at startup. That calls SCardEstablishContext(), which calls SCardCheckDaemonAvailability(), which — finding no pcscd — BUSY-LOOPS in fstatat64 at ~92% CPU rather than returning an error. It runs on Electron's main thread before the BrowserWindow is created, so no window is ever made and the panel stays white. It is a hang, not a crash, which is why it presented so badly. Nothing throws. Nothing is logged after "[StateStore] Initialized database". The process looks healthy: systemd reports the service active, Electron is running, and there is even a gpu-process. But a setInterval registered before startup never fires once in 32 seconds, the main thread sits in state R, and the remote debugger reports zero page targets. V8's own tooling cannot see it either, because the thread never yields to the inspector: Debugger.pause returns nothing and Profiler.stop times out. A native backtrace was the only thing that worked: #0 fstatat64 libc #1 SCardCheckDaemonAvailability libpcsclite #2 SCardEstablishContext libpcsclite #3 PCSCLite::PCSCLite() pcsclite.node #4 PCSCLite::New(...) Ruled out along the way, each by direct test on the machine: graphics (it fails identically with the GPU fully disabled), Electron on aarch64 (a minimal app renders fine), the Vue bundle (loading the real index.html from a minimal main process mounts the app and reaches "[ATM] State machine initialized"), the preload script, the CSP, kiosk and fullscreen window options, better-sqlite3, /dev/shm, memory, page size, X authorisation, and isDev. upboard.nix and batm3.nix both enable pcscd for their real readers, which is why no x86 machine has ever hit this. This module did not, and that was the entire difference. pcscd with no reader attached simply idles, so enabling it costs nothing. Worth noting for the wider fleet: any future board that omits pcscd inherits this, and it presents as a blank screen with a healthy-looking service. The robust fix is for the app to not block its main thread on a card-reader handshake at all — the NFC path is already documented as best-effort — but that is an app change and this unblocks the hardware. --- deploy/nixos/hardware/raspberry-pi-4.nix | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/deploy/nixos/hardware/raspberry-pi-4.nix b/deploy/nixos/hardware/raspberry-pi-4.nix index f32bf69..5b136dc 100644 --- a/deploy/nixos/hardware/raspberry-pi-4.nix +++ b/deploy/nixos/hardware/raspberry-pi-4.nix @@ -122,6 +122,41 @@ 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; -- 2.55.0 From 64a19da92451ba2ea81e5f04365a8fdc828f7663 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 25 Sep 2026 22:56:57 +0200 Subject: [PATCH 08/12] feat(machine): add rpi4/rpi5 device presets, wire 'apex' into the app types The Pi 4 bring-up got as far as a rendering kiosk and then failed every init cycle with [App] Initialization failed: TypeError: Cannot read properties of undefined (reading 'validator') MACHINE_PRESETS had entries for sintra, tejo, douro, gaia and batm3 but none for rpi4, so getDeviceConfig() dereferenced undefined. Pairing never happened either: the throw lands before the signer runs, so a correctly provisioned VITE_SPIRE_SEED sat in the process environment while bunker_binding stayed at zero. rpi5 had the same hole and would have hit it the moment anyone booted that target. This is the third instance today of the same shape: the Pi targets reuse the shared runtime, and the shared runtime carries per-model tables that nobody added the Pi to. pcscd was the first (a busy-loop, no window), the Electron cassette presets the second (silently seeded nothing). Also widens the validator union from 'id003' | 'ebds' to include 'apex' in both DeviceConfig and HalConfig. packages/hal has had ValidatorType = 'id003' | 'ebds' | 'apex' since the Pyramid Apex driver landed with the Pi 5 work, but these app-side unions were never widened, so no machine could be configured to use the driver at all. The presets below are the first thing that needed it, which is presumably why nobody noticed. The dispenser block in both presets 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. These boards are cash-in only until real hardware lands. A 'none' dispenser variant would be the honest fix and is worth doing separately. Validator device defaults to /dev/ttyValidator0, the FTDI udev symlink raspberry-pi-4.nix creates. Swap to ttyValidator1 (CP210x) or ttyValidator2 (CH340) to match the adapter fitted; `ls -l /dev/ttyValidator*` after plugging it in says which appeared. --- apps/machine/src/config/device.ts | 61 ++++++++++++++++++++++++++++++- apps/machine/src/services/hal.ts | 2 +- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/machine/src/config/device.ts b/apps/machine/src/config/device.ts index cffdaa3..9a06c9a 100644 --- a/apps/machine/src/config/device.ts +++ b/apps/machine/src/config/device.ts @@ -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 Date: Tue, 29 Sep 2026 18:16:41 +0200 Subject: [PATCH 09/12] fix(machine): make the dispenser optional, as the validator already was MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initializeHal created and initialised the dispenser unconditionally, so a missing dispenser device threw and aborted the WHOLE of HAL init — taking the validator down with it, even when the validator was present and working. The Pi bring-up hit exactly that. With a Pyramid Apex correctly wired and enumerated on /dev/ttyValidator0: [ATM] Validator device: /dev/ttyValidator0 [ATM] Dispenser device: /dev/ttyDispenser-not-fitted [Electron] HAL init failed: cannot open /dev/ttyDispenser-not-fitted [Recovery] Reloading renderer to re-attempt initialization and round again, forever, with a perfectly good acceptor attached. The validator has been optional since it was written — it checks the device exists, catches init failures, and logs "running dispenser-only". The dispenser had no equivalent. That asymmetry was the bug, not the placeholder device path that exposed it: a cash-in-only machine is a legitimate configuration, and the Raspberry Pi reference build is one. Mirrors the validator's handling exactly: existence check, try/catch, null on failure, and a log line saying what the machine will do instead ("running cash-in only"). Three call sites then need guarding — dispenseCash returns a clear "No dispenser fitted on this machine — cash-out unavailable" rather than dereferencing null, setCassettes still records the layout but skips the re-init, and cleanup uses an optional call. This also removes the sharp edge from the rpi4/rpi5 presets added in the previous commit. Their dispenser block points at a path that does not exist because DispenseType has no 'none' variant and DeviceConfig requires the field. That is still worth fixing properly with a real 'none' variant, but the machine no longer has to care. --- apps/machine/electron/hal-service.ts | 56 +++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 2ec863b..5453fea 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -87,19 +87,40 @@ export async function initializeHal(config: HalConfig): Promise { 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 { dispenseCash: async (amounts): Promise => { 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 { 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 { return new Promise((resolve) => { validator?.disable() validator?.lightOff() - dispenser.close() + dispenser?.close() if (validator) { validator.close((err?: Error) => { if (err) console.error('[HAL] Validator close error:', err) -- 2.55.0 From dd542eda88b197dfe89779c9f60cfd0be3c3566d Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 21:08:24 +0200 Subject: [PATCH 10/12] fix(hal): seed the validator's fiat code from config, or it rejects every note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Apex returned every bill inserted. Cause is not wiring or DIP switches: the driver had no fiat code, so no credit channel could resolve to a value. ApexValidator initialises `fiatCode` to null and only assigns it in setFiatCode(). NOTHING in this repo calls setFiatCode() — not hal-service, not the store, nothing. It is declared on the BillValidator interface and implemented three times, and it is dead code. So the resolver installed in run(): this.rs232.setDenomResolver((ch) => denomForChannel(this.fiatCode, ch)) is always called with null, and denomForChannel bails on its first line (`if (!fiatCode || channel < 1) return null`). Every note is read, resolves to null denomination, hits the `!bill.denomination` branch, and is handed straight back. id003 is unaffected because it never relies on the field: run() threads `config.fiatCode` into the rs232 config, so the value reaches the layer that needs it regardless. Apex and EBDS both read `this.fiatCode` instead, so both are broken the same way. EBDS is fixed here too — it has the identical dead-field dependency in _denominations() and would fail identically the first time it met hardware. Both constructors now seed from `config.fiatCode ?? config.rs232.fiatCode`, which is what the callers have been passing all along. setFiatCode() stays as a later override rather than the only path in. Also makes the failure loud, because the old log line is what sent us looking at the acceptor instead of the driver. "Bill rejected: unsupported/unmapped channel" reads as a dataset/hardware mismatch and gives no hint that the driver simply has no currency. It now names which of the two causes it is, and run() logs an error up front when there is no fiat code at all, since in that state every note is guaranteed to be returned. --- packages/hal/src/validators/apex/index.ts | 27 ++++++++++++++++++++++- packages/hal/src/validators/ebds/index.ts | 5 +++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/hal/src/validators/apex/index.ts b/packages/hal/src/validators/apex/index.ts index 463ee06..5d98c03 100644 --- a/packages/hal/src/validators/apex/index.ts +++ b/packages/hal/src/validators/apex/index.ts @@ -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 diff --git a/packages/hal/src/validators/ebds/index.ts b/packages/hal/src/validators/ebds/index.ts index 44dd540..52a749b 100644 --- a/packages/hal/src/validators/ebds/index.ts +++ b/packages/hal/src/validators/ebds/index.ts @@ -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) } -- 2.55.0 From f7b1942f3852c86e58f5ed5e2bdf130fe6faeb7d Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 30 Sep 2026 21:59:56 +0200 Subject: [PATCH 11/12] =?UTF-8?q?fix(hal):=20correct=20the=20Apex=20USD=20?= =?UTF-8?q?channel=20map=20=E2=80=94=20it=20credited=20notes=20at=20the=20?= =?UTF-8?q?wrong=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credit channel is a fixed protocol constant, not an index into whichever notes a given unit has enabled. Pyramid's RS-232 spec (Rev G, BYTE 2 bits 3-5) fixes it: 001=$1 010=$2 011=$5 100=$10 101=$20 110=$50 111=$100 This table omitted $2, with a comment calling it "rarely enabled". That is true and irrelevant: channel 2 is $2 whether or not the acceptor takes one. Dropping it shifted every larger note down a slot, so the machine would have credited: $5 as $10 $10 as $20 $20 as $50 $50 as $100 $100 as nothing at all (channel 7 ran off the end and read as unmapped, which the driver treats as an invalid note and returns) Every error is in the customer's favour and none of them is visible — the value never appears on the wire, only the channel, so there is nothing to reconcile against. A machine taking twenties would have paid out at fifty dollar rates until someone noticed the till was short. The tests encoded the same off-by-one, because they hand-copied the array instead of importing it, so they asserted the bug rather than catching it. They now resolve through denomForChannel and pin the full seven-channel order from the spec. Reverting the table alone fails three of them. Non-USD is unverified. The spec says only "foreign currencies are in sequential order as note 1-7", so those tables are the conventional ascending sets and nobody has checked them against a real configuration card. Called out in the module docstring rather than left to be discovered the same way. --- .../hal/src/validators/apex/denominations.ts | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/hal/src/validators/apex/denominations.ts b/packages/hal/src/validators/apex/denominations.ts index 9874da4..95410ed 100644 --- a/packages/hal/src/validators/apex/denominations.ts +++ b/packages/hal/src/validators/apex/denominations.ts @@ -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 = { - 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], -- 2.55.0 From 76f3c2ff9d5c2160ad69120b61ae5e9a01902b9b Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 30 Sep 2026 21:59:56 +0200 Subject: [PATCH 12/12] fix(hal): enable Apex escrow, use the real return bit, checksum the whole frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three protocol corrections from Pyramid's spec (RS_232 Rev G), all of which this driver had guessed at because it was written clean-room without it. **Escrow was never enabled.** BYTE 1 bit 4 is an enable, and the driver left it clear on every poll. With it clear the acceptor does not stop at escrow, so the host is never offered the stack-or-return decision and notes are banked before anything has validated them. The entire FSM here is built around that decision point, so this was not a missing nicety — the driver's central flow could not have happened. It is now asserted on every poll. **Return used the wrong mechanism.** The driver expressed "give the note back" by zeroing the enable mask mid-escrow, under an in-code assumption that "Apex has no distinct return opcode". It has one: BYTE 1 bit 6. The old approach was flagged in a comment as needing hardware verification; the spec settles it instead. Note the spec also distinguishes Returning (host refused a valid note) from Rejected (acceptor judged it invalid), which is the distinction this bit exists to express. **The checksum range was hardcoded to the host frame.** computeChecksum always XORed bytes 1..5, which is right for the 8-byte poll and wrong for the 11-byte reply, where it should span 1..8. So every reply failed validation. That was masked by the check being non-fatal "pending hardware verification", which logged a warning and parsed anyway. The range is now derived from the frame length, and verified against the two reset frames the spec spells out literally with their checksums — the only ground truth available without hardware. Those same frames are now a test. With the range confirmed, a mismatch becomes a hard drop rather than a warning. A corrupt frame carries a denomination field, and crediting a note from a frame known to be damaged is the one outcome worth refusing. The raw bytes are logged so a systematic framing error stays diagnosable. Also records two operational facts from the spec that were not written down: the interface is Mars/MEI GL5-compatible (hence the resemblance to the EBDS driver), and polls must not fall more than 5s apart or the acceptor may dump an escrowed note and stop accepting until the host resumes. Our 100ms cadence is comfortably inside that. --- .../apex/__tests__/apex-rs232.test.ts | 66 +++++++++++-- .../hal/src/validators/apex/apex-rs232.ts | 93 ++++++++++++------- 2 files changed, 120 insertions(+), 39 deletions(-) diff --git a/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts b/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts index 95c1607..bafb50d 100644 --- a/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts +++ b/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts @@ -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() }) }) }) diff --git a/packages/hal/src/validators/apex/apex-rs232.ts b/packages/hal/src/validators/apex/apex-rs232.ts index d8bcb7e..e8fc12f 100644 --- a/packages/hal/src/validators/apex/apex-rs232.ts +++ b/packages/hal/src/validators/apex/apex-rs232.ts @@ -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) -- 2.55.0