bitspire/deploy/nixos/hardware/batm3.nix
Patrick Mulligan ffbacafe39 fix(nfc): auto-recover a wedged CCID reader via USB power-cycle
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>
2026-08-06 19:51:39 +02:00

283 lines
12 KiB
Nix

# BATM3 Hardware Configuration
# Dell OptiPlex 9030 AIO, Intel i7-4790S, Intel HD 4600
# Board replacement for GeneralBytes BATM3 (ARM Android → x86 NixOS)
{ config, lib, pkgs, ... }:
{
boot = {
loader = {
systemd-boot.enable = true;
efi.canTouchEfiVariables = true;
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"
];
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"
];
};
# Disk layout: GPT with ESP (sda1) + ext4 root (sda2)
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
fileSystems."/boot" = {
device = "/dev/disk/by-label/ESP";
fsType = "vfat";
};
hardware = {
graphics = {
enable = true;
extraPackages = with pkgs; [
intel-media-driver
libva-vdpau-driver
libvdpau-va-gl
];
};
enableRedistributableFirmware = true;
cpu.intel.updateMicrocode = true;
};
powerManagement = {
enable = true;
cpuFreqGovernor = "performance";
};
# PC/SC daemon for the Feitian KP382 contactless reader (096e:0608, a CCID
# smart-card reader) used for Bolt Card tap-to-pay on cash-out. Enabling it
# binds the CCID driver to the reader; the app talks to pcscd's socket (via
# nfc-pcsc) rather than the USB device directly. Harmless if no reader is
# attached — pcscd just idles.
services.pcscd.enable = true;
# pcscd gates client access via polkit; without a rule the sandboxed
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
# it to talk to the daemon and the card. The second rule lets the app trigger
# the NFC reader wedge-recovery service (see nfc-reader-reset below).
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;
}
});
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "nfc-reader-reset.service" &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
'';
# NFC reader wedge-recovery. The Feitian R502-CL CCID reader (and, less often,
# any CCID reader) 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. This oneshot re-binds the reader's USB device (a
# software replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app
# restart (verified on-device). The app (unprivileged `bitspire`) starts it via
# the polkit rule above when it sees repeated read failures. Reader-agnostic:
# it matches the USB CCID interface class (0x0B), so it also covers a future
# ACR1252U swap without a config change.
systemd.services.nfc-reader-reset = {
description = "Power-cycle a wedged CCID NFC reader (USB re-bind)";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "reset-nfc-reader" ''
set -u
found=0
for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do
[ -f "$iface" ] || continue
[ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue
ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")")
dev=''${ifname%%:*}
echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2
echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true
${pkgs.coreutils}/bin/sleep 2
echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true
found=1
done
[ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; }
'';
};
};
# Disable suspend/hibernate for kiosk
systemd.targets = {
sleep.enable = false;
suspend.enable = false;
hibernate.enable = false;
hybrid-sleep.enable = false;
};
# WiFi: read credentials from /var/lib/bitspire/wifi.conf (not in repo)
# Format: SSID=MyNetwork\nPSK=MyPassword
system.activationScripts.wifi-setup = ''
WIFI_CONF="/var/lib/bitspire/wifi.conf"
if [ -f "$WIFI_CONF" ]; then
SSID=$(grep '^SSID=' "$WIFI_CONF" | cut -d= -f2-)
PSK=$(grep '^PSK=' "$WIFI_CONF" | cut -d= -f2-)
if [ -n "$SSID" ] && [ -n "$PSK" ]; then
CONN_FILE="/etc/NetworkManager/system-connections/$SSID.nmconnection"
if [ ! -f "$CONN_FILE" ]; then
cat > "$CONN_FILE" << EOF
[connection]
id=$SSID
type=wifi
autoconnect=true
[wifi]
mode=infrastructure
ssid=$SSID
[wifi-security]
key-mgmt=wpa-psk
psk=$PSK
[ipv4]
method=auto
[ipv6]
method=auto
EOF
chmod 600 "$CONN_FILE"
echo "[WiFi] Created connection for $SSID"
fi
fi
fi
'';
# eGalax touchscreen (Dell 9030 AIO built-in panel)
# 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"
KERNEL=="ttyACM[0-9]*", MODE="0666"
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 '${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'"
# Belt-and-suspenders for touch calibration: on a slow USB boot the
# usbtouchscreen panel can bind AFTER egalax-calibrate's poll window, which
# leaves the panel uncalibrated and unresponsive ("dead"). (Re)start the
# calibration the instant the eGalax input node actually appears — this is
# device-driven, so it cannot lose a boot-timing race no matter how late the
# driver hands over. Pairs with egalax-calibrate's own (widened) poll loop.
ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="eGalax Inc. USB TouchController", TAG+="systemd", ENV{SYSTEMD_WANTS}+="egalax-calibrate.service"
'';
# 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
# Matrix: swap X/Y axes, invert both, scale to active panel area (238-1853 x 197-1869)
systemd.services.egalax-calibrate = {
description = "Calibrate eGalax touchscreen";
after = [ "display-manager.service" ];
requires = [ "display-manager.service" ];
wantedBy = [ "graphical.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "bitspire";
# 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. 120s window: on a slow USB boot the
# panel has bound as late as ~30-60s after display-manager, so a 30s cap
# gave up before the device appeared and left touch dead (this service is
# ALSO re-triggered by a udev rule when the input node shows up, so this
# loop is the fallback, not the only path). 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 120); 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 120s" >&2
exit 1
'';
};
};
# WireGuard VPN address
networking.wireguard.interfaces.wg0.ips = [ "10.0.0.5/24" ];
}