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
235 lines
10 KiB
TypeScript
235 lines
10 KiB
TypeScript
import { motion } from "framer-motion";
|
|
import { useEffect, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { apiPost, apiGet, type Status, type Tag } from "../api";
|
|
import { useNfcWs } from "../NfcWsContext";
|
|
import { useToast } from "../toast";
|
|
import {
|
|
generalStatusHexLine,
|
|
sakLabel,
|
|
scanCheatLines,
|
|
typeHintLabel,
|
|
uidWithSeparators,
|
|
} from "../nfcUtils";
|
|
import HeroVisual from "../ui/HeroVisual";
|
|
|
|
export default function Dashboard() {
|
|
const toast = useToast();
|
|
const { lastTag, applyScanPoll } = useNfcWs();
|
|
const [st, setSt] = useState<Status | null>(null);
|
|
const [scan, setScan] = useState(true);
|
|
|
|
const poll = () => {
|
|
apiGet<Status>("/api/status")
|
|
.then(setSt)
|
|
.catch(() => toast("Status unreachable", "err"));
|
|
};
|
|
|
|
useEffect(() => {
|
|
poll();
|
|
const id = setInterval(poll, 3000);
|
|
return () => clearInterval(id);
|
|
}, [toast]);
|
|
|
|
useEffect(() => {
|
|
if (st && typeof st.scanning === "boolean") {
|
|
setScan(st.scanning);
|
|
}
|
|
}, [st]);
|
|
|
|
const toggleScan = async () => {
|
|
try {
|
|
await apiPost<{ enable: boolean }>("/api/nfc/scan", { enable: !scan });
|
|
setScan(!scan);
|
|
toast(scan ? "Continuous scan off" : "Continuous scan on");
|
|
} catch (e) {
|
|
toast(String(e), "err");
|
|
}
|
|
};
|
|
|
|
const pollOnce = async () => {
|
|
try {
|
|
const j = await apiPost<{ present: boolean; tag?: Tag }>("/api/nfc/poll", {});
|
|
if (j.present && j.tag) {
|
|
applyScanPoll(true, j.tag);
|
|
toast(`Tag ${j.tag.uid}`);
|
|
} else {
|
|
applyScanPoll(false);
|
|
toast("No tag");
|
|
}
|
|
} catch (e) {
|
|
toast(String(e), "err");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
|
className="glass relative overflow-hidden p-6 md:p-10"
|
|
>
|
|
<div className="pointer-events-none absolute inset-0 bg-[conic-gradient(from_180deg_at_50%_120%,rgba(0,229,255,0.08),transparent_40%,rgba(255,42,109,0.06),transparent_70%)]" />
|
|
<div className="relative grid gap-8 lg:grid-cols-[1fr_min(320px,40%)] lg:items-center">
|
|
<div>
|
|
<p className="font-mono text-[10px] font-bold tracking-[0.4em] text-bubble-accent/80">COMMAND_LAYER</p>
|
|
<h1 className="font-display mt-2 text-3xl font-normal tracking-wide text-glow-matrix md:text-5xl">
|
|
NFC <span className="text-bubble-accent">CONTROL</span>
|
|
</h1>
|
|
<p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-400">
|
|
Full-time scan out of the box. Fat RF retries. Browser log + device session export — crank it from
|
|
your phone.
|
|
</p>
|
|
{st?.session && (st.session.deepCapture || st.session.usedBytes > 0) && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.98 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="mt-5 flex flex-wrap items-center gap-3 rounded-xl border border-bubble-accent/40 bg-gradient-to-r from-bubble-accent/15 to-bubble-mint/10 px-4 py-3 text-sm shadow-glowCyan"
|
|
>
|
|
<span className="font-mono text-bubble-mint">
|
|
RAM {st.session.usedBytes}/{st.session.maxBytes}
|
|
{st.session.full ? " · LOCKED" : ""}
|
|
</span>
|
|
<Link
|
|
to="/capture"
|
|
className="btn-neon rounded-lg bg-bubble-mint/20 px-3 py-1 text-xs font-bold text-bubble-mint"
|
|
>
|
|
Read-all →
|
|
</Link>
|
|
</motion.div>
|
|
)}
|
|
<div className="mt-8 flex flex-wrap gap-3">
|
|
<motion.button
|
|
type="button"
|
|
whileHover={{ scale: 1.04 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
onClick={pollOnce}
|
|
className="btn-neon rounded-xl bg-gradient-to-r from-bubble-accent via-cyan-400 to-bubble-mint px-6 py-3 text-sm font-black uppercase tracking-wider text-bubble-950 shadow-neonBtn"
|
|
>
|
|
Poll tag
|
|
</motion.button>
|
|
<motion.button
|
|
type="button"
|
|
whileHover={{ scale: 1.03 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
onClick={toggleScan}
|
|
className={`rounded-xl border-2 px-6 py-3 text-sm font-bold uppercase tracking-wide transition ${
|
|
scan
|
|
? "border-bubble-mint/70 bg-bubble-mint/15 text-glow-matrix text-bubble-mint shadow-glow"
|
|
: "border-white/20 bg-black/30 text-slate-400 hover:border-bubble-accent/50 hover:text-bubble-accent"
|
|
}`}
|
|
>
|
|
{scan ? "Stop scan" : "Start scan"}
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
<div className="relative flex justify-center lg:justify-end">
|
|
<div className="relative w-full max-w-[280px] opacity-90 drop-shadow-[0_0_40px_rgba(0,229,255,0.35)]">
|
|
<HeroVisual />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<div className="grid gap-5 md:grid-cols-2">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.08 }}
|
|
className="glass p-6"
|
|
>
|
|
<h2 className="font-display text-lg font-normal text-bubble-accent text-glow-cyan">Device stack</h2>
|
|
{st ? (
|
|
<ul className="mt-4 space-y-3 font-mono text-sm text-slate-300">
|
|
<li className="flex justify-between border-b border-white/5 pb-2">
|
|
<span className="text-slate-500">uptime</span>
|
|
<span className="text-bubble-mint">{(st.uptimeMs / 1000).toFixed(1)}s</span>
|
|
</li>
|
|
<li className="flex justify-between border-b border-white/5 pb-2">
|
|
<span className="text-slate-500">heap</span>
|
|
<span className="text-white">{st.freeHeap}</span>
|
|
</li>
|
|
<li className="flex justify-between">
|
|
<span className="text-slate-500">PN532</span>
|
|
<span className="text-bubble-accent">
|
|
{st.pn532 ? `IC${st.pn532.ic} v${st.pn532.fwHi}.${st.pn532.fwLo}` : "n/a"}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
) : (
|
|
<p className="mt-4 animate-pulse font-mono text-sm text-bubble-accent/60">Pulling status…</p>
|
|
)}
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.14 }}
|
|
className="glass p-6"
|
|
>
|
|
<h2 className="font-display text-lg font-normal text-bubble-rose/90 text-glow-rose">Live tag</h2>
|
|
{lastTag ? (
|
|
<div className="mt-4 space-y-4 rounded-xl border border-bubble-mint/25 bg-black/35 p-4 shadow-glow">
|
|
<div>
|
|
<p className="text-[10px] font-mono uppercase tracking-widest text-bubble-mint/50">UID</p>
|
|
<div className="font-mono text-xl font-bold tracking-[0.15em] text-glow-matrix text-bubble-mint md:text-2xl">
|
|
{uidWithSeparators(lastTag.uid)}
|
|
</div>
|
|
<p className="mt-1 font-mono text-[11px] text-slate-500">
|
|
raw {lastTag.uid} · {lastTag.uidLen} byte{lastTag.uidLen === 1 ? "" : "s"}
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-3 border-t border-white/5 pt-3 font-mono text-[11px] text-slate-300 sm:grid-cols-2">
|
|
<div>
|
|
<span className="text-slate-500">ATQA</span>{" "}
|
|
<span className="text-bubble-accent">
|
|
0x{(lastTag.atqaHex ?? lastTag.atqa.toString(16).toUpperCase().padStart(4, "0")).slice(-4)}
|
|
</span>
|
|
<span className="ml-2 text-slate-500">({lastTag.atqa})</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-500">SAK</span>{" "}
|
|
<span className="text-bubble-accent">
|
|
0x{(lastTag.sakHex ?? lastTag.sak.toString(16).toUpperCase().padStart(2, "0")).slice(-2)}
|
|
</span>
|
|
<span className="ml-1 text-slate-400">— {sakLabel(lastTag.sak)}</span>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<span className="text-slate-500">Guess</span>{" "}
|
|
<span className="text-bubble-mint">{lastTag.typeGuess ?? typeHintLabel(lastTag.typeHint)}</span>
|
|
<span className="ml-2 text-slate-600">· hint {lastTag.typeHint}</span>
|
|
</div>
|
|
</div>
|
|
{lastTag.pn532GeneralStatus && lastTag.pn532GeneralStatus.length > 0 && (
|
|
<div className="rounded-lg border border-bubble-accent/20 bg-black/40 p-3">
|
|
<p className="text-[10px] font-mono uppercase tracking-widest text-bubble-accent/70">
|
|
PN532 general status
|
|
</p>
|
|
<p className="mt-2 break-all font-mono text-[10px] leading-relaxed text-slate-400">
|
|
{generalStatusHexLine(lastTag.pn532GeneralStatus)}
|
|
</p>
|
|
<p className="mt-2 text-[10px] text-slate-600">
|
|
Raw bytes from the chip right after this inventory (error flags, last command, tag count —
|
|
see NXP PN532 user manual).
|
|
</p>
|
|
</div>
|
|
)}
|
|
<ul className="space-y-1 border-t border-white/5 pt-3 text-[11px] leading-relaxed text-slate-500">
|
|
{scanCheatLines(lastTag).map((line, i) => (
|
|
<li key={i} className="flex gap-2">
|
|
<span className="text-bubble-mint/40">▸</span>
|
|
<span>{line}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : (
|
|
<p className="mt-6 font-mono text-sm text-bubble-mint/40">Listening on the field…</p>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|