various bug fixes

This commit is contained in:
2026-09-11 19:10:31 +02:00
parent e8b6ee2dcc
commit 029ceee69a
23 changed files with 2861 additions and 991 deletions

499
README.md
View File

@@ -1,75 +1,179 @@
# 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:
A feature-packed, handheld **Magic: The Gathering** companion built on the **ESP32-WROOM-32**. Slip an RFID/NFC tag into your card sleeves and tap them to the device for instant card lookups, official art display, life tracking, dice rolling, and complete deck management.
- 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.
All firmware is written in **pure ESP-IDF v5.x (C) / FreeRTOS** — no Arduino overhead, dual-core task segregation, and strictly optimized for devices without external PSRAM (< 520 KB SRAM).
---
## Features
## 🌟 Feature Tour & Modes
| | |
The companion boots directly into an interactive, rotary-controlled interface designed specifically for tabletop MTG gameplay:
```
┌──────────────────────────────┐
│ ★ MTG COMPANION ★ │
├──────────────────────────────┤
│ > [1] Card Scanner < │
│ [2] Life & Counters │
│ [3] Dice & Coin │
│ [4] Commander Tax │
│ [5] Token & Counters │
│ [6] Deck Storage │
│ [7] Device Info │
└──────────────────────────────┘
```
### 1. Interactive Boot Menu (`APP_STATE_MENU`)
On power-up, the device greets you with the Main Menu.
* **Rotate the knob** to highlight any of the 7 modes with an active amber cursor.
* **Click the knob** to enter the highlighted mode.
* **Universal Back (Hold knob $\ge$ 0.75s):** Press and hold the rotary switch from **any** screen to sound a double-chirp, flash purple on the WS2812B LED, and instantly return to the Main Menu.
* **Universal Card Interrupt:** Tapping a card tag from **any** screen or menu immediately transitions into that card's view.
---
### 2. Universal RFID Scanner & Art Crop Display (`APP_STATE_CARD_VIEW`)
* **Instant Tag Identification:** Supports MFRC522, ISO/IEC 14443A, NTAG213/215 stickers, and standard MIFARE 1K cards (4-byte and 7-byte UIDs).
* **True RGB565 Scryfall Art:** Cards display official card illustrations pre-scaled to a crisp 200×110 format, decoded via hardware-assisted Baseline JPEG in ~10 ms with true color accuracy.
* **Card Metadata:** Shows card name, mana cost, type line, oracle text, power/toughness, loyalty, and market pricing.
* **8-Bit Legendary Stinger & Neopixel Feedback:**
* **Legendary / Mythic Stinger:** Scanning any Legendary creature or Mythic Rare plays an upward 8-bit stinger and pulses the LED in gold!
* **Mana Color LEDs:** WS2812B Neopixel illuminates in the card's native mana identity (White, Blue, Black, Red, Green, Gold, Colorless).
* **Unmapped Card Support:** Tapping an unmapped tag switches to the Registration view, displaying the UID and pairing instructions.
* **Smart Cache Validation:** When a card tag is re-paired or changed in the web UI, the firmware automatically detects the change, purges old cached assets, and downloads the fresh card details and art.
---
### 3. Life & Multi-Counter (`APP_STATE_LIFE_COUNTER`)
A dedicated, high-contrast tournament-grade counter supporting the three most important Magic metrics:
* **Life Total (Starts at 40 Commander / 20 Standard):**
* Turn clockwise to gain life (green LED flash); turn counter-clockwise to lose life (red LED flash).
* **Click to Cycle Counter Types:**
1. **Life Total:** Standard life tracking.
2. **Poison Counters (0–10):** In MTG, taking 10 poison counters loses the game.
3. **Commander Combat Damage (0–21):** Dealing 21 combat damage with a single commander eliminates a player.
* **8-Bit Dramatic Loss Tone:** Reaching 0 life or 10 poison triggers an 8-bit descending defeat stinger and a red alert banner.
---
### 4. Spindown D20 / D6 / Fair Coin Flip (`APP_STATE_DICE_ROLLER`)
* **Rotate knob** to select between **D20 (Spindown)**, **D6**, or **Coin Flip**, then **click to roll**.
* Plays an interactive rolling shuffle animation with rapid tick sounds.
* **Natural 20 (Critical Hit):** Plays the 8-bit **Victory Fanfare** and flashes brilliant green.
* **Natural 1 (Critical Fail):** Sounds a buzzer tone and flashes red.
* **D6 Mode:** Quick six-sided die roll for ability triggers and random player selection.
* **Coin Flip Mode:** Random 50/50 flip landing on **HEADS (Green)** or **TAILS (Amber)**.
---
### 5. Built-in Token & Counter Spawner (`APP_STATE_TOKEN_SPAWNER`)
An interactive tabletop tool for spawning and managing tokens on the battlefield:
* **Pre-configured Tokens:**
* **Treasure** (Artifact Token) — `{T}, Sacrifice: Add one mana of any color.`
* **Food** (Artifact Token) — `{2}, {T}, Sacrifice: You gain 3 life.`
* **Clue** (Artifact Token) — `{2}, Sacrifice: Draw a card.`
* **1/1 Soldier** (White Creature Token) — With full P/T box.
* **2/2 Zombie** (Black Creature Token) — With full P/T box.
* **1/1 Goblin** (Red Creature Token) — With full P/T box.
* **3/3 Beast** (Green Creature Token) — With full P/T box.
* **+1/+1 Counter** (Permanent Modifier) — Universal stat counter.
* **Controls:**
* **Pick Mode:** Rotate the knob to cycle through tokens (`< 1 OF 8 >`).
* **Edit Mode:** Click the knob to select; rotate to increment/decrement count (`0` to `99`). Click again to save. Token quantities persist throughout the play session.
---
### 6. Commander Tax & Turn Tracker (`APP_STATE_COMMANDER_TRACKER`)
In Commander (EDH), casting your commander costs an additional {2} generic mana for each time it has previously been cast from the command zone.
* **Commander Casts:** Rotate the knob to adjust casts; the screen automatically calculates and displays the total tax (e.g., `Cast 3 = +4 Mana`).
* **Turn Counter:** Click the button to switch to the table turn tracker.
---
### 7. Deck & Storage Manager (`APP_STATE_STORAGE_MANAGER`)
View flash disk usage and manage cards stored in LittleFS:
* **Storage Metrics:** Displays total storage space (~1.9 MB), used bytes, and counts of cached JSON cards and art crop images.
* **Wipe All Cards (New Deck Mode):** Switching to a new deck? Rotate to highlight "Wipe All Cards", click, and confirm. This purges `/spiffs/cards/*` and resets `mappings.json`, freeing space for a completely new 100-card deck with full artwork.
---
### 8. Web Registration, SoftAP Pairing & Scanned Cards Overview (`web_server`)
Pair tags and manage your entire scanned deck directly from your smartphone browser—no host software required:
1. Connect your phone or laptop to the Wi-Fi network: **`MTG-Companion`** (open network, default IP `192.168.4.1`).
2. Open `http://192.168.4.1` in your browser.
3. **Pair New Tag:**
* Tap an RFID tag to the reader — the UID populates automatically.
* Type the card name (Scryfall autocomplete suggestions appear in real time).
* Click **Pair Card**. The binding is saved to `/spiffs/mappings.json` and the firmware fetches the card details and cover art over Wi-Fi.
4. **Scanned / Paired Cards Overview:**
* View an alphabetized list of all registered cards in your deck with live card counter (`Paired Cards (X)`).
* Displays the card title, tag UID, and cover art thumbnails streamed directly from LittleFS flash storage (`/api/art?uid=...`).
* **Unbind Card:** Click "Unbind" next to any card to immediately delete its mapping and clean up its cached JSON and JPEG files from flash memory.
* **Refresh:** Click "Refresh" to re-sync the list after scans or edits.
---
### 9. Device Info Screen (`APP_STATE_DEVICE_INFO`)
Real-time diagnostic screen showing:
* Wi-Fi STA connection status, SSID, and assigned IP address.
* SoftAP network details (`MTG-Companion`, `192.168.4.1`).
* Free internal heap SRAM (typically ~180 KB free during active rendering).
* System uptime and LittleFS partition status.
---
## 🎮 Quick Controls Cheat Sheet
| Action | Control |
| :--- | :--- |
| 🃏 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 |
| **Navigate / Adjust** | Rotate EC11 rotary knob clockwise / counter-clockwise |
| **Select / Roll / Toggle** | Short press encoder button |
| **Universal Back (To Menu)** | **Long press encoder button ($\ge$ 0.75s)** (double chirp + purple flash) |
| **Instant Card View** | Tap any RFID card sleeve to reader from **any** screen |
---
## Hardware Requirements
## 🛠️ 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
- **ESP32-WROOM-32** development board (4MB flash, dual-core LX6, no external PSRAM needed).
- **ST7789** SPI TFT LCD, 240x320 resolution (2.0" or 2.4" breakout).
- **MFRC522** RFID reader module (13.56 MHz, SPI).
- **EC11** rotary encoder with integrated push switch.
- **WS2812B / Neopixel** (single addressable RGB LED).
- **Passive piezo buzzer** (driven via LEDC PWM).
- **Antenna-friendly NTAG213/NTAG215** micro FPC stickers (for card sleeves) or standard MIFARE cards.
- **2200 µF Electrolytic Capacitor** (placed directly across MFRC522 VCC and GND) — **CRITICAL**: absorbs RF transmit spikes and prevents 3.3V rail voltage dips that trigger ESP32 brownouts or MFRC522 register resets.
---
## Wiring Guide
## 🔌 Peripheral Wiring & Pinout
> **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.
The display and RFID reader use **two separate SPI host controllers** to prevent SPI bus contention and clock mismatch:
- **ST7789 Display:** `SPI2_HOST` (VSPI) at 20 MHz.
- **MFRC522 Reader:** `SPI3_HOST` (HSPI) at 1 MHz.
| 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
| **ST7789 Display** (SPI2) | SCL (Clock) | **18** | SPI2_HOST (VSPI) clock |
| | SDA (MOSI) | **23** | SPI2_HOST MOSI |
| | CS | **21** | Dedicated Chip Select (non-strapping pin) |
| | DC | **19** | Data / Command select (non-strapping pin) |
| | RST | **4** | Display Reset |
| | VCC / GND | 3.3V / GND | Display logic & backlight power |
| **MFRC522 RFID** (SPI3) | SCK | **16** | SPI3_HOST (HSPI) clock |
| | MOSI | **17** | SPI3_HOST MOSI |
| | MISO | **13** | SPI3_HOST MISO |
| | SDA (CS) | **26** | Dedicated Chip Select |
| | RST | **22** | Hard reset line |
| | 3.3V / GND | 3.3V / GND | **Attach 2200 µF capacitor across these pins!** |
| **EC11 Rotary Encoder** | CLK (A) | **32** | Quadrature A (interrupt-driven, internal pull-up) |
| | DT (B) | **33** | Quadrature B (internal pull-up) |
| | SW (Button) | **25** | Push button (active LOW, internal pull-up) |
| **Indicators** | WS2812B DIN | **27** | RMT peripheral Tx (supply 5V to VDD, common GND) |
| **Audio** | Buzzer SIG | **14** | LEDC PWM channel |
### Wiring Diagram
```
ESP32-WROOM-32
┌────────────────────────────┐
@@ -78,282 +182,75 @@ All code is **pure ESP-IDF v5.x (C) / FreeRTOS** — no Arduino.
│ 13 ── MISO ────┼──┘ │
│ 26 ── SDA(CS) ─┘ │
│ 22 ── RST ─┐ │
│ 21 ── CS ──┼───────┤ ST7789 (7-pin)
│ 19 ── DC ──┤ │ SCL = 18, SDA = 23
│ 4 ── RST ─┘ │ VCC = 3V3, GND = GND
│ 21 ── CS ──┼───────┤ ST7789 (7-pin, SPI2_HOST)
│ 19 ── DC ──┤ │ SCL=18, SDA=23, CS=21, DC=19, RST=4
│ 4 ── RST ─┘ │ VCC=3.3V, 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)
│ 23 ── SDA ─────────
│ 32 ── CLK ────────┤ EC11 Rotary Encoder (CLK=32, DT=33, SW=25)
│ 27 ── Din ────────┤ WS2812B LED (GPIO 27, 5V rail)
│ 14 ── SIG ────────┤ Piezo Buzzer (GPIO 14)
└────────────────────────────┘
│ │
[2200 µF Cap]
│ │
┌─────┴───┴──────────────────┐
│ 3V3 GND │
│ MFRC522 RFID Module (SPI3) │
└────────────────────────────┘
```
> **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.
> **Note on Flashing:** GPIO21 (CS) and GPIO19 (DC) are deliberate non-strapping pins. The ST7789 display can remain fully connected during `idf.py flash`.
---
## 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:
## 💻 Software Setup & Build
### 1. Requirements
- **ESP-IDF v5.x** (v5.0 through v5.5 supported):
```powershell
# Windows (Espressif "ESP-IDF 5.5 PowerShell" terminal)
. C:\Espressif\Initialize-Idf.ps1 # or: . $IDF_PATH\export.ps1
. C:\Espressif\frameworks\esp-idf-v5.5\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
### 2. Configure Wi-Fi
Configure home Wi-Fi credentials for automatic Scryfall fetching:
```powershell
idf.py menuconfig
# Navigate to: MTG RFID Companion -> Wi-Fi SSID & Password
```
*(Or edit `sdkconfig.defaults.esp32` directly).*
### 3. Build & Flash
```powershell
idf.py set-target esp32
idf.py build
idf.py -p COM4 flash monitor
```
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`)
## 📂 LittleFS Storage Structure
```
/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
├── mappings.json UID -> Card Name JSON map
└── cards/
├── <UID>.json Scryfall attributes (Mana, Type, Oracle, P/T)
└── <UID>.jpg Cached 200x110 Baseline JPEG art crop
```
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.
The `storage/` repository directory is compiled directly into the LittleFS partition image at build time (`littlefs_create_partition_image`).
---
## Troubleshooting
## ❓ Troubleshooting
| Symptom | Fix |
| Symptom | Cause & Solution |
| :--- | :--- |
| 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.
| **ESP32 reboots when scanning card** | 3.3V rail voltage sag caused by the MFRC522 RF transmitter. **Add a 2200 µF capacitor** directly across the MFRC522 VCC and GND pins. |
| **Display colors inverted (blues look red)** | Check `flags.swap_color_bytes = 1` in JPEG config and `esp_lcd_panel_invert_color()` in `display_manager.c`. Panel clones differ between RGB and BGR. |
| **Card shows "PARSE FAILED"** | Corrupt card JSON in cache. The firmware auto-purges corrupt entries on reboot, or use **Deck Storage -> Wipe** to reset. |
| **Scanned card shows previously paired card** | Re-pairing a card now automatically purges the old cache. Ensure firmware is up to date, or clear via **Deck Storage -> Wipe**. |
| **Art crop doesn't show** | Ensure device has Wi-Fi connectivity to download from Scryfall. Images are cached locally on flash once downloaded. |
| **Flash fails: `Failed to connect to ESP32`** | Make sure no other terminal is monitoring COM4 (`Ctrl + ]` to exit monitor). If using older GPIO15/2 wiring, hold the BOOT button during connect. |

