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
PN532 NFC Toolkit
The browser-native NFC lab that fits in your pocket.
ESP32-S3 + PN532 + a serious web UI — no desktop app, no dongle software, no mystery binaries. You join Wi‑Fi, open a URL, and you’re operating the reader from any phone or laptop.
Why this is the best tool for PN532 workflows
-
Full remote control in the browser — Dashboard, live scan, deep capture, read/write helpers, dictionary attack, raw PN532 commands, and live status — all over HTTP + WebSocket. You can stand across the room with your phone while the hardware sits on the bench.
-
“Deep capture” is actually deep (for a PN532) — On each new UID the firmware doesn’t stop at inventory: it builds a JSON profile with MIFARE Classic sector sweeps (default keys, Key A then B per sector trailer, 1K and 4K layouts) with every readable block as hex, or an Ultralight/NTAG-style page sweep (up to 240 pages in firmware). Unknown or non-Classic paths still get solid ATQA/SAK/UID plus whatever the stack can return — nothing is silently dropped.
-
RAM session buffer with a hard stop + phone download — Captures live in on-chip RAM (48 KB buffer, NDJSON lines). When the buffer is full, RF polling pauses so you never lose data to silent overflow. You tap Download NDJSON, get one file with every profile, then Clear to resume. That workflow is built for field audits, bench sessions, and anything where “I need the dump on my phone, now” matters.
-
Self-hosted on the device — Default SoftAP: SSID
PN532-Toolkit, open network (no password, lab default). mDNS hostnamepn532tool.local(HTTP port 80). No cloud, no account, no telemetry. -
Honest architecture — ESP-IDF, explicit components (
pn532_host,nfc_engine,net_service), Vite/React UI embedded in SPIFFS. You extend it like real firmware, not a black-box sketch. PN532 talks SPI, I2C, or UART (HSU) — pins and bus are menuconfig, not guesses. -
Raw PN532 path — When the high-level UI isn’t enough, Raw sends command bytes; the stack handles transport framing.
GET /api/pn532/general-statusexposes the controller’s general status bytes for debugging. That’s how you stay aligned with the real chip, not a toy abstraction. -
UI that matches the ambition — Live WebSocket stream (scan + capture), browser-side log (thousands of events, exportable JSON), optional
apiBasefor talking to the box from another host, HashRouter so refreshes don’t fight the server, and lab fixtures (synthetic card blobs, keys, SHA checks) so you can validate the UI without burning tags.
This stack is not a Proxmark replacement (no LF, no raw carrier manipulation, no FPGA tricks). For hosted NFC with a PN532, it’s the full pocket operator: remote UI, deep reads, session export, Classic dictionary work, Ultralight read/write, card emulation hooks, and a straight line to the command set when you need it.
Every feature, in one pass
Firmware (what the chip actually does)
| Capability | What you get |
|---|---|
| Transport | PN532 over SPI (default pins in Kconfig), I2C, or UART (HSU) — bit rate and GPIOs set in menuconfig. |
| Continuous scan | Background task polls passive targets; ~65 ms when not in deep mode, ~220 ms when deep capture is on (more time per tag for heavy work). Toggle via POST /api/nfc/scan or the Dashboard. |
| RF sensitivity | On init, high passive-activation retries (RFConfiguration 0x05) so marginal tags get more chances to answer. |
| Deep profile | Per new UID: Classic sector layout detection (1K / 4K), per-sector auth with built-in default keys, block hex arrays; Ultralight/NTAG page reads up to firmware limit; structured JSON for export. |
| Session RAM | ~48 KB NDJSON capture buffer; mutex-protected; full flag stops polling until you clear. |
| HTTP API | JSON in/out on documented routes; CORS headers on API responses; OPTIONS /* preflight for cross-origin clients. POST bodies are read completely (chunked TCP-safe). |
| WebSocket | /ws: JSON envelopes {"channel":"…","payload":…} — scan (inventory / present:false when tag leaves), capture (recorded, bufferFull, etc.), pong for keepalive. |
| OTA endpoint | POST /api/ota now performs a real HTTPS OTA with the ESP-IDF certificate bundle; successful updates reboot into the new slot and rollback support is enabled. |
| Onboard RGB (DevKit-style) | Optional boot-time shutdown of a WS2812/SK6812 on GPIO 48 (Kconfig) so the addressable LED isn’t left on a random color — not on all boards; power LEDs are hardware. |
| Status | /api/status: uptime, free heap, Wi‑Fi mode, PN532 firmware version (when reachable), scan flag, targetActive, and session stats. |
| Probe API | POST /api/nfc/probe builds a richer capability profile for the present tag, including structured-clone suitability and Type 2 GET_VERSION when supported. |
| Structured clone flow | POST /api/clone/capture normalizes MIFARE Classic and Type 2 tags into JSON snapshots; POST /api/clone/program writes supported snapshots back to a destination tag with safe defaults. |
| Target mode | POST /api/nfc/target/* provides status, start, recv, send, and stop around TgInitAsTarget / TgGetData / TgSetData, with a raw-parameter escape hatch when you need exact bytes. |
Web UI (each page)
| Page | Purpose |
|---|---|
| Dash | Live status poll, continuous scan on/off, manual poll (uses live tag from WebSocket when present), hero readout. |
| Read-all (Capture) | Toggle deep capture, export NDJSON from device RAM, clear buffer, progress vs 48 KB budget. |
| Read | MIFARE block read with key + Key A/B; Ultralight page read; paste a sector trailer to decode access bits in the browser. |
| Write | MIFARE block write (16-byte hex, confirm dialog); Ul page write; lab fixture loader for offline testing flows; ties into live UID from WebSocket when available. |
| Brute | Classic 1K / 4K dictionary attack: firmware’s built-in key set + your lines, optional bounded variations; shows JSON result. |
| Emu | TgInitAsTarget and friends via POST /api/nfc/emulate-raw — you supply hex; experimental (bad sequences can require power cycle). |
| KeyLab | XOR span across up to three 6-byte base keys (all combinations → candidates for Brute/Keys); Shannon entropy on arbitrary hex blobs. Stage for Keys copies candidates into sessionStorage for the Keys page. |
| Lib | Saved hex snippets in localStorage; seed from built-in lab catalog samples; fixture loader for repeatable demos. |
| Keys | Browser-local key dictionary (defaults + your lines); merge lab keys; import Key Lab stash from staged session. Used when you copy keys into Brute manually — not auto-uploaded to flash. |
| Raw | Hex command payload → /api/raw/pn532; general status dump from /api/pn532/general-status; local TX/RX log. |
| Set | apiBase (empty = same host), theme (light/dark), reminder of SoftAP + mDNS. |
Browser extras (no extra server)
- Top bar — Download log JSON: last 5 000 WebSocket messages from sessionStorage (scan + capture + anything else the firmware sends).
- Scan feedback — Visual/audio “hit” flourishes on new tag / capture events (debounced so it doesn’t spam).
- Validation fixtures — Shared lab blobs and notes (
validationFixtures.ts) for UI testing and teaching.
Remote deep capture (how to use it)
- Power on → firmware starts continuous scan by default (toggle on the Dashboard).
- Open Read-all (Capture) → enable passive read-all (
POST /api/session/deep). - Present tags; each new UID appends one NDJSON line: full deep profile JSON.
- When the progress bar hits the end, scanning pauses automatically.
- Tap Download NDJSON — browser saves
pn532-deep-capture-*.ndjson. - Tap Clear buffer to free RAM and resume.
Session API (automation)
| Method | Path | Purpose |
|---|---|---|
GET |
/api/status |
App name, uptime, heap, Wi‑Fi mode, PN532 version, scanning, session (usedBytes, maxBytes, lines, full, deepCapture) |
POST |
/api/session/deep |
Body {"enable": true} or false |
GET |
/api/session/export |
text/plain / NDJSON attachment |
POST |
/api/session/clear |
Clears RAM buffer |
Live NFC API
| Method | Path | Purpose |
|---|---|---|
POST |
/api/nfc/poll |
One-shot poll; body may be {} — returns present, optional tag |
POST |
/api/nfc/probe |
Rich capability probe for the current tag; adds clone/program hints and Type 2 GET_VERSION when available |
POST |
/api/nfc/scan |
Body {"enable": true} or false — continuous background scan |
GET |
/api/pn532/general-status |
Raw PN532 general status byte array |
POST |
/api/mifare/read-block |
block, key (12 hex), keyB |
POST |
/api/mifare/write-block |
block, key, keyB, data (32 hex) |
POST |
/api/ul/read-page |
page |
POST |
/api/ul/write-page |
page, data (8 hex) |
POST |
/api/raw/pn532 |
frame — PN532 command bytes (not a full transport frame) |
POST |
/api/mifare/dictionary-attack |
readerType (classic1k / classic4k), variations, optional keysHex[], optional sectorFirst / sectorLast |
POST |
/api/clone/capture |
Optional mode (auto / classic / type2), optional keysHex[], variations, maxPages — returns a structured snapshot for supported tags |
POST |
/api/clone/program |
snapshot plus optional includeTrailers / includeLockPages — programs a supported destination tag with safe skips by default |
POST |
/api/nfc/target/status |
Returns whether PN532 target mode is active |
POST |
/api/nfc/target/start |
Start target mode from semantic JSON fields or rawParamsHex |
POST |
/api/nfc/target/recv |
Wait for initiator bytes and return status + payload hex |
POST |
/api/nfc/target/send |
Send target response bytes back to the initiator |
POST |
/api/nfc/target/stop |
Return PN532 to normal SAM mode |
POST |
/api/nfc/emulate-raw |
hex — raw command bytes (e.g. 0x8C sequences) |
POST |
/api/ota |
url (HTTPS), optional reboot — performs a real OTA update via esp_https_ota |
WebSocket: GET /ws — JSON text frames as above; send ping, receive pong.
Browser memory log
The SPA keeps the last 5 000 WebSocket events in sessionStorage. Download log JSON saves a pretty file on your phone or PC without using device RAM. Clear that log separately from the device capture buffer.
Dictionary “brute” (Classic)
Brute page → Classic 1K / 4K, optional variations, paste extra keys. Firmware runs POST /api/mifare/dictionary-attack: built-in public default keys (same family as community lists like Proxmark3 mfc_default_keys.dic) plus your lines, Key A then B per sector trailer. Variations add bounded XOR / nibble-style tweaks — not a full 2⁴⁸ search. You won’t magically open every sector; you systematically try keys people actually leak in the wild.
Card emulation (PN532 target mode)
Emulate now has two layers: a higher-level /api/nfc/target/* subsystem for TgInitAsTarget / TgGetData / TgSetData, plus POST /api/nfc/emulate-raw when you want to drive the exact byte stream yourself. The structured path is safer; raw mode is still experimental and bad sequences can wedge the front end until re-init or power cycle.
Build
One-command local run
./start-firmware.sh
What it does (see script for flags): bootstraps ESP-IDF under a local prefix if needed, builds the web UI into firmware/data, targets esp32s3, auto-detects serial when possible, builds and flashes.
Options include --build-only, --monitor, --port, etc.
Web UI → SPIFFS image
cd web
npm install
npm run build:fw
Firmware (ESP-IDF 5.x)
cd firmware
idf.py set-target esp32s3
idf.py menuconfig # PN532 Host: SPI / I2C / UART + pins; Board indicators (RGB) if you use DevKitC-1
idf.py build flash monitor
Common red PN532 module notes
The common red Elechouse-style PN532 board usually selects its bus with two onboard switches: HSU = OFF/OFF, I2C = ON/OFF, SPI = OFF/ON. HSU and I2C usually share header pins, and many boards ship in HSU by default, so a “dead” SPI/I2C setup is often just a switch mismatch rather than bad firmware.
From firmware/, with IDF already in your environment, you can also ./flash.sh (uses ESPPORT or the first argument as the serial device).
See docs/FLASHING.md and docs/PINOUT.md. Limitations and scope notes: docs/LIMITATIONS.md.
Repo layout
| Path | Role |
|---|---|
firmware/ |
ESP-IDF: PN532 transport, NFC engine, session buffer, deep profile, HTTP/WebSocket, SPIFFS |
web/ |
React UI: Dashboard, Capture, Read, Write, Brute, Emulate, KeyLab, Library, Keys, Raw, Settings |
docs/ |
Flashing, pinout, workflows, limitations |
start-firmware.sh |
One-shot IDF bootstrap + build + flash |