351 lines
12 KiB
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)
|
|
{
|
|
} |