View File

@@ -16,9 +16,15 @@ const char *app_state_name(app_state_t state)
{
switch (state) {
case APP_STATE_BOOT: return "BOOT";
case APP_STATE_MENU: return "MENU";
case APP_STATE_SCANNER: return "SCANNER";
case APP_STATE_CARD_VIEW: return "CARD_VIEW";
case APP_STATE_LIFE_COUNTER: return "LIFE_COUNTER";
case APP_STATE_DICE_ROLLER: return "DICE_ROLLER";
case APP_STATE_COMMANDER_TRACKER: return "COMMANDER_TRACKER";
case APP_STATE_TOKEN_SPAWNER: return "TOKEN_SPAWNER";
case APP_STATE_STORAGE_MANAGER: return "STORAGE_MANAGER";
case APP_STATE_DEVICE_INFO: return "DEVICE_INFO";
case APP_STATE_REGISTRATION: return "REGISTRATION";
}
return "UNKNOWN";

View File

@@ -16,9 +16,15 @@ extern "C" {
typedef enum {
APP_STATE_BOOT = 0,
APP_STATE_MENU,
APP_STATE_SCANNER,
APP_STATE_CARD_VIEW,
APP_STATE_LIFE_COUNTER,
APP_STATE_DICE_ROLLER,
APP_STATE_COMMANDER_TRACKER,
APP_STATE_TOKEN_SPAWNER,
APP_STATE_STORAGE_MANAGER,
APP_STATE_DEVICE_INFO,
APP_STATE_REGISTRATION,
} app_state_t;

View File

@@ -9,12 +9,71 @@
#include "hw_pins.h"
#include "buzzer.h"
#include "freertos/queue.h"
static const char *TAG = "buzzer";
#define BUZZER_SPEED_MODE LEDC_LOW_SPEED_MODE
#define BUZZER_TIMER_NUM LEDC_TIMER_0
#define BUZZER_CHANNEL LEDC_CHANNEL_0
static QueueHandle_t s_sound_queue = NULL;
static const buzzer_note_t MELODY_VICTORY[] = {
{NOTE_C5, 90}, {NOTE_C5, 90}, {NOTE_C5, 90}, {NOTE_C5, 220},
{NOTE_GS4, 220}, {NOTE_AS4, 220}, {NOTE_C5, 140}, {NOTE_REST, 35},
{NOTE_AS4, 90}, {NOTE_C5, 400}
};
static const buzzer_note_t MELODY_LEGENDARY[] = {
{NOTE_G4, 65}, {NOTE_C5, 65}, {NOTE_E5, 65}, {NOTE_G5, 90}, {NOTE_C6, 280}
};
static const buzzer_note_t MELODY_LOSS[] = {
{NOTE_D4, 130}, {NOTE_CS4, 130}, {NOTE_C4, 130}, {NOTE_B3, 260},
{NOTE_REST, 35}, {NOTE_AS3, 400}
};
esp_err_t buzzer_play_notes(const buzzer_note_t *notes, size_t count)
{
if (notes == NULL || count == 0) return ESP_OK;
for (size_t i = 0; i < count; i++) {
if (notes[i].freq_hz > 0) {
buzzer_start(notes[i].freq_hz, 30);
vTaskDelay(pdMS_TO_TICKS(notes[i].duration_ms));
buzzer_stop();
} else {
vTaskDelay(pdMS_TO_TICKS(notes[i].duration_ms));
}
/* 12ms articulation gap between notes for distinct 8-bit chiptune feel */
vTaskDelay(pdMS_TO_TICKS(12));
}
return ESP_OK;
}
static void buzzer_task(void *arg)
{
(void)arg;
buzzer_sound_t sound;
while (1) {
if (xQueueReceive(s_sound_queue, &sound, portMAX_DELAY) == pdTRUE) {
switch (sound) {
case BUZZER_SOUND_VICTORY:
buzzer_play_notes(MELODY_VICTORY, sizeof(MELODY_VICTORY) / sizeof(MELODY_VICTORY[0]));
break;
case BUZZER_SOUND_LEGENDARY:
buzzer_play_notes(MELODY_LEGENDARY, sizeof(MELODY_LEGENDARY) / sizeof(MELODY_LEGENDARY[0]));
break;
case BUZZER_SOUND_LOSS:
buzzer_play_notes(MELODY_LOSS, sizeof(MELODY_LOSS) / sizeof(MELODY_LOSS[0]));
break;
default:
break;
}
}
}
}
esp_err_t buzzer_init(void)
{
ledc_timer_config_t timer_cfg = {
@@ -42,7 +101,12 @@ esp_err_t buzzer_init(void)
return ret;
}
ESP_LOGI(TAG, "buzzer ready on GPIO %d", HW_BUZZER_GPIO);
if (s_sound_queue == NULL) {
s_sound_queue = xQueueCreate(4, sizeof(buzzer_sound_t));
xTaskCreate(buzzer_task, "buzzer_task", 2048, NULL, 3, NULL);
}
ESP_LOGI(TAG, "buzzer ready on GPIO %d (chiptune synth active)", HW_BUZZER_GPIO);
return ESP_OK;
}
@@ -82,3 +146,11 @@ esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms)
vTaskDelay(pdMS_TO_TICKS(duration_ms));
return buzzer_stop();
}
esp_err_t buzzer_play_sound(buzzer_sound_t sound)
{
if (s_sound_queue != NULL) {
xQueueSend(s_sound_queue, &sound, 0);
}
return ESP_OK;
}

View File

@@ -12,8 +12,56 @@
extern "C" {
#endif
/* Musical note frequencies (Hz) */
#define NOTE_REST 0
#define NOTE_G3 196
#define NOTE_GS3 208
#define NOTE_A3 220
#define NOTE_AS3 233
#define NOTE_B3 247
#define NOTE_C4 262
#define NOTE_CS4 277
#define NOTE_D4 294
#define NOTE_DS4 311
#define NOTE_E4 330
#define NOTE_F4 349
#define NOTE_FS4 370
#define NOTE_G4 392
#define NOTE_GS4 415
#define NOTE_A4 440
#define NOTE_AS4 466
#define NOTE_B4 494
#define NOTE_C5 523
#define NOTE_CS5 554
#define NOTE_D5 587
#define NOTE_DS5 622
#define NOTE_E5 659
#define NOTE_F5 698
#define NOTE_FS5 740
#define NOTE_G5 784
#define NOTE_GS5 831
#define NOTE_A5 880
#define NOTE_AS5 932
#define NOTE_B5 988
#define NOTE_C6 1047
#define NOTE_D6 1175
#define NOTE_E6 1319
#define NOTE_G6 1568
typedef struct {
uint16_t freq_hz;
uint16_t duration_ms;
} buzzer_note_t;
typedef enum {
BUZZER_SOUND_NONE = 0,
BUZZER_SOUND_VICTORY,
BUZZER_SOUND_LEGENDARY,
BUZZER_SOUND_LOSS,
} buzzer_sound_t;
/**
* @brief Initialize LEDC PWM channel for the buzzer.
* @brief Initialize LEDC PWM channel for the buzzer and starts worker task.
*/
esp_err_t buzzer_init(void);
@@ -34,6 +82,16 @@ esp_err_t buzzer_stop(void);
*/
esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms);
/**
* @brief Play an 8-bit melody asynchronously (non-blocking).
*/
esp_err_t buzzer_play_sound(buzzer_sound_t sound);
/**
* @brief Play a custom sequence of notes synchronously.
*/
esp_err_t buzzer_play_notes(const buzzer_note_t *notes, size_t count);
#ifdef __cplusplus
}
#endif

View File

