337 lines
14 KiB
Markdown
337 lines
14 KiB
Markdown
# 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
|
||
|
||
| Peripheral | Function | ESP32 GPIO | Notes |
|
||
| :--- | :--- | :---: | :--- |
|
||
| **Shared SPI** | SCK | **18** | SPI2_HOST (VSPI), 1 MHz modules |
|
||
| | MOSI | **23** | |
|
||
| | MISO | **19** | |
|
||
| **MFRC522** | SDA (CS) | **5** | dedicated chip select |
|
||
| | RST | **22** | hard reset |
|
||
| | 3.3V / GND | — | feed from board 3.3V rail |
|
||
| **ST7789** | CS | **15** | dedicated chip select |
|
||
| | DC (A0) | **2** | data/command |
|
||
| | RST | **4** | panel reset |
|
||
| | LED | 3.3V or 5V | optional backlight pin |
|
||
| **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 18 ──── SCK ───────┤ MFRC522 5 ── SDA(CS) 22 ── RST
|
||
│ GND 23 ──── MOSI ──────┤ (SPI) 3V3/GND
|
||
│ 19 ──── MISO ─────┤
|
||
│ 15 ──── CS ───────┤ ST7789 2 ── DC 4 ── RST
|
||
│ 4 ─────────────────┤ (SPI)
|
||
│ 32 ── CLK ─────────┤ EC11 33 ── DT 25 ── SW/GND
|
||
│ 27 ── Din ─────────┤ WS2812B (5V + GND)
|
||
│ 14 ── SIG ─────────┤ Buzzer (GND)
|
||
└────────────────────────────┘
|
||
```
|
||
|
||
- 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: <https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/> 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):
|
||
<https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/linux-setup-scratch.html#esp32-flash-download-tool>
|
||
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: <size> 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)
|
||
│ ├── <UID>.json Scryfall metadata cache
|
||
│ └── <UID>.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=GPIO5 / RST=GPIO22, and SCK/MOSI/MISO=18/23/19. Try lowering `RC522_SPI_CLK_HZ` in `rfid_manager.c` (e.g. `2000000`); some clones need a slower clock on a shared bus |
|
||
| 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 |
|
||
| 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. |