From ffbacafe3958443a845190f97a9eb5fb3dd8e3e1 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 6 Aug 2026 19:51:39 +0200 Subject: [PATCH] fix(nfc): auto-recover a wedged CCID reader via USB power-cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/machine/electron/nfc-service.ts | 58 ++++++++++++++++++++++++++-- deploy/nixos/hardware/batm3.nix | 42 +++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index ca9ace1..e94ddaf 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -13,6 +13,8 @@ * QR path keeps working — cash-out never depends on this. */ +import { execFile } from 'node:child_process' + export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' export interface NfcStatus { state: NfcState @@ -83,7 +85,11 @@ export async function readNdefLnurlw( const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) // Select the NDEF Tag Application (AID D2760000850101). - if (!swOk(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { + if ( + !swOk( + await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]) + ) + ) { return null } @@ -107,6 +113,29 @@ export async function readNdefLnurlw( let stopFn: (() => void) | null = null +// ── Wedge auto-recovery ─────────────────────────────────────────────────── +// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they +// keep detecting a card but every APDU returns "card absent or mute", and ONLY +// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of +// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot +// that re-binds the reader's USB device = a software replug); nfc-pcsc then +// re-detects the reader on hotplug with no app restart. The trigger is gated by +// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g. +// ACR1252U) wedges far less; this is belt-and-suspenders for any reader. +const WEDGE_FAILURE_THRESHOLD = 3 +const RESET_COOLDOWN_MS = 30_000 +// Persist across reader re-enumerations (a reset spawns a fresh reader closure). +let lastReaderResetAt = 0 + +/** Trigger the privileged USB power-cycle of the reader. Best-effort. */ +function resetWedgedReader(): void { + // NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it + // to start this one unit. systemctl lives at a stable path on the device. + execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => { + /* best-effort — if it fails the reader stays wedged until a manual reset */ + }) +} + /** * Start listening for Bolt Card taps. Idempotent. Returns a stop function. * Never throws — failures surface via onStatus. @@ -159,6 +188,10 @@ export async function startNfcReader( // a present↔empty storm when hammered, so ignore re-detections for a beat // after a failure. Successful reads don't cool down. let cooldownUntil = 0 + // Consecutive failed reads → wedge detection (see resetWedgedReader above). + // A completed read (Bolt Card or not) proves the reader is healthy and + // clears the count; only a run of thrown transmits trips the reset. + let consecutiveFailures = 0 r.on('card', async () => { if (Date.now() < cooldownUntil) return onStatus({ state: 'reading', reader: name }) @@ -167,13 +200,30 @@ export async function startNfcReader( // user simply re-taps. try { const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) + consecutiveFailures = 0 if (lnurlw) { onCard(lnurlw) return } onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) } catch (e) { - onStatus({ state: 'error', reader: name, message: 'card read failed — hold steady & retap' }) + consecutiveFailures++ + if ( + consecutiveFailures >= WEDGE_FAILURE_THRESHOLD && + Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS + ) { + // Reader looks wedged — auto power-cycle it (only fix that works). + lastReaderResetAt = Date.now() + consecutiveFailures = 0 + onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' }) + resetWedgedReader() + } else { + onStatus({ + state: 'error', + reader: name, + message: 'card read failed — hold steady & retap', + }) + } void e } cooldownUntil = Date.now() + 1500 @@ -182,7 +232,9 @@ export async function startNfcReader( r.on('error', (err: unknown) => onStatus({ state: 'error', reader: name, message: errMsg(err) }) ) - r.on('end', () => onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })) + r.on('end', () => + onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' }) + ) }) nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) })) diff --git a/deploy/nixos/hardware/batm3.nix b/deploy/nixos/hardware/batm3.nix index 1d6af0b..79f1b4d 100644 --- a/deploy/nixos/hardware/batm3.nix +++ b/deploy/nixos/hardware/batm3.nix @@ -94,7 +94,8 @@ # 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. + # 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" || @@ -103,8 +104,47 @@ 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;