From 8a95e1735aed3d064078e12f167cb88e9d0b3a13 Mon Sep 17 00:00:00 2001 From: Rasmus Date: Wed, 9 Sep 2026 18:14:50 +0200 Subject: [PATCH] Phase 6: SoftAP MTG-Companion web server, /api/last_uid + /api/bind UID pairing, embedded pairing SPA --- main/CMakeLists.txt | 1 + main/main.c | 4 +- main/storage_manager.c | 59 ++++++++ main/storage_manager.h | 11 ++ main/web_server.c | 304 +++++++++++++++++++++++++++++++++++++++++ main/web_server.h | 28 ++++ 6 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 main/web_server.c create mode 100644 main/web_server.h diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f68a0da..98eab0f 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -7,5 +7,6 @@ idf_component_register(SRCS "ui_task.c" "wifi_manager.c" "scryfall_client.c" + "web_server.c" INCLUDE_DIRS "." ) \ No newline at end of file diff --git a/main/main.c b/main/main.c index b7ddeca..95dd5ac 100644 --- a/main/main.c +++ b/main/main.c @@ -9,6 +9,7 @@ #include "ui_task.h" #include "wifi_manager.h" #include "scryfall_client.h" +#include "web_server.h" static const char *TAG = "MAIN"; @@ -26,10 +27,11 @@ void app_main(void) ESP_ERROR_CHECK(wifi_manager_init()); ESP_ERROR_CHECK(wifi_manager_start()); ESP_ERROR_CHECK(scryfall_client_start()); + ESP_ERROR_CHECK(web_server_start()); ESP_ERROR_CHECK(ui_task_start()); - ESP_LOGI(TAG, "Boot sequence complete. Ready for Phase 6+."); + ESP_LOGI(TAG, "Boot sequence complete. Ready for Phase 7+."); while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); diff --git a/main/storage_manager.c b/main/storage_manager.c index 1f212c3..7f6f844 100644 --- a/main/storage_manager.c +++ b/main/storage_manager.c @@ -9,6 +9,7 @@ #include #include "esp_log.h" #include "esp_littlefs.h" +#include "cJSON.h" #include "storage_manager.h" static const char *TAG = "storage"; @@ -109,6 +110,64 @@ esp_err_t storage_write_text(const char *path, const char *text) return ESP_OK; } +esp_err_t storage_manager_bind_uid(const char *uid, const char *card_name) +{ + cJSON *root = NULL; + char *existing = NULL; + size_t existing_len = 0; + + if (storage_read_text(STORAGE_MAPPINGS, &existing, &existing_len) == ESP_OK && existing != NULL) { + root = cJSON_Parse(existing); + } + free(existing); + + if (root == NULL || !cJSON_IsObject(root)) { + if (root) cJSON_Delete(root); + root = cJSON_CreateObject(); + } + if (root == NULL) { + return ESP_ERR_NO_MEM; + } + + cJSON_DeleteItemFromObject(root, uid); + cJSON_AddItemToObject(root, uid, cJSON_CreateString(card_name)); + + char *out = cJSON_Print(root); + esp_err_t ret = out ? storage_write_text(STORAGE_MAPPINGS, out) : ESP_ERR_NO_MEM; + free(out); + cJSON_Delete(root); + return ret; +} + +esp_err_t storage_manager_lookup_name(const char *uid, char **out) +{ + *out = NULL; + char *buf = NULL; + size_t len = 0; + if (storage_read_text(STORAGE_MAPPINGS, &buf, &len) != ESP_OK || buf == NULL) { + free(buf); + return ESP_ERR_NOT_FOUND; + } + + cJSON *root = cJSON_Parse(buf); + free(buf); + if (root == NULL || !cJSON_IsObject(root)) { + if (root) cJSON_Delete(root); + return ESP_ERR_NOT_FOUND; + } + + cJSON *item = cJSON_GetObjectItem(root, uid); + esp_err_t ret = (item && cJSON_IsString(item) && item->valuestring) ? ESP_OK : ESP_ERR_NOT_FOUND; + if (ret == ESP_OK) { + *out = strdup(item->valuestring); + if (*out == NULL) { + ret = ESP_ERR_NO_MEM; + } + } + cJSON_Delete(root); + return ret; +} + static esp_err_t seed_defaults(void) { /* Create /spiffs/cards directory */ diff --git a/main/storage_manager.h b/main/storage_manager.h index 0169441..25f407a 100644 --- a/main/storage_manager.h +++ b/main/storage_manager.h @@ -40,6 +40,17 @@ esp_err_t storage_read_text(const char *path, char **out_buf, size_t *out_len); */ esp_err_t storage_write_text(const char *path, const char *text); +/** + * @brief Add/replace a UID->card-name mapping in /spiffs/mappings.json. + */ +esp_err_t storage_manager_bind_uid(const char *uid, const char *card_name); + +/** + * @brief Lookup a card name for a UID in mappings.json. Caller frees *out. + * @return ESP_OK and non-NULL *out on success; ESP_ERR_NOT_FOUND otherwise. + */ +esp_err_t storage_manager_lookup_name(const char *uid, char **out); + #ifdef __cplusplus } #endif \ No newline at end of file diff --git a/main/web_server.c b/main/web_server.c new file mode 100644 index 0000000..fd6ca10 --- /dev/null +++ b/main/web_server.c @@ -0,0 +1,304 @@ +/* + * MTG RFID Companion - Registration web server (SoftAP) + */ + +#include +#include +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_netif.h" +#include "esp_http_server.h" +#include "cJSON.h" +#include "rfid_manager.h" +#include "storage_manager.h" +#include "scryfall_client.h" +#include "web_server.h" + +static const char *TAG = "web"; + +#define AP_SSID "MTG-Companion" +#define AP_PASS "" +#define AP_IP "192.168.4.1" +#define AP_MASK "255.255.255.0" + +#define HTTPD_BODY_MAX (2048) + +static const char SPA_PAGE[] = + "" + "" + "MTG RFID Companion" + "