@@ -1,38 +1,34 @@
/*
* MTG RFID Companion - ST7789 display manager (Core 1)
* MTG RFID Companion - ST7789 display manager with LVGL (ponytail: native library)
*/
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "esp_log.h"
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_panel_vendor.h"
#include "esp_lcd_panel_st7789.h"
#include "driver/spi_master.h"
#include "tjpgd.h"
#include "hw_pins.h"
#include "spi_bus_manager.h"
#include "display_manager.h"
#include "ui_font.h"
static const char *TAG = "display";
#define LCD_HOST HW_SPI_HOST
#define LCD_PIXEL_CLOCK_HZ (20 * 1000 * 1000)
#define LCD_PIXEL_CLOCK_HZ (16 * 1000 * 1000)
#define LCD_CMD_BITS 8
#define LCD_PARAM_BITS 8
#define LCD_TRANS_QUEUE 10
#define JPEG_POOL_SIZE (10 * 1024)
#define JPEG_SBUF (512) /* must match CONFIG_JD_SZBUF */
static esp_lcd_panel_handle_t s_panel = NULL;
static lv_disp_t *s_disp = NULL;
static esp_lcd_panel_handle_t s_panel;
static uint16_t *s_staging = NULL; /* 240 px line staging buffer for JPEG */
static esp_err_t panel_io_init(esp_lcd_panel_io_handle_t *io)
esp_err_t display_manager_init(void)
{
if (s_disp != NULL) {
return ESP_OK;
}
/* 1. Initialize ST7789 panel via esp_lcd */
esp_lcd_panel_io_handle_t io = NULL;
esp_lcd_panel_io_spi_config_t io_config = {
.dc_gpio_num = HW_LCD_DC_GPIO,
.cs_gpio_num = HW_LCD_CS_GPIO,
@@ -40,19 +36,9 @@ static esp_err_t panel_io_init(esp_lcd_panel_io_handle_t *io)
.lcd_cmd_bits = LCD_CMD_BITS,
.lcd_param_bits = LCD_PARAM_BITS,
.spi_mode = 0,
.trans_queue_depth = LCD_TRANS_QUEUE,
.trans_queue_depth = 10,
};
return esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST, &io_config, io);
}
esp_err_t display_manager_init(void)
{
if (s_panel != NULL) {
return ESP_OK;
}
esp_lcd_panel_io_handle_t io = NULL;
esp_err_t ret = panel_io_init(&io);
esp_err_t ret = esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST, &io_config, &io);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "panel io init failed: %s", esp_err_to_name(ret));
return ret;
@@ -72,214 +58,48 @@ esp_err_t display_manager_init(void)
ESP_ERROR_CHECK(esp_lcd_panel_reset(s_panel));
ESP_ERROR_CHECK(esp_lcd_panel_init(s_panel));
ESP_ERROR_CHECK(esp_lcd_panel_invert_color(s_panel, true));
/* No panel mirror: the font bit-order fix in display_put_text already
* yields correct (non-mirrored) glyphs. Mirroring here would flip the
* whole frame and re-break the text. */
ESP_ERROR_CHECK(esp_lcd_panel_mirror(s_panel, false, false));
ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(s_panel, true));
s_staging = malloc(DISP_W * sizeof(uint16_t));
if (s_staging == NULL) {
ESP_LOGE(TAG, "staging buffer alloc failed");
return ESP_ERR_NO_MEM;
/* 2. Initialize LVGL port on Core 1 */
lvgl_port_cfg_t port_cfg = ESP_LVGL_PORT_INIT_CONFIG();
port_cfg.task_affinity = 1;
ret = lvgl_port_init(&port_cfg);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "lvgl port init failed: %s", esp_err_to_name(ret));
return ret;
}
ESP_LOGI(TAG, "ST7789 %dx%d ready (CS=%d DC=%d RST=%d)",
/* 20 lines buffer (240 * 20 * 2 = 9.6 KB) - lean SRAM footprint */
lvgl_port_display_cfg_t disp_cfg = {
.io_handle = io,
.panel_handle = s_panel,
.buffer_size = DISP_W * 20,
.double_buffer = false,
.hres = DISP_W,
.vres = DISP_H,
.monochrome = false,
.rotation = {
.swap_xy = false,
.mirror_x = false,
.mirror_y = false,
},
.flags = {
.buff_dma = true,
},
};
s_disp = lvgl_port_add_disp(&disp_cfg);
if (s_disp == NULL) {
ESP_LOGE(TAG, "lvgl_port_add_disp failed");
return ESP_FAIL;
}
ESP_LOGI(TAG, "ST7789 %dx%d with LVGL ready (CS=%d DC=%d RST=%d)",
DISP_W, DISP_H, HW_LCD_CS_GPIO, HW_LCD_DC_GPIO, HW_LCD_RST_GPIO);
return ESP_OK;
}
void display_draw_bitmap(int x0, int y0, int x1, int y1, const uint16_t *pixels)
lv_disp_t *display_get_disp(void)
{
if (s_panel == NULL || pixels == NULL) {
return;
}
esp_lcd_panel_draw_bitmap(s_panel, x0, y0, x1, y1, pixels);
}
void display_fill_rect(int x0, int y0, int x1, int y1, uint16_t color)
{
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 > DISP_W) x1 = DISP_W;
if (y1 > DISP_H) y1 = DISP_H;
if (x1 <= x0 || y1 <= y0) {
return;
}
int width = x1 - x0;
int height = y1 - y0;
static uint16_t line[DISP_W];
for (int i = 0; i < width; i++) {
line[i] = color;
}
for (int r = 0; r < height; r++) {
display_draw_bitmap(x0, y0 + r, x1, y0 + r + 1, line);
}
}
void display_fill(uint16_t color)
{
display_fill_rect(0, 0, DISP_W, DISP_H, color);
}
int display_put_text(int x, int y, const char *str, uint16_t fg, uint16_t bg)
{
int x_start = x;
while (str != NULL && *str != '\0') {
char c = *str++;
if (c >= FONT5X7_FIRST_CHAR && c <= FONT5X7_LAST_CHAR) {
const uint8_t *glyph = font5x7[c - FONT5X7_FIRST_CHAR];
/* Row-major [row][col]. The font table stores each row with the
* MSB as the LEFT column, so read bit (W-1-gx) for column gx. */
uint16_t cell[FONT5X7_H][FONT5X7_W];
for (int gy = 0; gy < FONT5X7_H; gy++) {
for (int gx = 0; gx < FONT5X7_W; gx++) {
cell[gy][gx] = (glyph[gy] >> (FONT5X7_W - 1 - gx)) & 1 ? fg : bg;
}
}
if (x + FONT5X7_W <= DISP_W && y + FONT5X7_H <= DISP_H) {
display_draw_bitmap(x, y, x + FONT5X7_W, y + FONT5X7_H, &cell[0][0]);
}
x += FONT5X7_W + 1;
} else if (c == ' ') {
x += FONT5X7_W + 1;
}
}
return x - x_start;
}
void display_put_text_center(int y, const char *str, uint16_t fg, uint16_t bg)
{
int len = (int)strlen(str);
int width = len * (FONT5X7_W + 1) - 1;
int x = (DISP_W - width) / 2;
if (x < 0) x = 0;
display_put_text(x, y, str, fg, bg);
}
void display_draw_big_text(int x, int y, const char *str, int scale, uint16_t fg, uint16_t bg)
{
int cell_w = FONT5X7_W * scale;
int cell_h = FONT5X7_H * scale;
int num = (int)strlen(str);
if (num <= 0) {
return;
}
if (x + num * (cell_w + scale) - scale > DISP_W || y + cell_h > DISP_H) {
return;
}
/* Staging buffer for one glyph, scaled */
uint16_t *glyph_buf = malloc((size_t)cell_w * cell_h * sizeof(uint16_t));
if (glyph_buf == NULL) {
return;
}
for (int i = 0; i < num; i++) {
char c = str[i];
if (c < FONT5X7_FIRST_CHAR || c > FONT5X7_LAST_CHAR) {
x += cell_w + scale;
continue;
}
const uint8_t *g = font5x7[c - FONT5X7_FIRST_CHAR];
for (int gy = 0; gy < FONT5X7_H; gy++) {
for (int gx = 0; gx < FONT5X7_W; gx++) {
uint16_t color = ((g[gy] >> gx) & 1) ? fg : bg;
for (int sy = 0; sy < scale; sy++) {
for (int sx = 0; sx < scale; sx++) {
glyph_buf[(gy * scale + sy) * cell_w + (gx * scale + sx)] = color;
}
}
}
}
display_draw_bitmap(x, y, x + cell_w, y + cell_h, glyph_buf);
x += cell_w + scale;
}
free(glyph_buf);
}
/* ------------------------------------------------------------------ */
/* tjpgd streaming decode */
/* ------------------------------------------------------------------ */
typedef struct {
FILE *f;
} jpeg_io_t;
static size_t jpeg_input_cb(JDEC *jd, uint8_t *buf, size_t ndata)
{
jpeg_io_t *io = (jpeg_io_t *)jd->device;
if (io == NULL || io->f == NULL) {
return 0;
}
if (buf == NULL) {
/* tjpgd requests skipping bytes during header scan */
if (fseek(io->f, (long)ndata, SEEK_CUR) == 0) {
return ndata;
}
return 0;
}
return fread(buf, 1, ndata, io->f);
}
static int jpeg_output_cb(JDEC *jd, void *bitmap, JRECT *rect)
{
(void)jd;
uint16_t *pix = (uint16_t *)bitmap;
int w = rect->right - rect->left + 1;
int h = rect->bottom - rect->top + 1;
for (int row = 0; row < h; row++) {
/* Convert little-endian RGB565 words (tjpgd) to big-endian wire order */
for (int col = 0; col < w; col++) {
uint16_t v = pix[row * w + col];
s_staging[col] = (uint16_t)((v << 8) | (v >> 8));
}
display_draw_bitmap(rect->left, rect->top + row, rect->left + w, rect->top + row + 1, s_staging);
}
return 1; /* continue decoding */
}
esp_err_t display_show_jpeg_file(const char *path)
{
esp_err_t ret = ESP_FAIL;
FILE *f = fopen(path, "rb");
if (f == NULL) {
ESP_LOGE(TAG, "cannot open %s", path);
return ESP_ERR_NOT_FOUND;
}
jpeg_io_t io = { .f = f };
JDEC jdec;
void *pool = malloc(JPEG_POOL_SIZE);
if (pool == NULL) {
ESP_LOGE(TAG, "jpeg pool alloc failed");
fclose(f);
return ESP_ERR_NO_MEM;
}
JRESULT jr = jd_prepare(&jdec, jpeg_input_cb, pool, JPEG_POOL_SIZE, &io);
if (jr != JDR_OK) {
ESP_LOGE(TAG, "jd_prepare failed (%d)", jr);
goto done;
}
ESP_LOGI(TAG, "decoding %s: %ux%u", path, (unsigned)jdec.width, (unsigned)jdec.height);
jr = jd_decomp(&jdec, jpeg_output_cb, 0);
if (jr != JDR_OK) {
ESP_LOGE(TAG, "jd_decomp failed (%d)", jr);
goto done;
}
ESP_LOGI(TAG, "JPEG displayed: %s", path);
ret = ESP_OK;
done:
free(pool);
fclose(f);
return ret;
return s_disp;
}

View File

