Add PN532 toolkit firmware, web UI, and embedded SPIFFS assets
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
This commit is contained in:
168
web/src/pages/Capture.tsx
Normal file
168
web/src/pages/Capture.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user