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

@@ -368,6 +368,7 @@ static cJSON *find_sector_hit(const cJSON *attack, int sector)
int n = cJSON_GetArraySize(hits); int n = cJSON_GetArraySize(hits);
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
cJSON *it = cJSON_GetArrayItem(hits, i); cJSON *it = cJSON_GetArrayItem(hits, i);
if (!it) continue;
cJSON *s = cJSON_GetObjectItemCaseSensitive(it, "sector"); cJSON *s = cJSON_GetObjectItemCaseSensitive(it, "sector");
if (cJSON_IsNumber(s) && (int)cJSON_GetNumberValue(s) == sector) { if (cJSON_IsNumber(s) && (int)cJSON_GetNumberValue(s) == sector) {
return it; return it;
@@ -587,6 +588,7 @@ static cJSON *program_classic_snapshot_locked(const nfc_tag_info_t *tag, const c
int sectors_n = cJSON_GetArraySize(sectors); int sectors_n = cJSON_GetArraySize(sectors);
for (int si = 0; si < sectors_n; si++) { for (int si = 0; si < sectors_n; si++) {
cJSON *sec = cJSON_GetArrayItem(sectors, si); cJSON *sec = cJSON_GetArrayItem(sectors, si);
if (!sec) { skipped++; continue; }
cJSON *blocks = cJSON_GetObjectItemCaseSensitive(sec, "blocksHex"); cJSON *blocks = cJSON_GetObjectItemCaseSensitive(sec, "blocksHex");
cJSON *first = cJSON_GetObjectItemCaseSensitive(sec, "firstBlock"); cJSON *first = cJSON_GetObjectItemCaseSensitive(sec, "firstBlock");
cJSON *count = cJSON_GetObjectItemCaseSensitive(sec, "blockCount"); cJSON *count = cJSON_GetObjectItemCaseSensitive(sec, "blockCount");
@@ -661,16 +663,22 @@ static esp_err_t api_status(httpd_req_t *req)
wifi_mode_t mode; wifi_mode_t mode;
esp_wifi_get_mode(&mode); esp_wifi_get_mode(&mode);
cJSON_AddNumberToObject(o, "wifiMode", mode); cJSON_AddNumberToObject(o, "wifiMode", mode);
uint8_t ic = 0, hi = 0, lo = 0; bool pn532_ok = nfc_engine_is_ready();
nfc_access_lock(); cJSON_AddBoolToObject(o, "pn532Connected", pn532_ok);
if (pn532_get_firmware_version(&ic, &hi, &lo) == ESP_OK) { if (pn532_ok) {
cJSON *pn = cJSON_CreateObject(); uint8_t ic = 0, hi = 0, lo = 0;
cJSON_AddNumberToObject(pn, "ic", ic); nfc_access_lock();
cJSON_AddNumberToObject(pn, "fwHi", hi); if (pn532_get_firmware_version(&ic, &hi, &lo) == ESP_OK) {
cJSON_AddNumberToObject(pn, "fwLo", lo); cJSON *pn = cJSON_CreateObject();
cJSON_AddItemToObject(o, "pn532", pn); if (pn) {
cJSON_AddNumberToObject(pn, "ic", ic);
cJSON_AddNumberToObject(pn, "fwHi", hi);
cJSON_AddNumberToObject(pn, "fwLo", lo);
cJSON_AddItemToObject(o, "pn532", pn);
}
}
nfc_access_unlock();
} }
nfc_access_unlock();
cJSON_AddBoolToObject(o, "scanning", s_scan); cJSON_AddBoolToObject(o, "scanning", s_scan);
cJSON_AddBoolToObject(o, "targetActive", s_target_active); cJSON_AddBoolToObject(o, "targetActive", s_target_active);
size_t cap_u = 0; size_t cap_u = 0;
@@ -1710,12 +1718,33 @@ static esp_err_t ws_handler(httpd_req_t *req)
return ESP_OK; return ESP_OK;
} }
static void reconnect_task(void *arg)
{
(void)arg;
while (1) {
vTaskDelay(pdMS_TO_TICKS(5000));
if (!nfc_engine_is_ready()) {
nfc_access_lock();
bool ok = nfc_engine_try_reattach();
nfc_access_unlock();
if (ok) {
ESP_LOGI(TAG, "PN532 reattached — broadcasting status");
app_net_broadcast_json("pn532", "{\"connected\":true}");
}
}
}
}
static void scan_loop_task(void *arg) static void scan_loop_task(void *arg)
{ {
(void)arg; (void)arg;
nfc_tag_info_t last; nfc_tag_info_t last;
memset(&last, 0, sizeof(last)); memset(&last, 0, sizeof(last));
while (1) { while (1) {
if (!nfc_engine_is_ready()) {
vTaskDelay(pdMS_TO_TICKS(500));
continue;
}
if (!s_scan) { if (!s_scan) {
vTaskDelay(pdMS_TO_TICKS(200)); vTaskDelay(pdMS_TO_TICKS(200));
continue; continue;
@@ -2049,5 +2078,8 @@ esp_err_t app_net_init(void)
s_server = NULL; s_server = NULL;
return ESP_ERR_NO_MEM; return ESP_ERR_NO_MEM;
} }
if (xTaskCreate(reconnect_task, "pn532_reconnect", 4096, NULL, 3, NULL) != pdPASS) {
ESP_LOGW(TAG, "reconnect task create failed — hot-plug retry disabled");
}
return ESP_OK; return ESP_OK;
} }

View File

@@ -26,8 +26,19 @@ typedef struct {
bool key_b; bool key_b;
} nfc_mifare_key_t; } nfc_mifare_key_t;
/** Legacy: full init including transport — aborts on failure via caller's ESP_ERROR_CHECK. */
esp_err_t nfc_engine_init(void); esp_err_t nfc_engine_init(void);
/** Non-fatal first-boot init. Returns true if PN532 is present and configured. */
bool nfc_engine_try_init(void);
/** Soft re-attach after transport is already open — skips bus re-init, tries chip commands.
* Call from a background retry loop; always safe to call even if already ready. */
bool nfc_engine_try_reattach(void);
/** Returns true when the PN532 was successfully initialised (or re-attached). */
bool nfc_engine_is_ready(void);
esp_err_t nfc_poll_passive_target(nfc_tag_info_t *out); esp_err_t nfc_poll_passive_target(nfc_tag_info_t *out);
bool nfc_tag_is_mifare_classic(const nfc_tag_info_t *tag); bool nfc_tag_is_mifare_classic(const nfc_tag_info_t *tag);
bool nfc_tag_is_mifare_classic_4k(const nfc_tag_info_t *tag); bool nfc_tag_is_mifare_classic_4k(const nfc_tag_info_t *tag);

View File

@@ -8,6 +8,9 @@
static const char *TAG = "nfc_engine"; static const char *TAG = "nfc_engine";
static uint8_t s_tg = 1; static uint8_t s_tg = 1;
static volatile bool s_pn532_ready = false;
bool nfc_engine_is_ready(void) { return s_pn532_ready; }
static void hint_type(nfc_tag_info_t *t) static void hint_type(nfc_tag_info_t *t)
{ {
@@ -102,9 +105,51 @@ esp_err_t nfc_engine_init(void)
if (pn532_rf_max_retries() != ESP_OK) { if (pn532_rf_max_retries() != ESP_OK) {
ESP_LOGW(TAG, "RF max retries config failed"); ESP_LOGW(TAG, "RF max retries config failed");
} }
s_pn532_ready = true;
return ESP_OK; return ESP_OK;
} }
bool nfc_engine_try_init(void)
{
esp_err_t e = pn532_core_init();
if (e != ESP_OK) {
ESP_LOGW(TAG, "PN532 not found (%s) — AP running, will retry every 5 s", esp_err_to_name(e));
s_pn532_ready = false;
return false;
}
e = pn532_sam_config_normal();
if (e != ESP_OK) {
ESP_LOGW(TAG, "SAM config: %s", esp_err_to_name(e));
}
uint8_t ic = 0, hi = 0, lo = 0;
if (pn532_get_firmware_version(&ic, &hi, &lo) == ESP_OK) {
ESP_LOGI(TAG, "PN532 fw ic=0x%02x %u.%u", ic, hi, lo);
}
if (pn532_rf_max_retries() != ESP_OK) {
ESP_LOGW(TAG, "RF max retries config failed");
}
s_pn532_ready = true;
return true;
}
bool nfc_engine_try_reattach(void)
{
/* Transport already open — just ping the chip and re-apply configuration. */
esp_err_t e = pn532_sam_config_normal();
if (e != ESP_OK) {
return false;
}
uint8_t ic = 0, hi = 0, lo = 0;
if (pn532_get_firmware_version(&ic, &hi, &lo) == ESP_OK) {
ESP_LOGI(TAG, "PN532 reattached ic=0x%02x %u.%u", ic, hi, lo);
}
if (pn532_rf_max_retries() != ESP_OK) {
ESP_LOGW(TAG, "RF max retries config failed");
}
s_pn532_ready = true;
return true;
}
esp_err_t nfc_poll_passive_target(nfc_tag_info_t *out) esp_err_t nfc_poll_passive_target(nfc_tag_info_t *out)
{ {
if (!out) { if (!out) {

View File

@@ -1,6 +1,7 @@
#include "nfc_engine/session_capture.h" #include "nfc_engine/session_capture.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/semphr.h" #include "freertos/semphr.h"
#include "esp_log.h"
#include <string.h> #include <string.h>
#define SESSION_CAPTURE_BYTES (48 * 1024) #define SESSION_CAPTURE_BYTES (48 * 1024)
@@ -15,6 +16,10 @@ static SemaphoreHandle_t s_mu;
void session_capture_init(void) void session_capture_init(void)
{ {
s_mu = xSemaphoreCreateMutex(); s_mu = xSemaphoreCreateMutex();
if (!s_mu) {
ESP_LOGE("session_capture", "mutex create failed — aborting");
abort();
}
session_capture_clear(); session_capture_clear();
s_deep = false; s_deep = false;
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -11,8 +11,8 @@
href="https://fonts.googleapis.com/css2?family=Audiowide&family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=Orbitron:wght@500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Audiowide&family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=Orbitron:wght@500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<script type="module" crossorigin src="/assets/index-9oJp152C.js"></script> <script type="module" crossorigin src="/assets/index-BmIGATlK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hoMg1Qkq.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bb0xTRCn.css">
</head> </head>
<body class="bg-bubble-950 text-slate-200 antialiased selection:bg-bubble-accent/40 selection:text-bubble-950"> <body class="bg-bubble-950 text-slate-200 antialiased selection:bg-bubble-accent/40 selection:text-bubble-950">
<div id="root"></div> <div id="root"></div>

View File

@@ -12,8 +12,12 @@ void app_main(void)
(void)esp_ota_mark_app_valid_cancel_rollback(); (void)esp_ota_mark_app_valid_cancel_rollback();
board_rgb_led_quiet(); board_rgb_led_quiet();
ESP_LOGI(TAG, "PN532 NFC Toolkit starting"); ESP_LOGI(TAG, "PN532 NFC Toolkit starting");
ESP_ERROR_CHECK(nfc_engine_init()); nfc_engine_try_init(); /* non-fatal: logs warning if PN532 absent, AP starts regardless */
session_capture_init(); session_capture_init();
ESP_ERROR_CHECK(app_net_init()); ESP_ERROR_CHECK(app_net_init());
ESP_LOGI(TAG, "Open AP SSID PN532-Toolkit — http://192.168.4.1"); if (nfc_engine_is_ready()) {
ESP_LOGI(TAG, "PN532 ready · AP SSID PN532-Toolkit → http://192.168.4.1");
} else {
ESP_LOGW(TAG, "PN532 not found at boot — AP running, retrying · http://192.168.4.1");
}
} }

View File

@@ -3,7 +3,7 @@ import { NavLink, Route, Routes } from "react-router-dom";
import BrowserLogBar from "./BrowserLogBar"; import BrowserLogBar from "./BrowserLogBar";
import FlashBackdrop from "./FlashBackdrop"; import FlashBackdrop from "./FlashBackdrop";
import ScanCashFlourish from "./ScanCashFlourish"; import ScanCashFlourish from "./ScanCashFlourish";
import { NfcWsProvider } from "./NfcWsContext"; import { NfcWsProvider, useNfcWs } from "./NfcWsContext";
import { ToastHost } from "./toast"; import { ToastHost } from "./toast";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import ReadAnalyze from "./pages/ReadAnalyze"; import ReadAnalyze from "./pages/ReadAnalyze";
@@ -17,6 +17,29 @@ import Brute from "./pages/Brute";
import Emulate from "./pages/Emulate"; import Emulate from "./pages/Emulate";
import KeyLab from "./pages/KeyLab"; 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 = [ const nav = [
["/", "Dash"], ["/", "Dash"],
["/capture", "Read-all"], ["/capture", "Read-all"],
@@ -70,12 +93,7 @@ export default function App() {
MAXIMAL MAXIMAL
</span> </span>
</motion.div> </motion.div>
<span className="hidden font-mono text-[9px] text-bubble-mint/40 md:inline lg:text-[10px]"> <HeaderBadge />
<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>
</div> </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 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]) => ( {nav.map(([to, label]) => (

View File

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

View File

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

View File

@@ -19,7 +19,14 @@ export default function Brute() {
const keysHex = extraKeys const keysHex = extraKeys
.split(/\r?\n/) .split(/\r?\n/)
.map((l) => l.replace(/\s/g, "").toUpperCase()) .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 = { const body = {
readerType: reader, readerType: reader,
variations, variations,

View File

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

View File

@@ -152,9 +152,22 @@ export default function Dashboard() {
</li> </li>
<li className="flex justify-between"> <li className="flex justify-between">
<span className="text-slate-500">PN532</span> <span className="text-slate-500">PN532</span>
<span className="text-bubble-accent"> {st.pn532Connected ? (
{st.pn532 ? `IC${st.pn532.ic} v${st.pn532.fwHi}.${st.pn532.fwLo}` : "n/a"} <span className="flex items-center gap-2">
</span> {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> </li>
</ul> </ul>
) : ( ) : (

View File

@@ -11,13 +11,26 @@ export default function Emulate() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const send = async () => { 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); setBusy(true);
setOut(""); setOut("");
try { try {
const r = await fetch(apiUrl("/api/nfc/emulate-raw"), { const r = await fetch(apiUrl("/api/nfc/emulate-raw"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hex: hex.replace(/\s/g, "") }), body: JSON.stringify({ hex: cleanHex }),
}); });
const t = await r.text(); const t = await r.text();
if (!r.ok) { if (!r.ok) {

View File

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

View File

@@ -31,10 +31,12 @@ export default function ReadAnalyze() {
const readUl = async () => { const readUl = async () => {
try { 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) { if (j.data) {
setHex(j.data + " (UL page)"); setHex(j.data + " (UL page)");
toast("UL read OK"); toast("UL read OK");
} else {
toast(j.error ?? "no data returned", "err");
} }
} catch (e) { } catch (e) {
toast(String(e), "err"); toast(String(e), "err");

View File

@@ -17,6 +17,7 @@ export default function WriteClone() {
const [labBlob, setLabBlob] = useState<BinaryCardFixture | null>(null); const [labBlob, setLabBlob] = useState<BinaryCardFixture | null>(null);
const [ulPage, setUlPage] = useState(4); const [ulPage, setUlPage] = useState(4);
const [ulData, setUlData] = useState("00000000"); const [ulData, setUlData] = useState("00000000");
const [busy, setBusy] = useState(false);
const write = async () => { const write = async () => {
const cleanKey = key.replace(/\s/g, ""); const cleanKey = key.replace(/\s/g, "");
@@ -36,11 +37,14 @@ export default function WriteClone() {
if (!confirm("Write will modify tag memory. Continue?")) { if (!confirm("Write will modify tag memory. Continue?")) {
return; return;
} }
setBusy(true);
try { try {
await apiPost("/api/mifare/write-block", { block, key: cleanKey, keyB, data: cleanData }); await apiPost("/api/mifare/write-block", { block, key: cleanKey, keyB, data: cleanData });
toast("Write OK"); toast("Write OK");
} catch (e) { } catch (e) {
toast(String(e), "err"); 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?")) { if (!confirm("Ultralight page write — can brick OTP/lock bytes if misused. Continue?")) {
return; return;
} }
setBusy(true);
try { try {
await apiPost("/api/ul/write-page", { page: ulPage, data: h }); await apiPost("/api/ul/write-page", { page: ulPage, data: h });
toast("UL page write OK"); toast("UL page write OK");
} catch (e) { } catch (e) {
toast(String(e), "err"); toast(String(e), "err");
} finally {
setBusy(false);
} }
}; };
@@ -130,9 +137,10 @@ export default function WriteClone() {
<button <button
type="button" type="button"
onClick={write} 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> </button>
<div className="border-t border-white/10 pt-8"> <div className="border-t border-white/10 pt-8">
@@ -162,9 +170,10 @@ export default function WriteClone() {
<button <button
type="button" type="button"
onClick={writeUl} 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> </button>
</div> </div>
</div> </div>

View File

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