Files
nfc-pn532-warlord/web/src/pages/Capture.tsx
drjones 8968560565 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
2026-03-29 09:32:55 -07:00

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>
);
}