From 3708674bbba8a0bd7c503f378aeef01ed329520b Mon Sep 17 00:00:00 2001 From: Rasmus Date: Sat, 12 Sep 2026 13:47:34 +0200 Subject: [PATCH] AP menu --- README.md | 27 ++++++ components/rc522/src/rc522.c | 4 +- main/ota_manager.c | 72 +++++++++++++-- main/rfid_manager.c | 2 +- main/scryfall_client.c | 19 +++- main/storage_manager.c | 30 +------ main/ui_task.c | 167 +++++++++++++++++++++-------------- main/wifi_manager.c | 50 ++++++++++- main/wifi_manager.h | 17 ++++ sdkconfig.defaults | 7 +- storage/cards/.gitkeep | 0 storage/cards/sample.jpg | Bin 23530 -> 0 bytes storage/cards/sample.json | 7 -- storage/mappings.json | 7 +- 14 files changed, 289 insertions(+), 120 deletions(-) create mode 100644 storage/cards/.gitkeep delete mode 100644 storage/cards/sample.jpg delete mode 100644 storage/cards/sample.json diff --git a/README.md b/README.md index 1438b41..1b89845 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,33 @@ When heading to your Local Game Store (LGS), a friend's house, or a tournament, --- +### šŸ”‹ Battery Life & Power Optimization (850 mAh LiPo) + +The MTG Companion is engineered for long tabletop sessions powered by a standard **850 mAh 3.7V LiPo** cell (~3.15 Wh). + +#### Expected Runtime + +| Play Style | Average Current | Expected Battery Life | Notes | +| :--- | :---: | :---: | :--- | +| **Typical Commander / FNM Session** | **~60 – 70 mA** | **10 – 12 hours** | Screen ON, tracking life, token spawning, occasional card scans, Wi-Fi modem sleep active | +| **Offline Play (No Wi-Fi)** | **~50 – 60 mA** | **12 – 14 hours** | Wi-Fi idle/disconnected, all card art and Oracle data streamed from local LittleFS flash | +| **Heavy Wi-Fi Setup / Card Pairing** | **~130 – 160 mA** | **4 – 5.5 hours** | Continuous SoftAP web portal active, bulk Scryfall art downloads, OTA updates | + +#### Firmware Power-Saving Features + +* **šŸ’¤ Automatic Wi-Fi Modem Sleep (`WIFI_PS_MIN_MODEM`):** + When connected to Wi-Fi but not actively transferring data (e.g. while tracking life or waiting for card taps), the Wi-Fi radio enters modem sleep between beacon intervals. This cuts ESP32 power consumption by **~30–40 mA**. The radio automatically boosts to full power (`WIFI_PS_NONE`) during active Scryfall downloads, OTA flashing, or SoftAP pairing mode. +* **šŸ“” 125 ms RFID Carrier Duty Cycling:** + The MFRC522 polling loop runs an RF carrier burst sweep every 125 ms instead of continuously pumping 13.56 MHz RF power. This reduces average RFID reader draw from 20 mA down to **~5 mA** without any perceptible tag detection latency. + +#### āš ļø Hardware Tip: LDO Voltage Regulator Selection + +To extract the full capacity of a single-cell LiPo battery (discharging from 4.2V down to ~3.4V): +* **Recommended LDOs:** Use ultra-low-dropout regulators such as **ME6211**, **AP2112K**, or **XC6206** (dropout voltage ~100–250 mV). These maintain a solid 3.3V rail down to ~3.45V battery level, utilizing **>90%** of the battery's energy. +* **Avoid AMS1117-3.3:** The common AMS1117 has a large 1.1V–1.3V dropout. When powered from a 3.7V LiPo, it browns out before utilizing even 20% of the battery capacity. + +--- + ## šŸ› ļø Part 2: Technical Specifications & Build Guide ### Hardware Architecture diff --git a/components/rc522/src/rc522.c b/components/rc522/src/rc522.c index c5de2bd..9ed743d 100644 --- a/components/rc522/src/rc522.c +++ b/components/rc522/src/rc522.c @@ -236,7 +236,9 @@ void rc522_task(void *arg) continue; } - rc522_delay_ms(task_delay_ms); + uint32_t delay_ms = (rc522->config->poll_interval_ms > 0) ? rc522->config->poll_interval_ms : task_delay_ms; + rc522_delay_ms(delay_ms); + if (rc522->config->task_mutex != NULL) { if (xSemaphoreTake(rc522->config->task_mutex, pdMS_TO_TICKS(mutex_take_timeout_ms)) == pdTRUE) { diff --git a/main/ota_manager.c b/main/ota_manager.c index c0df77b..14c6c60 100644 --- a/main/ota_manager.c +++ b/main/ota_manager.c @@ -22,6 +22,7 @@ #include "nvs_flash.h" #include "nvs.h" #include "cJSON.h" +#include "wifi_manager.h" #include "ota_manager.h" static const char *TAG = "ota"; @@ -30,7 +31,42 @@ static const char *TAG = "ota"; #define GITEA_RELEASE_API_URL "https://git.rasmusbendtsen.dk/api/v1/repos/rasmus/MTGcompanion/releases/latest" #define GITEA_FIRMWARE_FALLBACK_URL "https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases/download/latest/mtg-rfid-companion.bin" -#define GITEA_API_BUF_SIZE (8192) +#define GITEA_API_BUF_SIZE (5120) + +/* Let's Encrypt ISRG Root X1 CA Certificate (valid through 2035) */ +static const char s_isrg_root_x1_pem[] = + "-----BEGIN CERTIFICATE-----\n" + "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\n" + "-----END CERTIFICATE-----\n"; + static SemaphoreHandle_t s_ota_mux = NULL; static ota_status_t s_status = OTA_STATUS_IDLE; @@ -215,6 +251,14 @@ static void ota_check_task(void *arg) ESP_LOGI(TAG, "Querying Gitea release API: %s", GITEA_RELEASE_API_URL); + if (wifi_manager_acquire_net_lock(pdMS_TO_TICKS(15000)) != ESP_OK) { + ESP_LOGW(TAG, "Network lock busy, cannot start OTA check"); + set_state(OTA_STATUS_FAILED, "Network busy"); + s_ota_task_handle = NULL; + vTaskDelete(NULL); + return; + } + http_rx_buf_t rx = { .buf = malloc(GITEA_API_BUF_SIZE), .len = 0, @@ -222,6 +266,7 @@ static void ota_check_task(void *arg) }; if (!rx.buf) { + wifi_manager_release_net_lock(); set_state(OTA_STATUS_FAILED, "Out of memory"); s_ota_task_handle = NULL; vTaskDelete(NULL); @@ -232,15 +277,16 @@ static void ota_check_task(void *arg) esp_http_client_config_t cfg = { .url = GITEA_RELEASE_API_URL, .transport_type = HTTP_TRANSPORT_OVER_SSL, - .timeout_ms = 10000, + .timeout_ms = 15000, .event_handler = http_rx_event_handler, .user_data = &rx, - .buffer_size = 2048, - .crt_bundle_attach = esp_crt_bundle_attach, + .buffer_size = 1024, + .cert_pem = s_isrg_root_x1_pem, }; esp_http_client_handle_t client = esp_http_client_init(&cfg); if (!client) { + wifi_manager_release_net_lock(); free(rx.buf); set_state(OTA_STATUS_FAILED, "HTTP init failed"); s_ota_task_handle = NULL; @@ -255,6 +301,8 @@ static void ota_check_task(void *arg) esp_err_t ret = esp_http_client_perform(client); int status_code = esp_http_client_get_status_code(client); esp_http_client_cleanup(client); + wifi_manager_release_net_lock(); + if (ret != ESP_OK || status_code != 200) { char err_msg[64]; @@ -415,6 +463,14 @@ static void ota_download_task(void *arg) s_bytes_read = 0; s_total_bytes = s_release.binary_size; + if (wifi_manager_acquire_net_lock(pdMS_TO_TICKS(15000)) != ESP_OK) { + ESP_LOGW(TAG, "Network lock busy, cannot start OTA download"); + set_state(OTA_STATUS_FAILED, "Network busy"); + s_ota_task_handle = NULL; + vTaskDelete(NULL); + return; + } + set_state(OTA_STATUS_DOWNLOADING, "Starting download..."); ESP_LOGI(TAG, "Starting HTTPS OTA from: %s", s_release.download_url); @@ -424,7 +480,7 @@ static void ota_download_task(void *arg) .timeout_ms = 30000, .keep_alive_enable = true, .buffer_size = 2048, - .crt_bundle_attach = esp_crt_bundle_attach, + .cert_pem = s_isrg_root_x1_pem, }; esp_https_ota_config_t ota_cfg = { @@ -434,6 +490,7 @@ static void ota_download_task(void *arg) esp_https_ota_handle_t ota_handle = NULL; esp_err_t err = esp_https_ota_begin(&ota_cfg, &ota_handle); if (err != ESP_OK || ota_handle == NULL) { + wifi_manager_release_net_lock(); ESP_LOGE(TAG, "esp_https_ota_begin failed: %s", esp_err_to_name(err)); set_state(OTA_STATUS_FAILED, "Failed to connect to image"); s_ota_task_handle = NULL; @@ -448,6 +505,7 @@ static void ota_download_task(void *arg) if (memcmp(new_app_info.app_elf_sha256, running->app_elf_sha256, sizeof(new_app_info.app_elf_sha256)) == 0) { ESP_LOGW(TAG, "Incoming image has identical ELF SHA-256 as running app - aborting flash write"); esp_https_ota_abort(ota_handle); + wifi_manager_release_net_lock(); set_state(OTA_STATUS_UP_TO_DATE, "Firmware is already up to date"); s_ota_task_handle = NULL; vTaskDelete(NULL); @@ -459,6 +517,7 @@ static void ota_download_task(void *arg) if (s_cancel_requested) { ESP_LOGW(TAG, "OTA canceled by user"); esp_https_ota_abort(ota_handle); + wifi_manager_release_net_lock(); set_state(OTA_STATUS_IDLE, "Update canceled"); s_ota_task_handle = NULL; vTaskDelete(NULL); @@ -481,6 +540,7 @@ static void ota_download_task(void *arg) if (err == ESP_OK) { if (esp_https_ota_is_complete_data_received(ota_handle)) { esp_err_t finish_err = esp_https_ota_finish(ota_handle); + wifi_manager_release_net_lock(); if (finish_err == ESP_OK) { /* Store installed release hash in NVS */ nvs_handle_t nvs_h; @@ -502,11 +562,13 @@ static void ota_download_task(void *arg) } } else { esp_https_ota_abort(ota_handle); + wifi_manager_release_net_lock(); set_state(OTA_STATUS_FAILED, "Incomplete download"); } } else { ESP_LOGE(TAG, "esp_https_ota_perform failed: %s", esp_err_to_name(err)); esp_https_ota_abort(ota_handle); + wifi_manager_release_net_lock(); set_state(OTA_STATUS_FAILED, "Flash write failed"); } diff --git a/main/rfid_manager.c b/main/rfid_manager.c index 26a29db..51036ee 100644 --- a/main/rfid_manager.c +++ b/main/rfid_manager.c @@ -20,7 +20,7 @@ static const char *TAG = "rfid"; * Keep this <= 15 chars and in sync with the rc522 library's * xTaskCreate("rc522_poll", ...). */ #define RC522_TASK_NAME "rc522_poll" -#define RC522_POLL_INTERVAL_MS (100) +#define RC522_POLL_INTERVAL_MS (125) #define RC522_TASK_STACK_SIZE (6 * 1024) #define RC522_TASK_PRIORITY (1) diff --git a/main/scryfall_client.c b/main/scryfall_client.c index 85ed7d6..ee4c8d6 100644 --- a/main/scryfall_client.c +++ b/main/scryfall_client.c @@ -148,6 +148,12 @@ static esp_err_t http_event_handler(esp_http_client_event_t *evt) static esp_err_t http_request(const char *url, http_sink_t *sink) { + if (wifi_manager_acquire_net_lock(pdMS_TO_TICKS(15000)) != ESP_OK) { + ESP_LOGW(TAG, "Network lock busy (e.g. OTA update in progress), deferring fetch %s", url); + return ESP_ERR_TIMEOUT; + } + + esp_err_t overall_ret = ESP_FAIL; for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { if (sink->file != NULL) { rewind(sink->file); @@ -168,7 +174,8 @@ static esp_err_t http_request(const char *url, http_sink_t *sink) }; esp_http_client_handle_t client = esp_http_client_init(&cfg); if (client == NULL) { - return ESP_ERR_NO_MEM; + overall_ret = ESP_ERR_NO_MEM; + break; } esp_http_client_set_method(client, HTTP_METHOD_GET); @@ -181,17 +188,21 @@ static esp_err_t http_request(const char *url, http_sink_t *sink) 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; + overall_ret = ESP_OK; + break; } 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; + overall_ret = sink->err; + break; } vTaskDelay(pdMS_TO_TICKS(500)); } - return ESP_FAIL; + + wifi_manager_release_net_lock(); + return overall_ret; } static esp_err_t http_get_json(const char *url, char **out_buf) diff --git a/main/storage_manager.c b/main/storage_manager.c index cbfafe2..04ba25e 100644 --- a/main/storage_manager.c +++ b/main/storage_manager.c @@ -17,20 +17,6 @@ static const char *TAG = "storage"; #define STORAGE_PARTITION_LABEL "storage" -static const char SAMPLE_MAPPINGS[] = - "{\n" - " \"04A2B3C4\": \"Sol Ring\",\n" - " \"04E1F2A3\": \"Atraxa, Praetors' Voice\"\n" - "}\n"; - -static const char SAMPLE_CARD[] = - "{\n" - " \"name\": \"Sol Ring\",\n" - " \"mana_cost\": \"{1}\",\n" - " \"type\": \"Artifact\",\n" - " \"oracle\": \"Tap: Add CC.\",\n" - " \"price_usd\": \"1.25\"\n" - "}\n"; bool storage_file_exists(const char *path) { @@ -222,22 +208,12 @@ esp_err_t storage_manager_lookup_name(const char *uid, char **out) static esp_err_t seed_defaults(void) { - /* Create /spiffs/cards directory */ + /* Ensure /spiffs/cards directory exists */ mkdir(STORAGE_CARDS_DIR, 0755); if (!storage_file_exists(STORAGE_MAPPINGS)) { - ESP_LOGI(TAG, "seeding %s", STORAGE_MAPPINGS); - esp_err_t ret = storage_write_text(STORAGE_MAPPINGS, SAMPLE_MAPPINGS); - if (ret != ESP_OK) { - return ret; - } - } - - char sample_path[64]; - snprintf(sample_path, sizeof(sample_path), STORAGE_CARDS_DIR "/sample.json"); - if (!storage_file_exists(sample_path)) { - ESP_LOGI(TAG, "seeding %s", sample_path); - esp_err_t ret = storage_write_text(sample_path, SAMPLE_CARD); + ESP_LOGI(TAG, "Creating empty %s", STORAGE_MAPPINGS); + esp_err_t ret = storage_write_text(STORAGE_MAPPINGS, "{\n}\n"); if (ret != ESP_OK) { return ret; } diff --git a/main/ui_task.c b/main/ui_task.c index 22e094d..e56a00b 100644 --- a/main/ui_task.c +++ b/main/ui_task.c @@ -887,34 +887,45 @@ static void view_storage(void) lv_obj_set_style_pad_all(stat_box, 8, 0); lv_obj_set_scrollbar_mode(stat_box, LV_SCROLLBAR_MODE_OFF); - lv_obj_t *key_lbl = lv_label_create(stat_box); - lv_obj_set_width(key_lbl, 125); - lv_obj_set_style_text_font(key_lbl, &lv_font_montserrat_14, 0); - lv_obj_set_style_text_color(key_lbl, lv_color_hex(0x00E5FF), 0); - lv_label_set_text(key_lbl, - "CACHED CARDS:\n" - "ART CROPS:\n\n" - "STORAGE USED:\n" - "PERCENT USED:\n" - "DECK CAPACITY:"); - lv_obj_align(key_lbl, LV_ALIGN_TOP_LEFT, 0, 0); + struct { + const char *key; + char val[32]; + } stat_rows[5]; + + snprintf(stat_rows[0].val, sizeof(stat_rows[0].val), "%d", json_c); + stat_rows[0].key = "CACHED CARDS:"; + + snprintf(stat_rows[1].val, sizeof(stat_rows[1].val), "%d", jpg_c); + stat_rows[1].key = "ART CROPS:"; + + snprintf(stat_rows[2].val, sizeof(stat_rows[2].val), "%d / %d KB", (int)(used_b / 1024), (int)(total_b / 1024)); + stat_rows[2].key = "STORAGE USED:"; - lv_obj_t *val_lbl = lv_label_create(stat_box); - lv_obj_set_width(val_lbl, 70); - lv_obj_set_style_text_font(val_lbl, &lv_font_montserrat_14, 0); - lv_obj_set_style_text_color(val_lbl, lv_color_hex(COLOR_AMBER), 0); - lv_obj_set_style_text_align(val_lbl, LV_TEXT_ALIGN_RIGHT, 0); int percent = total_b > 0 ? (int)((used_b * 100) / total_b) : 0; - lv_label_set_text_fmt(val_lbl, - "%d\n" - "%d\n\n" - "%d KB\n" - "%d%%\n" - "~120-140", - json_c, jpg_c, - (int)(used_b / 1024), - percent); - lv_obj_align(val_lbl, LV_ALIGN_TOP_RIGHT, 0, 0); + snprintf(stat_rows[3].val, sizeof(stat_rows[3].val), "%d%%", percent); + stat_rows[3].key = "PERCENT USED:"; + + snprintf(stat_rows[4].val, sizeof(stat_rows[4].val), "~100 (EDH)"); + stat_rows[4].key = "DECK CAPACITY:"; + + for (int i = 0; i < 5; i++) { + int y_pos = (i < 2) ? (i * 20) : (i * 20 + 8); + + lv_obj_t *k = lv_label_create(stat_box); + lv_label_set_text(k, stat_rows[i].key); + lv_obj_set_style_text_font(k, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(k, lv_color_hex(0x00E5FF), 0); + lv_obj_align(k, LV_ALIGN_TOP_LEFT, 0, y_pos); + + lv_obj_t *v = lv_label_create(stat_box); + lv_label_set_text(v, stat_rows[i].val); + lv_obj_set_width(v, 90); + lv_label_set_long_mode(v, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(v, lv_color_hex(COLOR_AMBER), 0); + lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(v, LV_ALIGN_TOP_RIGHT, 0, y_pos); + } /* Action 0: Clear Cards Button */ lv_obj_t *btn0 = lv_obj_create(lv_scr_act()); @@ -990,54 +1001,66 @@ static void view_device_info(void) const char *ssid = wifi_manager_get_connected_ssid(); int rssi = wifi_manager_get_rssi(); - char wifi_line[32]; + char wifi_line[64]; if (wifi_manager_connected() && ssid && strlen(ssid) > 0) { snprintf(wifi_line, sizeof(wifi_line), "%s (%ddB)", ssid, rssi); } else { snprintf(wifi_line, sizeof(wifi_line), "Disconnected"); } - lv_obj_t *lbl_keys = lv_label_create(box); - lv_obj_set_width(lbl_keys, 75); - lv_obj_set_style_text_font(lbl_keys, &lv_font_montserrat_14, 0); - lv_obj_set_style_text_color(lbl_keys, lv_color_hex(0x00E5FF), 0); - lv_label_set_text(lbl_keys, - "VERSION:\n" - "WI-FI:\n" - "IP ADDR:\n" - "HOTSPOT:\n" - "STORAGE:\n" - "CARDS:\n" - "FREE RAM:\n" - "ENCODER:\n" - "HARDWARE:\n" - "RFID:"); - lv_obj_align(lbl_keys, LV_ALIGN_TOP_LEFT, 0, 0); + struct { + const char *key; + char val[64]; + } rows[10]; - lv_obj_t *lbl_vals = lv_label_create(box); - lv_obj_set_width(lbl_vals, 117); - lv_obj_set_style_text_font(lbl_vals, &lv_font_montserrat_14, 0); - lv_obj_set_style_text_color(lbl_vals, lv_color_hex(COLOR_AMBER), 0); - lv_obj_set_style_text_align(lbl_vals, LV_TEXT_ALIGN_RIGHT, 0); - lv_label_set_text_fmt(lbl_vals, - "%s\n" - "%s\n" - "%s\n" - "192.168.4.1\n" - "%d / %d KB\n" - "%d cached\n" - "%d KB\n" - "%d steps\n" - "ESP32+ST7789\n" - "MFRC522", - ota_manager_get_current_version(), - wifi_line, - wifi_manager_get_ip(), - (int)(used_b / 1024), (int)(total_b / 1024), - json_c, - (int)(free_heap / 1024), - (int)encoder_get_steps_per_detent()); - lv_obj_align(lbl_vals, LV_ALIGN_TOP_RIGHT, 0, 0); + snprintf(rows[0].val, sizeof(rows[0].val), "%s", ota_manager_get_current_version()); + rows[0].key = "VERSION:"; + + snprintf(rows[1].val, sizeof(rows[1].val), "%s", wifi_line); + rows[1].key = "WI-FI:"; + + snprintf(rows[2].val, sizeof(rows[2].val), "%s", wifi_manager_get_ip()); + rows[2].key = "IP ADDR:"; + + snprintf(rows[3].val, sizeof(rows[3].val), "192.168.4.1"); + rows[3].key = "HOTSPOT:"; + + snprintf(rows[4].val, sizeof(rows[4].val), "%d / %d KB", (int)(used_b / 1024), (int)(total_b / 1024)); + rows[4].key = "STORAGE:"; + + snprintf(rows[5].val, sizeof(rows[5].val), "%d cached", json_c); + rows[5].key = "CARDS:"; + + snprintf(rows[6].val, sizeof(rows[6].val), "%d KB", (int)(free_heap / 1024)); + rows[6].key = "FREE RAM:"; + + snprintf(rows[7].val, sizeof(rows[7].val), "%d steps", (int)encoder_get_steps_per_detent()); + rows[7].key = "ENCODER:"; + + snprintf(rows[8].val, sizeof(rows[8].val), "ESP32+ST7789"); + rows[8].key = "HARDWARE:"; + + snprintf(rows[9].val, sizeof(rows[9].val), "MFRC522"); + rows[9].key = "RFID:"; + + for (int i = 0; i < 10; i++) { + int y_pos = i * 20; + + lv_obj_t *k = lv_label_create(box); + lv_label_set_text(k, rows[i].key); + lv_obj_set_style_text_font(k, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(k, lv_color_hex(0x00E5FF), 0); + lv_obj_align(k, LV_ALIGN_TOP_LEFT, 0, y_pos); + + lv_obj_t *v = lv_label_create(box); + lv_label_set_text(v, rows[i].val); + lv_obj_set_width(v, 120); + lv_label_set_long_mode(v, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(v, lv_color_hex(COLOR_AMBER), 0); + lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(v, LV_ALIGN_TOP_RIGHT, 0, y_pos); + } lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "CLICK: MENU"); @@ -1561,6 +1584,7 @@ static void handle_new_card(const char *uid) char *name = NULL; esp_err_t ret = storage_manager_lookup_name(uid, &name); if (ret != ESP_OK || name == NULL) { + wifi_manager_set_power_save(false); app_set_view(APP_STATE_REGISTRATION); view_unmapped(uid); buzzer_chirp(392, 80); @@ -1757,6 +1781,9 @@ static void handle_encoder_click(void) } else { if (s_storage_option_idx == 0) { storage_manager_clear_all_cards(true); + s_card_name[0] = '\0'; + s_uid[0] = '\0'; + s_waiting_fetch = false; buzzer_chirp(1046, 100); s_storage_confirm_dialog = false; view_storage(); @@ -1778,6 +1805,9 @@ static void handle_encoder_click(void) case APP_STATE_SCANNER: case APP_STATE_CARD_VIEW: case APP_STATE_REGISTRATION: + if (cur == APP_STATE_REGISTRATION) { + wifi_manager_set_power_save(true); + } if (cur == APP_STATE_CARD_VIEW && s_card_name[0] != '\0' && !s_waiting_fetch) { char jp[72]; snprintf(jp, sizeof(jp), STORAGE_CARDS_DIR "/%s.json", s_uid); @@ -1961,6 +1991,9 @@ static void ui_task(void *arg) buzzer_chirp(1046, 50); vTaskDelay(pdMS_TO_TICKS(40)); buzzer_chirp(1318, 70); + if (app_get_view() == APP_STATE_REGISTRATION) { + wifi_manager_set_power_save(true); + } app_set_view(APP_STATE_MENU); view_menu(); } diff --git a/main/wifi_manager.c b/main/wifi_manager.c index e5af657..5b8f39e 100644 --- a/main/wifi_manager.c +++ b/main/wifi_manager.c @@ -31,6 +31,8 @@ static int s_retry_count = 0; static char s_sta_ip[20] = "No Wi-Fi"; static char s_active_ssid[WIFI_SSID_MAX_LEN + 1] = "Disconnected"; static SemaphoreHandle_t s_wifi_mux = NULL; +static SemaphoreHandle_t s_net_lock = NULL; + /* ------------------------------------------------------------------ */ /* NVS Storage Helpers */ @@ -203,6 +205,9 @@ static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, voi if (s_wifi_group) { xEventGroupSetBits(s_wifi_group, WIFI_CONNECTED_BIT); } + /* Enable modem power saving when idle on station network */ + esp_wifi_set_ps(WIFI_PS_MIN_MODEM); + ESP_LOGI(TAG, "Wi-Fi modem sleep enabled (WIFI_PS_MIN_MODEM: ~30-40 mA saved)"); } } @@ -361,11 +366,13 @@ esp_err_t wifi_manager_init(void) } s_wifi_mux = xSemaphoreCreateMutex(); + s_net_lock = xSemaphoreCreateMutex(); s_wifi_group = xEventGroupCreate(); - if (s_wifi_group == NULL) { + if (s_wifi_group == NULL || s_net_lock == NULL) { return ESP_ERR_NO_MEM; } + ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta(); @@ -467,4 +474,45 @@ esp_err_t wifi_manager_start(void) } return ESP_OK; +} + +static bool s_power_save_enabled = true; + +esp_err_t wifi_manager_acquire_net_lock(TickType_t timeout_ticks) +{ + if (!s_net_lock) { + s_net_lock = xSemaphoreCreateMutex(); + } + if (!s_net_lock) { + return ESP_ERR_NO_MEM; + } + BaseType_t taken = xSemaphoreTake(s_net_lock, timeout_ticks); + if (taken == pdTRUE) { + /* Full RF performance for active TLS data transfers / OTA */ + esp_wifi_set_ps(WIFI_PS_NONE); + return ESP_OK; + } + return ESP_ERR_TIMEOUT; +} + +void wifi_manager_release_net_lock(void) +{ + if (s_net_lock) { + /* Revert to low-power modem sleep when idle */ + if (s_power_save_enabled && wifi_manager_connected()) { + esp_wifi_set_ps(WIFI_PS_MIN_MODEM); + } + xSemaphoreGive(s_net_lock); + } +} + +esp_err_t wifi_manager_set_power_save(bool enable) +{ + s_power_save_enabled = enable; + wifi_ps_type_t ps_type = enable ? WIFI_PS_MIN_MODEM : WIFI_PS_NONE; + esp_err_t err = esp_wifi_set_ps(ps_type); + ESP_LOGI(TAG, "Wi-Fi power save %s (%s)", + enable ? "enabled" : "disabled", + enable ? "WIFI_PS_MIN_MODEM" : "WIFI_PS_NONE"); + return err; } \ No newline at end of file diff --git a/main/wifi_manager.h b/main/wifi_manager.h index ccab756..faa022f 100644 --- a/main/wifi_manager.h +++ b/main/wifi_manager.h @@ -99,6 +99,23 @@ int wifi_manager_get_saved_profiles(wifi_profile_t *out_profiles, int max_count) */ esp_err_t wifi_manager_connect_new(const char *ssid, const char *password, bool save_to_nvs); +/** + * @brief Acquire exclusive network lock for heavy TLS / HTTPS operations. + * Prevents simultaneous TLS handshakes (OTA vs Scryfall) from exhausting SRAM. + */ +esp_err_t wifi_manager_acquire_net_lock(TickType_t timeout_ticks); + +/** + * @brief Release exclusive network lock. + */ +void wifi_manager_release_net_lock(void); + +/** + * @brief Set Wi-Fi modem power saving mode. + * @param enable True for WIFI_PS_MIN_MODEM (saves ~30-40 mA), False for WIFI_PS_NONE. + */ +esp_err_t wifi_manager_set_power_save(bool enable); + #ifdef __cplusplus } #endif \ No newline at end of file diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 448a885..14705c6 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -28,4 +28,9 @@ CONFIG_JD_FORMAT_RGB565=y CONFIG_JD_FASTDECODE_32BIT=y # Flash Chip Driver Optimization -CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y \ No newline at end of file +CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y + +# mbedTLS Dynamic Buffers & Memory Optimization (Vital for ESP32 without PSRAM) +CONFIG_MBEDTLS_DYNAMIC_BUFFER=y +CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y +CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT=y \ No newline at end of file diff --git a/storage/cards/.gitkeep b/storage/cards/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/cards/sample.jpg b/storage/cards/sample.jpg deleted file mode 100644 index 251af3d4cd6951e10b9f3e08b4071a2587a6c974..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23530 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 H|8D{SE?;j- diff --git a/storage/cards/sample.json b/storage/cards/sample.json deleted file mode 100644 index 85cde57..0000000 --- a/storage/cards/sample.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "Sol Ring", - "mana_cost": "{1}", - "type": "Artifact", - "oracle": "Tap: Add CC.", - "price_usd": "1.25" -} \ No newline at end of file diff --git a/storage/mappings.json b/storage/mappings.json index 034d815..9e26dfe 100644 --- a/storage/mappings.json +++ b/storage/mappings.json @@ -1,6 +1 @@ -{ - "04A2B3C4": "Sol Ring", - "04E1F2A3": "Atraxa, Praetors' Voice", - "508152B2": "Workshop Assistant", - "B34DF005": "Black Lotus" -} \ No newline at end of file +{} \ No newline at end of file