feat: resilient boot + full polish pass

Firmware:
- nfc_engine: add nfc_engine_try_init() (non-fatal, sets s_pn532_ready),
  nfc_engine_try_reattach() (soft re-init, skips bus re-init),
  nfc_engine_is_ready() accessor
- main: replace ESP_ERROR_CHECK(nfc_engine_init) with nfc_engine_try_init;
  device boots and serves web UI even with no PN532 connected
- app_net: add reconnect_task — every 5s retries nfc_engine_try_reattach()
  and broadcasts {"channel":"pn532","payload":{"connected":true}} over WS
- app_net: scan_loop_task skips polling when !nfc_engine_is_ready()
- app_net/api_status: always emit pn532Connected bool; null-guard pn532 fw object
- find_sector_hit / program_classic_snapshot_locked: null-guard cJSON array items
- session_capture: abort if xSemaphoreCreateMutex() returns NULL

Web:
- NfcWsContext: track pn532Connected state from WS pn532 channel + status fetch on connect
- App.tsx: live HeaderBadge (LIVE/NO RF/WAIT) replacing static text
- Dashboard: READY/SEARCHING pill with fw version when available
- api.ts: add pn532Connected to Status type
- toast.tsx: fix ID collision (Date.now + Math.random)
- Capture: surface status fetch errors
- ReadAnalyze: add error feedback for readUl when no data returned
- WriteClone: busy state on both write buttons
- RawConsole: toast when frame returns error not response
- Emulate: validate hex before send (non-empty, even length, hex chars only)
- Brute: warn and skip invalid custom key lines

Made-with: Cursor
This commit is contained in:
drjones
2026-04-09 15:49:18 -07:00
parent 63db10d400
commit 2da1515f8d
20 changed files with 236 additions and 53 deletions

View File

