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:
@@ -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]) => (
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user