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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
raspberry-pi-5.nix set `boot.kernelParams = lib.mkDefault [ "console=tty0" ]`
to keep the serial console off the GPIO UART so a validator can own it. It
never took effect: kernelParams is list-merged, and only definitions at the
highest priority survive — nixpkgs defines loglevel/lsm at normal priority,
so the mkDefault list was discarded wholesale. Effective params on
rpi5-installed were `[ "loglevel=4" "lsm=landlock,yama,bpf" ]`, no console=
at all, which makes the kernel fall back to the device tree's stdout-path:
that same UART.
Drop the mkDefault so the entry merges. Verified by evaluating
config.boot.kernelParams before/after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
`rpi5-installed` previously bundled the sd-image-aarch64 module, so it
was only good for building a flashable image — a `nixos-rebuild switch`
against it (local or the git+ssh remote form) would drag in the image
builder and its image-specific fs wiring. Split it, mirroring the x86
fleet's mkLiveConfig/mkInstalledConfig separation:
- rpi5-installed → in-place rebuild target. Declares the flashed media's
own root fs (NIXOS_SD / FIRMWARE labels), nothing image-specific. This
is what
sudo nixos-rebuild switch --flake \
"git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=<branch>#rpi5-installed"
targets, the aarch64 equivalent of the sintra/douro deploy ritual.
- rpi5-image → same shared runtime + the sd-image builder. Its
system.build.sdImage is the flashable artifact; packages.aarch64-linux
.sd-image-rpi5 now points here.
Shared runtime extracted into piBaseModules/mkPiRuntime; folded the
aiolabs cachix substituter + trusted key into the Pi's nix.settings so a
remote rebuild substitutes the heavy aarch64 closure instead of building
it on the Pi (no max-jobs/timeout watchdog — the Pi 5 can build locally
if it must).
Verified: rpi5-installed evaluates to a valid system toplevel (root fs
present), rpi5-image/sd-image-rpi5 to the .img.zst builder, and x86
sintra-installed is byte-identically unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGUJJjBDuYwRaWSkFdK3bm
Proper aarch64 NixOS target for the DIY Pi 5 build, wired additively so
the x86 fleet path is untouched (both the new Pi sd-image and the
existing sintra-installed still evaluate cleanly):
- nixos-hardware input (raspberry-pi-5 module) for Pi kernel/firmware/GPU.
- aarch64 pkgs + pkgs-unstable + mkAtmApp instances (parallel to x86).
- mkPiConfig: aarch64 nixosSystem reusing the shared configuration.nix +
bitspire-atm service, replicating the installed-config runtime (bitspire
service, first-boot env seed, electron service override, swap). Drops
the x86 fleet machinery for a first bring-up: no determinate/autoUpgrade
(not yet fleet-managed) and no atm-tui (needs an aarch64 package).
- raspberry-pi-5.nix hardware module: extlinux boot, vc4/v3d KMS for X,
primary UART free for a GPIO-wired validator, no-suspend, and stable
/dev/ttyValidator* udev symlinks for USB-serial validator adapters
(Apex 7600 RS-232 via adapter, NV10 USB+).
- nixosConfigurations.rpi5-installed + packages.aarch64-linux.sd-image-rpi5.
BUILD NOTE: the app closure (aarch64 electron/native addons) needs an
aarch64 builder — a native Pi/arm box or `boot.binfmt` qemu emulation on
an x86 host. Config evaluates on x86; it just can't build there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
New `apex` validator for Pyramid Technologies Apex-series acceptors
(Apex 5000/7000/7600) on their RS-232 interface, for the Raspberry Pi 5
build. Implemented from Pyramid's PUBLIC protocol facts (RS-232 Serial
Interface Specification + their published integrator samples) — not
ported from lamassu-machine or any licensed source, so it stays inside
this repo's provenance boundary and AGPL.
- apex-rs232.ts: 8-byte poll frame (STX/len/ctrl+ACK-toggle/enable/cmd/
rsvd/ETX/XOR-checksum), reply parsing (state+event+credit bytes),
escrow stack/return latching re-asserted until the note leaves escrow.
- apex-fsm.ts: status tracker → BillValidator events (mirrors the EBDS
tracker; dedupes continuous polling; escrow-latency watchdog).
- denominations.ts: per-fiat channel→value table (channel order must
match the acceptor's programmed dataset — verify on the unit).
- index.ts: ApexValidator implementing BillValidator; wired into the
createValidator factory as ValidatorType 'apex'.
- 13 unit tests for checksum, frame build, status priority, denom map.
Bench-verify on real hardware before trusting: the reply checksum range
(parsed leniently for now) and return-by-disable escrow behaviour are
flagged in-code as needing confirmation on the 7600.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
The Feitian R502-CL (and cheap CCID readers generally) can wedge: it keeps
detecting a card but every APDU returns "card absent or mute", and ONLY a
USB power-cycle clears it — restarting pcscd or the app does not (confirmed
on-device). Until now that left cash-out/cash-in taps dead until a manual
replug.
- nfc-service.ts: count consecutive read failures; after 3 (gated by a 30s
cooldown so a still-wedged reader can't reset-loop) trigger
nfc-reader-reset.service. nfc-pcsc then re-detects the reader on USB
hotplug with no app restart (verified live).
- batm3.nix: nfc-reader-reset.service (oneshot, root) re-binds the reader's
USB device (a software replug); reader-agnostic via the CCID interface
class (0x0B) so it also covers a future ACR1252U. A polkit rule lets the
unprivileged `bitspire` app start just that one unit.
Hardware track (separate): the durable fix is a better reader (ACR1252U —
large antenna for behind-panel, firmware-upgradable). This change makes any
reader's wedge a ~2s self-heal in the meantime.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
egalax-calibrate polls 30s for the eGalax X device then gives up; on a
slow USB boot usbtouchscreen binds the panel later than that, so the
calibration matrix is never applied and touch registers in the wrong
place ("dead" panel). Seen on cold boots (2/2 today), fine on others —
a nondeterministic race, not a regression.
- Add a udev rule that (re)starts egalax-calibrate the instant the eGalax
input node appears (SYSTEMD_WANTS) — device-driven, can't lose the race.
- Widen the calibrate poll window 30s -> 120s as a fallback.
Recovery when it does strand: `systemctl restart egalax-calibrate`, or
apply the matrix live via xinput set-prop.
Tap-to-receive for the buy flow, the receive counterpart to #83's
cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher
(wrong direction to deposit into it), so the tap is used as an
authenticated identity (external_id + SUN p/c) to resolve the card
wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the
payout over its existing nostr transport.
- electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay
target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single
HTTPS-today / nostr-tomorrow transport seam. 13 tests.
- IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) +
preload/electron.d.ts surface.
- stores/atm.ts: handleBoltCardReceive() settles via the existing
payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes
the same tap by flow (cash-out pulls, cash-in receives).
- CashInView.vue: NFC status + dev tap input.
- docs/boltcard-receive-resolver.md: spec for the custom LNbits
/boltcards/api/v1/pay/<id> resolver endpoint (omni-private side).
Card issuance is unchanged — same NDEF/keys/external_id; receive is a
server-side reading of the same tap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hammering a flaky CCID reader with rapid re-reads wedges it into a
present↔empty storm (only a USB replug clears it). After a failed read,
ignore card re-detections for 1.5s; successful reads don't cool down.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Retrying a read hammered the cheap CCID reader into a stuck present↔empty
loop (only cleared by a reboot), so drop the retry: a read is a single
attempt and the user re-taps if the RF link drops mid-read. Also skip the
Capability-Container round-trip in the common case — NTAG424 Bolt Cards use
NDEF FileID E104, so try E104/0004 directly and only read the CC to discover
the id if both fail. Fewer APDUs → a read completes inside a shorter stable
window.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First real-card tap read the NDEF file with id 0004 and got "not a Bolt
Card" — NTAG424 (Bolt Cards) use FileID E104. Read the Capability Container
(EF E103) after selecting the NDEF app to learn the advertised NDEF FileID,
then read that file; fall back to E104/0004. Tolerates a transient transmit
error (surfaced as a retryable status; the next tap re-reads).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pcscd gates clients via polkit; the sandboxed bitspire user was "Rejected
unauthorized PC/SC client", so add a polkit rule granting it
access_pcsc/access_card. Also log NFC reader status + taps from the main
process to journald (value redacted — it carries the card's SUN p/c) so
reader detection and taps are observable during testing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild @pokusew/pcsclite (V8 C++ addon) against Electron headers like
better-sqlite3, and package nfc-pcsc + @pokusew/pcsclite into the runtime
node_modules. Its binding.gyp hardcodes Debian /usr/include/PCSC + /usr/lib,
so point the compiler/linker at nixpkgs pcsclite via CPATH/LIBRARY_PATH
(winscard.h lives under include/PCSC); pcsclite.lib in buildInputs lets
autoPatchelf wire libpcsclite.so.1 into the .node RPATH. Bumps the pnpmDeps
hash for the added nfc-pcsc dependency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renderer side of tap-to-pay. The store subscribes to the main-process
reader (onNfcCardTapped/onNfcStatus); a tap during displayingInvoice pulls
payment for the shown invoice via lnurlWithdraw (amount in msats), guarded
against double-taps. Settlement still flows through the existing invoice
watcher → PAYMENT_RECEIVED → dispensingCash, so the state machine is
unchanged. Bolt Card state clears when leaving the invoice screen.
CashOutView: "Tap Card or Scan to Pay" + live reader/processing/declined
status on the invoice screen, plus a dev input to simulate a tap with a
pasted lnurlw. Exposes nfcStatus / boltCardProcessing / simulateBoltCardTap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Main-process driver over nfc-pcsc (PC/SC). On tap it reads the NTAG424
Type-4 NDEF file via ISO7816 APDUs (select NDEF app D2760000850101 →
select file → ReadBinary NLEN + message) and extracts the lnurlw voucher
(fresh SUN p/c per tap), forwarding it to the renderer on `nfc:card-tapped`
(+ `nfc:status`). Lazy, guarded import — a missing reader/pcscd just
reports 'unavailable', never breaking the cash-out QR path. Preload
removeAllListeners guards against a double payment-trigger on renderer reload.
- electron/nfc-service.ts: startNfcReader() + readNdefLnurlw()/extractLnurlw().
- electron/nfc-service.test.ts: 7 tests (NDEF URI extraction, Type-4 read
sequence incl. AID select, empty-file + select-fail handling).
- main.ts start + IPC forward; preload + electron.d.ts listeners.
- add nfc-pcsc dep (native @pokusew/pcsclite; nix build handling next).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Feitian KP382 (096e:0608) is a CCID contactless reader; PC/SC must be
running for the CCID driver to bind it. The app will talk to pcscd's socket
via nfc-pcsc. Idle/harmless when no reader is attached.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First, hardware-independent piece of Bolt Card tap-to-pay on the cash-out
flow. When a customer taps a Bolt Card, the ATM (which already has its
cash-out BOLT11) becomes the LNURL-*withdrawing* party: GET the card's
lnurlw voucher → GET callback?k1=…&pr=<invoice> so the card's wallet pays
the invoice. Settlement is still observed via the existing invoice watcher
(a returned ok=true means "card accepted the pull", not "cash dispensed").
- electron/lnurl-withdraw.ts: executeLnurlWithdraw() + lnurlwToHttps().
Runs in the main process (Node fetch) to avoid renderer CORS, since LNURL
endpoints send no CORS headers. Fully injectable fetch for testing.
- electron/lnurl-withdraw.test.ts: 12 tests (scheme mapping, two-step happy
path passing k1+pr, ERROR surfacing, non-withdraw tag, amount-over-limit
short-circuit, callback decline, network failure).
- IPC `lnurl:withdraw` (main) + preload + electron.d.ts.
Next: pcscd + an nfc-pcsc reader driver (reads the NTAG424 NDEF lnurlw),
then wire the tap into the cashOut displayingInvoice state + "tap or scan" UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lift the USB-variant config (distinct fs labels, nofail /boot, no
growPartition, autoUpgrade off) out of the inline disk-image-batm3-usb
`let` into `nixosConfigurations.batm3-usb`, and build the disk-image from
that same config. Enables in-place app deploys to a running stick via
`nix copy` + `switch-to-configuration` (build the toplevel, copy the
closure, activate) — no reflash, preserving pairing + /var/lib state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A connectivity-type init failure (e.g. "No connected relays" when the box
boots before the network) landed on "ATM Unavailable" permanently: init is
one-shot and the nostr reconnect only helps after a first successful
connect, so a machine never self-healed when internet returned.
Recover by reloading the renderer, which re-runs init from a clean JS
context (no leaked actors/subscriptions) while the main process keeps HAL:
- main.ts: new `app:recover` IPC → reloadRenderer() (resets secretsConsumed).
- hal:init is now idempotent (reuse the existing instance) so the reload —
and the pre-existing watchdog crash-reload — can't double-open serial ports.
- App.vue: when initError is a connectivity type (not the operator/
self-clearing states unpaired/awaiting-fees/maintenance), watch for the
`online` event (recover immediately) plus a 45s backoff safety net, and
render a kiosk-sized Retry button for a person at the machine.
Preserves pairing + /var/lib state (renderer reload, not a process restart).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The access/recovery plane (SSH/NetBird) stands and may carry recovery
procedures, but it is explicitly NOT the only or first-line recovery. Add
a layered, cheapest-first recovery model: (1) app auto-recovery of its own
relay/Lightning connectivity, (2) an on-screen Retry for an operator at the
kiosk, (3) SSH/NetBird as the last-resort remote plane for genuine app/OS
failure. A public kiosk must not need remote shell access to recover from a
transient/boot-before-network outage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Buy Bitcoin short-changed the customer: a $5 buy at 1564 sats/USD with a
12% commission paid out 6056 sats instead of 6882 — an effective ~22.6%.
The commission was applied twice.
`calculateSats` already subtracts the fee (7820 gross → 6882 net) into
`context.satsAmount`. But `generateLnurlWithdraw` then passed that
already-net value as `principal_sats` to the server's create_withdraw,
which derives fee + net from the principal and subtracted 12% AGAIN:
[ATM Service] create_withdraw: principal=6882 fee=826 net=6056
This contradicted the function's own contract ("the ATM sends only the
hardware-attested gross principal; the operator side derives fee + NET").
The quote, the recorded transaction (sats=6882, fee_fraction=0.12), and
the on-screen commission (12%) all read a single fee — only the delivered
LNURL-withdraw amount was double-charged.
Fix: send the GROSS principal (fiat × rate, before commission), so the
server applies the fee exactly once. Now 7820 → server 12% → net 6882,
matching the quote/receipt. Exchange rate itself was always correct.
Verified: vue-tsc typechecks; math checks (gross=7820 fee=938 net=6882).
Hardware retest (one $5 buy → 6882) recommended before relying in prod.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Dell 9030 AIO's built-in eGalax SAW panel (0eef:0001) was unusable:
touches either didn't register or landed in the wrong place. Full fix:
- Pin linuxPackages_6_6. On 25.11's default 6.12 kernel hid-multitouch
grabs the controller and mis-parses its HID report (axes read stuck)
and usbtouchscreen refuses to bind. On 6.6 usbtouchscreen binds and
produces a clean single-touch ABS device (the known-good internal-SATA
install runs 6.6.68). Mirrors douro.nix's per-hardware kernel pin.
- udev rule now modprobes usbtouchscreen ITSELF before unbinding usbhid
and handing over via new_id. On a USB boot systemd-udev-trigger fires
this rule (~2s) before systemd-modules-load loads usbtouchscreen
(~12s), so new_id previously hit a not-yet-loaded driver and the panel
bound to nothing. Loading it inline removes the boot-ordering race.
- Add an X evdev InputClass (99-egalax.conf) so X uses evdev + the
transformation matrix rather than libinput. Mirrors the working
internal-SATA install.
- egalax-calibrate: add XAUTHORITY (=/home/bitspire/.Xauthority) — the
actual boot-time bug. Without the auth cookie xinput died with
"Invalid MIT-MAGIC-COOKIE-1 key / Unable to connect to X server", so
the coordinate-transformation matrix was never applied and touches
landed in the wrong place. Also replace the fixed ExecStartPre sleep
with a 30s retry loop on the eGalax X device appearing — more robust
to boot timing than a race against display-manager.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a USB-bootable BATM3 disk-image target plus the batm3 hardware
changes that make a dd'd USB stick boot reliably on the Dell 9030 AIO.
flake.nix — new `disk-image-batm3-usb` target:
- Distinct partition labels (nixos-usb / ESP-USB) so stage-1 by-label
resolution can't latch onto an internal SATA drive that already holds
a generic nixos/ESP-labelled install. Post-build mlabel relabels the
ESP FAT volume to ESP-USB (bootloader files untouched; UEFI still
loads /EFI/BOOT/BOOTX64.EFI).
- /boot mounted nofail + short device-timeout: the firmware already
loaded the bootloader before Linux; without nofail a slow/late ESP-USB
enumeration drops to emergency mode with root locked — a dead end.
- NO growPartition/autoResize on the USB image: sfdisk rewriting the
partition table on first boot is the single most bus-stressing write,
and flaky USB bridges drop off the bus mid-rewrite (sfdisk wedges in
uninterruptible D-state and ESP-USB vanishes with the device, so /boot
times out too). Persistent state is a few MB and the image already
ships ~2GB free in root. The internal-SATA disk-image-batm3 keeps
growPartition — a real AHCI SSD won't drop the bus.
- autoUpgrade off (test image, not a managed fleet member).
batm3.nix — USB-boot reliability:
- Add usb_storage to initrd.availableKernelModules so stage-1 binds the
stick and /dev/disk/by-label/* appears.
- Blacklist uas + usbcore.autosuspend=-1: force the slower-but-reliable
Bulk-Only Transport path and stop the boot medium being power-suspended
mid-I/O — both were causing "device offline error" bus drops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The batm3 dispenser used the raw /dev/ttyUSB0, which is
enumeration-order dependent — a re-plug or reboot could reassign
ttyUSB0 to a different adapter. Switch to the stable udev symlink
/dev/ttyF56 (batm3.nix, serial DDDLb103Y23), matching the validator's
/dev/ttyMEI, so both peripherals bind by identity and survive
re-enumeration (incl. on an internal-SATA flash). Per-box override:
VITE_LAMASSU_DISPENSER_DEVICE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The batm3 preset defaulted the EBDS validator to /dev/ttyACM0, assuming a
CDC-ACM BNR Advance. The actual MEI acceptor enumerates as a USB-serial
device (ttyUSB*), exposed via the stable udev symlink /dev/ttyMEI
(batm3.nix). Because /dev/ttyACM0 never existed, hal-service skipped the
validator entirely and logged "[HAL] No validator — cash-in disabled", so
Buy Bitcoin silently ignored inserted bills.
Verified on hardware: with the correct device the validator starts, and
(with the EBDS latch fix) a bill escrows → stacks → credits. Removes the
need for the VITE_LAMASSU_VALIDATOR_DEVICE=/dev/ttyMEI per-box override.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cash-in stalled on the batm3: a note reached escrow and was read, but the
acceptor never stacked or returned it, and the customer was never
credited. Root cause: EBDS carries the stack/return decision as bits in
the omnibus *poll* command, but the driver sent stack()/reject() as a
single one-shot frame while a free-running 100ms poller kept sending
plain polls. The lone stack frame races/collides with the poller (or its
ack desyncs), gets dropped, and the device holds the note in escrow
indefinitely.
- ebds-rs232: latch the escrow decision (`pendingAction`) into the poll
command byte and re-assert it on every poll until the device leaves
escrow (cleared in _process when `!escrowed`). A dropped frame is now
simply retried on the next poll.
- hal-service: return an escrowed note on disableValidator() — disable
alone does not release it on EBDS, so an inactivity timeout / cancel
previously stranded the bill in the transport (observed on the batm3).
- atm store: stringify the `[ATM] Sending event` / `[ATM] State` logs —
they were printing `[object Object]`, which blinded the cash-in trace.
Verified: hal builds, machine app typechecks. Hardware behaviour to be
confirmed on the batm3 (no unit tests exist for this serial driver).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
batm3-installed existed as a nixosConfiguration but had no dd-able disk
image (only the live ISO, which is tmpfs — no persistent state.db/.env).
Mirrors the douro/sintra make-disk-image blocks, with one improvement:
boot.growPartition + fileSystems."/".autoResize so the root partition
and ext4 expand to fill the target drive on first boot. Flashing is
dd-and-done — no manual parted/resize2fs — and the full drive is
available to the nix store from day one (the #55 headroom lesson).
Image-only override via extendModules: the running system's
batm3-installed config (what auto-upgrade rebuilds against) is
unchanged.
Build: nix build .#disk-image-batm3 → result/nixos.img
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backports the legacy brain.js escrow interlock (from the public-domain
lamassu-machine tree at c0b69d1, see CLAUDE.md provenance):
- The id003/ebds drivers' `billsValid` event (bill physically reached
the stacker) is now the credit trigger. hal-service tracks
escrow → in-flight and fires onBillInserted only on confirmation;
hal:stack-bill no longer synthesizes the credit at command time.
- New BILL_PENDING machine event marks the in-flight bill;
FINISH_INSERTING is guard-blocked while one is pending, so "done"
pressed mid-stack can no longer mint an LNURL that includes a bill
still sitting in escrow (the aiolabs/bitspire#58 loss).
- BILL_INSERTED now requires a matching pending bill (stray or
out-of-state confirmations are never credited) and BILL_REJECTED
clears the in-flight marker — a failed/returned stack was never
credited, so nothing to unwind.
- Escrow decision is fail-closed (legacy _billsRead parity): bill read
outside insertingBills, or with unknown rate/balance, is returned to
the customer instead of stacked-and-swallowed (closes the #35 gap at
the decision point that physically takes the money).
- CashInView disables "Done" and shows a processing hint while a bill
is in flight; the dev simulator drives the same guarded two-event
path.
Both loss directions verified against the legacy semantics:
operator-pays-for-unstacked-cash and customer-bill-swallowed-uncredited.
6 new state-machine interlock tests; 27 state-machine + 43 machine-app
tests pass; full build (vue-tsc + vite + electron tsc) clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The provenance section claimed v8.1.5 was the last fully-open release.
GitHub history says otherwise: a9234d124d ("chore: add LICENSE",
2023-09-19) removed UNLICENSE and added the proprietary Appendix A SLA,
and the v8.1.5 tag (2023-09-21) already ships it. The true public-domain
boundary is that commit's parent, c0b69d1 ("chore: v8.6.0-beta.9") —
which is further along than 8.1.5 feature-wise.
lamassu-server's own boundary is unverified; flagged in the doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
recordTransaction() updated cassette counts with WHERE denomination = ?,
but the v9 migration made position the PK precisely so duplicate
denominations across bays are legal (and the HAL dispense path already
returns authoritative per-position results). On any machine with two
bays of the same denomination, a single dispense drained every matching
bay row — silently corrupting inventory, the operator cassette-state
publish, and out-of-money gating.
- cassettes branch: decrement by c.position
- mock-only fallback (no per-bay results): drain matching bays greedily
in position order, mirroring the dispenser's own fill order
- regression tests with a duplicate-denomination layout (3 of 5 fail
against the old code)
Found during the dev-branch architecture review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>