diff --git a/README.md b/README.md
index ce06f20..7ff4c4c 100644
--- a/README.md
+++ b/README.md
@@ -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
+## ๐ป Software Setup & Build
-### 1. Toolchain
-
-Install ESP-IDF **v5.5** (any 5.x works): https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/
-
-Verify:
+### 1. Requirements
+- **ESP-IDF v5.x** (v5.0 through v5.5 supported):
+ ```powershell
+ . C:\Espressif\frameworks\esp-idf-v5.5\export.ps1
+ idf.py --version
+ ```
+### 2. Configure Wi-Fi
+Configure home Wi-Fi credentials for automatic Scryfall fetching:
```powershell
-# Windows (Espressif "ESP-IDF 5.5 PowerShell" terminal)
-. C:\Espressif\Initialize-Idf.ps1 # or: . $IDF_PATH\export.ps1
-idf.py --version
+idf.py menuconfig
+# Navigate to: MTG RFID Companion -> Wi-Fi SSID & Password
```
+*(Or edit `sdkconfig.defaults.esp32` directly).*
-> **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
-
+### 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: then run the 5 commands in [Build](#2-build) |
-| **CI build** (GitHub Actions etc.) | a GitHub account | The repo can build on `ubuntu` in CI; push the edited file and download the built `mtg-rfid-companion.bin` artifact |
-
-### Flashing the new firmware (no toolchain needed)
-
-Flash using **Espressif Flash Download Tool** (graphical, no toolchain):
-
-or `esptool.py` if you already have Python:
-
-```
-pip install esptool
-esptool.py -p COM3 --chip esp32 --before default_reset write_flash --flash_mode dio --flash_size 4MB 0x1000 bootloader.bin 0x8000 partition-table.bin 0x10000 mtg-rfid-companion.bin
-```
-
-### TL;DR
-
-1. Edit `sdkconfig.defaults.esp32` โ 2. rebuild (yours or someone's toolchain) โ
-3. flash the `.bin` โ 4. done. If you don't need your home Wi-Fi, **skip the
- whole thing**: the device works standalone and has its own AP.
-
----
-
-### 4. Flash & watch
-
-```powershell
-idf.py -p COMx flash monitor # COMx = your serial port
-```
-
-Expected boot log if everything is wired right:
-
-```
-I storage: LittleFS mounted at /spiffs: bytes total ...
-I storage: boot_count read back = 1 (persistence across reboots OK)
-I rfid: RC522 polling task pinned to Core 0
-I display: ST7789 240x320 ready ...
-I web: SoftAP 'MTG-Companion' at 192.168.4.1, web server ready
-I ui: UI task on core 1
-```
-
-> **RFID reader missing / not wired? That's OK.** A reader self-test failure no
-> longer aborts boot โ the device logs a warning and continues into the display,
-> SoftAP and web-pairing screens without card scanning:
-
----
-
-## Usage
-
-### Scan a card
-Place a tagged card on the reader:
-1. **Mapped UID** โ chirp, mana-colored LED flash, then the card view (cover art + name/type/PยทT/price/mana).
-2. **Unknown UID** โ registration screen with the pairing address.
-
-### Life counter
-Press the encoder button to toggle **SCANNER โ LIFE_COUNTER**. Rotate to adjust
-(20/40 preset or whatever you last set; press again to reset to the preset).
-
-### Web pairing (registration mode)
-1. Connect your phone to the `MTG-Companion` access point. A phone with
- cellular data keeps its mobile internet, so Scryfall autocomplete works;
- otherwise type the name manually.
-2. Open `http://192.168.4.1`.
-3. Scan an unmapped card โ the UID appears in the page.
-4. Type / pick a Scryfall-autocomplete name โ **Pair UID with card**.
-5. The mapping persists to `/spiffs/mappings.json`. The card data/art fetch
- runs immediately if the device has Wi-Fi, or is picked up automatically by
- the boot-time prefetch the next time you boot with Wi-Fi configured.
-
----
-
-## Storage Layout (`/spiffs`)
+## ๐ LittleFS Storage Structure
```
/spiffs/
-โโโ mappings.json UID -> Card Name map
-โโโ cards/
-โ โโโ sample.json example metadata
-โ โโโ sample.jpg example cover art (baked into flash image)
-โ โโโ .json Scryfall metadata cache
-โ โโโ .jpg cached cover art
+โโโ mappings.json UID -> Card Name JSON map
+โโโ cards/
+ โโโ .json Scryfall attributes (Mana, Type, Oracle, P/T)
+ โโโ .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.
\ No newline at end of file
+| **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. |
\ No newline at end of file
diff --git a/main/app_state.c b/main/app_state.c
index 757a4c7..6cc6586 100644
--- a/main/app_state.c
+++ b/main/app_state.c
@@ -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";
diff --git a/main/app_state.h b/main/app_state.h
index 45ccbc9..ae11776 100644
--- a/main/app_state.h
+++ b/main/app_state.h
@@ -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;
diff --git a/main/buzzer.c b/main/buzzer.c
index 40c7d11..f138717 100644
--- a/main/buzzer.c
+++ b/main/buzzer.c
@@ -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;
}
@@ -81,4 +145,12 @@ 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;
}
\ No newline at end of file
diff --git a/main/buzzer.h b/main/buzzer.h
index 36cbb63..721ada5 100644
--- a/main/buzzer.h
+++ b/main/buzzer.h
@@ -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
\ No newline at end of file
diff --git a/main/display_manager.c b/main/display_manager.c
index 2825b75..a821be3 100644
--- a/main/display_manager.c
+++ b/main/display_manager.c
@@ -1,38 +1,34 @@
/*
- * MTG RFID Companion - ST7789 display manager (Core 1)
+ * MTG RFID Companion - ST7789 display manager with LVGL (ponytail: native library)
*/
-#include
-#include
-#include
#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;
}
\ No newline at end of file
diff --git a/main/display_manager.h b/main/display_manager.h
index eaab24c..b3f2d0e 100644
--- a/main/display_manager.h
+++ b/main/display_manager.h
@@ -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
+#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
}
diff --git a/main/encoder.c b/main/encoder.c
index d67b997..ed5ae56 100644
--- a/main/encoder.c
+++ b/main/encoder.c
@@ -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;
diff --git a/main/encoder.h b/main/encoder.h
index 8a74c85..7d0406f 100644
--- a/main/encoder.h
+++ b/main/encoder.h
@@ -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
\ No newline at end of file
diff --git a/main/idf_component.yml b/main/idf_component.yml
index 73a2042..67f0ee7 100644
--- a/main/idf_component.yml
+++ b/main/idf_component.yml
@@ -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"
\ No newline at end of file
+ esp_jpeg: ">=1.0.2"
+ lvgl/lvgl: "^8.3.11"
+ espressif/esp_lvgl_port: "^2.0.0"
\ No newline at end of file
diff --git a/main/main.c b/main/main.c
index 406eade..1ba92fb 100644
--- a/main/main.c
+++ b/main/main.c
@@ -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));
diff --git a/main/rfid_manager.c b/main/rfid_manager.c
index 5a8d035..b8829a9 100644
--- a/main/rfid_manager.c
+++ b/main/rfid_manager.c
@@ -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);
diff --git a/main/scryfall_client.c b/main/scryfall_client.c
index 73fdb14..0d2280e 100644
--- a/main/scryfall_client.c
+++ b/main/scryfall_client.c
@@ -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);
- return ESP_OK;
+ 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, "normal"), "");
+ 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,13 +429,8 @@ 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);
- scryfall_ensure_cached(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;
diff --git a/main/storage_manager.c b/main/storage_manager.c
index 7f6f844..b989411 100644
--- a/main/storage_manager.c
+++ b/main/storage_manager.c
@@ -7,6 +7,7 @@
#include
#include
#include
+#include
#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);
@@ -256,4 +321,63 @@ 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;
}
\ No newline at end of file
diff --git a/main/storage_manager.h b/main/storage_manager.h
index 25f407a..626fe80 100644
--- a/main/storage_manager.h
+++ b/main/storage_manager.h
@@ -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
\ No newline at end of file
diff --git a/main/ui_font.h b/main/ui_font.h
deleted file mode 100644
index aedd2d8..0000000
--- a/main/ui_font.h
+++ /dev/null
@@ -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 },
-};
\ No newline at end of file
diff --git a/main/ui_task.c b/main/ui_task.c
index f7058fb..c57dfeb 100644
--- a/main/ui_task.c
+++ b/main/ui_task.c
@@ -1,132 +1,228 @@
/*
- * MTG RFID Companion - UI task (Core 1)
- *
- * Phase 4/7: renders the boot demo, the scanner idle screen, the card view
- * (cover art + metadata) and the interactive life counter, reacting to RFID,
- * Scryfall cache results, and the rotary encoder.
- */
+ * MTG RFID Companion - UI task with LVGL
#include
#include
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
+#include "esp_random.h"
+#include "driver/gpio.h"
+#include "hw_pins.h"
#include "cJSON.h"
#include "display_manager.h"
-#include "ui_font.h"
#include "rfid_manager.h"
#include "storage_manager.h"
#include "scryfall_client.h"
+#include "wifi_manager.h"
#include "app_state.h"
#include "encoder.h"
#include "led_manager.h"
#include "buzzer.h"
+#include "jpeg_decoder.h"
#include "ui_task.h"
static const char *TAG = "ui";
-#define UI_TASK_STACK (10 * 1024)
+#define UI_TASK_STACK (8 * 1024)
#define UI_TASK_PRIO (5)
#define UI_CORE (1)
-
#define LED_HOLD_MS (2500)
-#define FETCH_POLL_MS (100)
+#define COLOR_AMBER (0xFFB300)
+#define COLOR_GOLD (0xFFCC00)
static TaskHandle_t s_ui_task = NULL;
-
static char s_uid[RFID_UID_STR_MAX_LEN] = {0};
static char s_card_name[128] = {0};
-static bool s_card_loaded = false; /* card view currently holds a card */
static bool s_waiting_fetch = false;
-static app_state_t s_prev_view = APP_STATE_SCANNER;
static uint32_t s_led_off_tick = 0;
+static lv_obj_t *s_life_label = NULL;
-static const char *get_json_str(cJSON *root, const char *key, const char *fallback);
+static lv_img_dsc_t s_art_img_dsc;
+static uint16_t *s_art_pixels = NULL;
-/* ------------------------------------------------------------------ */
-/* Views */
-/* ------------------------------------------------------------------ */
+/* Menu items */
+#define MENU_ITEM_COUNT 7
+static int s_menu_idx = 0;
+static const char *s_menu_labels[MENU_ITEM_COUNT] = {
+ "1. CARD SCANNER",
+ "2. LIFE & COUNTERS",
+ "3. DICE & COIN FLIP",
+ "4. COMMANDER TAX",
+ "5. TOKEN & COUNTERS",
+ "6. DECK STORAGE (LFS)",
+ "7. DEVICE & WIFI INFO"
+};
-static void view_scanner(void)
+/* Life & Counters */
+typedef enum {
+ LIFE_MODE_TOTAL = 0,
+ LIFE_MODE_POISON,
+ LIFE_MODE_CMD_DMG,
+} life_counter_mode_t;
+
+static life_counter_mode_t s_life_mode = LIFE_MODE_TOTAL;
+static int32_t s_poison = 0;
+static int32_t s_cmd_dmg = 0;
+static bool s_was_lethal = false;
+
+/* Dice & Coin */
+typedef enum {
+ DICE_MODE_D20 = 0,
+ DICE_MODE_D6,
+ DICE_MODE_COIN,
+} dice_mode_t;
+
+static dice_mode_t s_dice_mode = DICE_MODE_D20;
+static int s_dice_result = 20;
+static bool s_dice_rolled = false;
+
+/* Commander Tax & Turn Tracker */
+static int s_cmd_casts = 0;
+static int s_turn_count = 1;
+static bool s_tax_edit_turns = false;
+
+/* Token & Counter Spawner */
+typedef struct {
+ const char *name;
+ const char *type_line;
+ const char *pt;
+ const char *oracle;
+ uint32_t color_hex;
+ int count;
+} mtg_token_t;
+
+#define TOKEN_COUNT 8
+static mtg_token_t s_tokens[TOKEN_COUNT] = {
+ {"TREASURE", "Artifact Token", "", "{T}, Sacrifice: Add one mana of any color.", 0xFFB300, 0},
+ {"FOOD", "Artifact Token", "", "{2}, {T}, Sacrifice: You gain 3 life.", 0x00E676, 0},
+ {"CLUE", "Artifact Token", "", "{2}, Sacrifice: Draw a card.", 0x00E5FF, 0},
+ {"SOLDIER", "Creature Token โ Soldier", "1 / 1", "White Soldier creature token.", 0xFFFFFF, 0},
+ {"ZOMBIE", "Creature Token โ Zombie", "2 / 2", "Black Zombie creature token.", 0xBA68C8, 0},
+ {"GOBLIN", "Creature Token โ Goblin", "1 / 1", "Red Goblin creature token.", 0xFF5252, 0},
+ {"BEAST", "Creature Token โ Beast", "3 / 3", "Green Beast creature token.", 0x66BB6A, 0},
+ {"+1/+1 COUNTER", "Stat Counter", "", "Put a +1/+1 counter on target permanent.", 0xFFD700, 0},
+};
+
+static int s_token_idx = 0;
+static bool s_token_edit_count = false;
+
+/* Storage Manager */
+static bool s_storage_confirm_dialog = false;
+static int s_storage_option_idx = 0;
+
+/* Button holding timer */
+static uint32_t s_btn_held_ms = 0;
+static bool s_btn_long_fired = false;
+
+/* Forward declarations */
+static void view_menu(void);
+static void view_scanner(void);
+static void view_card(void);
+static void view_life(void);
+static void view_dice(void);
+static void view_commander_tax(void);
+static void view_token_spawner(void);
+static void view_storage(void);
+static void view_device_info(void);
+static void view_unmapped(const char *uid);
+static void view_fetching(const char *name);
+
+static void free_art_buffer(void)
{
- display_fill(COLOR_BLACK);
- display_put_text_center(40, "SCANNER", COLOR_GRAY, COLOR_BLACK);
- display_put_text_center(70, "PLACE A CARD", COLOR_WHITE, COLOR_BLACK);
- if (s_uid[0] != '\0') {
- char line[96];
- snprintf(line, sizeof(line), "LAST UID %s", s_uid);
- display_put_text_center(100, line, COLOR_CYAN, COLOR_BLACK);
+ if (s_art_pixels != NULL) {
+ free(s_art_pixels);
+ s_art_pixels = NULL;
}
- display_put_text_center(250, "PRESS ENC: LIFETOTAL", COLOR_GRAY, COLOR_BLACK);
- display_put_text_center(264, "UNMAPPED: WEB PAIRING", COLOR_GRAY, COLOR_BLACK);
}
-static void view_card(void)
+static bool load_art_crop(const char *uid)
{
- char json_path[72];
- snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", s_uid);
+ free_art_buffer();
- char *buf = NULL;
- size_t len = 0;
- if (storage_read_text(json_path, &buf, &len) != ESP_OK || buf == NULL) {
- free(buf);
- display_fill(COLOR_BLACK);
- display_put_text_center(80, "CARD DATA MISSING", COLOR_RED, COLOR_BLACK);
- return;
- }
- cJSON *root = cJSON_Parse(buf);
- free(buf);
- if (root == NULL) {
- display_fill(COLOR_BLACK);
- display_put_text_center(80, "PARSE FAILED", COLOR_RED, COLOR_BLACK);
- return;
- }
-
- const char *name = get_json_str(root, "name", s_card_name[0] ? s_card_name : "?");
- const char *mana = get_json_str(root, "mana_cost", "");
- const char *type = get_json_str(root, "type", "");
- const char *power = get_json_str(root, "power", "");
- const char *tough = get_json_str(root, "toughness", "");
- const char *oracle = get_json_str(root, "oracle", "");
- const char *price = get_json_str(root, "price_usd", "");
-
- /* Cover art fullscreen if present, else plain black */
char jpg_path[72];
- snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", s_uid);
- if (storage_file_exists(jpg_path)) {
- display_show_jpeg_file(jpg_path);
- } else {
- display_fill(COLOR_BLACK);
+ snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid);
+ if (!storage_file_exists(jpg_path)) {
+ return false;
}
- /* Metadata strip at the bottom */
- display_fill_rect(0, DISP_H - 86, DISP_W, DISP_H, RGB565(0, 0, 0));
- display_fill_rect(0, DISP_H - 88, DISP_W, DISP_H - 86, COLOR_YELLOW);
- display_put_text(6, DISP_H - 80, name, COLOR_YELLOW, COLOR_BLACK);
-
- char line[128];
- snprintf(line, sizeof(line), "%s", type);
- display_put_text(6, DISP_H - 66, line, COLOR_WHITE, COLOR_BLACK);
-
- if (power[0] || tough[0]) {
- snprintf(line, sizeof(line), "P/T %s / %s", power, tough);
- } else {
- snprintf(line, sizeof(line), "CARD");
+ FILE *f = fopen(jpg_path, "rb");
+ if (f == NULL) {
+ return false;
}
- display_put_text(6, DISP_H - 52, line, COLOR_CYAN, COLOR_BLACK);
+ fseek(f, 0, SEEK_END);
+ long fsize = ftell(f);
+ fseek(f, 0, SEEK_SET);
- if (price[0]) {
- snprintf(line, sizeof(line), "PRICE $%s", price);
- display_put_text(6, DISP_H - 38, line, COLOR_MAGENTA, COLOR_BLACK);
- }
- if (mana[0]) {
- char m[64];
- snprintf(m, sizeof(m), "MANA %s", mana);
- display_put_text(6, DISP_H - 24, m, COLOR_GREEN, COLOR_BLACK);
+ if (fsize <= 0 || fsize > 250 * 1024) {
+ fclose(f);
+ return false;
}
- (void)oracle;
- cJSON_Delete(root);
+ uint8_t *jpg_buf = malloc((size_t)fsize);
+ if (jpg_buf == NULL) {
+ fclose(f);
+ return false;
+ }
+
+ if (fread(jpg_buf, 1, (size_t)fsize, f) != (size_t)fsize) {
+ free(jpg_buf);
+ fclose(f);
+ return false;
+ }
+ fclose(f);
+
+ esp_jpeg_image_cfg_t cfg = {
+ .indata = jpg_buf,
+ .indata_size = (uint32_t)fsize,
+ .out_format = JPEG_IMAGE_FORMAT_RGB565,
+ .out_scale = JPEG_IMAGE_SCALE_0,
+ .flags = {
+ .swap_color_bytes = 1,
+ },
+ };
+ esp_jpeg_image_output_t out_info = {0};
+ if (esp_jpeg_get_image_info(&cfg, &out_info) != ESP_OK) {
+ ESP_LOGW(TAG, "Unsupported or corrupt JPEG: %s", jpg_path);
+ free(jpg_buf);
+ remove(jpg_path);
+ return false;
+ }
+
+ if (out_info.width > 240) {
+ cfg.out_scale = (out_info.width > 480) ? JPEG_IMAGE_SCALE_1_4 : JPEG_IMAGE_SCALE_1_2;
+ esp_jpeg_get_image_info(&cfg, &out_info);
+ }
+
+ s_art_pixels = malloc(out_info.output_len);
+ if (s_art_pixels == NULL) {
+ ESP_LOGE(TAG, "Failed to allocate %d bytes for art crop", (int)out_info.output_len);
+ free(jpg_buf);
+ return false;
+ }
+
+ cfg.outbuf = (uint8_t *)s_art_pixels;
+ cfg.outbuf_size = (uint32_t)out_info.output_len;
+
+ esp_err_t err = esp_jpeg_decode(&cfg, &out_info);
+ free(jpg_buf);
+
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG, "Decode failed for %s, removing", jpg_path);
+ remove(jpg_path);
+ free_art_buffer();
+ return false;
+ }
+
+ s_art_img_dsc.header.always_zero = 0;
+ s_art_img_dsc.header.w = out_info.width;
+ s_art_img_dsc.header.h = out_info.height;
+ s_art_img_dsc.data_size = out_info.output_len;
+ s_art_img_dsc.header.cf = LV_IMG_CF_TRUE_COLOR;
+ s_art_img_dsc.data = (const uint8_t *)s_art_pixels;
+
+ ESP_LOGI(TAG, "Decoded art crop: %dx%d (%d bytes)", out_info.width, out_info.height, (int)out_info.output_len);
+ return true;
}
static const char *get_json_str(cJSON *root, const char *key, const char *fallback)
@@ -138,43 +234,1111 @@ static const char *get_json_str(cJSON *root, const char *key, const char *fallba
return fallback;
}
+/* ------------------------------------------------------------------ */
+/* LVGL Screens */
+/* ------------------------------------------------------------------ */
+
+static void ui_clean_screen(void)
+{
+ free_art_buffer();
+ s_life_label = NULL;
+ lv_obj_clean(lv_scr_act());
+ lv_obj_set_style_bg_color(lv_scr_act(), lv_color_black(), 0);
+ lv_obj_set_style_bg_opa(lv_scr_act(), LV_OPA_COVER, 0);
+}
+
+/* 1. Main Menu */
+static void view_menu(void)
+{
+ encoder_set_steps_per_detent(4);
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ /* Top yellow accent bar */
+ lv_obj_t *bar = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(bar, DISP_W, 4);
+ lv_obj_set_style_bg_color(bar, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(bar, 0, 0);
+ lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
+
+ /* Title */
+ lv_obj_t *title = lv_label_create(lv_scr_act());
+ lv_label_set_text(title, "MTG COMPANION");
+ lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
+
+ /* Subtitle */
+ lv_obj_t *sub = lv_label_create(lv_scr_act());
+ lv_label_set_text(sub, "- MAIN MENU -");
+ lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 34);
+
+ /* 7 Menu Items */
+ int start_y = 52;
+ for (int i = 0; i < MENU_ITEM_COUNT; i++) {
+ bool selected = (i == s_menu_idx);
+ lv_obj_t *item_box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(item_box, DISP_W - 24, 31);
+ lv_obj_align(item_box, LV_ALIGN_TOP_MID, 0, start_y + i * 34);
+ lv_obj_set_style_radius(item_box, 6, 0);
+ lv_obj_set_style_pad_all(item_box, 2, 0);
+ lv_obj_set_scrollbar_mode(item_box, LV_SCROLLBAR_MODE_OFF);
+
+ if (selected) {
+ lv_obj_set_style_bg_color(item_box, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_color(item_box, lv_color_white(), 0);
+ lv_obj_set_style_border_width(item_box, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(item_box, lv_color_hex(0x1C1C1C), 0);
+ lv_obj_set_style_border_color(item_box, lv_color_hex(0x333333), 0);
+ lv_obj_set_style_border_width(item_box, 1, 0);
+ }
+
+ lv_obj_t *lbl = lv_label_create(item_box);
+ if (selected) {
+ lv_label_set_text_fmt(lbl, "> %s <", s_menu_labels[i]);
+ lv_obj_set_style_text_color(lbl, lv_color_black(), 0);
+ } else {
+ lv_label_set_text_fmt(lbl, " %s", s_menu_labels[i]);
+ lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
+ }
+ lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0);
+ lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0);
+ }
+
+ /* Bottom navigation hint */
+ lv_obj_t *hint = lv_label_create(lv_scr_act());
+ lv_label_set_text(hint, "ROTATE: NAV โข CLICK: SELECT");
+ lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -6);
+
+ lvgl_port_unlock();
+}
+
+/* 2. Card Scanner */
+static void view_scanner(void)
+{
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ lv_obj_t *title = lv_label_create(lv_scr_act());
+ lv_label_set_text(title, "CARD SCANNER");
+ lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 25);
+
+ lv_obj_t *box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(box, DISP_W - 32, 140);
+ lv_obj_align(box, LV_ALIGN_CENTER, 0, -15);
+ lv_obj_set_style_bg_color(box, lv_color_hex(0x181818), 0);
+ lv_obj_set_style_border_color(box, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(box, 2, 0);
+ lv_obj_set_style_radius(box, 10, 0);
+
+ lv_obj_t *sub = lv_label_create(box);
+ lv_label_set_text(sub, "TAP RFID CARD\nTO SCAN");
+ lv_obj_set_style_text_font(sub, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(sub, lv_color_white(), 0);
+ lv_obj_set_style_text_align(sub, LV_TEXT_ALIGN_CENTER, 0);
+ lv_obj_align(sub, LV_ALIGN_CENTER, 0, 0);
+
+ if (s_uid[0] != '\0') {
+ lv_obj_t *uid_lbl = lv_label_create(lv_scr_act());
+ lv_label_set_text_fmt(uid_lbl, "LAST SCANNED: %s", s_uid);
+ lv_obj_set_style_text_font(uid_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(uid_lbl, lv_color_hex(0x00E5FF), 0);
+ lv_obj_align(uid_lbl, LV_ALIGN_BOTTOM_MID, 0, -50);
+ }
+
+ lv_obj_t *hint1 = lv_label_create(lv_scr_act());
+ lv_label_set_text(hint1, "HOLD BUTTON: MAIN MENU");
+ lv_obj_set_style_text_font(hint1, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint1, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint1, LV_ALIGN_BOTTOM_MID, 0, -28);
+
+ lv_obj_t *hint2 = lv_label_create(lv_scr_act());
+ lv_label_set_text(hint2, "CLICK BUTTON: LIFE TOTAL");
+ lv_obj_set_style_text_font(hint2, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint2, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint2, LV_ALIGN_BOTTOM_MID, 0, -10);
+
+ lvgl_port_unlock();
+}
+
+/* 3. Life & Counters */
static void view_life(void)
{
- display_fill(COLOR_BLACK);
- display_fill_rect(0, 0, DISP_W, 4, COLOR_YELLOW);
- display_put_text_center(10, "LIFETOTAL", COLOR_GRAY, COLOR_BLACK);
+ lvgl_port_lock(0);
+ ui_clean_screen();
- int32_t life = app_get_life();
- char buf[16];
- snprintf(buf, sizeof(buf), "%ld", (long)(life < 0 ? 0 : life));
+ /* Top yellow accent bar */
+ lv_obj_t *bar = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(bar, DISP_W, 4);
+ lv_obj_set_style_bg_color(bar, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(bar, 0, 0);
+ lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
- int scale = 6;
- int text_w = ((int)strlen(buf) * (FONT5X7_W + 1) - 1) * scale;
- int x = (DISP_W - text_w) / 2;
- if (x < 0) x = 0;
- uint16_t fg = life < 10 ? COLOR_RED : life <= 15 ? COLOR_YELLOW : COLOR_GREEN;
- display_draw_big_text(x, 90, buf, scale, fg, COLOR_BLACK);
+ /* Counter Selector Bar */
+ const char *modes[3] = {"LIFE", "POISON", "CMD"};
+ int tab_w = (DISP_W - 24) / 3;
+ for (int i = 0; i < 3; i++) {
+ bool active = (i == (int)s_life_mode);
+ lv_obj_t *tbox = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(tbox, tab_w, 30);
+ lv_obj_align(tbox, LV_ALIGN_TOP_LEFT, 12 + i * tab_w, 10);
+ lv_obj_set_style_radius(tbox, 4, 0);
+ if (active) {
+ lv_obj_set_style_bg_color(tbox, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_color(tbox, lv_color_white(), 0);
+ lv_obj_set_style_border_width(tbox, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(tbox, lv_color_hex(0x202020), 0);
+ lv_obj_set_style_border_color(tbox, lv_color_hex(0x333333), 0);
+ lv_obj_set_style_border_width(tbox, 1, 0);
+ }
+ lv_obj_t *tlbl = lv_label_create(tbox);
+ lv_label_set_text(tlbl, modes[i]);
+ lv_obj_set_style_text_font(tlbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(tlbl, active ? lv_color_black() : lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(tlbl, LV_ALIGN_CENTER, 0, 0);
+ }
- display_put_text_center(240, "ROTATE: ADJUST", COLOR_GRAY, COLOR_BLACK);
- char preset[48];
- snprintf(preset, sizeof(preset), "PRESET %ld (PRESS RESETS)", (long)app_get_life_preset());
- display_put_text_center(256, preset, COLOR_GRAY, COLOR_BLACK);
- display_put_text_center(272, "PRESS AGAIN: CARD VIEW", COLOR_GRAY, COLOR_BLACK);
+ /* Main Big Number Display */
+ s_life_label = lv_label_create(lv_scr_act());
+ lv_obj_set_style_text_font(s_life_label, &lv_font_montserrat_48, 0);
+ lv_obj_align(s_life_label, LV_ALIGN_CENTER, 0, -20);
+
+ bool lethal = false;
+ if (s_life_mode == LIFE_MODE_TOTAL) {
+ int32_t life = app_get_life();
+ lv_label_set_text_fmt(s_life_label, "%ld", (long)life);
+ lv_color_t color = (life <= 0) ? lv_color_hex(0xFF1744) :
+ (life <= 10) ? lv_color_hex(0xFF9100) :
+ (life <= 15) ? lv_color_hex(0xFFCC00) :
+ lv_color_hex(0x00E676);
+ lv_obj_set_style_text_color(s_life_label, color, 0);
+ if (life <= 0) lethal = true;
+ } else if (s_life_mode == LIFE_MODE_POISON) {
+ lv_label_set_text_fmt(s_life_label, "%ld / 10", (long)s_poison);
+ lv_color_t color = (s_poison >= 10) ? lv_color_hex(0xFF1744) :
+ (s_poison >= 6) ? lv_color_hex(0xFF9100) :
+ lv_color_hex(0x00E5FF);
+ lv_obj_set_style_text_color(s_life_label, color, 0);
+ if (s_poison >= 10) lethal = true;
+ } else {
+ lv_label_set_text_fmt(s_life_label, "%ld / 21", (long)s_cmd_dmg);
+ lv_color_t color = (s_cmd_dmg >= 21) ? lv_color_hex(0xFF1744) :
+ (s_cmd_dmg >= 15) ? lv_color_hex(0xFF9100) :
+ lv_color_hex(0xE040FB);
+ lv_obj_set_style_text_color(s_life_label, color, 0);
+ if (s_cmd_dmg >= 21) lethal = true;
+ }
+
+ if (lethal) {
+ if (!s_was_lethal) {
+ s_was_lethal = true;
+ buzzer_play_sound(BUZZER_SOUND_LOSS);
+ led_manager_set_color(255, 0, 0);
+ s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS);
+ }
+ lv_obj_t *dead_lbl = lv_label_create(lv_scr_act());
+ lv_label_set_text(dead_lbl, "*** LETHAL / DEFEAT ***");
+ lv_obj_set_style_text_font(dead_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(dead_lbl, lv_color_hex(0xFF1744), 0);
+ lv_obj_align(dead_lbl, LV_ALIGN_CENTER, 0, 30);
+ } else {
+ s_was_lethal = false;
+ }
+
+ /* Subtitle details */
+ lv_obj_t *preset_lbl = lv_label_create(lv_scr_act());
+ if (s_life_mode == LIFE_MODE_TOTAL) {
+ lv_label_set_text_fmt(preset_lbl, "PRESET: %ld (CLICK: CYCLE)", (long)app_get_life_preset());
+ } else if (s_life_mode == LIFE_MODE_POISON) {
+ lv_label_set_text(preset_lbl, "10 POISON COUNTERS = LOSS");
+ } else {
+ lv_label_set_text(preset_lbl, "21 COMBAT DAMAGE = LOSS");
+ }
+ lv_obj_set_style_text_font(preset_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(preset_lbl, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(preset_lbl, LV_ALIGN_BOTTOM_MID, 0, -35);
+
+ lv_obj_t *hint = lv_label_create(lv_scr_act());
+ lv_label_set_text(hint, "ROTATE: +/- โข HOLD: MENU");
+ lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -10);
+
+ lvgl_port_unlock();
+}
+
+/* 4. Dice & Coin Flip */
+static void view_dice(void)
+{
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ /* Top yellow accent bar */
+ lv_obj_t *bar = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(bar, DISP_W, 4);
+ lv_obj_set_style_bg_color(bar, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(bar, 0, 0);
+ lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
+
+ /* Selector tabs */
+ const char *tabs[3] = {"[ D20 ]", "[ D6 ]", "[ COIN ]"};
+ int tab_w = (DISP_W - 24) / 3;
+ for (int i = 0; i < 3; i++) {
+ bool active = (i == (int)s_dice_mode);
+ lv_obj_t *tbox = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(tbox, tab_w, 32);
+ lv_obj_align(tbox, LV_ALIGN_TOP_LEFT, 12 + i * tab_w, 12);
+ lv_obj_set_style_radius(tbox, 4, 0);
+ if (active) {
+ lv_obj_set_style_bg_color(tbox, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_color(tbox, lv_color_white(), 0);
+ lv_obj_set_style_border_width(tbox, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(tbox, lv_color_hex(0x222222), 0);
+ lv_obj_set_style_border_color(tbox, lv_color_hex(0x444444), 0);
+ lv_obj_set_style_border_width(tbox, 1, 0);
+ }
+ lv_obj_t *tlbl = lv_label_create(tbox);
+ lv_label_set_text(tlbl, tabs[i]);
+ lv_obj_set_style_text_font(tlbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(tlbl, active ? lv_color_black() : lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(tlbl, LV_ALIGN_CENTER, 0, 0);
+ }
+
+ /* Result Container Box */
+ lv_obj_t *rbox = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(rbox, DISP_W - 24, 180);
+ lv_obj_align(rbox, LV_ALIGN_CENTER, 0, -10);
+ lv_obj_set_style_bg_color(rbox, lv_color_hex(0x181818), 0);
+ lv_obj_set_style_border_color(rbox, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(rbox, 2, 0);
+ lv_obj_set_style_radius(rbox, 12, 0);
+
+ lv_obj_t *res_lbl = lv_label_create(rbox);
+ lv_obj_set_style_text_font(res_lbl, &lv_font_montserrat_48, 0);
+
+ if (s_dice_mode == DICE_MODE_COIN) {
+ if (!s_dice_rolled) {
+ lv_label_set_text(res_lbl, "?");
+ lv_obj_set_style_text_color(res_lbl, lv_color_hex(COLOR_AMBER), 0);
+ } else if (s_dice_result == 1) {
+ lv_label_set_text(res_lbl, "HEADS");
+ lv_obj_set_style_text_font(res_lbl, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(res_lbl, lv_color_hex(0x00E676), 0);
+ } else {
+ lv_label_set_text(res_lbl, "TAILS");
+ lv_obj_set_style_text_font(res_lbl, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(res_lbl, lv_color_hex(0xFF9100), 0);
+ }
+ } else {
+ if (!s_dice_rolled) {
+ lv_label_set_text(res_lbl, s_dice_mode == DICE_MODE_D20 ? "D20" : "D6");
+ lv_obj_set_style_text_font(res_lbl, &lv_font_montserrat_48, 0);
+ lv_obj_set_style_text_color(res_lbl, lv_color_hex(COLOR_AMBER), 0);
+ } else {
+ lv_label_set_text_fmt(res_lbl, "%d", s_dice_result);
+ if (s_dice_mode == DICE_MODE_D20 && s_dice_result == 20) {
+ lv_obj_set_style_text_color(res_lbl, lv_color_hex(0x00E676), 0);
+ } else if (s_dice_mode == DICE_MODE_D20 && s_dice_result == 1) {
+ lv_obj_set_style_text_color(res_lbl, lv_color_hex(0xFF1744), 0);
+ } else {
+ lv_obj_set_style_text_color(res_lbl, lv_color_hex(0x00E5FF), 0);
+ }
+ }
+ }
+ lv_obj_align(res_lbl, LV_ALIGN_CENTER, 0, 0);
+
+ /* Status descriptor under result */
+ if (s_dice_rolled && s_dice_mode == DICE_MODE_D20) {
+ lv_obj_t *sub = lv_label_create(rbox);
+ if (s_dice_result == 20) {
+ lv_label_set_text(sub, "NATURAL 20!");
+ lv_obj_set_style_text_color(sub, lv_color_hex(0x00E676), 0);
+ } else if (s_dice_result == 1) {
+ lv_label_set_text(sub, "CRITICAL MISS!");
+ lv_obj_set_style_text_color(sub, lv_color_hex(0xFF1744), 0);
+ } else {
+ lv_label_set_text(sub, "ROLL RESULT");
+ lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_AMBER), 0);
+ }
+ lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0);
+ lv_obj_align(sub, LV_ALIGN_BOTTOM_MID, 0, -10);
+ }
+
+ /* Bottom prompt */
+ lv_obj_t *hint = lv_label_create(lv_scr_act());
+ lv_label_set_text(hint, "ROTATE: DIE โข CLICK: ROLL");
+ lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
+
+ lvgl_port_unlock();
+}
+
+static void roll_current_dice(void)
+{
+ /* Quick 3-tick animation */
+ for (int i = 0; i < 3; i++) {
+ buzzer_chirp(700 + i * 150, 20);
+ vTaskDelay(pdMS_TO_TICKS(40));
+ }
+
+ if (s_dice_mode == DICE_MODE_D20) {
+ s_dice_result = (int)(esp_random() % 20) + 1;
+ if (s_dice_result == 20) {
+ /* Natural 20 - Victory Fanfare! */
+ led_manager_set_color(0, 255, 0);
+ buzzer_play_sound(BUZZER_SOUND_VICTORY);
+ } else if (s_dice_result == 1) {
+ /* Critical Fail */
+ led_manager_set_color(255, 0, 0);
+ buzzer_chirp(220, 150);
+ } else {
+ led_manager_set_color(0, 180, 255);
+ buzzer_chirp(880, 50);
+ }
+ } else if (s_dice_mode == DICE_MODE_D6) {
+ s_dice_result = (int)(esp_random() % 6) + 1;
+ led_manager_set_color(0, 180, 255);
+ buzzer_chirp(880, 50);
+ } else {
+ /* Coin */
+ s_dice_result = (int)(esp_random() % 2); // 0 = Tails, 1 = Heads
+ if (s_dice_result == 1) {
+ led_manager_set_color(0, 255, 60); // Green for Heads
+ } else {
+ led_manager_set_color(255, 140, 0); // Amber for Tails
+ }
+ buzzer_chirp(988, 60);
+ }
+ s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(2000);
+ s_dice_rolled = true;
+ view_dice();
+}
+
+/* 5. Commander Tax & Turn Tracker */
+static void view_commander_tax(void)
+{
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ /* Top yellow accent bar */
+ lv_obj_t *bar = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(bar, DISP_W, 4);
+ lv_obj_set_style_bg_color(bar, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(bar, 0, 0);
+ lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
+
+ lv_obj_t *title = lv_label_create(lv_scr_act());
+ lv_label_set_text(title, "COMMANDER TAX");
+ lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 15);
+
+ /* Casts Box */
+ lv_obj_t *cast_box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(cast_box, DISP_W - 24, 100);
+ lv_obj_align(cast_box, LV_ALIGN_TOP_MID, 0, 48);
+ lv_obj_set_style_radius(cast_box, 8, 0);
+ if (!s_tax_edit_turns) {
+ lv_obj_set_style_bg_color(cast_box, lv_color_hex(0x282410), 0);
+ lv_obj_set_style_border_color(cast_box, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(cast_box, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(cast_box, lv_color_hex(0x181818), 0);
+ lv_obj_set_style_border_color(cast_box, lv_color_hex(0x333333), 0);
+ lv_obj_set_style_border_width(cast_box, 1, 0);
+ }
+
+ lv_obj_t *c_lbl = lv_label_create(cast_box);
+ lv_label_set_text(c_lbl, !s_tax_edit_turns ? "> CASTS FROM ZONE <" : "CASTS FROM ZONE");
+ lv_obj_set_style_text_font(c_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(c_lbl, !s_tax_edit_turns ? lv_color_hex(0xFFCC00) : lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(c_lbl, LV_ALIGN_TOP_MID, 0, 0);
+
+ lv_obj_t *c_val = lv_label_create(cast_box);
+ lv_label_set_text_fmt(c_val, "%d", s_cmd_casts);
+ lv_obj_set_style_text_font(c_val, &lv_font_montserrat_48, 0);
+ lv_obj_set_style_text_color(c_val, lv_color_white(), 0);
+ lv_obj_align(c_val, LV_ALIGN_CENTER, 0, 10);
+
+ /* Huge Tax Banner */
+ lv_obj_t *tax_box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(tax_box, DISP_W - 24, 52);
+ lv_obj_align(tax_box, LV_ALIGN_TOP_MID, 0, 156);
+ lv_obj_set_style_bg_color(tax_box, lv_color_hex(0x332800), 0);
+ lv_obj_set_style_border_color(tax_box, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(tax_box, 2, 0);
+ lv_obj_set_style_radius(tax_box, 6, 0);
+
+ lv_obj_t *tax_val = lv_label_create(tax_box);
+ lv_label_set_text_fmt(tax_val, "TAX: +{%d} MANA", s_cmd_casts * 2);
+ lv_obj_set_style_text_font(tax_val, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(tax_val, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(tax_val, LV_ALIGN_CENTER, 0, 0);
+
+ /* Turn Box */
+ lv_obj_t *turn_box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(turn_box, DISP_W - 24, 55);
+ lv_obj_align(turn_box, LV_ALIGN_TOP_MID, 0, 216);
+ lv_obj_set_style_radius(turn_box, 6, 0);
+ if (s_tax_edit_turns) {
+ lv_obj_set_style_bg_color(turn_box, lv_color_hex(0x102528), 0);
+ lv_obj_set_style_border_color(turn_box, lv_color_hex(0x00E5FF), 0);
+ lv_obj_set_style_border_width(turn_box, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(turn_box, lv_color_hex(0x181818), 0);
+ lv_obj_set_style_border_color(turn_box, lv_color_hex(0x333333), 0);
+ lv_obj_set_style_border_width(turn_box, 1, 0);
+ }
+
+ lv_obj_t *t_val = lv_label_create(turn_box);
+ lv_label_set_text_fmt(t_val, s_tax_edit_turns ? "> TURN: %d <" : "TURN: %d", s_turn_count);
+ lv_obj_set_style_text_font(t_val, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(t_val, s_tax_edit_turns ? lv_color_hex(0x00E5FF) : lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(t_val, LV_ALIGN_CENTER, 0, 0);
+
+ /* Bottom prompt */
+ lv_obj_t *hint = lv_label_create(lv_scr_act());
+ lv_label_set_text(hint, "CLICK: SWITCH โข ROTATE: +/-");
+ lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
+
+ lvgl_port_unlock();
+}
+
+/* 5. Token & Counter Spawner */
+static void view_token_spawner(void)
+{
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ /* Top yellow accent bar */
+ lv_obj_t *bar = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(bar, DISP_W, 4);
+ lv_obj_set_style_bg_color(bar, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(bar, 0, 0);
+ lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
+
+ /* Title */
+ lv_obj_t *title = lv_label_create(lv_scr_act());
+ lv_label_set_text(title, "TOKEN SPAWNER");
+ lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
+
+ /* Carousel indicator: < 1 OF 8 > */
+ lv_obj_t *idx_lbl = lv_label_create(lv_scr_act());
+ lv_label_set_text_fmt(idx_lbl, "< %d OF %d >", s_token_idx + 1, TOKEN_COUNT);
+ lv_obj_set_style_text_font(idx_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(idx_lbl, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(idx_lbl, LV_ALIGN_TOP_MID, 0, 34);
+
+ mtg_token_t *tok = &s_tokens[s_token_idx];
+
+ /* Token Card Display Box */
+ lv_obj_t *card_box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(card_box, DISP_W - 24, 155);
+ lv_obj_align(card_box, LV_ALIGN_TOP_MID, 0, 54);
+ lv_obj_set_style_bg_color(card_box, lv_color_hex(0x181818), 0);
+ lv_obj_set_style_border_color(card_box, lv_color_hex(tok->color_hex), 0);
+ lv_obj_set_style_border_width(card_box, 2, 0);
+ lv_obj_set_style_radius(card_box, 8, 0);
+ lv_obj_set_style_pad_all(card_box, 8, 0);
+ lv_obj_set_scrollbar_mode(card_box, LV_SCROLLBAR_MODE_OFF);
+
+ /* Token Name */
+ lv_obj_t *name_lbl = lv_label_create(card_box);
+ lv_label_set_text(name_lbl, tok->name);
+ lv_obj_set_style_text_font(name_lbl, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(name_lbl, lv_color_hex(tok->color_hex), 0);
+ lv_obj_align(name_lbl, LV_ALIGN_TOP_LEFT, 0, 0);
+
+ /* Token Type Line */
+ lv_obj_t *type_lbl = lv_label_create(card_box);
+ lv_label_set_text(type_lbl, tok->type_line);
+ lv_obj_set_style_text_font(type_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(type_lbl, lv_color_hex(0x00E5FF), 0);
+ lv_obj_align(type_lbl, LV_ALIGN_TOP_LEFT, 0, 26);
+
+ /* Rules / Oracle text */
+ lv_obj_t *desc = lv_label_create(card_box);
+ lv_label_set_text(desc, tok->oracle);
+ lv_label_set_long_mode(desc, LV_LABEL_LONG_WRAP);
+ lv_obj_set_width(desc, DISP_W - 44);
+ lv_obj_set_style_text_font(desc, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(desc, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(desc, LV_ALIGN_TOP_LEFT, 0, 48);
+
+ /* P/T Badge if creature token */
+ if (tok->pt[0] != '\0') {
+ lv_obj_t *pt_box = lv_obj_create(card_box);
+ lv_obj_set_size(pt_box, 65, 30);
+ lv_obj_align(pt_box, LV_ALIGN_BOTTOM_RIGHT, 0, 0);
+ lv_obj_set_style_bg_color(pt_box, lv_color_hex(0x282828), 0);
+ lv_obj_set_style_border_color(pt_box, lv_color_hex(tok->color_hex), 0);
+ lv_obj_set_style_border_width(pt_box, 1, 0);
+ lv_obj_set_style_radius(pt_box, 4, 0);
+ lv_obj_set_style_pad_all(pt_box, 0, 0);
+
+ lv_obj_t *pt_lbl = lv_label_create(pt_box);
+ lv_label_set_text(pt_lbl, tok->pt);
+ lv_obj_set_style_text_font(pt_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(pt_lbl, lv_color_white(), 0);
+ lv_obj_align(pt_lbl, LV_ALIGN_CENTER, 0, 0);
+ }
+
+ /* Quantity Box */
+ lv_obj_t *q_box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(q_box, DISP_W - 24, 52);
+ lv_obj_align(q_box, LV_ALIGN_TOP_MID, 0, 218);
+ lv_obj_set_style_radius(q_box, 6, 0);
+ if (s_token_edit_count) {
+ lv_obj_set_style_bg_color(q_box, lv_color_hex(0x282410), 0);
+ lv_obj_set_style_border_color(q_box, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(q_box, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(q_box, lv_color_hex(0x181818), 0);
+ lv_obj_set_style_border_color(q_box, lv_color_hex(0x333333), 0);
+ lv_obj_set_style_border_width(q_box, 1, 0);
+ }
+
+ lv_obj_t *q_lbl = lv_label_create(q_box);
+ if (s_token_edit_count) {
+ lv_label_set_text_fmt(q_lbl, "> COUNT: %d <", tok->count);
+ lv_obj_set_style_text_color(q_lbl, lv_color_hex(0xFFCC00), 0);
+ } else {
+ lv_label_set_text_fmt(q_lbl, "COUNT: %d", tok->count);
+ lv_obj_set_style_text_color(q_lbl, tok->count > 0 ? lv_color_white() : lv_color_hex(COLOR_AMBER), 0);
+ }
+ lv_obj_set_style_text_font(q_lbl, &lv_font_montserrat_20, 0);
+ lv_obj_align(q_lbl, LV_ALIGN_CENTER, 0, 0);
+
+ /* Bottom Prompt */
+ lv_obj_t *hint = lv_label_create(lv_scr_act());
+ if (s_token_edit_count) {
+ lv_label_set_text(hint, "TURN: +/- โข CLICK: DONE");
+ } else {
+ lv_label_set_text(hint, "TURN: PICK โข CLICK: COUNT");
+ }
+ lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
+
+ lvgl_port_unlock();
+}
+
+/* 6. Deck & Storage Manager */
+static void view_storage(void)
+{
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ /* Top yellow accent bar */
+ lv_obj_t *bar = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(bar, DISP_W, 4);
+ lv_obj_set_style_bg_color(bar, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(bar, 0, 0);
+ lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
+
+ lv_obj_t *title = lv_label_create(lv_scr_act());
+ lv_label_set_text(title, "DECK & STORAGE");
+ lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 12);
+
+ size_t total_b = 0, used_b = 0;
+ storage_manager_get_info(&total_b, &used_b);
+ int json_c = 0, jpg_c = 0;
+ storage_manager_count_cached(&json_c, &jpg_c);
+
+ if (s_storage_confirm_dialog) {
+ /* Confirmation dialog */
+ lv_obj_t *warn_box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(warn_box, DISP_W - 24, 210);
+ lv_obj_align(warn_box, LV_ALIGN_TOP_MID, 0, 48);
+ lv_obj_set_style_bg_color(warn_box, lv_color_hex(0x281010), 0);
+ lv_obj_set_style_border_color(warn_box, lv_color_hex(0xFF1744), 0);
+ lv_obj_set_style_border_width(warn_box, 2, 0);
+ lv_obj_set_style_radius(warn_box, 8, 0);
+
+ lv_obj_t *wtitle = lv_label_create(warn_box);
+ lv_label_set_text(wtitle, "CLEAR ALL CARDS?");
+ lv_obj_set_style_text_font(wtitle, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(wtitle, lv_color_hex(0xFF1744), 0);
+ lv_obj_align(wtitle, LV_ALIGN_TOP_MID, 0, 8);
+
+ lv_obj_t *wdesc = lv_label_create(warn_box);
+ lv_label_set_text(wdesc, "This wipes all cached\ncard art & data for\na fresh new deck.");
+ lv_obj_set_style_text_font(wdesc, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(wdesc, lv_color_white(), 0);
+ lv_obj_align(wdesc, LV_ALIGN_TOP_MID, 0, 38);
+
+ /* Confirm button */
+ lv_obj_t *c_btn = lv_obj_create(warn_box);
+ lv_obj_set_size(c_btn, DISP_W - 56, 38);
+ lv_obj_align(c_btn, LV_ALIGN_TOP_MID, 0, 95);
+ lv_obj_set_style_radius(c_btn, 4, 0);
+ if (s_storage_option_idx == 0) {
+ lv_obj_set_style_bg_color(c_btn, lv_color_hex(0xFF1744), 0);
+ lv_obj_set_style_border_color(c_btn, lv_color_white(), 0);
+ lv_obj_set_style_border_width(c_btn, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(c_btn, lv_color_hex(0x401010), 0);
+ lv_obj_set_style_border_width(c_btn, 0, 0);
+ }
+ lv_obj_t *c_lbl = lv_label_create(c_btn);
+ lv_label_set_text(c_lbl, s_storage_option_idx == 0 ? "> CONFIRM WIPE <" : "CONFIRM WIPE");
+ lv_obj_set_style_text_font(c_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(c_lbl, lv_color_white(), 0);
+ lv_obj_align(c_lbl, LV_ALIGN_CENTER, 0, 0);
+
+ /* Cancel button */
+ lv_obj_t *cancel_btn = lv_obj_create(warn_box);
+ lv_obj_set_size(cancel_btn, DISP_W - 56, 38);
+ lv_obj_align(cancel_btn, LV_ALIGN_TOP_MID, 0, 142);
+ lv_obj_set_style_radius(cancel_btn, 4, 0);
+ if (s_storage_option_idx == 1) {
+ lv_obj_set_style_bg_color(cancel_btn, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_color(cancel_btn, lv_color_white(), 0);
+ lv_obj_set_style_border_width(cancel_btn, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(cancel_btn, lv_color_hex(0x303030), 0);
+ lv_obj_set_style_border_width(cancel_btn, 0, 0);
+ }
+ lv_obj_t *cancel_lbl = lv_label_create(cancel_btn);
+ lv_label_set_text(cancel_lbl, s_storage_option_idx == 1 ? "> CANCEL <" : "CANCEL");
+ lv_obj_set_style_text_font(cancel_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(cancel_lbl, s_storage_option_idx == 1 ? lv_color_black() : lv_color_white(), 0);
+ lv_obj_align(cancel_lbl, LV_ALIGN_CENTER, 0, 0);
+
+ } else {
+ /* Status Card */
+ lv_obj_t *stat_box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(stat_box, DISP_W - 24, 136);
+ lv_obj_align(stat_box, LV_ALIGN_TOP_MID, 0, 44);
+ lv_obj_set_style_bg_color(stat_box, lv_color_hex(0x181818), 0);
+ lv_obj_set_style_border_color(stat_box, lv_color_hex(0x333333), 0);
+ lv_obj_set_style_border_width(stat_box, 1, 0);
+ lv_obj_set_style_radius(stat_box, 6, 0);
+ lv_obj_set_style_pad_all(stat_box, 8, 0);
+ lv_obj_set_scrollbar_mode(stat_box, LV_SCROLLBAR_MODE_OFF);
+
+ lv_obj_t *key_lbl = lv_label_create(stat_box);
+ lv_obj_set_width(key_lbl, 125);
+ lv_obj_set_style_text_font(key_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(key_lbl, lv_color_hex(0x00E5FF), 0);
+ lv_label_set_text(key_lbl,
+ "CACHED CARDS:\n"
+ "ART CROPS:\n\n"
+ "STORAGE USED:\n"
+ "PERCENT USED:\n"
+ "DECK CAPACITY:");
+ lv_obj_align(key_lbl, LV_ALIGN_TOP_LEFT, 0, 0);
+
+ lv_obj_t *val_lbl = lv_label_create(stat_box);
+ lv_obj_set_width(val_lbl, 70);
+ lv_obj_set_style_text_font(val_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(val_lbl, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_set_style_text_align(val_lbl, LV_TEXT_ALIGN_RIGHT, 0);
+ int percent = total_b > 0 ? (int)((used_b * 100) / total_b) : 0;
+ lv_label_set_text_fmt(val_lbl,
+ "%d\n"
+ "%d\n\n"
+ "%d KB\n"
+ "%d%%\n"
+ "~120-140",
+ json_c, jpg_c,
+ (int)(used_b / 1024),
+ percent);
+ lv_obj_align(val_lbl, LV_ALIGN_TOP_RIGHT, 0, 0);
+
+ /* Action 0: Clear Cards Button */
+ lv_obj_t *btn0 = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(btn0, DISP_W - 24, 42);
+ lv_obj_align(btn0, LV_ALIGN_TOP_MID, 0, 192);
+ lv_obj_set_style_radius(btn0, 6, 0);
+ if (s_storage_option_idx == 0) {
+ lv_obj_set_style_bg_color(btn0, lv_color_hex(0xFF1744), 0);
+ lv_obj_set_style_border_color(btn0, lv_color_white(), 0);
+ lv_obj_set_style_border_width(btn0, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(btn0, lv_color_hex(0x301515), 0);
+ lv_obj_set_style_border_color(btn0, lv_color_hex(0x502020), 0);
+ lv_obj_set_style_border_width(btn0, 1, 0);
+ }
+ lv_obj_t *b0_lbl = lv_label_create(btn0);
+ lv_label_set_text(b0_lbl, s_storage_option_idx == 0 ? "> CLEAR FOR NEW DECK <" : "CLEAR FOR NEW DECK");
+ lv_obj_set_style_text_font(b0_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(b0_lbl, lv_color_white(), 0);
+ lv_obj_align(b0_lbl, LV_ALIGN_CENTER, 0, 0);
+
+ /* Action 1: Back to Menu Button */
+ lv_obj_t *btn1 = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(btn1, DISP_W - 24, 38);
+ lv_obj_align(btn1, LV_ALIGN_TOP_MID, 0, 242);
+ lv_obj_set_style_radius(btn1, 6, 0);
+ if (s_storage_option_idx == 1) {
+ lv_obj_set_style_bg_color(btn1, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_color(btn1, lv_color_white(), 0);
+ lv_obj_set_style_border_width(btn1, 2, 0);
+ } else {
+ lv_obj_set_style_bg_color(btn1, lv_color_hex(0x202020), 0);
+ lv_obj_set_style_border_color(btn1, lv_color_hex(0x333333), 0);
+ lv_obj_set_style_border_width(btn1, 1, 0);
+ }
+ lv_obj_t *b1_lbl = lv_label_create(btn1);
+ lv_label_set_text(b1_lbl, s_storage_option_idx == 1 ? "> BACK TO MENU <" : "BACK TO MENU");
+ lv_obj_set_style_text_font(b1_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(b1_lbl, s_storage_option_idx == 1 ? lv_color_black() : lv_color_white(), 0);
+ lv_obj_align(b1_lbl, LV_ALIGN_CENTER, 0, 0);
+ }
+
+ lv_obj_t *hint = lv_label_create(lv_scr_act());
+ lv_label_set_text(hint, "ROTATE: OPTION โข CLICK: SELECT");
+ lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
+
+ lvgl_port_unlock();
+}
+
+/* 7. Device & Wi-Fi Info */
+static void view_device_info(void)
+{
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ /* Top yellow accent bar */
+ lv_obj_t *bar = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(bar, DISP_W, 4);
+ lv_obj_set_style_bg_color(bar, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(bar, 0, 0);
+ lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
+
+ lv_obj_t *title = lv_label_create(lv_scr_act());
+ lv_label_set_text(title, "DEVICE INFO");
+ lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 15);
+
+ lv_obj_t *box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(box, DISP_W - 24, 220);
+ lv_obj_align(box, LV_ALIGN_TOP_MID, 0, 48);
+ lv_obj_set_style_bg_color(box, lv_color_hex(0x181818), 0);
+ lv_obj_set_style_border_color(box, lv_color_hex(0x333333), 0);
+ lv_obj_set_style_radius(box, 6, 0);
+ lv_obj_set_style_pad_all(box, 8, 0);
+
+ size_t total_b = 0, used_b = 0;
+ storage_manager_get_info(&total_b, &used_b);
+ int json_c = 0, jpg_c = 0;
+ storage_manager_count_cached(&json_c, &jpg_c);
+ uint32_t free_heap = esp_get_free_heap_size();
+
+ lv_obj_t *lbl_keys = lv_label_create(box);
+ lv_obj_set_width(lbl_keys, 75);
+ lv_obj_set_style_text_font(lbl_keys, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(lbl_keys, lv_color_hex(0x00E5FF), 0);
+ lv_label_set_text(lbl_keys,
+ "WI-FI STA:\n"
+ "WEB PAIR:\n"
+ "STORAGE:\n"
+ "CARDS:\n"
+ "FREE RAM:\n"
+ "ENCODER:\n"
+ "HARDWARE:\n"
+ "RFID:");
+ lv_obj_align(lbl_keys, LV_ALIGN_TOP_LEFT, 0, 0);
+
+ lv_obj_t *lbl_vals = lv_label_create(box);
+ lv_obj_set_width(lbl_vals, 117);
+ lv_obj_set_style_text_font(lbl_vals, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(lbl_vals, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_set_style_text_align(lbl_vals, LV_TEXT_ALIGN_RIGHT, 0);
+ lv_label_set_text_fmt(lbl_vals,
+ "%s\n"
+ "192.168.4.1\n"
+ "%d / %d KB\n"
+ "%d cached\n"
+ "%d KB\n"
+ "%d steps\n"
+ "ESP32+ST7789\n"
+ "MFRC522",
+ wifi_manager_get_ip(),
+ (int)(used_b / 1024), (int)(total_b / 1024),
+ json_c,
+ (int)(free_heap / 1024),
+ (int)encoder_get_steps_per_detent());
+ lv_obj_align(lbl_vals, LV_ALIGN_TOP_RIGHT, 0, 0);
+
+ lv_obj_t *hint = lv_label_create(lv_scr_act());
+ lv_label_set_text(hint, "CLICK: MENU");
+ lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
+
+ lvgl_port_unlock();
+}
+
+/* 8. Card View Screen */
+static void view_card(void)
+{
+ char json_path[72];
+ snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", s_uid);
+
+ char *buf = NULL;
+ size_t len = 0;
+ if (storage_read_text(json_path, &buf, &len) != ESP_OK || buf == NULL) {
+ free(buf);
+ lvgl_port_lock(0);
+ ui_clean_screen();
+ lv_obj_t *err = lv_label_create(lv_scr_act());
+ lv_label_set_text(err, "CARD DATA MISSING");
+ lv_obj_set_style_text_font(err, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(err, lv_color_hex(0xFF1744), 0);
+ lv_obj_align(err, LV_ALIGN_CENTER, 0, 0);
+ lvgl_port_unlock();
+ return;
+ }
+ cJSON *root = cJSON_Parse(buf);
+ free(buf);
+ if (root == NULL) {
+ remove(json_path);
+ if (s_card_name[0] != '\0' && s_uid[0] != '\0') {
+ scryfall_fetch_request(s_card_name, s_uid);
+ }
+ lvgl_port_lock(0);
+ ui_clean_screen();
+ lv_obj_t *err = lv_label_create(lv_scr_act());
+ lv_label_set_text(err, "RE-FETCHING CARD...");
+ lv_obj_set_style_text_font(err, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(err, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(err, LV_ALIGN_CENTER, 0, 0);
+ lvgl_port_unlock();
+ return;
+ }
+
+ const char *name = get_json_str(root, "name", s_card_name[0] ? s_card_name : "?");
+ if (s_card_name[0] != '\0' && name[0] != '\0' && strcasecmp(name, s_card_name) != 0) {
+ /* Stale cache entry from a previous card! Purge and fetch fresh */
+ ESP_LOGW(TAG, "Cached card '%s' != mapped card '%s' for %s, re-fetching...", name, s_card_name, s_uid);
+ cJSON_Delete(root);
+ remove(json_path);
+ char jpg_path[72];
+ snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", s_uid);
+ remove(jpg_path);
+ s_waiting_fetch = true;
+ view_fetching(s_card_name);
+ scryfall_fetch_request(s_card_name, s_uid);
+ return;
+ }
+
+ const char *mana = get_json_str(root, "mana_cost", "");
+ const char *type = get_json_str(root, "type", "");
+ const char *rarity = get_json_str(root, "rarity", "");
+ const char *power = get_json_str(root, "power", "");
+ const char *tough = get_json_str(root, "toughness", "");
+ const char *loyalty = get_json_str(root, "loyalty", "");
+ const char *oracle = get_json_str(root, "oracle", "");
+ const char *price = get_json_str(root, "price_usd", "");
+
+ if (strstr(type, "Legendary") != NULL || strcasecmp(rarity, "mythic") == 0) {
+ buzzer_play_sound(BUZZER_SOUND_LEGENDARY);
+ }
+
+ bool has_art = load_art_crop(s_uid);
+
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ /* Card Box Container */
+ lv_obj_t *card_box = lv_obj_create(lv_scr_act());
+ lv_obj_set_size(card_box, DISP_W - 12, DISP_H - 12);
+ lv_obj_align(card_box, LV_ALIGN_CENTER, 0, 0);
+ lv_obj_set_style_bg_color(card_box, lv_color_hex(0x181818), 0);
+ lv_obj_set_style_border_color(card_box, lv_color_hex(0xFFCC00), 0);
+ lv_obj_set_style_border_width(card_box, 2, 0);
+ lv_obj_set_style_radius(card_box, 6, 0);
+ lv_obj_set_style_pad_all(card_box, 6, 0);
+ lv_obj_set_scrollbar_mode(card_box, LV_SCROLLBAR_MODE_OFF);
+
+ /* Name */
+ lv_obj_t *name_lbl = lv_label_create(card_box);
+ lv_label_set_text(name_lbl, name);
+ lv_obj_set_style_text_font(name_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(name_lbl, lv_color_hex(0xFFCC00), 0);
+ int name_max_w = (mana[0] != '\0') ? (DISP_W - 36 - 60) : (DISP_W - 36);
+ lv_obj_set_width(name_lbl, name_max_w);
+ lv_label_set_long_mode(name_lbl, LV_LABEL_LONG_DOT);
+ lv_obj_align(name_lbl, LV_ALIGN_TOP_LEFT, 0, 0);
+
+ /* Mana */
+ if (mana[0]) {
+ lv_obj_t *mana_lbl = lv_label_create(card_box);
+ lv_label_set_text(mana_lbl, mana);
+ lv_obj_set_style_text_font(mana_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(mana_lbl, lv_color_hex(0x00E676), 0);
+ lv_obj_align(mana_lbl, LV_ALIGN_TOP_RIGHT, 0, 0);
+ }
+
+ int cur_y = 20;
+
+ /* Art Crop Image */
+ if (has_art) {
+ lv_obj_t *img = lv_img_create(card_box);
+ lv_img_set_src(img, &s_art_img_dsc);
+ lv_obj_align(img, LV_ALIGN_TOP_MID, 0, cur_y);
+ lv_obj_set_style_border_color(img, lv_color_hex(0x8B6508), 0);
+ lv_obj_set_style_border_width(img, 1, 0);
+ lv_obj_set_style_radius(img, 3, 0);
+ cur_y += s_art_img_dsc.header.h + 4;
+ }
+
+ /* Type */
+ lv_obj_t *type_lbl = lv_label_create(card_box);
+ lv_label_set_text(type_lbl, type);
+ lv_obj_set_style_text_font(type_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(type_lbl, lv_color_white(), 0);
+ lv_obj_set_width(type_lbl, DISP_W - 28);
+ lv_label_set_long_mode(type_lbl, LV_LABEL_LONG_DOT);
+ lv_obj_align(type_lbl, LV_ALIGN_TOP_LEFT, 0, cur_y);
+ cur_y += 18;
+
+ /* Oracle text */
+ if (oracle[0]) {
+ lv_obj_t *oracle_lbl = lv_label_create(card_box);
+ lv_label_set_text(oracle_lbl, oracle);
+ lv_label_set_long_mode(oracle_lbl, LV_LABEL_LONG_WRAP);
+ lv_obj_set_width(oracle_lbl, DISP_W - 32);
+ lv_obj_set_style_text_font(oracle_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(oracle_lbl, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(oracle_lbl, LV_ALIGN_TOP_LEFT, 0, cur_y);
+ }
+
+ /* Bottom: P/T, Loyalty, and Price */
+ if (power[0] || tough[0]) {
+ lv_obj_t *pt_lbl = lv_label_create(card_box);
+ lv_label_set_text_fmt(pt_lbl, "%s / %s", power, tough);
+ lv_obj_set_style_text_font(pt_lbl, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(pt_lbl, lv_color_hex(0x00E5FF), 0);
+ lv_obj_align(pt_lbl, LV_ALIGN_BOTTOM_RIGHT, 0, 0);
+ } else if (loyalty[0]) {
+ lv_obj_t *loy_lbl = lv_label_create(card_box);
+ lv_label_set_text_fmt(loy_lbl, "[%s]", loyalty);
+ lv_obj_set_style_text_font(loy_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(loy_lbl, lv_color_hex(0x00E5FF), 0);
+ lv_obj_align(loy_lbl, LV_ALIGN_BOTTOM_RIGHT, 0, 0);
+ }
+
+ if (price[0]) {
+ lv_obj_t *pr_lbl = lv_label_create(card_box);
+ lv_label_set_text_fmt(pr_lbl, "$%s", price);
+ lv_obj_set_style_text_font(pr_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(pr_lbl, lv_color_hex(0xE040FB), 0);
+ lv_obj_align(pr_lbl, LV_ALIGN_BOTTOM_LEFT, 0, 0);
+ }
+
+ lvgl_port_unlock();
+ cJSON_Delete(root);
+}
+
+static void view_unmapped(const char *uid)
+{
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ lv_obj_t *title = lv_label_create(lv_scr_act());
+ lv_label_set_text(title, "UNMAPPED CARD");
+ lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 30);
+
+ lv_obj_t *u_lbl = lv_label_create(lv_scr_act());
+ lv_label_set_text_fmt(u_lbl, "UID: %s", uid);
+ lv_obj_set_style_text_font(u_lbl, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(u_lbl, lv_color_hex(0x00E5FF), 0);
+ lv_obj_align(u_lbl, LV_ALIGN_TOP_MID, 0, 70);
+
+ lv_obj_t *sub = lv_label_create(lv_scr_act());
+ lv_label_set_text(sub, "PAIR IT ON THE WEB:");
+ lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(sub, lv_color_white(), 0);
+ lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 130);
+
+ lv_obj_t *ssid = lv_label_create(lv_scr_act());
+ lv_label_set_text(ssid, "SSID: MTG-Companion");
+ lv_obj_set_style_text_font(ssid, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(ssid, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(ssid, LV_ALIGN_TOP_MID, 0, 160);
+
+ lv_obj_t *ip = lv_label_create(lv_scr_act());
+ lv_label_set_text(ip, "http://192.168.4.1");
+ lv_obj_set_style_text_font(ip, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(ip, lv_color_hex(0x00E676), 0);
+ lv_obj_align(ip, LV_ALIGN_TOP_MID, 0, 190);
+
+ lv_obj_t *hint = lv_label_create(lv_scr_act());
+ lv_label_set_text(hint, "HOLD BUTTON: MAIN MENU");
+ lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -10);
+
+ lvgl_port_unlock();
+}
+
+static void view_fetching(const char *name)
+{
+ lvgl_port_lock(0);
+ ui_clean_screen();
+
+ lv_obj_t *title = lv_label_create(lv_scr_act());
+ lv_label_set_text(title, "FETCHING CARD...");
+ lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
+ lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0);
+ lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 50);
+
+ lv_obj_t *cname = lv_label_create(lv_scr_act());
+ lv_label_set_text(cname, name);
+ lv_label_set_long_mode(cname, LV_LABEL_LONG_DOT);
+ lv_obj_set_width(cname, DISP_W - 24);
+ lv_obj_set_style_text_align(cname, LV_TEXT_ALIGN_CENTER, 0);
+ lv_obj_set_style_text_font(cname, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(cname, lv_color_white(), 0);
+ lv_obj_align(cname, LV_ALIGN_CENTER, 0, -20);
+
+ lv_obj_t *sub = lv_label_create(lv_scr_act());
+ lv_label_set_text(sub, "Downloading Scryfall data\nand art illustration...");
+ lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0);
+ lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_AMBER), 0);
+ lv_obj_set_style_text_align(sub, LV_TEXT_ALIGN_CENTER, 0);
+ lv_obj_align(sub, LV_ALIGN_CENTER, 0, 20);
+
+ lvgl_port_unlock();
}
/* ------------------------------------------------------------------ */
-/* Card events */
+/* LED helpers */
/* ------------------------------------------------------------------ */
static void led_for_card(const char *mana)
{
- uint8_t r = 120, g = 120, b = 120; /* colorless fallback */
- if (mana != NULL) {
- if (strstr(mana, "{W}")) { r = 255; g = 255; b = 255; }
- else if (strstr(mana, "{U}")) { r = 40; g = 100; b = 255; }
- else if (strstr(mana, "{B}")) { r = 90; g = 20; b = 150; }
- else if (strstr(mana, "{R}")) { r = 255; g = 60; b = 20; }
- else if (strstr(mana, "{G}")) { r = 20; g = 200; b = 60; }
+ uint8_t r = 255, g = 200, b = 0;
+ if (mana != NULL && mana[0] != '\0') {
+ if (strstr(mana, "{W}")) { r = 255; g = 255; b = 200; }
+ else if (strstr(mana, "{U}")) { r = 40; g = 100; b = 255; }
+ else if (strstr(mana, "{B}")) { r = 90; g = 20; b = 150; }
+ else if (strstr(mana, "{R}")) { r = 255; g = 60; b = 20; }
+ else if (strstr(mana, "{G}")) { r = 20; g = 200; b = 60; }
}
led_manager_set_color(r, g, b);
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS);
@@ -205,14 +1369,7 @@ static void handle_new_card(const char *uid)
esp_err_t ret = storage_manager_lookup_name(uid, &name);
if (ret != ESP_OK || name == NULL) {
app_set_view(APP_STATE_REGISTRATION);
- display_fill(COLOR_BLACK);
- display_put_text_center(60, "UNMAPPED CARD", COLOR_YELLOW, COLOR_BLACK);
- char u[40];
- snprintf(u, sizeof(u), "UID %s", uid);
- display_put_text_center(80, u, COLOR_CYAN, COLOR_BLACK);
- display_put_text_center(140, "PAIR IT ON THE WEB:", COLOR_WHITE, COLOR_BLACK);
- display_put_text_center(160, "SSID: MTG-Companion", COLOR_GREEN, COLOR_BLACK);
- display_put_text_center(176, "http://192.168.4.1", COLOR_GREEN, COLOR_BLACK);
+ view_unmapped(uid);
led_manager_set_color(60, 60, 200);
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS);
buzzer_chirp(392, 80);
@@ -221,8 +1378,6 @@ static void handle_new_card(const char *uid)
}
snprintf(s_card_name, sizeof(s_card_name), "%s", name);
- s_card_loaded = true;
- s_prev_view = APP_STATE_CARD_VIEW;
app_set_view(APP_STATE_CARD_VIEW);
buzzer_chirp(880, 60);
@@ -230,14 +1385,28 @@ static void handle_new_card(const char *uid)
char json_path[72];
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid);
+ bool need_fetch = true;
if (storage_file_exists(json_path)) {
+ char *chk = NULL;
+ size_t chk_len = 0;
+ if (storage_read_text(json_path, &chk, &chk_len) == ESP_OK && chk != NULL) {
+ cJSON *cj = cJSON_Parse(chk);
+ if (cj) {
+ cJSON *cn = cJSON_GetObjectItem(cj, "name");
+ if (cn && cn->valuestring && strcasecmp(cn->valuestring, name) == 0) {
+ need_fetch = false;
+ }
+ cJSON_Delete(cj);
+ }
+ free(chk);
+ }
+ }
+
+ if (!need_fetch) {
view_card();
} else {
s_waiting_fetch = true;
- display_fill(COLOR_BLACK);
- char line[160];
- snprintf(line, sizeof(line), "FETCHING %s...", name);
- display_put_text_center(140, line, COLOR_YELLOW, COLOR_BLACK);
+ view_fetching(name);
scryfall_fetch_request(name, uid);
}
free(name);
@@ -255,28 +1424,234 @@ static void handle_fetch_ready(void)
}
/* ------------------------------------------------------------------ */
-/* Task loop */
+/* Encoder event dispatcher */
/* ------------------------------------------------------------------ */
-static void ui_boot_demo(void)
+static void handle_encoder_click(void)
{
- static const uint16_t bars[] = {
- COLOR_WHITE, COLOR_YELLOW, COLOR_CYAN, COLOR_GREEN,
- COLOR_MAGENTA, COLOR_RED, COLOR_BLUE, COLOR_BLACK
- };
- const int band = DISP_W / 8;
- for (int i = 0; i < 8; i++) {
- display_fill_rect(i * band, 0, (i + 1) * band, 64, bars[i]);
- }
- vTaskDelay(pdMS_TO_TICKS(800));
+ app_state_t cur = app_get_view();
+ switch (cur) {
+ case APP_STATE_MENU:
+ buzzer_chirp(988, 40);
+ switch (s_menu_idx) {
+ case 0:
+ app_set_view(APP_STATE_SCANNER);
+ view_scanner();
+ break;
+ case 1:
+ app_set_view(APP_STATE_LIFE_COUNTER);
+ view_life();
+ break;
+ case 2:
+ app_set_view(APP_STATE_DICE_ROLLER);
+ view_dice();
+ break;
+ case 3:
+ app_set_view(APP_STATE_COMMANDER_TRACKER);
+ view_commander_tax();
+ break;
+ case 4:
+ app_set_view(APP_STATE_TOKEN_SPAWNER);
+ s_token_edit_count = false;
+ view_token_spawner();
+ break;
+ case 5:
+ app_set_view(APP_STATE_STORAGE_MANAGER);
+ s_storage_confirm_dialog = false;
+ s_storage_option_idx = 0;
+ view_storage();
+ break;
+ case 6:
+ app_set_view(APP_STATE_DEVICE_INFO);
+ view_device_info();
+ break;
+ default:
+ break;
+ }
+ break;
- display_fill(COLOR_BLACK);
- display_put_text_center(8, "MTG RFID COMPANION", COLOR_YELLOW, COLOR_BLACK);
- display_put_text_center(90, "TAP A CARD", COLOR_WHITE, COLOR_BLACK);
- vTaskDelay(pdMS_TO_TICKS(1200));
- view_scanner();
+ case APP_STATE_LIFE_COUNTER:
+ s_life_mode = (s_life_mode + 1) % 3;
+ buzzer_chirp(880, 30);
+ view_life();
+ break;
+
+ case APP_STATE_DICE_ROLLER:
+ roll_current_dice();
+ break;
+
+ case APP_STATE_COMMANDER_TRACKER:
+ s_tax_edit_turns = !s_tax_edit_turns;
+ buzzer_chirp(880, 30);
+ view_commander_tax();
+ break;
+
+ case APP_STATE_TOKEN_SPAWNER:
+ s_token_edit_count = !s_token_edit_count;
+ buzzer_chirp(880, 30);
+ view_token_spawner();
+ break;
+
+ case APP_STATE_STORAGE_MANAGER:
+ if (!s_storage_confirm_dialog) {
+ if (s_storage_option_idx == 0) {
+ s_storage_confirm_dialog = true;
+ buzzer_chirp(587, 60);
+ view_storage();
+ } else {
+ app_set_view(APP_STATE_MENU);
+ view_menu();
+ }
+ } else {
+ if (s_storage_option_idx == 0) {
+ storage_manager_clear_all_cards(true);
+ buzzer_chirp(1046, 100);
+ led_manager_set_color(0, 255, 60);
+ s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(1500);
+ s_storage_confirm_dialog = false;
+ view_storage();
+ } else {
+ s_storage_confirm_dialog = false;
+ s_storage_option_idx = 0;
+ view_storage();
+ }
+ }
+ break;
+
+ case APP_STATE_DEVICE_INFO:
+ encoder_set_steps_per_detent(4);
+ buzzer_chirp(880, 40);
+ app_set_view(APP_STATE_MENU);
+ view_menu();
+ break;
+
+ case APP_STATE_SCANNER:
+ case APP_STATE_CARD_VIEW:
+ case APP_STATE_REGISTRATION:
+ app_set_view(APP_STATE_LIFE_COUNTER);
+ view_life();
+ break;
+
+ default:
+ break;
+ }
}
+static void handle_encoder_rotation(int32_t delta)
+{
+ if (delta == 0) return;
+
+ app_state_t cur = app_get_view();
+ int32_t dir = (delta > 0) ? 1 : -1;
+ int32_t count = (delta > 0) ? delta : -delta;
+
+ /* Apply one discrete action per tick */
+ for (int32_t i = 0; i < count; i++) {
+ switch (cur) {
+ case APP_STATE_MENU:
+ if (dir > 0) {
+ s_menu_idx = (s_menu_idx + 1) % MENU_ITEM_COUNT;
+ } else {
+ s_menu_idx = (s_menu_idx - 1 + MENU_ITEM_COUNT) % MENU_ITEM_COUNT;
+ }
+ break;
+
+ case APP_STATE_LIFE_COUNTER:
+ if (s_life_mode == LIFE_MODE_TOTAL) {
+ app_set_life(app_get_life() + dir);
+ if (app_get_life() < 0) app_set_life(0);
+ } else if (s_life_mode == LIFE_MODE_POISON) {
+ s_poison += dir;
+ if (s_poison < 0) s_poison = 0;
+ if (s_poison > 10) s_poison = 10;
+ } else if (s_life_mode == LIFE_MODE_CMD_DMG) {
+ s_cmd_dmg += dir;
+ if (s_cmd_dmg < 0) s_cmd_dmg = 0;
+ if (s_cmd_dmg > 21) s_cmd_dmg = 21;
+ }
+ break;
+
+ case APP_STATE_DICE_ROLLER:
+ if (dir > 0) {
+ s_dice_mode = (s_dice_mode + 1) % 3;
+ } else {
+ s_dice_mode = (s_dice_mode - 1 + 3) % 3;
+ }
+ s_dice_rolled = false;
+ break;
+
+ case APP_STATE_COMMANDER_TRACKER:
+ if (!s_tax_edit_turns) {
+ s_cmd_casts += dir;
+ if (s_cmd_casts < 0) s_cmd_casts = 0;
+ if (s_cmd_casts > 50) s_cmd_casts = 50;
+ } else {
+ s_turn_count += dir;
+ if (s_turn_count < 1) s_turn_count = 1;
+ if (s_turn_count > 99) s_turn_count = 99;
+ }
+ break;
+
+ case APP_STATE_TOKEN_SPAWNER:
+ if (!s_token_edit_count) {
+ s_token_idx = (s_token_idx + dir + TOKEN_COUNT) % TOKEN_COUNT;
+ } else {
+ s_tokens[s_token_idx].count += dir;
+ if (s_tokens[s_token_idx].count < 0) s_tokens[s_token_idx].count = 0;
+ if (s_tokens[s_token_idx].count > 99) s_tokens[s_token_idx].count = 99;
+ }
+ break;
+
+ case APP_STATE_STORAGE_MANAGER:
+ s_storage_option_idx = (s_storage_option_idx == 0) ? 1 : 0;
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ /* Redraw view once per rotation event */
+ switch (cur) {
+ case APP_STATE_MENU:
+ buzzer_chirp(523, 15);
+ view_menu();
+ break;
+
+ case APP_STATE_LIFE_COUNTER:
+ buzzer_chirp(700, 15);
+ view_life();
+ break;
+
+ case APP_STATE_DICE_ROLLER:
+ buzzer_chirp(600, 15);
+ view_dice();
+ break;
+
+ case APP_STATE_COMMANDER_TRACKER:
+ buzzer_chirp(650, 15);
+ view_commander_tax();
+ break;
+
+ case APP_STATE_TOKEN_SPAWNER:
+ buzzer_chirp(s_token_edit_count ? 750 : 550, 15);
+ view_token_spawner();
+ break;
+
+ case APP_STATE_STORAGE_MANAGER:
+ buzzer_chirp(523, 15);
+ view_storage();
+ break;
+
+ default:
+ break;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Task loop */
+/* ------------------------------------------------------------------ */
+
static void ui_task(void *arg)
{
(void)arg;
@@ -286,11 +1661,12 @@ static void ui_task(void *arg)
led_manager_init();
buzzer_init();
- ui_boot_demo();
- app_set_view(APP_STATE_SCANNER);
+ /* Start on Main Menu at boot */
+ app_set_view(APP_STATE_MENU);
+ view_menu();
while (1) {
- /* 1. New RFID card */
+ /* 1. New RFID card - scans immediately anywhere */
if (rfid_manager_has_pending_card()) {
handle_new_card(rfid_manager_last_uid());
}
@@ -300,34 +1676,33 @@ static void ui_task(void *arg)
handle_fetch_ready();
}
- /* 3. Encoder button: toggle card/life view */
- if (encoder_button_pressed()) {
- encoder_clear_button();
- app_state_t cur = app_get_view();
- if (cur == APP_STATE_LIFE_COUNTER) {
- app_enter_keyed_view(s_prev_view == APP_STATE_CARD_VIEW ? APP_STATE_CARD_VIEW : APP_STATE_SCANNER);
- if (app_get_view() == APP_STATE_CARD_VIEW) {
- view_card();
- } else {
- s_prev_view = APP_STATE_SCANNER;
- view_scanner();
- }
- } else {
- s_prev_view = cur;
- app_set_view(APP_STATE_LIFE_COUNTER);
- app_set_life(app_get_life_preset());
- view_life();
+ /* 3. Button press & hold detection */
+ int sw_level = gpio_get_level(HW_ENC_SW_GPIO);
+ if (sw_level == 0) {
+ s_btn_held_ms += 25;
+ if (s_btn_held_ms >= 750 && !s_btn_long_fired) {
+ s_btn_long_fired = true;
+ /* Long press: RETURN TO MAIN MENU from any screen */
+ buzzer_chirp(1046, 50);
+ vTaskDelay(pdMS_TO_TICKS(40));
+ buzzer_chirp(1318, 70);
+ led_manager_set_color(180, 0, 255);
+ s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(800);
+ app_set_view(APP_STATE_MENU);
+ view_menu();
}
+ } else {
+ if (s_btn_held_ms >= 35 && !s_btn_long_fired) {
+ handle_encoder_click();
+ }
+ s_btn_held_ms = 0;
+ s_btn_long_fired = false;
}
- /* 4. Encoder rotation in life counter */
- if (app_get_view() == APP_STATE_LIFE_COUNTER && encoder_has_delta()) {
+ /* 4. Rotary encoder rotation */
+ if (encoder_has_delta()) {
int32_t delta = encoder_get_delta_and_reset();
- app_set_life(app_get_life() - delta);
- if (app_get_life() < 0) {
- app_set_life(0);
- }
- view_life();
+ handle_encoder_rotation(delta);
}
/* 5. Auto-dim LED */
diff --git a/main/web_server.c b/main/web_server.c
index e5bbc5a..e06ca15 100644
--- a/main/web_server.c
+++ b/main/web_server.c
@@ -2,6 +2,7 @@
* MTG RFID Companion - Registration web server (SoftAP)
*/
+#include
#include
#include
#include "esp_log.h"
@@ -25,28 +26,52 @@ static const char SPA_PAGE[] =
""
""
"MTG RFID Companion"
- "
MTG RFID Companion
"
- "
Pair a scanned card with its Scryfall name.
"
- "
Last scanned UID
waiting...
"
- "
Card name
"
- ""
- ""
+ "
MTG RFID Companion
"
+ "
Pair card sleeves and manage your scanned deck.
"
+ "
"
+ "
Last Scanned Tag
"
+ "
waiting...
"
+ "
Pair With Card Name
"
+ ""
+ ""
"
"
""
+ "
"
+ "
"
+ "
Paired Cards (0)
"
+ ""
+ "
"
+ "
Loading paired cards...
"
"";
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;
diff --git a/main/wifi_manager.c b/main/wifi_manager.c
index ec3a534..b740dfe 100644
--- a/main/wifi_manager.c
+++ b/main/wifi_manager.c
@@ -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();
diff --git a/main/wifi_manager.h b/main/wifi_manager.h
index 3dfc655..f05b7bd 100644
--- a/main/wifi_manager.h
+++ b/main/wifi_manager.h
@@ -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
\ No newline at end of file
diff --git a/sdkconfig b/sdkconfig
index 51d40e2..6132358 100644
--- a/sdkconfig
+++ b/sdkconfig
@@ -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
diff --git a/sdkconfig.defaults.esp32 b/sdkconfig.defaults.esp32
index 7fd107f..8315fe2 100644
--- a/sdkconfig.defaults.esp32
+++ b/sdkconfig.defaults.esp32
@@ -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"
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/storage/mappings.json b/storage/mappings.json
index 27d928a..034d815 100644
--- a/storage/mappings.json
+++ b/storage/mappings.json
@@ -1,4 +1,6 @@
{
"04A2B3C4": "Sol Ring",
- "04E1F2A3": "Atraxa, Praetors' Voice"
+ "04E1F2A3": "Atraxa, Praetors' Voice",
+ "508152B2": "Workshop Assistant",
+ "B34DF005": "Black Lotus"
}
\ No newline at end of file