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

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