Compare commits

...

6 commits

Author SHA1 Message Date
Patrick Mulligan
f52d942e57 fix(deploy): eGalax touchscreen on batm3 (kernel 6.6 + calibration)
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>
2026-07-29 23:56:41 +00:00
Patrick Mulligan
6edcb8b96d feat(deploy): USB-bootable batm3 test image (disk-image-batm3-usb)
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>
2026-07-29 23:56:41 +00:00
da3f3265ab Merge pull request 'fix(batm3): get cash-in working — EBDS escrow latch + correct serial device paths' (#79) from fix/ebds-escrow-stack-latch into dev
Reviewed-on: #79
2026-07-29 23:56:24 +00:00
Patrick Mulligan
2c71d9d823 fix(config): use stable /dev/ttyF56 symlink for batm3 dispenser
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>
2026-07-30 01:14:11 +02:00
Patrick Mulligan
eafdce36b6 fix(config): point batm3 validator at /dev/ttyMEI (was nonexistent ttyACM0)
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>
2026-07-30 00:57:48 +02:00
Patrick Mulligan
4d0e42f289 fix(hal): EBDS escrow stack/return latch + return-on-disable
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>
2026-07-30 00:40:06 +02:00
6 changed files with 214 additions and 19 deletions

View file

@ -221,6 +221,15 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
},
disableValidator: () => {
// If a note is sitting in escrow when we disable (inactivity timeout,
// cancel, or leaving the insert screen), return it to the customer.
// Disabling alone does NOT release an escrowed note on EBDS — it would
// be stranded in the transport until the next power cycle.
if (escrowDenomination !== null) {
console.log('[HAL] Returning escrowed bill on disable:', escrowDenomination)
escrowDenomination = null
validator?.reject()
}
validator?.disable()
validator?.lightOff()
},

View file

