commit 28c36c51f639154e647a6fdb1f1cf68cc256ea3f Author: Giorgio Gilestro Date: Fri Apr 3 18:43:15 2026 +0100 Initial commit: Minecraft Orb project ESP32-C3 firmware for interactive treasure hunt device with RFID, OLED display, LED effects, buzzer, and touch input. Includes 3D printable STL files for the enclosure. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21d856a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# PlatformIO +firmware/.pio/ + +# Secrets +firmware/include/secrets.h + +# Generated gcode files +*.gcode + +# Claude Code +.claude/ +tasks/ diff --git a/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_1.stl b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_1.stl new file mode 100644 index 0000000..4098aec Binary files /dev/null and b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_1.stl differ diff --git a/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_2.stl b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_2.stl new file mode 100644 index 0000000..f5f4e60 Binary files /dev/null and b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_2.stl differ diff --git a/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_3.stl b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_3.stl new file mode 100644 index 0000000..7245147 Binary files /dev/null and b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_3.stl differ diff --git a/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_4.stl b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_4.stl new file mode 100644 index 0000000..ade8d1a Binary files /dev/null and b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_4.stl differ diff --git a/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_5.stl b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_5.stl new file mode 100644 index 0000000..b1b9e77 Binary files /dev/null and b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_5.stl differ diff --git a/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_6.stl b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_6.stl new file mode 100644 index 0000000..ca9d41f Binary files /dev/null and b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - filler_6.stl differ diff --git a/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - fillers_ridged.3mf b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - fillers_ridged.3mf new file mode 100644 index 0000000..b2af100 Binary files /dev/null and b/STLs/Fillers/Fillers_with_ridge/Minecraft Ore - fillers_ridged.3mf differ diff --git a/STLs/Fillers/Minecraft Ore - Filler_1.stl b/STLs/Fillers/Minecraft Ore - Filler_1.stl new file mode 100644 index 0000000..b24cb5a Binary files /dev/null and b/STLs/Fillers/Minecraft Ore - Filler_1.stl differ diff --git a/STLs/Fillers/Minecraft Ore - Filler_2.stl b/STLs/Fillers/Minecraft Ore - Filler_2.stl new file mode 100644 index 0000000..4a6adf2 Binary files /dev/null and b/STLs/Fillers/Minecraft Ore - Filler_2.stl differ diff --git a/STLs/Fillers/Minecraft Ore - Filler_3.stl b/STLs/Fillers/Minecraft Ore - Filler_3.stl new file mode 100644 index 0000000..5e3a13b Binary files /dev/null and b/STLs/Fillers/Minecraft Ore - Filler_3.stl differ diff --git a/STLs/Fillers/Minecraft Ore - Filler_4.stl b/STLs/Fillers/Minecraft Ore - Filler_4.stl new file mode 100644 index 0000000..ee86dc3 Binary files /dev/null and b/STLs/Fillers/Minecraft Ore - Filler_4.stl differ diff --git a/STLs/Fillers/Minecraft Ore - Filler_5.stl b/STLs/Fillers/Minecraft Ore - Filler_5.stl new file mode 100644 index 0000000..74052a6 Binary files /dev/null and b/STLs/Fillers/Minecraft Ore - Filler_5.stl differ diff --git a/STLs/Fillers/Minecraft Ore - Filler_6.stl b/STLs/Fillers/Minecraft Ore - Filler_6.stl new file mode 100644 index 0000000..f9f700d Binary files /dev/null and b/STLs/Fillers/Minecraft Ore - Filler_6.stl differ diff --git a/STLs/Images/Model_closed.png b/STLs/Images/Model_closed.png new file mode 100644 index 0000000..ab201fa Binary files /dev/null and b/STLs/Images/Model_closed.png differ diff --git a/STLs/Images/Model_exploded.png b/STLs/Images/Model_exploded.png new file mode 100644 index 0000000..92f21f4 Binary files /dev/null and b/STLs/Images/Model_exploded.png differ diff --git a/STLs/Images/photo_1.jpg b/STLs/Images/photo_1.jpg new file mode 100644 index 0000000..40572e5 Binary files /dev/null and b/STLs/Images/photo_1.jpg differ diff --git a/STLs/Images/photo_2.jpg b/STLs/Images/photo_2.jpg new file mode 100644 index 0000000..bab8b2e Binary files /dev/null and b/STLs/Images/photo_2.jpg differ diff --git a/STLs/Minecraft Ore - Plain - Minecraft Ore - Plain.3mf b/STLs/Minecraft Ore - Plain - Minecraft Ore - Plain.3mf new file mode 100644 index 0000000..357db9b Binary files /dev/null and b/STLs/Minecraft Ore - Plain - Minecraft Ore - Plain.3mf differ diff --git a/STLs/README.md b/STLs/README.md new file mode 100644 index 0000000..eb4885e --- /dev/null +++ b/STLs/README.md @@ -0,0 +1,5 @@ +# Minecraft Orb - STL Files + +3D printable files for the Minecraft Orb project. + +The model is available on Printables: https://www.printables.com/model/1663249-a-minecraft-orb diff --git a/STLs/Structure/Minecraft Ore - Mounting Frame.stl b/STLs/Structure/Minecraft Ore - Mounting Frame.stl new file mode 100644 index 0000000..302557b Binary files /dev/null and b/STLs/Structure/Minecraft Ore - Mounting Frame.stl differ diff --git a/STLs/Structure/Minecraft Ore - Support for PCB.stl b/STLs/Structure/Minecraft Ore - Support for PCB.stl new file mode 100644 index 0000000..1053d78 Binary files /dev/null and b/STLs/Structure/Minecraft Ore - Support for PCB.stl differ diff --git a/STLs/Structure/Minecraft Ore - Top surface.stl b/STLs/Structure/Minecraft Ore - Top surface.stl new file mode 100644 index 0000000..f79c9df Binary files /dev/null and b/STLs/Structure/Minecraft Ore - Top surface.stl differ diff --git a/STLs/Structure/Minecraft Ore - Wall.stl b/STLs/Structure/Minecraft Ore - Wall.stl new file mode 100644 index 0000000..8c2ee21 Binary files /dev/null and b/STLs/Structure/Minecraft Ore - Wall.stl differ diff --git a/firmware/.gitignore b/firmware/.gitignore new file mode 100644 index 0000000..b0c2c4c --- /dev/null +++ b/firmware/.gitignore @@ -0,0 +1,2 @@ +.pio/ +include/secrets.h diff --git a/firmware/CLAUDE.md b/firmware/CLAUDE.md new file mode 100644 index 0000000..22a0028 --- /dev/null +++ b/firmware/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ESP32-C3 SuperMini firmware for the "Minecraft Orb" — an interactive treasure hunt device with RFID card reading, OLED display, LED effects, buzzer audio, and touch input. Built with Arduino framework via PlatformIO. Quest clues are stored directly on MIFARE Classic 1K cards and read/displayed by the device. + +## Build & Upload Commands + +```bash +pio run # Build firmware +pio run --target upload # Upload to board via USB-C +pio device monitor # Serial monitor (115200 baud) +pio run --target upload && pio device monitor # Upload + monitor +``` + +## Hardware Configuration + +- **MCU**: ESP32-C3 SuperMini (RISC-V), USB CDC serial on boot +- **Display**: SSD1306 128x64 OLED via I2C (GPIO2=SDA, GPIO3=SCL) +- **RFID**: RC522 via SPI (GPIO7=SCK, GPIO8=MOSI, GPIO9=MISO, GPIO6=CS, GPIO10=RST) +- **LED**: White LED on GPIO4 (PWM breathing effect) +- **Buzzer**: Piezo on GPIO0 +- **Touch**: TTP223 sensor on GPIO5 (must be GPIO0-5 for deep sleep wake) + +Pin definitions are in `include/pins.h`. Full wiring diagram in `pinout.svg`. + +## Architecture + +### Source Files + +- **`src/main.cpp`** (~1500 lines) — Main firmware: hardware init, event loop, display rendering, serial command handler, RFID event processing, Morse code, touch input puzzle, authentication state machine, LED breathing +- **`src/cards.cpp`** — Card database: NVS storage, MIFARE card read/write operations (sectors 1-4, blocks 4-18) +- **`include/cards.h`** — Card struct and database interface +- **`include/config.h`** — WiFi credentials, NVS namespace config, power management timeouts +- **`include/pins.h`** — GPIO pin assignments + +### Core Event Loop + +`setup()` initializes all hardware then enters auth mode. `loop()` calls `checkSerial()`, `checkRFID()`, `checkTouch()`, `breatheLED()`, and `checkPowerSave()` each cycle. State is tracked via globals: `authMode`, `programmingMode`, `waitingForCard`, `authCount`. + +### Operating Modes + +1. **Auth Mode** (startup default): Requires 5 players to scan AUTH cards in sequence (IDs 1-5 in order) +2. **Programming Mode**: Entered via `PROG` serial command. Write/read quest data to MIFARE cards +3. **Normal Mode**: Scan cards to display clues; handles special command cards (MORSE, INPUT, AUTH) + +### MIFARE Card Data Layout + +Quest data is written directly to MIFARE Classic 1K cards (not ESP32 flash): +- Sector 1 (blocks 4-5): Card name (32 bytes) +- Sectors 2-4 (blocks 8-18): Clue text (144 bytes) +- Authentication uses key A = `0xFFFFFFFFFFFF` + +### Serial Commands (Programming Mode) + +`PROG` — enter programming mode | `EXIT` — leave | `SCAN` — read card | `ADD name|clue` — write quest to card | `HELP` — show commands + +### Special Card Types + +Cards with specific clue prefixes trigger special behaviors: +- `MORSE:` — Plays clue as Morse code via LED + buzzer +- `INPUT:` — Touch-based number input puzzle (answer 1-10) +- `AUTH:` — Multi-player authentication card (format: `name|id|text`) + +## Key Libraries + +- Adafruit SSD1306 v2.5.7 + GFX v1.11.5 (display) +- MFRC522 v1.4.10 (RFID) +- Preferences (ESP32 NVS for persistent storage) + +## Power Management (Battery Operation) + +Powered via ESP32-C3 SuperMini expansion board with 3.7V LiPo battery (USB charging). + +- **Display auto-off**: 3 min inactivity (`DISPLAY_TIMEOUT_MS` in `config.h`) +- **Deep sleep**: 10 min inactivity (`SLEEP_TIMEOUT_MS` in `config.h`), ~µA draw +- **Wake**: Touch sensor press → full reboot through `setup()` +- Any interaction (card scan, touch, serial) resets inactivity timer +- ESP32-C3 deep sleep wake only supports GPIO0-5 — touch sensor must stay on one of these pins + +## Notes + +- No unit tests — embedded firmware tested via serial + hardware +- `main.cpp` is large; consider splitting display, audio, and RFID logic into separate modules for major changes +- WiFi code exists but is currently disabled +- See `PLANNING.md` for full architectural documentation and future plans diff --git a/firmware/PLANNING.md b/firmware/PLANNING.md new file mode 100644 index 0000000..f322de8 --- /dev/null +++ b/firmware/PLANNING.md @@ -0,0 +1,182 @@ +# Minecraft Orb - Firmware + +## Project Overview + +ESP32-C3 SuperMini-based RFID reader with OLED display, designed for the Minecraft Orb project (Giulio Hunt 2025). + +## Hardware + +### Microcontroller +- **Board**: ESP32-C3 SuperMini +- **Form factor**: Two rows of 8 pins, USB-C connector +- **Framework**: Arduino (via PlatformIO) +- **Power**: ESP32-C3 SuperMini Expansion Board with 3.7V LiPo battery (USB charging via TP4056) + +### Connected Components + +| Component | Interface | Description | +|-----------|-----------|-------------| +| SSD1306 OLED | I2C | 128x64 pixel display | +| RC522 | SPI | RFID/NFC reader (13.56 MHz) | +| White LED | GPIO | Status indicator (100 ohm resistor) | +| Piezo Buzzer | GPIO/PWM | Audio feedback (passive) | +| TTP223 | GPIO | Capacitive touch sensor | + +### Pin Configuration + +``` +ESP32-C3 SuperMini Pinout (Top View, USB-C facing down) + +Left Side Right Side +--------- ---------- +GPIO5 <- Touch 5V +GPIO6 <- RC522 SDA GND +GPIO7 <- RC522 SCK 3V3 +GPIO8 <- RC522 MOSI GPIO4 <- LED +GPIO9 <- RC522 MISO GPIO3 <- LCD SCL +GPIO10 <- RC522 RST GPIO2 <- LCD SDA +GPIO20 (FREE) GPIO1 (FREE) +GPIO21 (FREE) GPIO0 <- Buzzer +``` + +### Detailed Wiring + +| Component | Component Pin | GPIO | Notes | +|-----------|---------------|------|-------| +| SSD1306 | SDA | GPIO 2 | I2C Data | +| SSD1306 | SCL | GPIO 3 | I2C Clock | +| SSD1306 | VCC | 3V3 | 3.3V power | +| SSD1306 | GND | GND | Ground | +| RC522 | SDA (CS) | GPIO 6 | Chip Select | +| RC522 | SCK | GPIO 7 | SPI Clock | +| RC522 | MOSI | GPIO 8 | SPI Data Out | +| RC522 | MISO | GPIO 9 | SPI Data In | +| RC522 | RST | GPIO 10 | Reset | +| RC522 | VCC | 3V3 | 3.3V power | +| RC522 | GND | GND | Ground | +| White LED | Anode (+) | GPIO 4 | Via 100 ohm resistor | +| White LED | Cathode (-) | GND | Ground | +| Buzzer | + (Signal) | GPIO 0 | Passive piezo buzzer | +| Buzzer | - (GND) | GND | Ground | +| TTP223 | I/O (Signal) | GPIO 5 | Touch output (HIGH when touched, GPIO0-5 required for deep sleep wake) | +| TTP223 | VCC | 3V3 | 3.3V power | +| TTP223 | GND | GND | Ground | + +## Software Architecture + +### File Structure + +``` +firmware/ +├── platformio.ini # PlatformIO configuration +├── PLANNING.md # This file +├── include/ +│ ├── pins.h # Pin definitions +│ ├── config.h # WiFi credentials & constants +│ └── cards.h # Card database structures +└── src/ + ├── main.cpp # Main firmware + └── cards.cpp # Card database (NVS storage) +``` + +### Dependencies + +- `Adafruit SSD1306` - OLED display driver +- `Adafruit GFX Library` - Graphics primitives +- `MFRC522` - RC522 RFID reader driver +- `WiFi` - ESP32 WiFi (built-in, currently disabled) +- `Preferences` - ESP32 NVS storage (built-in) + +### Operating Modes + +1. **Auth Mode** (startup): 5 players must scan AUTH cards in ascending ID order +2. **Programming Mode**: Write/read quest data to MIFARE cards via serial +3. **Normal Mode**: Scan cards to display clues; handles MORSE, INPUT, AUTH command cards + +### Event Loop + +`setup()` initializes all hardware then enters auth mode. `loop()` calls: +1. `checkSerial()` - Process serial commands +2. `breatheLED()` - LED animation (normal mode only) +3. `checkRFID()` - Poll for RFID cards +4. `checkTouch()` - Poll touch sensor for activity +5. `checkPowerSave()` - Display timeout and deep sleep + +## Building & Flashing + +### Prerequisites +- PlatformIO CLI or VS Code with PlatformIO extension + +### Commands + +```bash +pio run # Build firmware +pio run --target upload # Upload to board +pio device monitor # Monitor serial output +``` + +## Serial Programming Mode + +Connect via serial (115200 baud) and use these commands: + +| Command | Description | +|---------|-------------| +| `PROG` | Enter programming mode | +| `EXIT` | Exit programming mode | +| `SCAN` | Scan card and read contents | +| `ADD \|` | Write quest data to card | +| `HELP` | Show help | + +Quest data is written directly to MIFARE Classic 1K cards (not stored in ESP32 flash). + +### Card Data Layout + +| Sector | Blocks | Content | +|--------|--------|---------| +| 1 | 4-5 | Name (32 bytes) | +| 2 | 8-10 | Clue part 1 (48 bytes) | +| 3 | 12-14 | Clue part 2 (48 bytes) | +| 4 | 16-18 | Clue part 3 (48 bytes) | + +Total capacity: 32 byte name + 144 byte clue + +### Command Cards + +Special card names trigger commands instead of displaying clues: + +| Name | Clue Format | Description | +|------|-------------|-------------| +| `MORSE` | `` | Play clue as Morse code | +| `INPUT` | `\|` | Touch input puzzle (1-10) | + +### Example Usage + +``` +PROG +ADD Quest 1|Go to the old oak tree near the lake + +EXIT +``` + +## Power Management (Battery Operation) + +Powered via ESP32-C3 SuperMini expansion board with 3.7V LiPo battery (USB charging via TP4056). + +| Feature | Timeout | Behavior | +|---------|---------|----------| +| Display auto-off | 3 min inactivity | OLED turned off, touch/card/serial wakes it | +| Deep sleep | 10 min inactivity | All peripherals off, ~uA draw | +| Wake from sleep | Touch sensor press | Full reboot through setup() | + +Timeouts configured in `include/config.h` (`DISPLAY_TIMEOUT_MS`, `SLEEP_TIMEOUT_MS`). + +Touch sensor must be on GPIO0-5 for deep sleep wake support (ESP32-C3 limitation). + +## Future Development + +- [x] Write quest data directly to MIFARE cards +- [x] WiFi connectivity +- [x] Command cards (MORSE, INPUT) +- [x] Deep sleep for battery operation +- [ ] Custom animations on display +- [ ] OTA firmware updates diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..7bafad1 --- /dev/null +++ b/firmware/README.md @@ -0,0 +1,149 @@ +# Minecraft Orb - Firmware + +An ESP32-C3 powered interactive treasure hunt device. Players scan RFID cards to reveal clues, solve Morse code puzzles, and complete touch-based challenges. Quest data is stored directly on MIFARE Classic 1K cards. + +Built for the **Giulio Hunt 2025** project. + +## Hardware + +| Component | Model | Interface | GPIO | +|-----------|-------|-----------|------| +| Microcontroller | ESP32-C3 SuperMini | - | - | +| Display | SSD1306 128x64 OLED | I2C | SDA=2, SCL=3 | +| RFID Reader | RC522 (13.56 MHz) | SPI | SCK=7, MOSI=8, MISO=9, CS=6, RST=10 | +| LED | White (100 ohm resistor) | PWM | 4 | +| Buzzer | Passive piezo | Tone | 0 | +| Touch Sensor | TTP223 capacitive | Digital | 5 | + +Power is supplied via the **ESP32-C3 SuperMini Expansion Board**, which supports 3.7V LiPo battery with USB charging. + +``` +ESP32-C3 SuperMini Pinout (Top View, USB-C facing down) + +Left Side Right Side +--------- ---------- +GPIO5 <- Touch 5V +GPIO6 <- RC522 SDA GND +GPIO7 <- RC522 SCK 3V3 +GPIO8 <- RC522 MOSI GPIO4 <- LED +GPIO9 <- RC522 MISO GPIO3 <- LCD SCL +GPIO10 <- RC522 RST GPIO2 <- LCD SDA +GPIO20 (FREE) GPIO1 (FREE) +GPIO21 (FREE) GPIO0 <- Buzzer +``` + +## Building & Uploading + +Requires [PlatformIO](https://platformio.org/). + +```bash +pio run # Build +pio run --target upload # Upload via USB-C +pio device monitor # Serial monitor (115200 baud) +``` + +## How It Works + +### Startup: Authentication + +On power-up, the device requires **5 players to scan their AUTH cards in ascending order** (IDs 1 through 5). Each card must have higher ID than the previous one. An out-of-order scan resets the sequence. + +AUTH card clue format: `PlayerName|ID|WelcomeMessage` + +Once all 5 authenticate successfully, the quest begins. + +### Quest Mode + +Players scan RFID cards to reveal clues on the OLED display. There are three types of quest cards: + +**Regular cards** — Display the card name and clue text on screen. + +**MORSE cards** — The clue is played as Morse code through the buzzer and LED. Dots and dashes are shown on the display but the text itself stays hidden. + +**INPUT cards** — A touch-based number puzzle. Players press the touch sensor N times to enter a code (1-10). The answer auto-submits after 3 seconds of no input. Correct answers reveal a message; wrong answers show an error. + +Card clue format for INPUT: `answer|message` (e.g., `5|The treasure is under the bridge`) + +### Programming Cards + +Connect via serial (115200 baud) and enter programming mode: + +``` +> PROG +>>> Entered programming mode + +> ADD Quest 1|Go to the old oak tree near the lake +Scan card to add as 'Quest 1'... + +OK: Card programmed as 'Quest 1' + +> SCAN + +Card UID: 12:AB:34:CD +Name: Quest 1 +Clue: Go to the old oak tree near the lake + +> EXIT +``` + +| Command | Description | +|---------|-------------| +| `PROG` | Enter programming mode | +| `EXIT` | Leave programming mode | +| `SCAN` | Read card UID and contents | +| `ADD name\|clue` | Write quest data to next scanned card | +| `HELP` | Show commands | + +### Card Data Layout (MIFARE Classic 1K) + +| Sector | Blocks | Content | Size | +|--------|--------|---------|------| +| 1 | 4-5 | Card name | 32 bytes | +| 2 | 8-10 | Clue part 1 | 48 bytes | +| 3 | 12-14 | Clue part 2 | 48 bytes | +| 4 | 16-18 | Clue part 3 | 48 bytes | + +Total capacity: 32 bytes name + 144 bytes clue. Uses default MIFARE key A (0xFFFFFFFFFFFF). + +## Battery Operation + +The device supports battery power via the ESP32-C3 SuperMini Expansion Board with a 3.7V LiPo cell. USB charging is handled by the board's TP4056 circuit (green LED = charging, LED off = full). + +Power saving features: + +| Event | Timeout | Behavior | +|-------|---------|----------| +| Display auto-off | 3 min inactivity | OLED turned off, any interaction wakes it | +| Deep sleep | 10 min inactivity | All peripherals powered down (~uA draw) | +| Wake | Touch sensor press | Full reboot through startup sequence | + +Timeouts are configurable in `include/config.h` (`DISPLAY_TIMEOUT_MS`, `SLEEP_TIMEOUT_MS`). + +The touch sensor must be on GPIO 0-5 for deep sleep wake to work (ESP32-C3 hardware limitation). + +## Project Structure + +``` +firmware/ +├── platformio.ini # Build configuration +├── include/ +│ ├── pins.h # GPIO pin assignments +│ ├── config.h # Timeouts, card limits, NVS keys +│ └── cards.h # Card data structures +└── src/ + ├── main.cpp # Firmware: init, event loop, display, RFID, audio, power + └── cards.cpp # NVS card database, MIFARE read/write operations +``` + +## Dependencies + +| Library | Version | Purpose | +|---------|---------|---------| +| Adafruit SSD1306 | 2.5.x | OLED display driver | +| Adafruit GFX | 1.12.x | Graphics primitives | +| MFRC522 | 1.4.x | RC522 RFID reader | +| Preferences | built-in | ESP32 NVS storage | + +## License + +Part of the Giulio Hunt 2025 project. diff --git a/firmware/include/cards.h b/firmware/include/cards.h new file mode 100644 index 0000000..9ef56e7 --- /dev/null +++ b/firmware/include/cards.h @@ -0,0 +1,118 @@ +/** + * @file cards.h + * @brief RFID Card database management for Minecraft Orb + * + * Provides structures and functions for storing and retrieving + * quest card data from ESP32 NVS (Non-Volatile Storage). + */ + +#ifndef CARDS_H +#define CARDS_H + +#include +#include "config.h" + +/** + * @brief Quest card data structure + * + * Stores RFID card UID along with quest metadata. + */ +struct QuestCard { + byte uid[MAX_UID_LEN]; // Card UID bytes + byte uidLength; // Actual UID length (4 or 7) + char name[CARD_NAME_LEN]; // Quest name + char clue[CARD_CLUE_LEN]; // Clue/description text +}; + +/** + * @brief Initialize the card database + * + * Opens NVS namespace and loads card count. + */ +void initCardDatabase(); + +/** + * @brief Get the number of stored cards + * + * Returns: + * int: Number of cards in database + */ +int getCardCount(); + +/** + * @brief Find a card by UID + * + * Args: + * uid: Pointer to UID bytes + * uidLength: Length of UID + * card: Pointer to QuestCard to fill if found + * + * Returns: + * int: Card index if found, -1 if not found + */ +int findCardByUID(byte* uid, byte uidLength, QuestCard* card); + +/** + * @brief Add a new card to the database + * + * Args: + * card: Pointer to QuestCard to add + * + * Returns: + * bool: true if added successfully + */ +bool addCard(QuestCard* card); + +/** + * @brief Delete a card by index + * + * Args: + * index: Card index to delete + * + * Returns: + * bool: true if deleted successfully + */ +bool deleteCard(int index); + +/** + * @brief Get a card by index + * + * Args: + * index: Card index + * card: Pointer to QuestCard to fill + * + * Returns: + * bool: true if card exists at index + */ +bool getCard(int index, QuestCard* card); + +/** + * @brief Clear all cards from database + */ +void clearAllCards(); + +/** + * @brief Format UID as hex string + * + * Args: + * uid: Pointer to UID bytes + * uidLength: Length of UID + * buffer: Output buffer (must be at least uidLength*3) + */ +void formatUID(byte* uid, byte uidLength, char* buffer); + +/** + * @brief Compare two UIDs + * + * Args: + * uid1: First UID + * len1: First UID length + * uid2: Second UID + * len2: Second UID length + * + * Returns: + * bool: true if UIDs match + */ +bool compareUID(byte* uid1, byte len1, byte* uid2, byte len2); + +#endif // CARDS_H diff --git a/firmware/include/config.h b/firmware/include/config.h new file mode 100644 index 0000000..3443c56 --- /dev/null +++ b/firmware/include/config.h @@ -0,0 +1,36 @@ +/** + * @file config.h + * @brief Configuration constants for Minecraft Orb + */ + +#ifndef CONFIG_H +#define CONFIG_H + +#include "secrets.h" // WIFI_SSID, WIFI_PASSWORD + +// ============================================================================= +// WiFi Configuration +// ============================================================================= +#define WIFI_TIMEOUT_MS 10000 // 10 seconds connection timeout + +// ============================================================================= +// Card Database Configuration +// ============================================================================= +#define MAX_CARDS 15 // Maximum number of stored cards +#define CARD_NAME_LEN 32 // Max length of quest name +#define CARD_CLUE_LEN 128 // Max length of clue text +#define MAX_UID_LEN 7 // Max RFID UID length (4 or 7 bytes) + +// ============================================================================= +// NVS Storage Keys +// ============================================================================= +#define NVS_NAMESPACE "orb_cards" +#define NVS_CARD_COUNT "card_count" + +// ============================================================================= +// Power Management (Battery Operation) +// ============================================================================= +#define DISPLAY_TIMEOUT_MS 180000 // 3 minutes: turn off display +#define SLEEP_TIMEOUT_MS 600000 // 10 minutes: enter deep sleep + +#endif // CONFIG_H diff --git a/firmware/include/pins.h b/firmware/include/pins.h new file mode 100644 index 0000000..3d49247 --- /dev/null +++ b/firmware/include/pins.h @@ -0,0 +1,48 @@ +/** + * @file pins.h + * @brief Pin definitions for Minecraft Orb - ESP32-C3 SuperMini + * + * Hardware Configuration: + * - SSD1306 OLED Display (128x64) via I2C + * - RC522 RFID Reader via SPI + * - White LED indicator + * - Piezo buzzer for audio feedback + * - TTP223 capacitive touch sensor + */ + +#ifndef PINS_H +#define PINS_H + +// ============================================================================= +// I2C - SSD1306 OLED Display (Right side of board) +// ============================================================================= +#define I2C_SDA 2 // I2C Data +#define I2C_SCL 3 // I2C Clock +#define OLED_ADDRESS 0x3C // Default I2C address for SSD1306 + +// ============================================================================= +// SPI - RC522 RFID Reader (Left side of board) +// Pin order matches RC522 board: SDA, SCK, MOSI, MISO, RST +// ============================================================================= +#define RC522_CS 6 // SDA - Chip Select for Reader +#define SPI_SCK 7 // SCK - SPI Clock +#define SPI_MOSI 8 // MOSI - SPI Data Out +#define SPI_MISO 9 // MISO - SPI Data In +#define RC522_RST 10 // RST - Reset for Reader + +// ============================================================================= +// LED (Right side of board) +// ============================================================================= +#define LED_WHITE 4 // White LED (via 100 ohm resistor) + +// ============================================================================= +// Buzzer (Right side of board) +// ============================================================================= +#define BUZZER_PIN 0 // Piezo buzzer (passive) + +// ============================================================================= +// Touch Sensor (Left side of board) +// ============================================================================= +#define TOUCH_PIN 5 // TTP223 capacitive touch sensor (GPIO0-5 for deep sleep wake) + +#endif // PINS_H diff --git a/firmware/include/secrets.h.example b/firmware/include/secrets.h.example new file mode 100644 index 0000000..f3e9150 --- /dev/null +++ b/firmware/include/secrets.h.example @@ -0,0 +1,14 @@ +/** + * @file secrets.h + * @brief Sensitive credentials — DO NOT COMMIT + * + * Copy this file to secrets.h and fill in your values. + */ + +#ifndef SECRETS_H +#define SECRETS_H + +#define WIFI_SSID "your-ssid" +#define WIFI_PASSWORD "your-password" + +#endif // SECRETS_H diff --git a/firmware/pinout.svg b/firmware/pinout.svg new file mode 100644 index 0000000..ac46444 --- /dev/null +++ b/firmware/pinout.svg @@ -0,0 +1,284 @@ + + + +LCD SDA (4)LCD SCK (3)LCD GND (1)LCD Vcc (2)Buzzer Vcc (+)RFID SCK (2)RFID MISO (4)RFID MOSI (3)TOUCH_1 (IN)LED (+)RFID RST (7)RFID SDA (1)GND3v3RFID GND (6)RFID Vcc (8)Buzzer GND (-)TOUCH_1 Vcc (+)TOUCH_1 GND (-)LED GND (-) diff --git a/firmware/platformio.ini b/firmware/platformio.ini new file mode 100644 index 0000000..8da72ea --- /dev/null +++ b/firmware/platformio.ini @@ -0,0 +1,27 @@ +; PlatformIO Project Configuration File +; Minecraft Orb - ESP32-C3 SuperMini +; https://docs.platformio.org/page/projectconf.html + +[env:esp32c3] +platform = espressif32 +board = esp32-c3-devkitm-1 +framework = arduino + +; Serial monitor +monitor_speed = 115200 + +; Build flags +build_flags = + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_USB_MODE=1 + +; Libraries +lib_deps = + ; OLED Display (SSD1306 via I2C) + adafruit/Adafruit SSD1306@^2.5.7 + adafruit/Adafruit GFX Library@^1.11.5 + ; RFID Reader (RC522 via SPI) + miguelbalboa/MFRC522@^1.4.10 + +; Upload settings for ESP32-C3 SuperMini +upload_speed = 921600 diff --git a/firmware/src/cards.cpp b/firmware/src/cards.cpp new file mode 100644 index 0000000..ff48a9e --- /dev/null +++ b/firmware/src/cards.cpp @@ -0,0 +1,137 @@ +/** + * @file cards.cpp + * @brief RFID Card database implementation + * + * Uses ESP32 Preferences library for NVS storage. + */ + +#include "cards.h" +#include + +// NVS preferences instance +static Preferences prefs; +static int cardCount = 0; + +void initCardDatabase() { + prefs.begin(NVS_NAMESPACE, false); // false = read/write mode + cardCount = prefs.getInt(NVS_CARD_COUNT, 0); + Serial.print("OK: Card database initialized, "); + Serial.print(cardCount); + Serial.println(" cards loaded"); +} + +int getCardCount() { + return cardCount; +} + +bool compareUID(byte* uid1, byte len1, byte* uid2, byte len2) { + if (len1 != len2) return false; + for (byte i = 0; i < len1; i++) { + if (uid1[i] != uid2[i]) return false; + } + return true; +} + +void formatUID(byte* uid, byte uidLength, char* buffer) { + buffer[0] = '\0'; + for (byte i = 0; i < uidLength; i++) { + char hex[4]; + sprintf(hex, "%02X", uid[i]); + strcat(buffer, hex); + if (i < uidLength - 1) strcat(buffer, ":"); + } +} + +int findCardByUID(byte* uid, byte uidLength, QuestCard* card) { + QuestCard temp; + for (int i = 0; i < cardCount; i++) { + if (getCard(i, &temp)) { + if (compareUID(uid, uidLength, temp.uid, temp.uidLength)) { + if (card != nullptr) { + memcpy(card, &temp, sizeof(QuestCard)); + } + return i; + } + } + } + return -1; +} + +bool getCard(int index, QuestCard* card) { + if (index < 0 || index >= cardCount) return false; + + char key[16]; + sprintf(key, "card_%d", index); + + size_t len = prefs.getBytesLength(key); + if (len != sizeof(QuestCard)) return false; + + prefs.getBytes(key, card, sizeof(QuestCard)); + return true; +} + +bool addCard(QuestCard* card) { + if (cardCount >= MAX_CARDS) { + Serial.println("ERROR: Card database full"); + return false; + } + + // Check if card already exists + if (findCardByUID(card->uid, card->uidLength, nullptr) >= 0) { + Serial.println("ERROR: Card already exists"); + return false; + } + + char key[16]; + sprintf(key, "card_%d", cardCount); + + prefs.putBytes(key, card, sizeof(QuestCard)); + cardCount++; + prefs.putInt(NVS_CARD_COUNT, cardCount); + + Serial.print("OK: Card added at index "); + Serial.println(cardCount - 1); + return true; +} + +bool deleteCard(int index) { + if (index < 0 || index >= cardCount) { + Serial.println("ERROR: Invalid card index"); + return false; + } + + // Shift all cards after this one down by one + for (int i = index; i < cardCount - 1; i++) { + QuestCard temp; + char srcKey[16], dstKey[16]; + sprintf(srcKey, "card_%d", i + 1); + sprintf(dstKey, "card_%d", i); + + prefs.getBytes(srcKey, &temp, sizeof(QuestCard)); + prefs.putBytes(dstKey, &temp, sizeof(QuestCard)); + } + + // Remove the last card entry + char lastKey[16]; + sprintf(lastKey, "card_%d", cardCount - 1); + prefs.remove(lastKey); + + cardCount--; + prefs.putInt(NVS_CARD_COUNT, cardCount); + + Serial.print("OK: Card deleted, "); + Serial.print(cardCount); + Serial.println(" cards remaining"); + return true; +} + +void clearAllCards() { + for (int i = 0; i < cardCount; i++) { + char key[16]; + sprintf(key, "card_%d", i); + prefs.remove(key); + } + cardCount = 0; + prefs.putInt(NVS_CARD_COUNT, 0); + Serial.println("OK: All cards cleared"); +} diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp new file mode 100644 index 0000000..ecfb222 --- /dev/null +++ b/firmware/src/main.cpp @@ -0,0 +1,1631 @@ +/** + * @file main.cpp + * @brief Treasure Hunt Orb firmware + * + * Features: + * - SSD1306 OLED Display (I2C) + * - RC522 RFID Reader (SPI) + * - White LED indicator + * - Piezo buzzer + * - TTP223 capacitive touch sensor + * - WiFi connectivity + * - RFID card programming via serial + */ + +#include +#include +#include +#include +// #include // Disabled - not needed for now +#include +#include +#include +#include "pins.h" +#include "config.h" +#include "cards.h" + +// Display configuration +#define SCREEN_WIDTH 128 +#define SCREEN_HEIGHT 64 + +// Create objects +Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1); +MFRC522 rfid(RC522_CS, RC522_RST); + +// State tracking +bool displayOK = false; +bool rfidOK = false; +bool wifiOK = false; +unsigned long lastBreathUpdate = 0; +int breathBrightness = 0; +int breathDirection = 1; // 1 = getting brighter, -1 = getting dimmer + +// PWM configuration for LED breathing +#define LED_PWM_CHANNEL 0 +#define LED_PWM_FREQ 5000 +#define LED_PWM_RESOLUTION 8 // 8-bit = 0-255 + +// Power management +unsigned long lastActivityTime = 0; +bool displayAsleep = false; + +// Programming mode +bool programmingMode = false; +bool waitingForCard = false; +String pendingCardName = ""; +String pendingCardClue = ""; +String serialBuffer = ""; + +// Authentication mode (required at startup) +bool authMode = true; // Start in auth mode +int authCount = 0; // Number of successful authentications +int lastAuthId = 0; // Last authenticated ID (for order checking) +String lastAuthName = ""; // Last authenticated name (for display) +#define AUTH_REQUIRED_USERS 5 // Number of users needed to complete auth + +// Forward declarations +void handleProgModeCard(); +void showStatus(); +void showProgModeScreen(); +void showAuthScreen(); +void resetActivity(); +void checkPowerSave(); +void enterDeepSleep(); +void wakeDisplay(); +void checkTouch(); + +/** + * @brief Initialize the OLED display + * + * Returns: + * bool: true if display initialized successfully + */ +bool initDisplay() { + Wire.begin(I2C_SDA, I2C_SCL); + + if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) { + Serial.println("ERROR: SSD1306 display not found!"); + return false; + } + + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(SSD1306_WHITE); + display.setCursor(0, 0); + display.println("Minecraft Orb"); + display.println("-------------"); + display.println("Hello World!"); + display.display(); + + Serial.println("OK: Display initialized"); + return true; +} + +/** + * @brief Initialize the RC522 RFID reader + * + * Returns: + * bool: true if reader initialized successfully + */ +bool initRFID() { + // Initialize SPI with custom pins for ESP32-C3 + SPI.begin(SPI_SCK, SPI_MISO, SPI_MOSI, RC522_CS); + + rfid.PCD_Init(); + delay(50); // Small delay for RC522 to stabilize + + // Check if RC522 is responding + byte version = rfid.PCD_ReadRegister(MFRC522::VersionReg); + if (version == 0x00 || version == 0xFF) { + Serial.println("ERROR: RC522 not found!"); + return false; + } + + Serial.print("OK: RC522 initialized (version 0x"); + Serial.print(version, HEX); + Serial.println(")"); + return true; +} + +/** + * @brief Initialize the LED pin with PWM for breathing effect + */ +void initLED() { + ledcSetup(LED_PWM_CHANNEL, LED_PWM_FREQ, LED_PWM_RESOLUTION); + ledcAttachPin(LED_WHITE, LED_PWM_CHANNEL); + ledcWrite(LED_PWM_CHANNEL, 0); + Serial.println("OK: LED initialized (PWM)"); +} + +/** + * @brief Set LED brightness (0-255) + */ +void setLED(int brightness) { + ledcWrite(LED_PWM_CHANNEL, brightness); +} + +/** + * @brief Initialize the buzzer pin + */ +void initBuzzer() { + pinMode(BUZZER_PIN, OUTPUT); + digitalWrite(BUZZER_PIN, LOW); + Serial.println("OK: Buzzer initialized"); +} + +/** + * @brief Play a tone on the buzzer + * + * Args: + * frequency: Tone frequency in Hz + * duration: Duration in milliseconds + */ +void beep(int frequency, int duration) { + tone(BUZZER_PIN, frequency, duration); + delay(duration); + noTone(BUZZER_PIN); +} + +/** + * @brief Play startup melody + */ +void playStartupSound() { + beep(523, 100); // C5 + beep(659, 100); // E5 + beep(784, 150); // G5 +} + +/** + * @brief Play card detected sound + */ +void playCardSound() { + beep(880, 100); // A5 + beep(1109, 150); // C#6 +} + +/** + * @brief Play touch feedback sound + */ +void playTouchSound() { + beep(1318, 50); // E6 - short chirp +} + +// ============================================================================= +// Morse Code +// ============================================================================= + +// Morse code timing (in ms) - 75% slower than standard +#define MORSE_DOT 260 +#define MORSE_DASH (MORSE_DOT * 3) +#define MORSE_SYMBOL MORSE_DOT // Gap between dots/dashes +#define MORSE_LETTER (MORSE_DOT * 3) // Gap between letters +#define MORSE_WORD (MORSE_DOT * 7) // Gap between words +#define MORSE_FREQ 800 // Tone frequency + +// Morse code lookup table (A-Z, 0-9) +// Each string contains dots and dashes for the character +const char* morseTable[] = { + ".-", // A + "-...", // B + "-.-.", // C + "-..", // D + ".", // E + "..-.", // F + "--.", // G + "....", // H + "..", // I + ".---", // J + "-.-", // K + ".-..", // L + "--", // M + "-.", // N + "---", // O + ".--.", // P + "--.-", // Q + ".-.", // R + "...", // S + "-", // T + "..-", // U + "...-", // V + ".--", // W + "-..-", // X + "-.--", // Y + "--..", // Z + "-----", // 0 + ".----", // 1 + "..---", // 2 + "...--", // 3 + "....-", // 4 + ".....", // 5 + "-....", // 6 + "--...", // 7 + "---..", // 8 + "----.", // 9 +}; + +/** + * @brief Get Morse code pattern for a character + * + * Args: + * c: Character to look up (A-Z, 0-9) + * + * Returns: + * const char*: Morse pattern or NULL if not found + */ +const char* getMorsePattern(char c) { + c = toupper(c); + if (c >= 'A' && c <= 'Z') { + return morseTable[c - 'A']; + } else if (c >= '0' && c <= '9') { + return morseTable[26 + (c - '0')]; + } + return NULL; +} + +/** + * @brief Play a single Morse symbol (dot or dash) + * + * Args: + * isDash: true for dash, false for dot + * displayX: X position on display for symbol + * displayY: Y position on display for symbol + */ +void playMorseSymbol(bool isDash, int displayX, int displayY) { + int duration = isDash ? MORSE_DASH : MORSE_DOT; + + // LED on + setLED(255); + + // Start tone + tone(BUZZER_PIN, MORSE_FREQ, duration); + + // Update display with symbol + if (displayOK) { + display.setTextSize(2); + display.setCursor(displayX, displayY); + display.print(isDash ? "-" : "."); + display.display(); + } + + delay(duration); + + // LED off + setLED(0); + noTone(BUZZER_PIN); + + // Gap between symbols + delay(MORSE_SYMBOL); +} + +/** + * @brief Play a message in Morse code + * + * Only shows dots and dashes on the LCD, not the actual message. + * + * Args: + * message: The message to play + */ +void playMorseMessage(const char* message) { + Serial.print("Playing Morse: "); + Serial.println(message); + + // Show header on display (no message text - that's the puzzle!) + if (displayOK) { + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.println("SECRET MESSAGE"); + display.println("INCOMING..."); + display.display(); + } + + int displayX = 0; + int displayY = 40; // Bottom area for Morse symbols + + for (int i = 0; message[i] != '\0'; i++) { + char c = message[i]; + + if (c == ' ') { + // Word gap + delay(MORSE_WORD - MORSE_LETTER); + displayX = 0; // Reset for next word + if (displayOK) { + // Clear the Morse symbol area + display.fillRect(0, 36, 128, 28, SSD1306_BLACK); + display.display(); + } + continue; + } + + const char* pattern = getMorsePattern(c); + if (pattern == NULL) continue; + + // Play each symbol in the pattern + for (int j = 0; pattern[j] != '\0'; j++) { + playMorseSymbol(pattern[j] == '-', displayX, displayY); + displayX += 14; // Move cursor for next symbol + + // Wrap if needed + if (displayX > 114) { + displayX = 0; + if (displayOK) { + display.fillRect(0, 36, 128, 28, SSD1306_BLACK); + display.display(); + } + } + } + + // Letter gap - add visual separator + if (displayOK) { + display.setCursor(displayX, displayY); + display.setTextSize(2); + display.print(" "); + display.display(); + } + delay(MORSE_LETTER - MORSE_SYMBOL); + displayX += 8; // Extra space between letters + } + + Serial.println("Morse playback complete"); +} + +// ============================================================================= +// MIFARE Card Read/Write - Store quest data directly on cards +// ============================================================================= + +// Default MIFARE key (factory default) +MFRC522::MIFARE_Key mifareKey = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// Card data layout (MIFARE Classic 1K): +// Sector 1 (blocks 4-7): Name (blocks 4,5 = 32 bytes, block 7 = trailer) +// Sector 2 (blocks 8-11): Clue part 1 (blocks 8,9,10 = 48 bytes) +// Sector 3 (blocks 12-15): Clue part 2 (blocks 12,13,14 = 48 bytes) +// Sector 4 (blocks 16-19): Clue part 3 (blocks 16,17,18 = 48 bytes) +// Total: 32 + 144 = 176 bytes available + +#define NAME_BLOCK_START 4 // Sector 1 +#define CLUE_BLOCK_START 8 // Sector 2 + +/** + * @brief Authenticate a sector for read/write + * + * Args: + * blockAddr: Any block in the sector to authenticate + * + * Returns: + * bool: true if authentication successful + */ +bool authenticateSector(byte blockAddr) { + MFRC522::StatusCode status = rfid.PCD_Authenticate( + MFRC522::PICC_CMD_MF_AUTH_KEY_A, + blockAddr, + &mifareKey, + &(rfid.uid) + ); + return (status == MFRC522::STATUS_OK); +} + +/** + * @brief Write quest data to MIFARE card + * + * Args: + * name: Quest name (max 32 chars) + * clue: Quest clue (max 128 chars) + * + * Returns: + * bool: true if write successful + */ +bool writeQuestToCard(const char* name, const char* clue) { + byte buffer[18]; // 16 data + 2 CRC + MFRC522::StatusCode status; + + Serial.println("Writing quest data to card..."); + + // Write name (2 blocks = 32 bytes) in sector 1 + if (!authenticateSector(NAME_BLOCK_START)) { + Serial.println("ERROR: Auth failed for name sector"); + return false; + } + + // Block 4: first 16 chars of name + memset(buffer, 0, 16); + strncpy((char*)buffer, name, 16); + status = rfid.MIFARE_Write(4, buffer, 16); + if (status != MFRC522::STATUS_OK) { + Serial.print("ERROR: Write block 4 failed: "); + Serial.println(rfid.GetStatusCodeName(status)); + return false; + } + + // Block 5: next 16 chars of name + memset(buffer, 0, 16); + if (strlen(name) > 16) { + strncpy((char*)buffer, name + 16, 16); + } + status = rfid.MIFARE_Write(5, buffer, 16); + if (status != MFRC522::STATUS_OK) { + Serial.print("ERROR: Write block 5 failed: "); + Serial.println(rfid.GetStatusCodeName(status)); + return false; + } + + // Write clue (up to 9 blocks = 144 bytes) across sectors 2, 3, 4 + int clueLen = strlen(clue); + int clueOffset = 0; + byte blocks[] = {8, 9, 10, 12, 13, 14, 16, 17, 18}; // Skip sector trailers + + for (int i = 0; i < 9 && clueOffset < clueLen; i++) { + byte blockAddr = blocks[i]; + + // Authenticate sector if needed (new sector every 4 blocks) + if (blockAddr == 8 || blockAddr == 12 || blockAddr == 16) { + if (!authenticateSector(blockAddr)) { + Serial.print("ERROR: Auth failed for block "); + Serial.println(blockAddr); + return false; + } + } + + memset(buffer, 0, 16); + int copyLen = min(16, clueLen - clueOffset); + strncpy((char*)buffer, clue + clueOffset, copyLen); + clueOffset += 16; + + status = rfid.MIFARE_Write(blockAddr, buffer, 16); + if (status != MFRC522::STATUS_OK) { + Serial.print("ERROR: Write block "); + Serial.print(blockAddr); + Serial.print(" failed: "); + Serial.println(rfid.GetStatusCodeName(status)); + return false; + } + } + + Serial.println("OK: Quest data written to card"); + return true; +} + +/** + * @brief Read quest data from MIFARE card + * + * Args: + * name: Buffer for quest name (min 33 bytes) + * clue: Buffer for quest clue (min 145 bytes) + * + * Returns: + * bool: true if read successful and data found + */ +bool readQuestFromCard(char* name, char* clue) { + byte buffer[18]; + byte size = 18; + MFRC522::StatusCode status; + + // Read name from sector 1 + if (!authenticateSector(NAME_BLOCK_START)) { + Serial.println("ERROR: Auth failed for name sector"); + return false; + } + + // Read block 4 + size = 18; + status = rfid.MIFARE_Read(4, buffer, &size); + if (status != MFRC522::STATUS_OK) { + Serial.println("ERROR: Read block 4 failed"); + return false; + } + memcpy(name, buffer, 16); + + // Read block 5 + size = 18; + status = rfid.MIFARE_Read(5, buffer, &size); + if (status != MFRC522::STATUS_OK) { + Serial.println("ERROR: Read block 5 failed"); + return false; + } + memcpy(name + 16, buffer, 16); + name[32] = '\0'; + + // Check if card has data (name not empty/all zeros) + bool hasData = false; + for (int i = 0; i < 32 && !hasData; i++) { + if (name[i] != 0 && name[i] != 0xFF) hasData = true; + } + if (!hasData) { + Serial.println("Card appears blank"); + return false; + } + + // Read clue from sectors 2, 3, 4 + byte blocks[] = {8, 9, 10, 12, 13, 14, 16, 17, 18}; + int clueOffset = 0; + + for (int i = 0; i < 9; i++) { + byte blockAddr = blocks[i]; + + // Authenticate sector if needed + if (blockAddr == 8 || blockAddr == 12 || blockAddr == 16) { + if (!authenticateSector(blockAddr)) { + Serial.print("ERROR: Auth failed for block "); + Serial.println(blockAddr); + return false; + } + } + + size = 18; + status = rfid.MIFARE_Read(blockAddr, buffer, &size); + if (status != MFRC522::STATUS_OK) { + Serial.print("ERROR: Read block "); + Serial.print(blockAddr); + Serial.println(" failed"); + return false; + } + + memcpy(clue + clueOffset, buffer, 16); + clueOffset += 16; + } + clue[144] = '\0'; + + // Trim trailing nulls/spaces from name and clue + for (int i = 31; i >= 0 && (name[i] == 0 || name[i] == ' '); i--) { + name[i] = '\0'; + } + for (int i = 143; i >= 0 && (clue[i] == 0 || clue[i] == ' '); i--) { + clue[i] = '\0'; + } + + return true; +} + +// ============================================================================= +// INPUT Command - Touch-based number input +// ============================================================================= + +#define INPUT_TIMEOUT_MS 3000 // 3 seconds of no touch = submit + +/** + * @brief Handle INPUT command - wait for touch-based number input + * + * Args: + * answer: The correct answer (1-10) + * message: Message to display if correct + * + * Returns: + * bool: true if correct answer entered + */ +bool handleInputCommand(int answer, const char* message) { + int count = 0; + unsigned long lastTouchTime = millis(); + bool lastState = false; + bool submitted = false; + + Serial.print("INPUT command: expecting "); + Serial.println(answer); + + // Show initial prompt + if (displayOK) { + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.println("ENTER CODE"); + display.println("----------------"); + display.println(); + display.println("Find and touch the"); + display.println("secret button to"); + display.println("enter your answer."); + display.println(); + display.println("Wait 3s to submit."); + display.display(); + } + + // Wait for first touch to start + Serial.println("Waiting for input..."); + + while (!submitted) { + bool currentTouch = digitalRead(TOUCH_PIN); + + // Detect rising edge (new touch) + if (currentTouch && !lastState) { + resetActivity(); + count++; + lastTouchTime = millis(); + + Serial.print("Touch! Count: "); + Serial.println(count); + + // Audio feedback + beep(1000, 50); + + // Visual feedback + setLED(255); + delay(50); + setLED(0); + + // Update display + if (displayOK) { + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.println("ENTER CODE"); + display.println("----------------"); + display.setTextSize(4); + display.setCursor(50, 28); + display.println(count); + display.display(); + } + + // Cap at 10 + if (count >= 10) { + delay(500); + submitted = true; + } + } + + lastState = currentTouch; + + // Check for timeout (3 seconds of no touch after at least one input) + if (count > 0 && (millis() - lastTouchTime > INPUT_TIMEOUT_MS)) { + submitted = true; + } + + delay(50); // Debounce + } + + Serial.print("Submitted: "); + Serial.println(count); + + // Check answer + if (count == answer) { + Serial.println("CORRECT!"); + + // Success sound + beep(523, 100); + beep(659, 100); + beep(784, 100); + beep(1047, 200); + + // Flash LED + for (int i = 0; i < 5; i++) { + setLED(255); + delay(100); + setLED(0); + delay(100); + } + + // Show success message + if (displayOK) { + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.println("CORRECT!"); + display.println("----------------"); + display.println(); + display.println(message); + display.display(); + } + + delay(5000); // Show message for 5 seconds + return true; + } else { + Serial.println("WRONG!"); + + // Failure sound + beep(200, 300); + beep(150, 400); + + // Show failure + if (displayOK) { + display.clearDisplay(); + display.setTextSize(2); + display.setCursor(0, 20); + display.println(" WRONG!"); + display.display(); + } + + delay(2000); + return false; + } +} + +/** + * @brief Initialize the touch sensor pin + */ +void initTouch() { + pinMode(TOUCH_PIN, INPUT); + Serial.println("OK: Touch sensor initialized"); +} + +// ============================================================================= +// Power Management (Battery Operation) +// ============================================================================= + +/** + * @brief Reset the activity timer (call on any user interaction) + */ +void resetActivity() { + lastActivityTime = millis(); + if (displayAsleep) { + wakeDisplay(); + } +} + +/** + * @brief Turn off the OLED display to save power + */ +void sleepDisplay() { + if (!displayAsleep && displayOK) { + display.ssd1306_command(SSD1306_DISPLAYOFF); + displayAsleep = true; + Serial.println("Display off (power save)"); + } +} + +/** + * @brief Turn the OLED display back on and refresh content + */ +void wakeDisplay() { + if (displayAsleep && displayOK) { + display.ssd1306_command(SSD1306_DISPLAYON); + displayAsleep = false; + Serial.println("Display on"); + // Refresh current screen + if (authMode) { + showAuthScreen(); + } else if (programmingMode) { + showProgModeScreen(); + } else { + showStatus(); + } + } +} + +/** + * @brief Enter deep sleep to conserve battery + * + * Wakes on touch sensor press (GPIO5 HIGH). + * After wake, the ESP32 resets and runs setup() again. + * Requires TOUCH_PIN on GPIO0-5 for deep sleep wake support. + */ +void enterDeepSleep() { + Serial.println("Entering deep sleep..."); + Serial.flush(); + + // Turn off peripherals + if (displayOK) { + display.ssd1306_command(SSD1306_DISPLAYOFF); + } + setLED(0); + noTone(BUZZER_PIN); + rfid.PCD_SoftPowerDown(); + + // Configure wake on touch button (HIGH = touched) + esp_deep_sleep_enable_gpio_wakeup(BIT(TOUCH_PIN), ESP_GPIO_WAKEUP_GPIO_HIGH); + esp_deep_sleep_start(); + // Device resets after wake — execution never reaches here +} + +/** + * @brief Check inactivity timers and manage power saving + * + * Called every loop iteration. Turns off display after DISPLAY_TIMEOUT_MS, + * enters deep sleep after SLEEP_TIMEOUT_MS. + */ +void checkPowerSave() { + unsigned long elapsed = millis() - lastActivityTime; + + if (elapsed >= SLEEP_TIMEOUT_MS) { + enterDeepSleep(); + } else if (elapsed >= DISPLAY_TIMEOUT_MS) { + sleepDisplay(); + } +} + +/** + * @brief Poll touch sensor to wake display / reset activity timer + */ +void checkTouch() { + if (digitalRead(TOUCH_PIN)) { + resetActivity(); + } +} + +// WiFi disabled - not needed for now +#if 0 +bool initWiFi() { + Serial.print("Connecting to WiFi: "); + Serial.println(WIFI_SSID); + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("Minecraft Orb"); + display.println("-------------"); + display.println(); + display.println("Connecting WiFi..."); + display.println(WIFI_SSID); + display.display(); + } + + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + unsigned long startTime = millis(); + while (WiFi.status() != WL_CONNECTED) { + if (millis() - startTime > WIFI_TIMEOUT_MS) { + Serial.println("WARN: WiFi connection timeout"); + return false; + } + delay(500); + Serial.print("."); + } + + Serial.println(); + Serial.print("OK: WiFi connected, IP: "); + Serial.println(WiFi.localIP()); + return true; +} +#endif + +/** + * @brief Display status on OLED + */ +void showStatus() { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("Minecraft Orb"); + display.println("-------------"); + display.println(); + display.print("RFID: "); + display.println(rfidOK ? "OK" : "FAIL"); + display.println(); + display.println("Scan RFID card..."); + display.display(); +} + +/** + * @brief Show authentication mode screen + */ +void showAuthScreen() { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("=== AUTH REQUIRED ==="); + display.println(); + + if (authCount == 0) { + display.println("Scan AUTH cards"); + display.println("in the correct order"); + display.println(); + display.print("Need "); + display.print(AUTH_REQUIRED_USERS); + display.println(" players"); + } else { + display.print("Authenticated: "); + display.print(authCount); + display.print("/"); + display.println(AUTH_REQUIRED_USERS); + display.print("Last: "); + display.println(lastAuthName); + display.println(); + display.print("Need "); + display.print(AUTH_REQUIRED_USERS - authCount); + display.println(" more"); + } + display.display(); +} + +/** + * @brief Check for RFID card and handle accordingly + * + * In programming mode: delegates to handleProgModeCard() + * In normal mode: reads quest data directly from the card + */ +void checkRFID() { + if (!rfidOK) return; + + // Check for new card + if (!rfid.PICC_IsNewCardPresent()) return; + if (!rfid.PICC_ReadCardSerial()) return; + + resetActivity(); + + // Handle programming mode separately + if (programmingMode && waitingForCard) { + handleProgModeCard(); + rfid.PICC_HaltA(); + rfid.PCD_StopCrypto1(); + return; + } + + // Read card data + char uidStr[24]; + formatUID(rfid.uid.uidByte, rfid.uid.size, uidStr); + + Serial.print("Card detected! UID: "); + Serial.println(uidStr); + + char cardName[33]; + char cardClue[145]; + + if (!readQuestFromCard(cardName, cardClue)) { + // Blank or unreadable card + Serial.println("Blank/unreadable card"); + beep(330, 200); + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("BLANK CARD"); + display.println(); + display.println("UID:"); + display.println(uidStr); + display.display(); + } + + delay(1500); + if (displayOK) { + if (authMode) showAuthScreen(); + else if (!programmingMode) showStatus(); + } + rfid.PICC_HaltA(); + rfid.PCD_StopCrypto1(); + return; + } + + // Card read successfully + Serial.print("Card: "); + Serial.println(cardName); + + String nameStr = String(cardName); + nameStr.toUpperCase(); + + // Handle authentication mode + if (authMode) { + // In auth mode, only AUTH cards are accepted + if (!nameStr.startsWith("AUTH")) { + Serial.println("Auth mode: Non-AUTH card rejected"); + beep(220, 300); + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("AUTH REQUIRED!"); + display.println(); + display.println("Please scan your"); + display.println("AUTH card first."); + display.display(); + } + + delay(1500); + if (displayOK) showAuthScreen(); + rfid.PICC_HaltA(); + rfid.PCD_StopCrypto1(); + return; + } + + // Parse AUTH card: FULLNAME|ID|TXT + String clueStr = String(cardClue); + int sep1 = clueStr.indexOf('|'); + int sep2 = clueStr.indexOf('|', sep1 + 1); + + if (sep1 > 0 && sep2 > sep1) { + String fullName = clueStr.substring(0, sep1); + int cardId = clueStr.substring(sep1 + 1, sep2).toInt(); + String authTxt = clueStr.substring(sep2 + 1); + + Serial.print(" Name: "); + Serial.println(fullName); + Serial.print(" ID: "); + Serial.println(cardId); + + // Check order: ID must be greater than last + if (cardId > lastAuthId) { + // Correct order - accept authentication + authCount++; + lastAuthId = cardId; + lastAuthName = fullName; + + Serial.println(" -> Auth sequence OK"); + + beep(523, 100); + beep(659, 100); + beep(784, 150); + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("AUTH OK!"); + display.println("----------------"); + display.println(); + display.println(fullName); + display.println(); + display.print("Position: "); + display.println(authCount); + display.println(); + display.println(authTxt); + display.display(); + } + + for (int i = 0; i < 3; i++) { + setLED(255); + delay(100); + setLED(0); + delay(100); + } + + delay(2000); + + // Check if all users authenticated + if (authCount >= AUTH_REQUIRED_USERS) { + authMode = false; + Serial.println(); + Serial.print("All "); + Serial.print(AUTH_REQUIRED_USERS); + Serial.println(" users authenticated! Quest starting..."); + + // Victory sound + beep(523, 150); + beep(659, 150); + beep(784, 150); + beep(1047, 300); + + // Celebration LED + for (int i = 0; i < 10; i++) { + setLED(255); + delay(100); + setLED(0); + delay(100); + } + + // Show first clue (stays until next card scan) + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("QUEST BEGINS!"); + display.println("================"); + display.println(); + display.println("Where frogs sing"); + display.println("and lilies float,"); + display.println(); + display.println("In the garden,"); + display.println("find the moat."); + display.display(); + } + + Serial.println("First clue: Where frogs sing and lilies float, In the garden, find the moat."); + + rfid.PICC_HaltA(); + rfid.PCD_StopCrypto1(); + return; + } + } else { + // Wrong order - reset sequence + Serial.println(" -> WRONG ORDER! Resetting..."); + Serial.print(" Expected ID > "); + Serial.print(lastAuthId); + Serial.print(", got "); + Serial.println(cardId); + + authCount = 0; + lastAuthId = 0; + lastAuthName = ""; + + beep(200, 200); + beep(150, 200); + beep(100, 400); + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(2); + display.println("WRONG"); + display.println("ORDER!"); + display.setTextSize(1); + display.println(); + display.println("Sequence reset."); + display.println("Start over!"); + display.display(); + } + + for (int i = 0; i < 10; i++) { + setLED(255); + delay(50); + setLED(0); + delay(50); + } + + delay(2000); + } + } else { + Serial.println("ERROR: Invalid AUTH format"); + beep(220, 300); + } + + if (displayOK) showAuthScreen(); + rfid.PICC_HaltA(); + rfid.PCD_StopCrypto1(); + return; + } + + // Normal mode: check for command cards + if (nameStr.startsWith("MORSE")) { + // MORSE command: play clue as Morse code + Serial.println("Command: MORSE"); + playMorseMessage(cardClue); + delay(1000); + } else if (nameStr.startsWith("INPUT")) { + // INPUT command: parse ANSWER|MESSAGE from clue + Serial.println("Command: INPUT"); + String clueStr = String(cardClue); + int sep = clueStr.indexOf('|'); + if (sep > 0) { + int answer = clueStr.substring(0, sep).toInt(); + String message = clueStr.substring(sep + 1); + handleInputCommand(answer, message.c_str()); + } else { + Serial.println("ERROR: Invalid INPUT format - expected ANSWER|MESSAGE"); + } + delay(1000); + } else if (nameStr.startsWith("AUTH")) { + // AUTH command: parse FULLNAME|ID|TXT from clue + Serial.println("Command: AUTH"); + String clueStr = String(cardClue); + + // Parse three fields: FULLNAME|ID|TXT + int sep1 = clueStr.indexOf('|'); + int sep2 = clueStr.indexOf('|', sep1 + 1); + + if (sep1 > 0 && sep2 > sep1) { + String fullName = clueStr.substring(0, sep1); + String odooId = clueStr.substring(sep1 + 1, sep2); + String authTxt = clueStr.substring(sep2 + 1); + + Serial.print(" Name: "); + Serial.println(fullName); + Serial.print(" ID: "); + Serial.println(odooId); + Serial.print(" Text: "); + Serial.println(authTxt); + + // Success sound + beep(523, 100); + beep(659, 100); + beep(784, 150); + + // Display auth info + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("AUTHENTICATED"); + display.println("----------------"); + display.println(); + display.println(fullName); + display.println(); + display.print("ID: "); + display.println(odooId); + display.println(); + display.println(authTxt); + display.display(); + } + + // Flash LED green-style (rapid flashes) + for (int i = 0; i < 5; i++) { + setLED(255); + delay(80); + setLED(0); + delay(80); + } + + delay(3000); + } else { + Serial.println("ERROR: Invalid AUTH format - expected FULLNAME|ID|TXT"); + beep(220, 300); + } + } else { + // Regular quest card - display clue + Serial.print("Clue: "); + Serial.println(cardClue); + + playCardSound(); + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println(cardName); + display.println("----------------"); + display.println(); + display.println(cardClue); + display.display(); + } + + // Flash LED to indicate success + for (int i = 0; i < 3; i++) { + setLED(255); + delay(100); + setLED(0); + delay(100); + } + + delay(3000); // Show clue for 3 seconds + } + + if (displayOK && !programmingMode && !authMode) { + showStatus(); + } + + rfid.PICC_HaltA(); + rfid.PCD_StopCrypto1(); +} + +/** + * @brief Slow breathing LED effect to indicate system is running + * + * Uses exponential curve so LED spends more time dim than bright. + * Full breath cycle takes ~4 seconds (2s up, 2s down) + */ +void breatheLED() { + unsigned long now = millis(); + // Update every 20ms for smooth animation + if (now - lastBreathUpdate >= 20) { + lastBreathUpdate = now; + + // Update linear position (0-255) + breathBrightness += breathDirection * 3; + + // Reverse direction at limits + if (breathBrightness >= 255) { + breathBrightness = 255; + breathDirection = -1; + } else if (breathBrightness <= 0) { + breathBrightness = 0; + breathDirection = 1; + } + + // Apply exponential curve: more time dim, quick peak + // Formula: (x^3) / 65025 gives range 0-255 with cubic falloff + int actualBrightness = ((long)breathBrightness * breathBrightness * breathBrightness) / 65025; + setLED(actualBrightness); + } +} + +/** + * @brief Show programming mode screen on OLED + */ +void showProgModeScreen() { + display.clearDisplay(); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("=== PROG MODE ==="); + display.println(); + if (waitingForCard) { + display.println("Scan card to write:"); + display.println(pendingCardName); + } else { + display.println("Commands:"); + display.println("SCAN ADD EXIT"); + display.println(); + display.println("Writes to MIFARE"); + display.println("cards directly."); + } + display.display(); +} + +/** + * @brief Print help message for serial commands + */ +void printHelp() { + Serial.println(); + Serial.println("=== Minecraft Orb - Help ==="); + Serial.println(); + Serial.println("Commands:"); + Serial.println(" PROG Enter programming mode"); + Serial.println(" EXIT Exit programming mode"); + Serial.println(" SCAN Read card contents"); + Serial.println(" ADD | Write quest to card"); + Serial.println(" HELP Show this help"); + Serial.println(); + Serial.println("Examples:"); + Serial.println(" ADD Quest 1|Look under the old oak tree"); + Serial.println(" ADD MORSE|HELLO WORLD"); + Serial.println(" ADD INPUT|5|The treasure is in the garden"); + Serial.println(" ADD AUTH|John Smith|12345|Welcome!"); + Serial.println(); + Serial.println("Command cards (use as name):"); + Serial.println(" MORSE - Plays clue as Morse code (LED + buzzer)"); + Serial.println(" INPUT - Touch puzzle: tap button N times"); + Serial.println(" Format: |"); + Serial.println(" AUTH - Authentication card"); + Serial.println(" Format: ||"); + Serial.println(); + Serial.println("Data is written directly to MIFARE Classic 1K cards."); + Serial.println(); +} + +/** + * @brief Process serial command + * + * Args: + * cmd: Command string to process + */ +void processCommand(String cmd) { + cmd.trim(); + if (cmd.length() == 0) return; + + String cmdUpper = cmd; + cmdUpper.toUpperCase(); + + // Commands available anytime + if (cmdUpper == "HELP") { + printHelp(); + return; + } + + if (cmdUpper == "PROG") { + programmingMode = true; + waitingForCard = false; + setLED(255); // Solid LED in prog mode + Serial.println(); + Serial.println(">>> Entered programming mode"); + printHelp(); + if (displayOK) showProgModeScreen(); + return; + } + + // Commands only in programming mode + if (!programmingMode) { + Serial.println("Not in programming mode. Send PROG first."); + return; + } + + if (cmdUpper == "EXIT") { + programmingMode = false; + waitingForCard = false; + pendingCardName = ""; + pendingCardClue = ""; + setLED(0); + Serial.println(">>> Exited programming mode"); + if (displayOK) showStatus(); + return; + } + + if (cmdUpper == "SCAN") { + waitingForCard = true; + pendingCardName = ""; + pendingCardClue = ""; + Serial.println("Scan a card to see its UID..."); + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.println("=== SCAN MODE ==="); + display.println(); + display.println("Present card to"); + display.println("read UID..."); + display.display(); + } + return; + } + + if (cmdUpper.startsWith("ADD ")) { + String params = cmd.substring(4); + int sep = params.indexOf('|'); + if (sep < 0) { + Serial.println("ERROR: Format: ADD |"); + return; + } + pendingCardName = params.substring(0, sep); + pendingCardClue = params.substring(sep + 1); + pendingCardName.trim(); + pendingCardClue.trim(); + + if (pendingCardName.length() == 0 || pendingCardClue.length() == 0) { + Serial.println("ERROR: Name and clue cannot be empty"); + pendingCardName = ""; + pendingCardClue = ""; + return; + } + + waitingForCard = true; + Serial.print("Scan card to add as '"); + Serial.print(pendingCardName); + Serial.println("'..."); + if (displayOK) showProgModeScreen(); + return; + } + + Serial.print("Unknown command: "); + Serial.println(cmd); + Serial.println("Type HELP for available commands"); +} + +/** + * @brief Handle card scan in programming mode + * + * Writes quest data directly to the MIFARE card. + */ +void handleProgModeCard() { + char uidStr[24]; + formatUID(rfid.uid.uidByte, rfid.uid.size, uidStr); + + if (pendingCardName.length() > 0) { + // Writing quest data to card + Serial.print("Writing to card "); + Serial.print(uidStr); + Serial.println("..."); + + if (writeQuestToCard(pendingCardName.c_str(), pendingCardClue.c_str())) { + Serial.print("OK: Card programmed as '"); + Serial.print(pendingCardName); + Serial.println("'"); + beep(880, 100); + beep(1109, 150); + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.println("CARD PROGRAMMED!"); + display.println(); + display.println(pendingCardName); + display.display(); + delay(1500); + } + } else { + Serial.println("ERROR: Failed to write to card"); + beep(220, 300); // Low tone for error + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.println("WRITE FAILED!"); + display.println(); + display.println("Check card type"); + display.display(); + delay(1500); + } + } + + pendingCardName = ""; + pendingCardClue = ""; + waitingForCard = false; + } else { + // Just scanning - read and display card contents + Serial.print("Card UID: "); + Serial.println(uidStr); + + char name[33]; + char clue[145]; + if (readQuestFromCard(name, clue)) { + Serial.print(" Name: "); + Serial.println(name); + Serial.print(" Clue: "); + Serial.println(clue); + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.println("CARD CONTENTS:"); + display.println(name); + display.println("----------------"); + display.println(clue); + display.display(); + delay(2000); + } + } else { + Serial.println(" -> Blank or unreadable card"); + + if (displayOK) { + display.clearDisplay(); + display.setCursor(0, 0); + display.println("BLANK CARD"); + display.println(); + display.println("UID:"); + display.println(uidStr); + display.display(); + delay(1500); + } + } + + beep(1000, 100); + waitingForCard = false; + } + + if (displayOK) showProgModeScreen(); +} + +/** + * @brief Check serial port for incoming commands + * + * Echoes characters back so user can see what they're typing. + */ +void checkSerial() { + while (Serial.available()) { + resetActivity(); + char c = Serial.read(); + if (c == '\n' || c == '\r') { + Serial.println(); // Echo newline + if (serialBuffer.length() > 0) { + processCommand(serialBuffer); + serialBuffer = ""; + } + Serial.print("> "); // Prompt for next command + } else if (c == 8 || c == 127) { + // Handle backspace + if (serialBuffer.length() > 0) { + serialBuffer.remove(serialBuffer.length() - 1); + Serial.print("\b \b"); // Erase character on terminal + } + } else { + serialBuffer += c; + Serial.print(c); // Echo character + } + } +} + +void setup() { + // Initialize serial + Serial.begin(115200); + delay(1000); // Give time for USB CDC to initialize + + Serial.println(); + Serial.println("================================"); + Serial.println(" Minecraft Orb - Hello World"); + Serial.println("================================"); + Serial.println(); + + // Initialize components + initLED(); + initBuzzer(); + initTouch(); + displayOK = initDisplay(); + rfidOK = initRFID(); + initCardDatabase(); + // wifiOK = initWiFi(); // Disabled - not needed for now + + // Play startup sound + playStartupSound(); + + Serial.println(); + Serial.println("Initialization complete!"); + Serial.println("Type HELP for commands, PROG to program cards"); + Serial.println(); + Serial.print("> "); + + // Show auth screen on display (auth required at startup) + if (displayOK) { + delay(1000); + showAuthScreen(); + } + + Serial.println("Authentication required - scan AUTH cards in order"); + + // Start activity timer for power management + lastActivityTime = millis(); +} + +void loop() { + // Check serial for programming commands + checkSerial(); + + // Slow breathing LED as heartbeat (only when not in programming mode or auth mode) + if (!programmingMode && !authMode) { + breatheLED(); + } + + // Check for RFID cards + checkRFID(); + + // Check touch sensor (wake display on touch) + checkTouch(); + + // Power management (display timeout + light sleep) + checkPowerSave(); + + // Small delay to prevent overwhelming the CPU + delay(50); +}