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>
250 lines
9.7 KiB
TypeScript
250 lines
9.7 KiB
TypeScript
/**
|
|
* NFC reader driver (main process) for Bolt Card tap-to-pay.
|
|
*
|
|
* Wraps `nfc-pcsc` (PC/SC via the Feitian KP382 CCID reader). On each card
|
|
* tap it reads the NTAG424 Type-4 NDEF file over ISO7816 APDUs and extracts
|
|
* the `lnurlw://…?p=…&c=…` voucher (the card computes fresh SUN p/c per tap),
|
|
* then hands it to the renderer over IPC. The renderer, when showing a
|
|
* cash-out invoice, pays it via LNURL-withdraw (see lnurl-withdraw.ts).
|
|
*
|
|
* Everything here is best-effort and lazy: `nfc-pcsc` is a native addon, so it
|
|
* is dynamically imported and every failure is swallowed into a status
|
|
* callback. If the reader/library is absent, NFC is simply unavailable and the
|
|
* 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
|
|
reader?: string
|
|
message?: string
|
|
}
|
|
|
|
type CardHandler = (lnurlw: string) => void
|
|
type StatusHandler = (status: NfcStatus) => void
|
|
|
|
function errMsg(e: unknown): string {
|
|
return e instanceof Error ? e.message : String(e)
|
|
}
|
|
|
|
/** Pull the lnurlw (or a boltcards https scan URL) out of a Type-4 NDEF blob. */
|
|
export function extractLnurlw(ndef: Buffer): string | null {
|
|
// Robust to record framing: the URI record embeds the literal string; grab
|
|
// it directly, bounded to URL-safe characters so we stop at the record end.
|
|
const text = ndef.toString('latin1')
|
|
const urlChars = "[A-Za-z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+"
|
|
const m =
|
|
text.match(new RegExp('lnurlw://' + urlChars, 'i')) ||
|
|
text.match(new RegExp('https://' + urlChars + '/boltcards/' + urlChars, 'i'))
|
|
return m ? m[0] : null
|
|
}
|
|
|
|
const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00
|
|
|
|
/** Select an EF by its 2-byte file id and read + parse its NDEF message. */
|
|
async function readNdefFile(
|
|
send: (bytes: number[]) => Promise<Buffer>,
|
|
fid: [number, number]
|
|
): Promise<string | null> {
|
|
if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null
|
|
// 2-byte NLEN header at offset 0.
|
|
const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02])
|
|
if (!swOk(lenResp)) return null
|
|
const nlen = (lenResp[0] << 8) | lenResp[1]
|
|
if (nlen <= 0 || nlen > 0x2000) return null
|
|
// NDEF message starts at offset 2; read in <=250-byte chunks.
|
|
const chunks: Buffer[] = []
|
|
let offset = 2
|
|
let remaining = nlen
|
|
while (remaining > 0) {
|
|
const toRead = Math.min(remaining, 0xfa)
|
|
const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead])
|
|
if (!swOk(resp)) break
|
|
const data = resp.subarray(0, resp.length - 2)
|
|
if (data.length === 0) break
|
|
chunks.push(data)
|
|
offset += data.length
|
|
remaining -= data.length
|
|
}
|
|
return extractLnurlw(Buffer.concat(chunks))
|
|
}
|
|
|
|
/**
|
|
* Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null.
|
|
* `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2.
|
|
*
|
|
* Select the NDEF Tag Application, read the Capability Container to learn the
|
|
* real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use),
|
|
* then read that file. Falls back to E104/0004 if the CC read is unavailable.
|
|
*/
|
|
export async function readNdefLnurlw(
|
|
transmit: (apdu: Buffer, maxLen: number) => Promise<Buffer>
|
|
): Promise<string | null> {
|
|
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])
|
|
)
|
|
) {
|
|
return null
|
|
}
|
|
|
|
// NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to
|
|
// minimise APDU round-trips over a flaky RF link; only fall back to reading
|
|
// the Capability Container to discover the id if both direct reads fail.
|
|
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) {
|
|
const found = await readNdefFile(send, fid)
|
|
if (found) return found
|
|
}
|
|
if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) {
|
|
const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f])
|
|
// CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID.
|
|
if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) {
|
|
const found = await readNdefFile(send, [cc[9], cc[10]])
|
|
if (found) return found
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
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.
|
|
*/
|
|
export async function startNfcReader(
|
|
onCard: CardHandler,
|
|
onStatus: StatusHandler
|
|
): Promise<() => void> {
|
|
if (stopFn) return stopFn
|
|
|
|
let mod: unknown
|
|
try {
|
|
// Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc
|
|
// while resolving normally at runtime.
|
|
const pkg = 'nfc-pcsc'
|
|
mod = (await import(pkg)) as unknown
|
|
} catch (e) {
|
|
onStatus({ state: 'unavailable', message: `NFC library unavailable: ${errMsg(e)}` })
|
|
return () => {}
|
|
}
|
|
const NFC =
|
|
(mod as { NFC?: unknown }).NFC ?? (mod as { default?: { NFC?: unknown } }).default?.NFC
|
|
if (typeof NFC !== 'function') {
|
|
onStatus({ state: 'unavailable', message: 'NFC library has no NFC export' })
|
|
return () => {}
|
|
}
|
|
|
|
let nfc: { on: (e: string, cb: (...a: unknown[]) => void) => void; close?: () => void }
|
|
try {
|
|
nfc = new (NFC as new () => typeof nfc)()
|
|
} catch (e) {
|
|
onStatus({ state: 'unavailable', message: `NFC init failed: ${errMsg(e)}` })
|
|
return () => {}
|
|
}
|
|
|
|
nfc.on('reader', (reader: unknown) => {
|
|
const r = reader as {
|
|
name?: string
|
|
reader?: { name?: string }
|
|
autoProcessing?: boolean
|
|
on: (e: string, cb: (...a: unknown[]) => void) => void
|
|
transmit: (data: Buffer, maxLen: number) => Promise<Buffer>
|
|
}
|
|
const name = r.name ?? r.reader?.name ?? 'reader'
|
|
// We do our own NDEF APDU read, not nfc-pcsc's UID auto-processing.
|
|
r.autoProcessing = false
|
|
onStatus({ state: 'ready', reader: name })
|
|
|
|
// Cooldown after a failed read: these cheap CCID readers can get wedged into
|
|
// 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 })
|
|
// Single attempt: retrying hammers a flaky RF link. A read is a few APDU
|
|
// round-trips; if the card shifts mid-read the transmit fails and the
|
|
// 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) {
|
|
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
|
|
})
|
|
r.on('card.off', () => onStatus({ state: 'card-removed', reader: name }))
|
|
r.on('error', (err: unknown) =>
|
|
onStatus({ state: 'error', reader: name, message: errMsg(err) })
|
|
)
|
|
r.on('end', () =>
|
|
onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })
|
|
)
|
|
})
|
|
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))
|
|
|
|
stopFn = () => {
|
|
try {
|
|
nfc.close?.()
|
|
} catch {
|
|
/* idempotent */
|
|
}
|
|
stopFn = null
|
|
}
|
|
return stopFn
|
|
}
|