@ -139,11 +139,21 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
model: 'batm3',
validator: {
type: 'ebds',
device: '/dev/ttyACM0',
// MEI bill acceptor (EBDS) on a USB-serial bridge, exposed via the
// stable udev symlink /dev/ttyMEI (batm3.nix, serial A9YW78OC). The old
// /dev/ttyACM0 default assumed a CDC-ACM BNR; this hardware enumerates as
// ttyUSB* instead, so ACM0 never existed and cash-in was silently
// disabled ("[HAL] No validator"). Per-box override: VITE_LAMASSU_VALIDATOR_DEVICE.
device: '/dev/ttyMEI',
},
dispenser: {
type: 'f56',
device: '/dev/ttyUSB0',
// Fujitsu F56 on a USB-serial bridge, via the stable udev symlink
// /dev/ttyF56 (batm3.nix, serial DDDLb103Y23). Avoids the raw
// /dev/ttyUSB0, which is enumeration-order dependent and could point at
// the wrong adapter after a re-plug/reboot. Per-box override:
// VITE_LAMASSU_DISPENSER_DEVICE.
device: '/dev/ttyF56',
cassettes: [
{ denomination: 20, count: 400 },
{ denomination: 1, count: 400 },

View file

@ -458,7 +458,7 @@ export const useAtmStore = defineStore('atm', () => {
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
const prevSnapshot = snapshot.value
snapshot.value = newSnapshot
console.log('[ATM] State:', newSnapshot.value)
console.log('[ATM] State:', JSON.stringify(newSnapshot.value))
// Detect transition into a complete state
const state = newSnapshot.value
@ -1374,7 +1374,7 @@ export const useAtmStore = defineStore('atm', () => {
console.error('[ATM] Cannot send event: machine not initialized')
return
}
console.log('[ATM] Sending event:', event)
console.log('[ATM] Sending event:', event.type, JSON.stringify(event))
actor.value.send(event)
}

View file

@ -12,13 +12,36 @@
timeout = 3;
};
# Pin the 6.6 LTS kernel. The Dell 9030 AIO's eGalax SAW touch panel
# (0eef:0001) works with the usbtouchscreen driver on 6.6 (the known-good
# internal-SATA install runs 6.6.68). On 25.11's default 6.12 kernel this
# old controller regressed: hid-multitouch grabs it and mis-parses the HID
# report ("failed to fetch feature 7", axes read stuck), usbtouchscreen
# refuses it, and touch is unusable regardless of udev/X config. Matching
# douro.nix's per-hardware kernel pin. Re-test touch before bumping this.
kernelPackages = pkgs.linuxPackages_6_6;
initrd.availableKernelModules = [
"xhci_pci"
"ahci"
"usbhid"
"sd_mod"
# USB mass-storage: required to boot the dd'd image from a USB stick
# (stage-1 must bind the flash drive as a SCSI disk so
# /dev/disk/by-label/nixos appears). Harmless on the internal-SATA
# install, where ahci+sd_mod already cover the root device.
#
# NOTE: deliberately NO "uas" here. Many USB sticks/bridges advertise
# UAS but drop off the bus ("device offline error, dev sdb") under the
# sustained write load of first-boot growPartition/journal/swapfile.
# Blacklisting uas below forces the slower-but-reliable usb-storage
# (Bulk-Only Transport) path. SATA/eMMC installs don't use uas anyway.
"usb_storage"
];
# Keep the USB flash drive off the flaky UAS driver (see note above).
blacklistedKernelModules = [ "uas" ];
kernelModules = [
"kvm-intel"
"usbtouchscreen"
@ -27,6 +50,9 @@
kernelParams = [
"quiet"
"splash"
# Disable USB autosuspend so the boot medium (and kiosk peripherals)
# aren't power-suspended mid-I/O — another cause of "device offline".
"usbcore.autosuspend=-1"
];
};
@ -105,10 +131,20 @@
'';
# eGalax touchscreen (Dell 9030 AIO built-in panel)
# The eGalax HID descriptor confuses libinput (treats it as touchpad).
# Fix: unbind from usbhid at boot, bind to usbtouchscreen kernel module,
# then apply calibration matrix after X11 starts.
# Unbind eGalax from usbhid, bind to usbtouchscreen
# By default usbhid/hid-multitouch claim the eGalax and mis-parse its
# HID report descriptor (X axis reads as stuck), so touch is unusable.
# Fix: hand the device to the usbtouchscreen kernel driver, which parses
# the raw eGalax protocol into a clean single-touch ABS device that the
# X evdev driver + calibration matrix (below) map correctly. This mirrors
# the known-good internal-SATA install.
#
# The RUN command modprobes usbtouchscreen ITSELF before unbinding usbhid
# and handing over via new_id. usbtouchscreen is also in boot.kernelModules
# (systemd-modules-load), but on a USB boot systemd-udev-trigger fires this
# rule (~2s) BEFORE modules-load gets usbtouchscreen in (~12s) — so the
# new_id write hit a not-yet-loaded driver and the panel was left bound to
# nothing. Loading it inline here makes the handoff independent of that
# boot-ordering race (on internal-SATA boot the order happened to work).
services.udev.extraRules = lib.mkAfter ''
KERNEL=="ttyS[0-9]*", MODE="0666"
KERNEL=="ttyUSB[0-9]*", MODE="0666"
@ -116,7 +152,25 @@
SUBSYSTEM=="tty", ATTRS{serial}=="DDDLb103Y23", SYMLINK+="ttyF56", MODE="0666"
SUBSYSTEM=="tty", ATTRS{serial}=="A9YW78OC", SYMLINK+="ttyMEI", MODE="0666"
SUBSYSTEM=="tty", ATTRS{serial}=="A9ZF8ELY", SYMLINK+="ttyNFC", MODE="0666"
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", RUN+="${pkgs.bash}/bin/bash -c 'echo ''$kernel:1.0 > /sys/bus/usb/drivers/usbhid/unbind 2>/dev/null; echo 0eef 0001 > /sys/bus/usb/drivers/usbtouchscreen/new_id 2>/dev/null'"
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", RUN+="${pkgs.bash}/bin/bash -c '${pkgs.kmod}/bin/modprobe usbtouchscreen 2>/dev/null; echo ''$kernel:1.0 > /sys/bus/usb/drivers/usbhid/unbind 2>/dev/null; echo 0eef 0001 > /sys/bus/usb/drivers/usbtouchscreen/new_id 2>/dev/null'"
'';
# Force the X evdev driver on the eGalax (not libinput). The usbtouchscreen
# node is a plain single-touch absolute device; evdev + the transformation
# matrix in egalax-calibrate below give correct orientation. Mirrors the
# working internal-SATA install's /etc/X11/xorg.conf.d/99-egalax.conf.
environment.etc."X11/xorg.conf.d/99-egalax.conf".text = ''
Section "InputClass"
Identifier "eGalax Touchscreen"
MatchVendor "0eef"
MatchProduct "0001"
MatchDevicePath "/dev/input/event*"
Driver "evdev"
Option "InvertY" "false"
Option "InvertX" "false"
Option "SwapAxes" "false"
Option "Calibration" ""
EndSection
'';
# Apply touchscreen calibration after X11 starts
@ -130,9 +184,26 @@
Type = "oneshot";
RemainAfterExit = true;
User = "bitspire";
Environment = "DISPLAY=:0";
ExecStartPre = "${pkgs.coreutils}/bin/sleep 3";
ExecStart = "${pkgs.xorg.xinput}/bin/xinput set-prop 'eGalax Inc. USB TouchController' 'Coordinate Transformation Matrix' 0 -1.268 1.147 -1.224 0 1.118 0 0 1";
# DISPLAY *and* XAUTHORITY — without the auth cookie xinput dies with
# "Invalid MIT-MAGIC-COOKIE-1 key / Unable to connect to X server" and
# the matrix is never applied, so touches register but land in the wrong
# place (the panel then feels dead). This was the actual boot-time bug.
Environment = [ "DISPLAY=:0" "XAUTHORITY=/home/bitspire/.Xauthority" ];
# Wait for the eGalax X device to appear (usbtouchscreen binds a little
# after display-manager on a USB boot) and retry, instead of a fixed
# sleep — more robust to boot timing. Matrix: swap X/Y + invert + scale
# to the active panel area (matches the known-good internal install).
ExecStart = pkgs.writeShellScript "egalax-calibrate" ''
for i in $(${pkgs.coreutils}/bin/seq 1 30); do
if ${pkgs.xorg.xinput}/bin/xinput list --name-only 2>/dev/null | ${pkgs.gnugrep}/bin/grep -qx 'eGalax Inc. USB TouchController'; then
exec ${pkgs.xorg.xinput}/bin/xinput set-prop 'eGalax Inc. USB TouchController' \
'Coordinate Transformation Matrix' 0 -1.268 1.147 -1.224 0 1.118 0 0 1
fi
${pkgs.coreutils}/bin/sleep 1
done
echo "egalax-calibrate: eGalax device not found after 30s" >&2
exit 1
'';
};
};

View file

@ -424,6 +424,83 @@
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
'';
# USB-bootable BATM3 TEST image with DISTINCT partition labels
# (nixos-usb / ESP-USB). The plain disk-image-batm3 reuses the generic
# nixos/ESP labels, so a USB stick carrying it, booted on a batm3 whose
# internal SATA drive ALREADY holds a nixos/ESP-labelled install, makes
# stage-1's by-label/nixos resolve to the internal drive (larger fs,
# journal recovers) instead of the stick — the stage-2 init path baked
# into the USB's boot entry isn't on that root, so stage 1 aborts.
# Distinct labels make stage-1 pick the stick unambiguously WITHOUT
# touching the internal drive. Unlike disk-image-sintra-usb this keeps
# systemd-boot: the batm3 firmware UEFI-USB-boots fine via the ESP's
# /EFI/BOOT/BOOTX64.EFI removable fallback, so no GRUB/hybrid-table
# change is needed — only the label disambiguation here plus the
# usb_storage/uas initrd modules (in batm3.nix). Does NOT grow to fill
# the stick (see the growPartition note below — sfdisk on first boot
# wedges flaky USB bridges); auto-upgrade off (test image, not a managed
# fleet member — also stops scheduled bootloader writes landing on the
# internal drive's ESP).
disk-image-batm3-usb =
let
cfg = self.nixosConfigurations.batm3-installed.extendModules {
modules = [
({ lib, ... }: {
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
# /boot must NOT be a hard boot dependency on the USB test
# image. The firmware already loaded the bootloader from the
# ESP before Linux started; /boot is only remounted so the OS
# can *update* the bootloader — which this image never does
# (autoUpgrade off, no nixos-rebuild on the stick). Without
# nofail, a slow/late ESP-USB enumeration (BOT is slower than
# UAS) blows past systemd's 90s device-timeout and drops to
# emergency mode — with root locked, an unrecoverable dead end.
# nofail + a short timeout lets the (already-mounted) root carry
# the boot to completion; /boot mounts if/when the ESP shows up.
fileSystems."/boot".options = [ "nofail" "x-systemd.device-timeout=10s" ];
# DELIBERATELY NO growPartition/autoResize on the USB image.
# growPartition runs sfdisk to rewrite the stick's partition
# table on first boot — the single most bus-stressing write of
# the boot. Flaky USB bridges drop off the bus mid-rewrite
# (sfdisk hangs forever as an uninterruptible D-state task) and,
# worse, partition 1 (ESP-USB) vanishes with the device, so
# /boot times out too. The kiosk's persistent state (state.db,
# .env, wifi.conf, logs) is a few MB and the built image already
# carries ~2GB free inside root — growing to fill the stick buys
# nothing and costs reliability. The internal-SATA target
# (disk-image-batm3) keeps growPartition: a real AHCI SSD won't
# drop the bus and there filling the disk is worth it.
system.autoUpgrade.enable = lib.mkForce false;
})
];
};
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
inherit pkgs lib;
config = cfg.config;
format = "raw";
partitionTableType = "efi";
diskSize = "auto";
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
};
in
pkgs.runCommand "nixos-disk-image-batm3-usb"
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
''
mkdir -p $out
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
chmod +w $out/nixos.img
# make-disk-image hardcodes the ESP FAT label to "ESP"; relabel the
# volume to ESP-USB so /boot (by-label/ESP-USB) can't resolve to an
# internal drive's ESP. Volume label only — bootloader files are
# untouched, and UEFI loads /EFI/BOOT/BOOTX64.EFI regardless.
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
export MTOOLS_SKIP_CHECK=1
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
'';
# Backwards compat
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
};

View file

@ -313,6 +313,13 @@ export class EbdsRs232 extends EventEmitter {
private serial: SerialPort | null = null
private ack: number = 0x0
private enabledDenominations: number = 0x00
// Latched escrow decision. In EBDS the stack/return choice is NOT a one-shot
// message — it's carried as bits in the omnibus poll command, and the device
// holds the escrowed note until a poll asserts stack or return. We keep the
// action set and re-assert it on every poll until the device leaves escrow
// (cleared in _process), so a single dropped/collided frame no longer strands
// the note in escrow forever.
private pendingAction: 'none' | 'stack' | 'return' = 'none'
private lastStatusFlags: string | null = null
private firmwareLogged: boolean = false
@ -405,23 +412,38 @@ export class EbdsRs232 extends EventEmitter {
// -- Commands (Appendix D, Controller Message) ---------------------------
/** Send an Omnibus poll command with current denomination mask */
/**
* Command byte 1 for the omnibus poll, encoding any latched escrow action.
* `stack` (0x3f) and `return` (0x5f) differ from the plain poll (0x1b) only
* in the stack/return bits; while an action is latched every poll re-asserts
* it until the device acts.
*/
private commandByte(): number {
if (this.pendingAction === 'stack') return 0x3f
if (this.pendingAction === 'return') return 0x5f
return 0x1b
}
/** Send an Omnibus poll command with the current mask + latched action */
poll(): void {
this._dispatch([this.enabledDenominations, 0x1b, 0x10])
this._dispatch([this.enabledDenominations, this.commandByte(), 0x10])
}
/** Stack the bill currently in escrow */
/** Latch "stack the escrowed note"; re-asserted each poll until it takes. */
stack(): void {
this._dispatch([this.enabledDenominations, 0x3f, 0x10])
this.pendingAction = 'stack'
this.poll()
}
/** Reject/return the bill currently in escrow */
/** Latch "return the escrowed note"; re-asserted each poll until it takes. */
reject(): void {
this._dispatch([this.enabledDenominations, 0x5f, 0x10])
this.pendingAction = 'return'
this.poll()
}
/** Send initial setup command (disable all, reset state) */
reset(): void {
this.pendingAction = 'none'
this._dispatch([0x00, 0x1b, 0x10])
}
@ -470,7 +492,13 @@ export class EbdsRs232 extends EventEmitter {
validatePacket(packet)
const result = interpret(packet)
if (result) {
if (result.destructedData) this._logStatusOnChange(result.destructedData)
if (result.destructedData) {
// Clear a latched stack/return once the device has left escrow — it
// is now stacking/returning/idle, so we must stop asserting the
// action or it would leak onto the next note.
if (!result.destructedData[0].escrowed) this.pendingAction = 'none'
this._logStatusOnChange(result.destructedData)
}
this.emit('message', result)
}
} catch (ex) {