Files
nfc-pn532-warlord/web/src/pages/Dashboard.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

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