@@ -3,7 +3,7 @@ import { NavLink, Route, Routes } from "react-router-dom";
import BrowserLogBar from "./BrowserLogBar";
import FlashBackdrop from "./FlashBackdrop";
import ScanCashFlourish from "./ScanCashFlourish";
import { NfcWsProvider } from "./NfcWsContext";
import { NfcWsProvider, useNfcWs } from "./NfcWsContext";
import { ToastHost } from "./toast";
import Dashboard from "./pages/Dashboard";
import ReadAnalyze from "./pages/ReadAnalyze";
@@ -17,6 +17,29 @@ import Brute from "./pages/Brute";
import Emulate from "./pages/Emulate";
import KeyLab from "./pages/KeyLab";
function HeaderBadge() {
const { wsOk, pn532Connected } = useNfcWs();
return (
<span className="hidden font-mono text-[9px] text-bubble-mint/40 md:inline lg:text-[10px]">
<span className={wsOk ? "text-bubble-accent/90" : "animate-pulse text-bubble-rose/60"}></span>{" "}
RF_STACK{" "}
<span
className={
!wsOk
? "animate-pulse text-bubble-rose/80"
: pn532Connected
? "font-bold text-bubble-mint"
: "animate-pulse text-amber-400"
}
>
{!wsOk ? "WAIT" : pn532Connected ? "LIVE" : "NO RF"}
</span>
<span className="mx-1.5 text-bubble-mint/25"></span>
<span className="text-bubble-mint/50">ws://stream</span>
</span>
);
}
const nav = [
["/", "Dash"],
["/capture", "Read-all"],
@@ -70,12 +93,7 @@ export default function App() {
MAXIMAL
</span>
</motion.div>
<span className="hidden font-mono text-[9px] text-bubble-mint/40 md:inline lg:text-[10px]">
<span className="text-bubble-accent/90"></span> RF_STACK{" "}
<span className="text-bubble-rose/80">LIVE</span>
<span className="mx-1.5 text-bubble-mint/25"></span>
<span className="text-bubble-mint/50">ws://stream</span>
</span>
<HeaderBadge />
</div>
<nav className="flex max-w-full flex-wrap justify-end gap-1 text-[10px] font-mono sm:gap-1.5 sm:text-[11px]">
{nav.map(([to, label]) => (

View File

@@ -1,5 +1,5 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import type { Tag } from "./api";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { apiGet, type Status, type Tag } from "./api";
import { useToolkitWs } from "./useWebSocket";
export type BrowserLogEntry = {
@@ -12,6 +12,7 @@ export type CashVariant = "tag" | "vault";
type NfcCtx = {
wsOk: boolean;
pn532Connected: boolean;
lastTag: Tag | null;
tagPresent: boolean;
log: BrowserLogEntry[];
@@ -89,6 +90,7 @@ function isCaptureRecorded(payload: unknown): boolean {
export function NfcWsProvider({ children }: { children: React.ReactNode }) {
const [lastTag, setLastTag] = useState<Tag | null>(null);
const [tagPresent, setTagPresent] = useState(false);
const [pn532Connected, setPn532Connected] = useState(false);
const [log, setLog] = useState<BrowserLogEntry[]>(() => loadPersisted());
const [cashWave, setCashWave] = useState(0);
const [cashVariant, setCashVariant] = useState<CashVariant>("tag");
@@ -121,6 +123,11 @@ export function NfcWsProvider({ children }: { children: React.ReactNode }) {
}
} else if (ch === "capture" && isCaptureRecorded(o.payload)) {
bump("vault");
} else if (ch === "pn532") {
const p = o.payload as { connected?: boolean } | undefined;
if (typeof p?.connected === "boolean") {
setPn532Connected(p.connected);
}
}
} catch {
/* ignore */
@@ -131,6 +138,19 @@ export function NfcWsProvider({ children }: { children: React.ReactNode }) {
const wsOk = useToolkitWs(onMsg);
/* Fetch initial PN532 state as soon as WS connects (avoids waiting for first broadcast). */
useEffect(() => {
if (wsOk) {
apiGet<Status>("/api/status")
.then((s) => {
if (typeof s.pn532Connected === "boolean") {
setPn532Connected(s.pn532Connected);
}
})
.catch(() => { /* silently ignore — badge will update on next WS event */ });
}
}, [wsOk]);
const applyScanPoll = useCallback(
(present: boolean, tag?: Tag) => {
if (!present) {
@@ -165,6 +185,7 @@ export function NfcWsProvider({ children }: { children: React.ReactNode }) {
const v = useMemo(
() => ({
wsOk,
pn532Connected,
lastTag,
tagPresent,
log,
@@ -174,7 +195,7 @@ export function NfcWsProvider({ children }: { children: React.ReactNode }) {
exportBrowserLog,
applyScanPoll,
}),
[wsOk, lastTag, tagPresent, log, cashWave, cashVariant, clearBrowserLog, exportBrowserLog, applyScanPoll],
[wsOk, pn532Connected, lastTag, tagPresent, log, cashWave, cashVariant, clearBrowserLog, exportBrowserLog, applyScanPoll],
);
return <Ctx.Provider value={v}>{children}</Ctx.Provider>;

View File

@@ -42,6 +42,7 @@ export type Status = {
uptimeMs: number;
freeHeap: number;
wifiMode: number;
pn532Connected: boolean;
pn532?: { ic: number; fwHi: number; fwLo: number };
scanning: boolean;
session?: SessionInfo;

View File

@@ -19,7 +19,14 @@ export default function Brute() {
const keysHex = extraKeys
.split(/\r?\n/)
.map((l) => l.replace(/\s/g, "").toUpperCase())
.filter((l) => l.length === 12);
.filter((l) => l.length === 12 && /^[0-9A-F]+$/.test(l));
const invalidCount = extraKeys
.split(/\r?\n/)
.map((l) => l.replace(/\s/g, ""))
.filter((l) => l.length > 0 && (l.length !== 12 || !/^[0-9A-Fa-f]+$/.test(l))).length;
if (invalidCount > 0) {
toast(`${invalidCount} line(s) not valid 12-hex keys — skipped, firmware built-ins still run`);
}
const body = {
readerType: reader,
variations,

View File

@@ -12,8 +12,8 @@ export default function Capture() {
const refresh = useCallback(() => {
apiGet<Status>("/api/status")
.then(setSt)
.catch(() => {});
}, []);
.catch(() => toast("Status unreachable", "err"));
}, [toast]);
useEffect(() => {
refresh();

View File

@@ -152,9 +152,22 @@ export default function Dashboard() {
</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>
{st.pn532Connected ? (
<span className="flex items-center gap-2">
{st.pn532 && (
<span className="text-[10px] text-slate-500">
IC{st.pn532.ic} v{st.pn532.fwHi}.{st.pn532.fwLo}
</span>
)}
<span className="rounded px-1.5 py-0.5 bg-bubble-mint/20 text-bubble-mint font-bold text-[10px] tracking-widest">
READY
</span>
</span>
) : (
<span className="animate-pulse rounded px-1.5 py-0.5 bg-amber-500/20 text-amber-400 font-bold text-[10px] tracking-widest">
SEARCHING
</span>
)}
</li>
</ul>
) : (

View File

@@ -11,13 +11,26 @@ export default function Emulate() {
const [busy, setBusy] = useState(false);
const send = async () => {
const cleanHex = hex.replace(/\s/g, "");
if (!cleanHex) {
toast("Hex payload is empty", "err");
return;
}
if (cleanHex.length % 2 !== 0) {
toast("Hex must have an even number of characters", "err");
return;
}
if (!/^[0-9A-Fa-f]+$/.test(cleanHex)) {
toast("Hex must contain only 0-9 A-F characters", "err");
return;
}
setBusy(true);
setOut("");
try {
const r = await fetch(apiUrl("/api/nfc/emulate-raw"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hex: hex.replace(/\s/g, "") }),
body: JSON.stringify({ hex: cleanHex }),
});
const t = await r.text();
if (!r.ok) {

View File

@@ -19,6 +19,8 @@ export default function RawConsole() {
push(`RX ${j.response || j.error || "?"}`);
if (j.response) {
toast("Frame OK");
} else if (j.error) {
toast(j.error, "err");
}
} catch (e) {
push(`ERR ${String(e)}`);

View File

@@ -31,10 +31,12 @@ export default function ReadAnalyze() {
const readUl = async () => {
try {
const j = await apiPost<{ data?: string }>("/api/ul/read-page", { page: block });
const j = await apiPost<{ data?: string; error?: string }>("/api/ul/read-page", { page: block });
if (j.data) {
setHex(j.data + " (UL page)");
toast("UL read OK");
} else {
toast(j.error ?? "no data returned", "err");
}
} catch (e) {
toast(String(e), "err");

View File

@@ -17,6 +17,7 @@ export default function WriteClone() {
const [labBlob, setLabBlob] = useState<BinaryCardFixture | null>(null);
const [ulPage, setUlPage] = useState(4);
const [ulData, setUlData] = useState("00000000");
const [busy, setBusy] = useState(false);
const write = async () => {
const cleanKey = key.replace(/\s/g, "");
@@ -36,11 +37,14 @@ export default function WriteClone() {
if (!confirm("Write will modify tag memory. Continue?")) {
return;
}
setBusy(true);
try {
await apiPost("/api/mifare/write-block", { block, key: cleanKey, keyB, data: cleanData });
toast("Write OK");
} catch (e) {
toast(String(e), "err");
} finally {
setBusy(false);
}
};
@@ -53,11 +57,14 @@ export default function WriteClone() {
if (!confirm("Ultralight page write — can brick OTP/lock bytes if misused. Continue?")) {
return;
}
setBusy(true);
try {
await apiPost("/api/ul/write-page", { page: ulPage, data: h });
toast("UL page write OK");
} catch (e) {
toast(String(e), "err");
} finally {
setBusy(false);
}
};
@@ -130,9 +137,10 @@ export default function WriteClone() {
<button
type="button"
onClick={write}
className="rounded-2xl bg-gradient-to-r from-bubble-rose to-orange-400 px-6 py-3 font-bold text-white shadow-glow"
disabled={busy}
className="rounded-2xl bg-gradient-to-r from-bubble-rose to-orange-400 px-6 py-3 font-bold text-white shadow-glow disabled:opacity-50"
>
Write block
{busy ? "Writing…" : "Write block"}
</button>
<div className="border-t border-white/10 pt-8">
@@ -162,9 +170,10 @@ export default function WriteClone() {
<button
type="button"
onClick={writeUl}
className="mt-4 rounded-2xl border border-bubble-accent/50 bg-bubble-accent/20 px-6 py-3 font-bold text-bubble-accent"
disabled={busy}
className="mt-4 rounded-2xl border border-bubble-accent/50 bg-bubble-accent/20 px-6 py-3 font-bold text-bubble-accent disabled:opacity-50"
>
Write UL page
{busy ? "Writing…" : "Write UL page"}
</button>
</div>
</div>

View File

@@ -7,7 +7,7 @@ const Ctx = createContext<(msg: string, kind?: Toast["kind"]) => void>(() => {})
export function ToastHost({ children }: { children: React.ReactNode }) {
const [list, setList] = useState<Toast[]>([]);
const push = useCallback((msg: string, kind: Toast["kind"] = "info") => {
const id = Date.now();
const id = Date.now() + Math.random();
setList((x) => [...x, { id, msg, kind }]);
setTimeout(() => setList((x) => x.filter((t) => t.id !== id)), 4200);
}, []);