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>
283 lines
12 KiB
Nix
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" ];
|
|
}
|