Phase 7: EC11 encoder, life counter views, WS2812B RMT + LEDC buzzer feedback, E2E UI state machine; document build/hardware notes

This commit is contained in:
2026-09-09 18:36:17 +02:00
parent 8a95e1735a
commit 01c24d17b6
14 changed files with 914 additions and 29 deletions

View File

@@ -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.
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`.

View File

@@ -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 "."
)

91
main/app_state.c Normal file
View File

@@ -0,0 +1,91 @@
/*
* MTG RFID Companion - System state machine
*/
#include <string.h>
#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);
}
}

40
main/app_state.h Normal file
View File

@@ -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 <stdbool.h>
#include <stdint.h>
#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

84
main/buzzer.c Normal file
View File

@@ -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();
}

39
main/buzzer.h Normal file
View File

@@ -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

View File

@@ -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 */
/* ------------------------------------------------------------------ */

View File

@@ -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.

103
main/encoder.c Normal file
View File

@@ -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;
}

51
main/encoder.h Normal file
View File

@@ -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 <stdbool.h>
#include <stdint.h>
#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

90
main/led_manager.c Normal file
View File

@@ -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);
}

33
main/led_manager.h Normal file
View File

@@ -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

View File

@@ -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 <string.h>
#include <stdlib.h>
#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. */
}

View File

@@ -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;
}