@@ -1,15 +1,12 @@
/*
* MTG RFID Companion - ST7789 display manager (Core 1)
*
* Phase 4: Initializes the ST7789 240x320 panel via esp_lcd as a device on
* the shared SPI2_HOST bus, provides RGB565 rendering primitives, glyph text,
* and streaming tjpgd JPEG decode into the panel.
* MTG RFID Companion - ST7789 display manager with LVGL (ponytail: native library)
*/
#pragma once
#include "esp_err.h"
#include <stdint.h>
#include "esp_lvgl_port.h"
#include "lvgl.h"
#ifdef __cplusplus
extern "C" {
@@ -18,59 +15,8 @@ extern "C" {
#define DISP_W (240)
#define DISP_H (320)
#define RGB565(r, g, b) ((uint16_t)((((r)&0xF8) << 8) | (((g)&0xFC) << 3) | (((b)&0xF8) >> 3)))
#define COLOR_BLACK RGB565(0, 0, 0)
#define COLOR_WHITE RGB565(255, 255, 255)
#define COLOR_RED RGB565(255, 0, 0)
#define COLOR_GREEN RGB565(0, 255, 0)
#define COLOR_BLUE RGB565(0, 0, 255)
#define COLOR_YELLOW RGB565(255, 255, 0)
#define COLOR_CYAN RGB565(0, 255, 255)
#define COLOR_MAGENTA RGB565(255, 0, 255)
#define COLOR_GRAY RGB565(128, 128, 128)
/**
* @brief Initialize the ST7789 panel. Requires the shared SPI bus to exist.
*/
esp_err_t display_manager_init(void);
/**
* @brief Fill the whole screen with a color.
*/
void display_fill(uint16_t color);
/**
* @brief Fill a rectangle. x1/y1 are exclusive.
*/
void display_fill_rect(int x0, int y0, int x1, int y1, uint16_t color);
/**
* @brief Draw pixels. x1/y1 exclusive, buffer length = (x1-x0)*(y1-y0) RGB565 words.
*/
void display_draw_bitmap(int x0, int y0, int x1, int y1, const uint16_t *pixels);
/**
* @brief Draw text with a 5x7 font. Returns pixel width consumed.
*/
int display_put_text(int x, int y, const char *str, uint16_t fg, uint16_t bg);
/**
* @brief Draw text centered horizontally on the given row.
*/
void display_put_text_center(int y, const char *str, uint16_t fg, uint16_t bg);
/**
* @brief Draw large digits (life counter style) using a scaled 5x7 pattern.
* @param x,y Top-left corner; scale = pixel size of one 5x7 cell.
*/
void display_draw_big_text(int x, int y, const char *str, int scale, uint16_t fg, uint16_t bg);
/**
* @brief Stream-decode a JPEG from LittleFS and draw it at the top-left.
* Uses tjpgd with a line-by-line output into the panel DMA path.
*/
esp_err_t display_show_jpeg_file(const char *path);
lv_disp_t *display_get_disp(void);
#ifdef __cplusplus
}

View File

@@ -1,5 +1,11 @@
/*
* MTG RFID Companion - EC11 rotary encoder driver
*
* Implements a true Gray Code quadrature state machine.
* Both CLK and DT pins generate interrupts on ANYEDGE.
* Contact bounce is inherently rejected by valid transition filtering.
* Each mechanical detent ("tick") advances through a fixed number of valid
* quadrature transitions (default 4 for standard full-step EC11, or 2 for half-step).
*/
#include "freertos/FreeRTOS.h"
@@ -12,38 +18,87 @@
static const char *TAG = "encoder";
static volatile int32_t s_delta = 0;
static volatile int8_t s_accum = 0;
static volatile uint8_t s_prev_state = 0x03;
static volatile int8_t s_steps_per_detent = 4; /* 4 = full-step, 2 = half-step */
static volatile bool s_button_pressed = false;
static volatile uint32_t s_last_sw_tick = 0;
#define SW_DEBOUNCE_MS (35)
/* Quadrature decode: on each CLK edge, sample DT.
* DT == CLK level => one direction, != => other.
* Physical wiring is corrected here if a knob feels inverted. */
#define DIR_CLK_HIGH (1)
/*
* If the direction comes out reversed, flip this to -1.
* Quadrature Gray-Code Transition Table
* Index: (old_state << 2) | new_state (4-bit, 0..15)
* State bits: bit 1 = CLK (GPIO 32), bit 0 = DT (GPIO 33)
*
* Clockwise sequence: 11 (3) -> 01 (1) -> 00 (0) -> 10 (2) -> 11 (3)
* 11->01 (13): +1
* 01->00 (4): +1
* 00->10 (2): +1
* 10->11 (11): +1
*
* Counter-Clockwise sequence: 11 (3) -> 10 (2) -> 00 (0) -> 01 (1) -> 11 (3)
* 11->10 (14): -1
* 10->00 (8): -1
* 00->01 (1): -1
* 01->11 (7): -1
*
* All bounces (e.g. 11 <-> 01) cancel out: +1 + (-1) = 0.
* Invalid diagonal skips (00 <-> 11, 01 <-> 10): 0.
*/
#define ENC_DIRECTION (1)
static const int8_t s_quad_table[16] = {
[0] = 0,
[1] = -1, // 00 -> 01 (CCW)
[2] = 1, // 00 -> 10 (CW)
[3] = 0, // 00 -> 11 (invalid)
[4] = 1, // 01 -> 00 (CW)
[5] = 0,
[6] = 0, // 01 -> 10 (invalid)
[7] = -1, // 01 -> 11 (CCW)
[8] = -1, // 10 -> 00 (CCW)
[9] = 0, // 10 -> 01 (invalid)
[10] = 0,
[11] = 1, // 10 -> 11 (CW)
[12] = 0, // 11 -> 00 (invalid)
[13] = 1, // 11 -> 01 (CW)
[14] = -1, // 11 -> 10 (CCW)
[15] = 0,
};
static void IRAM_ATTR encoder_isr(void *arg)
{
uint32_t gpio = (uint32_t)(uintptr_t)arg;
if (gpio == HW_ENC_CLK_GPIO) {
int dt = gpio_get_level(HW_ENC_DT_GPIO);
int clk = gpio_get_level(HW_ENC_CLK_GPIO);
/* Increment on one edge per detent; using level comparison on both edges
* doubles the count, so we only act when CLK transitions are detected as
* ticks. To keep this robust we count on every edge and divide the
* sense: signed direction * 1. */
s_delta += (dt == clk) ? (ENC_DIRECTION) : (-ENC_DIRECTION);
} else if (gpio == HW_ENC_SW_GPIO) {
if (gpio == HW_ENC_SW_GPIO) {
unsigned int now = (unsigned int)xTaskGetTickCountFromISR();
if ((now - s_last_sw_tick) > pdMS_TO_TICKS(SW_DEBOUNCE_MS)) {
s_last_sw_tick = now;
s_button_pressed = true;
}
return;
}
/* Read current levels of both encoder channels */
uint8_t clk = (uint8_t)gpio_get_level(HW_ENC_CLK_GPIO);
uint8_t dt = (uint8_t)gpio_get_level(HW_ENC_DT_GPIO);
uint8_t curr = (clk << 1) | dt;
if (curr != s_prev_state) {
uint8_t idx = ((s_prev_state & 0x03) << 2) | (curr & 0x03);
int8_t step = s_quad_table[idx];
s_prev_state = curr;
if (step != 0) {
s_accum += step;
if (s_accum >= s_steps_per_detent) {
s_delta += 1;
s_accum = 0;
} else if (s_accum <= -s_steps_per_detent) {
s_delta -= 1;
s_accum = 0;
}
}
}
}
@@ -61,20 +116,46 @@ static esp_err_t config_pin(gpio_num_t pin, gpio_int_type_t intr_type)
esp_err_t encoder_init(void)
{
gpio_install_isr_service(ESP_INTR_FLAG_LEVEL1);
esp_err_t err = gpio_install_isr_service(ESP_INTR_FLAG_LEVEL1);
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_ERROR_CHECK(err);
}
ESP_ERROR_CHECK(config_pin(HW_ENC_CLK_GPIO, GPIO_INTR_POSEDGE));
ESP_ERROR_CHECK(config_pin(HW_ENC_DT_GPIO, GPIO_INTR_DISABLE));
ESP_ERROR_CHECK(config_pin(HW_ENC_CLK_GPIO, GPIO_INTR_ANYEDGE));
ESP_ERROR_CHECK(config_pin(HW_ENC_DT_GPIO, GPIO_INTR_ANYEDGE));
ESP_ERROR_CHECK(config_pin(HW_ENC_SW_GPIO, GPIO_INTR_NEGEDGE));
/* Initialize initial state */
uint8_t clk = (uint8_t)gpio_get_level(HW_ENC_CLK_GPIO);
uint8_t dt = (uint8_t)gpio_get_level(HW_ENC_DT_GPIO);
s_prev_state = (clk << 1) | dt;
s_accum = 0;
s_delta = 0;
ESP_ERROR_CHECK(gpio_isr_handler_add(HW_ENC_CLK_GPIO, encoder_isr, (void *)(uintptr_t)HW_ENC_CLK_GPIO));
ESP_ERROR_CHECK(gpio_isr_handler_add(HW_ENC_DT_GPIO, encoder_isr, (void *)(uintptr_t)HW_ENC_DT_GPIO));
ESP_ERROR_CHECK(gpio_isr_handler_add(HW_ENC_SW_GPIO, encoder_isr, (void *)(uintptr_t)HW_ENC_SW_GPIO));
ESP_LOGI(TAG, "EC11 encoder ready (CLK=%d DT=%d SW=%d)",
HW_ENC_CLK_GPIO, HW_ENC_DT_GPIO, HW_ENC_SW_GPIO);
ESP_LOGI(TAG, "EC11 quadrature encoder ready (CLK=%d DT=%d SW=%d steps/tick=%d)",
HW_ENC_CLK_GPIO, HW_ENC_DT_GPIO, HW_ENC_SW_GPIO, s_steps_per_detent);
return ESP_OK;
}
int8_t encoder_get_steps_per_detent(void)
{
return s_steps_per_detent;
}
void encoder_set_steps_per_detent(int8_t steps)
{
if (steps != 2 && steps != 4) {
steps = 4;
}
s_steps_per_detent = steps;
s_accum = 0;
ESP_LOGI(TAG, "Encoder resolution set to %d steps/detent", steps);
}
bool encoder_has_delta(void)
{
return s_delta != 0;

View File

@@ -46,6 +46,16 @@ bool encoder_button_pressed(void);
*/
void encoder_clear_button(void);
/**
* @brief Get current quadrature steps per mechanical detent tick (2 or 4).
*/
int8_t encoder_get_steps_per_detent(void);
/**
* @brief Set quadrature steps per mechanical detent tick (2 or 4).
*/
void encoder_set_steps_per_detent(int8_t steps);
#ifdef __cplusplus
}
#endif

View File

@@ -2,6 +2,7 @@
## NOTE: espressif/esp_lcd is a built-in IDF component - do NOT declare it here.
dependencies:
idf: ">=5.0"
abobija/rc522: "*"
joltwallet/littlefs: "*"
esp_jpeg: ">=1.0.2"
lvgl/lvgl: "^8.3.11"
espressif/esp_lvgl_port: "^2.0.0"

View File

@@ -20,9 +20,9 @@ void app_main(void)
ESP_ERROR_CHECK(spi_bus_manager_init());
ESP_ERROR_CHECK(storage_manager_init());
/* RFID comes BEFORE the display: the reader lives on its own SPI3 bus now
/* RFID comes BEFORE the display: the reader lives on its own SPI3 bus
* (display owns SPI2), but initializing it before the panel's init traffic
* still gives the self-test the quietest possible bus timing. */
* gives the startup the quietest possible bus timing. */
esp_err_t ret = rfid_manager_init();
if (ret != ESP_OK || rfid_manager_start() != ESP_OK) {
ESP_LOGW(TAG, "RFID unavailable (%s) - continuing without card scanning", esp_err_to_name(ret));

View File

@@ -23,11 +23,11 @@ static const char *TAG = "rfid";
#define RC522_TASK_NAME "rc522_poll"
#define RC522_POLL_INTERVAL_MS (100)
#define RC522_TASK_STACK_SIZE (6 * 1024)
#define RC522_TASK_PRIORITY (4)
#define RC522_TASK_PRIORITY (1)
/* Conservative clock for MFRC522 clones. If the FIFO self-test still
* complains, lower further or check power/wiring. */
#define RC522_SPI_CLK_HZ (1000000)
#define RC522_SPI_CLK_HZ (5000000)
#define RC522_START_ATTEMPTS (3)
/* Some clone MFRC522 modules (esp. those wired with RST left floating and
@@ -76,40 +76,12 @@ static void on_picc_state_changed(void *arg, esp_event_base_t base, int32_t even
* hardware-CS device. Any plausible value (0x88/0x91/0x92) means the chip is
* alive and MISO works; 0x00/0xFF means MISO/power/wiring is dead. This is a
* pure diagnostic and does not depend on the rc522 driver's reset path. */
static void rfid_probe_version(void)
{
spi_device_handle_t probe = NULL;
spi_device_interface_config_t devcfg = {
.clock_speed_hz = RC522_SPI_CLK_HZ,
.mode = 0,
.spics_io_num = HW_RC522_CS_GPIO,
.queue_size = 1,
.command_bits = 1, /* read/write select bit */
.address_bits = 6, /* register address */
.dummy_bits = 1,
.flags = SPI_DEVICE_HALFDUPLEX, /* RX-only reads like the rc522 driver */
};
if (spi_bus_add_device(HW_RFID_SPI_HOST, &devcfg, &probe) != ESP_OK || probe == NULL) {
ESP_LOGW(TAG, "probe: could not add temp SPI device on SPI3");
return;
}
uint8_t version = 0;
spi_transaction_t t = {
.cmd = 1, /* read */
.addr = 0x37, /* VersionReg */
.rxlength = 8,
.rx_buffer = &version,
};
esp_err_t ret = spi_device_polling_transmit(probe, &t);
ESP_LOGI(TAG, "MFRC522 VersionReg=0x%02X (%s). 0x88/0x91/0x92=alive; 0x00/0xFF=MISO/power/wiring",
version, esp_err_to_name(ret));
spi_bus_remove_device(probe);
}
esp_err_t rfid_manager_init(void)
{
esp_log_level_set("rc522", ESP_LOG_INFO);
/* Dedicated SPI3_HOST (HSPI) bus for the reader - independent controller
* so the display (SPI2_HOST) and the RFID never share bus timing. We
* initialize SPI3 here (with a real MISO) and hand the already-initialized
@@ -124,14 +96,12 @@ esp_err_t rfid_manager_init(void)
ESP_LOGI(TAG, "initializing MFRC522 on SPI3_HOST (SCK=%d MOSI=%d MISO=%d CS=%d)",
HW_RFID_SCK_GPIO, HW_RFID_MOSI_GPIO, HW_RFID_MISO_GPIO, HW_RC522_CS_GPIO);
esp_err_t ret = spi_bus_initialize(HW_RFID_SPI_HOST, &rfid_bus_cfg, SPI_DMA_CH_AUTO);
esp_err_t ret = spi_bus_initialize(HW_RFID_SPI_HOST, &rfid_bus_cfg, SPI_DMA_DISABLED);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "spi_bus_initialize(SPI3) failed: %s", esp_err_to_name(ret));
return ret;
}
rfid_probe_version();
rc522_spi_config_t driver_config = {
.host_id = HW_RFID_SPI_HOST,
.bus_config = NULL, /* SPI3 already initialized above */
@@ -139,11 +109,8 @@ esp_err_t rfid_manager_init(void)
.spics_io_num = HW_RC522_CS_GPIO,
.clock_speed_hz = RC522_SPI_CLK_HZ,
},
.dma_chan = SPI_DMA_CH_AUTO,
/* RST left unconnected (-1): the module comes up on its own power-on
* reset and uses the library soft-reset. Wire RST=GPIO22 and set this
* to HW_RC522_RST_GPIO if your module needs the hard-reset line. */
.rst_io_num = -1,
.dma_chan = SPI_DMA_DISABLED,
.rst_io_num = HW_RC522_RST_GPIO,
};
ret = rc522_spi_create(&driver_config, &s_driver);

View File

@@ -29,7 +29,7 @@ static const char *TAG = "scryfall";
#define MAX_ATTEMPTS (2)
#define JSON_BUF_MAX (128 * 1024)
#define NAME_MAX (128)
#define CARD_NAME_MAX (128)
#define UID_MAX (24)
#define FIELD_STR_MAX (256)
#define ORACLE_MAX (768)
@@ -38,7 +38,7 @@ static const char *TAG = "scryfall";
#define EVT_CARD_CACHED (1U << 0)
typedef struct {
char name[NAME_MAX];
char name[CARD_NAME_MAX];
char uid[UID_MAX];
} fetch_request_t;
@@ -163,13 +163,20 @@ static esp_err_t http_request(const char *url, http_sink_t *sink)
}
esp_http_client_set_method(client, HTTP_METHOD_GET);
esp_http_client_set_header(client, "User-Agent", "MTGCompanion/1.0");
esp_http_client_set_header(client, "Accept", "*/*");
esp_err_t ret = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
ESP_LOGD(TAG, "GET %s -> %s", url, esp_err_to_name(ret));
if (ret == ESP_OK) {
ESP_LOGD(TAG, "GET %s -> %s (status %d)", url, esp_err_to_name(ret), status);
if (ret == ESP_OK && status >= 200 && status < 300) {
return ESP_OK;
}
if (ret == ESP_OK && (status < 200 || status >= 300)) {
ESP_LOGW(TAG, "HTTP GET returned status %d for %s", status, url);
}
if (sink->err != ESP_OK) {
return sink->err;
}
@@ -227,14 +234,48 @@ esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid)
{
char json_path[64];
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid);
char jpg_path[64];
snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid);
if (storage_file_exists(json_path)) {
ESP_LOGI(TAG, "cache hit %s", json_path);
char *chk = NULL;
size_t chk_len = 0;
bool valid = false;
if (storage_read_text(json_path, &chk, &chk_len) == ESP_OK && chk != NULL) {
cJSON *cj = cJSON_Parse(chk);
if (cj) {
cJSON *n = cJSON_GetObjectItem(cj, "name");
cJSON *t = cJSON_GetObjectItem(cj, "type");
cJSON *o = cJSON_GetObjectItem(cj, "oracle");
/* Verify name matches the requested card name */
if (n && n->valuestring && strcasecmp(n->valuestring, card_name) == 0) {
if ((t && t->valuestring && strlen(t->valuestring) > 0) ||
(o && o->valuestring && strlen(o->valuestring) > 0)) {
valid = true;
}
}
cJSON_Delete(cj);
}
free(chk);
}
if (valid) {
if (storage_file_exists(jpg_path)) {
ESP_LOGI(TAG, "cache hit %s and %s", json_path, jpg_path);
s_result_bits |= EVT_CARD_CACHED;
snprintf(s_last_uid, sizeof(s_last_uid), "%s", uid);
return ESP_OK;
}
ESP_LOGI(TAG, "json valid but %s missing, fetching art...", jpg_path);
} else {
ESP_LOGW(TAG, "cache entry %s is invalid or card mismatch (expected '%s'), re-fetching...", json_path, card_name);
remove(json_path);
remove(jpg_path);
}
}
/* 1. GET card JSON */
char url[512];
char enc[NAME_MAX * 3];
char enc[CARD_NAME_MAX * 3];
url_encode(card_name, enc, sizeof(enc));
snprintf(url, sizeof(url), SCRYFALL_API SCRYFALL_CARD_ARTS_URL "%s", enc);
@@ -253,6 +294,14 @@ esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid)
return ESP_FAIL;
}
cJSON *obj = cJSON_GetObjectItem(root, "object");
if (obj && cJSON_IsString(obj) && strcmp(obj->valuestring, "error") == 0) {
ESP_LOGE(TAG, "Scryfall API error: %s",
get_string(cJSON_GetObjectItem(root, "details"), "unknown error"));
cJSON_Delete(root);
return ESP_FAIL;
}
/* 2. Extract fields */
const char *name = get_string(cJSON_GetObjectItem(root, "name"), card_name);
const char *mana = NULL;
@@ -265,40 +314,79 @@ esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid)
const char *type_line = get_string(cJSON_GetObjectItem(root, "type_line"), "");
const char *power = get_string(cJSON_GetObjectItem(root, "power"), "");
const char *toughness = get_string(cJSON_GetObjectItem(root, "toughness"), "");
const char *loyalty = get_string(cJSON_GetObjectItem(root, "loyalty"), "");
const char *oracle = get_string(cJSON_GetObjectItem(root, "oracle_text"), "");
const char *image_url = NULL;
cJSON *uris = cJSON_GetObjectItem(root, "image_uris");
cJSON *faces = cJSON_GetObjectItem(root, "card_faces");
if (faces && cJSON_IsArray(faces) && cJSON_GetArraySize(faces) > 0) {
cJSON *face0 = cJSON_GetArrayItem(faces, 0);
if (face0) {
if (uris == NULL) {
uris = cJSON_GetObjectItem(face0, "image_uris");
}
if (strlen(oracle) == 0) {
oracle = get_string(cJSON_GetObjectItem(face0, "oracle_text"), "");
}
if (strlen(mana) == 0) {
mana = get_string(cJSON_GetObjectItem(face0, "mana_cost"), "");
}
if (strlen(type_line) == 0) {
type_line = get_string(cJSON_GetObjectItem(face0, "type_line"), "");
}
}
}
if (uris) {
image_url = get_string(cJSON_GetObjectItem(uris, "art_crop"), "");
if (strlen(image_url) == 0) {
image_url = get_string(cJSON_GetObjectItem(uris, "normal"), "");
}
}
const char *price_usd = NULL;
cJSON *prices = cJSON_GetObjectItem(root, "prices");
if (prices) {
price_usd = get_string(cJSON_GetObjectItem(prices, "usd"), "");
}
const char *rarity = get_string(cJSON_GetObjectItem(root, "rarity"), "");
/* 3. Persist metadata */
char meta[1536];
snprintf(meta, sizeof(meta),
"{\n"
" \"name\": \"%s\",\n"
" \"mana_cost\": \"%s\",\n"
" \"type\": \"%s\",\n"
" \"power\": \"%s\",\n"
" \"toughness\": \"%s\",\n"
" \"oracle\": \"%s\",\n"
" \"price_usd\": \"%s\"\n"
"}\n",
name, mana, type_line, power, toughness, oracle,
price_usd ? price_usd : "");
/* 3. Persist metadata with proper JSON escaping */
cJSON *card_obj = cJSON_CreateObject();
if (card_obj) {
cJSON_AddStringToObject(card_obj, "name", name);
cJSON_AddStringToObject(card_obj, "mana_cost", mana);
cJSON_AddStringToObject(card_obj, "type", type_line);
cJSON_AddStringToObject(card_obj, "rarity", rarity);
cJSON_AddStringToObject(card_obj, "power", power);
cJSON_AddStringToObject(card_obj, "toughness", toughness);
cJSON_AddStringToObject(card_obj, "loyalty", loyalty);
cJSON_AddStringToObject(card_obj, "oracle", oracle);
cJSON_AddStringToObject(card_obj, "price_usd", price_usd ? price_usd : "");
ret = storage_write_text(json_path, meta);
char *meta_str = cJSON_PrintUnformatted(card_obj);
cJSON_Delete(card_obj);
if (meta_str != NULL) {
ret = storage_write_text(json_path, meta_str);
free(meta_str);
} else {
ret = ESP_ERR_NO_MEM;
}
} else {
ret = ESP_ERR_NO_MEM;
}
/* 4. Download cover art (best effort) */
if (image_url && strlen(image_url) > 0) {
char jpg_path[64];
snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid);
esp_err_t dl = http_download_file(image_url, jpg_path);
char proxy_url[600];
/* wsrv.nl converts Scryfall's progressive JPEG to a lightweight 200x110 baseline JPEG */
snprintf(proxy_url, sizeof(proxy_url), "https://wsrv.nl/?url=%s&w=200&h=110&fit=cover&output=jpg", image_url);
esp_err_t dl = http_download_file(proxy_url, jpg_path);
if (dl != ESP_OK) {
ESP_LOGW(TAG, "proxied image download failed, trying direct: %s", image_url);
dl = http_download_file(image_url, jpg_path);
}
if (dl != ESP_OK) {
ESP_LOGW(TAG, "image download failed: %s", image_url);
} else {
@@ -341,14 +429,9 @@ static void prefetch_mappings(void)
cJSON *item = NULL;
cJSON_ArrayForEach(item, root) {
uid = item->string;
if (uid == NULL) continue;
char path[64];
snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s.json", uid);
if (!storage_file_exists(path)) {
ESP_LOGI(TAG, "prefetching '%s' for %s", item->valuestring, uid);
if (uid == NULL || item->valuestring == NULL) continue;
scryfall_ensure_cached(item->valuestring, uid);
}
}
cJSON_Delete(root);
}
@@ -397,7 +480,7 @@ esp_err_t scryfall_fetch_request(const char *card_name, const char *uid)
if (s_fetch_queue == NULL) {
return ESP_ERR_INVALID_STATE;
}
if (strlen(card_name) >= NAME_MAX || strlen(uid) >= UID_MAX || strlen(card_name) == 0) {
if (strlen(card_name) >= CARD_NAME_MAX || strlen(uid) >= UID_MAX || strlen(card_name) == 0) {
return ESP_ERR_INVALID_ARG;
}
fetch_request_t req;

View File

@@ -7,6 +7,7 @@
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/unistd.h>
#include <dirent.h>
#include "esp_log.h"
#include "esp_littlefs.h"
#include "cJSON.h"
@@ -129,6 +130,18 @@ esp_err_t storage_manager_bind_uid(const char *uid, const char *card_name)
return ESP_ERR_NO_MEM;
}
/* If previous mapping was for a different card, purge cached files so new card fetches clean */
cJSON *prev = cJSON_GetObjectItem(root, uid);
if (prev == NULL || prev->valuestring == NULL || strcasecmp(prev->valuestring, card_name) != 0) {
char old_json[64];
char old_jpg[64];
snprintf(old_json, sizeof(old_json), STORAGE_CARDS_DIR "/%s.json", uid);
snprintf(old_jpg, sizeof(old_jpg), STORAGE_CARDS_DIR "/%s.jpg", uid);
remove(old_json);
remove(old_jpg);
ESP_LOGI(TAG, "purged old card cache for %s due to new binding -> '%s'", uid, card_name);
}
cJSON_DeleteItemFromObject(root, uid);
cJSON_AddItemToObject(root, uid, cJSON_CreateString(card_name));
@@ -139,6 +152,47 @@ esp_err_t storage_manager_bind_uid(const char *uid, const char *card_name)
return ret;
}
esp_err_t storage_manager_unbind_uid(const char *uid)
{
if (uid == NULL || uid[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
char *existing = NULL;
size_t existing_len = 0;
cJSON *root = NULL;
if (storage_read_text(STORAGE_MAPPINGS, &existing, &existing_len) == ESP_OK && existing != NULL) {
root = cJSON_Parse(existing);
}
free(existing);
if (root == NULL || !cJSON_IsObject(root)) {
if (root) cJSON_Delete(root);
return ESP_ERR_NOT_FOUND;
}
if (!cJSON_HasObjectItem(root, uid)) {
cJSON_Delete(root);
return ESP_ERR_NOT_FOUND;
}
cJSON_DeleteItemFromObject(root, uid);
char old_json[64];
char old_jpg[64];
snprintf(old_json, sizeof(old_json), STORAGE_CARDS_DIR "/%s.json", uid);
snprintf(old_jpg, sizeof(old_jpg), STORAGE_CARDS_DIR "/%s.jpg", uid);
remove(old_json);
remove(old_jpg);
ESP_LOGI(TAG, "unbound UID %s and removed cached files", uid);
char *out = cJSON_Print(root);
esp_err_t ret = out ? storage_write_text(STORAGE_MAPPINGS, out) : ESP_ERR_NO_MEM;
free(out);
cJSON_Delete(root);
return ret;
}
esp_err_t storage_manager_lookup_name(const char *uid, char **out)
{
*out = NULL;
@@ -157,6 +211,17 @@ esp_err_t storage_manager_lookup_name(const char *uid, char **out)
}
cJSON *item = cJSON_GetObjectItem(root, uid);
if (item == NULL) {
char prefix[48];
snprintf(prefix, sizeof(prefix), "%s&name=", uid);
cJSON *child = NULL;
cJSON_ArrayForEach(child, root) {
if (child->string && strncmp(child->string, prefix, strlen(prefix)) == 0) {
item = child;
break;
}
}
}
esp_err_t ret = (item && cJSON_IsString(item) && item->valuestring) ? ESP_OK : ESP_ERR_NOT_FOUND;
if (ret == ESP_OK) {
*out = strdup(item->valuestring);
@@ -257,3 +322,62 @@ esp_err_t storage_manager_init(void)
cross_reboot_persistence_test();
return ESP_OK;
}
esp_err_t storage_manager_get_info(size_t *total_bytes, size_t *used_bytes)
{
if (total_bytes == NULL || used_bytes == NULL) {
return ESP_ERR_INVALID_ARG;
}
return esp_littlefs_info(STORAGE_PARTITION_LABEL, total_bytes, used_bytes);
}
esp_err_t storage_manager_count_cached(int *json_count, int *jpg_count)
{
if (json_count == NULL || jpg_count == NULL) {
return ESP_ERR_INVALID_ARG;
}
*json_count = 0;
*jpg_count = 0;
DIR *d = opendir(STORAGE_CARDS_DIR);
if (d == NULL) {
return ESP_OK;
}
struct dirent *entry;
while ((entry = readdir(d)) != NULL) {
if (entry->d_type == DT_REG || entry->d_type == DT_UNKNOWN) {
size_t nlen = strlen(entry->d_name);
if (nlen > 5 && strcmp(entry->d_name + nlen - 5, ".json") == 0) {
(*json_count)++;
} else if (nlen > 4 && strcmp(entry->d_name + nlen - 4, ".jpg") == 0) {
(*jpg_count)++;
}
}
}
closedir(d);
return ESP_OK;
}
esp_err_t storage_manager_clear_all_cards(bool reset_mappings)
{
DIR *d = opendir(STORAGE_CARDS_DIR);
if (d != NULL) {
struct dirent *entry;
while ((entry = readdir(d)) != NULL) {
if (entry->d_name[0] == '.') {
continue;
}
char path[300];
snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s", entry->d_name);
unlink(path);
}
closedir(d);
ESP_LOGI(TAG, "Cleared all files in %s", STORAGE_CARDS_DIR);
}
if (reset_mappings) {
storage_write_text(STORAGE_MAPPINGS, "{\n}\n");
ESP_LOGI(TAG, "Reset %s to empty", STORAGE_MAPPINGS);
}
return ESP_OK;
}

View File

@@ -45,12 +45,32 @@ esp_err_t storage_write_text(const char *path, const char *text);
*/
esp_err_t storage_manager_bind_uid(const char *uid, const char *card_name);
/**
* @brief Remove a UID mapping and its cached card JSON/JPG files.
*/
esp_err_t storage_manager_unbind_uid(const char *uid);
/**
* @brief Lookup a card name for a UID in mappings.json. Caller frees *out.
* @return ESP_OK and non-NULL *out on success; ESP_ERR_NOT_FOUND otherwise.
*/
esp_err_t storage_manager_lookup_name(const char *uid, char **out);
/**
* @brief Get partition usage information.
*/
esp_err_t storage_manager_get_info(size_t *total_bytes, size_t *used_bytes);
/**
* @brief Count number of cached cards (.json and .jpg) in /spiffs/cards.
*/
esp_err_t storage_manager_count_cached(int *json_count, int *jpg_count);
/**
* @brief Wipe all cached cards and optionally reset mappings.json for a new deck.
*/
esp_err_t storage_manager_clear_all_cards(bool reset_mappings);
#ifdef __cplusplus
}
#endif

View File

@@ -1,114 +0,0 @@
/*
* MTG RFID Companion - 5x7 bitmap font (uppercase, digits, punctuation)
*
* Row-major glyph table. Each glyph is 7 bytes; one byte per pixel row,
* low 5 bits map columns 0..4 from left to right (bit0 = left column).
* Index = ASCII code - 32.
*/
#pragma once
#define FONT5X7_W (5)
#define FONT5X7_H (7)
#define FONT5X7_FIRST_CHAR (32)
#define FONT5X7_LAST_CHAR (126)
#define FONT5X7_NUM_CHARS (FONT5X7_LAST_CHAR - FONT5X7_FIRST_CHAR + 1)
/* (char - 32) -> glyph rows */
static const uint8_t font5x7[FONT5X7_NUM_CHARS][FONT5X7_H] = {
/* 0x20 ' ' */ { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 },
/* 0x21 '!' */ { 0x04, 0x04, 0x04, 0x04, 0x00, 0x04, 0x04 },
/* 0x22 '"' */ { 0x0A, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00 },
/* 0x23 '#' */ { 0x0A, 0x15, 0x1F, 0x15, 0x1F, 0x0A, 0x0A },
/* 0x24 '$' */ { 0x0E, 0x11, 0x0F, 0x14, 0x0F, 0x11, 0x0E },
/* 0x25 '%' */ { 0x19, 0x1A, 0x02, 0x04, 0x08, 0x0B, 0x13 },
/* 0x26 '&' */ { 0x0E, 0x11, 0x09, 0x06, 0x13, 0x11, 0x0E },
/* 0x27 '\'' */ { 0x06, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00 },
/* 0x28 '(' */ { 0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02 },
/* 0x29 ')' */ { 0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08 },
/* 0x2A '*' */ { 0x04, 0x15, 0x1F, 0x04, 0x04, 0x00, 0x00 },
/* 0x2B '+' */ { 0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00 },
/* 0x2C ',' */ { 0x00, 0x00, 0x00, 0x00, 0x0C, 0x04, 0x08 },
/* 0x2D '-' */ { 0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00 },
/* 0x2E '.' */ { 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C },
/* 0x2F '/' */ { 0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10 },
/* 0x30 '0' */ { 0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x31 '1' */ { 0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x1F },
/* 0x32 '2' */ { 0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F },
/* 0x33 '3' */ { 0x0E, 0x01, 0x01, 0x0E, 0x01, 0x01, 0x0E },
/* 0x34 '4' */ { 0x11, 0x11, 0x11, 0x1F, 0x01, 0x01, 0x01 },
/* 0x35 '5' */ { 0x1F, 0x10, 0x10, 0x1E, 0x01, 0x01, 0x0E },
/* 0x36 '6' */ { 0x0E, 0x11, 0x10, 0x1E, 0x11, 0x11, 0x0E },
/* 0x37 '7' */ { 0x1F, 0x01, 0x02, 0x04, 0x04, 0x04, 0x04 },
/* 0x38 '8' */ { 0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E },
/* 0x39 '9' */ { 0x0E, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x0E },
/* 0x3A ':' */ { 0x00, 0x06, 0x06, 0x00, 0x06, 0x06, 0x00 },
/* 0x3B ';' */ { 0x00, 0x06, 0x06, 0x00, 0x06, 0x02, 0x04 },
/* 0x3C '<' */ { 0x02, 0x06, 0x0A, 0x1E, 0x08, 0x08, 0x00 },
/* 0x3D '=' */ { 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00, 0x00 },
/* 0x3E '>' */ { 0x08, 0x0C, 0x0A, 0x0F, 0x02, 0x02, 0x00 },
/* 0x3F '?' */ { 0x0E, 0x11, 0x01, 0x06, 0x04, 0x00, 0x04 },
/* 0x40 '@' */ { 0x0E, 0x11, 0x15, 0x1F, 0x10, 0x10, 0x0F },
/* 0x41 'A' */ { 0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11 },
/* 0x42 'B' */ { 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E },
/* 0x43 'C' */ { 0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E },
/* 0x44 'D' */ { 0x18, 0x11, 0x11, 0x11, 0x11, 0x11, 0x18 },
/* 0x45 'E' */ { 0x1F, 0x10, 0x10, 0x1F, 0x10, 0x10, 0x1F },
/* 0x46 'F' */ { 0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10 },
/* 0x47 'G' */ { 0x0E, 0x11, 0x10, 0x13, 0x11, 0x11, 0x0F },
/* 0x48 'H' */ { 0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11 },
/* 0x49 'I' */ { 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x1F },
/* 0x4A 'J' */ { 0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E },
/* 0x4B 'K' */ { 0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11 },
/* 0x4C 'L' */ { 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F },
/* 0x4D 'M' */ { 0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11 },
/* 0x4E 'N' */ { 0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11 },
/* 0x4F 'O' */ { 0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x50 'P' */ { 0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10 },
/* 0x51 'Q' */ { 0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D },
/* 0x52 'R' */ { 0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11 },
/* 0x53 'S' */ { 0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E },
/* 0x54 'T' */ { 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 },
/* 0x55 'U' */ { 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x56 'V' */ { 0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04 },
/* 0x57 'W' */ { 0x11, 0x11, 0x11, 0x13, 0x15, 0x19, 0x11 },
/* 0x58 'X' */ { 0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11 },
/* 0x59 'Y' */ { 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04 },
/* 0x5A 'Z' */ { 0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F },
/* 0x5B '[' */ { 0x08, 0x0C, 0x08, 0x08, 0x08, 0x08, 0x0E },
/* 0x5C '\\' */ { 0x10, 0x10, 0x08, 0x04, 0x02, 0x01, 0x01 },
/* 0x5D ']' */ { 0x0E, 0x04, 0x04, 0x04, 0x04, 0x0C, 0x04 },
/* 0x5E '^' */ { 0x04, 0x0A, 0x15, 0x04, 0x04, 0x00, 0x00 },
/* 0x5F '_' */ { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F },
/* 0x60 '`' */ { 0x06, 0x08, 0x04, 0x00, 0x00, 0x00, 0x00 },
/* 0x61 'a' */ { 0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11 },
/* 0x62 'b' */ { 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E },
/* 0x63 'c' */ { 0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E },
/* 0x64 'd' */ { 0x18, 0x11, 0x11, 0x11, 0x11, 0x11, 0x18 },
/* 0x65 'e' */ { 0x1F, 0x10, 0x10, 0x1F, 0x10, 0x10, 0x1F },
/* 0x66 'f' */ { 0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10 },
/* 0x67 'g' */ { 0x0E, 0x11, 0x10, 0x13, 0x11, 0x11, 0x0F },
/* 0x68 'h' */ { 0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11 },
/* 0x69 'i' */ { 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x1F },
/* 0x6A 'j' */ { 0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E },
/* 0x6B 'k' */ { 0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11 },
/* 0x6C 'l' */ { 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F },
/* 0x6D 'm' */ { 0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11 },
/* 0x6E 'n' */ { 0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11 },
/* 0x6F 'o' */ { 0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x70 'p' */ { 0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10 },
/* 0x71 'q' */ { 0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D },
/* 0x72 'r' */ { 0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11 },
/* 0x73 's' */ { 0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E },
/* 0x74 't' */ { 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 },
/* 0x75 'u' */ { 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x76 'v' */ { 0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04 },
/* 0x77 'w' */ { 0x11, 0x11, 0x11, 0x13, 0x15, 0x19, 0x11 },
/* 0x78 'x' */ { 0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11 },
/* 0x79 'y' */ { 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04 },
/* 0x7A 'z' */ { 0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F },
/* 0x7B '{' */ { 0x06, 0x04, 0x08, 0x0C, 0x08, 0x04, 0x18 },
/* 0x7C '|' */ { 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 },
/* 0x7D '}' */ { 0x0C, 0x04, 0x02, 0x06, 0x02, 0x04, 0x06 },
/* 0x7E '~' */ { 0x00, 0x1B, 0x16, 0x00, 0x00, 0x00, 0x00 },
};

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
* MTG RFID Companion - Registration web server (SoftAP)
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "esp_log.h"
@@ -25,28 +26,52 @@ static const char SPA_PAGE[] =
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
"<title>MTG RFID Companion</title><style>"
"body{font-family:system-ui;background:#1a1a2e;color:#eee;max-width:560px;margin:2em auto;padding:1em}"
"h1{color:#ffd75a}.uid{font-size:1.6em;letter-spacing:2px;color:#7fd77f;word-break:break-all}"
"input,.btn{font-size:1em;padding:.5em;border-radius:6px;border:1px solid #555;background:#222;color:#eee}"
"button{margin:.4em}.sug{list-style:none;padding:0}.sug li{cursor:pointer;padding:.5em;border-bottom:1px solid #444}"
"body{font-family:system-ui,-apple-system,sans-serif;background:#141424;color:#eee;max-width:600px;margin:1.5em auto;padding:1em}"
"h1{color:#ffd75a;margin-bottom:0.2em}h2{color:#ffd75a;font-size:1.15em;margin-top:1.2em}"
".uid{font-size:1.5em;font-family:monospace;letter-spacing:2px;color:#7fd77f;word-break:break-all;background:#1e1e32;padding:.4em .6em;border-radius:6px;border:1px solid #333}"
"input{font-size:1em;padding:.5em .8em;border-radius:6px;border:1px solid #555;background:#222;color:#eee;width:calc(100% - 1.6em);box-sizing:border-box}"
"button,.btn{font-size:.95em;padding:.5em 1em;border-radius:6px;border:1px solid #555;background:#2a2a44;color:#eee;cursor:pointer;margin:.3em 0}"
"button:hover,.btn:hover{background:#3a3a5c}"
".btn-primary{background:#ffd75a;color:#111;border:none;font-weight:bold}"
".btn-primary:hover{background:#ffe380}"
".btn-del{background:#4a1e1e;color:#ff9999;border:1px solid #772222;font-size:.85em;padding:.4em .8em;cursor:pointer;border-radius:6px}"
".btn-del:hover{background:#772222;color:#fff}"
".sug{list-style:none;padding:0;margin:.4em 0;background:#1f1f33;border-radius:6px;border:1px solid #333}"
".sug li{cursor:pointer;padding:.5em .8em;border-bottom:1px solid #333}"
".sug li:hover{background:#2a2a48;color:#ffd75a}"
".ok{color:#7fd77f}.bad{color:#ff6b6b}"
".card-item{display:flex;align-items:center;gap:12px;background:#1e1e32;border:1px solid #33334c;border-radius:8px;padding:8px 12px;margin-bottom:8px}"
".card-thumb{width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid #444;background:#111;flex-shrink:0}"
".card-info{flex:1;min-width:0}"
".card-title{font-weight:bold;color:#ffd75a;font-size:1.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"
".card-uid{font-family:monospace;color:#7fd77f;font-size:.85em;margin-top:2px}"
".card-none{color:#777;font-style:italic;padding:.8em 0}"
".header-row{display:flex;align-items:center;justify-content:space-between}"
"</style></head><body>"
"<h1>MTG RFID Companion</h1>"
"<p>Pair a scanned card with its Scryfall name.</p>"
"<h2>Last scanned UID</h2><p class=\"uid\" id=\"uid\">waiting...</p>"
"<h2>Card name</h2>"
"<input id=\"name\" placeholder=\"Type a card name\" autocomplete=\"off\">"
"<button onclick=\"bindCard()\">Pair UID with card</button>"
"<p style=\"color:#aaa;margin-top:0\">Pair card sleeves and manage your scanned deck.</p>"
"<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">"
"<h2 style=\"margin-top:0\">Last Scanned Tag</h2>"
"<p class=\"uid\" id=\"uid\">waiting...</p>"
"<h2>Pair With Card Name</h2>"
"<input id=\"name\" placeholder=\"Type card name (e.g. Black Lotus)\" autocomplete=\"off\">"
"<div style=\"margin-top:.6em\"><button class=\"btn-primary\" onclick=\"bindCard()\">Pair Card</button></div>"
"<ul class=\"sug\" id=\"res\"></ul>"
"<p id=\"status\"></p>"
"</div>"
"<div class=\"header-row\">"
"<h2>Paired Cards (<span id=\"count\">0</span>)</h2>"
"<button class=\"btn\" style=\"font-size:.8em\" onclick=\"loadCards()\">Refresh</button>"
"</div>"
"<div id=\"cards-box\"><p class=\"card-none\">Loading paired cards...</p></div>"
"<script>"
"var lastUid='';var suggestions=[];"
"function poll(){fetch('/api/last_uid').then(function(r){return r.json();}).then(function(j){"
" if(j.uid&&j.uid!==lastUid){lastUid=j.uid;document.getElementById('uid').textContent=j.uid;"
" suggestions=[];document.getElementById('res').innerHTML='';show('','');}})"
" .catch(function(){});setTimeout(poll,1000);}"
"function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}"
"function show(cls,msg){document.getElementById('status').className=cls;document.getElementById('status').textContent=msg;}"
"function poll(){fetch('/api/last_uid').then(function(r){return r.json();}).then(function(j){"
" if(j.uid&&j.uid!==lastUid){lastUid=j.uid;document.getElementById('uid').textContent=j.uid;"
" suggestions=[];document.getElementById('res').innerHTML='';show('','');}"
"}).catch(function(){});setTimeout(poll,1000);}"
"function autocomplete(){var q=document.getElementById('name').value;"
" if(q.length<3){suggestions=[];document.getElementById('res').innerHTML='';return;}"
" fetch('https://api.scryfall.com/cards/autocomplete?q='+encodeURIComponent(q))"
@@ -58,13 +83,38 @@ static const char SPA_PAGE[] =
"document.getElementById('name').oninput=autocomplete;"
"function bindCard(){var n=document.getElementById('name').value.trim();"
" if(!lastUid||!n){show('bad','Scan a card and enter a name first');return;}"
" show('','Pairing...');"
" fetch('/api/bind',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
" body:'uid='+encodeURIComponent(lastUid)+'&name='+encodeURIComponent(n)})"
" .then(function(r){return r.json();}).then(function(j){"
" if(j.ok){show('ok','Paired '+j.uid+' -> '+j.name);document.getElementById('name').value='';"
" document.getElementById('res').innerHTML='';}"
" document.getElementById('res').innerHTML='';loadCards();}"
" else{show('bad','Error: '+j.error);}}).catch(function(e){show('bad','Network: '+e);});}"
"poll();"
"function unbindCard(u){if(!confirm('Unbind card '+u+'?'))return;"
" fetch('/api/unbind',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
" body:'uid='+encodeURIComponent(u)})"
" .then(function(r){return r.json();}).then(function(j){"
" if(j.ok){loadCards();show('ok','Unbound '+u);}"
" else{show('bad','Error unbinding '+u);}}).catch(function(e){show('bad','Network: '+e);});}"
"function loadCards(){"
" fetch('/api/cards').then(function(r){return r.json();}).then(function(data){"
" var box=document.getElementById('cards-box');var uids=Object.keys(data);"
" document.getElementById('count').textContent=uids.length;"
" if(uids.length===0){box.innerHTML='<p class=\"card-none\">No cards paired yet. Scan a card above to begin!</p>';return;}"
" uids.sort(function(a,b){return data[a].localeCompare(data[b]);});"
" var html='';"
" uids.forEach(function(u){"
" var nm=esc(data[u]);"
" html+='<div class=\"card-item\">';"
" html+='<img class=\"card-thumb\" src=\"/api/art?uid='+u+'\" alt=\"art\" onerror=\"this.style.display=\\'none\\'\">';"
" html+='<div class=\"card-info\"><div class=\"card-title\">'+nm+'</div><div class=\"card-uid\">'+u+'</div></div>';"
" html+='<button class=\"btn-del\" onclick=\"unbindCard(\\''+u+'\\')\">Unbind</button>';"
" html+='</div>';"
" });"
" box.innerHTML=html;"
" }).catch(function(e){document.getElementById('cards-box').innerHTML='<p class=\"bad\">Failed to load cards: '+e+'</p>';});"
"}"
"poll();loadCards();"
"</script></body></html>";
static httpd_handle_t s_server = NULL;
@@ -72,6 +122,9 @@ static httpd_handle_t s_server = NULL;
static esp_err_t handle_root(httpd_req_t *req);
static esp_err_t handle_last_uid(httpd_req_t *req);
static esp_err_t handle_bind(httpd_req_t *req);
static esp_err_t handle_cards(httpd_req_t *req);
static esp_err_t handle_art(httpd_req_t *req);
static esp_err_t handle_unbind(httpd_req_t *req);
static const httpd_uri_t URI_ROOT = {
.uri = "/", .method = HTTP_GET, .handler = handle_root, .user_ctx = NULL
@@ -82,6 +135,15 @@ static const httpd_uri_t URI_LAST_UID = {
static const httpd_uri_t URI_BIND = {
.uri = "/api/bind", .method = HTTP_POST, .handler = handle_bind, .user_ctx = NULL
};
static const httpd_uri_t URI_CARDS = {
.uri = "/api/cards", .method = HTTP_GET, .handler = handle_cards, .user_ctx = NULL
};
static const httpd_uri_t URI_ART = {
.uri = "/api/art", .method = HTTP_GET, .handler = handle_art, .user_ctx = NULL
};
static const httpd_uri_t URI_UNBIND = {
.uri = "/api/unbind", .method = HTTP_POST, .handler = handle_unbind, .user_ctx = NULL
};
static void send_json(httpd_req_t *req, int status, const char *body)
{
@@ -174,18 +236,29 @@ static esp_err_t handle_bind(httpd_req_t *req)
char *uid = NULL;
char *name = NULL;
const char *u = strstr(form, "uid=");
const char *n = strstr(form, "name=");
char *u = strstr(form, "uid=");
char *n = strstr(form, "name=");
if (u) {
char *end = strchr(u, '&');
(void)end;
if (end) *end = '\0';
uid = url_decode(u + 4);
if (end) *end = '&';
}
if (n) {
char *end = strchr(n, '&');
if (end) *end = '\0';
name = url_decode(n + 5);
if (end) *end = '&';
}
free(form);
if (uid) {
size_t ulen = strlen(uid);
while (ulen > 0 && (uid[ulen - 1] == ' ' || uid[ulen - 1] == '\r' || uid[ulen - 1] == '\n')) {
uid[--ulen] = '\0';
}
}
if (uid == NULL || name == NULL || strlen(uid) == 0 || strlen(name) == 0) {
free(uid);
free(name);
@@ -212,6 +285,114 @@ static esp_err_t handle_bind(httpd_req_t *req)
return ESP_OK;
}
static esp_err_t handle_cards(httpd_req_t *req)
{
char *text = NULL;
size_t len = 0;
if (storage_read_text(STORAGE_MAPPINGS, &text, &len) == ESP_OK && text != NULL) {
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, text, (ssize_t)len);
free(text);
} else {
send_json(req, 200, "{}\n");
}
return ESP_OK;
}
static esp_err_t handle_art(httpd_req_t *req)
{
char query[64] = {0};
char uid[32] = {0};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
httpd_query_key_value(query, "uid", uid, sizeof(uid));
}
if (uid[0] == '\0') {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing uid");
return ESP_OK;
}
char path[64];
snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s.jpg", uid);
FILE *f = fopen(path, "rb");
if (!f) {
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Art not found");
return ESP_OK;
}
httpd_resp_set_type(req, "image/jpeg");
httpd_resp_set_hdr(req, "Cache-Control", "public, max-age=86400");
char chunk[1024];
size_t n = 0;
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
if (httpd_resp_send_chunk(req, chunk, (ssize_t)n) != ESP_OK) {
fclose(f);
return ESP_FAIL;
}
}
fclose(f);
httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK;
}
static esp_err_t handle_unbind(httpd_req_t *req)
{
if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) {
send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}");
return ESP_OK;
}
char *form = malloc((size_t)req->content_len + 1);
if (form == NULL) {
send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}");
return ESP_OK;
}
int got = httpd_req_recv(req, form, (size_t)req->content_len);
if (got != req->content_len) {
free(form);
send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}");
return ESP_OK;
}
form[got] = '\0';
char *uid = NULL;
char *u = strstr(form, "uid=");
if (u) {
char *end = strchr(u, '&');
if (end) *end = '\0';
uid = url_decode(u + 4);
if (end) *end = '&';
}
free(form);
if (uid) {
size_t ulen = strlen(uid);
while (ulen > 0 && (uid[ulen - 1] == ' ' || uid[ulen - 1] == '\r' || uid[ulen - 1] == '\n')) {
uid[--ulen] = '\0';
}
}
if (uid == NULL || strlen(uid) == 0) {
free(uid);
send_json(req, 400, "{\"ok\":false,\"error\":\"missing uid\"}");
return ESP_OK;
}
esp_err_t ret = storage_manager_unbind_uid(uid);
if (ret != ESP_OK) {
free(uid);
send_json(req, 404, "{\"ok\":false,\"error\":\"uid not found\"}");
return ESP_OK;
}
char ok[128];
snprintf(ok, sizeof(ok), "{\"ok\":true,\"uid\":\"%s\"}\n", uid);
ESP_LOGI(TAG, "unbound UID %s via web", uid);
send_json(req, 200, ok);
free(uid);
return ESP_OK;
}
/* ------------------------------------------------------------------ */
/* SoftAP + server */
/* ------------------------------------------------------------------ */
@@ -286,7 +467,7 @@ esp_err_t web_server_start(void)
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 6 * 1024;
config.max_uri_handlers = 8;
config.max_uri_handlers = 12;
config.lru_purge_enable = true;
config.keep_alive_enable = true;
config.max_open_sockets = 4;
@@ -300,6 +481,9 @@ esp_err_t web_server_start(void)
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ROOT));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LAST_UID));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_BIND));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CARDS));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ART));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_UNBIND));
ESP_LOGI(TAG, "SoftAP '%s' at 192.168.4.1, web server ready", AP_SSID);
return ESP_OK;

