From 01c24d17b6d6ddae7416eed58a03e8163d1ebafb Mon Sep 17 00:00:00 2001 From: Rasmus Date: Wed, 9 Sep 2026 18:36:17 +0200 Subject: [PATCH] Phase 7: EC11 encoder, life counter views, WS2812B RMT + LEDC buzzer feedback, E2E UI state machine; document build/hardware notes --- AGENTS.md | 30 +++- main/CMakeLists.txt | 4 + main/app_state.c | 91 ++++++++++++ main/app_state.h | 40 +++++ main/buzzer.c | 84 +++++++++++ main/buzzer.h | 39 +++++ main/display_manager.c | 42 ++++++ main/display_manager.h | 6 + main/encoder.c | 103 +++++++++++++ main/encoder.h | 51 +++++++ main/led_manager.c | 90 ++++++++++++ main/led_manager.h | 33 +++++ main/ui_task.c | 326 +++++++++++++++++++++++++++++++++++++---- main/web_server.c | 4 +- 14 files changed, 914 insertions(+), 29 deletions(-) create mode 100644 main/app_state.c create mode 100644 main/app_state.h create mode 100644 main/buzzer.c create mode 100644 main/buzzer.h create mode 100644 main/encoder.c create mode 100644 main/encoder.h create mode 100644 main/led_manager.c create mode 100644 main/led_manager.h diff --git a/AGENTS.md b/AGENTS.md index 6b88030..6e84d87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,8 @@ State transitions must be managed cleanly using FreeRTOS Event Groups or Queue m Do NOT assume an SD card is attached. All local database operations use standard C file I/O (`fopen`, `fread`, `fwrite`) over LittleFS. +Contents are pre-seeded from the repo `storage/` folder via `littlefs_create_partition_image(storage storage FLASH_IN_PROJECT)` at build time. + * `/spiffs/mappings.json` — Maps scanned UIDs to Card Names: ```json { @@ -81,6 +83,8 @@ Do NOT assume an SD card is attached. All local database operations use standard "price_usd": "1.25" } + /spiffs/cards/[UID].jpg — Cached Scryfall cover art (streamed via tjpgd). + 6. Coding Standards & Conventions When generating or editing code for this repository: @@ -128,4 +132,28 @@ Build Commands When modifying hardware code: Cross-check the shared SPI bus configurations to prevent SPI2_HOST bus conflicts between ST7789 and MFRC522. - When implementing network code: Use non-blocking esp_http_client requests running on a dedicated FreeRTOS task to keep screen rendering smooth. \ No newline at end of file + When implementing network code: Use non-blocking esp_http_client requests running on a dedicated FreeRTOS task to keep screen rendering smooth. + +9. Module Map (main/) + +| File | Responsibility | Task / Core | +| :--- | :--- | :--- | +| `main.c` | Boot orchestration | app_main | +| `hw_pins.h` | GPIO assignments | - | +| `spi_bus_manager.c` | Shared SPI2_HOST bus init | - | +| `storage_manager.c` | LittleFS mount + JSON mapping/card helpers | - | +| `rfid_manager.c` | MFRC522, UID hex, Core-0 pinning | rc522_polling_task / 0 | +| `display_manager.c` | ST7789 240x320 primitives, 5x7 text, big digits, tjpgd stream | ui_task / 1 | +| `ui_task.c` | State machine + all screens | ui_task / 1 | +| `wifi_manager.c` | STA auto-connect + reconnect | event loop | +| `scryfall_client.c` | Scryfall HTTPS, cJSON cache, prefetch | net_task / 0 | +| `web_server.c` | SoftAP MTG-Companion 192.168.4.1 + pairing SPA | httpd task | +| `encoder.c` | EC11 ISR decode + button | ISR | +| `led_manager.c` | WS2812B RMT | ISR + ui_task | +| `buzzer.c` | LEDC PWM tones | ui_task | +| `app_state.c` | STATE_* + life counter values | all | + +Build & runtime notes: +- `idf.py menuconfig` -> "MTG RFID Companion" sets `MTG_WIFI_SSID` / `MTG_WIFI_PASS`; leave empty to skip STA (SoftAP web pairing always runs). +- Flash: `idf.py -p COMx flash monitor`. The `storage/` folder is baked into the LittleFS partition image. +- The ST7789 is driven in RGB565 big-endian wire order; if colors appear inverted on a given panel variant, toggle `esp_lcd_panel_invert_color()` / `LCD_RGB_ELEMENT_ORDER_*` in `display_manager.c`. \ No newline at end of file diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 98eab0f..236a1ea 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -8,5 +8,9 @@ idf_component_register(SRCS "wifi_manager.c" "scryfall_client.c" "web_server.c" + "encoder.c" + "led_manager.c" + "buzzer.c" + "app_state.c" INCLUDE_DIRS "." ) \ No newline at end of file diff --git a/main/app_state.c b/main/app_state.c new file mode 100644 index 0000000..757a4c7 --- /dev/null +++ b/main/app_state.c @@ -0,0 +1,91 @@ +/* + * MTG RFID Companion - System state machine + */ + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "app_state.h" + +static SemaphoreHandle_t s_lock; +static app_state_t s_view = APP_STATE_SCANNER; +static int32_t s_life = 20; +static int32_t s_life_preset = 20; + +const char *app_state_name(app_state_t state) +{ + switch (state) { + case APP_STATE_BOOT: return "BOOT"; + 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_REGISTRATION: return "REGISTRATION"; + } + return "UNKNOWN"; +} + +static void ensure_lock(void) +{ + if (s_lock == NULL) { + s_lock = xSemaphoreCreateMutex(); + } +} + +void app_set_life(int32_t life) +{ + ensure_lock(); + xSemaphoreTake(s_lock, portMAX_DELAY); + s_life = life; + xSemaphoreGive(s_lock); +} + +int32_t app_get_life(void) +{ + ensure_lock(); + xSemaphoreTake(s_lock, portMAX_DELAY); + int32_t v = s_life; + xSemaphoreGive(s_lock); + return v; +} + +void app_set_life_preset(int32_t preset) +{ + ensure_lock(); + xSemaphoreTake(s_lock, portMAX_DELAY); + s_life_preset = preset; + s_life = preset; + xSemaphoreGive(s_lock); +} + +int32_t app_get_life_preset(void) +{ + ensure_lock(); + xSemaphoreTake(s_lock, portMAX_DELAY); + int32_t v = s_life_preset; + xSemaphoreGive(s_lock); + return v; +} + +void app_set_view(app_state_t view) +{ + ensure_lock(); + xSemaphoreTake(s_lock, portMAX_DELAY); + s_view = view; + xSemaphoreGive(s_lock); +} + +app_state_t app_get_view(void) +{ + ensure_lock(); + xSemaphoreTake(s_lock, portMAX_DELAY); + app_state_t v = s_view; + xSemaphoreGive(s_lock); + return v; +} + +void app_enter_keyed_view(app_state_t view) +{ + if (view == APP_STATE_LIFE_COUNTER || view == APP_STATE_CARD_VIEW) { + app_set_view(view); + } +} \ No newline at end of file diff --git a/main/app_state.h b/main/app_state.h new file mode 100644 index 0000000..45ccbc9 --- /dev/null +++ b/main/app_state.h @@ -0,0 +1,40 @@ +/* + * MTG RFID Companion - System state machine + * + * Phase 7 Step 7.2: State transitions CARD_VIEW <-> LIFE_COUNTER, plus the + * boot/scanner/registration states. Driven by the UI task on Core 1. + */ + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + APP_STATE_BOOT = 0, + APP_STATE_SCANNER, + APP_STATE_CARD_VIEW, + APP_STATE_LIFE_COUNTER, + APP_STATE_REGISTRATION, +} app_state_t; + +const char *app_state_name(app_state_t state); + +/* Life counter state */ +void app_set_life(int32_t life); +int32_t app_get_life(void); +void app_set_life_preset(int32_t preset); +int32_t app_get_life_preset(void); + +/* Which "screen" is active - CARD_VIEW vs LIFE_COUNTER. */ +void app_set_view(app_state_t view); +app_state_t app_get_view(void); +void app_enter_keyed_view(app_state_t view); + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/main/buzzer.c b/main/buzzer.c new file mode 100644 index 0000000..40c7d11 --- /dev/null +++ b/main/buzzer.c @@ -0,0 +1,84 @@ +/* + * MTG RFID Companion - Piezo buzzer driver (LEDC PWM) + */ + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "driver/ledc.h" +#include "hw_pins.h" +#include "buzzer.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 + +esp_err_t buzzer_init(void) +{ + ledc_timer_config_t timer_cfg = { + .speed_mode = BUZZER_SPEED_MODE, + .timer_num = BUZZER_TIMER_NUM, + .duty_resolution = LEDC_TIMER_13_BIT, + .freq_hz = 2000, + .clk_cfg = LEDC_AUTO_CLK, + }; + esp_err_t ret = ledc_timer_config(&timer_cfg); + if (ret != ESP_OK) { + return ret; + } + + ledc_channel_config_t chan_cfg = { + .gpio_num = HW_BUZZER_GPIO, + .speed_mode = BUZZER_SPEED_MODE, + .channel = BUZZER_CHANNEL, + .timer_sel = BUZZER_TIMER_NUM, + .duty = 0, + .hpoint = 0, + }; + ret = ledc_channel_config(&chan_cfg); + if (ret != ESP_OK) { + return ret; + } + + ESP_LOGI(TAG, "buzzer ready on GPIO %d", HW_BUZZER_GPIO); + return ESP_OK; +} + +esp_err_t buzzer_start(uint32_t freq_hz, uint32_t duty_percent) +{ + if (freq_hz == 0) { + return buzzer_stop(); + } + uint32_t duty = ((uint32_t)1 << 13) * duty_percent / 100u; + if (duty_percent >= 100) { + duty = ((uint32_t)1 << 13); + } + + esp_err_t ret = ledc_set_freq(BUZZER_SPEED_MODE, BUZZER_TIMER_NUM, freq_hz); + if (ret != ESP_OK) { + return ret; + } + ret = ledc_set_duty(BUZZER_SPEED_MODE, BUZZER_CHANNEL, duty); + if (ret != ESP_OK) { + return ret; + } + return ledc_update_duty(BUZZER_SPEED_MODE, BUZZER_CHANNEL); +} + +esp_err_t buzzer_stop(void) +{ + ledc_set_duty(BUZZER_SPEED_MODE, BUZZER_CHANNEL, 0); + return ledc_update_duty(BUZZER_SPEED_MODE, BUZZER_CHANNEL); +} + +esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms) +{ + esp_err_t ret = buzzer_start(freq_hz, 30); + if (ret != ESP_OK) { + return ret; + } + vTaskDelay(pdMS_TO_TICKS(duration_ms)); + return buzzer_stop(); +} \ No newline at end of file diff --git a/main/buzzer.h b/main/buzzer.h new file mode 100644 index 0000000..36cbb63 --- /dev/null +++ b/main/buzzer.h @@ -0,0 +1,39 @@ +/* + * MTG RFID Companion - Piezo buzzer driver (LEDC PWM) + * + * Phase 7 Step 7.4: Subtle audio cues on GPIO 14. + */ + +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Initialize LEDC PWM channel for the buzzer. + */ +esp_err_t buzzer_init(void); + +/** + * @brief Start a tone at the given frequency. + */ +esp_err_t buzzer_start(uint32_t freq_hz, uint32_t duty_percent); + +/** + * @brief Silence the buzzer. + */ +esp_err_t buzzer_stop(void); + +/** + * @brief Play a short chirp (blocking). + * @param freq_hz Tone frequency + * @param duration_ms Tone length in ms + */ +esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms); + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/main/display_manager.c b/main/display_manager.c index bd8703f..416f1d1 100644 --- a/main/display_manager.c +++ b/main/display_manager.c @@ -155,6 +155,48 @@ void display_put_text_center(int y, const char *str, uint16_t fg, uint16_t bg) 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 */ /* ------------------------------------------------------------------ */ diff --git a/main/display_manager.h b/main/display_manager.h index 9d5939d..eaab24c 100644 --- a/main/display_manager.h +++ b/main/display_manager.h @@ -60,6 +60,12 @@ int display_put_text(int x, int y, const char *str, uint16_t fg, uint16_t bg); */ 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. diff --git a/main/encoder.c b/main/encoder.c new file mode 100644 index 0000000..d67b997 --- /dev/null +++ b/main/encoder.c @@ -0,0 +1,103 @@ +/* + * MTG RFID Companion - EC11 rotary encoder driver + */ + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "driver/gpio.h" +#include "hw_pins.h" +#include "encoder.h" + +static const char *TAG = "encoder"; + +static volatile int32_t s_delta = 0; +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. + */ +#define ENC_DIRECTION (1) + +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) { + 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; + } + } +} + +static esp_err_t config_pin(gpio_num_t pin, gpio_int_type_t intr_type) +{ + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << pin, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = intr_type, + }; + return gpio_config(&cfg); +} + +esp_err_t encoder_init(void) +{ + gpio_install_isr_service(ESP_INTR_FLAG_LEVEL1); + + 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_SW_GPIO, GPIO_INTR_NEGEDGE)); + + 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_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); + return ESP_OK; +} + +bool encoder_has_delta(void) +{ + return s_delta != 0; +} + +int32_t encoder_get_delta(void) +{ + return (int32_t)s_delta; +} + +int32_t encoder_get_delta_and_reset(void) +{ + int32_t v = (int32_t)s_delta; + s_delta = 0; + return v; +} + +bool encoder_button_pressed(void) +{ + return s_button_pressed; +} + +void encoder_clear_button(void) +{ + s_button_pressed = false; +} \ No newline at end of file diff --git a/main/encoder.h b/main/encoder.h new file mode 100644 index 0000000..8a74c85 --- /dev/null +++ b/main/encoder.h @@ -0,0 +1,51 @@ +/* + * MTG RFID Companion - EC11 rotary encoder driver + * + * Phase 7 Step 7.1: Interrupt-driven quadrature decode on GPIO CLK/DT with + * a debounced push button on SW (active low). + */ + +#pragma once + +#include "esp_err.h" +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Install GPIO ISR service and configure encoder pins. + */ +esp_err_t encoder_init(void); + +/** + * @brief True if the encoder has rotated since the last check. + */ +bool encoder_has_delta(void); + +/** + * @brief Return the accumulated rotation steps and reset it. + * Positive = clockwise, negative = counter-clockwise (configurable). + */ +int32_t encoder_get_delta_and_reset(void); + +/** + * @brief Return current accumulator without resetting. + */ +int32_t encoder_get_delta(void); + +/** + * @brief True if the push button was pressed since last poll. + */ +bool encoder_button_pressed(void); + +/** + * @brief Clear the button-pressed flag. + */ +void encoder_clear_button(void); + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/main/led_manager.c b/main/led_manager.c new file mode 100644 index 0000000..1e90a40 --- /dev/null +++ b/main/led_manager.c @@ -0,0 +1,90 @@ +/* + * MTG RFID Companion - WS2812B Neopixel LED manager (RMT) + */ + +#include "esp_log.h" +#include "driver/rmt_tx.h" +#include "driver/rmt_encoder.h" +#include "hw_pins.h" +#include "led_manager.h" + +static const char *TAG = "led"; + +static rmt_channel_handle_t s_tx_chan = NULL; +static rmt_encoder_handle_t s_enc = NULL; + +esp_err_t led_manager_init(void) +{ + if (s_tx_chan != NULL) { + return ESP_OK; + } + + rmt_tx_channel_config_t chan_cfg = { + .gpio_num = HW_NEOPIXEL_GPIO, + .clk_src = RMT_CLK_SRC_DEFAULT, + .resolution_hz = 10 * 1000 * 1000, /* 100 ns per tick */ + .mem_block_symbols = 64, + .trans_queue_depth = 4, + }; + esp_err_t ret = rmt_new_tx_channel(&chan_cfg, &s_tx_chan); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "rmt_new_tx_channel failed: %s", esp_err_to_name(ret)); + return ret; + } + + rmt_bytes_encoder_config_t enc_cfg = { + .bit0 = { + .duration0 = 4, /* T0H ~ 400 ns */ + .level0 = 1, + .duration1 = 8, /* T0L ~ 800 ns */ + .level1 = 0, + }, + .bit1 = { + .duration0 = 9, /* T1H ~ 900 ns */ + .level0 = 1, + .duration1 = 4, /* T1L ~ 400 ns */ + .level1 = 0, + }, + }; + ret = rmt_new_bytes_encoder(&enc_cfg, &s_enc); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "rmt_new_bytes_encoder failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = rmt_enable(s_tx_chan); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "rmt_enable failed: %s", esp_err_to_name(ret)); + return ret; + } + + ESP_LOGI(TAG, "WS2812B LED ready on GPIO %d", HW_NEOPIXEL_GPIO); + return ESP_OK; +} + +esp_err_t led_manager_set_color(uint8_t r, uint8_t g, uint8_t b) +{ + if (s_tx_chan == NULL) { + esp_err_t ret = led_manager_init(); + if (ret != ESP_OK) { + return ret; + } + } + + /* WS2812B expects GRB byte order */ + uint8_t txbuf[3] = { g, r, b }; + + rmt_transmit_config_t tx_cfg = { + .loop_count = 0, /* one-shot; LED latches and holds the level */ + }; + esp_err_t ret = rmt_transmit(s_tx_chan, s_enc, txbuf, sizeof(txbuf), &tx_cfg); + if (ret != ESP_OK) { + return ret; + } + return rmt_tx_wait_all_done(s_tx_chan, pdMS_TO_TICKS(50)); +} + +esp_err_t led_manager_off(void) +{ + return led_manager_set_color(0, 0, 0); +} \ No newline at end of file diff --git a/main/led_manager.h b/main/led_manager.h new file mode 100644 index 0000000..0d4bcf1 --- /dev/null +++ b/main/led_manager.h @@ -0,0 +1,33 @@ +/* + * MTG RFID Companion - WS2812B Neopixel LED manager (RMT) + * + * Phase 7 Step 7.3: Single WS2812B driver on GPIO 27. LED holds the last + * transmitted color until it is overwritten. + */ + +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Initialize RMT TX channel for the Neopixel. + */ +esp_err_t led_manager_init(void); + +/** + * @brief Set the LED color immediately (GRB order internally). + */ +esp_err_t led_manager_set_color(uint8_t r, uint8_t g, uint8_t b); + +/** + * @brief Turn the LED off. + */ +esp_err_t led_manager_off(void); + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/main/ui_task.c b/main/ui_task.c index 9c997fd..f7058fb 100644 --- a/main/ui_task.c +++ b/main/ui_task.c @@ -1,23 +1,264 @@ /* * 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. */ #include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.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 "app_state.h" +#include "encoder.h" +#include "led_manager.h" +#include "buzzer.h" #include "ui_task.h" static const char *TAG = "ui"; -#define UI_TASK_STACK (8 * 1024) +#define UI_TASK_STACK (10 * 1024) #define UI_TASK_PRIO (5) #define UI_CORE (1) +#define LED_HOLD_MS (2500) +#define FETCH_POLL_MS (100) + static TaskHandle_t s_ui_task = NULL; -static void draw_color_bars(void) +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 const char *get_json_str(cJSON *root, const char *key, const char *fallback); + +/* ------------------------------------------------------------------ */ +/* Views */ +/* ------------------------------------------------------------------ */ + +static void view_scanner(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); + } + 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) +{ + 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); + 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); + } + + /* 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"); + } + display_put_text(6, DISP_H - 52, line, COLOR_CYAN, COLOR_BLACK); + + 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); + } + + (void)oracle; + cJSON_Delete(root); +} + +static const char *get_json_str(cJSON *root, const char *key, const char *fallback) +{ + cJSON *item = cJSON_GetObjectItem(root, key); + if (item != NULL && cJSON_IsString(item) && item->valuestring != NULL) { + return item->valuestring; + } + return fallback; +} + +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); + + int32_t life = app_get_life(); + char buf[16]; + snprintf(buf, sizeof(buf), "%ld", (long)(life < 0 ? 0 : life)); + + 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); + + 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); +} + +/* ------------------------------------------------------------------ */ +/* Card events */ +/* ------------------------------------------------------------------ */ + +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; } + } + led_manager_set_color(r, g, b); + s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS); +} + +static void read_cached_color(const char *uid) +{ + char json_path[72]; + snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid); + char *buf = NULL; + size_t len = 0; + if (storage_read_text(json_path, &buf, &len) == ESP_OK && buf != NULL) { + cJSON *root = cJSON_Parse(buf); + if (root != NULL) { + led_for_card(get_json_str(root, "mana_cost", "")); + cJSON_Delete(root); + } + free(buf); + } +} + +static void handle_new_card(const char *uid) +{ + snprintf(s_uid, sizeof(s_uid), "%s", uid); + rfid_manager_consume_card(); + + char *name = NULL; + 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); + led_manager_set_color(60, 60, 200); + s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS); + buzzer_chirp(392, 80); + free(name); + return; + } + + 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); + read_cached_color(uid); + + char json_path[72]; + snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid); + if (storage_file_exists(json_path)) { + 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); + scryfall_fetch_request(name, uid); + } + free(name); +} + +static void handle_fetch_ready(void) +{ + const char *ready_uid = scryfall_last_result_uid(); + if (ready_uid != NULL && strcmp(ready_uid, s_uid) == 0) { + scryfall_consume_result(); + s_waiting_fetch = false; + read_cached_color(s_uid); + view_card(); + } +} + +/* ------------------------------------------------------------------ */ +/* Task loop */ +/* ------------------------------------------------------------------ */ + +static void ui_boot_demo(void) { static const uint16_t bars[] = { COLOR_WHITE, COLOR_YELLOW, COLOR_CYAN, COLOR_GREEN, @@ -27,39 +268,75 @@ static void draw_color_bars(void) for (int i = 0; i < 8; i++) { display_fill_rect(i * band, 0, (i + 1) * band, 64, bars[i]); } -} + vTaskDelay(pdMS_TO_TICKS(800)); -static void ui_boot_demo(void) -{ - ESP_LOGI(TAG, "UI demo: color bars"); - draw_color_bars(); - vTaskDelay(pdMS_TO_TICKS(1500)); - - ESP_LOGI(TAG, "UI demo: text rendering"); display_fill(COLOR_BLACK); display_put_text_center(8, "MTG RFID COMPANION", COLOR_YELLOW, COLOR_BLACK); - display_put_text(16, 40, "UID: 04A2B3C4", COLOR_GREEN, COLOR_BLACK); - display_put_text(16, 56, "SOL RING", COLOR_WHITE, COLOR_BLACK); - display_put_text(16, 72, "MANA {1}", COLOR_CYAN, COLOR_BLACK); - display_put_text(16, 88, "PRICE $1.25", COLOR_MAGENTA, COLOR_BLACK); - vTaskDelay(pdMS_TO_TICKS(2000)); - - ESP_LOGI(TAG, "UI demo: JPEG decode from LittleFS"); - display_show_jpeg_file("/spiffs/cards/sample.jpg"); - vTaskDelay(pdMS_TO_TICKS(3000)); - - ESP_LOGI(TAG, "UI demo complete"); + display_put_text_center(90, "TAP A CARD", COLOR_WHITE, COLOR_BLACK); + vTaskDelay(pdMS_TO_TICKS(1200)); + view_scanner(); } static void ui_task(void *arg) { (void)arg; ESP_LOGI(TAG, "UI task on core %d", xPortGetCoreID()); + + encoder_init(); + led_manager_init(); + buzzer_init(); + ui_boot_demo(); - ESP_LOGI(TAG, "UI task entering idle render loop"); + app_set_view(APP_STATE_SCANNER); + while (1) { - /* Placeholder idle loop; state-driven rendering lands in Phase 7. */ - vTaskDelay(pdMS_TO_TICKS(100)); + /* 1. New RFID card */ + if (rfid_manager_has_pending_card()) { + handle_new_card(rfid_manager_last_uid()); + } + + /* 2. Async Scryfall cache result */ + if (s_waiting_fetch && scryfall_has_pending_result()) { + 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(); + } + } + + /* 4. Encoder rotation in life counter */ + if (app_get_view() == APP_STATE_LIFE_COUNTER && 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(); + } + + /* 5. Auto-dim LED */ + if (s_led_off_tick != 0 && xTaskGetTickCount() >= s_led_off_tick) { + s_led_off_tick = 0; + led_manager_off(); + } + + vTaskDelay(pdMS_TO_TICKS(25)); } } @@ -71,5 +348,4 @@ esp_err_t ui_task_start(void) void ui_task_wait_boot(void) { - /* Expose for later phases; currently no-op before state machine lands. */ } \ No newline at end of file diff --git a/main/web_server.c b/main/web_server.c index fd6ca10..340db8f 100644 --- a/main/web_server.c +++ b/main/web_server.c @@ -18,8 +18,6 @@ static const char *TAG = "web"; #define AP_SSID "MTG-Companion" #define AP_PASS "" -#define AP_IP "192.168.4.1" -#define AP_MASK "255.255.255.0" #define HTTPD_BODY_MAX (2048) @@ -294,7 +292,7 @@ esp_err_t web_server_start(void) ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LAST_UID)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_BIND)); - ESP_LOGI(TAG, "SoftAP '%s' at %s, web server ready", AP_SSID, AP_IP); + ESP_LOGI(TAG, "SoftAP '%s' at 192.168.4.1, web server ready", AP_SSID); return ESP_OK; }