Merge pull request 'fix(nfc): auto-recover a wedged CCID reader via USB power-cycle' (#85) from fix/nfc-reader-auto-recovery into dev
Reviewed-on: #85
This commit is contained in:
commit
83300784ec
2 changed files with 96 additions and 4 deletions
|
|
@ -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) }))
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue