Add PN532 toolkit firmware, web UI, and embedded SPIFFS assets
Includes ESP-IDF NFC stack (deep capture, 4K Classic geometry, UL write API, open SoftAP), React dashboard with live tag diagnostics, and docs. README updated for APIs and lab Wi-Fi defaults. Made-with: Cursor
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
firmware/build/
|
||||
firmware/sdkconfig
|
||||
firmware/sdkconfig.old
|
||||
firmware/managed_components/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
.DS_Store
|
||||
123
README.md
123
README.md
@@ -1,16 +1,73 @@
|
||||
# PN532 NFC Toolkit (ESP32-S3)
|
||||
# PN532 NFC Toolkit
|
||||
|
||||
Production-style firmware and embedded web UI for **PN532** NFC controllers attached to **ESP32-S3** (and other ESP32-class targets with minor config tweaks).
|
||||
**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**.
|
||||
|
||||
## Features
|
||||
---
|
||||
|
||||
- **Wi-Fi AP** default: SSID `PN532-Toolkit`, password `nfc-toolkit`, URL [http://192.168.4.1](http://192.168.4.1)
|
||||
- **mDNS** hostname `pn532tool.local` (HTTP port 80)
|
||||
- **REST API**: poll tags, MIFARE Classic read/write, Ultralight page read, PN532 general status, raw command injection, OTA stub (`501` — extend with `esp_https_ota`)
|
||||
- **WebSocket** `/ws`: continuous tag presence stream when enabled from the UI
|
||||
- **Web UI** (Vite + React + Tailwind + Framer Motion): dashboard, read/analyze, write helpers, local tag library, key dictionary, raw console, settings
|
||||
## Why this is the best tool for PN532 workflows
|
||||
|
||||
## Build — Web UI
|
||||
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.
|
||||
|
||||
2. **“Deep capture” is actually deep (for a PN532)** — On each new tag we don’t 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
6. **Raw frame escape hatch** — When the high-level UI isn’t enough, hit **Raw** and send PN532 command bytes (frame wrapper handled in firmware). That’s 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**, it’s built to be the **most complete pocket operator**: remote UI, deep reads, session export, and a path to grow.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
API (for 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` |
|
||||
|
||||
WebSocket `/ws`: channels `scan` (inventory) and `capture` (recorded / bufferFull events).
|
||||
|
||||
### Browser memory log (nothing missed in the UI)
|
||||
|
||||
The SPA keeps the **last 5 000** 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.
|
||||
|
||||
### “Maximum sensitivity” (firmware)
|
||||
|
||||
On init the PN532 is configured for **high passive-activation retries** (`RFConfiguration` 0x05), and the poll loop runs at **~65 ms** when not doing deep capture — weak coupling / marginal tags get more chances to answer.
|
||||
|
||||
### 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
|
||||
|
||||
**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.
|
||||
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
### Web UI → flash image
|
||||
|
||||
```bash
|
||||
cd web
|
||||
@@ -18,37 +75,47 @@ npm install
|
||||
npm run build:fw
|
||||
```
|
||||
|
||||
This compiles the SPA and copies `dist/*` into `firmware/data/` for SPIFFS embedding.
|
||||
|
||||
## Build — Firmware (ESP-IDF 5.x)
|
||||
### Firmware (ESP-IDF 5.x)
|
||||
|
||||
```bash
|
||||
cd firmware
|
||||
idf.py set-target esp32s3
|
||||
idf.py menuconfig # PN532 Host → SPI / I2C / UART pins (Kconfig)
|
||||
idf.py menuconfig # PN532 Host: SPI / I2C / UART + pins
|
||||
idf.py build flash monitor
|
||||
```
|
||||
|
||||
Partition table targets a **8MB** flash module (adjust `partitions.csv` + `sdkconfig` for 4MB/16MB).
|
||||
See [docs/FLASHING.md](docs/FLASHING.md) and [docs/PINOUT.md](docs/PINOUT.md).
|
||||
|
||||
## Hardware / Kconfig
|
||||
---
|
||||
|
||||
Open **Component config → PN532 Host** in `menuconfig`:
|
||||
## 9 meaningful upgrades we don’t have yet (roadmap)
|
||||
|
||||
- **SPI** (default): MOSI / MISO / SCLK / CS + clock Hz (start **100 kHz** if unstable)
|
||||
- **I2C**: SDA / SCL, **7-bit address `0x24`** (left-shifted to `0x48` on the wire)
|
||||
- **UART (HSU)**: TX/RX, **115200**
|
||||
1. **FeliCa / Type B surfaces** — More first-class UI for non–Type A paths the PN532 can speak.
|
||||
|
||||
Refer to [docs/PINOUT.md](docs/PINOUT.md) for ESP32-S3-DevKitC-1 wiring notes.
|
||||
2. **Configurable RAM budget + optional PSRAM** — Compile-time or NVS `maxBytes`, and external SPIRAM for **multi‑hundred‑KB** sessions on N8R8 modules.
|
||||
|
||||
## Repository layout
|
||||
3. **Chunked / resumable export** — HTTP range or multipart export so **multi‑MB** captures don’t require one giant `httpd_resp_send`.
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `firmware/` | ESP-IDF project, `components/pn532_host`, `nfc_engine`, `net_service` |
|
||||
| `web/` | SPA source |
|
||||
| `docs/` | Flashing, workflows, limitations |
|
||||
4. **User-supplied key dictionary on device** — Upload common keys file to flash and run **automatic sector retries** without typing keys in the UI.
|
||||
|
||||
## Legal / ethics
|
||||
5. **NDEF record editor** — Parse TLV/NDEF in the browser, edit records, write back through page/block APIs with lock-byte warnings.
|
||||
|
||||
Use only on tags and systems you own or are explicitly authorized to test.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Repo layout
|
||||
|
||||
| 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 |
|
||||
| `docs/` | Flashing, pinout, workflows, limitations |
|
||||
|
||||
34
docs/FLASHING.md
Normal file
34
docs/FLASHING.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Flashing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [ESP-IDF](https://docs.espressif.com/projects/esp-idf/en/latest/esp-idf-en-latest-esp32s3/esp32s3/get-started-esp32s3.html) **v5.x** installed and exported (`get_idf`)
|
||||
- USB cable to the DevKit **USB** port (USB‑JTAG / serial)
|
||||
- PN532 module wired per [PINOUT.md](PINOUT.md)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Build the web assets (embeds into SPIFFS):
|
||||
|
||||
```bash
|
||||
cd web && npm install && npm run build:fw
|
||||
```
|
||||
|
||||
2. Configure & flash:
|
||||
|
||||
```bash
|
||||
cd firmware
|
||||
idf.py set-target esp32s3
|
||||
idf.py menuconfig # set PN532 transport + GPIOs
|
||||
idf.py build flash monitor
|
||||
```
|
||||
|
||||
3. Connect to AP **PN532-Toolkit** / **nfc-toolkit**, browse to **http://192.168.4.1** or **http://pn532tool.local**.
|
||||
|
||||
## SPIFFS / UI missing?
|
||||
|
||||
If `firmware/data/` is empty, the device serves a placeholder HTML. Always run `npm run build:fw` before `idf.py build` if you changed the UI.
|
||||
|
||||
## Flash size
|
||||
|
||||
`partitions.csv` assumes **8MB** flash. For **4MB**, shrink `factory` / OTA / `storage` regions and disable dual OTA if needed.
|
||||
9
docs/LIMITATIONS.md
Normal file
9
docs/LIMITATIONS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Capabilities & limitations
|
||||
|
||||
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.”
|
||||
- **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.
|
||||
22
docs/PINOUT.md
Normal file
22
docs/PINOUT.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Pinout notes (ESP32-S3-DevKitC-1)
|
||||
|
||||
Strapping and USB pins differ by revision — **avoid** GPIO `19–20` for PN532 when using USB-Serial/JTAG on many boards. Prefer **SPI2** on free GPIOs from the [DevKitC-1 user guide](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s3/esp32-s3-devkitc-1/user_guide_v1.1.html).
|
||||
|
||||
## Kconfig defaults (examples only)
|
||||
|
||||
The firmware Kconfig ships **example** GPIOs:
|
||||
|
||||
- **SPI**: MOSI `11`, MISO `13`, SCLK `12`, CS `10`
|
||||
- **I2C**: SDA `8`, SCL `9`
|
||||
|
||||
**You must verify** these against your PCB/breadboard and PN532 breakout (Adafruit / Elechouse / clones often label SPI and I2C jumpers).
|
||||
|
||||
## PN532 wiring checklist
|
||||
|
||||
- **SPI**: connect `RSTO`/`RST` to MCU if exposed; some breakouts auto-reset via I2C/SPI activity.
|
||||
- **I2C**: set address pins per module (usually **0x24** 7‑bit).
|
||||
- **Power**: **3.3V** logic on ESP32; ensure PN532 module is 3.3V compliant (level-shift if using a 5V Arduino-style shield).
|
||||
|
||||
## 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**).
|
||||
28
docs/WORKFLOWS.md
Normal file
28
docs/WORKFLOWS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Example workflows
|
||||
|
||||
## 1. Read a MIFARE Classic sector
|
||||
|
||||
1. Dashboard → **Poll once** to verify presence and UID.
|
||||
2. **Read** tab → enter default key (e.g. `FFFFFFFFFFFF`), block number, **MIFARE read block**.
|
||||
3. Copy hex → save to **Library** (local browser).
|
||||
|
||||
## 2. Write a known-good block
|
||||
|
||||
1. Authenticate with a key that still allows write on that sector.
|
||||
2. **Write** tab → paste **32 hex chars** (16 bytes) → confirm dialog.
|
||||
3. Re-read the block to verify.
|
||||
|
||||
## 3. Ultralight / NTAG pages
|
||||
|
||||
1. Use **Read** → **Ultralight read page** with page index (start at `0` for UID/lock pages per datasheet — exercise caution on OTP/lock bytes).
|
||||
|
||||
## 4. Raw PN532 frames
|
||||
|
||||
1. **Raw** tab → send e.g. `4A0100` (`InListPassiveTarget`, 1 target, 106 kbps Type A).
|
||||
2. Interpret response bytes per NXP **UM0701** / PN532 user manual.
|
||||
|
||||
## 5. Continuous scan to WebSocket consumers
|
||||
|
||||
1. Dashboard → **Start live scan**.
|
||||
2. Connect a WebSocket client to `ws://192.168.4.1/ws` (or `ws://pn532tool.local/ws`).
|
||||
3. Messages look like: `{"channel":"scan","payload":{...}}`
|
||||
5
firmware/CMakeLists.txt
Normal file
5
firmware/CMakeLists.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# PN532 NFC Toolkit — ESP-IDF root
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(pn532_nfc_toolkit)
|
||||
6
firmware/components/net_service/CMakeLists.txt
Normal file
6
firmware/components/net_service/CMakeLists.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
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
|
||||
)
|
||||
908
firmware/components/net_service/app_net.c
Normal file
908
firmware/components/net_service/app_net.c
Normal file
@@ -0,0 +1,908 @@
|
||||
#include "net_service/app_net.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_spiffs.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mdns.h"
|
||||
#include "nfc_engine/nfc_brute.h"
|
||||
#include "nfc_engine/nfc_deep.h"
|
||||
#include "nfc_engine/nfc_engine.h"
|
||||
#include "nfc_engine/session_capture.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "pn532_host/pn532_core.h"
|
||||
#include "cJSON.h"
|
||||
#include "http_parser.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/param.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
static const char *TAG = "app_net";
|
||||
|
||||
/* SoftAP: open network (no password) for lab / fastest join. Change SSID here if you want. */
|
||||
#define SOFTAP_SSID "PN532-Toolkit"
|
||||
|
||||
#define MAX_JSON 4096
|
||||
#define WS_BROADCAST_BUF 4096
|
||||
|
||||
static httpd_handle_t s_server;
|
||||
static bool s_scan = true;
|
||||
static TaskHandle_t s_scan_task;
|
||||
|
||||
static int hexval(char c)
|
||||
{
|
||||
if (c >= '0' && c <= '9') {
|
||||
return c - '0';
|
||||
}
|
||||
if (c >= 'a' && c <= 'f') {
|
||||
return 10 + c - 'a';
|
||||
}
|
||||
if (c >= 'A' && c <= 'F') {
|
||||
return 10 + c - 'A';
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static bool hex_to_bin(const char *hex, uint8_t *out, size_t out_len)
|
||||
{
|
||||
size_t n = strlen(hex);
|
||||
if (n != out_len * 2) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < out_len; i++) {
|
||||
int h = hexval(hex[i * 2]);
|
||||
int l = hexval(hex[i * 2 + 1]);
|
||||
if (h < 0 || l < 0) {
|
||||
return false;
|
||||
}
|
||||
out[i] = (uint8_t)((h << 4) | l);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool hex_decode_flex(const char *hex, uint8_t *out, size_t out_cap, size_t *out_len)
|
||||
{
|
||||
size_t n = strlen(hex);
|
||||
if (n % 2 || n / 2 > out_cap) {
|
||||
return false;
|
||||
}
|
||||
*out_len = n / 2;
|
||||
return hex_to_bin(hex, out, *out_len);
|
||||
}
|
||||
|
||||
/** Read full POST body (httpd may return partial data in multiple recv calls). */
|
||||
static int recv_body_capped(httpd_req_t *req, char *buf, size_t cap)
|
||||
{
|
||||
if (!buf || cap < 2) {
|
||||
return -1;
|
||||
}
|
||||
size_t cl = req->content_len;
|
||||
if (cl >= cap) {
|
||||
return -1;
|
||||
}
|
||||
if (cl == 0) {
|
||||
buf[0] = '\0';
|
||||
return 0;
|
||||
}
|
||||
size_t got = 0;
|
||||
while (got < cl) {
|
||||
int r = httpd_req_recv(req, buf + got, cl - got);
|
||||
if (r <= 0) {
|
||||
return -1;
|
||||
}
|
||||
got += (size_t)r;
|
||||
}
|
||||
buf[got] = '\0';
|
||||
return (int)got;
|
||||
}
|
||||
|
||||
static char *recv_body_alloc(httpd_req_t *req, size_t max_len, int *out_len)
|
||||
{
|
||||
size_t cl = req->content_len;
|
||||
if (cl == 0 || cl > max_len) {
|
||||
return NULL;
|
||||
}
|
||||
char *buf = malloc(cl + 1);
|
||||
if (!buf) {
|
||||
return NULL;
|
||||
}
|
||||
size_t got = 0;
|
||||
while (got < cl) {
|
||||
int r = httpd_req_recv(req, buf + got, cl - got);
|
||||
if (r <= 0) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
got += (size_t)r;
|
||||
}
|
||||
buf[cl] = '\0';
|
||||
if (out_len) {
|
||||
*out_len = (int)cl;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
esp_err_t app_net_broadcast_json(const char *channel, const char *json_text)
|
||||
{
|
||||
(void)channel;
|
||||
if (!s_server || !json_text) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
char line[WS_BROADCAST_BUF];
|
||||
int n = snprintf(line, sizeof(line), "{\"channel\":\"%s\",\"payload\":%s}", channel ? channel : "event",
|
||||
json_text);
|
||||
if (n < 0 || n >= (int)sizeof(line)) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
int fds[16];
|
||||
size_t fdcount = sizeof(fds) / sizeof(fds[0]);
|
||||
esp_err_t er = httpd_get_client_list(s_server, &fdcount, fds);
|
||||
if (er != ESP_OK) {
|
||||
return er;
|
||||
}
|
||||
httpd_ws_frame_t pkt = {.type = HTTPD_WS_TYPE_TEXT, .payload = (uint8_t *)line, .len = (size_t)n};
|
||||
for (size_t i = 0; i < fdcount; i++) {
|
||||
if (httpd_ws_get_fd_info(s_server, fds[i]) == HTTPD_WS_CLIENT_WEBSOCKET) {
|
||||
(void)httpd_ws_send_frame_async(s_server, fds[i], &pkt);
|
||||
}
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t send_json(httpd_req_t *req, cJSON *j, int status)
|
||||
{
|
||||
char *p = cJSON_PrintUnformatted(j);
|
||||
cJSON_Delete(j);
|
||||
if (!p) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type");
|
||||
httpd_resp_set_status(req, status == 200 ? "200 OK" : "400 Bad Request");
|
||||
esp_err_t e = httpd_resp_send(req, p, HTTPD_RESP_USE_STRLEN);
|
||||
free(p);
|
||||
return e;
|
||||
}
|
||||
|
||||
static esp_err_t api_status(httpd_req_t *req)
|
||||
{
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "app", "pn532_nfc_toolkit");
|
||||
cJSON_AddNumberToObject(o, "uptimeMs", (double)(esp_timer_get_time() / 1000));
|
||||
cJSON_AddNumberToObject(o, "freeHeap", (double)esp_get_free_heap_size());
|
||||
wifi_mode_t mode;
|
||||
esp_wifi_get_mode(&mode);
|
||||
cJSON_AddNumberToObject(o, "wifiMode", mode);
|
||||
uint8_t ic = 0, hi = 0, lo = 0;
|
||||
if (pn532_get_firmware_version(&ic, &hi, &lo) == ESP_OK) {
|
||||
cJSON *pn = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(pn, "ic", ic);
|
||||
cJSON_AddNumberToObject(pn, "fwHi", hi);
|
||||
cJSON_AddNumberToObject(pn, "fwLo", lo);
|
||||
cJSON_AddItemToObject(o, "pn532", pn);
|
||||
}
|
||||
cJSON_AddBoolToObject(o, "scanning", s_scan);
|
||||
size_t cap_u = 0;
|
||||
uint32_t cap_l = 0;
|
||||
bool cap_f = false;
|
||||
session_capture_get_status(&cap_u, &cap_l, &cap_f);
|
||||
cJSON *cap = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(cap, "usedBytes", (double)cap_u);
|
||||
cJSON_AddNumberToObject(cap, "maxBytes", (double)session_capture_max());
|
||||
cJSON_AddNumberToObject(cap, "lines", (double)cap_l);
|
||||
cJSON_AddBoolToObject(cap, "full", cap_f);
|
||||
cJSON_AddBoolToObject(cap, "deepCapture", session_capture_deep_enabled());
|
||||
cJSON_AddItemToObject(o, "session", cap);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static void tag_uid_hex(const nfc_tag_info_t *tag, char *out, size_t out_sz)
|
||||
{
|
||||
if (!out || out_sz == 0) {
|
||||
return;
|
||||
}
|
||||
out[0] = 0;
|
||||
size_t p = 0;
|
||||
for (int i = 0; i < tag->uid_len && p + 2 < out_sz; i++) {
|
||||
p += (size_t)snprintf(out + p, out_sz - p, "%02X", tag->uid[i]);
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t api_session_export(httpd_req_t *req)
|
||||
{
|
||||
size_t n = session_capture_export_size();
|
||||
if (n == 0) {
|
||||
httpd_resp_set_type(req, "text/plain");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
return httpd_resp_send(req, "empty", HTTPD_RESP_USE_STRLEN);
|
||||
}
|
||||
char *buf = malloc(n);
|
||||
if (!buf) {
|
||||
return send_json(req, cJSON_CreateString("out of memory"), 400);
|
||||
}
|
||||
size_t got = 0;
|
||||
session_capture_copy_to(buf, n, &got);
|
||||
httpd_resp_set_type(req, "application/x-ndjson");
|
||||
httpd_resp_set_hdr(req, "Content-Disposition", "attachment; filename=\"pn532-deep-capture.ndjson\"");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
esp_err_t er = httpd_resp_send(req, buf, got);
|
||||
free(buf);
|
||||
return er;
|
||||
}
|
||||
|
||||
static esp_err_t api_session_clear(httpd_req_t *req)
|
||||
{
|
||||
char drain[128];
|
||||
(void)recv_body_capped(req, drain, sizeof(drain));
|
||||
session_capture_clear();
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(o, "ok", true);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_session_deep(httpd_req_t *req)
|
||||
{
|
||||
char buf[128];
|
||||
int r = recv_body_capped(req, buf, sizeof(buf));
|
||||
if (r < 0) {
|
||||
return send_json(req, cJSON_CreateString("body required or too large"), 400);
|
||||
}
|
||||
cJSON *j = cJSON_Parse(buf);
|
||||
if (!j) {
|
||||
return send_json(req, cJSON_CreateString("bad json"), 400);
|
||||
}
|
||||
cJSON *en = cJSON_GetObjectItem(j, "enable");
|
||||
if (cJSON_IsBool(en)) {
|
||||
session_capture_set_deep(cJSON_IsTrue(en));
|
||||
}
|
||||
cJSON_Delete(j);
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(o, "deepCapture", session_capture_deep_enabled());
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_nfc_poll(httpd_req_t *req)
|
||||
{
|
||||
char drain[128];
|
||||
(void)recv_body_capped(req, drain, sizeof(drain));
|
||||
|
||||
nfc_tag_info_t tag;
|
||||
esp_err_t e = nfc_poll_passive_target(&tag);
|
||||
if (e == ESP_ERR_NOT_FOUND) {
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(o, "present", false);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
if (e != ESP_OK) {
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "error", esp_err_to_name(e));
|
||||
return send_json(req, o, 400);
|
||||
}
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(o, "present", true);
|
||||
cJSON_AddItemToObject(o, "tag", nfc_tag_to_json(&tag));
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_scan(httpd_req_t *req)
|
||||
{
|
||||
char buf[128];
|
||||
int r = recv_body_capped(req, buf, sizeof(buf));
|
||||
if (r < 0) {
|
||||
return send_json(req, cJSON_CreateString("no body or too large"), 400);
|
||||
}
|
||||
cJSON *j = cJSON_Parse(buf);
|
||||
if (!j) {
|
||||
return send_json(req, cJSON_CreateString("bad json"), 400);
|
||||
}
|
||||
cJSON *en = cJSON_GetObjectItem(j, "enable");
|
||||
if (cJSON_IsBool(en)) {
|
||||
app_net_set_continuous_scan(cJSON_IsTrue(en));
|
||||
}
|
||||
cJSON_Delete(j);
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(o, "enable", s_scan);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_general_status(httpd_req_t *req)
|
||||
{
|
||||
uint8_t gs[32];
|
||||
size_t gl = 0;
|
||||
esp_err_t e = pn532_get_general_status(gs, sizeof(gs), &gl);
|
||||
if (e != ESP_OK) {
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "error", esp_err_to_name(e));
|
||||
return send_json(req, o, 400);
|
||||
}
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
for (size_t i = 0; i < gl; i++) {
|
||||
cJSON_AddItemToArray(arr, cJSON_CreateNumber(gs[i]));
|
||||
}
|
||||
cJSON_AddItemToObject(o, "raw", arr);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_mifare_read(httpd_req_t *req)
|
||||
{
|
||||
char buf[512];
|
||||
int r = recv_body_capped(req, buf, sizeof(buf));
|
||||
if (r < 0) {
|
||||
return send_json(req, cJSON_CreateString("no body or too large"), 400);
|
||||
}
|
||||
cJSON *j = cJSON_Parse(buf);
|
||||
if (!j) {
|
||||
return send_json(req, cJSON_CreateString("bad json"), 400);
|
||||
}
|
||||
cJSON *jbk = cJSON_GetObjectItem(j, "block");
|
||||
cJSON *jk = cJSON_GetObjectItem(j, "key");
|
||||
int block = cJSON_IsNumber(jbk) ? (int)cJSON_GetNumberValue(jbk) : -1;
|
||||
const char *key_hex = cJSON_IsString(jk) ? jk->valuestring : NULL;
|
||||
cJSON *jb = cJSON_GetObjectItem(j, "keyB");
|
||||
bool key_b = cJSON_IsTrue(jb);
|
||||
cJSON_Delete(j);
|
||||
if (block < 0 || !key_hex) {
|
||||
return send_json(req, cJSON_CreateString("block/key required"), 400);
|
||||
}
|
||||
uint8_t keyb[6];
|
||||
if (!hex_to_bin(key_hex, keyb, 6)) {
|
||||
return send_json(req, cJSON_CreateString("key must be 12 hex chars"), 400);
|
||||
}
|
||||
nfc_tag_info_t tag;
|
||||
if (nfc_poll_passive_target(&tag) != ESP_OK) {
|
||||
return send_json(req, cJSON_CreateString("no tag"), 400);
|
||||
}
|
||||
nfc_mifare_key_t k = {.key_b = key_b};
|
||||
memcpy(k.key, keyb, 6);
|
||||
if (nfc_mifare_authenticate_block(&tag, (uint8_t)block, &k) != ESP_OK) {
|
||||
return send_json(req, cJSON_CreateString("auth failed"), 400);
|
||||
}
|
||||
uint8_t blk[NFC_BLOCK_LEN];
|
||||
if (nfc_mifare_read_block((uint8_t)block, blk) != ESP_OK) {
|
||||
return send_json(req, cJSON_CreateString("read failed"), 400);
|
||||
}
|
||||
char hexout[NFC_BLOCK_LEN * 2 + 1];
|
||||
for (int i = 0; i < NFC_BLOCK_LEN; i++) {
|
||||
snprintf(hexout + i * 2, 3, "%02X", blk[i]);
|
||||
}
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(o, "block", block);
|
||||
cJSON_AddStringToObject(o, "data", hexout);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_mifare_write(httpd_req_t *req)
|
||||
{
|
||||
char buf[512];
|
||||
int r = recv_body_capped(req, buf, sizeof(buf));
|
||||
if (r < 0) {
|
||||
return send_json(req, cJSON_CreateString("no body or too large"), 400);
|
||||
}
|
||||
cJSON *j = cJSON_Parse(buf);
|
||||
if (!j) {
|
||||
return send_json(req, cJSON_CreateString("bad json"), 400);
|
||||
}
|
||||
cJSON *jbk = cJSON_GetObjectItem(j, "block");
|
||||
cJSON *jk = cJSON_GetObjectItem(j, "key");
|
||||
cJSON *jd = cJSON_GetObjectItem(j, "data");
|
||||
int block = cJSON_IsNumber(jbk) ? (int)cJSON_GetNumberValue(jbk) : -1;
|
||||
const char *key_hex = cJSON_IsString(jk) ? jk->valuestring : NULL;
|
||||
const char *data_hex = cJSON_IsString(jd) ? jd->valuestring : NULL;
|
||||
cJSON *jb = cJSON_GetObjectItem(j, "keyB");
|
||||
bool key_b = cJSON_IsTrue(jb);
|
||||
cJSON_Delete(j);
|
||||
if (block < 0 || !key_hex || !data_hex) {
|
||||
return send_json(req, cJSON_CreateString("block/key/data required"), 400);
|
||||
}
|
||||
uint8_t keyb[6], blk[NFC_BLOCK_LEN];
|
||||
if (!hex_to_bin(key_hex, keyb, 6) || !hex_to_bin(data_hex, blk, NFC_BLOCK_LEN)) {
|
||||
return send_json(req, cJSON_CreateString("bad hex"), 400);
|
||||
}
|
||||
nfc_tag_info_t tag;
|
||||
if (nfc_poll_passive_target(&tag) != ESP_OK) {
|
||||
return send_json(req, cJSON_CreateString("no tag"), 400);
|
||||
}
|
||||
nfc_mifare_key_t k = {.key_b = key_b};
|
||||
memcpy(k.key, keyb, 6);
|
||||
if (nfc_mifare_authenticate_block(&tag, (uint8_t)block, &k) != ESP_OK) {
|
||||
return send_json(req, cJSON_CreateString("auth failed"), 400);
|
||||
}
|
||||
if (nfc_mifare_write_block((uint8_t)block, blk) != ESP_OK) {
|
||||
return send_json(req, cJSON_CreateString("write failed"), 400);
|
||||
}
|
||||
return send_json(req, cJSON_CreateObject(), 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_ul_read(httpd_req_t *req)
|
||||
{
|
||||
char buf[128];
|
||||
int r = recv_body_capped(req, buf, sizeof(buf));
|
||||
if (r < 0) {
|
||||
return send_json(req, cJSON_CreateString("no body or too large"), 400);
|
||||
}
|
||||
cJSON *j = cJSON_Parse(buf);
|
||||
if (!j) {
|
||||
return send_json(req, cJSON_CreateString("bad json"), 400);
|
||||
}
|
||||
cJSON *jp = cJSON_GetObjectItem(j, "page");
|
||||
int page = cJSON_IsNumber(jp) ? (int)cJSON_GetNumberValue(jp) : -1;
|
||||
cJSON_Delete(j);
|
||||
if (page < 0) {
|
||||
return send_json(req, cJSON_CreateString("page required"), 400);
|
||||
}
|
||||
uint8_t d[4];
|
||||
if (nfc_ultralight_read_page((uint8_t)page, d) != ESP_OK) {
|
||||
return send_json(req, cJSON_CreateString("read failed"), 400);
|
||||
}
|
||||
char hx[9];
|
||||
snprintf(hx, sizeof hx, "%02X%02X%02X%02X", d[0], d[1], d[2], d[3]);
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(o, "page", page);
|
||||
cJSON_AddStringToObject(o, "data", hx);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_ul_write(httpd_req_t *req)
|
||||
{
|
||||
char buf[128];
|
||||
int r = recv_body_capped(req, buf, sizeof(buf));
|
||||
if (r < 0) {
|
||||
return send_json(req, cJSON_CreateString("no body or too large"), 400);
|
||||
}
|
||||
cJSON *j = cJSON_Parse(buf);
|
||||
if (!j) {
|
||||
return send_json(req, cJSON_CreateString("bad json"), 400);
|
||||
}
|
||||
cJSON *jp = cJSON_GetObjectItem(j, "page");
|
||||
cJSON *jd = cJSON_GetObjectItem(j, "data");
|
||||
int page = cJSON_IsNumber(jp) ? (int)cJSON_GetNumberValue(jp) : -1;
|
||||
const char *data_hex = cJSON_IsString(jd) ? jd->valuestring : NULL;
|
||||
cJSON_Delete(j);
|
||||
if (page < 0 || !data_hex) {
|
||||
return send_json(req, cJSON_CreateString("page and data (8 hex) required"), 400);
|
||||
}
|
||||
uint8_t d[4];
|
||||
if (!hex_to_bin(data_hex, d, 4)) {
|
||||
return send_json(req, cJSON_CreateString("data must be 8 hex chars (4 bytes)"), 400);
|
||||
}
|
||||
if (nfc_ultralight_write_page((uint8_t)page, d) != ESP_OK) {
|
||||
return send_json(req, cJSON_CreateString("write failed"), 400);
|
||||
}
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(o, "page", page);
|
||||
cJSON_AddBoolToObject(o, "ok", true);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_ota_stub(httpd_req_t *req)
|
||||
{
|
||||
(void)req;
|
||||
cJSON *o = cJSON_CreateString("Use idf.py app-flash or extend with esp_https_ota + bundle URL");
|
||||
char *p = cJSON_PrintUnformatted(o);
|
||||
cJSON_Delete(o);
|
||||
if (!p) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
httpd_resp_set_status(req, "501 Not Implemented");
|
||||
esp_err_t e = httpd_resp_send(req, p, HTTPD_RESP_USE_STRLEN);
|
||||
free(p);
|
||||
return e;
|
||||
}
|
||||
|
||||
static esp_err_t api_mifare_dictionary_attack(httpd_req_t *req)
|
||||
{
|
||||
char *body = recv_body_alloc(req, 8192, NULL);
|
||||
if (!body) {
|
||||
return send_json(req, cJSON_CreateString("no body or too large"), 400);
|
||||
}
|
||||
cJSON *j = cJSON_Parse(body);
|
||||
free(body);
|
||||
if (!j) {
|
||||
return send_json(req, cJSON_CreateString("bad json"), 400);
|
||||
}
|
||||
cJSON *jr = cJSON_GetObjectItem(j, "readerType");
|
||||
const char *rtype = cJSON_IsString(jr) ? jr->valuestring : NULL;
|
||||
uint8_t s0 = 0;
|
||||
uint8_t s1 = 15;
|
||||
cJSON *jf = cJSON_GetObjectItem(j, "sectorFirst");
|
||||
cJSON *jl = cJSON_GetObjectItem(j, "sectorLast");
|
||||
if (cJSON_IsNumber(jf)) {
|
||||
s0 = (uint8_t)cJSON_GetNumberValue(jf);
|
||||
}
|
||||
if (cJSON_IsNumber(jl)) {
|
||||
s1 = (uint8_t)cJSON_GetNumberValue(jl);
|
||||
} else if (rtype && strcmp(rtype, "classic4k") == 0) {
|
||||
s1 = 39;
|
||||
}
|
||||
bool variations = cJSON_IsTrue(cJSON_GetObjectItem(j, "variations"));
|
||||
uint8_t extra[96 * 6];
|
||||
size_t extra_n = 0;
|
||||
cJSON *keys = cJSON_GetObjectItem(j, "keysHex");
|
||||
if (cJSON_IsArray(keys)) {
|
||||
int n = cJSON_GetArraySize(keys);
|
||||
for (int i = 0; i < n && extra_n < 96; i++) {
|
||||
cJSON *it = cJSON_GetArrayItem(keys, i);
|
||||
if (!cJSON_IsString(it)) {
|
||||
continue;
|
||||
}
|
||||
if (hex_to_bin(it->valuestring, extra + extra_n * 6, 6)) {
|
||||
extra_n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(j);
|
||||
|
||||
nfc_tag_info_t tag;
|
||||
if (nfc_poll_passive_target(&tag) != ESP_OK) {
|
||||
return send_json(req, cJSON_CreateString("no tag present"), 400);
|
||||
}
|
||||
int attempts = 0;
|
||||
cJSON *out = nfc_mifare_dictionary_attack(&tag, s0, s1, extra_n ? extra : NULL, extra_n, variations, &attempts);
|
||||
if (!out) {
|
||||
return send_json(req, cJSON_CreateString("attack failed"), 400);
|
||||
}
|
||||
char *p = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
if (!p) {
|
||||
return send_json(req, cJSON_CreateString("print failed"), 400);
|
||||
}
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
httpd_resp_set_status(req, "200 OK");
|
||||
esp_err_t e = httpd_resp_send(req, p, HTTPD_RESP_USE_STRLEN);
|
||||
free(p);
|
||||
return e;
|
||||
}
|
||||
|
||||
static esp_err_t api_nfc_emulate_raw(httpd_req_t *req)
|
||||
{
|
||||
char buf[1024];
|
||||
int r = recv_body_capped(req, buf, sizeof(buf));
|
||||
if (r < 0) {
|
||||
return send_json(req, cJSON_CreateString("no body or too large"), 400);
|
||||
}
|
||||
cJSON *j = cJSON_Parse(buf);
|
||||
if (!j) {
|
||||
return send_json(req, cJSON_CreateString("bad json"), 400);
|
||||
}
|
||||
cJSON *jh = cJSON_GetObjectItem(j, "hex");
|
||||
const char *hex = cJSON_IsString(jh) ? jh->valuestring : NULL;
|
||||
cJSON_Delete(j);
|
||||
uint8_t bin[260];
|
||||
size_t blen = 0;
|
||||
if (!hex || !hex_decode_flex(hex, bin, sizeof(bin), &blen)) {
|
||||
return send_json(req, cJSON_CreateString("hex command required"), 400);
|
||||
}
|
||||
uint8_t resp[300];
|
||||
size_t rlen = 0;
|
||||
esp_err_t err = pn532_send_cmd(bin, blen, resp, sizeof(resp), &rlen, 800);
|
||||
if (err != ESP_OK) {
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "error", esp_err_to_name(err));
|
||||
return send_json(req, o, 400);
|
||||
}
|
||||
char *rh = calloc(1, rlen * 2 + 1);
|
||||
for (size_t i = 0; i < rlen; i++) {
|
||||
snprintf(rh + i * 2, 3, "%02X", resp[i]);
|
||||
}
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "response", rh);
|
||||
free(rh);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_raw_pn532(httpd_req_t *req)
|
||||
{
|
||||
char buf[1024];
|
||||
int r = recv_body_capped(req, buf, sizeof(buf));
|
||||
if (r < 0) {
|
||||
return send_json(req, cJSON_CreateString("no body or too large"), 400);
|
||||
}
|
||||
cJSON *j = cJSON_Parse(buf);
|
||||
if (!j) {
|
||||
return send_json(req, cJSON_CreateString("bad json"), 400);
|
||||
}
|
||||
cJSON *jf = cJSON_GetObjectItem(j, "frame");
|
||||
const char *hex = cJSON_IsString(jf) ? jf->valuestring : NULL;
|
||||
cJSON_Delete(j);
|
||||
size_t blen = 0;
|
||||
uint8_t bin[260];
|
||||
if (!hex || !hex_decode_flex(hex, bin, sizeof(bin), &blen)) {
|
||||
return send_json(req, cJSON_CreateString("frame hex required, even length, max 260B"), 400);
|
||||
}
|
||||
uint8_t resp[300];
|
||||
size_t rlen = 0;
|
||||
esp_err_t e = pn532_send_cmd(bin, blen, resp, sizeof(resp), &rlen, 300);
|
||||
if (e != ESP_OK) {
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "error", esp_err_to_name(e));
|
||||
return send_json(req, o, 400);
|
||||
}
|
||||
char *rh = calloc(1, rlen * 2 + 1);
|
||||
for (size_t i = 0; i < rlen; i++) {
|
||||
snprintf(rh + i * 2, 3, "%02X", resp[i]);
|
||||
}
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "response", rh);
|
||||
free(rh);
|
||||
return send_json(req, o, 200);
|
||||
}
|
||||
|
||||
static esp_err_t api_cors_preflight(httpd_req_t *req)
|
||||
{
|
||||
(void)req;
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Max-Age", "86400");
|
||||
httpd_resp_set_status(req, "204 No Content");
|
||||
return httpd_resp_send(req, "", 0);
|
||||
}
|
||||
|
||||
static esp_err_t static_any(httpd_req_t *req)
|
||||
{
|
||||
if (strcmp(req->uri, "/") == 0) {
|
||||
httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
|
||||
FILE *f = fopen("/spiffs/index.html", "r");
|
||||
if (!f) {
|
||||
httpd_resp_send(req, "<h1>PN532 Toolkit</h1><p>Build web UI into /data</p>", HTTPD_RESP_USE_STRLEN);
|
||||
return ESP_OK;
|
||||
}
|
||||
char buf[512];
|
||||
size_t n;
|
||||
httpd_resp_set_type(req, "text/html");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) {
|
||||
if (httpd_resp_send_chunk(req, buf, n) != ESP_OK) {
|
||||
fclose(f);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return httpd_resp_send_chunk(req, NULL, 0);
|
||||
}
|
||||
if (strstr(req->uri, "..") != NULL) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "bad path");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
char path[96];
|
||||
snprintf(path, sizeof path, "/spiffs%s", req->uri);
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) {
|
||||
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "not found");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) {
|
||||
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "not found");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (strstr(req->uri, ".js")) {
|
||||
httpd_resp_set_type(req, "application/javascript");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
} else if (strstr(req->uri, ".css")) {
|
||||
httpd_resp_set_type(req, "text/css");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
}
|
||||
char buf[512];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) {
|
||||
if (httpd_resp_send_chunk(req, buf, n) != ESP_OK) {
|
||||
fclose(f);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return httpd_resp_send_chunk(req, NULL, 0);
|
||||
}
|
||||
|
||||
static esp_err_t ws_handler(httpd_req_t *req)
|
||||
{
|
||||
if (req->method == HTTP_GET) {
|
||||
ESP_LOGI(TAG, "WS handshake");
|
||||
return ESP_OK;
|
||||
}
|
||||
httpd_ws_frame_t ws = {.type = HTTPD_WS_TYPE_TEXT};
|
||||
uint8_t buf[128];
|
||||
ws.payload = buf;
|
||||
esp_err_t ret = httpd_ws_recv_frame(req, &ws, sizeof(buf));
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
if (ws.type == HTTPD_WS_TYPE_TEXT && ws.len < sizeof(buf)) {
|
||||
buf[ws.len] = 0;
|
||||
if (strcmp((char *)buf, "ping") == 0) {
|
||||
ws.type = HTTPD_WS_TYPE_TEXT;
|
||||
ws.payload = (uint8_t *)"{\"channel\":\"pong\"}";
|
||||
ws.len = strlen((char *)ws.payload);
|
||||
return httpd_ws_send_frame(req, &ws);
|
||||
}
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void scan_loop_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
nfc_tag_info_t last;
|
||||
memset(&last, 0, sizeof(last));
|
||||
while (1) {
|
||||
if (!s_scan) {
|
||||
vTaskDelay(pdMS_TO_TICKS(200));
|
||||
continue;
|
||||
}
|
||||
if (session_capture_deep_enabled() && session_capture_is_full()) {
|
||||
vTaskDelay(pdMS_TO_TICKS(400));
|
||||
continue;
|
||||
}
|
||||
nfc_tag_info_t tag;
|
||||
esp_err_t e = nfc_poll_passive_target(&tag);
|
||||
if (e == ESP_OK) {
|
||||
bool is_new = (last.uid_len != tag.uid_len || memcmp(last.uid, tag.uid, tag.uid_len) != 0);
|
||||
if (is_new) {
|
||||
last = tag;
|
||||
cJSON *j = nfc_tag_to_json(&tag);
|
||||
char *raw = cJSON_PrintUnformatted(j);
|
||||
cJSON_Delete(j);
|
||||
if (raw) {
|
||||
app_net_broadcast_json("scan", raw);
|
||||
free(raw);
|
||||
}
|
||||
if (session_capture_deep_enabled() && !session_capture_is_full()) {
|
||||
cJSON *deep = nfc_tag_deep_profile(&tag);
|
||||
char *line = deep ? cJSON_PrintUnformatted(deep) : NULL;
|
||||
cJSON_Delete(deep);
|
||||
if (line) {
|
||||
if (!session_capture_append_line(line)) {
|
||||
cJSON *mini = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(mini, "event", "bufferFull");
|
||||
cJSON_AddBoolToObject(mini, "paused", true);
|
||||
char *m = cJSON_PrintUnformatted(mini);
|
||||
cJSON_Delete(mini);
|
||||
if (m) {
|
||||
app_net_broadcast_json("capture", m);
|
||||
free(m);
|
||||
}
|
||||
} else {
|
||||
size_t u = 0;
|
||||
uint32_t lc = 0;
|
||||
bool f = false;
|
||||
session_capture_get_status(&u, &lc, &f);
|
||||
char uh[32];
|
||||
tag_uid_hex(&tag, uh, sizeof uh);
|
||||
cJSON *ev = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(ev, "event", "recorded");
|
||||
cJSON_AddStringToObject(ev, "uid", uh);
|
||||
cJSON_AddNumberToObject(ev, "usedBytes", (double)u);
|
||||
cJSON_AddNumberToObject(ev, "lines", (double)lc);
|
||||
cJSON_AddBoolToObject(ev, "full", f);
|
||||
char *es = cJSON_PrintUnformatted(ev);
|
||||
cJSON_Delete(ev);
|
||||
if (es) {
|
||||
app_net_broadcast_json("capture", es);
|
||||
free(es);
|
||||
}
|
||||
}
|
||||
free(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (last.uid_len) {
|
||||
memset(&last, 0, sizeof(last));
|
||||
app_net_broadcast_json("scan", "{\"present\":false}");
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(session_capture_deep_enabled() ? 220 : 65));
|
||||
}
|
||||
}
|
||||
|
||||
void app_net_set_continuous_scan(bool on) { s_scan = on; }
|
||||
|
||||
bool app_net_continuous_scan(void) { return s_scan; }
|
||||
|
||||
static httpd_handle_t start_server(void)
|
||||
{
|
||||
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
|
||||
cfg.lru_purge_enable = true;
|
||||
cfg.max_uri_handlers = 48;
|
||||
cfg.stack_size = 8192;
|
||||
httpd_handle_t s = NULL;
|
||||
if (httpd_start(&s, &cfg) != ESP_OK) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
httpd_uri_t u = {.uri = "/*", .method = HTTP_OPTIONS, .handler = api_cors_preflight};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
|
||||
u = (httpd_uri_t){.uri = "/api/status", .method = HTTP_GET, .handler = api_status};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/nfc/poll", .method = HTTP_POST, .handler = api_nfc_poll};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/nfc/scan", .method = HTTP_POST, .handler = api_scan};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/pn532/general-status", .method = HTTP_GET, .handler = api_general_status};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/mifare/read-block", .method = HTTP_POST, .handler = api_mifare_read};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/mifare/write-block", .method = HTTP_POST, .handler = api_mifare_write};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/ul/read-page", .method = HTTP_POST, .handler = api_ul_read};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/ul/write-page", .method = HTTP_POST, .handler = api_ul_write};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/raw/pn532", .method = HTTP_POST, .handler = api_raw_pn532};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/ota", .method = HTTP_POST, .handler = api_ota_stub};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/session/export", .method = HTTP_GET, .handler = api_session_export};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/session/clear", .method = HTTP_POST, .handler = api_session_clear};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/session/deep", .method = HTTP_POST, .handler = api_session_deep};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/mifare/dictionary-attack", .method = HTTP_POST,
|
||||
.handler = api_mifare_dictionary_attack};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/api/nfc/emulate-raw", .method = HTTP_POST, .handler = api_nfc_emulate_raw};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/ws", .method = HTTP_GET, .handler = ws_handler, .is_websocket = true};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
u = (httpd_uri_t){.uri = "/*", .method = HTTP_GET, .handler = static_any};
|
||||
httpd_register_uri_handler(s, &u);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
esp_err_t app_net_init(void)
|
||||
{
|
||||
ESP_ERROR_CHECK(nvs_flash_init());
|
||||
|
||||
esp_netif_init();
|
||||
esp_event_loop_create_default();
|
||||
esp_netif_create_default_wifi_ap();
|
||||
|
||||
wifi_init_config_t wcfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&wcfg));
|
||||
wifi_config_t ap = {0};
|
||||
strncpy((char *)ap.ap.ssid, SOFTAP_SSID, sizeof(ap.ap.ssid));
|
||||
ap.ap.ssid_len = (uint8_t)strlen(SOFTAP_SSID);
|
||||
ap.ap.channel = 6;
|
||||
ap.ap.max_connection = 8;
|
||||
ap.ap.authmode = WIFI_AUTH_OPEN;
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
esp_vfs_spiffs_conf_t sp = {
|
||||
.base_path = "/spiffs",
|
||||
.partition_label = "storage",
|
||||
.max_files = 16,
|
||||
.format_if_mount_failed = true,
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_vfs_spiffs_register(&sp));
|
||||
|
||||
mdns_init();
|
||||
mdns_hostname_set("pn532tool");
|
||||
mdns_instance_name_set("PN532 NFC Toolkit");
|
||||
mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0);
|
||||
|
||||
s_server = start_server();
|
||||
if (!s_server) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
xTaskCreate(scan_loop_task, "nfc_scan", 20480, NULL, 5, &s_scan_task);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
esp_err_t app_net_init(void);
|
||||
void app_net_set_continuous_scan(bool on);
|
||||
bool app_net_continuous_scan(void);
|
||||
esp_err_t app_net_broadcast_json(const char *channel, const char *json_text);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
5
firmware/components/nfc_engine/CMakeLists.txt
Normal file
5
firmware/components/nfc_engine/CMakeLists.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "nfc_engine.c" "jobs.c" "session_capture.c" "nfc_deep.c" "nfc_brute.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES pn532_host esp_common freertos json esp_timer esp_system
|
||||
)
|
||||
26
firmware/components/nfc_engine/include/nfc_engine/jobs.h
Normal file
26
firmware/components/nfc_engine/include/nfc_engine/jobs.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef void (*job_progress_cb_t)(int pct, const char *msg, void *ctx);
|
||||
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
char name[32];
|
||||
volatile int pct;
|
||||
volatile int done;
|
||||
} nfc_job_t;
|
||||
|
||||
uint32_t jobs_create(const char *name);
|
||||
void jobs_set_progress(uint32_t id, int pct, const char *msg);
|
||||
void jobs_finish(uint32_t id);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "nfc_engine/nfc_engine.h"
|
||||
#include "cJSON.h"
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Dictionary attack on MIFARE Classic sector trailer (Key A then Key B per candidate).
|
||||
* `extra` = packed 6-byte keys, `extra_n` = number of keys.
|
||||
* If `variations`, expands each base key with bounded bit/nibble tweaks (not full 2^48 space).
|
||||
* Returns JSON object: { "attempts": N, "sectorHits": [ {sector, keyHex, keyType}, ... ] }
|
||||
* Caller cJSON_Delete().
|
||||
*/
|
||||
cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, uint8_t sector_last,
|
||||
const uint8_t *extra, size_t extra_n, bool variations,
|
||||
int *attempts_out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
18
firmware/components/nfc_engine/include/nfc_engine/nfc_deep.h
Normal file
18
firmware/components/nfc_engine/include/nfc_engine/nfc_deep.h
Normal file
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "nfc_engine/nfc_engine.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Best-effort deep read for PN532 path: inventory + general status + MIFARE sector sweep
|
||||
* (common keys) and/or Ultralight/NTAG page sweep. Caller must cJSON_Delete() result.
|
||||
*/
|
||||
cJSON *nfc_tag_deep_profile(nfc_tag_info_t *tag);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "cJSON.h"
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define NFC_MAX_UID_LEN 10
|
||||
#define NFC_BLOCK_LEN 16
|
||||
|
||||
typedef struct {
|
||||
uint8_t uid_len;
|
||||
uint8_t uid[NFC_MAX_UID_LEN];
|
||||
uint16_t atqa;
|
||||
uint8_t sak;
|
||||
uint8_t type_hint; /* 0 unknown, 1 classic, 2 ultralight/ntag */
|
||||
} nfc_tag_info_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t key[6];
|
||||
bool key_b;
|
||||
} nfc_mifare_key_t;
|
||||
|
||||
esp_err_t nfc_engine_init(void);
|
||||
|
||||
esp_err_t nfc_poll_passive_target(nfc_tag_info_t *out);
|
||||
|
||||
esp_err_t nfc_mifare_authenticate_block(const nfc_tag_info_t *tag, uint8_t block_no,
|
||||
const nfc_mifare_key_t *key);
|
||||
esp_err_t nfc_mifare_read_block(uint8_t block_no, uint8_t block[NFC_BLOCK_LEN]);
|
||||
esp_err_t nfc_mifare_write_block(uint8_t block_no, const uint8_t block[NFC_BLOCK_LEN]);
|
||||
|
||||
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);
|
||||
|
||||
/** Build JSON snapshot of last seen tag + optional blocks (caller frees cJSON). */
|
||||
cJSON *nfc_tag_to_json(const nfc_tag_info_t *tag);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** RAM-only capture buffer (NDJSON lines). When full, NFC deep capture pauses until clear. */
|
||||
void session_capture_init(void);
|
||||
void session_capture_clear(void);
|
||||
|
||||
bool session_capture_is_full(void);
|
||||
bool session_capture_deep_enabled(void);
|
||||
void session_capture_set_deep(bool on);
|
||||
|
||||
size_t session_capture_max(void);
|
||||
void session_capture_get_status(size_t *used_bytes, uint32_t *line_count, bool *full);
|
||||
|
||||
/** Append one NDJSON line (no trailing newline in `line`). Returns false if buffer full. */
|
||||
bool session_capture_append_line(const char *line);
|
||||
|
||||
/** Export raw bytes (entire buffer) for HTTP download. */
|
||||
size_t session_capture_export_size(void);
|
||||
const char *session_capture_export_ptr(void);
|
||||
|
||||
/** Copy up to `cap` bytes (typically cap >= session_capture_export_size()). */
|
||||
void session_capture_copy_to(char *dst, size_t cap, size_t *out_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
23
firmware/components/nfc_engine/jobs.c
Normal file
23
firmware/components/nfc_engine/jobs.c
Normal file
@@ -0,0 +1,23 @@
|
||||
#include "nfc_engine/jobs.h"
|
||||
#include "esp_log.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "nfc_jobs";
|
||||
|
||||
uint32_t jobs_create(const char *name)
|
||||
{
|
||||
static uint32_t s_next = 1;
|
||||
(void)name;
|
||||
ESP_LOGI(TAG, "job %s id=%lu", name ? name : "?", (unsigned long)s_next);
|
||||
return s_next++;
|
||||
}
|
||||
|
||||
void jobs_set_progress(uint32_t id, int pct, const char *msg)
|
||||
{
|
||||
ESP_LOGD(TAG, "job %lu %d%% %s", (unsigned long)id, pct, msg ? msg : "");
|
||||
}
|
||||
|
||||
void jobs_finish(uint32_t id)
|
||||
{
|
||||
ESP_LOGI(TAG, "job %lu done", (unsigned long)id);
|
||||
}
|
||||
232
firmware/components/nfc_engine/nfc_brute.c
Normal file
232
firmware/components/nfc_engine/nfc_brute.c
Normal file
@@ -0,0 +1,232 @@
|
||||
#include "nfc_engine/nfc_brute.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_task_wdt.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "nfc_brute";
|
||||
|
||||
/* Community default keys (subset from public Proxmark3 / MCT-style lists). */
|
||||
static const uint8_t k_builtin[][6] = {
|
||||
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, {0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
{0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5}, {0xA5, 0xA4, 0xA3, 0xA2, 0xA1, 0xA0},
|
||||
{0x89, 0xEC, 0xA9, 0x7F, 0x8C, 0x2A}, {0x5C, 0x8F, 0xF9, 0x99, 0x0D, 0xA2},
|
||||
{0x75, 0xCC, 0xB5, 0x9C, 0x9B, 0xED}, {0xD0, 0x1A, 0xFE, 0xEB, 0x89, 0x0A},
|
||||
{0x4B, 0x79, 0x1B, 0xEA, 0x7B, 0xCC}, {0x26, 0x12, 0xC6, 0xDE, 0x84, 0xCA},
|
||||
{0x70, 0x7B, 0x11, 0xFC, 0x14, 0x81}, {0x03, 0xF9, 0x06, 0x76, 0x46, 0xAE},
|
||||
{0x23, 0x52, 0xC5, 0xB5, 0x6D, 0x85}, {0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5},
|
||||
{0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5}, {0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5},
|
||||
{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}, {0x4D, 0x3A, 0x99, 0xC3, 0x51, 0xDD},
|
||||
{0x1A, 0x98, 0x2C, 0x7E, 0x45, 0x9A}, {0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA},
|
||||
{0xFB, 0xFB, 0xFB, 0xFB, 0xFB, 0xFB}, {0xD3, 0xF7, 0xD3, 0xF7, 0xD3, 0xF7},
|
||||
{0x5A, 0x1B, 0x85, 0xFC, 0xE2, 0x0A}, {0x71, 0x4C, 0x5C, 0x88, 0x6E, 0x97},
|
||||
{0x58, 0x7E, 0xE5, 0xF9, 0x35, 0x0F}, {0xA0, 0x47, 0x8C, 0xC3, 0x90, 0x91},
|
||||
{0x53, 0x3C, 0xB6, 0xC7, 0x23, 0xF6}, {0x8F, 0xD0, 0xA4, 0xF2, 0x56, 0xE9},
|
||||
{0xE0, 0x00, 0x00, 0x00, 0x00, 0x00}, {0xE7, 0xD6, 0x06, 0x4C, 0x58, 0x60},
|
||||
{0xB2, 0x7C, 0xCA, 0xB3, 0x0D, 0xBD}, {0xD2, 0xEC, 0xE8, 0xB9, 0x39, 0x5E},
|
||||
{0x14, 0x94, 0xE8, 0x16, 0x63, 0xD7}, {0x7C, 0x9F, 0xB8, 0x47, 0x42, 0x42},
|
||||
{0x56, 0x93, 0x69, 0xC5, 0xA0, 0xE5}, {0x63, 0x21, 0x93, 0xBE, 0x1C, 0x3C},
|
||||
{0x8E, 0x26, 0x5B, 0xE2, 0x45, 0xBF}, {0xF4, 0x6B, 0x6D, 0xC0, 0xD6, 0xC4},
|
||||
{0x2A, 0xA0, 0x5E, 0xD1, 0x85, 0x6F}, {0xAE, 0x3F, 0xF4, 0xEE, 0xA0, 0xDB},
|
||||
};
|
||||
|
||||
#define NBUILTIN (sizeof(k_builtin) / sizeof(k_builtin[0]))
|
||||
#define MAX_VARIANTS_PER_KEY 14
|
||||
#define MAX_TRIES_BEFORE_WDT 48
|
||||
|
||||
static int push_variant(const uint8_t base[6], int idx, uint8_t out[6])
|
||||
{
|
||||
memcpy(out, base, 6);
|
||||
switch (idx) {
|
||||
case 0:
|
||||
return 0;
|
||||
case 1:
|
||||
out[5] ^= 0xFF;
|
||||
return 0;
|
||||
case 2:
|
||||
out[0] ^= 0xFF;
|
||||
return 0;
|
||||
case 3:
|
||||
out[5] ^= 0xAA;
|
||||
return 0;
|
||||
case 4:
|
||||
out[5] ^= 0x55;
|
||||
return 0;
|
||||
default: {
|
||||
int n = idx - 5;
|
||||
if (n >= 0 && n < 10) {
|
||||
out[5] = (uint8_t)((out[5] & 0xF0) | (uint8_t)n);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
static bool try_key_on_trailer(nfc_tag_info_t *tag, uint8_t trailer, const uint8_t key[6], bool key_b)
|
||||
{
|
||||
nfc_mifare_key_t k;
|
||||
memcpy(k.key, key, 6);
|
||||
k.key_b = key_b;
|
||||
return nfc_mifare_authenticate_block(tag, trailer, &k) == ESP_OK;
|
||||
}
|
||||
|
||||
/** Trailer block for MIFARE Classic sector index (0..15 for 1K, 0..39 for 4K). */
|
||||
static uint8_t classic_trailer_for_sector(const nfc_tag_info_t *tag, uint8_t sec)
|
||||
{
|
||||
if (tag->sak == 0x19) {
|
||||
if (sec <= 31) {
|
||||
return (uint8_t)(sec * 4 + 3);
|
||||
}
|
||||
if (sec <= 39) {
|
||||
return (uint8_t)(128 + (sec - 32) * 16 + 15);
|
||||
}
|
||||
return 0xFF;
|
||||
}
|
||||
return (uint8_t)(sec * 4 + 3);
|
||||
}
|
||||
|
||||
cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, uint8_t sector_last,
|
||||
const uint8_t *extra, size_t extra_n, bool variations,
|
||||
int *attempts_out)
|
||||
{
|
||||
int64_t t0_us = esp_timer_get_time();
|
||||
int attempts = 0;
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON *hits = cJSON_CreateArray();
|
||||
if (!root || !hits) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(hits);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (tag->type_hint != 1) {
|
||||
cJSON_AddStringToObject(root, "error", "not_classic_sak_hint");
|
||||
cJSON_AddItemToObject(root, "sectorHits", hits);
|
||||
if (attempts_out) {
|
||||
*attempts_out = 0;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
if (sector_first > sector_last) {
|
||||
uint8_t t = sector_first;
|
||||
sector_first = sector_last;
|
||||
sector_last = t;
|
||||
}
|
||||
|
||||
for (uint8_t sec = sector_first; sec <= sector_last; sec++) {
|
||||
uint8_t trailer = classic_trailer_for_sector(tag, sec);
|
||||
if (trailer == 0xFF) {
|
||||
continue;
|
||||
}
|
||||
bool got = false;
|
||||
|
||||
for (size_t bi = 0; bi < NBUILTIN && !got; bi++) {
|
||||
uint8_t trial[6];
|
||||
int maxv = variations ? MAX_VARIANTS_PER_KEY : 1;
|
||||
for (int vi = 0; vi < maxv; vi++) {
|
||||
if (push_variant(k_builtin[bi], vi, trial) != 0) {
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
if (try_key_on_trailer(tag, trailer, trial, false)) {
|
||||
char hx[16];
|
||||
for (int i = 0; i < 6; i++) {
|
||||
snprintf(hx + i * 2, 3, "%02X", trial[i]);
|
||||
}
|
||||
hx[12] = 0;
|
||||
cJSON *h = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(h, "sector", sec);
|
||||
cJSON_AddStringToObject(h, "keyHex", hx);
|
||||
cJSON_AddStringToObject(h, "keyType", "A");
|
||||
cJSON_AddItemToArray(hits, h);
|
||||
got = true;
|
||||
break;
|
||||
}
|
||||
if (try_key_on_trailer(tag, trailer, trial, true)) {
|
||||
char hx[16];
|
||||
for (int i = 0; i < 6; i++) {
|
||||
snprintf(hx + i * 2, 3, "%02X", trial[i]);
|
||||
}
|
||||
hx[12] = 0;
|
||||
cJSON *h = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(h, "sector", sec);
|
||||
cJSON_AddStringToObject(h, "keyHex", hx);
|
||||
cJSON_AddStringToObject(h, "keyType", "B");
|
||||
cJSON_AddItemToArray(hits, h);
|
||||
got = true;
|
||||
break;
|
||||
}
|
||||
if ((attempts % MAX_TRIES_BEFORE_WDT) == 0) {
|
||||
esp_task_wdt_reset();
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t ei = 0; ei < extra_n && !got; ei++) {
|
||||
const uint8_t *ek = extra + ei * 6;
|
||||
uint8_t trial[6];
|
||||
int maxv = variations ? MAX_VARIANTS_PER_KEY : 1;
|
||||
for (int vi = 0; vi < maxv; vi++) {
|
||||
if (push_variant(ek, vi, trial) != 0) {
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
if (try_key_on_trailer(tag, trailer, trial, false)) {
|
||||
char hx[16];
|
||||
for (int i = 0; i < 6; i++) {
|
||||
snprintf(hx + i * 2, 3, "%02X", trial[i]);
|
||||
}
|
||||
hx[12] = 0;
|
||||
cJSON *h = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(h, "sector", sec);
|
||||
cJSON_AddStringToObject(h, "keyHex", hx);
|
||||
cJSON_AddStringToObject(h, "keyType", "A");
|
||||
cJSON_AddItemToArray(hits, h);
|
||||
got = true;
|
||||
break;
|
||||
}
|
||||
if (try_key_on_trailer(tag, trailer, trial, true)) {
|
||||
char hx[16];
|
||||
for (int i = 0; i < 6; i++) {
|
||||
snprintf(hx + i * 2, 3, "%02X", trial[i]);
|
||||
}
|
||||
hx[12] = 0;
|
||||
cJSON *h = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(h, "sector", sec);
|
||||
cJSON_AddStringToObject(h, "keyHex", hx);
|
||||
cJSON_AddStringToObject(h, "keyType", "B");
|
||||
cJSON_AddItemToArray(hits, h);
|
||||
got = true;
|
||||
break;
|
||||
}
|
||||
if ((attempts % MAX_TRIES_BEFORE_WDT) == 0) {
|
||||
esp_task_wdt_reset();
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!got) {
|
||||
cJSON *h = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(h, "sector", sec);
|
||||
cJSON_AddBoolToObject(h, "miss", true);
|
||||
cJSON_AddItemToArray(hits, h);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(root, "sectorHits", hits);
|
||||
cJSON_AddNumberToObject(root, "attempts", attempts);
|
||||
cJSON_AddNumberToObject(root, "durationMs", (double)((esp_timer_get_time() - t0_us) / 1000));
|
||||
cJSON_AddStringToObject(root, "note",
|
||||
"Dictionary + bounded variants only — not exhaustive 48-bit keyspace.");
|
||||
if (attempts_out) {
|
||||
*attempts_out = attempts;
|
||||
}
|
||||
ESP_LOGI(TAG, "dictionary attack attempts=%d", attempts);
|
||||
return root;
|
||||
}
|
||||
188
firmware/components/nfc_engine/nfc_deep.c
Normal file
188
firmware/components/nfc_engine/nfc_deep.c
Normal file
@@ -0,0 +1,188 @@
|
||||
#include "nfc_engine/nfc_deep.h"
|
||||
#include "nfc_engine/nfc_engine.h"
|
||||
#include "pn532_host/pn532_core.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "nfc_deep";
|
||||
|
||||
#define MAX_UL_PAGES 240
|
||||
|
||||
static const uint8_t k_default_keys[][6] = {
|
||||
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF},
|
||||
{0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5},
|
||||
{0xD3, 0xF7, 0xD3, 0xF7, 0xD3, 0xF7},
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
};
|
||||
|
||||
static void block_to_hex(const uint8_t blk[NFC_BLOCK_LEN], char *out33)
|
||||
{
|
||||
for (int i = 0; i < NFC_BLOCK_LEN; i++) {
|
||||
snprintf(out33 + i * 2, 3, "%02X", blk[i]);
|
||||
}
|
||||
out33[32] = 0;
|
||||
}
|
||||
|
||||
static void key_to_hex(const uint8_t k[6], char *out13)
|
||||
{
|
||||
for (int i = 0; i < 6; i++) {
|
||||
snprintf(out13 + i * 2, 3, "%02X", k[i]);
|
||||
}
|
||||
out13[12] = 0;
|
||||
}
|
||||
|
||||
/** Classic 1K: 16 sectors x 4 blocks. Classic 4K: sectors 0–31 x 4 blocks, sectors 32–39 x 16 blocks. */
|
||||
static bool mfc_sector_layout(const nfc_tag_info_t *tag, int sector, int *first_block, int *num_blocks,
|
||||
uint8_t *trailer_block)
|
||||
{
|
||||
bool is4k = (tag->sak == 0x19);
|
||||
if (!is4k) {
|
||||
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;
|
||||
}
|
||||
|
||||
static bool try_sector(nfc_tag_info_t *tag, int sector, cJSON *sec_out)
|
||||
{
|
||||
int fb = 0;
|
||||
int nb = 0;
|
||||
uint8_t trailer = 0;
|
||||
if (!mfc_sector_layout(tag, sector, &fb, &nb, &trailer)) {
|
||||
return false;
|
||||
}
|
||||
cJSON_AddNumberToObject(sec_out, "sector", sector);
|
||||
cJSON_AddNumberToObject(sec_out, "firstBlock", fb);
|
||||
cJSON_AddNumberToObject(sec_out, "trailerBlock", trailer);
|
||||
cJSON_AddNumberToObject(sec_out, "blockCount", nb);
|
||||
|
||||
for (size_t ki = 0; ki < sizeof(k_default_keys) / 6; ki++) {
|
||||
nfc_mifare_key_t key;
|
||||
memcpy(key.key, k_default_keys[ki], 6);
|
||||
key.key_b = false;
|
||||
if (nfc_mifare_authenticate_block(tag, trailer, &key) == ESP_OK) {
|
||||
cJSON_AddStringToObject(sec_out, "keyType", "A");
|
||||
char kh[16];
|
||||
key_to_hex(key.key, kh);
|
||||
cJSON_AddStringToObject(sec_out, "keyHex", kh);
|
||||
cJSON *blocks = cJSON_CreateArray();
|
||||
for (int b = 0; b < nb; b++) {
|
||||
uint8_t bn = (uint8_t)(fb + b);
|
||||
uint8_t blk[NFC_BLOCK_LEN];
|
||||
if (nfc_mifare_read_block(bn, blk) == ESP_OK) {
|
||||
char hx[36];
|
||||
block_to_hex(blk, hx);
|
||||
cJSON_AddItemToArray(blocks, cJSON_CreateString(hx));
|
||||
} else {
|
||||
cJSON_AddItemToArray(blocks, cJSON_CreateNull());
|
||||
}
|
||||
}
|
||||
cJSON_AddItemToObject(sec_out, "blocksHex", blocks);
|
||||
return true;
|
||||
}
|
||||
key.key_b = true;
|
||||
if (nfc_mifare_authenticate_block(tag, trailer, &key) == ESP_OK) {
|
||||
cJSON_AddStringToObject(sec_out, "keyType", "B");
|
||||
char kh[16];
|
||||
key_to_hex(key.key, kh);
|
||||
cJSON_AddStringToObject(sec_out, "keyHex", kh);
|
||||
cJSON *blocks = cJSON_CreateArray();
|
||||
for (int b = 0; b < nb; b++) {
|
||||
uint8_t bn = (uint8_t)(fb + b);
|
||||
uint8_t blk[NFC_BLOCK_LEN];
|
||||
if (nfc_mifare_read_block(bn, blk) == ESP_OK) {
|
||||
char hx[36];
|
||||
block_to_hex(blk, hx);
|
||||
cJSON_AddItemToArray(blocks, cJSON_CreateString(hx));
|
||||
} else {
|
||||
cJSON_AddItemToArray(blocks, cJSON_CreateNull());
|
||||
}
|
||||
}
|
||||
cJSON_AddItemToObject(sec_out, "blocksHex", blocks);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
cJSON_AddBoolToObject(sec_out, "authFailed", true);
|
||||
return false;
|
||||
}
|
||||
|
||||
static void add_mifare_classic(nfc_tag_info_t *tag, cJSON *root)
|
||||
{
|
||||
int sectors = (tag->sak == 0x19) ? 40 : 16;
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
for (int s = 0; s < sectors; s++) {
|
||||
cJSON *sec = cJSON_CreateObject();
|
||||
(void)try_sector(tag, s, sec);
|
||||
cJSON_AddItemToArray(arr, sec);
|
||||
}
|
||||
cJSON_AddItemToObject(root, "mifareClassic", arr);
|
||||
}
|
||||
|
||||
static void add_ultralight(nfc_tag_info_t *tag, cJSON *root)
|
||||
{
|
||||
(void)tag;
|
||||
cJSON *pages = cJSON_CreateArray();
|
||||
uint8_t buf[4];
|
||||
for (int p = 0; p < MAX_UL_PAGES; p++) {
|
||||
if (nfc_ultralight_read_page((uint8_t)p, buf) != ESP_OK) {
|
||||
break;
|
||||
}
|
||||
char line[12];
|
||||
snprintf(line, sizeof line, "%02X%02X%02X%02X", buf[0], buf[1], buf[2], buf[3]);
|
||||
cJSON_AddItemToArray(pages, cJSON_CreateString(line));
|
||||
}
|
||||
cJSON_AddItemToObject(root, "ultralightPagesHex", pages);
|
||||
}
|
||||
|
||||
cJSON *nfc_tag_deep_profile(nfc_tag_info_t *tag)
|
||||
{
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
if (!o) {
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddNumberToObject(o, "capturedMs", (double)(esp_timer_get_time() / 1000));
|
||||
cJSON_AddItemToObject(o, "tag", nfc_tag_to_json(tag));
|
||||
|
||||
uint8_t gs[32];
|
||||
size_t gl = 0;
|
||||
if (pn532_get_general_status(gs, sizeof(gs), &gl) == ESP_OK && gl > 0) {
|
||||
cJSON *g = cJSON_CreateArray();
|
||||
for (size_t i = 0; i < gl; i++) {
|
||||
cJSON_AddItemToArray(g, cJSON_CreateNumber(gs[i]));
|
||||
}
|
||||
cJSON_AddItemToObject(o, "pn532GeneralStatus", g);
|
||||
}
|
||||
|
||||
if (tag->type_hint == 1) {
|
||||
add_mifare_classic(tag, o);
|
||||
} else if (tag->type_hint == 2) {
|
||||
add_ultralight(tag, o);
|
||||
} else {
|
||||
cJSON_AddStringToObject(
|
||||
o, "note",
|
||||
"Unknown type from SAK/ATQA — stored inventory + PN532 status only. Use Raw console for ISO14443-4 / other stacks.");
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "deep profile done type_hint=%u", tag->type_hint);
|
||||
return o;
|
||||
}
|
||||
255
firmware/components/nfc_engine/nfc_engine.c
Normal file
255
firmware/components/nfc_engine/nfc_engine.c
Normal file
@@ -0,0 +1,255 @@
|
||||
#include "nfc_engine/nfc_engine.h"
|
||||
#include "pn532_host/pn532_core.h"
|
||||
#include "esp_log.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "nfc_engine";
|
||||
|
||||
static uint8_t s_tg = 1;
|
||||
|
||||
static void hint_type(nfc_tag_info_t *t)
|
||||
{
|
||||
switch (t->sak) {
|
||||
case 0x08:
|
||||
case 0x00:
|
||||
t->type_hint = 2;
|
||||
break;
|
||||
case 0x09:
|
||||
case 0x18:
|
||||
case 0x19:
|
||||
t->type_hint = 1;
|
||||
break;
|
||||
default:
|
||||
t->type_hint = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t nfc_engine_init(void)
|
||||
{
|
||||
esp_err_t e = pn532_core_init();
|
||||
if (e != ESP_OK) {
|
||||
return e;
|
||||
}
|
||||
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");
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nfc_poll_passive_target(nfc_tag_info_t *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
uint8_t resp[64];
|
||||
size_t rlen = 0;
|
||||
esp_err_t e = pn532_in_list_passive_target(1, 0x00, resp, sizeof(resp), &rlen);
|
||||
if (e != ESP_OK) {
|
||||
return e;
|
||||
}
|
||||
if (rlen < 2 || resp[0] != 0x00) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
if (resp[1] < 1) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (rlen < 8) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
s_tg = resp[2];
|
||||
out->atqa = (uint16_t)(((uint16_t)resp[3] << 8) | resp[4]);
|
||||
out->sak = resp[5];
|
||||
out->uid_len = resp[6];
|
||||
if (out->uid_len > NFC_MAX_UID_LEN || (size_t)(7 + out->uid_len) > rlen) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
memcpy(out->uid, resp + 7, out->uid_len);
|
||||
hint_type(out);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t in_data_tg(const uint8_t *data, size_t data_len, uint8_t *response, size_t response_max,
|
||||
size_t *response_len)
|
||||
{
|
||||
uint8_t buf[64];
|
||||
if (data_len > sizeof(buf) - 3) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
buf[0] = PN532_CMD_INDATAEXCHANGE;
|
||||
buf[1] = s_tg;
|
||||
memcpy(buf + 2, data, data_len);
|
||||
return pn532_send_cmd(buf, 2 + data_len, response, response_max, response_len, 500);
|
||||
}
|
||||
|
||||
esp_err_t nfc_mifare_authenticate_block(const nfc_tag_info_t *tag, uint8_t block_no,
|
||||
const nfc_mifare_key_t *key)
|
||||
{
|
||||
uint8_t data[12];
|
||||
data[0] = key->key_b ? PN532_MIFARE_CMD_AUTH_B : PN532_MIFARE_CMD_AUTH_A;
|
||||
data[1] = block_no;
|
||||
memcpy(data + 2, key->key, 6);
|
||||
uint8_t uid_use[4];
|
||||
if (tag->uid_len == 4) {
|
||||
memcpy(uid_use, tag->uid, 4);
|
||||
} else if (tag->uid_len >= 7) {
|
||||
memcpy(uid_use, tag->uid + tag->uid_len - 4, 4);
|
||||
} else if (tag->uid_len > 4) {
|
||||
memcpy(uid_use, tag->uid + tag->uid_len - 4, 4);
|
||||
} else {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memcpy(data + 8, uid_use, 4);
|
||||
uint8_t resp[32];
|
||||
size_t rlen = 0;
|
||||
esp_err_t e = in_data_tg(data, sizeof(data), resp, sizeof(resp), &rlen);
|
||||
if (e != ESP_OK) {
|
||||
return e;
|
||||
}
|
||||
if (rlen < 1 || resp[0] != 0x00) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nfc_mifare_read_block(uint8_t block_no, uint8_t block[NFC_BLOCK_LEN])
|
||||
{
|
||||
uint8_t d[] = {PN532_MIFARE_CMD_READ, block_no};
|
||||
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 < 1 + NFC_BLOCK_LEN || resp[0] != 0x00) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
memcpy(block, resp + 1, NFC_BLOCK_LEN);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nfc_mifare_write_block(uint8_t block_no, const uint8_t block[NFC_BLOCK_LEN])
|
||||
{
|
||||
uint8_t d[2 + NFC_BLOCK_LEN];
|
||||
d[0] = PN532_MIFARE_CMD_WRITE;
|
||||
d[1] = block_no;
|
||||
memcpy(d + 2, block, NFC_BLOCK_LEN);
|
||||
uint8_t resp[16];
|
||||
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 < 1 || resp[0] != 0x00) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nfc_ultralight_read_page(uint8_t page, uint8_t data[4])
|
||||
{
|
||||
uint8_t d[] = {PN532_MIFARE_CMD_READ, page};
|
||||
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 < 1 + 16 || resp[0] != 0x00) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
memcpy(data, resp + 1, 4);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nfc_ultralight_write_page(uint8_t page, const uint8_t data[4])
|
||||
{
|
||||
uint8_t d[6] = {0xA2, page, data[0], data[1], data[2], data[3]};
|
||||
uint8_t resp[16];
|
||||
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 < 1 || resp[0] != 0x00) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nfc_ul_fast_read(uint8_t start_page, uint8_t *out, size_t out_max, size_t *got)
|
||||
{
|
||||
*got = 0;
|
||||
size_t off = 0;
|
||||
uint8_t d[2] = {0x3A, start_page};
|
||||
uint8_t resp[256];
|
||||
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 || resp[0] != 0x00) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
size_t payload = rlen - 1;
|
||||
if (payload > out_max) {
|
||||
payload = out_max;
|
||||
}
|
||||
memcpy(out, resp + 1, payload);
|
||||
off = payload;
|
||||
*got = off;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
cJSON *nfc_tag_to_json(const nfc_tag_info_t *tag)
|
||||
{
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
if (!o) {
|
||||
return NULL;
|
||||
}
|
||||
char uidhex[NFC_MAX_UID_LEN * 2 + 4];
|
||||
for (int i = 0; i < tag->uid_len; i++) {
|
||||
snprintf(uidhex + i * 2, 3, "%02X", tag->uid[i]);
|
||||
}
|
||||
uidhex[tag->uid_len * 2] = 0;
|
||||
cJSON_AddStringToObject(o, "uid", uidhex);
|
||||
cJSON_AddNumberToObject(o, "uidLen", tag->uid_len);
|
||||
cJSON_AddNumberToObject(o, "atqa", tag->atqa);
|
||||
cJSON_AddNumberToObject(o, "sak", tag->sak);
|
||||
cJSON_AddNumberToObject(o, "typeHint", tag->type_hint);
|
||||
|
||||
char aq[8];
|
||||
snprintf(aq, sizeof aq, "%04X", (unsigned)tag->atqa);
|
||||
cJSON_AddStringToObject(o, "atqaHex", aq);
|
||||
char sk[8];
|
||||
snprintf(sk, sizeof sk, "%02X", tag->sak);
|
||||
cJSON_AddStringToObject(o, "sakHex", sk);
|
||||
|
||||
const char *guess = "Unknown / use Raw or RATS";
|
||||
if (tag->type_hint == 1) {
|
||||
guess = (tag->sak == 0x19) ? "MIFARE Classic 4K" : "MIFARE Classic 1K or compatible";
|
||||
} else if (tag->type_hint == 2) {
|
||||
guess = "Ultralight / NTAG / Type 2 family";
|
||||
}
|
||||
cJSON_AddStringToObject(o, "typeGuess", guess);
|
||||
|
||||
uint8_t gs[32];
|
||||
size_t gl = 0;
|
||||
if (pn532_get_general_status(gs, sizeof(gs), &gl) == ESP_OK && gl > 0) {
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
if (arr) {
|
||||
for (size_t i = 0; i < gl; i++) {
|
||||
cJSON_AddItemToArray(arr, cJSON_CreateNumber(gs[i]));
|
||||
}
|
||||
cJSON_AddItemToObject(o, "pn532GeneralStatus", arr);
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
129
firmware/components/nfc_engine/session_capture.c
Normal file
129
firmware/components/nfc_engine/session_capture.c
Normal file
@@ -0,0 +1,129 @@
|
||||
#include "nfc_engine/session_capture.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include <string.h>
|
||||
|
||||
#define SESSION_CAPTURE_BYTES (48 * 1024)
|
||||
|
||||
static char s_buf[SESSION_CAPTURE_BYTES];
|
||||
static size_t s_len;
|
||||
static uint32_t s_lines;
|
||||
static bool s_full;
|
||||
static bool s_deep;
|
||||
static SemaphoreHandle_t s_mu;
|
||||
|
||||
void session_capture_init(void)
|
||||
{
|
||||
s_mu = xSemaphoreCreateMutex();
|
||||
session_capture_clear();
|
||||
s_deep = false;
|
||||
}
|
||||
|
||||
void session_capture_clear(void)
|
||||
{
|
||||
if (s_mu) {
|
||||
xSemaphoreTake(s_mu, portMAX_DELAY);
|
||||
}
|
||||
s_len = 0;
|
||||
s_lines = 0;
|
||||
s_full = false;
|
||||
s_buf[0] = 0;
|
||||
if (s_mu) {
|
||||
xSemaphoreGive(s_mu);
|
||||
}
|
||||
}
|
||||
|
||||
bool session_capture_is_full(void)
|
||||
{
|
||||
return s_full;
|
||||
}
|
||||
|
||||
bool session_capture_deep_enabled(void) { return s_deep; }
|
||||
|
||||
void session_capture_set_deep(bool on) { s_deep = on; }
|
||||
|
||||
size_t session_capture_max(void) { return sizeof(s_buf) - 2; }
|
||||
|
||||
void session_capture_get_status(size_t *used_bytes, uint32_t *line_count, bool *full)
|
||||
{
|
||||
if (s_mu) {
|
||||
xSemaphoreTake(s_mu, portMAX_DELAY);
|
||||
}
|
||||
if (used_bytes) {
|
||||
*used_bytes = s_len;
|
||||
}
|
||||
if (line_count) {
|
||||
*line_count = s_lines;
|
||||
}
|
||||
if (full) {
|
||||
*full = s_full;
|
||||
}
|
||||
if (s_mu) {
|
||||
xSemaphoreGive(s_mu);
|
||||
}
|
||||
}
|
||||
|
||||
bool session_capture_append_line(const char *line)
|
||||
{
|
||||
if (!line || s_full) {
|
||||
return false;
|
||||
}
|
||||
size_t l = strlen(line);
|
||||
size_t need = l + 1; /* newline */
|
||||
if (s_mu) {
|
||||
xSemaphoreTake(s_mu, portMAX_DELAY);
|
||||
}
|
||||
if (s_full || s_len + need >= sizeof(s_buf)) {
|
||||
s_full = true;
|
||||
if (s_mu) {
|
||||
xSemaphoreGive(s_mu);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
memcpy(s_buf + s_len, line, l);
|
||||
s_len += l;
|
||||
s_buf[s_len++] = '\n';
|
||||
s_buf[s_len] = 0;
|
||||
s_lines++;
|
||||
if (s_len + 2 >= sizeof(s_buf)) {
|
||||
s_full = true;
|
||||
}
|
||||
if (s_mu) {
|
||||
xSemaphoreGive(s_mu);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t session_capture_export_size(void)
|
||||
{
|
||||
if (s_mu) {
|
||||
xSemaphoreTake(s_mu, portMAX_DELAY);
|
||||
}
|
||||
size_t n = s_len;
|
||||
if (s_mu) {
|
||||
xSemaphoreGive(s_mu);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const char *session_capture_export_ptr(void) { return s_buf; }
|
||||
|
||||
void session_capture_copy_to(char *dst, size_t cap, size_t *out_len)
|
||||
{
|
||||
if (s_mu) {
|
||||
xSemaphoreTake(s_mu, portMAX_DELAY);
|
||||
}
|
||||
size_t n = s_len;
|
||||
if (n > cap) {
|
||||
n = cap;
|
||||
}
|
||||
if (dst && n) {
|
||||
memcpy(dst, s_buf, n);
|
||||
}
|
||||
if (out_len) {
|
||||
*out_len = n;
|
||||
}
|
||||
if (s_mu) {
|
||||
xSemaphoreGive(s_mu);
|
||||
}
|
||||
}
|
||||
7
firmware/components/pn532_host/CMakeLists.txt
Normal file
7
firmware/components/pn532_host/CMakeLists.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"pn532_transport.c"
|
||||
"pn532_core.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES driver esp_timer freertos
|
||||
)
|
||||
84
firmware/components/pn532_host/Kconfig
Normal file
84
firmware/components/pn532_host/Kconfig
Normal file
@@ -0,0 +1,84 @@
|
||||
menu "PN532 Host"
|
||||
|
||||
choice PN532_TRANSPORT
|
||||
prompt "PN532 bus"
|
||||
default PN532_TRANSPORT_SPI
|
||||
config PN532_TRANSPORT_SPI
|
||||
bool "SPI"
|
||||
config PN532_TRANSPORT_I2C
|
||||
bool "I2C"
|
||||
config PN532_TRANSPORT_HSU
|
||||
bool "UART (HSU)"
|
||||
endchoice
|
||||
|
||||
config PN532_SPI_HOST
|
||||
int "SPI host (2=SPI2, 3=SPI3)"
|
||||
default 2
|
||||
depends on PN532_TRANSPORT_SPI
|
||||
|
||||
config PN532_SPI_MOSI_GPIO
|
||||
int "SPI MOSI GPIO"
|
||||
default 11
|
||||
depends on PN532_TRANSPORT_SPI
|
||||
|
||||
config PN532_SPI_MISO_GPIO
|
||||
int "SPI MISO GPIO"
|
||||
default 13
|
||||
depends on PN532_TRANSPORT_SPI
|
||||
|
||||
config PN532_SPI_SCLK_GPIO
|
||||
int "SPI SCLK GPIO"
|
||||
default 12
|
||||
depends on PN532_TRANSPORT_SPI
|
||||
|
||||
config PN532_SPI_CS_GPIO
|
||||
int "SPI CS GPIO"
|
||||
default 10
|
||||
depends on PN532_TRANSPORT_SPI
|
||||
|
||||
config PN532_SPI_CLOCK_HZ
|
||||
int "SPI clock Hz"
|
||||
default 100000
|
||||
depends on PN532_TRANSPORT_SPI
|
||||
|
||||
config PN532_I2C_PORT
|
||||
int "I2C port"
|
||||
default 0
|
||||
depends on PN532_TRANSPORT_I2C
|
||||
|
||||
config PN532_I2C_SDA_GPIO
|
||||
int "I2C SDA GPIO"
|
||||
default 8
|
||||
depends on PN532_TRANSPORT_I2C
|
||||
|
||||
config PN532_I2C_SCL_GPIO
|
||||
int "I2C SCL GPIO"
|
||||
default 9
|
||||
depends on PN532_TRANSPORT_I2C
|
||||
|
||||
config PN532_I2C_ADDR
|
||||
hex "PN532 I2C 7-bit address"
|
||||
default 0x24
|
||||
depends on PN532_TRANSPORT_I2C
|
||||
|
||||
config PN532_HSU_UART_NUM
|
||||
int "UART num for HSU"
|
||||
default 1
|
||||
depends on PN532_TRANSPORT_HSU
|
||||
|
||||
config PN532_HSU_TX_GPIO
|
||||
int "UART TX GPIO"
|
||||
default 17
|
||||
depends on PN532_TRANSPORT_HSU
|
||||
|
||||
config PN532_HSU_RX_GPIO
|
||||
int "UART RX GPIO"
|
||||
default 18
|
||||
depends on PN532_TRANSPORT_HSU
|
||||
|
||||
config PN532_HSU_BAUD
|
||||
int "UART baud"
|
||||
default 115200
|
||||
depends on PN532_TRANSPORT_HSU
|
||||
|
||||
endmenu
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define PN532_HOST_TO_PN532 0xD4
|
||||
#define PN532_PN532_TO_HOST 0xD5
|
||||
|
||||
#define PN532_CMD_GETFIRMWAREVERSION 0x02
|
||||
#define PN532_CMD_GETGENERALSTATUS 0x04
|
||||
#define PN532_CMD_SAMCONFIGURATION 0x14
|
||||
#define PN532_CMD_INLISTPASSIVETARGET 0x4A
|
||||
#define PN532_CMD_INDATAEXCHANGE 0x40
|
||||
#define PN532_CMD_INCOMMUNICATETHRU 0x42
|
||||
#define PN532_CMD_RFCONFIGURATION 0x32
|
||||
#define PN532_CMD_TGINITASTARGET 0x8C
|
||||
#define PN532_CMD_TGGETDATA 0x86
|
||||
#define PN532_CMD_TGSETDATA 0x8E
|
||||
#define PN532_CMD_POWERDOWN 0x16
|
||||
|
||||
#define PN532_MIFARE_CMD_AUTH_A 0x60
|
||||
#define PN532_MIFARE_CMD_AUTH_B 0x61
|
||||
#define PN532_MIFARE_CMD_READ 0x30
|
||||
#define PN532_MIFARE_CMD_WRITE 0xA0
|
||||
#define PN532_MIFARE_CMD_TRANSFER 0xB0
|
||||
|
||||
#define PN532_EEPROM_MAX_CMD_PAYLOAD 254
|
||||
|
||||
esp_err_t pn532_core_init(void);
|
||||
esp_err_t pn532_sam_config_normal(void);
|
||||
esp_err_t pn532_get_firmware_version(uint8_t *ic_ver, uint8_t *fw_ver_hi, uint8_t *fw_ver_lo);
|
||||
esp_err_t pn532_get_general_status(uint8_t *buf, size_t buf_len, size_t *out_len);
|
||||
|
||||
/**
|
||||
* Send full command body after TFI: [cmd] [params...]
|
||||
* response_data is pn532 payload after response TFI (first byte often status 0x00 = OK).
|
||||
*/
|
||||
esp_err_t pn532_send_cmd(const uint8_t *cmd_and_data, size_t len,
|
||||
uint8_t *response, size_t response_max,
|
||||
size_t *response_len, int timeout_ms);
|
||||
|
||||
esp_err_t pn532_in_list_passive_target(uint8_t max_targets, uint8_t baud,
|
||||
uint8_t *response, size_t response_max,
|
||||
size_t *response_len);
|
||||
|
||||
esp_err_t pn532_in_data_exchange(const uint8_t *data, size_t data_len,
|
||||
uint8_t *response, size_t response_max,
|
||||
size_t *response_len);
|
||||
|
||||
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);
|
||||
|
||||
/** RF field on/off via RFConfiguration (0x32) item 0x01, RF field */
|
||||
esp_err_t pn532_rf_field(bool on);
|
||||
|
||||
/** Max passive activation / RF retries (0x32 item 0x05) — improves weak-coupling reads */
|
||||
esp_err_t pn532_rf_max_retries(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
PN532_SPI,
|
||||
PN532_I2C,
|
||||
PN532_HSU,
|
||||
} pn532_bus_t;
|
||||
|
||||
esp_err_t pn532_transport_init(void);
|
||||
void pn532_transport_lock(void);
|
||||
void pn532_transport_unlock(void);
|
||||
|
||||
/** Raw PN532 frame body: TFI + payload (caller builds payload after TFI). */
|
||||
esp_err_t pn532_transport_exchange(const uint8_t *tx_body, size_t tx_body_len,
|
||||
uint8_t *rx_body, size_t rx_body_max,
|
||||
size_t *rx_body_len, int timeout_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
114
firmware/components/pn532_host/pn532_core.c
Normal file
114
firmware/components/pn532_host/pn532_core.c
Normal file
@@ -0,0 +1,114 @@
|
||||
#include "pn532_host/pn532_core.h"
|
||||
#include "pn532_host/pn532_transport.h"
|
||||
#include "esp_log.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "pn532_core";
|
||||
|
||||
esp_err_t pn532_send_cmd(const uint8_t *cmd_and_data, size_t len, uint8_t *response, size_t response_max,
|
||||
size_t *response_len, int timeout_ms)
|
||||
{
|
||||
if (!cmd_and_data || !response || !response_len) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
pn532_transport_lock();
|
||||
esp_err_t err =
|
||||
pn532_transport_exchange(cmd_and_data, len, response, response_max, response_len, timeout_ms);
|
||||
pn532_transport_unlock();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
if (*response_len < 1) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
if (response[0] != 0x00) {
|
||||
ESP_LOGW(TAG, "chip status 0x%02x", response[0]);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pn532_core_init(void)
|
||||
{
|
||||
return pn532_transport_init();
|
||||
}
|
||||
|
||||
esp_err_t pn532_get_firmware_version(uint8_t *ic_ver, uint8_t *fw_ver_hi, uint8_t *fw_ver_lo)
|
||||
{
|
||||
uint8_t cmd = PN532_CMD_GETFIRMWAREVERSION;
|
||||
uint8_t resp[16];
|
||||
size_t rlen = 0;
|
||||
esp_err_t e = pn532_send_cmd(&cmd, 1, resp, sizeof(resp), &rlen, 200);
|
||||
if (e != ESP_OK) {
|
||||
return e;
|
||||
}
|
||||
if (rlen < 4) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
*ic_ver = resp[1];
|
||||
*fw_ver_hi = resp[2];
|
||||
*fw_ver_lo = resp[3];
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pn532_sam_config_normal(void)
|
||||
{
|
||||
uint8_t buf[] = {PN532_CMD_SAMCONFIGURATION, 0x01, 0x14, 0x01};
|
||||
uint8_t resp[8];
|
||||
size_t rlen = 0;
|
||||
return pn532_send_cmd(buf, sizeof(buf), resp, sizeof(resp), &rlen, 200);
|
||||
}
|
||||
|
||||
esp_err_t pn532_get_general_status(uint8_t *buf, size_t buf_len, size_t *out_len)
|
||||
{
|
||||
uint8_t cmd = PN532_CMD_GETGENERALSTATUS;
|
||||
return pn532_send_cmd(&cmd, 1, buf, buf_len, out_len, 200);
|
||||
}
|
||||
|
||||
esp_err_t pn532_rf_field(bool on)
|
||||
{
|
||||
uint8_t buf[] = {PN532_CMD_RFCONFIGURATION, 0x01, on ? 0x01 : 0x00};
|
||||
uint8_t resp[8];
|
||||
size_t rlen = 0;
|
||||
return pn532_send_cmd(buf, sizeof(buf), resp, sizeof(resp), &rlen, 200);
|
||||
}
|
||||
|
||||
esp_err_t pn532_rf_max_retries(void)
|
||||
{
|
||||
/* RFConfiguration 0x05 MaxRetries: ATR/PSL/PassiveActivation — high values = more sensitivity to marginal tags */
|
||||
uint8_t buf[] = {PN532_CMD_RFCONFIGURATION, 0x05, 0xFF, 0xFF, 0xFF};
|
||||
uint8_t resp[8];
|
||||
size_t rlen = 0;
|
||||
return pn532_send_cmd(buf, sizeof(buf), resp, sizeof(resp), &rlen, 200);
|
||||
}
|
||||
|
||||
esp_err_t pn532_in_list_passive_target(uint8_t max_targets, uint8_t baud, uint8_t *response,
|
||||
size_t response_max, size_t *response_len)
|
||||
{
|
||||
uint8_t buf[] = {PN532_CMD_INLISTPASSIVETARGET, max_targets, baud};
|
||||
return pn532_send_cmd(buf, sizeof(buf), response, response_max, response_len, 500);
|
||||
}
|
||||
|
||||
esp_err_t pn532_in_data_exchange(const uint8_t *data, size_t data_len, uint8_t *response,
|
||||
size_t response_max, size_t *response_len)
|
||||
{
|
||||
if (!data || data_len == 0 || data_len > PN532_EEPROM_MAX_CMD_PAYLOAD - 1) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
uint8_t buf[PN532_EEPROM_MAX_CMD_PAYLOAD + 1];
|
||||
buf[0] = PN532_CMD_INDATAEXCHANGE;
|
||||
buf[1] = 0x01; /* logical target 1 */
|
||||
memcpy(buf + 2, data, data_len);
|
||||
return pn532_send_cmd(buf, 2 + data_len, response, response_max, response_len, 500);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (!data || data_len > PN532_EEPROM_MAX_CMD_PAYLOAD - 1) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
uint8_t buf[PN532_EEPROM_MAX_CMD_PAYLOAD + 1];
|
||||
buf[0] = PN532_CMD_INCOMMUNICATETHRU;
|
||||
memcpy(buf + 1, data, data_len);
|
||||
return pn532_send_cmd(buf, 1 + data_len, response, response_max, response_len, 500);
|
||||
}
|
||||
361
firmware/components/pn532_host/pn532_transport.c
Normal file
361
firmware/components/pn532_host/pn532_transport.c
Normal file
@@ -0,0 +1,361 @@
|
||||
#include "pn532_host/pn532_transport.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_timer.h"
|
||||
#include "driver/i2c.h"
|
||||
#include "driver/spi_master.h"
|
||||
#include "driver/uart.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include <string.h>
|
||||
#include <sys/param.h>
|
||||
|
||||
#define PN532_HOST_TO_PN532 0xD4
|
||||
#define PN532_TXBUF_MAX 264
|
||||
|
||||
static const char *TAG = "pn532_xport";
|
||||
|
||||
#if defined(CONFIG_PN532_TRANSPORT_SPI)
|
||||
static spi_host_device_t pn532_spi_host(void)
|
||||
{
|
||||
return CONFIG_PN532_SPI_HOST == 3 ? SPI3_HOST : SPI2_HOST;
|
||||
}
|
||||
#endif
|
||||
|
||||
static SemaphoreHandle_t s_bus_mutex;
|
||||
|
||||
#if defined(CONFIG_PN532_TRANSPORT_SPI)
|
||||
static spi_device_handle_t s_spi;
|
||||
static int s_spi_cs_gpio = CONFIG_PN532_SPI_CS_GPIO;
|
||||
|
||||
static esp_err_t spi_wait_ready(int timeout_ms)
|
||||
{
|
||||
uint8_t status = 0;
|
||||
const int64_t end = esp_timer_get_time() / 1000 + timeout_ms;
|
||||
while ((esp_timer_get_time() / 1000) < (uint64_t)end) {
|
||||
spi_transaction_t t = {};
|
||||
uint8_t tx = 0x02; /* SPIstatus read */
|
||||
t.length = 8;
|
||||
t.tx_buffer = &tx;
|
||||
t.rx_buffer = &status;
|
||||
gpio_set_level(s_spi_cs_gpio, 0);
|
||||
esp_err_t e = spi_device_polling_transmit(s_spi, &t);
|
||||
gpio_set_level(s_spi_cs_gpio, 1);
|
||||
if (e != ESP_OK) {
|
||||
return e;
|
||||
}
|
||||
if (status & 0x01) {
|
||||
return ESP_OK;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
}
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
static esp_err_t spi_write_frame(const uint8_t *data, size_t len)
|
||||
{
|
||||
ESP_RETURN_ON_ERROR(spi_wait_ready(200), TAG, "wait before write");
|
||||
gpio_set_level(s_spi_cs_gpio, 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(2));
|
||||
uint8_t hdr = 0x04; /* Data write */
|
||||
spi_transaction_t t0 = {};
|
||||
t0.length = 8;
|
||||
t0.tx_buffer = &hdr;
|
||||
ESP_RETURN_ON_ERROR(spi_device_polling_transmit(s_spi, &t0), TAG, "hdr");
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
spi_transaction_t t = {};
|
||||
t.length = 8;
|
||||
t.tx_buffer = &data[i];
|
||||
ESP_RETURN_ON_ERROR(spi_device_polling_transmit(s_spi, &t), TAG, "w");
|
||||
}
|
||||
gpio_set_level(s_spi_cs_gpio, 1);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t spi_read_bytes(uint8_t *out, size_t len)
|
||||
{
|
||||
ESP_RETURN_ON_ERROR(spi_wait_ready(300), TAG, "wait read");
|
||||
gpio_set_level(s_spi_cs_gpio, 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(2));
|
||||
uint8_t hdr = 0x03; /* Data read */
|
||||
spi_transaction_t t0 = {};
|
||||
t0.length = 8;
|
||||
t0.tx_buffer = &hdr;
|
||||
ESP_RETURN_ON_ERROR(spi_device_polling_transmit(s_spi, &t0), TAG, "rhdr");
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
spi_transaction_t t = {};
|
||||
uint8_t tx = 0xFF;
|
||||
t.length = 8;
|
||||
t.tx_buffer = &tx;
|
||||
t.rx_buffer = &out[i];
|
||||
ESP_RETURN_ON_ERROR(spi_device_polling_transmit(s_spi, &t), TAG, "r");
|
||||
}
|
||||
gpio_set_level(s_spi_cs_gpio, 1);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_I2C)
|
||||
#define PN532_I2C_PORT ((i2c_port_t)CONFIG_PN532_I2C_PORT)
|
||||
|
||||
static esp_err_t i2c_wakeup(void)
|
||||
{
|
||||
const uint8_t w[] = {0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
i2c_cmd_handle_t c = i2c_cmd_link_create();
|
||||
i2c_master_start(c);
|
||||
i2c_master_write_byte(c, (CONFIG_PN532_I2C_ADDR << 1) | I2C_MASTER_WRITE, true);
|
||||
i2c_master_write(c, w, sizeof(w), I2C_MASTER_LAST_NACK);
|
||||
i2c_master_stop(c);
|
||||
esp_err_t e = i2c_master_cmd_begin(PN532_I2C_PORT, c, pdMS_TO_TICKS(50));
|
||||
i2c_cmd_link_delete(c);
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
return e;
|
||||
}
|
||||
|
||||
static esp_err_t i2c_write_raw(const uint8_t *buf, size_t len)
|
||||
{
|
||||
i2c_cmd_handle_t c = i2c_cmd_link_create();
|
||||
i2c_master_start(c);
|
||||
i2c_master_write_byte(c, (CONFIG_PN532_I2C_ADDR << 1) | I2C_MASTER_WRITE, true);
|
||||
i2c_master_write(c, buf, len, I2C_MASTER_LAST_NACK);
|
||||
i2c_master_stop(c);
|
||||
esp_err_t e = i2c_master_cmd_begin(PN532_I2C_PORT, c, pdMS_TO_TICKS(200));
|
||||
i2c_cmd_link_delete(c);
|
||||
return e;
|
||||
}
|
||||
|
||||
static esp_err_t i2c_read_raw(uint8_t *buf, size_t len)
|
||||
{
|
||||
i2c_cmd_handle_t c = i2c_cmd_link_create();
|
||||
i2c_master_start(c);
|
||||
i2c_master_write_byte(c, (CONFIG_PN532_I2C_ADDR << 1) | I2C_MASTER_READ, true);
|
||||
if (len > 1) {
|
||||
i2c_master_read(c, buf, len - 1, I2C_MASTER_ACK);
|
||||
}
|
||||
i2c_master_read_byte(c, buf + len - 1, I2C_MASTER_NACK);
|
||||
i2c_master_stop(c);
|
||||
esp_err_t e = i2c_master_cmd_begin(PN532_I2C_PORT, c, pdMS_TO_TICKS(200));
|
||||
i2c_cmd_link_delete(c);
|
||||
return e;
|
||||
}
|
||||
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_HSU)
|
||||
#define PN532_UART ((uart_port_t)CONFIG_PN532_HSU_UART_NUM)
|
||||
|
||||
static esp_err_t hsu_write_raw(const uint8_t *buf, size_t len)
|
||||
{
|
||||
int n = uart_write_bytes(PN532_UART, buf, len);
|
||||
return (n == (int)len) ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
static esp_err_t hsu_read_raw(uint8_t *buf, size_t len, int timeout_ms)
|
||||
{
|
||||
size_t got = 0;
|
||||
int64_t start = esp_timer_get_time();
|
||||
while (got < len) {
|
||||
int n = uart_read_bytes(PN532_UART, buf + got, len - got,
|
||||
pdMS_TO_TICKS(MAX(1, timeout_ms)));
|
||||
if (n > 0) {
|
||||
got += (size_t)n;
|
||||
}
|
||||
if ((esp_timer_get_time() - start) / 1000 > timeout_ms) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return got == len ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
#endif
|
||||
|
||||
void pn532_transport_lock(void) { xSemaphoreTake(s_bus_mutex, portMAX_DELAY); }
|
||||
|
||||
void pn532_transport_unlock(void) { xSemaphoreGive(s_bus_mutex); }
|
||||
|
||||
static esp_err_t read_ack(int timeout_ms)
|
||||
{
|
||||
const uint8_t ack_ok[] = {0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00};
|
||||
uint8_t ack[6];
|
||||
#if defined(CONFIG_PN532_TRANSPORT_SPI)
|
||||
ESP_RETURN_ON_ERROR(spi_read_bytes(ack, sizeof(ack)), TAG, "read ack spi");
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_I2C)
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
ESP_RETURN_ON_ERROR(i2c_read_raw(ack, sizeof(ack)), TAG, "read ack i2c");
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_HSU)
|
||||
ESP_RETURN_ON_ERROR(hsu_read_raw(ack, sizeof(ack), timeout_ms), TAG, "read ack hsu");
|
||||
#endif
|
||||
if (memcmp(ack, ack_ok, 6) != 0) {
|
||||
ESP_LOG_BUFFER_HEX_LEVEL(TAG, ack, 6, ESP_LOG_WARN);
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t read_response_frame(uint8_t *body_out, size_t body_max, size_t *body_len, int timeout_ms)
|
||||
{
|
||||
uint8_t hdr[8];
|
||||
#if defined(CONFIG_PN532_TRANSPORT_SPI)
|
||||
ESP_RETURN_ON_ERROR(spi_read_bytes(hdr, 6), TAG, "hdr spi");
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_I2C)
|
||||
{
|
||||
int64_t t0 = esp_timer_get_time() / 1000;
|
||||
bool ok = false;
|
||||
while (((esp_timer_get_time() / 1000) - t0) < timeout_ms) {
|
||||
uint8_t peek[1];
|
||||
if (i2c_read_raw(peek, 1) == ESP_OK && peek[0] == 0x01) {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(2));
|
||||
}
|
||||
if (!ok) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
ESP_RETURN_ON_ERROR(i2c_read_raw(hdr, 6), TAG, "hdr i2c");
|
||||
}
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_HSU)
|
||||
ESP_RETURN_ON_ERROR(hsu_read_raw(hdr, 6, timeout_ms), TAG, "hdr hsu");
|
||||
#endif
|
||||
if (hdr[0] != 0x00 || hdr[1] != 0x00 || hdr[2] != 0xFF) {
|
||||
ESP_LOG_BUFFER_HEX_LEVEL(TAG, hdr, 6, ESP_LOG_WARN);
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
uint16_t L = (uint16_t)(hdr[3] * 256 + hdr[4]);
|
||||
uint8_t lcs = hdr[5];
|
||||
if ((uint8_t)((hdr[3] + hdr[4] + lcs) & 0xFF) != 0) {
|
||||
return ESP_ERR_INVALID_CRC;
|
||||
}
|
||||
if (L < 2) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
/* Read TFI..data (L bytes) + DCS — L includes TFI through last payload byte */
|
||||
const size_t read_total = (size_t)L + 1;
|
||||
if (read_total > 270) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
uint8_t chunk[272];
|
||||
#if defined(CONFIG_PN532_TRANSPORT_SPI)
|
||||
ESP_RETURN_ON_ERROR(spi_read_bytes(chunk, read_total), TAG, "payload spi");
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_I2C)
|
||||
ESP_RETURN_ON_ERROR(i2c_read_raw(chunk, read_total), TAG, "payload i2c");
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_HSU)
|
||||
ESP_RETURN_ON_ERROR(hsu_read_raw(chunk, read_total, timeout_ms), TAG, "payload hsu");
|
||||
#endif
|
||||
uint8_t tfi = chunk[0];
|
||||
if (tfi != 0xD5) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
uint8_t sum = 0;
|
||||
for (uint16_t i = 0; i < L; i++) {
|
||||
sum += chunk[i];
|
||||
}
|
||||
uint8_t dcs = chunk[L];
|
||||
if ((uint8_t)((sum + dcs) & 0xFF) != 0) {
|
||||
return ESP_ERR_INVALID_CRC;
|
||||
}
|
||||
*body_len = (size_t)L - 1;
|
||||
if (*body_len > body_max) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
memcpy(body_out, chunk + 1, *body_len);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pn532_transport_exchange(const uint8_t *tx_body, size_t tx_body_len, uint8_t *rx_body,
|
||||
size_t rx_body_max, size_t *rx_body_len, int timeout_ms)
|
||||
{
|
||||
if (tx_body_len == 0 || tx_body_len > 255) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
uint16_t L = (uint16_t)(1 + tx_body_len);
|
||||
uint8_t lcs = (uint8_t)(0x100 - (uint8_t)(((L >> 8) + (L & 0xFF)) & 0xFF));
|
||||
uint8_t sum = PN532_HOST_TO_PN532;
|
||||
for (size_t i = 0; i < tx_body_len; i++) {
|
||||
sum += tx_body[i];
|
||||
}
|
||||
dcs = (uint8_t)(256 - sum);
|
||||
|
||||
uint8_t frame[PN532_TXBUF_MAX];
|
||||
size_t pos = 0;
|
||||
frame[pos++] = 0x00;
|
||||
frame[pos++] = 0x00;
|
||||
frame[pos++] = 0xFF;
|
||||
frame[pos++] = (uint8_t)((L >> 8) & 0xFF);
|
||||
frame[pos++] = (uint8_t)(L & 0xFF);
|
||||
frame[pos++] = lcs;
|
||||
frame[pos++] = PN532_HOST_TO_PN532;
|
||||
memcpy(frame + pos, tx_body, tx_body_len);
|
||||
pos += tx_body_len;
|
||||
frame[pos++] = dcs;
|
||||
|
||||
#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_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");
|
||||
#endif
|
||||
|
||||
ESP_RETURN_ON_ERROR(read_ack(timeout_ms), TAG, "ack");
|
||||
return read_response_frame(rx_body, rx_body_max, rx_body_len, timeout_ms);
|
||||
}
|
||||
|
||||
esp_err_t pn532_transport_init(void)
|
||||
{
|
||||
s_bus_mutex = xSemaphoreCreateMutex();
|
||||
if (!s_bus_mutex) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
#if defined(CONFIG_PN532_TRANSPORT_SPI)
|
||||
spi_bus_config_t buscfg = {
|
||||
.mosi_io_num = CONFIG_PN532_SPI_MOSI_GPIO,
|
||||
.miso_io_num = CONFIG_PN532_SPI_MISO_GPIO,
|
||||
.sclk_io_num = CONFIG_PN532_SPI_SCLK_GPIO,
|
||||
.quadwp_io_num = -1,
|
||||
.quadhd_io_num = -1,
|
||||
.max_transfer_sz = 512,
|
||||
};
|
||||
spi_host_device_t host = pn532_spi_host();
|
||||
ESP_RETURN_ON_ERROR(spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO), TAG, "spi bus");
|
||||
spi_device_interface_config_t devcfg = {
|
||||
.clock_speed_hz = CONFIG_PN532_SPI_CLOCK_HZ,
|
||||
.mode = 0,
|
||||
.spics_io_num = -1, /* manual CS */
|
||||
.queue_size = 4,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(spi_bus_add_device(host, &devcfg, &s_spi), TAG, "spi dev");
|
||||
gpio_reset_pin((gpio_num_t)s_spi_cs_gpio);
|
||||
gpio_set_direction((gpio_num_t)s_spi_cs_gpio, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(s_spi_cs_gpio, 1);
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_I2C)
|
||||
i2c_config_t ic = {
|
||||
.mode = I2C_MODE_MASTER,
|
||||
.sda_io_num = CONFIG_PN532_I2C_SDA_GPIO,
|
||||
.scl_io_num = CONFIG_PN532_I2C_SCL_GPIO,
|
||||
.sda_pullup_en = GPIO_PULLUP_ENABLE,
|
||||
.scl_pullup_en = GPIO_PULLUP_ENABLE,
|
||||
.master = {.clk_speed = 400000},
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(i2c_param_config(PN532_I2C_PORT, &ic), TAG, "i2c cfg");
|
||||
ESP_RETURN_ON_ERROR(i2c_driver_install(PN532_I2C_PORT, I2C_MODE_MASTER, 0, 0, 0), TAG, "i2c drvr");
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_HSU)
|
||||
uart_config_t uc = {
|
||||
.baud_rate = CONFIG_PN532_HSU_BAUD,
|
||||
.data_bits = UART_DATA_8_BITS,
|
||||
.parity = UART_PARITY_DISABLE,
|
||||
.stop_bits = UART_STOP_BITS_1,
|
||||
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
|
||||
.source_clk = UART_SCLK_DEFAULT,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(uart_driver_install(PN532_UART, 2048, 2048, 0, NULL, 0), TAG, "uart");
|
||||
ESP_RETURN_ON_ERROR(uart_param_config(PN532_UART, &uc), TAG, "uart cfg");
|
||||
ESP_RETURN_ON_ERROR(uart_set_pin(PN532_UART, CONFIG_PN532_HSU_TX_GPIO, CONFIG_PN532_HSU_RX_GPIO,
|
||||
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE),
|
||||
TAG, "uart pins");
|
||||
#endif
|
||||
return ESP_OK;
|
||||
}
|
||||
</think>
|
||||
Fixing a typo in `pn532_transport.c` and correcting the DCS checksum calculation.
|
||||
|
||||
<|tool▁calls▁begin|><|tool▁call▁begin|>
|
||||
Read
|
||||
80
firmware/data/assets/index-EAAhhled.js
Normal file
80
firmware/data/assets/index-EAAhhled.js
Normal file
File diff suppressed because one or more lines are too long
1
firmware/data/assets/index-hoMg1Qkq.css
Normal file
1
firmware/data/assets/index-hoMg1Qkq.css
Normal file
File diff suppressed because one or more lines are too long
20
firmware/data/index.html
Normal file
20
firmware/data/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark hack-root">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#020408" />
|
||||
<title>PN532 // MAXIMAL_FIELD</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
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-EAAhhled.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hoMg1Qkq.css">
|
||||
</head>
|
||||
<body class="bg-bubble-950 text-slate-200 antialiased selection:bg-bubble-accent/40 selection:text-bubble-950">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
firmware/flash.sh
Executable file
12
firmware/flash.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Flash PN532 toolkit. Requires ESP-IDF 5.x in PATH (run export.sh first).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
PORT="${ESPPORT:-${1:-/dev/cu.usbserial-A5069RR4}}"
|
||||
cd "$ROOT"
|
||||
if ! command -v idf.py >/dev/null 2>&1; then
|
||||
echo "idf.py not found. Source your ESP-IDF export.sh, then re-run:" >&2
|
||||
echo " ESPPORT=$PORT $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
idf.py -p "$PORT" flash
|
||||
3
firmware/main/CMakeLists.txt
Normal file
3
firmware/main/CMakeLists.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
idf_component_register(SRCS "main.c" "board_rgb_off.c" INCLUDE_DIRS "." REQUIRES net_service nfc_engine led_strip)
|
||||
|
||||
spiffs_create_partition_image(storage ../data FLASH_IN_PROJECT)
|
||||
18
firmware/main/Kconfig.projbuild
Normal file
18
firmware/main/Kconfig.projbuild
Normal file
@@ -0,0 +1,18 @@
|
||||
menu "Board indicators"
|
||||
|
||||
config BOARD_RGB_LED_ENABLE
|
||||
bool "Turn off onboard addressable RGB at boot (WS2812/SK6812)"
|
||||
default y
|
||||
help
|
||||
ESP32-S3-DevKitC-1 v1.x typically uses one SK6812/WS2812 on GPIO 48.
|
||||
Disable if your board has no addressable LED or uses a different data pin.
|
||||
|
||||
config BOARD_RGB_LED_GPIO
|
||||
int "Addressable LED data GPIO"
|
||||
default 48
|
||||
range 0 48
|
||||
depends on BOARD_RGB_LED_ENABLE
|
||||
help
|
||||
Official DevKitC-1 v1.1: GPIO 48. Older notes sometimes cite GPIO 38 — set in menuconfig if needed.
|
||||
|
||||
endmenu
|
||||
37
firmware/main/board_rgb_off.c
Normal file
37
firmware/main/board_rgb_off.c
Normal file
@@ -0,0 +1,37 @@
|
||||
#include "sdkconfig.h"
|
||||
#include "board_rgb_off.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#if CONFIG_BOARD_RGB_LED_ENABLE
|
||||
#include "esp_err.h"
|
||||
#include "led_strip.h"
|
||||
#endif
|
||||
|
||||
void board_rgb_led_quiet(void)
|
||||
{
|
||||
#if CONFIG_BOARD_RGB_LED_ENABLE
|
||||
led_strip_handle_t strip = NULL;
|
||||
const led_strip_config_t strip_config = {
|
||||
.strip_gpio_num = CONFIG_BOARD_RGB_LED_GPIO,
|
||||
.max_leds = 1,
|
||||
.led_pixel_format = LED_PIXEL_FORMAT_GRB,
|
||||
.led_model = LED_MODEL_WS2812,
|
||||
.flags = {.invert_out = false},
|
||||
};
|
||||
const led_strip_rmt_config_t rmt_config = {
|
||||
.clk_src = RMT_CLK_SRC_DEFAULT,
|
||||
.resolution_hz = 10 * 1000 * 1000,
|
||||
.flags = {.with_dma = false},
|
||||
};
|
||||
esp_err_t err = led_strip_new_rmt_device(&strip_config, &rmt_config, &strip);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW("board_rgb", "RGB init failed (%s), leaving LED as-is", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
err = led_strip_clear(strip);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW("board_rgb", "RGB clear failed (%s)", esp_err_to_name(err));
|
||||
}
|
||||
led_strip_del(strip);
|
||||
#endif
|
||||
}
|
||||
4
firmware/main/board_rgb_off.h
Normal file
4
firmware/main/board_rgb_off.h
Normal file
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
/** One-shot: drive onboard WS2812/SK6812 to black, then release RMT. */
|
||||
void board_rgb_led_quiet(void);
|
||||
3
firmware/main/idf_component.yml
Normal file
3
firmware/main/idf_component.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
## IDF Component Manager — addressable RGB (DevKitC-1)
|
||||
dependencies:
|
||||
espressif/led_strip: "^2.5.5"
|
||||
17
firmware/main/main.c
Normal file
17
firmware/main/main.c
Normal file
@@ -0,0 +1,17 @@
|
||||
#include "esp_log.h"
|
||||
#include "board_rgb_off.h"
|
||||
#include "nfc_engine/nfc_engine.h"
|
||||
#include "nfc_engine/session_capture.h"
|
||||
#include "net_service/app_net.h"
|
||||
|
||||
static const char *TAG = "main";
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
board_rgb_led_quiet();
|
||||
ESP_LOGI(TAG, "PN532 NFC Toolkit starting");
|
||||
ESP_ERROR_CHECK(nfc_engine_init());
|
||||
session_capture_init();
|
||||
ESP_ERROR_CHECK(app_net_init());
|
||||
ESP_LOGI(TAG, "Open AP SSID PN532-Toolkit — http://192.168.4.1");
|
||||
}
|
||||
9
firmware/partitions.csv
Normal file
9
firmware/partitions.csv
Normal file
@@ -0,0 +1,9 @@
|
||||
# 8MB flash layout (ESP32-S3-DevKitC-1 N8). For 16MB, increase storage size.
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
otadata, data, ota, 0xf000, 0x2000,
|
||||
phy_init, data, phy, 0x11000, 0x1000,
|
||||
factory, app, factory, 0x20000, 0x180000,
|
||||
ota_0, app, ota_0, 0x1A0000,0x180000,
|
||||
ota_1, app, ota_1, 0x320000,0x180000,
|
||||
storage, data, spiffs, 0x4A0000,0x350000,
|
||||
|
34
firmware/sdkconfig.defaults
Normal file
34
firmware/sdkconfig.defaults
Normal file
@@ -0,0 +1,34 @@
|
||||
CONFIG_IDF_TARGET_ESP32S3=y
|
||||
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
|
||||
CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
|
||||
|
||||
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
|
||||
# Enable if module has PSRAM (e.g. N8R8)
|
||||
# CONFIG_SPIRAM=y
|
||||
# CONFIG_SPIRAM_MODE_OCT=y
|
||||
# CONFIG_SPIRAM_SPEED_80M=y
|
||||
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
CONFIG_FREERTOS_HZ=1000
|
||||
|
||||
CONFIG_HTTPD_WS_SUPPORT=y
|
||||
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
|
||||
CONFIG_HTTPD_MAX_URI_LEN=512
|
||||
CONFIG_ESP_HTTP_CLIENT_ENABLE_HTTPS=y
|
||||
|
||||
CONFIG_LWIP_LOCAL_IP4_TTL=64
|
||||
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10
|
||||
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32
|
||||
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_OFFSET=0x8000
|
||||
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE=y
|
||||
|
||||
# mDNS
|
||||
CONFIG_MDNS_MAX_SERVICES=10
|
||||
19
web/index.html
Normal file
19
web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark hack-root">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#020408" />
|
||||
<title>PN532 // MAXIMAL_FIELD</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
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"
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-bubble-950 text-slate-200 antialiased selection:bg-bubble-accent/40 selection:text-bubble-950">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2757
web/package-lock.json
generated
Normal file
2757
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
web/package.json
Normal file
28
web/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "pn532-toolkit-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:fw": "vite build && node scripts/sync-fw-data.mjs",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^11.11.17",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
15
web/scripts/sync-fw-data.mjs
Normal file
15
web/scripts/sync-fw-data.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cp, readdir, rm, mkdir } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, "..");
|
||||
const dist = join(root, "dist");
|
||||
const target = join(root, "../firmware/data");
|
||||
|
||||
await rm(target, { recursive: true, force: true });
|
||||
await mkdir(target, { recursive: true });
|
||||
for (const name of await readdir(dist)) {
|
||||
await cp(join(dist, name), join(target, name), { recursive: true });
|
||||
}
|
||||
console.log("Synced web build → firmware/data");
|
||||
129
web/src/App.tsx
Normal file
129
web/src/App.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { motion } from "framer-motion";
|
||||
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 { ToastHost } from "./toast";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ReadAnalyze from "./pages/ReadAnalyze";
|
||||
import WriteClone from "./pages/WriteClone";
|
||||
import Library from "./pages/Library";
|
||||
import Keys from "./pages/Keys";
|
||||
import RawConsole from "./pages/RawConsole";
|
||||
import Settings from "./pages/Settings";
|
||||
import Capture from "./pages/Capture";
|
||||
import Brute from "./pages/Brute";
|
||||
import Emulate from "./pages/Emulate";
|
||||
import KeyLab from "./pages/KeyLab";
|
||||
|
||||
const nav = [
|
||||
["/", "Dash"],
|
||||
["/capture", "Read-all"],
|
||||
["/read", "Read"],
|
||||
["/write", "Write"],
|
||||
["/brute", "Brute"],
|
||||
["/emulate", "Emu"],
|
||||
["/keylab", "KeyLab"],
|
||||
["/library", "Lib"],
|
||||
["/keys", "Keys"],
|
||||
["/raw", "Raw"],
|
||||
["/settings", "Set"],
|
||||
] as const;
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ToastHost>
|
||||
<NfcWsProvider>
|
||||
<div className="hack-scanlines hack-grid relative min-h-screen pb-20">
|
||||
<FlashBackdrop />
|
||||
<ScanCashFlourish />
|
||||
<BrowserLogBar />
|
||||
<header className="relative z-40 border-b border-bubble-accent/20 bg-bubble-950/80 backdrop-blur-xl">
|
||||
<div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-bubble-mint/60 to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-bubble-rose/30 via-bubble-accent/40 to-bubble-mint/30 opacity-80" />
|
||||
<div className="relative mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-4">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
||||
<motion.div
|
||||
className="font-display text-lg font-normal tracking-[0.12em] sm:text-xl md:text-2xl"
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ type: "spring", stiffness: 120, damping: 18 }}
|
||||
>
|
||||
<motion.span
|
||||
className="bg-gradient-to-r from-bubble-mint via-bubble-accent to-bubble-rose bg-clip-text text-transparent text-glow-matrix"
|
||||
animate={{
|
||||
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
|
||||
}}
|
||||
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
|
||||
style={{
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, #00ff9d, #00e5ff, #ff2a6d, #d4ff00, #00ff9d, #00ff9d)",
|
||||
backgroundSize: "250% 100%",
|
||||
}}
|
||||
>
|
||||
PN532
|
||||
</motion.span>
|
||||
<span className="ml-2 text-[9px] font-mono font-normal tracking-[0.35em] text-bubble-accent/60 sm:text-[10px]">
|
||||
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>
|
||||
</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]) => (
|
||||
<NavLink key={to} to={to}>
|
||||
{({ isActive }) => (
|
||||
<motion.span
|
||||
className={`inline-block rounded-md border px-1.5 py-1 sm:px-2 sm:py-1.5 ${
|
||||
isActive
|
||||
? "nav-hack-active"
|
||||
: "border-transparent text-slate-500 hover:border-bubble-accent/35 hover:text-bubble-accent hover:shadow-[0_0_18px_rgba(0,229,255,0.25)]"
|
||||
}`}
|
||||
whileHover={{ scale: 1.06, y: -1 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 22 }}
|
||||
>
|
||||
<span className="text-bubble-mint/35">⟨</span>
|
||||
{label}
|
||||
<span className="text-bubble-mint/35">⟩</span>
|
||||
</motion.span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="relative z-10 mx-auto max-w-6xl px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/capture" element={<Capture />} />
|
||||
<Route path="/read" element={<ReadAnalyze />} />
|
||||
<Route path="/write" element={<WriteClone />} />
|
||||
<Route path="/brute" element={<Brute />} />
|
||||
<Route path="/emulate" element={<Emulate />} />
|
||||
<Route path="/keylab" element={<KeyLab />} />
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/keys" element={<Keys />} />
|
||||
<Route path="/raw" element={<RawConsole />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
</NfcWsProvider>
|
||||
</ToastHost>
|
||||
);
|
||||
}
|
||||
49
web/src/BrowserLogBar.tsx
Normal file
49
web/src/BrowserLogBar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useNfcWs } from "./NfcWsContext";
|
||||
|
||||
export default function BrowserLogBar() {
|
||||
const { log, exportBrowserLog, clearBrowserLog, wsOk } = useNfcWs();
|
||||
return (
|
||||
<div className="flash-log-bar relative z-40 mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-2 overflow-hidden border-b border-bubble-accent/15 px-4 py-2.5 font-mono text-[10px] text-bubble-mint/80 sm:text-xs">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-bubble-accent/10 to-transparent animate-shimmerLine" />
|
||||
</div>
|
||||
<div className="relative flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="shrink-0 animate-pulse text-bubble-volt">◆</span>
|
||||
<span className="truncate">
|
||||
<span className="text-bubble-accent/70">BUF</span>{" "}
|
||||
<span className="text-glow-matrix font-bold text-bubble-mint">{log.length}</span>
|
||||
<span className="text-bubble-mint/40">_evt</span>
|
||||
</span>
|
||||
<span className="hidden text-bubble-mint/20 sm:inline">│</span>
|
||||
<span>
|
||||
<span className="text-bubble-accent/60">WS</span>{" "}
|
||||
<span
|
||||
className={
|
||||
wsOk
|
||||
? "text-glow-matrix font-bold tracking-wide text-bubble-mint"
|
||||
: "animate-pulse text-glow-rose font-semibold text-bubble-rose"
|
||||
}
|
||||
>
|
||||
{wsOk ? "SYNC" : "WAIT"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportBrowserLog}
|
||||
className="btn-neon rounded-md border border-bubble-accent/50 bg-gradient-to-r from-bubble-accent/25 to-bubble-mint/15 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-bubble-accent sm:text-xs"
|
||||
>
|
||||
Exfil
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearBrowserLog}
|
||||
className="rounded-md border border-bubble-rose/40 bg-bubble-rose/10 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wide text-bubble-rose transition hover:border-bubble-rose hover:bg-bubble-rose/20 sm:text-xs"
|
||||
>
|
||||
Purge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
web/src/FlashBackdrop.tsx
Normal file
19
web/src/FlashBackdrop.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Ambient neon soup — pointer-events none, stays under UI */
|
||||
export default function FlashBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-[2] overflow-hidden">
|
||||
<div
|
||||
className="absolute -left-[20%] top-[10%] h-[min(70vh,520px)] w-[min(70vh,520px)] rounded-full bg-fuchsia-600/25 blur-[100px] animate-pulseGlow"
|
||||
style={{ animationDelay: "0s" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -right-[15%] bottom-[5%] h-[min(55vh,440px)] w-[min(55vh,440px)] rounded-full bg-cyan-400/20 blur-[90px] animate-pulseGlow"
|
||||
style={{ animationDelay: "1.2s" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-[35%] top-[40%] h-[min(45vh,360px)] w-[min(45vh,360px)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-400/15 blur-[80px] animate-floatSlow"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_0%,rgba(2,4,8,0.75)_100%)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
web/src/LabFixtureLoad.tsx
Normal file
99
web/src/LabFixtureLoad.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { BINARY_CARD_FIXTURES, EMULATE_LAB, RAW_PN532_LAB, type BinaryCardFixture } from "./validationFixtures";
|
||||
|
||||
type Mode = "raw" | "emulate" | "binary";
|
||||
|
||||
type Props = {
|
||||
mode: Mode;
|
||||
onApplyHex: (hex: string) => void;
|
||||
onPickBinary?: (f: BinaryCardFixture) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function LabFixtureLoad({ mode, onApplyHex, onPickBinary, className = "" }: Props) {
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
const bump = () => setResetKey((k) => k + 1);
|
||||
|
||||
if (mode === "raw") {
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 ${className}`}>
|
||||
<select
|
||||
key={resetKey}
|
||||
defaultValue=""
|
||||
aria-label="Load PN532 lab command"
|
||||
className="max-w-full rounded-xl border border-bubble-mint/25 bg-black/40 px-3 py-2 font-mono text-xs text-bubble-mint/90"
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
if (!id) return;
|
||||
const f = RAW_PN532_LAB.find((x) => x.id === id);
|
||||
if (f) onApplyHex(f.hex);
|
||||
bump();
|
||||
}}
|
||||
>
|
||||
<option value="">Lab: PN532 command bytes…</option>
|
||||
{RAW_PN532_LAB.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "emulate") {
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 ${className}`}>
|
||||
<select
|
||||
key={resetKey}
|
||||
defaultValue=""
|
||||
aria-label="Load emulate lab frame"
|
||||
className="max-w-full rounded-xl border border-bubble-mint/25 bg-black/40 px-3 py-2 font-mono text-xs text-bubble-mint/90"
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
if (!id) return;
|
||||
const f = EMULATE_LAB.find((x) => x.id === id);
|
||||
if (f) onApplyHex(f.hex);
|
||||
bump();
|
||||
}}
|
||||
>
|
||||
<option value="">Lab: emulate / TgInit…</option>
|
||||
{EMULATE_LAB.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
<select
|
||||
key={resetKey}
|
||||
defaultValue=""
|
||||
aria-label="Load synthetic card blob"
|
||||
className="w-full max-w-full rounded-xl border border-bubble-mint/25 bg-black/40 px-3 py-2 font-mono text-xs text-bubble-mint/90"
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
if (!id) return;
|
||||
const f = BINARY_CARD_FIXTURES.find((x) => x.id === id);
|
||||
if (f) {
|
||||
onApplyHex(f.hex);
|
||||
onPickBinary?.(f);
|
||||
}
|
||||
bump();
|
||||
}}
|
||||
>
|
||||
<option value="">Lab: synthetic card / NDEF blob…</option>
|
||||
{BINARY_CARD_FIXTURES.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.title} ({f.byteLength} B)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
web/src/NfcWsContext.tsx
Normal file
189
web/src/NfcWsContext.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import type { Tag } from "./api";
|
||||
import { useToolkitWs } from "./useWebSocket";
|
||||
|
||||
export type BrowserLogEntry = {
|
||||
t: number;
|
||||
channel: string;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
export type CashVariant = "tag" | "vault";
|
||||
|
||||
type NfcCtx = {
|
||||
wsOk: boolean;
|
||||
lastTag: Tag | null;
|
||||
tagPresent: boolean;
|
||||
log: BrowserLogEntry[];
|
||||
/** Increments on each “cha-ching” moment (new tag scan or capture line). */
|
||||
cashWave: number;
|
||||
cashVariant: CashVariant;
|
||||
clearBrowserLog: () => void;
|
||||
exportBrowserLog: () => void;
|
||||
applyScanPoll: (present: boolean, tag?: Tag) => void;
|
||||
};
|
||||
|
||||
const Ctx = createContext<NfcCtx | null>(null);
|
||||
|
||||
const MAX_LOG = 5000;
|
||||
const LS_KEY = "pn532_browser_log_v1";
|
||||
|
||||
function loadPersisted(): BrowserLogEntry[] {
|
||||
try {
|
||||
const s = sessionStorage.getItem(LS_KEY);
|
||||
if (!s) {
|
||||
return [];
|
||||
}
|
||||
const j = JSON.parse(s) as BrowserLogEntry[];
|
||||
return Array.isArray(j) ? j.slice(-MAX_LOG) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistLog(entries: BrowserLogEntry[]) {
|
||||
try {
|
||||
sessionStorage.setItem(LS_KEY, JSON.stringify(entries.slice(-MAX_LOG)));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
function handleScanPayload(
|
||||
payload: unknown,
|
||||
setLastTag: (t: Tag | null) => void,
|
||||
setTagPresent: (v: boolean) => void,
|
||||
) {
|
||||
if (typeof payload === "object" && payload && "present" in (payload as object)) {
|
||||
const p = payload as { present?: boolean };
|
||||
if (p.present === false) {
|
||||
setLastTag(null);
|
||||
setTagPresent(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setTagPresent(true);
|
||||
if (typeof payload === "object" && payload && "uid" in (payload as object)) {
|
||||
setLastTag(payload as Tag);
|
||||
}
|
||||
}
|
||||
|
||||
function isTagPayload(payload: unknown): payload is Record<string, unknown> & { uid: string } {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return false;
|
||||
}
|
||||
const p = payload as Record<string, unknown>;
|
||||
if (p.present === false) {
|
||||
return false;
|
||||
}
|
||||
return typeof p.uid === "string" && p.uid.length > 0;
|
||||
}
|
||||
|
||||
function isCaptureRecorded(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (payload as { event?: string }).event === "recorded";
|
||||
}
|
||||
|
||||
export function NfcWsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lastTag, setLastTag] = useState<Tag | null>(null);
|
||||
const [tagPresent, setTagPresent] = useState(false);
|
||||
const [log, setLog] = useState<BrowserLogEntry[]>(() => loadPersisted());
|
||||
const [cashWave, setCashWave] = useState(0);
|
||||
const [cashVariant, setCashVariant] = useState<CashVariant>("tag");
|
||||
const bumpLock = useRef(0);
|
||||
|
||||
const bump = useCallback((variant: CashVariant) => {
|
||||
const now = Date.now();
|
||||
if (variant === "tag" && now - bumpLock.current < 280) {
|
||||
return;
|
||||
}
|
||||
bumpLock.current = now;
|
||||
setCashVariant(variant);
|
||||
setCashWave((w) => w + 1);
|
||||
}, []);
|
||||
|
||||
const onMsg = useCallback(
|
||||
(msg: string) => {
|
||||
try {
|
||||
const o = JSON.parse(msg) as { channel?: string; payload?: unknown };
|
||||
const ch = o.channel || "unknown";
|
||||
setLog((prev) => {
|
||||
const next = [...prev, { t: Date.now(), channel: ch, payload: o.payload }].slice(-MAX_LOG);
|
||||
persistLog(next);
|
||||
return next;
|
||||
});
|
||||
if (ch === "scan") {
|
||||
handleScanPayload(o.payload, setLastTag, setTagPresent);
|
||||
if (isTagPayload(o.payload)) {
|
||||
bump("tag");
|
||||
}
|
||||
} else if (ch === "capture" && isCaptureRecorded(o.payload)) {
|
||||
bump("vault");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[bump],
|
||||
);
|
||||
|
||||
const wsOk = useToolkitWs(onMsg);
|
||||
|
||||
const applyScanPoll = useCallback(
|
||||
(present: boolean, tag?: Tag) => {
|
||||
if (!present) {
|
||||
setLastTag(null);
|
||||
setTagPresent(false);
|
||||
return;
|
||||
}
|
||||
if (tag) {
|
||||
setLastTag(tag);
|
||||
setTagPresent(true);
|
||||
bump("tag");
|
||||
}
|
||||
},
|
||||
[bump],
|
||||
);
|
||||
|
||||
const clearBrowserLog = useCallback(() => {
|
||||
setLog([]);
|
||||
sessionStorage.removeItem(LS_KEY);
|
||||
}, []);
|
||||
|
||||
const exportBrowserLog = useCallback(() => {
|
||||
const blob = new Blob([JSON.stringify(log, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `pn532-browser-log-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [log]);
|
||||
|
||||
const v = useMemo(
|
||||
() => ({
|
||||
wsOk,
|
||||
lastTag,
|
||||
tagPresent,
|
||||
log,
|
||||
cashWave,
|
||||
cashVariant,
|
||||
clearBrowserLog,
|
||||
exportBrowserLog,
|
||||
applyScanPoll,
|
||||
}),
|
||||
[wsOk, lastTag, tagPresent, log, cashWave, cashVariant, clearBrowserLog, exportBrowserLog, applyScanPoll],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={v}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useNfcWs() {
|
||||
const x = useContext(Ctx);
|
||||
if (!x) {
|
||||
throw new Error("useNfcWs outside NfcWsProvider");
|
||||
}
|
||||
return x;
|
||||
}
|
||||
234
web/src/ScanCashFlourish.tsx
Normal file
234
web/src/ScanCashFlourish.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNfcWs } from "./NfcWsContext";
|
||||
|
||||
const SPARKS = 16;
|
||||
const COINS = 7;
|
||||
|
||||
function playChaChing() {
|
||||
try {
|
||||
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctx) {
|
||||
return;
|
||||
}
|
||||
const ctx = new Ctx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.11;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
const ding = (freq: number, t0: number, dur: number) => {
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.type = "sine";
|
||||
o.frequency.setValueAtTime(freq, t0);
|
||||
g.gain.setValueAtTime(0, t0);
|
||||
g.gain.linearRampToValueAtTime(1, t0 + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.01, t0 + dur);
|
||||
o.connect(g);
|
||||
g.connect(master);
|
||||
o.start(t0);
|
||||
o.stop(t0 + dur + 0.05);
|
||||
};
|
||||
|
||||
const now = ctx.currentTime;
|
||||
ding(523.25, now, 0.11);
|
||||
ding(659.25, now + 0.055, 0.13);
|
||||
ding(783.99, now + 0.1, 0.16);
|
||||
ding(1046.5, now + 0.14, 0.2);
|
||||
|
||||
const noise = ctx.createBufferSource();
|
||||
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.07, ctx.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (data.length * 0.32));
|
||||
}
|
||||
noise.buffer = buf;
|
||||
const ng = ctx.createGain();
|
||||
ng.gain.setValueAtTime(0.055, now + 0.04);
|
||||
ng.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||
noise.connect(ng);
|
||||
ng.connect(master);
|
||||
noise.start(now + 0.04);
|
||||
noise.stop(now + 0.18);
|
||||
|
||||
void ctx.resume?.();
|
||||
setTimeout(() => void ctx.close(), 700);
|
||||
} catch {
|
||||
/* autoplay / policy */
|
||||
}
|
||||
}
|
||||
|
||||
export default function ScanCashFlourish() {
|
||||
const { cashWave, cashVariant, lastTag } = useNfcWs();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [burstKey, setBurstKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (cashWave === 0) {
|
||||
return;
|
||||
}
|
||||
setBurstKey(cashWave);
|
||||
setVisible(true);
|
||||
playChaChing();
|
||||
const t = window.setTimeout(() => setVisible(false), 1650);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [cashWave]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed left-2 top-[4.25rem] z-[55] sm:left-4 sm:top-[4.5rem] md:top-[5.25rem]">
|
||||
<AnimatePresence mode="wait">
|
||||
{visible ? (
|
||||
<motion.div
|
||||
key={burstKey}
|
||||
className="relative flex flex-col items-start"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.92, filter: "blur(4px)" }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute -left-8 -top-8 h-40 w-40 rounded-full bg-gradient-to-br from-amber-400/45 via-yellow-200/30 to-bubble-mint/35 blur-2xl"
|
||||
initial={{ scale: 0.2, opacity: 0 }}
|
||||
animate={{ scale: [0.2, 1.35, 1], opacity: [0, 1, 0.7] }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
/>
|
||||
|
||||
{Array.from({ length: SPARKS }).map((_, i) => {
|
||||
const a = (i / SPARKS) * Math.PI * 2;
|
||||
const dist = 56 + (i % 4) * 10;
|
||||
return (
|
||||
<motion.span
|
||||
key={`s-${i}`}
|
||||
className="absolute left-8 top-9 h-2 w-2 rounded-full bg-gradient-to-br from-yellow-100 to-amber-500 shadow-[0_0_12px_rgba(250,204,21,1)]"
|
||||
initial={{ x: 0, y: 0, opacity: 0, scale: 0 }}
|
||||
animate={{
|
||||
x: Math.cos(a) * dist,
|
||||
y: Math.sin(a) * dist,
|
||||
opacity: [0, 1, 0],
|
||||
scale: [0, 1.3, 0.3],
|
||||
}}
|
||||
transition={{ duration: 0.8, delay: i * 0.018, ease: "easeOut" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{Array.from({ length: COINS }).map((_, i) => (
|
||||
<motion.span
|
||||
key={`c-${i}`}
|
||||
className="absolute left-8 top-8 text-xl sm:text-2xl"
|
||||
initial={{ x: 0, y: 0, opacity: 0, rotate: -30, scale: 0 }}
|
||||
animate={{
|
||||
x: (i % 2 === 0 ? 1 : -1) * (36 + i * 12),
|
||||
y: -32 - i * 11,
|
||||
opacity: [0, 1, 0],
|
||||
rotate: i * 35,
|
||||
scale: [0, 1.15, 0.85],
|
||||
}}
|
||||
transition={{ duration: 0.9, delay: 0.04 + i * 0.035, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
🪙
|
||||
</motion.span>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
className="relative flex h-[4.75rem] w-[4.75rem] items-center justify-center sm:h-[5.5rem] sm:w-[5.5rem]"
|
||||
initial={{ scale: 0, rotate: -40 }}
|
||||
animate={{
|
||||
scale: [0, 1.3, 0.92, 1.06, 1],
|
||||
rotate: [-40, 12, -6, 3, 0],
|
||||
}}
|
||||
transition={{ duration: 0.7, ease: [0.34, 1.56, 0.64, 1] }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-2xl border-2 border-yellow-200/90"
|
||||
animate={{
|
||||
boxShadow: [
|
||||
"0 0 25px rgba(250,204,21,0.55), inset 0 0 22px rgba(254,240,138,0.2)",
|
||||
"0 0 50px rgba(0,255,157,0.5), inset 0 0 28px rgba(0,229,255,0.15)",
|
||||
"0 0 28px rgba(250,204,21,0.6), inset 0 0 18px rgba(254,240,138,0.25)",
|
||||
],
|
||||
}}
|
||||
transition={{ duration: 1.1, repeat: 2 }}
|
||||
/>
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="relative z-[1] h-[72%] w-[72%] drop-shadow-[0_0_14px_rgba(250,204,21,0.9)]"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="cashGold" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#fef9c3" />
|
||||
<stop offset="40%" stopColor="#facc15" />
|
||||
<stop offset="100%" stopColor="#a16207" />
|
||||
</linearGradient>
|
||||
<filter id="cashGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1.2" result="b" />
|
||||
<feMerge>
|
||||
<feMergeNode in="b" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="44" fill="url(#cashGold)" filter="url(#cashGlow)" />
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="#422006" strokeOpacity="0.28" strokeWidth="2" />
|
||||
<text
|
||||
x="50"
|
||||
y="64"
|
||||
textAnchor="middle"
|
||||
fill="#713f12"
|
||||
fontSize="54"
|
||||
fontWeight="700"
|
||||
fontFamily="Audiowide, Orbitron, system-ui, sans-serif"
|
||||
>
|
||||
$
|
||||
</text>
|
||||
</svg>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl"
|
||||
initial={false}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-y-2 w-2/5 bg-gradient-to-r from-transparent via-white/60 to-transparent skew-x-[-18deg]"
|
||||
initial={{ left: "-40%" }}
|
||||
animate={{ left: "140%" }}
|
||||
transition={{ duration: 0.55, delay: 0.12, ease: "easeInOut" }}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="relative -mt-0.5 max-w-[12rem] pl-0.5"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.12, type: "spring", stiffness: 320, damping: 22 }}
|
||||
>
|
||||
<motion.p
|
||||
className="font-display text-[10px] tracking-[0.42em] text-yellow-100 sm:text-[11px]"
|
||||
animate={{
|
||||
textShadow: [
|
||||
"0 0 10px rgba(250,204,21,0.95)",
|
||||
"0 0 22px rgba(0,255,157,0.75)",
|
||||
"0 0 12px rgba(250,204,21,0.9)",
|
||||
],
|
||||
}}
|
||||
transition={{ duration: 0.75, repeat: 3 }}
|
||||
>
|
||||
{cashVariant === "vault" ? "VAULT · LOCKED" : "CHA-CHING · HIT"}
|
||||
</motion.p>
|
||||
{lastTag?.uid ? (
|
||||
<motion.p
|
||||
className="mt-1 truncate font-mono text-[10px] font-bold tracking-[0.15em] text-bubble-mint sm:text-xs"
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.22 }}
|
||||
>
|
||||
{lastTag.uid}
|
||||
</motion.p>
|
||||
) : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
web/src/api.ts
Normal file
62
web/src/api.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/** Stored API origin; trailing slashes stripped so paths join cleanly. */
|
||||
export function apiBaseUrl(): string {
|
||||
return (localStorage.getItem("apiBase") || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
const b = apiBaseUrl();
|
||||
const p = path.startsWith("/") ? path : `/${path}`;
|
||||
return b ? `${b}${p}` : p;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const r = await fetch(apiUrl(path));
|
||||
if (!r.ok) {
|
||||
throw new Error(await r.text());
|
||||
}
|
||||
return r.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const r = await fetch(apiUrl(path), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(await r.text());
|
||||
}
|
||||
return r.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
usedBytes: number;
|
||||
maxBytes: number;
|
||||
lines: number;
|
||||
full: boolean;
|
||||
deepCapture: boolean;
|
||||
};
|
||||
|
||||
export type Status = {
|
||||
app: string;
|
||||
uptimeMs: number;
|
||||
freeHeap: number;
|
||||
wifiMode: number;
|
||||
pn532?: { ic: number; fwHi: number; fwLo: number };
|
||||
scanning: boolean;
|
||||
session?: SessionInfo;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
uid: string;
|
||||
uidLen: number;
|
||||
atqa: number;
|
||||
sak: number;
|
||||
typeHint: number;
|
||||
/** ATQA as 4 hex digits (MSB first, same as firmware). */
|
||||
atqaHex?: string;
|
||||
sakHex?: string;
|
||||
typeGuess?: string;
|
||||
/** PN532 `GetGeneralStatus` payload bytes (chip-dependent length). */
|
||||
pn532GeneralStatus?: number[];
|
||||
};
|
||||
127
web/src/index.css
Normal file
127
web/src/index.css
Normal file
@@ -0,0 +1,127 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--hack-matrix: #00ff9d;
|
||||
--hack-cyan: #00e5ff;
|
||||
--hack-void: #020408;
|
||||
--hack-rose: #ff2a6d;
|
||||
}
|
||||
|
||||
.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Deep void + animated grid + aurora blobs */
|
||||
.hack-grid {
|
||||
background-color: var(--hack-void);
|
||||
background-image: linear-gradient(rgba(0, 255, 157, 0.055) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 229, 255, 0.05) 1px, transparent 1px),
|
||||
radial-gradient(ellipse 100% 60% at 50% -30%, rgba(0, 229, 255, 0.18), transparent 55%),
|
||||
radial-gradient(ellipse 70% 50% at 110% 80%, rgba(255, 42, 109, 0.12), transparent 50%),
|
||||
radial-gradient(ellipse 50% 40% at -10% 60%, rgba(0, 255, 157, 0.1), transparent 45%);
|
||||
background-size: 20px 20px, 20px 20px, 100% 100%, 100% 100%, 100% 100%;
|
||||
}
|
||||
|
||||
/* CRT + vignette */
|
||||
.hack-scanlines::before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 35;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.18) 2px,
|
||||
rgba(0, 0, 0, 0.18) 4px
|
||||
);
|
||||
opacity: 0.45;
|
||||
box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.light.hack-root .hack-scanlines::before {
|
||||
opacity: 0.06;
|
||||
}
|
||||
|
||||
/* Neon chassis panels */
|
||||
.glass {
|
||||
@apply relative rounded-2xl border border-bubble-mint/25 bg-bubble-900/75 shadow-insetTerminal backdrop-blur-lg;
|
||||
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.12), 0 0 40px -12px rgba(0, 255, 157, 0.25),
|
||||
0 12px 40px -12px rgba(0, 0, 0, 0.75), inset 0 1px 0 0 rgba(0, 255, 157, 0.1);
|
||||
transition: box-shadow 0.35s ease, border-color 0.35s ease;
|
||||
}
|
||||
|
||||
.glass:hover {
|
||||
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.22), 0 0 55px -10px rgba(0, 255, 157, 0.4),
|
||||
0 16px 48px -12px rgba(0, 0, 0, 0.8), inset 0 1px 0 0 rgba(0, 229, 255, 0.12);
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
}
|
||||
|
||||
.light .glass {
|
||||
@apply border-slate-300/80 bg-white/90 shadow-xl;
|
||||
box-shadow: 0 4px 24px -4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.light .glass:hover {
|
||||
box-shadow: 0 8px 32px -4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.text-glow-matrix {
|
||||
text-shadow: 0 0 12px rgba(0, 255, 157, 0.8), 0 0 28px rgba(0, 255, 157, 0.45), 0 0 60px rgba(0, 229, 255, 0.25);
|
||||
}
|
||||
|
||||
.text-glow-cyan {
|
||||
text-shadow: 0 0 14px rgba(0, 229, 255, 0.75), 0 0 36px rgba(0, 229, 255, 0.35);
|
||||
}
|
||||
|
||||
.text-glow-rose {
|
||||
text-shadow: 0 0 16px rgba(255, 42, 109, 0.65);
|
||||
}
|
||||
|
||||
/* Nav active — laser bracket */
|
||||
.nav-hack-active {
|
||||
@apply border border-bubble-mint/70 bg-gradient-to-br from-bubble-mint/20 to-bubble-accent/10 text-bubble-mint;
|
||||
box-shadow: 0 0 28px -4px rgba(0, 255, 157, 0.55), 0 0 40px -8px rgba(0, 229, 255, 0.35),
|
||||
inset 0 0 20px -8px rgba(0, 229, 255, 0.2);
|
||||
animation: borderPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Primary flashy buttons (use alongside existing classes) */
|
||||
.btn-neon {
|
||||
@apply relative overflow-hidden font-bold transition-all duration-300;
|
||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn-neon:hover {
|
||||
transform: translateY(-1px) scale(1.02);
|
||||
box-shadow: 0 0 35px rgba(0, 255, 157, 0.45), 0 0 50px rgba(0, 229, 255, 0.25);
|
||||
}
|
||||
|
||||
.btn-neon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(105deg, transparent 40%, rgba(255, 255, 255, 0.2) 50%, transparent 60%);
|
||||
transform: translateX(-100%);
|
||||
animation: shimmerLine 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Top status bar chrome */
|
||||
.flash-log-bar {
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.85), rgba(5, 18, 16, 0.92), rgba(0, 0, 0, 0.85));
|
||||
box-shadow: 0 4px 24px rgba(0, 255, 157, 0.08), inset 0 1px 0 rgba(0, 229, 255, 0.15);
|
||||
}
|
||||
|
||||
.flash-log-bar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 255, 157, 0.5), rgba(0, 229, 255, 0.6), transparent);
|
||||
}
|
||||
22
web/src/main.tsx
Normal file
22
web/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const th = localStorage.getItem("theme");
|
||||
if (th === "light") {
|
||||
document.documentElement.classList.add("light");
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
85
web/src/nfcUtils.ts
Normal file
85
web/src/nfcUtils.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/** MIFARE Classic sector trailer access bits (C1 C2 C3) decode — informational only */
|
||||
|
||||
export type AccessBits = {
|
||||
c1: number;
|
||||
c2: number;
|
||||
c3: number;
|
||||
};
|
||||
|
||||
export function parseTrailerAccess(hex16: string): AccessBits | null {
|
||||
const h = hex16.replace(/\s/g, "");
|
||||
if (h.length !== 32) {
|
||||
return null;
|
||||
}
|
||||
const b = new Uint8Array(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
b[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
const ab0 = b[6];
|
||||
const ab1 = b[7];
|
||||
const ab2 = b[8];
|
||||
const c1 = ((ab0 >> 4) & 1) | ((ab1 >> 0) & 2) | ((ab2 >> 0) & 4);
|
||||
const c2 = ((ab0 >> 5) & 1) | ((ab1 >> 1) & 2) | ((ab2 >> 1) & 4);
|
||||
const c3 = ((ab0 >> 6) & 1) | ((ab1 >> 2) & 2) | ((ab2 >> 2) & 4);
|
||||
return { c1, c2, c3 };
|
||||
}
|
||||
|
||||
export function sakLabel(sak: number): string {
|
||||
switch (sak) {
|
||||
case 0x08:
|
||||
return "Ultralight family";
|
||||
case 0x09:
|
||||
return "Mini / Classic";
|
||||
case 0x18:
|
||||
return "Classic 1K";
|
||||
case 0x19:
|
||||
return "Classic 4K";
|
||||
default:
|
||||
return `SAK 0x${sak.toString(16).toUpperCase()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function uidWithSeparators(uid: string): string {
|
||||
const clean = uid.replace(/\s/g, "");
|
||||
if (clean.length % 2 !== 0) {
|
||||
return uid;
|
||||
}
|
||||
return clean.match(/.{2}/g)?.join(":") ?? clean;
|
||||
}
|
||||
|
||||
export function typeHintLabel(hint: number): string {
|
||||
switch (hint) {
|
||||
case 1:
|
||||
return "Classic-style (MIFARE)";
|
||||
case 2:
|
||||
return "Type 2 / Ultralight-style";
|
||||
default:
|
||||
return "Unknown (check SAK/ATQA)";
|
||||
}
|
||||
}
|
||||
|
||||
/** Short cheat-sheet lines for the live scan panel (not a full ISO parser). */
|
||||
export function scanCheatLines(tag: { uidLen: number; atqa: number; sak: number }): string[] {
|
||||
const lines: string[] = [];
|
||||
if (tag.uidLen === 4) {
|
||||
lines.push("4-byte UID — single cascade level (CL1).");
|
||||
} else if (tag.uidLen === 7) {
|
||||
lines.push("7-byte UID — double cascade (CL1 + CL2) typical for 7B tags.");
|
||||
} else if (tag.uidLen === 10) {
|
||||
lines.push("10-byte UID — triple cascade path.");
|
||||
}
|
||||
if (tag.sak === 0x08) {
|
||||
lines.push("SAK 0x08 — often Ultralight / Type 2; use page read/write for user memory.");
|
||||
}
|
||||
if (tag.sak === 0x18 || tag.sak === 0x19) {
|
||||
lines.push("MIFARE Classic — authenticate per sector trailer, then block read/write.");
|
||||
}
|
||||
if (tag.atqa === 0x0044 || tag.atqa === 0x4400) {
|
||||
lines.push("ATQA 0x4400 pattern — very common Type A inventory response.");
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function generalStatusHexLine(bytes: number[]): string {
|
||||
return bytes.map((b) => (Number(b) & 0xff).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||
}
|
||||
113
web/src/pages/Brute.tsx
Normal file
113
web/src/pages/Brute.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { apiUrl } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
export default function Brute() {
|
||||
const toast = useToast();
|
||||
const [reader, setReader] = useState<"classic1k" | "classic4k">("classic1k");
|
||||
const [variations, setVariations] = useState(true);
|
||||
const [extraKeys, setExtraKeys] = useState(
|
||||
"FFFFFFFFFFFF\nA0A1A2A3A4A5\nD3F7D3F7D3F7",
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<string>("");
|
||||
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
setResult("");
|
||||
try {
|
||||
const keysHex = extraKeys
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/\s/g, "").toUpperCase())
|
||||
.filter((l) => l.length === 12);
|
||||
const body = {
|
||||
readerType: reader,
|
||||
variations,
|
||||
keysHex,
|
||||
};
|
||||
const r = await fetch(apiUrl("/api/mifare/dictionary-attack"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) {
|
||||
throw new Error(text);
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(text) as object;
|
||||
setResult(JSON.stringify(j, null, 2));
|
||||
} catch {
|
||||
setResult(text);
|
||||
}
|
||||
toast("Dictionary pass finished");
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass space-y-6 p-6">
|
||||
<h1 className="font-display text-2xl font-bold">Dictionary attack</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
Select card family, optionally enable <strong>bounded variations</strong> (XOR / low-nibble tweaks per
|
||||
key). Firmware tries a <strong>built-in community list</strong> (Proxmark3 / MCT-style defaults) plus
|
||||
your lines below. This is <strong>not</strong> a full 2⁴⁸ exhaustive search — only keys you and the
|
||||
community already know. Hold a <strong>MIFARE Classic</strong> on the coil.{" "}
|
||||
<a
|
||||
className="text-bubble-mint underline"
|
||||
href="https://github.com/RfidResearchGroup/proxmark3/blob/master/client/dictionaries/mfc_default_keys.dic"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
More keys online
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<label className="block text-sm">
|
||||
Reader / map
|
||||
<select
|
||||
value={reader}
|
||||
onChange={(e) => setReader(e.target.value as "classic1k" | "classic4k")}
|
||||
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
|
||||
>
|
||||
<option value="classic1k">MIFARE Classic 1K (sectors 0–15)</option>
|
||||
<option value="classic4k">MIFARE Classic 4K (sectors 0–39, proper 4/16-block geometry)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={variations} onChange={(e) => setVariations(e.target.checked)} />
|
||||
Variations (extra tries per key — slower, wider net)
|
||||
</label>
|
||||
|
||||
<label className="block text-sm">
|
||||
Extra keys (one 12-hex key per line, merged after built-in list)
|
||||
<textarea
|
||||
value={extraKeys}
|
||||
onChange={(e) => setExtraKeys(e.target.value)}
|
||||
rows={6}
|
||||
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={run}
|
||||
className="rounded-2xl bg-gradient-to-r from-bubble-rose to-orange-500 px-6 py-3 font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Running…" : "Run dictionary attack"}
|
||||
</button>
|
||||
|
||||
{result && (
|
||||
<pre className="max-h-96 overflow-auto rounded-2xl border border-white/10 bg-black/40 p-4 text-xs text-bubble-mint">
|
||||
{result}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
web/src/pages/Capture.tsx
Normal file
168
web/src/pages/Capture.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiGet, apiPost, apiUrl, type Status } from "../api";
|
||||
import { useNfcWs } from "../NfcWsContext";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
export default function Capture() {
|
||||
const toast = useToast();
|
||||
const { log } = useNfcWs();
|
||||
const [st, setSt] = useState<Status | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
apiGet<Status>("/api/status")
|
||||
.then(setSt)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const id = setInterval(refresh, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [log.length, refresh]);
|
||||
|
||||
const lastEv = (() => {
|
||||
const c = [...log].reverse().find((e) => e.channel === "capture");
|
||||
if (!c) {
|
||||
return "";
|
||||
}
|
||||
return typeof c.payload === "object" ? JSON.stringify(c.payload) : String(c.payload);
|
||||
})();
|
||||
|
||||
const sess = st?.session;
|
||||
const pct = sess ? Math.min(100, (sess.usedBytes / Math.max(1, sess.maxBytes)) * 100) : 0;
|
||||
|
||||
const setDeep = async (enable: boolean) => {
|
||||
try {
|
||||
await apiPost("/api/session/deep", { enable });
|
||||
toast(
|
||||
enable ? "Passive read-all on (live scan on by default at boot)" : "Passive read-all off",
|
||||
);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
const clearBuf = async () => {
|
||||
try {
|
||||
await apiPost("/api/session/clear", {});
|
||||
toast("Buffer cleared — scanning can resume");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const r = await fetch(apiUrl("/api/session/export"));
|
||||
if (!r.ok) {
|
||||
throw new Error(await r.text());
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `pn532-deep-capture-${Date.now()}.ndjson`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast("Download started — check your Downloads folder");
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass relative overflow-hidden p-8"
|
||||
>
|
||||
<div className="pointer-events-none absolute -left-20 top-0 h-48 w-48 rounded-full bg-bubble-mint/20 blur-3xl" />
|
||||
<h1 className="font-display text-3xl font-bold md:text-4xl">Passive read-all mode</h1>
|
||||
<p className="mt-1 text-sm font-medium text-bubble-mint/90">
|
||||
Same feature as “deep capture” — fully controlled from this screen.
|
||||
</p>
|
||||
<p className="mt-3 max-w-2xl text-slate-300">
|
||||
Turn it on below and keep <strong>live scan</strong> running (on by default at boot). Each{" "}
|
||||
<strong>new tag</strong> in the field is read <strong>passively</strong>: no per-block clicks — the
|
||||
firmware pulls <strong>all data it can</strong> (PN532 status + Classic sector/block dump with
|
||||
default keys, or Ultralight/NTAG page sweep). Results queue in <strong>device RAM</strong>; when
|
||||
full, polling pauses until you <strong>download</strong> and <strong>clear</strong>.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="glass p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold">Read-all session buffer</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
{sess?.lines ?? 0} full dumps · {sess?.usedBytes ?? 0} / {sess?.maxBytes ?? "—"} bytes RAM
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeep(!(sess?.deepCapture ?? false))}
|
||||
className={`rounded-2xl px-4 py-2 font-semibold ${
|
||||
sess?.deepCapture
|
||||
? "bg-bubble-mint/20 text-bubble-mint ring-2 ring-bubble-mint/40"
|
||||
: "border border-white/15 bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{sess?.deepCapture ? "Passive read-all ON" : "Enable passive read-all"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={download}
|
||||
className="rounded-2xl bg-gradient-to-r from-bubble-accent to-indigo-400 px-4 py-2 font-bold text-white shadow-glow"
|
||||
>
|
||||
Download NDJSON
|
||||
</button>
|
||||
<button type="button" onClick={clearBuf} className="rounded-2xl border border-white/20 px-4 py-2">
|
||||
Clear buffer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 h-4 overflow-hidden rounded-full bg-black/40">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-bubble-accent to-bubble-mint"
|
||||
initial={false}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ type: "spring", stiffness: 120, damping: 20 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sess?.full && (
|
||||
<div className="mt-6 rounded-2xl border-2 border-bubble-rose/50 bg-bubble-rose/10 p-4 text-center">
|
||||
<p className="font-display text-lg font-bold text-bubble-rose">Buffer full — reader paused</p>
|
||||
<p className="mt-1 text-sm text-slate-300">
|
||||
Tap <strong>Download NDJSON</strong> to pull every card profile to your phone, then{" "}
|
||||
<strong>Clear buffer</strong> to resume field scans.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sess?.deepCapture && st && !st.scanning && (
|
||||
<p className="mt-4 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-100">
|
||||
Firmware normally has <strong>Live scan</strong> on at boot. If you turned it off, enable it on the
|
||||
Dashboard.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{lastEv && (
|
||||
<pre className="mt-4 max-h-40 overflow-auto rounded-2xl border border-white/10 bg-black/30 p-3 text-xs text-slate-300">
|
||||
Last capture event: {lastEv}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
web/src/pages/Dashboard.tsx
Normal file
234
web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { apiPost, apiGet, type Status, type Tag } from "../api";
|
||||
import { useNfcWs } from "../NfcWsContext";
|
||||
import { useToast } from "../toast";
|
||||
import {
|
||||
generalStatusHexLine,
|
||||
sakLabel,
|
||||
scanCheatLines,
|
||||
typeHintLabel,
|
||||
uidWithSeparators,
|
||||
} from "../nfcUtils";
|
||||
import HeroVisual from "../ui/HeroVisual";
|
||||
|
||||
export default function Dashboard() {
|
||||
const toast = useToast();
|
||||
const { lastTag, applyScanPoll } = useNfcWs();
|
||||
const [st, setSt] = useState<Status | null>(null);
|
||||
const [scan, setScan] = useState(true);
|
||||
|
||||
const poll = () => {
|
||||
apiGet<Status>("/api/status")
|
||||
.then(setSt)
|
||||
.catch(() => toast("Status unreachable", "err"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
poll();
|
||||
const id = setInterval(poll, 3000);
|
||||
return () => clearInterval(id);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (st && typeof st.scanning === "boolean") {
|
||||
setScan(st.scanning);
|
||||
}
|
||||
}, [st]);
|
||||
|
||||
const toggleScan = async () => {
|
||||
try {
|
||||
await apiPost<{ enable: boolean }>("/api/nfc/scan", { enable: !scan });
|
||||
setScan(!scan);
|
||||
toast(scan ? "Continuous scan off" : "Continuous scan on");
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
const pollOnce = async () => {
|
||||
try {
|
||||
const j = await apiPost<{ present: boolean; tag?: Tag }>("/api/nfc/poll", {});
|
||||
if (j.present && j.tag) {
|
||||
applyScanPoll(true, j.tag);
|
||||
toast(`Tag ${j.tag.uid}`);
|
||||
} else {
|
||||
applyScanPoll(false);
|
||||
toast("No tag");
|
||||
}
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="glass relative overflow-hidden p-6 md:p-10"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[conic-gradient(from_180deg_at_50%_120%,rgba(0,229,255,0.08),transparent_40%,rgba(255,42,109,0.06),transparent_70%)]" />
|
||||
<div className="relative grid gap-8 lg:grid-cols-[1fr_min(320px,40%)] lg:items-center">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] font-bold tracking-[0.4em] text-bubble-accent/80">COMMAND_LAYER</p>
|
||||
<h1 className="font-display mt-2 text-3xl font-normal tracking-wide text-glow-matrix md:text-5xl">
|
||||
NFC <span className="text-bubble-accent">CONTROL</span>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-400">
|
||||
Full-time scan out of the box. Fat RF retries. Browser log + device session export — crank it from
|
||||
your phone.
|
||||
</p>
|
||||
{st?.session && (st.session.deepCapture || st.session.usedBytes > 0) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="mt-5 flex flex-wrap items-center gap-3 rounded-xl border border-bubble-accent/40 bg-gradient-to-r from-bubble-accent/15 to-bubble-mint/10 px-4 py-3 text-sm shadow-glowCyan"
|
||||
>
|
||||
<span className="font-mono text-bubble-mint">
|
||||
RAM {st.session.usedBytes}/{st.session.maxBytes}
|
||||
{st.session.full ? " · LOCKED" : ""}
|
||||
</span>
|
||||
<Link
|
||||
to="/capture"
|
||||
className="btn-neon rounded-lg bg-bubble-mint/20 px-3 py-1 text-xs font-bold text-bubble-mint"
|
||||
>
|
||||
Read-all →
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.04 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={pollOnce}
|
||||
className="btn-neon rounded-xl bg-gradient-to-r from-bubble-accent via-cyan-400 to-bubble-mint px-6 py-3 text-sm font-black uppercase tracking-wider text-bubble-950 shadow-neonBtn"
|
||||
>
|
||||
Poll tag
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={toggleScan}
|
||||
className={`rounded-xl border-2 px-6 py-3 text-sm font-bold uppercase tracking-wide transition ${
|
||||
scan
|
||||
? "border-bubble-mint/70 bg-bubble-mint/15 text-glow-matrix text-bubble-mint shadow-glow"
|
||||
: "border-white/20 bg-black/30 text-slate-400 hover:border-bubble-accent/50 hover:text-bubble-accent"
|
||||
}`}
|
||||
>
|
||||
{scan ? "Stop scan" : "Start scan"}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex justify-center lg:justify-end">
|
||||
<div className="relative w-full max-w-[280px] opacity-90 drop-shadow-[0_0_40px_rgba(0,229,255,0.35)]">
|
||||
<HeroVisual />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.08 }}
|
||||
className="glass p-6"
|
||||
>
|
||||
<h2 className="font-display text-lg font-normal text-bubble-accent text-glow-cyan">Device stack</h2>
|
||||
{st ? (
|
||||
<ul className="mt-4 space-y-3 font-mono text-sm text-slate-300">
|
||||
<li className="flex justify-between border-b border-white/5 pb-2">
|
||||
<span className="text-slate-500">uptime</span>
|
||||
<span className="text-bubble-mint">{(st.uptimeMs / 1000).toFixed(1)}s</span>
|
||||
</li>
|
||||
<li className="flex justify-between border-b border-white/5 pb-2">
|
||||
<span className="text-slate-500">heap</span>
|
||||
<span className="text-white">{st.freeHeap}</span>
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-4 animate-pulse font-mono text-sm text-bubble-accent/60">Pulling status…</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.14 }}
|
||||
className="glass p-6"
|
||||
>
|
||||
<h2 className="font-display text-lg font-normal text-bubble-rose/90 text-glow-rose">Live tag</h2>
|
||||
{lastTag ? (
|
||||
<div className="mt-4 space-y-4 rounded-xl border border-bubble-mint/25 bg-black/35 p-4 shadow-glow">
|
||||
<div>
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-bubble-mint/50">UID</p>
|
||||
<div className="font-mono text-xl font-bold tracking-[0.15em] text-glow-matrix text-bubble-mint md:text-2xl">
|
||||
{uidWithSeparators(lastTag.uid)}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[11px] text-slate-500">
|
||||
raw {lastTag.uid} · {lastTag.uidLen} byte{lastTag.uidLen === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 border-t border-white/5 pt-3 font-mono text-[11px] text-slate-300 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-slate-500">ATQA</span>{" "}
|
||||
<span className="text-bubble-accent">
|
||||
0x{(lastTag.atqaHex ?? lastTag.atqa.toString(16).toUpperCase().padStart(4, "0")).slice(-4)}
|
||||
</span>
|
||||
<span className="ml-2 text-slate-500">({lastTag.atqa})</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">SAK</span>{" "}
|
||||
<span className="text-bubble-accent">
|
||||
0x{(lastTag.sakHex ?? lastTag.sak.toString(16).toUpperCase().padStart(2, "0")).slice(-2)}
|
||||
</span>
|
||||
<span className="ml-1 text-slate-400">— {sakLabel(lastTag.sak)}</span>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<span className="text-slate-500">Guess</span>{" "}
|
||||
<span className="text-bubble-mint">{lastTag.typeGuess ?? typeHintLabel(lastTag.typeHint)}</span>
|
||||
<span className="ml-2 text-slate-600">· hint {lastTag.typeHint}</span>
|
||||
</div>
|
||||
</div>
|
||||
{lastTag.pn532GeneralStatus && lastTag.pn532GeneralStatus.length > 0 && (
|
||||
<div className="rounded-lg border border-bubble-accent/20 bg-black/40 p-3">
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-bubble-accent/70">
|
||||
PN532 general status
|
||||
</p>
|
||||
<p className="mt-2 break-all font-mono text-[10px] leading-relaxed text-slate-400">
|
||||
{generalStatusHexLine(lastTag.pn532GeneralStatus)}
|
||||
</p>
|
||||
<p className="mt-2 text-[10px] text-slate-600">
|
||||
Raw bytes from the chip right after this inventory (error flags, last command, tag count —
|
||||
see NXP PN532 user manual).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-1 border-t border-white/5 pt-3 text-[11px] leading-relaxed text-slate-500">
|
||||
{scanCheatLines(lastTag).map((line, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="text-bubble-mint/40">▸</span>
|
||||
<span>{line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-6 font-mono text-sm text-bubble-mint/40">Listening on the field…</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
web/src/pages/Emulate.tsx
Normal file
74
web/src/pages/Emulate.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import LabFixtureLoad from "../LabFixtureLoad";
|
||||
import { LAB_PURPOSE_NOTE } from "../validationFixtures";
|
||||
import { apiUrl } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
export default function Emulate() {
|
||||
const toast = useToast();
|
||||
const [hex, setHex] = useState("8C");
|
||||
const [out, setOut] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const send = async () => {
|
||||
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, "") }),
|
||||
});
|
||||
const t = await r.text();
|
||||
if (!r.ok) {
|
||||
throw new Error(t);
|
||||
}
|
||||
try {
|
||||
setOut(JSON.stringify(JSON.parse(t), null, 2));
|
||||
} catch {
|
||||
setOut(t);
|
||||
}
|
||||
toast("PN532 emulation command sent");
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass space-y-6 p-6">
|
||||
<h1 className="font-display text-2xl font-bold">Card emulation (PN532 target mode)</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
Sends <code className="text-bubble-mint">TgInitAsTarget</code> (0x8C) and following bytes as one PN532
|
||||
payload. Real card emulation depends on UID length, timing, and reader behavior — this is an{" "}
|
||||
<strong>expert / experimental</strong> path. Build the byte sequence from NXP UM0701 or community
|
||||
examples. Wrong frames can leave the RF stack busy; power-cycle if the field acts stuck.
|
||||
</p>
|
||||
<p className="text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
|
||||
<LabFixtureLoad mode="emulate" onApplyHex={(h) => setHex(h)} />
|
||||
<label className="block text-sm">
|
||||
Command + parameters (hex, no spaces required)
|
||||
<textarea
|
||||
value={hex}
|
||||
onChange={(e) => setHex(e.target.value)}
|
||||
rows={4}
|
||||
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={send}
|
||||
className="rounded-2xl bg-bubble-accent px-6 py-2 font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Sending…" : "Send emulate frame"}
|
||||
</button>
|
||||
{out && (
|
||||
<pre className="max-h-80 overflow-auto rounded-2xl border border-white/10 bg-black/40 p-4 text-xs text-slate-200">
|
||||
{out}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
web/src/pages/KeyLab.tsx
Normal file
203
web/src/pages/KeyLab.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useToast } from "../toast";
|
||||
import Panel from "../ui/Panel";
|
||||
|
||||
function parseKey(hex: string): number[] | null {
|
||||
const h = hex.replace(/\s/g, "");
|
||||
if (h.length !== 12) {
|
||||
return null;
|
||||
}
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < 12; i += 2) {
|
||||
const b = parseInt(h.slice(i, i + 2), 16);
|
||||
if (Number.isNaN(b)) {
|
||||
return null;
|
||||
}
|
||||
out.push(b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function keyToHex(k: number[]) {
|
||||
return k.map((b) => b.toString(16).toUpperCase().padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function xorSpan(bases: number[][]): number[][] {
|
||||
const n = bases.length;
|
||||
const seen = new Set<string>();
|
||||
const out: number[][] = [];
|
||||
for (let mask = 0; mask < 1 << n; mask++) {
|
||||
const v = new Array(6).fill(0) as number[];
|
||||
for (let b = 0; b < n; b++) {
|
||||
if (mask & (1 << b)) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
v[i] ^= bases[b][i];
|
||||
}
|
||||
}
|
||||
}
|
||||
const hx = keyToHex(v);
|
||||
if (!seen.has(hx)) {
|
||||
seen.add(hx);
|
||||
out.push([...v]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shannonEntropyBitsPerByte(hex: string): number | null {
|
||||
const clean = hex.replace(/\s/g, "");
|
||||
if (clean.length % 2) {
|
||||
return null;
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < clean.length; i += 2) {
|
||||
const v = parseInt(clean.slice(i, i + 2), 16);
|
||||
if (Number.isNaN(v)) {
|
||||
return null;
|
||||
}
|
||||
bytes.push(v);
|
||||
}
|
||||
if (!bytes.length) {
|
||||
return null;
|
||||
}
|
||||
const freq = new Map<number, number>();
|
||||
for (const b of bytes) {
|
||||
freq.set(b, (freq.get(b) || 0) + 1);
|
||||
}
|
||||
let H = 0;
|
||||
const n = bytes.length;
|
||||
for (const c of freq.values()) {
|
||||
const p = c / n;
|
||||
H -= p * Math.log2(p);
|
||||
}
|
||||
return H;
|
||||
}
|
||||
|
||||
export default function KeyLab() {
|
||||
const toast = useToast();
|
||||
const [k1, setK1] = useState("FFFFFFFFFFFF");
|
||||
const [k2, setK2] = useState("A0A1A2A3A4A5");
|
||||
const [k3, setK3] = useState("000000000000");
|
||||
const [use3, setUse3] = useState(false);
|
||||
const [blobHex, setBlobHex] = useState("DEADBEEF");
|
||||
|
||||
const bases = useMemo(() => {
|
||||
const a = parseKey(k1);
|
||||
const b = parseKey(k2);
|
||||
const c = parseKey(k3);
|
||||
if (!a || !b) {
|
||||
return null;
|
||||
}
|
||||
return use3 && c ? [a, b, c] : [a, b];
|
||||
}, [k1, k2, k3, use3]);
|
||||
|
||||
const span = useMemo(() => (bases ? xorSpan(bases) : []), [bases]);
|
||||
|
||||
const entropy = useMemo(() => shannonEntropyBitsPerByte(blobHex), [blobHex]);
|
||||
|
||||
const copySpan = () => {
|
||||
const lines = span.map((v) => keyToHex(v)).join("\n");
|
||||
navigator.clipboard.writeText(lines);
|
||||
toast("Copied XOR span keys");
|
||||
};
|
||||
|
||||
const pushToKeysPage = () => {
|
||||
const lines = span.map((v) => keyToHex(v)).join("\n");
|
||||
sessionStorage.setItem("pn532_keylab_import", lines);
|
||||
toast("Stored for Keys page — open Keys and tap “Import Key Lab”");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="glass relative overflow-hidden p-8">
|
||||
<div className="pointer-events-none absolute -right-24 top-0 h-72 w-72 rounded-full bg-bubble-accent/15 blur-3xl" />
|
||||
<h1 className="font-display text-3xl font-bold text-glow-matrix md:text-4xl">Key Lab</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-400">
|
||||
XOR your base keys together in every combination → more candidates to paste into <strong>Brute</strong> or{" "}
|
||||
<strong>Keys</strong>. Entropy readout is just for fun on random hex blobs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Panel title="XOR span generator" badge="mix">
|
||||
<p className="mb-4 text-xs leading-relaxed text-slate-500">
|
||||
All XOR combinations of your base keys (2 bases → up to 4 keys; 3 bases → up to 8). Each result is a
|
||||
valid 6-byte Classic key candidate.
|
||||
</p>
|
||||
<label className="block text-xs text-slate-400">
|
||||
Base A
|
||||
<input
|
||||
value={k1}
|
||||
onChange={(e) => setK1(e.target.value.toUpperCase())}
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block text-xs text-slate-400">
|
||||
Base B
|
||||
<input
|
||||
value={k2}
|
||||
onChange={(e) => setK2(e.target.value.toUpperCase())}
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 flex items-center gap-2 text-xs text-slate-400">
|
||||
<input type="checkbox" checked={use3} onChange={(e) => setUse3(e.target.checked)} />
|
||||
Use third base
|
||||
</label>
|
||||
{use3 ? (
|
||||
<label className="mt-2 block text-xs text-slate-400">
|
||||
Base C
|
||||
<input
|
||||
value={k3}
|
||||
onChange={(e) => setK3(e.target.value.toUpperCase())}
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{!bases ? (
|
||||
<p className="mt-4 text-sm text-bubble-rose">Enter valid 12-hex keys.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="mt-4 max-h-48 space-y-1 overflow-auto rounded-xl border border-bubble-mint/15 bg-black/35 p-3 font-mono text-xs text-bubble-mint">
|
||||
{span.map((v) => (
|
||||
<li key={keyToHex(v)}>{keyToHex(v)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copySpan}
|
||||
className="rounded-xl bg-bubble-accent px-4 py-2 text-sm font-semibold text-bubble-950"
|
||||
>
|
||||
Copy all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={pushToKeysPage}
|
||||
className="rounded-xl border border-bubble-mint/40 px-4 py-2 text-sm text-bubble-mint"
|
||||
>
|
||||
Stage for Keys
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Blob entropy" badge="analysis">
|
||||
<p className="mb-4 text-xs text-slate-500">
|
||||
Shannon entropy per byte of your hex blob (0–8). Random uniform bytes → ~8; sparse UID-like → lower.
|
||||
</p>
|
||||
<textarea
|
||||
value={blobHex}
|
||||
onChange={(e) => setBlobHex(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-xl border border-white/10 bg-black/30 p-3 font-mono text-xs"
|
||||
/>
|
||||
<div className="mt-4 rounded-xl border border-bubble-accent/25 bg-bubble-accent/5 p-4 font-mono text-sm text-bubble-accent">
|
||||
{entropy == null ? "Invalid hex (even length, 0-9A-F)" : `${entropy.toFixed(3)} bits / byte`}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
web/src/pages/Keys.tsx
Normal file
137
web/src/pages/Keys.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useToast } from "../toast";
|
||||
import { LAB_DICTIONARY_KEYS, LAB_PURPOSE_NOTE } from "../validationFixtures";
|
||||
|
||||
const DEFAULT_KEYS = [
|
||||
"FFFFFFFFFFFF",
|
||||
"A0A1A2A3A4A5",
|
||||
"D3F7D3F7D3F7",
|
||||
"000000000000",
|
||||
];
|
||||
|
||||
const KEYSTORE = "pn532_key_dict_v1";
|
||||
|
||||
export default function Keys() {
|
||||
const toast = useToast();
|
||||
const [dict, setDict] = useState<string[]>([]);
|
||||
const [line, setLine] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(KEYSTORE);
|
||||
setDict(raw ? (JSON.parse(raw) as string[]) : [...DEFAULT_KEYS]);
|
||||
}, []);
|
||||
|
||||
const save = (next: string[]) => {
|
||||
localStorage.setItem(KEYSTORE, JSON.stringify(next));
|
||||
setDict(next);
|
||||
};
|
||||
|
||||
const mergeLabKeys = () => {
|
||||
const next = [...dict];
|
||||
let added = 0;
|
||||
for (const k of LAB_DICTIONARY_KEYS) {
|
||||
if (!next.includes(k)) {
|
||||
next.push(k);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
if (!added) {
|
||||
toast("Lab key corpus already merged", "info");
|
||||
return;
|
||||
}
|
||||
save(next);
|
||||
toast(`Added ${added} public lab key(s)`);
|
||||
};
|
||||
|
||||
const importKeyLab = () => {
|
||||
const raw = sessionStorage.getItem("pn532_keylab_import");
|
||||
if (!raw?.trim()) {
|
||||
toast("Key Lab has nothing staged", "info");
|
||||
return;
|
||||
}
|
||||
const keys = raw
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/\s/g, "").toUpperCase())
|
||||
.filter((l) => l.length === 12);
|
||||
if (!keys.length) {
|
||||
toast("No valid 12-hex keys in stash", "err");
|
||||
return;
|
||||
}
|
||||
const next = [...dict];
|
||||
let n = 0;
|
||||
for (const k of keys) {
|
||||
if (!next.includes(k)) {
|
||||
next.push(k);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
save(next);
|
||||
sessionStorage.removeItem("pn532_keylab_import");
|
||||
toast(n ? `Merged ${n} key(s) from Key Lab` : "Keys already in list");
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
const k = line.replace(/\s/g, "").toUpperCase();
|
||||
if (k.length !== 12) {
|
||||
toast("12 hex chars required", "err");
|
||||
return;
|
||||
}
|
||||
if (!dict.includes(k)) {
|
||||
save([k, ...dict]);
|
||||
toast("Key added");
|
||||
} else {
|
||||
toast("Already in dictionary");
|
||||
}
|
||||
setLine("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass space-y-6 p-6">
|
||||
<h1 className="font-display text-2xl font-bold">Key manager</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
Dictionary for manual trials (not a cloud rainbow table). Keys stay in your browser.
|
||||
</p>
|
||||
<p className="text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={mergeLabKeys}
|
||||
className="btn-neon rounded-xl border border-bubble-mint/40 bg-bubble-mint/10 px-4 py-2 font-mono text-xs font-bold text-bubble-mint"
|
||||
>
|
||||
Merge lab corpus
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={importKeyLab}
|
||||
className="rounded-xl border border-bubble-accent/45 bg-bubble-accent/10 px-4 py-2 font-mono text-xs font-bold text-bubble-accent"
|
||||
>
|
||||
Import Key Lab stash
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={line}
|
||||
onChange={(e) => setLine(e.target.value)}
|
||||
placeholder="New key"
|
||||
className="flex-1 rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono"
|
||||
/>
|
||||
<button type="button" className="rounded-2xl bg-bubble-accent px-4 py-2 font-semibold" onClick={add}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{dict.map((k) => (
|
||||
<li
|
||||
key={k}
|
||||
className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-2 font-mono text-sm"
|
||||
>
|
||||
{k}
|
||||
<button type="button" className="text-rose-300" onClick={() => save(dict.filter((x) => x !== k))}>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
web/src/pages/Library.tsx
Normal file
133
web/src/pages/Library.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import LabFixtureLoad from "../LabFixtureLoad";
|
||||
import { LIBRARY_LAB_SAMPLES, LAB_PURPOSE_NOTE } from "../validationFixtures";
|
||||
import type { BinaryCardFixture } from "../validationFixtures";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
type Saved = { id: string; name: string; hex: string; ts: number };
|
||||
|
||||
const KEY = "pn532_saved_tags_v1";
|
||||
|
||||
export default function Library() {
|
||||
const toast = useToast();
|
||||
const [items, setItems] = useState<Saved[]>([]);
|
||||
const [name, setName] = useState("My tag");
|
||||
const [hex, setHex] = useState("");
|
||||
const [labPick, setLabPick] = useState<BinaryCardFixture | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
setItems(raw ? (JSON.parse(raw) as Saved[]) : []);
|
||||
} catch {
|
||||
setItems([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persist = (next: Saved[]) => {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
setItems(next);
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
if (!hex.trim()) {
|
||||
toast("Paste hex first", "err");
|
||||
return;
|
||||
}
|
||||
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const s: Saved = { id, name, hex: hex.replace(/\s/g, ""), ts: Date.now() };
|
||||
persist([s, ...items]);
|
||||
toast("Saved");
|
||||
};
|
||||
|
||||
const remove = (id: string) => persist(items.filter((x) => x.id !== id));
|
||||
|
||||
const seedLabCatalog = () => {
|
||||
const names = new Set(items.map((x) => x.name));
|
||||
const ts = Date.now();
|
||||
const fresh = LIBRARY_LAB_SAMPLES.filter((s) => !names.has(s.name)).map((s, i) => ({
|
||||
id: `lab-seed-${ts}-${i}`,
|
||||
name: s.name,
|
||||
hex: s.hex,
|
||||
ts,
|
||||
}));
|
||||
if (!fresh.length) {
|
||||
toast("Lab catalog already in library", "info");
|
||||
return;
|
||||
}
|
||||
persist([...fresh, ...items]);
|
||||
toast(`Seeded ${fresh.length} lab record(s)`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass space-y-6 p-6">
|
||||
<h1 className="font-display text-2xl font-bold">Saved tag library</h1>
|
||||
<p className="text-sm text-slate-400">Local browser storage — export by copy/paste.</p>
|
||||
<p className="text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={seedLabCatalog}
|
||||
className="rounded-2xl border border-bubble-mint/30 bg-bubble-mint/10 px-4 py-2 font-mono text-xs font-semibold text-bubble-mint"
|
||||
>
|
||||
Seed all lab samples
|
||||
</button>
|
||||
</div>
|
||||
<LabFixtureLoad
|
||||
mode="binary"
|
||||
onApplyHex={(h) => setHex(h.replace(/\s/g, ""))}
|
||||
onPickBinary={setLabPick}
|
||||
/>
|
||||
{labPick && (
|
||||
<div className="rounded-xl border border-bubble-accent/25 bg-black/30 p-3 font-mono text-[10px] text-bubble-accent/90">
|
||||
<div className="text-bubble-mint/70">Expected SHA-256 (canonical blob, pre-edit)</div>
|
||||
<div className="break-all">{labPick.sha256Hex}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Label"
|
||||
className="rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
|
||||
/>
|
||||
<button type="button" onClick={add} className="rounded-2xl bg-bubble-accent px-4 py-2 font-semibold">
|
||||
Save current hex
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={hex}
|
||||
onChange={(e) => setHex(e.target.value)}
|
||||
placeholder="Hex dump"
|
||||
rows={5}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-xs"
|
||||
/>
|
||||
<ul className="space-y-3">
|
||||
{items.map((s) => (
|
||||
<li key={s.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-display font-semibold">{s.name}</div>
|
||||
<div className="text-xs text-slate-500">{new Date(s.ts).toLocaleString()}</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => remove(s.id)} className="text-rose-300">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<pre className="mt-2 max-h-32 overflow-auto text-xs text-bubble-mint/90">{s.hex}</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(s.hex);
|
||||
toast("Copied");
|
||||
}}
|
||||
className="mt-2 text-sm text-slate-300 underline"
|
||||
>
|
||||
Copy hex
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
web/src/pages/RawConsole.tsx
Normal file
77
web/src/pages/RawConsole.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from "react";
|
||||
import { apiGet, apiPost } from "../api";
|
||||
import LabFixtureLoad from "../LabFixtureLoad";
|
||||
import { LAB_PURPOSE_NOTE } from "../validationFixtures";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
export default function RawConsole() {
|
||||
const toast = useToast();
|
||||
const [frame, setFrame] = useState("4A0100");
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [gs, setGs] = useState("");
|
||||
|
||||
const push = (s: string) => setLog((x) => [new Date().toLocaleTimeString() + " " + s, ...x].slice(0, 80));
|
||||
|
||||
const send = async () => {
|
||||
try {
|
||||
const j = await apiPost<{ response?: string; error?: string }>("/api/raw/pn532", { frame });
|
||||
push(`TX ${frame}`);
|
||||
push(`RX ${j.response || j.error || "?"}`);
|
||||
if (j.response) {
|
||||
toast("Frame OK");
|
||||
}
|
||||
} catch (e) {
|
||||
push(`ERR ${String(e)}`);
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
const status = async () => {
|
||||
try {
|
||||
const j = await apiGet<{ raw?: number[] }>("/api/pn532/general-status");
|
||||
setGs(JSON.stringify(j.raw ?? j, null, 2));
|
||||
toast("Fetched PN532 status");
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass space-y-6 p-6">
|
||||
<h1 className="font-display text-2xl font-bold">Advanced / raw PN532</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
Send command bytes (without PN532 frame wrapper). Example: <code>4A0100</code> lists one Type A
|
||||
passive target at 106 kbps.
|
||||
</p>
|
||||
<p className="text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
|
||||
<LabFixtureLoad mode="raw" onApplyHex={(h) => setFrame(h)} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
value={frame}
|
||||
onChange={(e) => setFrame(e.target.value)}
|
||||
className="min-w-[16rem] flex-1 rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono text-sm"
|
||||
/>
|
||||
<button type="button" onClick={send} className="rounded-2xl bg-bubble-accent px-4 py-2 font-semibold">
|
||||
Send
|
||||
</button>
|
||||
<button type="button" onClick={status} className="rounded-2xl border border-white/15 px-4 py-2">
|
||||
General status
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-300">Log</h2>
|
||||
<pre className="mt-2 max-h-80 overflow-auto rounded-2xl border border-white/10 bg-black/30 p-3 text-xs">
|
||||
{log.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-300">PN532 status snapshot</h2>
|
||||
<pre className="mt-2 max-h-80 overflow-auto rounded-2xl border border-white/10 bg-black/30 p-3 text-xs">
|
||||
{gs || "—"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
web/src/pages/ReadAnalyze.tsx
Normal file
117
web/src/pages/ReadAnalyze.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { apiPost } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { parseTrailerAccess, sakLabel } from "../nfcUtils";
|
||||
|
||||
export default function ReadAnalyze() {
|
||||
const toast = useToast();
|
||||
const [key, setKey] = useState("FFFFFFFFFFFF");
|
||||
const [keyB, setKeyB] = useState(false);
|
||||
const [block, setBlock] = useState(0);
|
||||
const [hex, setHex] = useState("");
|
||||
const [trailer, setTrailer] = useState("");
|
||||
|
||||
const readBlock = async () => {
|
||||
try {
|
||||
const j = await apiPost<{ data?: string; error?: string }>("/api/mifare/read-block", {
|
||||
block,
|
||||
key,
|
||||
keyB,
|
||||
});
|
||||
if (j.data) {
|
||||
setHex(j.data);
|
||||
toast("Read OK");
|
||||
} else {
|
||||
toast(j.error || "failed", "err");
|
||||
}
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
const readUl = async () => {
|
||||
try {
|
||||
const j = await apiPost<{ data?: string }>("/api/ul/read-page", { page: block });
|
||||
if (j.data) {
|
||||
setHex(j.data + " (UL page)");
|
||||
toast("UL read OK");
|
||||
}
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
const bits = trailer ? parseTrailerAccess(trailer) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="glass p-6">
|
||||
<h1 className="font-display text-2xl font-bold">Read / analyze</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Authenticate with a known key, dump a block, paste a sector trailer to decode access bits.
|
||||
</p>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<label className="block space-y-2 text-sm">
|
||||
Key (12 hex)
|
||||
<input
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono text-bubble-mint outline-none ring-bubble-accent/40 focus:ring-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-end gap-3 text-sm">
|
||||
<input type="checkbox" checked={keyB} onChange={(e) => setKeyB(e.target.checked)} /> Key B
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm">
|
||||
Block / page
|
||||
<input
|
||||
type="number"
|
||||
value={block}
|
||||
onChange={(e) => setBlock(Number(e.target.value))}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2 outline-none ring-bubble-accent/40 focus:ring-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={readBlock}
|
||||
className="rounded-2xl bg-bubble-accent/90 px-4 py-2 font-semibold text-white"
|
||||
>
|
||||
MIFARE read block
|
||||
</button>
|
||||
<button type="button" onClick={readUl} className="rounded-2xl border border-white/15 px-4 py-2">
|
||||
Ultralight read page
|
||||
</button>
|
||||
</div>
|
||||
{hex && (
|
||||
<pre className="mt-4 overflow-auto rounded-2xl border border-white/10 bg-black/30 p-4 font-mono text-sm">
|
||||
{hex}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass p-6">
|
||||
<h2 className="font-display text-lg font-semibold">Sector trailer playground</h2>
|
||||
<label className="mt-4 block text-sm">
|
||||
16-byte trailer (32 hex) — bytes 6–8 are access bytes
|
||||
<textarea
|
||||
value={trailer}
|
||||
onChange={(e) => setTrailer(e.target.value)}
|
||||
rows={3}
|
||||
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-xs outline-none focus:ring-2 focus:ring-bubble-accent/40"
|
||||
/>
|
||||
</label>
|
||||
{bits && (
|
||||
<div className="mt-4 rounded-2xl border border-bubble-mint/30 bg-bubble-mint/5 p-4 text-sm">
|
||||
Parsed C1–C3 nibble pattern: {bits.c1} {bits.c2} {bits.c3} (see NXP MIFARE docs for truth
|
||||
tables)
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-xs text-slate-500">
|
||||
SAK hints: common values for labeling only — {sakLabel(0x18)} etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
web/src/pages/Settings.tsx
Normal file
55
web/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
export default function Settings() {
|
||||
const toast = useToast();
|
||||
const [apiBase, setApiBase] = useState("");
|
||||
const [light, setLight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setApiBase(localStorage.getItem("apiBase") || "");
|
||||
setLight(document.documentElement.classList.contains("light"));
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
localStorage.setItem("apiBase", apiBase);
|
||||
toast("Saved API base — reload for WS");
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = !light;
|
||||
setLight(next);
|
||||
document.documentElement.classList.toggle("light", next);
|
||||
document.documentElement.classList.toggle("dark", !next);
|
||||
localStorage.setItem("theme", next ? "light" : "dark");
|
||||
toast(next ? "Light" : "Dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass max-w-xl space-y-6 p-6">
|
||||
<h1 className="font-display text-2xl font-bold">Settings</h1>
|
||||
<label className="block text-sm">
|
||||
API base (empty = same host)
|
||||
<input
|
||||
value={apiBase}
|
||||
onChange={(e) => setApiBase(e.target.value)}
|
||||
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
|
||||
placeholder="http://192.168.4.1"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={save} className="rounded-2xl bg-bubble-accent px-4 py-2 font-semibold">
|
||||
Save
|
||||
</button>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<span className="text-sm">Theme</span>
|
||||
<button type="button" onClick={toggleTheme} className="rounded-full border border-white/15 px-4 py-1 text-sm">
|
||||
{light ? "Switch to dark" : "Switch to light"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Firmware opens a <strong>password-free</strong> SoftAP named <strong>PN532-Toolkit</strong> (lab
|
||||
default). mDNS: <code>pn532tool.local</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
web/src/pages/WriteClone.tsx
Normal file
153
web/src/pages/WriteClone.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import { apiPost } from "../api";
|
||||
import LabFixtureLoad from "../LabFixtureLoad";
|
||||
import { LAB_PURPOSE_NOTE } from "../validationFixtures";
|
||||
import type { BinaryCardFixture } from "../validationFixtures";
|
||||
import { useToast } from "../toast";
|
||||
import { useNfcWs } from "../NfcWsContext";
|
||||
import { uidWithSeparators } from "../nfcUtils";
|
||||
|
||||
export default function WriteClone() {
|
||||
const toast = useToast();
|
||||
const { lastTag } = useNfcWs();
|
||||
const [key, setKey] = useState("FFFFFFFFFFFF");
|
||||
const [keyB, setKeyB] = useState(false);
|
||||
const [block, setBlock] = useState(4);
|
||||
const [data, setData] = useState("00000000000000000000000000000000");
|
||||
const [labBlob, setLabBlob] = useState<BinaryCardFixture | null>(null);
|
||||
const [ulPage, setUlPage] = useState(4);
|
||||
const [ulData, setUlData] = useState("00000000");
|
||||
|
||||
const write = async () => {
|
||||
if (!confirm("Write will modify tag memory. Continue?")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiPost("/api/mifare/write-block", { block, key, keyB, data });
|
||||
toast("Write OK");
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
const writeUl = async () => {
|
||||
if (!confirm("Ultralight page write — can brick OTP/lock bytes if misused. Continue?")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiPost("/api/ul/write-page", { page: ulPage, data: ulData.replace(/\s/g, "") });
|
||||
toast("UL page write OK");
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-bold">Write / clone helpers</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Block editor for MIFARE Classic. Read blocks on the Read tab, adjust hex here, then write with a
|
||||
key that unlocks the sector.
|
||||
</p>
|
||||
{lastTag && (
|
||||
<p className="mt-2 rounded-lg border border-bubble-mint/20 bg-bubble-mint/5 px-3 py-2 font-mono text-xs text-bubble-mint">
|
||||
Field: <span className="text-white">{uidWithSeparators(lastTag.uid)}</span> ·{" "}
|
||||
{lastTag.typeGuess ?? `hint ${lastTag.typeHint}`}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
|
||||
<LabFixtureLoad
|
||||
mode="binary"
|
||||
className="mt-3"
|
||||
onApplyHex={(h) => {
|
||||
const clean = h.replace(/\s/g, "");
|
||||
if (clean.length <= 32) {
|
||||
setData(clean);
|
||||
} else {
|
||||
setData(clean.slice(0, 32));
|
||||
toast("First 16 bytes of lab blob loaded into block editor", "info");
|
||||
}
|
||||
}}
|
||||
onPickBinary={setLabBlob}
|
||||
/>
|
||||
{labBlob && (
|
||||
<p className="mt-2 font-mono text-[10px] text-bubble-accent/90">
|
||||
Canonical SHA-256 ({labBlob.byteLength} B): {labBlob.sha256Hex} — changes after any edit
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
Key (12 hex)
|
||||
<input
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-end gap-2 text-sm">
|
||||
<input type="checkbox" checked={keyB} onChange={(e) => setKeyB(e.target.checked)} /> Key B
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
Block
|
||||
<input
|
||||
type="number"
|
||||
value={block}
|
||||
onChange={(e) => setBlock(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
16 bytes (32 hex)
|
||||
<textarea
|
||||
value={data}
|
||||
onChange={(e) => setData(e.target.value)}
|
||||
rows={4}
|
||||
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
<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"
|
||||
>
|
||||
Write block
|
||||
</button>
|
||||
|
||||
<div className="border-t border-white/10 pt-8">
|
||||
<h2 className="font-display text-lg font-bold text-bubble-accent">Ultralight / NTAG page write</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
4 bytes per page (8 hex). Keep tag on the coil. Avoid lock / config pages unless you mean it.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
Page
|
||||
<input
|
||||
type="number"
|
||||
value={ulPage}
|
||||
onChange={(e) => setUlPage(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
Data (8 hex)
|
||||
<input
|
||||
value={ulData}
|
||||
onChange={(e) => setUlData(e.target.value)}
|
||||
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Write UL page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
web/src/toast.tsx
Normal file
52
web/src/toast.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
type Toast = { id: number; msg: string; kind: "info" | "err" };
|
||||
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();
|
||||
setList((x) => [...x, { id, msg, kind }]);
|
||||
setTimeout(() => setList((x) => x.filter((t) => t.id !== id)), 4200);
|
||||
}, []);
|
||||
const v = useMemo(() => push, [push]);
|
||||
return (
|
||||
<Ctx.Provider value={v}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-5 right-5 z-[60] flex max-w-sm flex-col gap-3">
|
||||
<AnimatePresence>
|
||||
{list.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
initial={{ opacity: 0, x: 40, rotate: -2, scale: 0.92 }}
|
||||
animate={{ opacity: 1, x: 0, rotate: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 20, scale: 0.9 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 26 }}
|
||||
className={`pointer-events-auto relative overflow-hidden border-2 px-5 py-3.5 font-mono text-xs backdrop-blur-md ${
|
||||
t.kind === "err"
|
||||
? "border-bubble-rose bg-bubble-950/95 text-bubble-rose shadow-glowRose"
|
||||
: "border-bubble-mint bg-bubble-900/95 text-bubble-mint shadow-glow"
|
||||
} `}
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 opacity-30 ${
|
||||
t.kind === "err"
|
||||
? "bg-gradient-to-r from-bubble-rose/20 to-transparent"
|
||||
: "bg-gradient-to-r from-bubble-accent/20 to-bubble-mint/10"
|
||||
}`}
|
||||
/>
|
||||
<span className="relative mr-2 font-black opacity-80">{t.kind === "err" ? "!!" : "»"}</span>
|
||||
<span className="relative">{t.msg}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
83
web/src/ui/HeroVisual.tsx
Normal file
83
web/src/ui/HeroVisual.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/** Decorative RF / matrix hero — pure SVG, no external assets */
|
||||
export default function HeroVisual({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div className={`relative ${className}`.trim()}>
|
||||
<svg
|
||||
viewBox="0 0 400 220"
|
||||
className="h-auto w-full max-w-md text-bubble-mint/90"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="g1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.9" />
|
||||
<stop offset="100%" stopColor="#00e5ff" stopOpacity="0.35" />
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="b" />
|
||||
<feMerge>
|
||||
<feMergeNode in="b" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Field rings */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<ellipse
|
||||
key={i}
|
||||
cx="200"
|
||||
cy="110"
|
||||
rx={40 + i * 38}
|
||||
ry={28 + i * 26}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.6"
|
||||
strokeOpacity={0.22 - i * 0.04}
|
||||
className="origin-center animate-[spin_32s_linear_infinite]"
|
||||
style={{ transformOrigin: "200px 110px", animationDelay: `${i * 2}s` }}
|
||||
/>
|
||||
))}
|
||||
{/* Sweep */}
|
||||
<g filter="url(#glow)">
|
||||
<path
|
||||
d="M200 110 L360 110 A160 80 0 0 1 200 190 Z"
|
||||
fill="url(#g1)"
|
||||
fillOpacity="0.12"
|
||||
className="origin-center animate-[spin_6s_linear_infinite]"
|
||||
style={{ transformOrigin: "200px 110px" }}
|
||||
/>
|
||||
</g>
|
||||
{/* Coil */}
|
||||
<circle cx="200" cy="110" r="36" fill="none" stroke="currentColor" strokeWidth="1.2" opacity="0.5" />
|
||||
<circle cx="200" cy="110" r="22" fill="none" stroke="#00e5ff" strokeWidth="0.8" opacity="0.45" />
|
||||
<circle cx="200" cy="110" r="8" fill="currentColor" opacity="0.35" />
|
||||
{/* Matrix ticks */}
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const a = (i / 12) * Math.PI * 2;
|
||||
const x = 200 + Math.cos(a) * 118;
|
||||
const y = 110 + Math.sin(a) * 78;
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={x - 1}
|
||||
y={y - 1}
|
||||
width="2"
|
||||
height="2"
|
||||
fill="currentColor"
|
||||
opacity={0.15 + (i % 3) * 0.08}
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: `${i * 0.15}s` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<text
|
||||
x="200"
|
||||
y="205"
|
||||
textAnchor="middle"
|
||||
className="fill-bubble-mint/40 font-mono text-[9px] tracking-[0.35em]"
|
||||
>
|
||||
13.56MHZ
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
web/src/ui/Panel.tsx
Normal file
44
web/src/ui/Panel.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function Panel({
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
title: string;
|
||||
badge?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-40px" }}
|
||||
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={`glass panel-edge relative overflow-hidden p-6 ${className}`.trim()}
|
||||
>
|
||||
<div className="pointer-events-none absolute -right-20 -top-20 h-40 w-40 rounded-full bg-bubble-accent/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-16 -left-16 h-36 w-36 rounded-full bg-bubble-rose/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-bubble-accent/[0.06] via-transparent to-bubble-mint/[0.07]" />
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="font-display text-lg font-normal tracking-wide text-bubble-mint text-glow-matrix">
|
||||
{title}
|
||||
</h2>
|
||||
{badge ? (
|
||||
<motion.span
|
||||
animate={{ boxShadow: ["0 0 12px rgba(0,229,255,0.3)", "0 0 22px rgba(0,255,157,0.45)", "0 0 12px rgba(0,229,255,0.3)"] }}
|
||||
transition={{ duration: 2.2, repeat: Infinity }}
|
||||
className="rounded-full border border-bubble-accent/50 bg-bubble-accent/15 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-widest text-bubble-accent"
|
||||
>
|
||||
{badge}
|
||||
</motion.span>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
58
web/src/useWebSocket.ts
Normal file
58
web/src/useWebSocket.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { apiBaseUrl } from "./api";
|
||||
|
||||
const wsUrl = () => {
|
||||
const base = apiBaseUrl();
|
||||
if (base) {
|
||||
try {
|
||||
const u = new URL(base.includes("://") ? base : `http://${base}`);
|
||||
const proto = u.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${u.host}/ws`;
|
||||
} catch {
|
||||
/* invalid stored base — fall back to current page */
|
||||
}
|
||||
}
|
||||
const loc = window.location;
|
||||
const proto = loc.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${loc.host}/ws`;
|
||||
};
|
||||
|
||||
export function useToolkitWs(onMessage: (data: string) => void) {
|
||||
const [ok, setOk] = useState(false);
|
||||
const cb = useRef(onMessage);
|
||||
cb.current = onMessage;
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
let attempt = 0;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
const connect = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
ws = new WebSocket(wsUrl());
|
||||
ws.onopen = () => {
|
||||
attempt = 0;
|
||||
setOk(true);
|
||||
ws?.send("ping");
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setOk(false);
|
||||
attempt += 1;
|
||||
timer = setTimeout(connect, Math.min(8000, 500 + attempt * 400));
|
||||
};
|
||||
ws.onerror = () => ws?.close();
|
||||
ws.onmessage = (ev) => cb.current(String(ev.data));
|
||||
};
|
||||
connect();
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
ws?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ok;
|
||||
}
|
||||
137
web/src/validationFixtures.ts
Normal file
137
web/src/validationFixtures.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Synthetic / public-domain patterns for lab validation only.
|
||||
* No real credentials, payment PANs, or live tag dumps.
|
||||
* Use these to verify hex decode/encode, hashing, and UI flows before mutating bytes for your own tests.
|
||||
*/
|
||||
|
||||
export const LAB_PURPOSE_NOTE =
|
||||
"Lab-only synthetic data and well-known defaults. Edit bytes freely to validate parsers, checksums, and emulate/raw paths — never treat these as real tags.";
|
||||
|
||||
/** PN532 command bytes only (no transport wrapper). */
|
||||
export type RawPn532Lab = { id: string; title: string; hex: string; detail: string };
|
||||
|
||||
export const RAW_PN532_LAB: RawPn532Lab[] = [
|
||||
{
|
||||
id: "get-fw",
|
||||
title: "GetFirmwareVersion (0x02)",
|
||||
hex: "02",
|
||||
detail: "Expect PN532 firmware major/minor in response payload.",
|
||||
},
|
||||
{
|
||||
id: "get-status",
|
||||
title: "GetGeneralStatus (0x04)",
|
||||
hex: "04",
|
||||
detail: "Chip + RF status snapshot; useful sanity check after a stuck session.",
|
||||
},
|
||||
{
|
||||
id: "in-list-a",
|
||||
title: "InListPassiveTarget — 106 kbps, 1 target",
|
||||
hex: "4A0100",
|
||||
detail: "Poll one ISO14443-A target at 106 kbps (maxTg=1, BrTy=0).",
|
||||
},
|
||||
];
|
||||
|
||||
/** Emulate path: full payload after host framing (what /api/nfc/emulate-raw sends). */
|
||||
export type EmulateLab = { id: string; title: string; hex: string; detail: string };
|
||||
|
||||
export const EMULATE_LAB: EmulateLab[] = [
|
||||
{
|
||||
id: "tg-stub",
|
||||
title: "TgInitAsTarget — opcode only (shell)",
|
||||
hex: "8C",
|
||||
detail: "Minimal placeholder; real target mode needs a full parameter block per NXP UM0701. Extend with NFCID, FeliCa params, etc.",
|
||||
},
|
||||
{
|
||||
id: "tg-type-a-sketch",
|
||||
title: "TgInitAsTarget — Type A sketch (synthetic NFCID3T)",
|
||||
hex: "8C000012DEADBEEF00000000000000000000000000000000000000000000000000",
|
||||
detail:
|
||||
"Synthetic 0x8C prefix + padded fake ID — not guaranteed to satisfy any reader; use as a byte-editing template only.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Contiguous hex blobs: mutate one nibble and confirm SHA-256 changes in your tooling. */
|
||||
export type BinaryCardFixture = {
|
||||
id: string;
|
||||
title: string;
|
||||
hex: string;
|
||||
byteLength: number;
|
||||
/** SHA-256 of raw bytes from `hex` (pre-edit canonical blob). */
|
||||
sha256Hex: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
/** MIFARE Classic sector 0 (4×16 B): synthetic UID block + empty data + default trailer. */
|
||||
const MIFARE_SECTOR0_LAB =
|
||||
"DEADBEEF220804000000000000000000" +
|
||||
"00000000000000000000000000000000" +
|
||||
"00000000000000000000000000000000" +
|
||||
"FFFFFFFFFFFFFF078069FFFFFFFFFFFF";
|
||||
|
||||
/** NTAG Type 2 — 7 pages × 4 B (UID / internal / lock placeholders). */
|
||||
const NTAG_PAGES_0_6_LAB = "04112233445566172233445566172233445566172233445566172233";
|
||||
|
||||
/** NDEF TLV on Type 2: URI https://example.com/ (well-known RTD). */
|
||||
const NDEF_TLV_URI_LAB = "0310D1010C55046578616D706C652E636F6DFE";
|
||||
|
||||
export const BINARY_CARD_FIXTURES: BinaryCardFixture[] = [
|
||||
{
|
||||
id: "mifare-block0",
|
||||
title: "MIFARE Classic — block 0 (16 B, synthetic UID+BCC)",
|
||||
hex: "DEADBEEF220804000000000000000000",
|
||||
byteLength: 16,
|
||||
sha256Hex: "6f13f064d70ec33b7ace5842011d66aff3852baa4ab7c0a85aa66d113c7189fe",
|
||||
detail:
|
||||
"Fake 4-byte UID (DE:AD:BE:EF wire order as stored), BCC = XOR(UID), rest filler. Pair with key FFFFFFFFFFFF on a writable tag only.",
|
||||
},
|
||||
{
|
||||
id: "mifare-sector0",
|
||||
title: "MIFARE Classic — sector 0 dump (64 B)",
|
||||
hex: MIFARE_SECTOR0_LAB,
|
||||
byteLength: 64,
|
||||
sha256Hex: "11095df4db11e4271f40234533db45de35c4ca69f04e231e3fa47f38181e0168",
|
||||
detail:
|
||||
"Four blocks: manufacturer/UID, empty, empty, default trailer (Key A/B FFFFFFFFFFFF, access FF078069). Edit block 1–2 payload and re-hash to validate your pipeline.",
|
||||
},
|
||||
{
|
||||
id: "ntag-pages",
|
||||
title: "NTAG-style — pages 0–6 (28 B)",
|
||||
hex: NTAG_PAGES_0_6_LAB,
|
||||
byteLength: 28,
|
||||
sha256Hex: "8ffd95e646b2deb218dabd72e35c7a1938725e07332d8ed7df4f2a2656e590a0",
|
||||
detail: "Synthetic UID/internal/lock placeholders — not copied from a physical tag.",
|
||||
},
|
||||
{
|
||||
id: "ndef-uri-tlv",
|
||||
title: "NDEF TLV — URI https://example.com/",
|
||||
hex: NDEF_TLV_URI_LAB,
|
||||
byteLength: 19,
|
||||
sha256Hex: "81197b6cd6e40a3f62f56f0c0f3f449c63a956919d04c5b5317895ad4328fa14",
|
||||
detail: "Type 2 TLV (0x03) + short NDEF Well-Known URI record; append to user memory after static pages in real layouts.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Extra well-known MIFARE Classic keys (public lists / defaults). Lab corpus only. */
|
||||
export const LAB_DICTIONARY_KEYS: string[] = [
|
||||
"FFFFFFFFFFFF",
|
||||
"A0A1A2A3A4A5",
|
||||
"D3F7D3F7D3F7",
|
||||
"000000000000",
|
||||
"B0B1B2B3B4B5",
|
||||
"AABBCCDDEEFF",
|
||||
"4D3A99C351DD",
|
||||
"1A982C7E459A",
|
||||
"714C5C886E97",
|
||||
"587EE5F9350F",
|
||||
"A0478CC39091",
|
||||
"26940B21FFF5",
|
||||
"E4410EF8ED2D",
|
||||
];
|
||||
|
||||
export type LibraryLabSample = { name: string; hex: string; note: string };
|
||||
|
||||
export const LIBRARY_LAB_SAMPLES: LibraryLabSample[] = BINARY_CARD_FIXTURES.map((f) => ({
|
||||
name: `LAB // ${f.title}`,
|
||||
hex: f.hex,
|
||||
note: f.detail,
|
||||
}));
|
||||
81
web/tailwind.config.js
Normal file
81
web/tailwind.config.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ["Audiowide", "Orbitron", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
sans: ["JetBrains Mono", "ui-monospace", "monospace"],
|
||||
mono: ["JetBrains Mono", "ui-monospace", "monospace"],
|
||||
},
|
||||
colors: {
|
||||
bubble: {
|
||||
950: "#020408",
|
||||
900: "#051210",
|
||||
800: "#0a1a16",
|
||||
700: "#0f2820",
|
||||
accent: "#00e5ff",
|
||||
mint: "#00ff9d",
|
||||
rose: "#ff2a6d",
|
||||
volt: "#d4ff00",
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
glow: "0 0 50px -10px rgba(0,255,157,0.55), 0 0 100px -40px rgba(0,229,255,0.35), 0 0 30px -5px rgba(255,42,109,0.2)",
|
||||
glowCyan: "0 0 40px -5px rgba(0,229,255,0.65)",
|
||||
glowRose: "0 0 35px -5px rgba(255,42,109,0.5)",
|
||||
insetTerminal: "inset 0 1px 0 0 rgba(0,255,157,0.12)",
|
||||
neonBtn: "0 0 25px rgba(0,229,255,0.45), 0 0 50px rgba(0,255,157,0.2), inset 0 0 20px rgba(0,229,255,0.15)",
|
||||
},
|
||||
keyframes: {
|
||||
flicker: {
|
||||
"0%, 100%": { opacity: "1" },
|
||||
"92%": { opacity: "0.96" },
|
||||
"94%": { opacity: "1" },
|
||||
},
|
||||
borderPulse: {
|
||||
"0%, 100%": { borderColor: "rgba(0, 255, 157, 0.35)" },
|
||||
"50%": { borderColor: "rgba(0, 229, 255, 0.55)" },
|
||||
},
|
||||
pulseGlow: {
|
||||
"0%, 100%": { opacity: "0.35", transform: "scale(1)" },
|
||||
"50%": { opacity: "0.65", transform: "scale(1.08)" },
|
||||
},
|
||||
floatSlow: {
|
||||
"0%, 100%": { transform: "translate(0, 0) rotate(0deg)" },
|
||||
"33%": { transform: "translate(12px, -18px) rotate(2deg)" },
|
||||
"66%": { transform: "translate(-8px, 10px) rotate(-1deg)" },
|
||||
},
|
||||
gridDrift: {
|
||||
"0%": { backgroundPosition: "0 0, 0 0, 0 0" },
|
||||
"100%": { backgroundPosition: "24px 24px, 24px 24px, 100% 100%" },
|
||||
},
|
||||
scanBar: {
|
||||
"0%": { transform: "translateX(-100%)" },
|
||||
"100%": { transform: "translateX(400%)" },
|
||||
},
|
||||
hueCycle: {
|
||||
"0%": { filter: "hue-rotate(0deg)" },
|
||||
"100%": { filter: "hue-rotate(360deg)" },
|
||||
},
|
||||
shimmerLine: {
|
||||
"0%": { transform: "translateX(-100%) skewX(-12deg)", opacity: "0" },
|
||||
"20%": { opacity: "0.9" },
|
||||
"100%": { transform: "translateX(200%) skewX(-12deg)", opacity: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
flicker: "flicker 3.5s ease-in-out infinite",
|
||||
borderPulse: "borderPulse 2.5s ease-in-out infinite",
|
||||
pulseGlow: "pulseGlow 5s ease-in-out infinite",
|
||||
floatSlow: "floatSlow 18s ease-in-out infinite",
|
||||
gridDrift: "gridDrift 20s linear infinite",
|
||||
scanBar: "scanBar 3.5s ease-in-out infinite",
|
||||
hueCycle: "hueCycle 14s linear infinite",
|
||||
shimmerLine: "shimmerLine 2.8s ease-in-out infinite",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
16
web/tsconfig.json
Normal file
16
web/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
web/vite.config.ts
Normal file
11
web/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "/",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user