View File

@@ -21,6 +21,7 @@ static EventGroupHandle_t s_wifi_group = NULL;
#define WIFI_CONNECTED_BIT BIT0
static int s_retry_count = 0;
static char s_sta_ip[20] = "No Wi-Fi";
static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data)
{
@@ -30,6 +31,7 @@ static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, voi
ESP_LOGI(TAG, "station started, connecting...");
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
strncpy(s_sta_ip, "No Wi-Fi", sizeof(s_sta_ip));
if (s_retry_count < WIFI_RETRY_MAX) {
ESP_LOGW(TAG, "disconnected (%d), retrying in %d ms", s_retry_count, WIFI_RECONNECT_DELAY_MS);
xEventGroupClearBits(s_wifi_group, WIFI_CONNECTED_BIT);
@@ -39,12 +41,21 @@ static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, voi
}
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)data;
ESP_LOGI(TAG, "connected, IP: " IPSTR, IP2STR(&event->ip_info.ip));
snprintf(s_sta_ip, sizeof(s_sta_ip), IPSTR, IP2STR(&event->ip_info.ip));
ESP_LOGI(TAG, "connected, IP: %s", s_sta_ip);
s_retry_count = 0;
xEventGroupSetBits(s_wifi_group, WIFI_CONNECTED_BIT);
}
}
const char *wifi_manager_get_ip(void)
{
if (!wifi_manager_connected()) {
return "No Wi-Fi";
}
return s_sta_ip;
}
esp_err_t wifi_manager_init(void)
{
esp_err_t ret = nvs_flash_init();

View File

@@ -41,6 +41,11 @@ bool wifi_manager_connected(void);
*/
esp_err_t wifi_manager_wait_connected(TickType_t timeout_ticks);
/**
* @brief Get the STA IP address as a string (or "No Wi-Fi" if disconnected).
*/
const char *wifi_manager_get_ip(void);
#ifdef __cplusplus
}
#endif

