Compare commits

...

3 Commits

Author SHA1 Message Date
drjones
2da1515f8d 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
2026-04-09 15:49:18 -07:00
drjones
63db10d400 fw: robust HTTP body reads, CORS preflight, LED off at boot; update docs
- app_net.c: replace bare httpd_req_recv with recv_body_capped/alloc
  helpers (TCP-safe, full-body reads); add OPTIONS/* CORS preflight
  handler; bump WS broadcast buffer to 2048; add CORS Allow-Methods
- CMakeLists (net_service): add http_parser dep for HTTP_OPTIONS
- nfc_engine/pn532_core: add nfc_access_lock/unlock mutex, board-RGB
  quiet helper, UL type detection, general-status improvements
- pn532_transport: minor cleanup
- main.c: call board_rgb_led_quiet() at boot to kill onboard LED
- sdkconfig.defaults: add board RGB Kconfig defaults
- README, docs/LIMITATIONS, docs/PINOUT: expand and correct

Made-with: Cursor
2026-04-07 21:23:04 -07:00
drjones
ad814c6a12 docs: expand README with full feature catalog and API tables
Include firmware/.cache/clangd index artifacts from the staged set.

Made-with: Cursor
2026-04-07 19:06:06 -07:00
85 changed files with 1386 additions and 129 deletions

167
README.md
View File

@@ -7,61 +7,123 @@ ESP32-S3 + PN532 + a serious web UI — no desktop app, no dongle software, no m
## Why this is the best tool for PN532 workflows
1. **Full remote control in the browser** — Dashboard, live scan, deep capture, read/write helpers, raw PN532 frames, and status — all over HTTP + WebSocket. You can stand across the room with your phone while the hardware sits on the bench.
1. **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.
2. **“Deep capture” is actually deep (for a PN532)** — On each new tag we dont stop at UID: we pull **PN532 general status bytes**, **full inventory (ATQA/SAK/UID)**, then either a **MIFARE Classic sector sweep** (default key set, Key A and Key B per sector trailer) with **every readable block as hex**, or an **Ultralight/NTAG-style page sweep** until the tag stops responding. Unknown SAKs still get inventory + controller status so nothing is silently dropped.
2. **“Deep capture” is actually deep (for a PN532)** — On each **new UID** the firmware doesnt 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.
3. **RAM session buffer with hard stop + phone download** — Captures are stored in **on-chip RAM** (default **48 KB** of NDJSON lines). When the buffer is full, **RF polling pauses** so you never lose data to silent overflow. You tap **Download NDJSON** on your phone, get a single file with every profile, then **Clear** to resume. That workflow is purpose-built for field audits and clone/research sessions.
3. **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.
4. **Self-hosted on the device** — Default **open SoftAP** SSID `PN532-Toolkit` (no password, lab default), **mDNS** `pn532tool.local`. No cloud, no account, no telemetry.
4. **Self-hosted on the device** — Default **SoftAP**: SSID **`PN532-Toolkit`**, **open network** (no password, lab default). **mDNS** hostname **`pn532tool.local`** (HTTP port **80**). No cloud, no account, no telemetry.
5. **Honest architecture** — ESP-IDF, explicit components (`pn532_host`, `nfc_engine`, `net_service`), embedded Vite/React UI flashed to SPIFFS. You can extend it like real firmware, not a black-box sketch.
5. **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.
6. **Raw frame escape hatch** — When the high-level UI isnt enough, hit **Raw** and send PN532 command bytes (frame wrapper handled in firmware). Thats how you stay aligned with the real chip, not a toy abstraction.
6. **Raw PN532 path** — When the high-level UI isnt enough, **Raw** sends **command bytes**; the stack handles transport framing. **`GET /api/pn532/general-status`** exposes the controllers **general status bytes** for debugging. Thats how you stay aligned with the real chip, not a toy abstraction.
This stack is **not** a Proxmark replacement (no LF, no raw carrier manipulation). For **hosted NFC with PN532**, its built to be the **most complete pocket operator**: remote UI, deep reads, session export, and a path to grow.
7. **UI that matches the ambition** — Live **WebSocket** stream (scan + capture), **browser-side log** (thousands of events, exportable JSON), optional **`apiBase`** for talking to the box from another host, **HashRouter** so refreshes dont 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**, its 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 isnt left on a random color — **not** on all boards; power LEDs are hardware. |
| **Status** | **`/api/status`**: uptime, free heap, WiFi 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: firmwares 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 **5000** 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 doesnt spam).
- **Validation fixtures** — Shared **lab** blobs and notes (`validationFixtures.ts`) for UI testing and teaching.
---
## Remote deep capture (how to use it)
1. **Power on** → firmware starts **continuous scan** automatically (you can still toggle it on the Dashboard).
2. **Read-all** (Capture page) → enable **passive read-all** (`POST /api/session/deep`) when you want automatic full dumps per new tag into device RAM.
1. **Power on** → firmware starts **continuous scan** by default (toggle on the Dashboard).
2. Open **Read-all (Capture)** → enable **passive read-all** (`POST /api/session/deep`).
3. Present tags; each **new UID** appends one **NDJSON** line: full deep profile JSON.
4. When the **progress bar hits the end**, scanning **pauses** automatically.
5. Tap **Download NDJSON** on your phone (or laptop) — browser saves `pn532-deep-capture-*.ndjson`.
6. Tap **Clear buffer** to free RAM and **resume** scanning.
5. Tap **Download NDJSON** — browser saves `pn532-deep-capture-*.ndjson`.
6. Tap **Clear buffer** to free RAM and **resume**.
API (for automation):
### Session API (automation)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/status` | Includes `session.usedBytes`, `maxBytes`, `lines`, `full`, `deepCapture` |
| `POST` | `/api/session/deep` | Body `{"enable":true}` — toggle deep capture |
| `GET` | `/api/session/export` | Attachment: all captured lines |
| `POST` | `/api/session/clear` | Wipe buffer, clear `full` |
| `GET` | `/api/status` | App name, uptime, heap, WiFi 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 |
WebSocket `/ws`: channels `scan` (inventory) and `capture` (recorded / bufferFull events).
### Live NFC API
### Browser memory log (nothing missed in the UI)
| 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` |
The SPA keeps the **last 5000** WebSocket events in **sessionStorage** (scan + capture + anything else the firmware pushes). Use the top bar **Download log JSON** to save a pretty file on your phone or PC without touching device RAM. Clear the log separately from the device capture buffer.
**WebSocket:** `GET /ws` — JSON text frames as above; send **`ping`**, receive **`pong`**.
### “Maximum sensitivity” (firmware)
### Browser memory log
On init the PN532 is configured for **high passive-activation retries** (`RFConfiguration` 0x05), and the poll loop runs at **~65ms** when not doing deep capture — weak coupling / marginal tags get more chances to answer.
The SPA keeps the **last 5000** 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 → pick **Classic 1K / 4K map**, optional **variations**, paste extra keys. Firmware runs **`POST /api/mifare/dictionary-attack`**: built-in **public default keys** (subset of community lists such as the [Proxmark3 mfc_default_keys.dic](https://github.com/RfidResearchGroup/proxmark3/blob/master/client/dictionaries/mfc_default_keys.dic)) plus your lines, Key **A** then **B** per sector trailer. **Variations** add bounded XOR / nibble tweaks — **not** a full 2⁴⁸ keyspace search. You will **not** magically open every door; you will systematically try keys people actually leak online. ### Emulation
**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`](https://github.com/RfidResearchGroup/proxmark3/blob/master/client/dictionaries/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 wont magically open every sector; you **systematically** try keys people actually leak in the wild.
**Emulate** sends raw **`TgInitAsTarget` (0x8C)** payloads via **`POST /api/nfc/emulate-raw`**. You are responsible for correct bytes (NXP UM0701). This is **experimental**; bad sequences can require a power cycle.
### Card emulation (PN532 target mode)
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/mifare/dictionary-attack` | Body: `readerType`, `variations`, optional `keysHex[]` |
| `POST` | `/api/nfc/emulate-raw` | Body: `{ "hex": "8C..." }` |
| `POST` | `/api/ul/write-page` | Body: `{ "page": 4, "data": "AABBCCDD" }` (8 hex) |
**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.
---
@@ -73,21 +135,11 @@ On init the PN532 is configured for **high passive-activation retries** (`RFConf
./start-firmware.sh
```
What it does:
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**.
- bootstraps ESP-IDF locally if needed
- builds the web UI into `firmware/data`
- configures target `esp32s3`
- auto-detects the serial port
- builds and flashes the device
Options include **`--build-only`**, **`--monitor`**, **`--port`**, etc.
Useful options:
- `./start-firmware.sh --build-only`
- `./start-firmware.sh --monitor`
- `./start-firmware.sh --port /dev/cu.usbmodemXXXX`
### Web UI → flash image
### Web UI → SPIFFS image
```bash
cd web
@@ -100,35 +152,17 @@ npm run build:fw
```bash
cd firmware
idf.py set-target esp32s3
idf.py menuconfig # PN532 Host: SPI / I2C / UART + pins
idf.py menuconfig # PN532 Host: SPI / I2C / UART + pins; Board indicators (RGB) if you use DevKitC-1
idf.py build flash monitor
```
See [docs/FLASHING.md](docs/FLASHING.md) and [docs/PINOUT.md](docs/PINOUT.md).
### 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.
## 9 meaningful upgrades we dont have yet (roadmap)
From `firmware/`, with IDF already in your environment, you can also **`./flash.sh`** (uses **`ESPPORT`** or the first argument as the serial device).
1. **FeliCa / Type B surfaces** — More first-class UI for nonType A paths the PN532 can speak.
2. **Configurable RAM budget + optional PSRAM** — Compile-time or NVS `maxBytes`, and external SPIRAM for **multihundredKB** sessions on N8R8 modules.
3. **Chunked / resumable export** — HTTP range or multipart export so **multiMB** captures dont require one giant `httpd_resp_send`.
4. **User-supplied key dictionary on device** — Upload common keys file to flash and run **automatic sector retries** without typing keys in the UI.
5. **NDEF record editor** — Parse TLV/NDEF in the browser, edit records, write back through page/block APIs with lock-byte warnings.
6. **ISO14443-4 / Type B automation** — Higher-layer APDU helpers where PN532 allows; clearer UI for “Type A only” vs “RATS/PPS” paths.
7. **Wi-Fi STA onboarding** — Captive portal or dedicated SSID scan + save credentials to NVS (today defaults to AP).
8. **Real OTA from the UI** — Replace `501` stub with signed `esp_https_ota` + rollback partition sanity checks.
9. **Installable PWA + richer diagnostics**`manifest.json`, icons, `theme-color`, and UI for **error histograms / timing** from the PN532 status path.
10. **Magic UID / Gen2** — Not exposed; PN532 is a legitimate reader/writer, not a UID-spoofing modem.
See [docs/FLASHING.md](docs/FLASHING.md) and [docs/PINOUT.md](docs/PINOUT.md). Limitations and scope notes: [docs/LIMITATIONS.md](docs/LIMITATIONS.md).
---
@@ -136,6 +170,7 @@ See [docs/FLASHING.md](docs/FLASHING.md) and [docs/PINOUT.md](docs/PINOUT.md).
| Path | Role |
|------|------|
| `firmware/` | ESP-IDF: PN532 transport, NFC engine, **session RAM buffer**, **deep profile**, HTTP/WS |
| `web/` | React UI: Dashboard, **Capture**, Read/Write, Library, Keys, Raw, Settings |
| `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 |

View File

@@ -3,7 +3,7 @@
The PN532 is a **hosted NFC controller**, not a low-level RF lab instrument. This project exposes PN532 features honestly:
- **ISO14443-B**: reader support exists with **chip-level caveats** (anticollision / stack-dependent behavior). No promises of full mobile/PICC coverage.
- **Card emulation / TG modes**: PN532 firmware supports target commands; real-world mimicry depends on timing, UID size, and reader expectations — expose experimentally, not as “propable MIFARE magic.”
- **Card emulation / TG modes**: PN532 firmware supports target commands and this repo now exposes a structured target-mode API, but real-world mimicry still depends on timing, UID size, ATS/general bytes, and reader expectations. Treat it as a controllable subsystem, not magic full-card impersonation.
- **Key recovery**: on-device “brute force” at Proxmark scale is **infeasible**. The UI provides **dictionary / manual** key workflows.
- **Signal / RF metrics**: diagnostics use **PN532 status / timings / retries**, not calibrated dBm.
- **OTA via UI**: `POST /api/ota` is a **stub** (`501`) — ship OTA with `esp_https_ota` + signed images when you need production updates.
- **OTA via UI**: `POST /api/ota` now performs real HTTPS OTA with the ESP-IDF certificate bundle. For production fleets, add your own release signing, manifest control, and hardware validation gates before broad rollout.

View File

@@ -20,3 +20,13 @@ The firmware Kconfig ships **example** GPIOs:
## Transport selection
Start with the bus your breakout is jumpered for — **I2C is often simplest** on ESP32 for bring-up; SPI may require **lower clock** initially (e.g. **100 kHz**).
## Common red module switch matrix
For the common Elechouse-style red PN532 board, the onboard two-position switch matrix is typically:
- **HSU**: `OFF/OFF`
- **I2C**: `ON/OFF`
- **SPI**: `OFF/ON`
Many of these boards boot in **HSU** by default, and the **I2C** and **HSU** labels often refer to the same physical header pins from opposite sides of the PCB. If the bus looks dead, check the switches before changing firmware.

View File

@@ -2,5 +2,5 @@ idf_component_register(
SRCS "app_net.c"
INCLUDE_DIRS "include"
REQUIRES esp_http_server http_parser esp_wifi esp_netif nvs_flash mdns esp_timer
json spiffs vfs freertos nfc_engine pn532_host
json spiffs vfs freertos nfc_engine pn532_host esp_https_ota app_update mbedtls
)

File diff suppressed because it is too large Load Diff

View File

@@ -26,12 +26,26 @@ typedef struct {
bool key_b;
} 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);
/** 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);
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_type2(const nfc_tag_info_t *tag);
int nfc_mifare_sector_count(const nfc_tag_info_t *tag);
bool nfc_mifare_sector_layout(const nfc_tag_info_t *tag, int sector, int *first_block, int *num_blocks,
uint8_t *trailer_block);
esp_err_t nfc_mifare_authenticate_block(const nfc_tag_info_t *tag, uint8_t block_no,
const nfc_mifare_key_t *key);
@@ -42,6 +56,7 @@ esp_err_t nfc_ultralight_read_page(uint8_t page, uint8_t data[4]);
esp_err_t nfc_ultralight_write_page(uint8_t page, const uint8_t data[4]);
esp_err_t nfc_ul_fast_read(uint8_t start_page, uint8_t *out, size_t out_max, size_t *got);
esp_err_t nfc_type2_get_version(uint8_t version[8]);
/** Build JSON snapshot of last seen tag + optional blocks (caller frees cJSON). */
cJSON *nfc_tag_to_json(const nfc_tag_info_t *tag);

