/* * 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 (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 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, COLOR_MAGENTA, COLOR_RED, COLOR_BLUE, COLOR_BLACK }; const int band = DISP_W / 8; for (int i = 0; i < 8; i++) { display_fill_rect(i * band, 0, (i + 1) * band, 64, bars[i]); } vTaskDelay(pdMS_TO_TICKS(800)); display_fill(COLOR_BLACK); display_put_text_center(8, "MTG RFID COMPANION", COLOR_YELLOW, COLOR_BLACK); display_put_text_center(90, "TAP A CARD", COLOR_WHITE, COLOR_BLACK); vTaskDelay(pdMS_TO_TICKS(1200)); view_scanner(); } 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(); app_set_view(APP_STATE_SCANNER); while (1) { /* 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)); } } esp_err_t ui_task_start(void) { xTaskCreatePinnedToCore(ui_task, "ui_task", UI_TASK_STACK, NULL, UI_TASK_PRIO, &s_ui_task, UI_CORE); return ESP_OK; } void ui_task_wait_boot(void) { }