# MTG RFID Companion A hand-held **Magic: The Gathering** companion built on an **ESP32-WROOM-32**. Tap an RFID/NFC-tagged card to a built-in reader and the device: - renders the card's cover art and metadata on a **2.0" ST7789 TFT (240x320)**, - flashes a **WS2812B Neopixel** in the card's mana color and plays a short **buzzer** chirp, - keeps a **life counter** (20/40) you adjust with a rotary encoder, - caches cards locally on **LittleFS** and refetches from the **Scryfall API** over Wi-Fi, - runs a **SoftAP registration server** (`MTG-Companion`) so you can pair an unknown tag to a card name from your phone. All code is **pure ESP-IDF v5.x (C) / FreeRTOS** β€” no Arduino. --- ## Features | | | | :--- | :--- | | πŸƒ RFID scanning | MFRC522, NTAG21x / MIFARE tags, 4- and 7-byte UIDs | | πŸ–₯️ Display | ST7789 240x320 RGB565, 5x7 text, scaled digits, streaming JPEG decode | | 🌐 Network | Wi-Fi STA auto-connect, Scryfall HTTPS with local cache | | πŸ“‘ Pairing | SoftAP `MTG-Companion` (192.168.4.1) web SPA for UID ↔ card name | | πŸ”„ UX | EC11 rotary encoder, backlit life counter, mana-colored LED, audio cues | | πŸ’Ύ Storage | LittleFS `/spiffs` (~1.9MB) seeded with card mapping + example art | --- ## Hardware Requirements - **ESP32-WROOM-32** dev board (4MB flash, no PSRAM required) - **MFRC522** RFID reader (13.56 MHz, SPI) - **ST7789** SPI TFT, 240x320 (e.g. common 2.0-2.4" breakout) - **EC11** rotary encoder with push switch - **WS2812B / Neopixel** (single LED) - **Passive piezo buzzer** - Antenna-friendly **NTAG213/NTAG215** stickers or MIFARE cards, ~3.3V–5V supply --- ## Wiring Guide > **Do not change these GPIO assignments in code** unless you update both > `main/hw_pins.h` and this table. The ST7789 and MFRC522 **share one SPI bus** (SPI2_HOST). ### Pin map > **Display and reader are on two separate SPI controllers:** the ST7789 on > **SPI2_HOST (VSPI)**, the MFRC522 on **SPI3_HOST (HSPI)** β€” so the panel and > the RFID reader never share bus timing. | Peripheral | Function | ESP32 GPIO | Notes | | :--- | :--- | :---: | :--- | | **ST7789** (7-pin) β€” SPI2_HOST | SCL | **18** | SPI2 (VSPI) clock | | | SDA | **23** | SPI2 MOSI (data) | | | CS | **21** | dedicated chip select (non-strapping pin) | | | DC | **19** | data/command (non-strapping pin) | | | RST | **4** | panel reset | | | VCC / GND | 3.3V / GND | panel power (no separate LED pin) | | **MFRC522** β€” SPI3_HOST | SCK | **16** | SPI3 (HSPI) clock (own bus) | | | MOSI | **17** | SPI3 data out | | | MISO | **13** | SPI3 data in | | | SDA (CS) | **26** | dedicated chip select | | | RST | **22** | hard reset | | | 3.3V / GND | β€” | feed from board 3.3V rail | | **EC11** | CLK | **32** | interrupt input, pull-up | | | DT | **33** | quadrature B | | | SW | **25** | button, active low, pull-up | | **WS2812B** | DIN | **27** | RMT Tx; supply 5V/VDD, GND common | | **Buzzer** | SIG | **14** | LEDC PWM | ### Suggested breadboard layout ``` ESP32-WROOM-32 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 3V3 16 ── SCK ────────┐ β”‚ β”‚ GND 17 ── MOSI ────┐ β”‚ β”‚ β”‚ 13 ── MISO β”€β”€β”€β”€β”Όβ”€β”€β”˜ β”‚ β”‚ 26 ── SDA(CS) β”€β”˜ β”‚ β”‚ 22 ── RST ─┐ β”‚ β”‚ 21 ── CS ──┼──────── ST7789 (7-pin) β”‚ 19 ── DC ─── β”‚ SCL = 18, SDA = 23 β”‚ 4 ── RST β”€β”˜ β”‚ VCC = 3V3, GND = GND β”‚ 18 ── SCL β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ 23 ── SDA ───────── (display = SPI2) β”‚ 32 ── CLK ───────── EC11 33 ── DT 25 ── SW/GND β”‚ 27 ── Din ───────── WS2812B (5V + GND) β”‚ 14 ── SIG ───────── Buzzer (GND) β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` > **MFRC522 (own SPI3/HSPI bus):** SCKβ†’16, MOSIβ†’17, MISOβ†’13, SDA(CS)β†’26, > RSTβ†’22, VCCβ†’3.3V, GNDβ†’GND. It shares **no lines** with the display. > > **ST7789 (SPI2/VSPI):** SCLβ†’18, SDAβ†’23, CSβ†’21, DCβ†’19, RSTβ†’4, VCCβ†’3.3V, GNDβ†’GND. > > CS (GPIO21) and DC (GPIO19) are deliberately **not** strapping pins, so the > panel can stay connected during `idf.py flash`. (The old GPIO15/GPIO2 > assignment made the panel hold boot-strap lines and block flashing.) - Keep SPI signal runs short (< 10 cm) for reliable 20 MHz display transfers. - NTAG213/215 FPC stickers need to sit flat and close to the MFRC522 antenna. - The **WS2812B** draws up to ~60 mA; power it from the 5V rail, never from a GPIO. --- ## Getting Started ### 1. Toolchain Install ESP-IDF **v5.5** (any 5.x works): https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/ Verify: ```powershell # Windows (Espressif "ESP-IDF 5.5 PowerShell" terminal) . C:\Espressif\Initialize-Idf.ps1 # or: . $IDF_PATH\export.ps1 idf.py --version ``` > **Never used Espressif tools before? Skip ahead to > "[Changing the Wi-Fi without the toolchain](#changing-the-wifi-without-the-toolchain)";** > the section below is optional. ### 2. Build ```powershell idf.py set-target esp32 idf.py build ``` The custom partition table (`partitions.csv`) reserves: | Offset | Size | Subtype | Purpose | | :--- | :--- | :--- | :--- | | 0x10000 | 2 MB | app/factory | firmware | | 0x210000 | ~1.9 MB | data/littlefs | `/spiffs` cache (seeded from `storage/`) | ### 3. Configure Wi-Fi Two equivalent ways: ```powershell # a) interactive menu (recommended) idf.py menuconfig # -> "MTG RFID Companion" -> SSID + password # b) edit the preset file directly # sdkconfig.defaults.esp32 ``` Leaving the SSID empty (or keeping the `YOUR_...` placeholders) **disables station mode**. The SoftAP pairing server runs regardless, so the device stays usable as a fully standalone scanner β€” no home network required. #### What "station mode disabled" actually means The Wi-Fi radio still turns on, but only as an access point: 1. `wifi_manager_start()` sees the blank/placeholder SSID and returns early β€” it never calls `esp_wifi_connect()`, so the device won't try to join any router (and won't pretend to be online). 2. `web_server_start()` starts unconditionally: SoftAP **`MTG-Companion`** at `192.168.4.1` (own DHCP) plus the `/api/last_uid` and `/api/bind` endpoints. 3. `net_task` skips the 60-second connect wait **and** the boot-time prefetch. Card lookups only run when you explicitly trigger one, and those fail fast (no internet route) with a `scryfall` log line β€” they never block the UI. #### What works and what doesn't | Feature | In AP-only mode | Notes | | :--- | :--- | :--- | | RFID scanning, life counter, LED/buzzer | βœ” | fully local | | Showing **already-cached** cards (art + stats) | βœ” | served from `/spiffs` | | Binding a UID to a card name | βœ” | written to `/spiffs/mappings.json` immediately | | **Scryfall lookup + art download** | ✘ | the device has no internet here; the fetch fails and is logged. It completes automatically on a later boot **with Wi-Fi configured** β€” the boot-time prefetch walks `mappings.json` and downloads anything missing | | Scryfall autocomplete in the pairing page | ⚠️ | the AP doesn't pass internet through (no NAT). A phone with **cellular data keeps its mobile connection**, so autocomplete works there; on a device with no other internet path, type the exact card name manually | > **Use case:** AP-only mode is the normal "deck on the table" flow β€” mid-game > you almost never need live Scryfall lookups. When you do (new card, missing > art), configure Wi-Fi once, boot briefly, and the binding + art cache update > themselves via prefetch. --- ## Changing the Wi-Fi without the toolchain > **If you're new to this and haven't installed any Espressif software yet, > start here.** The Wi-Fi network the device connects to is **baked into the firmware at compile time** β€” there is no on-device settings menu yet. To change it you must edit one text file and produce a new firmware image. Here is the shortest ESPRESSIF-FREE path to do that, with zero drivers or toolchains. ### The one file you edit Open **`sdkconfig.defaults.esp32`** in any text editor (Notepad is fine) and find these two lines: ``` CONFIG_MTG_WIFI_SSID="YOUR_SSID_HERE" CONFIG_MTG_WIFI_PASS="YOUR_PASSWORD_HERE" ``` Replace the placeholders with **your** network name and password, keeping the quotes: ``` CONFIG_MTG_WIFI_SSID="MyHomeWiFi" CONFIG_MTG_WIFI_PASS="correct horse battery staple" ``` - Keep the file **UTF-8 without BOM**, and don't add spaces inside the quotes. - If your password has a `"` or `\` in it, this file format can't represent it (rare) β€” pick that one character out of the password or use the toolchain menu instead. - Want to go back to offline-only later? Restore the `YOUR_...` placeholders and rebuild. ### Getting a new firmware image from this file The repo cannot build firmware without the toolchain. Your options, easiest first: | Path | What's needed | How | | :--- | :--- | :--- | | **Ask whoever gave you the device** | a minute of their time | Send them the edited file; they run `idf.py build` and hand you a new `.bin` | | **One-time toolchain install (~45 min, guided)** | the Espressif installer (free, Windows/macOS/Linux) | Follow the official guide: then run the 5 commands in [Build](#2-build) | | **CI build** (GitHub Actions etc.) | a GitHub account | The repo can build on `ubuntu` in CI; push the edited file and download the built `mtg-rfid-companion.bin` artifact | ### Flashing the new firmware (no toolchain needed) Flash using **Espressif Flash Download Tool** (graphical, no toolchain): or `esptool.py` if you already have Python: ``` pip install esptool esptool.py -p COM3 --chip esp32 --before default_reset write_flash --flash_mode dio --flash_size 4MB 0x1000 bootloader.bin 0x8000 partition-table.bin 0x10000 mtg-rfid-companion.bin ``` ### TL;DR 1. Edit `sdkconfig.defaults.esp32` β†’ 2. rebuild (yours or someone's toolchain) β†’ 3. flash the `.bin` β†’ 4. done. If you don't need your home Wi-Fi, **skip the whole thing**: the device works standalone and has its own AP. --- ### 4. Flash & watch ```powershell idf.py -p COMx flash monitor # COMx = your serial port ``` Expected boot log if everything is wired right: ``` I storage: LittleFS mounted at /spiffs: bytes total ... I storage: boot_count read back = 1 (persistence across reboots OK) I rfid: RC522 polling task pinned to Core 0 I display: ST7789 240x320 ready ... I web: SoftAP 'MTG-Companion' at 192.168.4.1, web server ready I ui: UI task on core 1 ``` > **RFID reader missing / not wired? That's OK.** A reader self-test failure no > longer aborts boot β€” the device logs a warning and continues into the display, > SoftAP and web-pairing screens without card scanning: --- ## Usage ### Scan a card Place a tagged card on the reader: 1. **Mapped UID** β†’ chirp, mana-colored LED flash, then the card view (cover art + name/type/PΒ·T/price/mana). 2. **Unknown UID** β†’ registration screen with the pairing address. ### Life counter Press the encoder button to toggle **SCANNER ↔ LIFE_COUNTER**. Rotate to adjust (20/40 preset or whatever you last set; press again to reset to the preset). ### Web pairing (registration mode) 1. Connect your phone to the `MTG-Companion` access point. A phone with cellular data keeps its mobile internet, so Scryfall autocomplete works; otherwise type the name manually. 2. Open `http://192.168.4.1`. 3. Scan an unmapped card β€” the UID appears in the page. 4. Type / pick a Scryfall-autocomplete name β†’ **Pair UID with card**. 5. The mapping persists to `/spiffs/mappings.json`. The card data/art fetch runs immediately if the device has Wi-Fi, or is picked up automatically by the boot-time prefetch the next time you boot with Wi-Fi configured. --- ## Storage Layout (`/spiffs`) ``` /spiffs/ β”œβ”€β”€ mappings.json UID -> Card Name map β”œβ”€β”€ cards/ β”‚ β”œβ”€β”€ sample.json example metadata β”‚ β”œβ”€β”€ sample.jpg example cover art (baked into flash image) β”‚ β”œβ”€β”€ .json Scryfall metadata cache β”‚ └── .jpg cached cover art ``` The `storage/` folder is compiled into the LittleFS partition image at build time (`littlefs_create_partition_image`), so a fresh flash already contains `mappings.json` and the example art. --- ## Troubleshooting | Symptom | Fix | | :--- | :--- | | Display = black / garbage | Check CS/DC/RST wiring; try toggling `esp_lcd_panel_invert_color` and `LCD_RGB_ELEMENT_ORDER_*` in `display_manager.c` | | Colors inverted | Same toggle as above β€” panel clones differ | | No UID printed on scan | Verify MFRC522 wiring, tag type (ISO14443A), antenna positioning; confirm `rfid: RC522 polling task pinned to Core 0` in the log | | Boot log: `rc522: FIFO length missmatch` / `RTOS: RFID unavailable` | Reader self-test failed. Check the MFRC522 has 3.3V power (many clones have a separate power pin), CS=GPIO26 / RST=GPIO22, SCK/MOSI/MISO=16/17/13 on its own SPI3 bus. Try lowering `RC522_SPI_CLK_HZ` in `rfid_manager.c` (e.g. `1000000`); the skip-self-test option (`CONFIG_MTG_RFID_SKIP_SELFTEST`) lets scanning start anyway | | Wi-Fi never connects | Re-run `idf.py menuconfig`, confirm you replaced the `YOUR_...` placeholders | | Scryfall autocomplete shows nothing | The AP has no internet passthrough β€” the phone needs its own mobile data connection, or type the card name manually | | Can't reach 192.168.4.1 | Ensure your phone joined `MTG-Companion`, not a cached network | | Flash fails: `Serial data stream stopped` | If the ST7789 is wired with CS=GPIO15 / DC=GPIO2 (the old strap-pin assignment), disconnect/power-down the display during `idf.py flash` β€” the panel holds the boot-strap lines at reset and corrupts the serial link. With the current recommended wiring (CS=GPIO21, DC=GPIO19, both non-strapping) the display can stay connected. | | RFID step of boot log: `Buffers content missmatch`, buffer2 = `13 33 37` | The reader answers (SPI OK) but the PCD self-test is flaky on clones. RST can stay unconnected (`-1`), the clock is already 1 MHz; confirm the MFRC522's dedicated power header gets a clean 3.3V and that MISO=GPIO13 is actually connected | | Card renders but no art | Card not cached yet β€” check `scryfall` log lines; art downloads are best-effort | | App grows near 2MB | mbedTLS + HTTP pulls are large; if needed, enable `CONFIG_MBEDTLS_CERTIFICATE_BUNDLE` (smaller) | --- ## Project Layout ``` β”œβ”€β”€ CMakeLists.txt top-level; bakes LittleFS storage image β”œβ”€β”€ partitions.csv custom partition table β”œβ”€β”€ sdkconfig.defaults shared ESP32 defaults β”œβ”€β”€ sdkconfig.defaults.esp32 Wi-Fi credential preset (edit me) β”œβ”€β”€ storage/ files pre-seeded into /spiffs β”œβ”€β”€ tools/ host-side helpers (sample JPEG generator) └── main/ β”œβ”€β”€ hw_pins.h GPIO assignments (authoritative) β”œβ”€β”€ spi_bus_manager.c shared SPI2_HOST bus β”œβ”€β”€ storage_manager.c LittleFS mount + JSON helpers β”œβ”€β”€ rfid_manager.c MFRC522 / Core 0 polling β”œβ”€β”€ display_manager.c ST7789 + fonts + streaming JPEG β”œβ”€β”€ ui_task.c state machine + screens (Core 1) β”œβ”€β”€ wifi_manager.c STA connect/reconnect β”œβ”€β”€ scryfall_client.c Scryfall HTTPS + cache (net_task) β”œβ”€β”€ web_server.c SoftAP pairing server β”œβ”€β”€ encoder.c EC11 ISR decode β”œβ”€β”€ led_manager.c WS2812B RMT β”œβ”€β”€ buzzer.c LEDC PWM tones └── app_state.c shared app/life-counter state ``` See `AGENTS.md` for the detailed architecture, core split, and coding conventions used across this repository.