MTG RFID Companion

" + "

Pair a scanned card with its Scryfall name.

" + "

Last scanned UID

waiting...

" + "

Card name

" + "" + "" + "
    " + "

    " + ""; + +static httpd_handle_t s_server = NULL; + +static esp_err_t handle_root(httpd_req_t *req); +static esp_err_t handle_last_uid(httpd_req_t *req); +static esp_err_t handle_bind(httpd_req_t *req); + +static const httpd_uri_t URI_ROOT = { + .uri = "/", .method = HTTP_GET, .handler = handle_root, .user_ctx = NULL +}; +static const httpd_uri_t URI_LAST_UID = { + .uri = "/api/last_uid", .method = HTTP_GET, .handler = handle_last_uid, .user_ctx = NULL +}; +static const httpd_uri_t URI_BIND = { + .uri = "/api/bind", .method = HTTP_POST, .handler = handle_bind, .user_ctx = NULL +}; + +static void send_json(httpd_req_t *req, int status, const char *body) +{ + httpd_resp_set_status(req, status == 200 ? "200 OK" : "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, body, -1); +} + +static char *url_decode(const char *src) +{ + if (src == NULL) { + return NULL; + } + size_t len = strlen(src); + if (len > HTTPD_BODY_MAX - 1) { + len = HTTPD_BODY_MAX - 1; + } + char *out = malloc(len + 1); + if (out == NULL) { + return NULL; + } + size_t o = 0; + for (size_t i = 0; i < len && src[i] != '\0'; i++) { + if (src[i] == '%' && i + 2 < len) { + int hi = src[i + 1] >= 'a' ? src[i + 1] - 'a' + 10 + : src[i + 1] >= 'A' ? src[i + 1] - 'A' + 10 + : src[i + 1] - '0'; + int lo = src[i + 2] >= 'a' ? src[i + 2] - 'a' + 10 + : src[i + 2] >= 'A' ? src[i + 2] - 'A' + 10 + : src[i + 2] - '0'; + if (hi < 0 || hi > 15 || lo < 0 || lo > 15) { + free(out); + return NULL; + } + out[o++] = (char)((hi << 4) | lo); + i += 2; + } else if (src[i] == '+') { + out[o++] = ' '; + } else { + out[o++] = src[i]; + } + } + out[o] = '\0'; + return out; +} + +/* ------------------------------------------------------------------ */ +/* Endpoints */ +/* ------------------------------------------------------------------ */ + +static esp_err_t handle_root(httpd_req_t *req) +{ + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, SPA_PAGE, (int)sizeof(SPA_PAGE) - 1); + return ESP_OK; +} + +static esp_err_t handle_last_uid(httpd_req_t *req) +{ + char buf[80]; + const char *uid = rfid_manager_last_uid(); + if (uid == NULL || strlen(uid) == 0) { + snprintf(buf, sizeof(buf), "{\"uid\":\"\"}\n"); + } else { + snprintf(buf, sizeof(buf), "{\"uid\":\"%s\"}\n", uid); + } + send_json(req, 200, buf); + return ESP_OK; +} + +static esp_err_t handle_bind(httpd_req_t *req) +{ + if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) { + send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}"); + return ESP_OK; + } + + char *form = malloc((size_t)req->content_len + 1); + if (form == NULL) { + send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}"); + return ESP_OK; + } + int got = httpd_req_recv(req, form, (size_t)req->content_len); + if (got != req->content_len) { + free(form); + send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}"); + return ESP_OK; + } + form[got] = '\0'; + + char *uid = NULL; + char *name = NULL; + const char *u = strstr(form, "uid="); + const char *n = strstr(form, "name="); + if (u) { + char *end = strchr(u, '&'); + (void)end; + uid = url_decode(u + 4); + } + if (n) { + name = url_decode(n + 5); + } + free(form); + + if (uid == NULL || name == NULL || strlen(uid) == 0 || strlen(name) == 0) { + free(uid); + free(name); + send_json(req, 400, "{\"ok\":false,\"error\":\"missing uid or name\"}"); + return ESP_OK; + } + + esp_err_t ret = storage_manager_bind_uid(uid, name); + if (ret != ESP_OK) { + free(uid); + free(name); + send_json(req, 400, "{\"ok\":false,\"error\":\"persist failed\"}"); + return ESP_OK; + } + + scryfall_fetch_request(name, uid); + char ok[192]; + snprintf(ok, sizeof(ok), "{\"ok\":true,\"uid\":\"%s\",\"name\":\"%s\"}\n", uid, name); + ESP_LOGI(TAG, "paired UID %s -> %s", uid, name); + send_json(req, 200, ok); + + free(uid); + free(name); + return ESP_OK; +} + +/* ------------------------------------------------------------------ */ +/* SoftAP + server */ +/* ------------------------------------------------------------------ */ + +esp_err_t web_server_start(void) +{ + if (s_server != NULL) { + return ESP_OK; + } + + /* AP netif must exist before wifi start */ + esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); + if (ap == NULL) { + ap = esp_netif_create_default_wifi_ap(); + if (ap == NULL) { + ESP_LOGE(TAG, "failed to create AP netif"); + return ESP_FAIL; + } + } + + esp_err_t ret = esp_wifi_stop(); /* safe even if not started */ + if (ret != ESP_OK && ret != ESP_ERR_WIFI_NOT_STARTED) { + ESP_LOGE(TAG, "wifi stop failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = esp_wifi_set_mode(WIFI_MODE_APSTA); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "set mode APSTA failed: %s", esp_err_to_name(ret)); + return ret; + } + + wifi_config_t wifi_cfg = { + .ap = { + .ssid_len = sizeof(AP_SSID) - 1, + .max_connection = 4, + .authmode = (AP_PASS[0] == '\0') ? WIFI_AUTH_OPEN : WIFI_AUTH_WPA2_PSK, + }, + }; + strncpy((char *)wifi_cfg.ap.ssid, AP_SSID, sizeof(wifi_cfg.ap.ssid) - 1); + if (AP_PASS[0] != '\0') { + strncpy((char *)wifi_cfg.ap.password, AP_PASS, sizeof(wifi_cfg.ap.password) - 1); + } + ret = esp_wifi_set_config(WIFI_IF_AP, &wifi_cfg); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "set AP config failed: %s", esp_err_to_name(ret)); + return ret; + } + + esp_netif_ip_info_t ip_info = { + .ip = { .addr = ESP_IP4TOADDR(192, 168, 4, 1) }, + .netmask = { .addr = ESP_IP4TOADDR(255, 255, 255, 0) }, + .gw = { .addr = ESP_IP4TOADDR(192, 168, 4, 1) }, + }; + ESP_ERROR_CHECK(esp_netif_set_ip_info(ap, &ip_info)); + + ret = esp_wifi_start(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "wifi start (APSTA) failed: %s", esp_err_to_name(ret)); + return ret; + } + ESP_ERROR_CHECK(esp_netif_dhcps_start(ap)); + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.stack_size = 6 * 1024; + config.max_uri_handlers = 8; + config.lru_purge_enable = true; + config.keep_alive_enable = true; + config.max_open_sockets = 4; + + ret = httpd_start(&s_server, &config); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "httpd_start failed: %s", esp_err_to_name(ret)); + return ret; + } + + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ROOT)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LAST_UID)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_BIND)); + + ESP_LOGI(TAG, "SoftAP '%s' at %s, web server ready", AP_SSID, AP_IP); + return ESP_OK; +} + +bool web_server_is_running(void) +{ + return s_server != NULL; +} \ No newline at end of file diff --git a/main/web_server.h b/main/web_server.h new file mode 100644 index 0000000..1bf2723 --- /dev/null +++ b/main/web_server.h @@ -0,0 +1,28 @@ +/* + * MTG RFID Companion - Registration web server (SoftAP) + * + * Phase 6: Hosts the SoftAP "MTG-Companion" (192.168.4.1) and serves the + * card-pairing SPA plus /api/last_uid and /api/bind endpoints. + */ + +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Start the SoftAP and HTTP server. Idempotent. + */ +esp_err_t web_server_start(void); + +/** + * @brief True if the HTTP server is running. + */ +bool web_server_is_running(void); + +#ifdef __cplusplus +} +#endif \ No newline at end of file