314
sdkconfig
View File

@@ -434,8 +434,8 @@ CONFIG_PARTITION_TABLE_MD5=y
#
# MTG RFID Companion
#
CONFIG_MTG_WIFI_SSID="YOUR_SSID_HERE"
CONFIG_MTG_WIFI_PASS="YOUR_PASSWORD_HERE"
CONFIG_MTG_WIFI_SSID="WiFimodem-FD58"
CONFIG_MTG_WIFI_PASS="gzhrt5yzm3"
CONFIG_MTG_SCAN_PREFETCH=y
# end of MTG RFID Companion
@@ -2078,6 +2078,11 @@ CONFIG_JD_FASTDECODE_32BIT=y
# CONFIG_JD_DEFAULT_HUFFMAN is not set
# end of JPEG Decoder
#
# ESP LVGL PORT
#
# end of ESP LVGL PORT
#
# LittleFS
#
@@ -2107,6 +2112,311 @@ CONFIG_LITTLEFS_ASSERTS=y
# CONFIG_LITTLEFS_MMAP_PARTITION is not set
# CONFIG_LITTLEFS_WDT_RESET is not set
# end of LittleFS
#
# LVGL configuration
#
CONFIG_LV_CONF_SKIP=y
# CONFIG_LV_CONF_MINIMAL is not set
#
# Color settings
#
# CONFIG_LV_COLOR_DEPTH_32 is not set
CONFIG_LV_COLOR_DEPTH_16=y
# CONFIG_LV_COLOR_DEPTH_8 is not set
# CONFIG_LV_COLOR_DEPTH_1 is not set
CONFIG_LV_COLOR_DEPTH=16
CONFIG_LV_COLOR_16_SWAP=y
# CONFIG_LV_COLOR_SCREEN_TRANSP is not set
CONFIG_LV_COLOR_MIX_ROUND_OFS=128
CONFIG_LV_COLOR_CHROMA_KEY_HEX=0x00FF00
# end of Color settings
#
# Memory settings
#
# CONFIG_LV_MEM_CUSTOM is not set
CONFIG_LV_MEM_SIZE_KILOBYTES=32
CONFIG_LV_MEM_ADDR=0x0
CONFIG_LV_MEM_BUF_MAX_NUM=16
# CONFIG_LV_MEMCPY_MEMSET_STD is not set
# end of Memory settings
#
# HAL Settings
#
CONFIG_LV_DISP_DEF_REFR_PERIOD=30
CONFIG_LV_INDEV_DEF_READ_PERIOD=30
# CONFIG_LV_TICK_CUSTOM is not set
CONFIG_LV_DPI_DEF=130
# end of HAL Settings
#
# Feature configuration
#
#
# Drawing
#
CONFIG_LV_DRAW_COMPLEX=y
CONFIG_LV_SHADOW_CACHE_SIZE=0
CONFIG_LV_CIRCLE_CACHE_SIZE=4
CONFIG_LV_LAYER_SIMPLE_BUF_SIZE=24576
CONFIG_LV_IMG_CACHE_DEF_SIZE=0
CONFIG_LV_GRADIENT_MAX_STOPS=2
CONFIG_LV_GRAD_CACHE_DEF_SIZE=0
# CONFIG_LV_DITHER_GRADIENT is not set
CONFIG_LV_DISP_ROT_MAX_BUF=10240
# end of Drawing
#
# GPU
#
# CONFIG_LV_USE_GPU_ARM2D is not set
# CONFIG_LV_USE_GPU_STM32_DMA2D is not set
# CONFIG_LV_USE_GPU_RA6M3_G2D is not set
# CONFIG_LV_USE_GPU_SWM341_DMA2D is not set
# CONFIG_LV_USE_GPU_NXP_PXP is not set
# CONFIG_LV_USE_GPU_NXP_VG_LITE is not set
# CONFIG_LV_USE_GPU_SDL is not set
# end of GPU
#
# Logging
#
# CONFIG_LV_USE_LOG is not set
# end of Logging
#
# Asserts
#
CONFIG_LV_USE_ASSERT_NULL=y
CONFIG_LV_USE_ASSERT_MALLOC=y
# CONFIG_LV_USE_ASSERT_STYLE is not set
# CONFIG_LV_USE_ASSERT_MEM_INTEGRITY is not set
# CONFIG_LV_USE_ASSERT_OBJ is not set
CONFIG_LV_ASSERT_HANDLER_INCLUDE="assert.h"
# end of Asserts
#
# Others
#
# CONFIG_LV_USE_PERF_MONITOR is not set
# CONFIG_LV_USE_MEM_MONITOR is not set
# CONFIG_LV_USE_REFR_DEBUG is not set
# CONFIG_LV_SPRINTF_CUSTOM is not set
# CONFIG_LV_SPRINTF_USE_FLOAT is not set
CONFIG_LV_USE_USER_DATA=y
# CONFIG_LV_ENABLE_GC is not set
# end of Others
#
# Compiler settings
#
# CONFIG_LV_BIG_ENDIAN_SYSTEM is not set
CONFIG_LV_ATTRIBUTE_MEM_ALIGN_SIZE=1
# CONFIG_LV_ATTRIBUTE_FAST_MEM_USE_IRAM is not set
# CONFIG_LV_USE_LARGE_COORD is not set
# end of Compiler settings
# end of Feature configuration
#
# Font usage
#
#
# Enable built-in fonts
#
# CONFIG_LV_FONT_MONTSERRAT_8 is not set
# CONFIG_LV_FONT_MONTSERRAT_10 is not set
# CONFIG_LV_FONT_MONTSERRAT_12 is not set
CONFIG_LV_FONT_MONTSERRAT_14=y
# CONFIG_LV_FONT_MONTSERRAT_16 is not set
# CONFIG_LV_FONT_MONTSERRAT_18 is not set
CONFIG_LV_FONT_MONTSERRAT_20=y
# CONFIG_LV_FONT_MONTSERRAT_22 is not set
# CONFIG_LV_FONT_MONTSERRAT_24 is not set
# CONFIG_LV_FONT_MONTSERRAT_26 is not set
# CONFIG_LV_FONT_MONTSERRAT_28 is not set
# CONFIG_LV_FONT_MONTSERRAT_30 is not set
# CONFIG_LV_FONT_MONTSERRAT_32 is not set
# CONFIG_LV_FONT_MONTSERRAT_34 is not set
# CONFIG_LV_FONT_MONTSERRAT_36 is not set
# CONFIG_LV_FONT_MONTSERRAT_38 is not set
# CONFIG_LV_FONT_MONTSERRAT_40 is not set
# CONFIG_LV_FONT_MONTSERRAT_42 is not set
# CONFIG_LV_FONT_MONTSERRAT_44 is not set
# CONFIG_LV_FONT_MONTSERRAT_46 is not set
CONFIG_LV_FONT_MONTSERRAT_48=y
# CONFIG_LV_FONT_MONTSERRAT_12_SUBPX is not set
# CONFIG_LV_FONT_MONTSERRAT_28_COMPRESSED is not set
# CONFIG_LV_FONT_DEJAVU_16_PERSIAN_HEBREW is not set
# CONFIG_LV_FONT_SIMSUN_16_CJK is not set
# CONFIG_LV_FONT_UNSCII_8 is not set
# CONFIG_LV_FONT_UNSCII_16 is not set
# CONFIG_LV_FONT_CUSTOM is not set
# end of Enable built-in fonts
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_8 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_12 is not set
CONFIG_LV_FONT_DEFAULT_MONTSERRAT_14=y
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_16 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_18 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_20 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_22 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_24 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_26 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_28 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_30 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_32 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_34 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_36 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_38 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_40 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_42 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_44 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_46 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_48 is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_12_SUBPX is not set
# CONFIG_LV_FONT_DEFAULT_MONTSERRAT_28_COMPRESSED is not set
# CONFIG_LV_FONT_DEFAULT_DEJAVU_16_PERSIAN_HEBREW is not set
# CONFIG_LV_FONT_DEFAULT_SIMSUN_16_CJK is not set
# CONFIG_LV_FONT_DEFAULT_UNSCII_8 is not set
# CONFIG_LV_FONT_DEFAULT_UNSCII_16 is not set
# CONFIG_LV_FONT_FMT_TXT_LARGE is not set
# CONFIG_LV_USE_FONT_COMPRESSED is not set
# CONFIG_LV_USE_FONT_SUBPX is not set
CONFIG_LV_USE_FONT_PLACEHOLDER=y
# end of Font usage
#
# Text Settings
#
CONFIG_LV_TXT_ENC_UTF8=y
# CONFIG_LV_TXT_ENC_ASCII is not set
CONFIG_LV_TXT_BREAK_CHARS=" ,.;:-_"
CONFIG_LV_TXT_LINE_BREAK_LONG_LEN=0
CONFIG_LV_TXT_COLOR_CMD="#"
# CONFIG_LV_USE_BIDI is not set
# CONFIG_LV_USE_ARABIC_PERSIAN_CHARS is not set
# end of Text Settings
#
# Widget usage
#
CONFIG_LV_USE_ARC=y
CONFIG_LV_USE_BAR=y
CONFIG_LV_USE_BTN=y
CONFIG_LV_USE_BTNMATRIX=y
CONFIG_LV_USE_CANVAS=y
CONFIG_LV_USE_CHECKBOX=y
CONFIG_LV_USE_DROPDOWN=y
CONFIG_LV_USE_IMG=y
CONFIG_LV_USE_LABEL=y
CONFIG_LV_LABEL_TEXT_SELECTION=y
CONFIG_LV_LABEL_LONG_TXT_HINT=y
CONFIG_LV_USE_LINE=y
CONFIG_LV_USE_ROLLER=y
CONFIG_LV_ROLLER_INF_PAGES=7
CONFIG_LV_USE_SLIDER=y
CONFIG_LV_USE_SWITCH=y
CONFIG_LV_USE_TEXTAREA=y
CONFIG_LV_TEXTAREA_DEF_PWD_SHOW_TIME=1500
CONFIG_LV_USE_TABLE=y
# end of Widget usage
#
# Extra Widgets
#
CONFIG_LV_USE_ANIMIMG=y
CONFIG_LV_USE_CALENDAR=y
# CONFIG_LV_CALENDAR_WEEK_STARTS_MONDAY is not set
CONFIG_LV_USE_CALENDAR_HEADER_ARROW=y
CONFIG_LV_USE_CALENDAR_HEADER_DROPDOWN=y
CONFIG_LV_USE_CHART=y
CONFIG_LV_USE_COLORWHEEL=y
CONFIG_LV_USE_IMGBTN=y
CONFIG_LV_USE_KEYBOARD=y
CONFIG_LV_USE_LED=y
CONFIG_LV_USE_LIST=y
CONFIG_LV_USE_MENU=y
CONFIG_LV_USE_METER=y
CONFIG_LV_USE_MSGBOX=y
CONFIG_LV_USE_SPAN=y
CONFIG_LV_SPAN_SNIPPET_STACK_SIZE=64
CONFIG_LV_USE_SPINBOX=y
CONFIG_LV_USE_SPINNER=y
CONFIG_LV_USE_TABVIEW=y
CONFIG_LV_USE_TILEVIEW=y
CONFIG_LV_USE_WIN=y
# end of Extra Widgets
#
# Themes
#
CONFIG_LV_USE_THEME_DEFAULT=y
# CONFIG_LV_THEME_DEFAULT_DARK is not set
CONFIG_LV_THEME_DEFAULT_GROW=y
CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=80
CONFIG_LV_USE_THEME_BASIC=y
# CONFIG_LV_USE_THEME_MONO is not set
# end of Themes
#
# Layouts
#
CONFIG_LV_USE_FLEX=y
CONFIG_LV_USE_GRID=y
# end of Layouts
#
# 3rd Party Libraries
#
# CONFIG_LV_USE_FS_STDIO is not set
# CONFIG_LV_USE_FS_POSIX is not set
# CONFIG_LV_USE_FS_WIN32 is not set
# CONFIG_LV_USE_FS_FATFS is not set
# CONFIG_LV_USE_FS_LITTLEFS is not set
# CONFIG_LV_USE_PNG is not set
# CONFIG_LV_USE_BMP is not set
# CONFIG_LV_USE_SJPG is not set
# CONFIG_LV_USE_GIF is not set
# CONFIG_LV_USE_QRCODE is not set
# CONFIG_LV_USE_FREETYPE is not set
# CONFIG_LV_USE_TINY_TTF is not set
# CONFIG_LV_USE_RLOTTIE is not set
# CONFIG_LV_USE_FFMPEG is not set
# end of 3rd Party Libraries
#
# Others
#
CONFIG_LV_USE_SNAPSHOT=y
# CONFIG_LV_USE_MONKEY is not set
# CONFIG_LV_USE_GRIDNAV is not set
# CONFIG_LV_USE_FRAGMENT is not set
# CONFIG_LV_USE_IMGFONT is not set
# CONFIG_LV_USE_MSG is not set
# CONFIG_LV_USE_IME_PINYIN is not set
# end of Others
#
# Examples
#
CONFIG_LV_BUILD_EXAMPLES=y
# end of Examples
#
# Demos
#
# CONFIG_LV_USE_DEMO_WIDGETS is not set
# CONFIG_LV_USE_DEMO_KEYPAD_AND_ENCODER is not set
# CONFIG_LV_USE_DEMO_BENCHMARK is not set
# CONFIG_LV_USE_DEMO_STRESS is not set
# CONFIG_LV_USE_DEMO_MUSIC is not set
# end of Demos
# end of LVGL configuration
# end of Component config
# CONFIG_IDF_EXPERIMENTAL_FEATURES is not set

View File

@@ -8,5 +8,11 @@
# over these defaults. To apply changes here on an existing build, either run
# `idf.py menuconfig` ( -> "MTG RFID Companion") or remove `sdkconfig` and
# rebuild.
CONFIG_MTG_WIFI_SSID="YOUR_SSID_HERE"
CONFIG_MTG_WIFI_PASS="YOUR_PASSWORD_HERE"
CONFIG_MTG_WIFI_SSID="WiFimodem-FD58"
CONFIG_MTG_WIFI_PASS="gzhrt5yzm3"
# LVGL Configuration (ponytail: native library settings)
CONFIG_LV_COLOR_16_SWAP=y
CONFIG_LV_FONT_MONTSERRAT_14=y
CONFIG_LV_FONT_MONTSERRAT_20=y
CONFIG_LV_FONT_MONTSERRAT_48=y

View File

@@ -1,4 +1,6 @@
{
"04A2B3C4": "Sol Ring",
"04E1F2A3": "Atraxa, Praetors' Voice"
"04E1F2A3": "Atraxa, Praetors' Voice",
"508152B2": "Workshop Assistant",
"B34DF005": "Black Lotus"
}