/* * MTG RFID Companion - Scryfall API client (Core 0) */ #include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" #include "esp_log.h" #include "esp_http_client.h" #include "esp_crt_bundle.h" #include "cJSON.h" #include "wifi_manager.h" #include "storage_manager.h" #include "rfid_manager.h" #include "scryfall_client.h" static const char *TAG = "scryfall"; #define NET_TASK_STACK (10 * 1024) #define NET_TASK_PRIO (3) #define NET_CORE (0) #define FETCH_QUEUE_LEN (8) #define HTTP_TIMEOUT_MS (20000) #define MAX_ATTEMPTS (2) #define JSON_BUF_MAX (128 * 1024) #define CARD_NAME_MAX (128) #define UID_MAX (24) #define FIELD_STR_MAX (256) #define ORACLE_MAX (768) #define IMAGE_URL_MAX (512) #define EVT_CARD_CACHED (1U << 0) #define EVT_CARD_FAILED (1U << 1) typedef struct { char name[CARD_NAME_MAX]; char uid[UID_MAX]; } fetch_request_t; static QueueHandle_t s_fetch_queue; static TaskHandle_t s_net_task; static volatile uint32_t s_result_bits = 0; static char s_last_uid[UID_MAX] = {0}; /* ------------------------------------------------------------------ */ /* URL helpers */ /* ------------------------------------------------------------------ */ static char to_hex_digit(unsigned int v) { return (char)(v < 10 ? '0' + v : 'A' + (v - 10)); } static void url_encode(const char *in, char *out, size_t out_cap) { size_t o = 0; for (size_t i = 0; in[i] != '\0' && o < out_cap - 3; i++) { unsigned char c = (unsigned char)in[i]; if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') { out[o++] = (char)c; } else if (c == ' ') { out[o++] = '+'; } else { out[o++] = '%'; out[o++] = to_hex_digit((c >> 4) & 0x0F); out[o++] = to_hex_digit(c & 0x0F); } } out[o] = '\0'; } /* ------------------------------------------------------------------ */ /* HTTP primitives (blocking, net_task only) */ /* ------------------------------------------------------------------ */ typedef struct { char *buf; size_t len; size_t cap; esp_err_t err; FILE *file; /* when downloading to a file */ int status; } http_sink_t; static esp_err_t http_on_body(http_sink_t *sink, const char *data, size_t len) { if (sink->file != NULL) { if (len > 0 && fwrite(data, 1, len, sink->file) != len) { return ESP_FAIL; } return ESP_OK; } /* Reserve space for data plus 1 byte for null terminator */ if (len + 1 > sink->cap - sink->len) { size_t req = sink->len + len + 1; if (req > JSON_BUF_MAX) { sink->err = ESP_ERR_NO_MEM; return ESP_FAIL; } size_t new_cap = sink->cap ? sink->cap : 4096; while (new_cap < req) { new_cap *= 2; } char *nb = realloc(sink->buf, new_cap); if (nb == NULL) { sink->err = ESP_ERR_NO_MEM; return ESP_FAIL; } sink->buf = nb; sink->cap = new_cap; } memcpy(sink->buf + sink->len, data, len); sink->len += len; return ESP_OK; } static esp_err_t http_event_handler(esp_http_client_event_t *evt) { http_sink_t *sink = (http_sink_t *)evt->user_data; if (sink == NULL) { return ESP_OK; } switch (evt->event_id) { case HTTP_EVENT_ON_DATA: return http_on_body(sink, (const char *)evt->data, evt->data_len); case HTTP_EVENT_DISCONNECTED: if (sink->file != NULL) { fflush(sink->file); } break; case HTTP_EVENT_ERROR: sink->err = ESP_FAIL; break; default: break; } return ESP_OK; } static esp_err_t http_request(const char *url, http_sink_t *sink) { for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { if (sink->file != NULL) { rewind(sink->file); ftruncate(fileno(sink->file), 0); } else { sink->len = 0; } sink->err = ESP_OK; esp_http_client_config_t cfg = { .url = url, .transport_type = HTTP_TRANSPORT_OVER_SSL, .timeout_ms = HTTP_TIMEOUT_MS, .event_handler = http_event_handler, .user_data = sink, .buffer_size = 2048, .crt_bundle_attach = esp_crt_bundle_attach, }; esp_http_client_handle_t client = esp_http_client_init(&cfg); if (client == NULL) { return ESP_ERR_NO_MEM; } esp_http_client_set_method(client, HTTP_METHOD_GET); esp_http_client_set_header(client, "User-Agent", "MTGCompanion/1.0"); esp_http_client_set_header(client, "Accept", "*/*"); esp_err_t ret = esp_http_client_perform(client); int status = esp_http_client_get_status_code(client); esp_http_client_cleanup(client); ESP_LOGD(TAG, "GET %s -> %s (status %d)", url, esp_err_to_name(ret), status); if (ret == ESP_OK && status >= 200 && status < 300) { return ESP_OK; } if (ret == ESP_OK && (status < 200 || status >= 300)) { ESP_LOGW(TAG, "HTTP GET returned status %d for %s", status, url); } if (sink->err != ESP_OK) { return sink->err; } vTaskDelay(pdMS_TO_TICKS(500)); } return ESP_FAIL; } static esp_err_t http_get_json(const char *url, char **out_buf) { *out_buf = NULL; http_sink_t sink = {0}; esp_err_t ret = http_request(url, &sink); if (ret != ESP_OK || sink.buf == NULL) { free(sink.buf); ESP_LOGE(TAG, "JSON fetch failed: %s", url); return ret; } sink.buf[sink.len] = '\0'; *out_buf = sink.buf; ESP_LOGI(TAG, "fetched %d bytes from %s", (int)sink.len, url); return ESP_OK; } static esp_err_t http_download_file(const char *url, const char *path) { FILE *f = fopen(path, "wb"); if (f == NULL) { return ESP_FAIL; } http_sink_t sink = { .file = f }; esp_err_t ret = http_request(url, &sink); fclose(f); if (ret != ESP_OK) { remove(path); return ret; } return ESP_OK; } static const char *get_string(const cJSON *item, const char *fallback) { if (item == NULL || !cJSON_IsString(item) || item->valuestring == NULL) { return fallback; } return item->valuestring; } /* ------------------------------------------------------------------ */ /* Cache metadata */ /* ------------------------------------------------------------------ */ static esp_err_t scryfall_do_fetch(const char *card_name, const char *uid) { char json_path[64]; snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid); char jpg_path[64]; snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid); if (storage_file_exists(json_path)) { char *chk = NULL; size_t chk_len = 0; bool valid = false; if (storage_read_text(json_path, &chk, &chk_len) == ESP_OK && chk != NULL) { cJSON *cj = cJSON_Parse(chk); if (cj) { cJSON *n = cJSON_GetObjectItem(cj, "name"); cJSON *t = cJSON_GetObjectItem(cj, "type"); cJSON *o = cJSON_GetObjectItem(cj, "oracle"); /* Verify name matches the requested card name */ if (n && n->valuestring && strcasecmp(n->valuestring, card_name) == 0) { if ((t && t->valuestring && strlen(t->valuestring) > 0) || (o && o->valuestring && strlen(o->valuestring) > 0)) { valid = true; } } cJSON_Delete(cj); } free(chk); } if (valid) { if (storage_file_exists(jpg_path)) { ESP_LOGI(TAG, "cache hit %s and %s", json_path, jpg_path); return ESP_OK; } ESP_LOGI(TAG, "json valid but %s missing, fetching art...", jpg_path); } else { ESP_LOGW(TAG, "cache entry %s is invalid or card mismatch (expected '%s'), re-fetching...", json_path, card_name); remove(json_path); remove(jpg_path); } } /* 1. GET card JSON */ char url[512]; char enc[CARD_NAME_MAX * 3]; url_encode(card_name, enc, sizeof(enc)); snprintf(url, sizeof(url), SCRYFALL_API SCRYFALL_CARD_ARTS_URL "%s", enc); char *body = NULL; esp_err_t ret = http_get_json(url, &body); if (ret != ESP_OK || body == NULL) { free(body); ESP_LOGE(TAG, "scryfall request failed for '%s'", card_name); return ret != ESP_OK ? ret : ESP_FAIL; } cJSON *root = cJSON_Parse(body); free(body); if (root == NULL) { ESP_LOGE(TAG, "JSON parse failed for '%s'", card_name); return ESP_FAIL; } cJSON *obj = cJSON_GetObjectItem(root, "object"); if (obj && cJSON_IsString(obj) && strcmp(obj->valuestring, "error") == 0) { ESP_LOGE(TAG, "Scryfall API error: %s", get_string(cJSON_GetObjectItem(root, "details"), "unknown error")); cJSON_Delete(root); return ESP_FAIL; } /* 2. Extract fields */ const char *name = get_string(cJSON_GetObjectItem(root, "name"), card_name); const char *mana = NULL; cJSON *mc = cJSON_GetObjectItem(root, "mana_cost"); if (cJSON_IsArray(mc) && cJSON_GetArraySize(mc) > 0) { mana = get_string(cJSON_GetArrayItem(mc, 0), ""); } else { mana = get_string(mc, ""); } const char *type_line = get_string(cJSON_GetObjectItem(root, "type_line"), ""); const char *power = get_string(cJSON_GetObjectItem(root, "power"), ""); const char *toughness = get_string(cJSON_GetObjectItem(root, "toughness"), ""); const char *loyalty = get_string(cJSON_GetObjectItem(root, "loyalty"), ""); const char *oracle = get_string(cJSON_GetObjectItem(root, "oracle_text"), ""); const char *image_url = NULL; cJSON *uris = cJSON_GetObjectItem(root, "image_uris"); cJSON *faces = cJSON_GetObjectItem(root, "card_faces"); if (faces && cJSON_IsArray(faces) && cJSON_GetArraySize(faces) > 0) { cJSON *face0 = cJSON_GetArrayItem(faces, 0); if (face0) { if (uris == NULL) { uris = cJSON_GetObjectItem(face0, "image_uris"); } if (strlen(oracle) == 0) { oracle = get_string(cJSON_GetObjectItem(face0, "oracle_text"), ""); } if (strlen(mana) == 0) { mana = get_string(cJSON_GetObjectItem(face0, "mana_cost"), ""); } if (strlen(type_line) == 0) { type_line = get_string(cJSON_GetObjectItem(face0, "type_line"), ""); } } } if (uris) { image_url = get_string(cJSON_GetObjectItem(uris, "art_crop"), ""); if (strlen(image_url) == 0) { image_url = get_string(cJSON_GetObjectItem(uris, "normal"), ""); } } const char *price_usd = NULL; cJSON *prices = cJSON_GetObjectItem(root, "prices"); if (prices) { price_usd = get_string(cJSON_GetObjectItem(prices, "usd"), ""); } const char *rarity = get_string(cJSON_GetObjectItem(root, "rarity"), ""); /* 3. Persist metadata with proper JSON escaping */ cJSON *card_obj = cJSON_CreateObject(); if (card_obj) { cJSON_AddStringToObject(card_obj, "name", name); cJSON_AddStringToObject(card_obj, "mana_cost", mana); cJSON_AddStringToObject(card_obj, "type", type_line); cJSON_AddStringToObject(card_obj, "rarity", rarity); cJSON_AddStringToObject(card_obj, "power", power); cJSON_AddStringToObject(card_obj, "toughness", toughness); cJSON_AddStringToObject(card_obj, "loyalty", loyalty); cJSON_AddStringToObject(card_obj, "oracle", oracle); cJSON_AddStringToObject(card_obj, "price_usd", price_usd ? price_usd : ""); char *meta_str = cJSON_PrintUnformatted(card_obj); cJSON_Delete(card_obj); if (meta_str != NULL) { ret = storage_write_text(json_path, meta_str); free(meta_str); } else { ret = ESP_ERR_NO_MEM; } } else { ret = ESP_ERR_NO_MEM; } /* 4. Download cover art (best effort) */ if (image_url && strlen(image_url) > 0) { char jpg_path[64]; snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid); char proxy_url[600]; /* wsrv.nl converts Scryfall's progressive JPEG to a lightweight 200x110 baseline JPEG */ snprintf(proxy_url, sizeof(proxy_url), "https://wsrv.nl/?url=%s&w=200&h=110&fit=cover&output=jpg", image_url); esp_err_t dl = http_download_file(proxy_url, jpg_path); if (dl != ESP_OK) { ESP_LOGW(TAG, "proxied image download failed, trying direct: %s", image_url); dl = http_download_file(image_url, jpg_path); } if (dl != ESP_OK) { ESP_LOGW(TAG, "image download failed: %s", image_url); } else { ESP_LOGI(TAG, "image cached to %s", jpg_path); } } cJSON_Delete(root); if (ret == ESP_OK) { ESP_LOGI(TAG, "card '%s' cached under %s", card_name, uid); } return ret; } static esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid) { esp_err_t ret = scryfall_do_fetch(card_name, uid); snprintf(s_last_uid, sizeof(s_last_uid), "%s", uid); s_result_bits = (ret == ESP_OK) ? EVT_CARD_CACHED : EVT_CARD_FAILED; return ret; } /* ------------------------------------------------------------------ */ /* net_task */ /* ------------------------------------------------------------------ */ static void prefetch_mappings(void) { char *buf = NULL; size_t len = 0; if (storage_read_text(STORAGE_MAPPINGS, &buf, &len) != ESP_OK || buf == NULL) { free(buf); return; } cJSON *root = cJSON_Parse(buf); free(buf); if (root == NULL || !cJSON_IsObject(root)) { if (root) cJSON_Delete(root); ESP_LOGW(TAG, "mappings.json unreadable"); return; } const char *uid; cJSON *item = NULL; cJSON_ArrayForEach(item, root) { uid = item->string; if (uid == NULL || item->valuestring == NULL) continue; scryfall_ensure_cached(item->valuestring, uid); /* Rate-limit delay: Scryfall requests 50-100ms between queries */ vTaskDelay(pdMS_TO_TICKS(100)); } cJSON_Delete(root); } static void net_task(void *arg) { (void)arg; ESP_LOGI(TAG, "net_task on core %d", xPortGetCoreID()); #ifndef CONFIG_MTG_WIFI_SSID #error "Missing CONFIG_MTG_WIFI_SSID" #endif if (wifi_manager_sta_configured()) { if (wifi_manager_wait_connected(pdMS_TO_TICKS(60000)) != ESP_OK) { ESP_LOGW(TAG, "Wi-Fi connect timed out, continuing without network"); } else { #ifdef CONFIG_MTG_SCAN_PREFETCH prefetch_mappings(); #endif } } while (1) { fetch_request_t req; BaseType_t got = xQueueReceive(s_fetch_queue, &req, portMAX_DELAY); if (got == pdFALSE) { continue; } scryfall_ensure_cached(req.name, req.uid); } } esp_err_t scryfall_client_start(void) { if (s_fetch_queue == NULL) { s_fetch_queue = xQueueCreate(FETCH_QUEUE_LEN, sizeof(fetch_request_t)); if (s_fetch_queue == NULL) { return ESP_ERR_NO_MEM; } xTaskCreatePinnedToCore(net_task, "net_task", NET_TASK_STACK, NULL, NET_TASK_PRIO, &s_net_task, NET_CORE); } return ESP_OK; } esp_err_t scryfall_fetch_request(const char *card_name, const char *uid) { if (s_fetch_queue == NULL) { return ESP_ERR_INVALID_STATE; } if (strlen(card_name) >= CARD_NAME_MAX || strlen(uid) >= UID_MAX || strlen(card_name) == 0) { return ESP_ERR_INVALID_ARG; } fetch_request_t req; snprintf(req.name, sizeof(req.name), "%s", card_name); snprintf(req.uid, sizeof(req.uid), "%s", uid); return xQueueSend(s_fetch_queue, &req, 0) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; } bool scryfall_has_pending_result(void) { return (s_result_bits & (EVT_CARD_CACHED | EVT_CARD_FAILED)) != 0; } bool scryfall_last_result_success(void) { return (s_result_bits & EVT_CARD_CACHED) != 0; } const char *scryfall_last_result_uid(void) { return s_last_uid; } void scryfall_consume_result(void) { s_result_bits = 0; }