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:
326
main/ui_task.c
326
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 <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. */
|
||||
}
|
||||
Reference in New Issue
Block a user