Files
MTGcompanion/main/ui_task.c

351 lines
12 KiB
C

/*
* 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 (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)
{
}