From 029ceee69ae43261b04ff0c5e949c40bd12a1cb8 Mon Sep 17 00:00:00 2001 From: Rasmus Date: Fri, 11 Sep 2026 19:10:31 +0200 Subject: [PATCH] various bug fixes --- README.md | 501 +++++------ main/app_state.c | 6 + main/app_state.h | 6 + main/buzzer.c | 74 +- main/buzzer.h | 60 +- main/display_manager.c | 274 ++---- main/display_manager.h | 62 +- main/encoder.c | 121 ++- main/encoder.h | 10 + main/idf_component.yml | 5 +- main/main.c | 4 +- main/rfid_manager.c | 47 +- main/scryfall_client.c | 147 +++- main/storage_manager.c | 124 +++ main/storage_manager.h | 20 + main/ui_font.h | 114 --- main/ui_task.c | 1707 ++++++++++++++++++++++++++++++++++---- main/web_server.c | 224 ++++- main/wifi_manager.c | 13 +- main/wifi_manager.h | 5 + sdkconfig | 314 ++++++- sdkconfig.defaults.esp32 | 10 +- storage/mappings.json | 4 +- 23 files changed, 2861 insertions(+), 991 deletions(-) delete mode 100644 main/ui_font.h 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