Includes ESP-IDF NFC stack (deep capture, 4K Classic geometry, UL write API, open SoftAP), React dashboard with live tag diagnostics, and docs. README updated for APIs and lab Wi-Fi defaults. Made-with: Cursor
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
import { motion } from "framer-motion";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { apiGet, apiPost, apiUrl, type Status } from "../api";
|
|
import { useNfcWs } from "../NfcWsContext";
|
|
import { useToast } from "../toast";
|
|
|
|
export default function Capture() {
|
|
const toast = useToast();
|
|
const { log } = useNfcWs();
|
|
const [st, setSt] = useState<Status | null>(null);
|
|
|
|
const refresh = useCallback(() => {
|
|
apiGet<Status>("/api/status")
|
|
.then(setSt)
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
const id = setInterval(refresh, 2000);
|
|
return () => clearInterval(id);
|
|
}, [refresh]);
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
}, [log.length, refresh]);
|
|
|
|
const lastEv = (() => {
|
|
const c = [...log].reverse().find((e) => e.channel === "capture");
|
|
if (!c) {
|
|
return "";
|
|
}
|
|
return typeof c.payload === "object" ? JSON.stringify(c.payload) : String(c.payload);
|
|
})();
|
|
|
|
const sess = st?.session;
|
|
const pct = sess ? Math.min(100, (sess.usedBytes / Math.max(1, sess.maxBytes)) * 100) : 0;
|
|
|
|
const setDeep = async (enable: boolean) => {
|
|
try {
|
|
await apiPost("/api/session/deep", { enable });
|
|
toast(
|
|
enable ? "Passive read-all on (live scan on by default at boot)" : "Passive read-all off",
|
|
);
|
|
refresh();
|
|
} catch (e) {
|
|
toast(String(e), "err");
|
|
}
|
|
};
|
|
|
|
const clearBuf = async () => {
|
|
try {
|
|
await apiPost("/api/session/clear", {});
|
|
toast("Buffer cleared — scanning can resume");
|
|
refresh();
|
|
} catch (e) {
|
|
toast(String(e), "err");
|
|
}
|
|
};
|
|
|
|
const download = async () => {
|
|
try {
|
|
const r = await fetch(apiUrl("/api/session/export"));
|
|
if (!r.ok) {
|
|
throw new Error(await r.text());
|
|
}
|
|
const blob = await r.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `pn532-deep-capture-${Date.now()}.ndjson`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
toast("Download started — check your Downloads folder");
|
|
} catch (e) {
|
|
toast(String(e), "err");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="glass relative overflow-hidden p-8"
|
|
>
|
|
<div className="pointer-events-none absolute -left-20 top-0 h-48 w-48 rounded-full bg-bubble-mint/20 blur-3xl" />
|
|
<h1 className="font-display text-3xl font-bold md:text-4xl">Passive read-all mode</h1>
|
|
<p className="mt-1 text-sm font-medium text-bubble-mint/90">
|
|
Same feature as “deep capture” — fully controlled from this screen.
|
|
</p>
|
|
<p className="mt-3 max-w-2xl text-slate-300">
|
|
Turn it on below and keep <strong>live scan</strong> running (on by default at boot). Each{" "}
|
|
<strong>new tag</strong> in the field is read <strong>passively</strong>: no per-block clicks — the
|
|
firmware pulls <strong>all data it can</strong> (PN532 status + Classic sector/block dump with
|
|
default keys, or Ultralight/NTAG page sweep). Results queue in <strong>device RAM</strong>; when
|
|
full, polling pauses until you <strong>download</strong> and <strong>clear</strong>.
|
|
</p>
|
|
</motion.div>
|
|
|
|
<div className="glass p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="font-display text-lg font-semibold">Read-all session buffer</h2>
|
|
<p className="text-sm text-slate-400">
|
|
{sess?.lines ?? 0} full dumps · {sess?.usedBytes ?? 0} / {sess?.maxBytes ?? "—"} bytes RAM
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeep(!(sess?.deepCapture ?? false))}
|
|
className={`rounded-2xl px-4 py-2 font-semibold ${
|
|
sess?.deepCapture
|
|
? "bg-bubble-mint/20 text-bubble-mint ring-2 ring-bubble-mint/40"
|
|
: "border border-white/15 bg-white/5"
|
|
}`}
|
|
>
|
|
{sess?.deepCapture ? "Passive read-all ON" : "Enable passive read-all"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={download}
|
|
className="rounded-2xl bg-gradient-to-r from-bubble-accent to-indigo-400 px-4 py-2 font-bold text-white shadow-glow"
|
|
>
|
|
Download NDJSON
|
|
</button>
|
|
<button type="button" onClick={clearBuf} className="rounded-2xl border border-white/20 px-4 py-2">
|
|
Clear buffer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 h-4 overflow-hidden rounded-full bg-black/40">
|
|
<motion.div
|
|
className="h-full rounded-full bg-gradient-to-r from-bubble-accent to-bubble-mint"
|
|
initial={false}
|
|
animate={{ width: `${pct}%` }}
|
|
transition={{ type: "spring", stiffness: 120, damping: 20 }}
|
|
/>
|
|
</div>
|
|
|
|
{sess?.full && (
|
|
<div className="mt-6 rounded-2xl border-2 border-bubble-rose/50 bg-bubble-rose/10 p-4 text-center">
|
|
<p className="font-display text-lg font-bold text-bubble-rose">Buffer full — reader paused</p>
|
|
<p className="mt-1 text-sm text-slate-300">
|
|
Tap <strong>Download NDJSON</strong> to pull every card profile to your phone, then{" "}
|
|
<strong>Clear buffer</strong> to resume field scans.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{sess?.deepCapture && st && !st.scanning && (
|
|
<p className="mt-4 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-100">
|
|
Firmware normally has <strong>Live scan</strong> on at boot. If you turned it off, enable it on the
|
|
Dashboard.
|
|
</p>
|
|
)}
|
|
|
|
{lastEv && (
|
|
<pre className="mt-4 max-h-40 overflow-auto rounded-2xl border border-white/10 bg-black/30 p-3 text-xs text-slate-300">
|
|
Last capture event: {lastEv}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|