View File

@@ -170,6 +170,7 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
attempts++;
if (try_key_on_trailer(tag, trailer, trial, false)) {
if (!add_sector_hit(hits, sec, trial, "A")) {
cJSON_Delete(hits);
cJSON_Delete(root);
return NULL;
}
@@ -178,6 +179,7 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
}
if (try_key_on_trailer(tag, trailer, trial, true)) {
if (!add_sector_hit(hits, sec, trial, "B")) {
cJSON_Delete(hits);
cJSON_Delete(root);
return NULL;
}
@@ -202,6 +204,7 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
attempts++;
if (try_key_on_trailer(tag, trailer, trial, false)) {
if (!add_sector_hit(hits, sec, trial, "A")) {
cJSON_Delete(hits);
cJSON_Delete(root);
return NULL;
}
@@ -210,6 +213,7 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
}
if (try_key_on_trailer(tag, trailer, trial, true)) {
if (!add_sector_hit(hits, sec, trial, "B")) {
cJSON_Delete(hits);
cJSON_Delete(root);
return NULL;
}
@@ -226,6 +230,7 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
if (!got) {
cJSON *h = cJSON_CreateObject();
if (!h) {
cJSON_Delete(hits);
cJSON_Delete(root);
return NULL;
}

View File

@@ -8,6 +8,9 @@
static const char *TAG = "nfc_engine";
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)
{
@@ -18,7 +21,12 @@ static void hint_type(nfc_tag_info_t *t)
t->type_hint = 1;
break;
case 0x00:
t->type_hint = 2;
/* Typical Type 2 inventory tuple is ATQA 0x0044 and 7-byte UID. */
if ((t->atqa == 0x4400 || t->atqa == 0x0044) && t->uid_len == 7) {
t->type_hint = 2;
} else {
t->type_hint = 0;
}
break;
default:
t->type_hint = 0;
@@ -41,6 +49,45 @@ bool nfc_tag_is_type2(const nfc_tag_info_t *tag)
return tag && tag->type_hint == 2;
}
int nfc_mifare_sector_count(const nfc_tag_info_t *tag)
{
if (!nfc_tag_is_mifare_classic(tag)) {
return 0;
}
return nfc_tag_is_mifare_classic_4k(tag) ? 40 : 16;
}
bool nfc_mifare_sector_layout(const nfc_tag_info_t *tag, int sector, int *first_block, int *num_blocks,
uint8_t *trailer_block)
{
if (!nfc_tag_is_mifare_classic(tag) || !first_block || !num_blocks || !trailer_block) {
return false;
}
if (!nfc_tag_is_mifare_classic_4k(tag)) {
if (sector < 0 || sector > 15) {
return false;
}
*first_block = sector * 4;
*num_blocks = 4;
*trailer_block = (uint8_t)(sector * 4 + 3);
return true;
}
if (sector < 0 || sector > 39) {
return false;
}
if (sector <= 31) {
*first_block = sector * 4;
*num_blocks = 4;
*trailer_block = (uint8_t)(sector * 4 + 3);
} else {
int r = sector - 32;
*first_block = 128 + r * 16;
*num_blocks = 16;
*trailer_block = (uint8_t)(128 + r * 16 + 15);
}
return true;
}
esp_err_t nfc_engine_init(void)
{
esp_err_t e = pn532_core_init();
@@ -58,9 +105,51 @@ esp_err_t nfc_engine_init(void)
if (pn532_rf_max_retries() != ESP_OK) {
ESP_LOGW(TAG, "RF max retries config failed");
}
s_pn532_ready = true;
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)
{
if (!out) {
@@ -248,6 +337,25 @@ esp_err_t nfc_ul_fast_read(uint8_t start_page, uint8_t *out, size_t out_max, siz
return ESP_OK;
}
esp_err_t nfc_type2_get_version(uint8_t version[8])
{
if (!version) {
return ESP_ERR_INVALID_ARG;
}
uint8_t d[] = {0x60};
uint8_t resp[32];
size_t rlen = 0;
esp_err_t e = in_data_tg(d, sizeof(d), resp, sizeof(resp), &rlen);
if (e != ESP_OK) {
return e;
}
if (rlen < 2 + 8 || resp[0] != (uint8_t)(PN532_CMD_INDATAEXCHANGE + 1) || resp[1] != 0x00) {
return ESP_ERR_INVALID_RESPONSE;
}
memcpy(version, resp + 2, 8);
return ESP_OK;
}
cJSON *nfc_tag_to_json(const nfc_tag_info_t *tag)
{
if (!tag || tag->uid_len > NFC_MAX_UID_LEN) {

View File

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

View File

@@ -57,6 +57,20 @@ esp_err_t pn532_in_communicate_thru(const uint8_t *data, size_t data_len,
uint8_t *response, size_t response_max,
size_t *response_len);
/** Enter PN532 target mode using TgInitAsTarget parameters (mode + target descriptors + optional GT). */
esp_err_t pn532_tg_init_as_target(const uint8_t *params, size_t params_len,
uint8_t *response, size_t response_max,
size_t *response_len, int timeout_ms);
/** Receive bytes from the initiator while PN532 is in target mode. */
esp_err_t pn532_tg_get_data(uint8_t *response, size_t response_max,
size_t *response_len, int timeout_ms);
/** Send bytes back to the initiator while PN532 is in target mode. */
esp_err_t pn532_tg_set_data(const uint8_t *data, size_t data_len,
uint8_t *response, size_t response_max,
size_t *response_len, int timeout_ms);
/** RF field on/off via RFConfiguration (0x32) item 0x01, RF field */
esp_err_t pn532_rf_field(bool on);

View File

@@ -190,3 +190,81 @@ esp_err_t pn532_in_communicate_thru(const uint8_t *data, size_t data_len, uint8_
*response_len = payload_len;
return ESP_OK;
}
esp_err_t pn532_tg_init_as_target(const uint8_t *params, size_t params_len, uint8_t *response,
size_t response_max, size_t *response_len, int timeout_ms)
{
if (!params || !response || !response_len || params_len == 0 || params_len > PN532_EEPROM_MAX_CMD_PAYLOAD - 1) {
return ESP_ERR_INVALID_ARG;
}
uint8_t buf[PN532_EEPROM_MAX_CMD_PAYLOAD];
buf[0] = PN532_CMD_TGINITASTARGET;
memcpy(buf + 1, params, params_len);
uint8_t raw[PN532_EEPROM_MAX_CMD_PAYLOAD];
size_t raw_len = 0;
esp_err_t e = pn532_send_cmd(buf, 1 + params_len, raw, sizeof(raw), &raw_len, timeout_ms);
if (e != ESP_OK) {
return e;
}
if (raw_len < 2 || raw[0] != (uint8_t)(PN532_CMD_TGINITASTARGET + 1)) {
return ESP_ERR_INVALID_RESPONSE;
}
size_t payload_len = raw_len - 1;
if (payload_len > response_max) {
return ESP_ERR_INVALID_SIZE;
}
memcpy(response, raw + 1, payload_len);
*response_len = payload_len;
return ESP_OK;
}
esp_err_t pn532_tg_get_data(uint8_t *response, size_t response_max, size_t *response_len, int timeout_ms)
{
if (!response || !response_len) {
return ESP_ERR_INVALID_ARG;
}
uint8_t cmd = PN532_CMD_TGGETDATA;
uint8_t raw[PN532_EEPROM_MAX_CMD_PAYLOAD];
size_t raw_len = 0;
esp_err_t e = pn532_send_cmd(&cmd, 1, raw, sizeof(raw), &raw_len, timeout_ms);
if (e != ESP_OK) {
return e;
}
if (raw_len < 2 || raw[0] != (uint8_t)(PN532_CMD_TGGETDATA + 1)) {
return ESP_ERR_INVALID_RESPONSE;
}
size_t payload_len = raw_len - 1;
if (payload_len > response_max) {
return ESP_ERR_INVALID_SIZE;
}
memcpy(response, raw + 1, payload_len);
*response_len = payload_len;
return ESP_OK;
}
esp_err_t pn532_tg_set_data(const uint8_t *data, size_t data_len, uint8_t *response, size_t response_max,
size_t *response_len, int timeout_ms)
{
if (!data || !response || !response_len || data_len == 0 || data_len > PN532_EEPROM_MAX_CMD_PAYLOAD - 1) {
return ESP_ERR_INVALID_ARG;
}
uint8_t buf[PN532_EEPROM_MAX_CMD_PAYLOAD];
buf[0] = PN532_CMD_TGSETDATA;
memcpy(buf + 1, data, data_len);
uint8_t raw[PN532_EEPROM_MAX_CMD_PAYLOAD];
size_t raw_len = 0;
esp_err_t e = pn532_send_cmd(buf, 1 + data_len, raw, sizeof(raw), &raw_len, timeout_ms);
if (e != ESP_OK) {
return e;
}
if (raw_len < 2 || raw[0] != (uint8_t)(PN532_CMD_TGSETDATA + 1)) {
return ESP_ERR_INVALID_RESPONSE;
}
size_t payload_len = raw_len - 1;
if (payload_len > response_max) {
return ESP_ERR_INVALID_SIZE;
}
memcpy(response, raw + 1, payload_len);
*response_len = payload_len;
return ESP_OK;
}

View File

@@ -352,7 +352,7 @@ esp_err_t pn532_transport_exchange(const uint8_t *tx_body, size_t tx_body_len, u
#if defined(CONFIG_PN532_TRANSPORT_SPI)
ESP_RETURN_ON_ERROR(spi_write_frame(frame, pos), TAG, "spi wr");
#elif defined(CONFIG_PN532_TRANSPORT_I2C)
i2c_wakeup();
ESP_RETURN_ON_ERROR(i2c_wakeup(), TAG, "i2c wake");
ESP_RETURN_ON_ERROR(i2c_write_raw(frame, pos), TAG, "i2c wr");
#elif defined(CONFIG_PN532_TRANSPORT_HSU)
ESP_RETURN_ON_ERROR(hsu_write_raw(frame, pos), TAG, "hsu wr");

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"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/index-9oJp152C.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hoMg1Qkq.css">
<script type="module" crossorigin src="/assets/index-BmIGATlK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bb0xTRCn.css">
</head>
<body class="bg-bubble-950 text-slate-200 antialiased selection:bg-bubble-accent/40 selection:text-bubble-950">
<div id="root"></div>

View File

@@ -1,4 +1,5 @@
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "board_rgb_off.h"
#include "nfc_engine/nfc_engine.h"
#include "nfc_engine/session_capture.h"
@@ -8,10 +9,15 @@ static const char *TAG = "main";
void app_main(void)
{
(void)esp_ota_mark_app_valid_cancel_rollback();
board_rgb_led_quiet();
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();
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

@@ -31,3 +31,6 @@ CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE=y
# mDNS
CONFIG_MDNS_MAX_SERVICES=10
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y

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);
}, []);