Phase 5: Wi-Fi manager w/ reconnect, Scryfall HTTPS client + cJSON caching to /spiffs on net_task (Core 0)
This commit is contained in:
@@ -5,5 +5,7 @@ idf_component_register(SRCS
|
|||||||
"rfid_manager.c"
|
"rfid_manager.c"
|
||||||
"display_manager.c"
|
"display_manager.c"
|
||||||
"ui_task.c"
|
"ui_task.c"
|
||||||
|
"wifi_manager.c"
|
||||||
|
"scryfall_client.c"
|
||||||
INCLUDE_DIRS "."
|
INCLUDE_DIRS "."
|
||||||
)
|
)
|
||||||
15
main/Kconfig.projbuild
Normal file
15
main/Kconfig.projbuild
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
menu "MTG RFID Companion"
|
||||||
|
|
||||||
|
config MTG_WIFI_SSID
|
||||||
|
string "Wi-Fi SSID (station mode)"
|
||||||
|
default ""
|
||||||
|
|
||||||
|
config MTG_WIFI_PASS
|
||||||
|
string "Wi-Fi password"
|
||||||
|
default ""
|
||||||
|
|
||||||
|
config MTG_SCAN_PREFETCH
|
||||||
|
bool "Prefetch card data for all mapped UIDs at boot"
|
||||||
|
default y
|
||||||
|
|
||||||
|
endmenu
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
#include "rfid_manager.h"
|
#include "rfid_manager.h"
|
||||||
#include "display_manager.h"
|
#include "display_manager.h"
|
||||||
#include "ui_task.h"
|
#include "ui_task.h"
|
||||||
|
#include "wifi_manager.h"
|
||||||
|
#include "scryfall_client.h"
|
||||||
|
|
||||||
static const char *TAG = "MAIN";
|
static const char *TAG = "MAIN";
|
||||||
|
|
||||||
@@ -21,9 +23,13 @@ void app_main(void)
|
|||||||
ESP_ERROR_CHECK(rfid_manager_init());
|
ESP_ERROR_CHECK(rfid_manager_init());
|
||||||
ESP_ERROR_CHECK(rfid_manager_start());
|
ESP_ERROR_CHECK(rfid_manager_start());
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(wifi_manager_init());
|
||||||
|
ESP_ERROR_CHECK(wifi_manager_start());
|
||||||
|
ESP_ERROR_CHECK(scryfall_client_start());
|
||||||
|
|
||||||
ESP_ERROR_CHECK(ui_task_start());
|
ESP_ERROR_CHECK(ui_task_start());
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Boot sequence complete. Ready for Phase 5+.");
|
ESP_LOGI(TAG, "Boot sequence complete. Ready for Phase 6+.");
|
||||||
|
|
||||||
while (1) {
|
while (1) {
|
||||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
|||||||
422
main/scryfall_client.c
Normal file
422
main/scryfall_client.c
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
/*
|
||||||
|
* MTG RFID Companion - Scryfall API client (Core 0)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#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 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)
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char name[NAME_MAX];
|
||||||
|
char uid[UID_MAX];
|
||||||
|
} fetch_request_t;
|
||||||
|
|
||||||
|
static QueueHandle_t s_fetch_queue;
|
||||||
|
static TaskHandle_t s_net_task;
|
||||||
|
|
||||||
|
static 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len > sink->cap - sink->len) {
|
||||||
|
/* grow, never beyond cap */
|
||||||
|
size_t req = sink->len + len;
|
||||||
|
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++) {
|
||||||
|
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_err_t ret = esp_http_client_perform(client);
|
||||||
|
esp_http_client_cleanup(client);
|
||||||
|
|
||||||
|
ESP_LOGD(TAG, "GET %s -> %s", url, esp_err_to_name(ret));
|
||||||
|
if (ret == ESP_OK) {
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
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 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid)
|
||||||
|
{
|
||||||
|
char json_path[64];
|
||||||
|
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid);
|
||||||
|
if (storage_file_exists(json_path)) {
|
||||||
|
ESP_LOGI(TAG, "cache hit %s", json_path);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1. GET card JSON */
|
||||||
|
char url[512];
|
||||||
|
char enc[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 *oracle = get_string(cJSON_GetObjectItem(root, "oracle_text"), "");
|
||||||
|
const char *image_url = NULL;
|
||||||
|
cJSON *uris = cJSON_GetObjectItem(root, "image_uris");
|
||||||
|
if (uris) {
|
||||||
|
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"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Persist metadata */
|
||||||
|
char meta[1536];
|
||||||
|
snprintf(meta, sizeof(meta),
|
||||||
|
"{\n"
|
||||||
|
" \"name\": \"%s\",\n"
|
||||||
|
" \"mana_cost\": \"%s\",\n"
|
||||||
|
" \"type\": \"%s\",\n"
|
||||||
|
" \"power\": \"%s\",\n"
|
||||||
|
" \"toughness\": \"%s\",\n"
|
||||||
|
" \"oracle\": \"%s\",\n"
|
||||||
|
" \"price_usd\": \"%s\"\n"
|
||||||
|
"}\n",
|
||||||
|
name, mana, type_line, power, toughness, oracle,
|
||||||
|
price_usd ? price_usd : "");
|
||||||
|
|
||||||
|
ret = storage_write_text(json_path, meta);
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
esp_err_t 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) {
|
||||||
|
s_result_bits |= EVT_CARD_CACHED;
|
||||||
|
snprintf(s_last_uid, sizeof(s_last_uid), "%s", uid);
|
||||||
|
ESP_LOGI(TAG, "card '%s' cached under %s", card_name, uid);
|
||||||
|
}
|
||||||
|
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) continue;
|
||||||
|
char path[64];
|
||||||
|
snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s.json", uid);
|
||||||
|
if (!storage_file_exists(path)) {
|
||||||
|
ESP_LOGI(TAG, "prefetching '%s' for %s", item->valuestring, uid);
|
||||||
|
scryfall_ensure_cached(item->valuestring, uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 (strlen(CONFIG_MTG_WIFI_SSID) > 0) {
|
||||||
|
if (wifi_manager_wait_connected(pdMS_TO_TICKS(60000)) != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Wi-Fi connect timed out, continuing without network");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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) >= 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) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *scryfall_last_result_uid(void)
|
||||||
|
{
|
||||||
|
return s_last_uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void scryfall_consume_result(void)
|
||||||
|
{
|
||||||
|
s_result_bits &= ~EVT_CARD_CACHED;
|
||||||
|
}
|
||||||
56
main/scryfall_client.h
Normal file
56
main/scryfall_client.h
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* MTG RFID Companion - Scryfall API client (Core 0)
|
||||||
|
*
|
||||||
|
* Phase 5: HTTPS card lookups with cJSON parsing and LittleFS caching of
|
||||||
|
* metadata + card art. Runs on the dedicated net_task.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define SCRYFALL_API "https://api.scryfall.com"
|
||||||
|
#define SCRYFALL_CARD_ARTS_URL "/cards/named?exact="
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Start the net_task (waits for Wi-Fi, prefetches mapping cache,
|
||||||
|
* then serves fetch requests). Must be called after wifi start.
|
||||||
|
*/
|
||||||
|
esp_err_t scryfall_client_start(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Queue an async fetch by exact card name (net_task performs it).
|
||||||
|
* @param card_name Exact card name, e.g. "Sol Ring"
|
||||||
|
* @param uid Uppercase hex UID used for cache filenames
|
||||||
|
*/
|
||||||
|
esp_err_t scryfall_fetch_request(const char *card_name, const char *uid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Synchronously ensure the card cache exists for uid; fetch if missing.
|
||||||
|
* Safe to call from net_task only.
|
||||||
|
*/
|
||||||
|
esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief True if net_task finished caching a card since last consumption.
|
||||||
|
*/
|
||||||
|
bool scryfall_has_pending_result(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief UID of the most recently cached card.
|
||||||
|
*/
|
||||||
|
const char *scryfall_last_result_uid(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Clear the pending-result flag.
|
||||||
|
*/
|
||||||
|
void scryfall_consume_result(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
121
main/wifi_manager.c
Normal file
121
main/wifi_manager.c
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* MTG RFID Companion - Wi-Fi manager (Core 0)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_wifi.h"
|
||||||
|
#include "esp_event.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
#include "nvs_flash.h"
|
||||||
|
#include "wifi_manager.h"
|
||||||
|
|
||||||
|
static const char *TAG = "wifi";
|
||||||
|
|
||||||
|
#define WIFI_RETRY_MAX (UINT8_MAX)
|
||||||
|
#define WIFI_RECONNECT_DELAY_MS (3000)
|
||||||
|
|
||||||
|
static EventGroupHandle_t s_wifi_group = NULL;
|
||||||
|
#define WIFI_CONNECTED_BIT BIT0
|
||||||
|
|
||||||
|
static int s_retry_count = 0;
|
||||||
|
|
||||||
|
static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||||
|
{
|
||||||
|
(void)arg;
|
||||||
|
|
||||||
|
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
|
||||||
|
ESP_LOGI(TAG, "station started, connecting...");
|
||||||
|
esp_wifi_connect();
|
||||||
|
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||||
|
if (s_retry_count < WIFI_RETRY_MAX) {
|
||||||
|
ESP_LOGW(TAG, "disconnected (%d), retrying in %d ms", s_retry_count, WIFI_RECONNECT_DELAY_MS);
|
||||||
|
xEventGroupClearBits(s_wifi_group, WIFI_CONNECTED_BIT);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(WIFI_RECONNECT_DELAY_MS));
|
||||||
|
esp_wifi_connect();
|
||||||
|
s_retry_count++;
|
||||||
|
}
|
||||||
|
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
|
||||||
|
ip_event_got_ip_t *event = (ip_event_got_ip_t *)data;
|
||||||
|
ESP_LOGI(TAG, "connected, IP: " IPSTR, IP2STR(&event->ip_info.ip));
|
||||||
|
s_retry_count = 0;
|
||||||
|
xEventGroupSetBits(s_wifi_group, WIFI_CONNECTED_BIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t wifi_manager_init(void)
|
||||||
|
{
|
||||||
|
esp_err_t ret = nvs_flash_init();
|
||||||
|
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||||
|
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||||
|
ret = nvs_flash_init();
|
||||||
|
}
|
||||||
|
if (ret != ESP_OK) {
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
s_wifi_group = xEventGroupCreate();
|
||||||
|
if (s_wifi_group == NULL) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(esp_netif_init());
|
||||||
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||||
|
esp_netif_create_default_wifi_sta();
|
||||||
|
|
||||||
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(
|
||||||
|
WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(
|
||||||
|
IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL));
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t wifi_manager_start(void)
|
||||||
|
{
|
||||||
|
#ifndef CONFIG_MTG_WIFI_SSID
|
||||||
|
#error "Missing CONFIG_MTG_WIFI_SSID - run idf.py menuconfig"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
const char *ssid = CONFIG_MTG_WIFI_SSID;
|
||||||
|
const char *pass = CONFIG_MTG_WIFI_PASS;
|
||||||
|
|
||||||
|
if (strlen(ssid) == 0) {
|
||||||
|
ESP_LOGW(TAG, "no SSID configured - Wi-Fi station skipped (set CONFIG_MTG_WIFI_SSID)");
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
wifi_config_t wifi_config = {
|
||||||
|
.sta = {
|
||||||
|
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
strncpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid) - 1);
|
||||||
|
strncpy((char *)wifi_config.sta.password, pass, sizeof(wifi_config.sta.password) - 1);
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_start());
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "station mode started for '%s'", ssid);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wifi_manager_connected(void)
|
||||||
|
{
|
||||||
|
return s_wifi_group != NULL && (xEventGroupGetBits(s_wifi_group) & WIFI_CONNECTED_BIT) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t wifi_manager_wait_connected(TickType_t timeout_ticks)
|
||||||
|
{
|
||||||
|
if (s_wifi_group == NULL) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
EventBits_t bits = xEventGroupWaitBits(s_wifi_group, WIFI_CONNECTED_BIT, pdFALSE, pdTRUE, timeout_ticks);
|
||||||
|
return (bits & WIFI_CONNECTED_BIT) ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||||
|
}
|
||||||
40
main/wifi_manager.h
Normal file
40
main/wifi_manager.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* MTG RFID Companion - Wi-Fi manager (Core 0)
|
||||||
|
*
|
||||||
|
* Phase 5 Step 5.1: Station-mode auto-connect with disconnect event
|
||||||
|
* handling and reconnect back-off.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initialize the Wi-Fi subsystem (netif, driver, events).
|
||||||
|
* @return ESP_OK on success.
|
||||||
|
*/
|
||||||
|
esp_err_t wifi_manager_init(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Start station mode and attempt to connect to the configured AP.
|
||||||
|
*/
|
||||||
|
esp_err_t wifi_manager_start(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief True if the STA has a valid IP.
|
||||||
|
*/
|
||||||
|
bool wifi_manager_connected(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Block until connected or timeout. Returns ESP_OK if connected.
|
||||||
|
*/
|
||||||
|
esp_err_t wifi_manager_wait_connected(TickType_t timeout_ticks);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -431,6 +431,14 @@ CONFIG_PARTITION_TABLE_OFFSET=0x8000
|
|||||||
CONFIG_PARTITION_TABLE_MD5=y
|
CONFIG_PARTITION_TABLE_MD5=y
|
||||||
# end of Partition Table
|
# end of Partition Table
|
||||||
|
|
||||||
|
#
|
||||||
|
# MTG RFID Companion
|
||||||
|
#
|
||||||
|
CONFIG_MTG_WIFI_SSID=""
|
||||||
|
CONFIG_MTG_WIFI_PASS=""
|
||||||
|
CONFIG_MTG_SCAN_PREFETCH=y
|
||||||
|
# end of MTG RFID Companion
|
||||||
|
|
||||||
#
|
#
|
||||||
# Compiler options
|
# Compiler options
|
||||||
#
|
#
|
||||||
|
|||||||
Reference in New Issue
Block a user