From d691febfe43d15c6f1c5ad13c25286018049fbb3 Mon Sep 17 00:00:00 2001 From: Rasmus Date: Sat, 12 Sep 2026 13:09:47 +0200 Subject: [PATCH] on device wifi --- .gitea/workflows/releases.yml | 59 +++-- README.md | 86 +++---- main/CMakeLists.txt | 1 + main/dns_server.c | 163 +++++++++++++ main/dns_server.h | 29 +++ main/ota_manager.c | 220 +++++++++++++----- main/ota_manager.h | 11 +- main/ui_task.c | 41 +++- main/web_server.c | 416 ++++++++++++++++++++++++++++++++- main/wifi_manager.c | 426 ++++++++++++++++++++++++++++++---- main/wifi_manager.h | 67 +++++- sdkconfig.defaults | 28 ++- sdkconfig.defaults.esp32 | 17 -- 13 files changed, 1340 insertions(+), 224 deletions(-) create mode 100644 main/dns_server.c create mode 100644 main/dns_server.h delete mode 100644 sdkconfig.defaults.esp32 diff --git a/.gitea/workflows/releases.yml b/.gitea/workflows/releases.yml index fbde84a..3cb15a9 100644 --- a/.gitea/workflows/releases.yml +++ b/.gitea/workflows/releases.yml @@ -112,39 +112,46 @@ jobs: # ------------------------------------------------------------ # Prepare release artifacts # ------------------------------------------------------------ + # ------------------------------------------------------------ + # Prepare release artifacts & calculate hashes + # ------------------------------------------------------------ - name: Prepare release shell: bash run: | mkdir -p release - echo "Build artifacts:" - find build -maxdepth 2 -type f \ - \( -name "*.bin" -o -name "*.elf" -o -name "flasher_args.json" \) \ - -print - - # Find merged binary - MERGED_BIN=$(find build -maxdepth 1 -type f -name "*.bin" | head -n 1) - - if [ -z "$MERGED_BIN" ]; then - echo "ERROR: No merged .bin found" + # Primary OTA application binary + if [ -f build/mtg-rfid-companion.bin ]; then + cp build/mtg-rfid-companion.bin release/mtg-rfid-companion.bin + cp build/mtg-rfid-companion.bin release/firmware.bin + else + echo "ERROR: build/mtg-rfid-companion.bin not found!" exit 1 fi - echo "Using merged binary: $MERGED_BIN" - - cp "$MERGED_BIN" release/firmware.bin - - # ELF for debugging - find build -maxdepth 1 -type f -name "*.elf" \ - -exec cp {} release/ \; - - # Flash configuration - if [ -f build/flasher_args.json ]; then - cp build/flasher_args.json release/ + # Merged binary for USB factory flashing + if [ -f build/merged-binary.bin ]; then + cp build/merged-binary.bin release/merged-firmware.bin fi - echo "" - echo "Release contents:" + # ELF for debugging + find build -maxdepth 1 -type f -name "*.elf" -exec cp {} release/ \; + + # Calculate hashes + BIN_SHA256=$(sha256sum release/mtg-rfid-companion.bin | awk '{print $1}') + IMG_SHA256=$(python3 -c "with open('release/mtg-rfid-companion.bin','rb') as f: f.seek(-32,2); print(f.read().hex())") + + echo "BIN_SHA256=${BIN_SHA256}" >> $GITHUB_ENV + echo "IMG_SHA256=${IMG_SHA256}" >> $GITHUB_ENV + + echo "Firmware SHA256: ${BIN_SHA256}" > release/firmware.sha256 + echo "Image SHA256: ${IMG_SHA256}" >> release/firmware.sha256 + echo "Commit: ${{ github.sha }}" >> release/firmware.sha256 + + echo "OTA Release prepared:" + echo " Binary: release/mtg-rfid-companion.bin" + echo " File SHA-256: ${BIN_SHA256}" + echo " Image SHA-256: ${IMG_SHA256}" ls -lh release/ # ------------------------------------------------------------ @@ -191,6 +198,12 @@ jobs: Commit: ${{ github.sha }} + SHA256: + ${{ env.IMG_SHA256 }} + + File-SHA256: + ${{ env.BIN_SHA256 }} + ESP-IDF: 5.5 diff --git a/README.md b/README.md index 5b4307d..1438b41 100644 --- a/README.md +++ b/README.md @@ -80,50 +80,52 @@ Never dig through your token box again for missing tokens: * Rotate the knob each time your commander is recast from the command zone; the screen calculates the extra mana required (e.g. `Cast 3 = +4 Mana`). * Click to switch to the table turn counter. -#### 6. Pairing Cards from your Phone (No App Needed!) -When you scan a new card tag that hasn't been programmed yet: -1. The screen displays: - ``` - PAIR VIA WI-FI: - http://192.168.0.57 - (or Hotspot: MTG-Companion @ 192.168.4.1) - ``` -2. Open that address in your smartphone or laptop browser. -3. Tap the card sleeve to the device—its unique ID pops up in the web form automatically. -4. Start typing the card name (e.g. `"Sol Ring"`). Real-time Scryfall suggestions appear. -5. Click **Pair Card**. The device downloads the high-res card art and text and saves it to its permanent memory. -6. **Deck Overview:** The web page also lets you view your entire scanned deck with card art thumbnails and unbind old cards with one click. +#### 6. Pairing Cards & On-Device Wi-Fi Setup (No App Needed!) +The device runs a built-in web portal that works on any smartphone, tablet, or laptop: + +* **Connecting to the Portal:** + * **Option A (Instant Captive Portal):** Connect your phone to the Wi-Fi network named **`MTG-Companion`** (no password). Your phone will automatically pop up the setup screen! (Or open `http://192.168.4.1` in your browser). + * **Option B (Home Wi-Fi):** If connected to your home network, simply browse to the device's IP shown on screen (e.g. `http://192.168.0.57`). + +* **🎴 Card Pairing Tab:** + 1. Tap an unmapped card sleeve to the reader—its unique tag ID appears instantly in the form. + 2. Type the card name (e.g. `"Sol Ring"`); Scryfall auto-complete displays live suggestions. + 3. Click **Pair Card**. The device downloads high-resolution illustration art and official oracle text into permanent flash memory. + 4. Browse and manage all paired cards in your deck with illustration thumbnails and 1-click unbind. + +* **📶 Wi-Fi Settings Tab:** + 1. Click **Scan Networks** to see all nearby Wi-Fi networks with signal strength and security indicators. + 2. Select your network (or mobile hotspot) from the dropdown, enter the password, and click **Connect & Save**. + 3. **Multi-Network Memory:** The device remembers up to 5 Wi-Fi profiles in permanent flash. When you turn the companion on, it automatically scans and connects to whichever saved network is in range (e.g. seamlessly switching between Home Wi-Fi and your Phone's Mobile Hotspot)! + 4. Forget unwanted networks anytime with one click. #### 7. Over-The-Air (OTA) Updates -* Select **8. CHECK FIRMWARE OTA** from the menu (or let it auto-check on boot when connected to Wi-Fi). -* If a new version is detected, the screen shows the new version number and asks you to click to install or turn the knob to dismiss. -* While updating, a live progress bar shows the download. When finished, the device verifies the update and restarts automatically. +The companion automatically fetches firmware releases directly from: +**`https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases`** + +* **Automatic Hash Verification:** Whenever you push code to `main`, Gitea Actions builds the new firmware and computes its cryptographic SHA-256 hash. +* **Instant Detection:** Select **8. CHECK FIRMWARE OTA** from the menu (or let it auto-check on boot when connected to Wi-Fi). The device compares its currently running image hash against the latest build on your server: + * If a new build is available, the screen shows the new target hash and invites you to click the knob to install. + * If the hashes match, it confirms the firmware is up to date and skips unnecessary flashing. +* **Safe Dual-Slot Flashing:** While updating, a live progress bar streams the binary over HTTPS into the inactive partition slot. The device validates the new image and reboots automatically with rollback protection. --- -### 💡 Pro-Tip: Changing Wi-Fi Networks on the Go (Home Wi-Fi ↔ Phone Hotspot) +### 💡 Switching Wi-Fi Networks on the Go (Home Wi-Fi ↔ Phone Hotspot) -When heading to your Local Game Store (LGS), a friend's house, or a tournament, you may want your companion connected to your phone's mobile hotspot so it can fetch fresh card data from Scryfall on the fly. +When heading to your Local Game Store (LGS), a friend's house, or a tournament, switching between your home Wi-Fi and phone hotspot is completely effortless: -Because your Companion supports Over-The-Air updates through Gitea, **you never need a USB cable or computer to change Wi-Fi credentials!** +1. **One-Time Setup via Phone Hotspot:** + - Turn on your phone's personal hotspot. + - Connect your phone to the **`MTG-Companion`** hotspot. + - In the pop-up portal under **📶 Wi-Fi Settings**, click **Scan Networks**, select your phone hotspot, enter the password, and click **Connect & Save**. +2. **Automatic Hand-off:** + - The device stores both your Home Wi-Fi and Phone Hotspot. + - At home: it automatically joins Home Wi-Fi. + - At the game store: turn on your phone's hotspot and turn on the companion—it locks onto your phone automatically! -#### Switching from Home Wi-Fi to Phone Hotspot (Before heading out): -1. **Update Wi-Fi in Gitea:** Open your Gitea repository in your browser (even on your phone) and edit `sdkconfig.defaults`: - ```ini - CONFIG_MTG_WIFI_SSID="MyPhoneHotspot" - CONFIG_MTG_WIFI_PASS="MyHotspotPassword" - ``` -2. **Commit to `main`:** Gitea Actions will automatically compile your new firmware in ~1–2 minutes. -3. **Trigger OTA Update:** While still on your home Wi-Fi, select **8. CHECK FIRMWARE OTA** on your device and click to install. -4. **Ready for the Game Store!** The device reboots with your hotspot credentials saved. When you turn on your phone's personal hotspot at the store, the companion connects automatically. - -#### Switching Back to Home Wi-Fi: -Simply revert or update `sdkconfig.defaults` in Gitea back to your home Wi-Fi name, trigger the OTA update over your phone hotspot, and you're back on your home network. - -> 🛟 **Forgot to update before leaving home?** -> No problem: -> - **The "Mirror" Hotspot Trick:** Set your phone's personal hotspot name and password to match your home Wi-Fi network. The companion will connect to your phone immediately, thinking it's at home! -> - **Offline Play Always Works:** All previously scanned cards and art are stored safely in local flash memory, and the device's built-in `MTG-Companion` pairing page works offline with zero internet required. +> 🛟 **Offline Play Always Works:** +> All previously scanned cards and art are stored in local flash memory, and the device's built-in `MTG-Companion` pairing portal works offline with zero internet required. --- @@ -235,14 +237,13 @@ idf.py --version ``` #### 2. Configuration -Configure Wi-Fi credentials and Gitea instance details: +Configure initial Wi-Fi credentials (or use on-device Wi-Fi setup): ```powershell idf.py menuconfig # Navigate to: "MTG RFID Companion" -# - Wi-Fi SSID & Password -# - Gitea instance base URL (e.g. https://git.example.com) -# - Gitea repository owner & repo name +# - Wi-Fi SSID & Password (Optional - can also be configured via captive portal) ``` +*(The firmware is pre-configured to check updates directly from `https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases`)* #### 3. Build & Flash (Initial USB Provisioning) ```powershell @@ -256,9 +257,10 @@ idf.py -p COM4 erase-flash flash monitor ### Automated CI / Gitea Actions Workflow The repository includes a Gitea Actions workflow in [`.gitea/workflows/releases.yml`](file:///c:/Users/rsben/Desktop/Rasmus/Programmering/espMTG/.gitea/workflows/releases.yml): -* **Trigger:** Pushing any version tag (e.g. `git tag v1.0.1 && git push origin v1.0.1`). +* **Trigger:** Pushes to the `main` branch. * **Environment:** Official `docker://espressif/idf:v5.5` container. -* **Artifacts:** Compiles the firmware and automatically attaches `mtg-rfid-companion.bin` (for wireless OTA updates) and `full-flash-bundle-*.tar.gz` (for USB cable flashing) directly to the Gitea release. +* **Artifacts & Hashes:** Compiles the firmware, generates SHA-256 hashes (`firmware.sha256`), and automatically releases `mtg-rfid-companion.bin` (for wireless OTA updates) to the Gitea repository at `https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases`. +* **Hash-Based Verification:** Embeds the Git commit SHA and binary SHA-256 hash in the release metadata so the ESP32 can verify whether an update is available without relying on manual version bumps. --- diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 321c42e..9bd74ac 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -11,5 +11,6 @@ idf_component_register(SRCS "buzzer.c" "app_state.c" "ota_manager.c" + "dns_server.c" INCLUDE_DIRS "." ) \ No newline at end of file diff --git a/main/dns_server.c b/main/dns_server.c new file mode 100644 index 0000000..fa68caa --- /dev/null +++ b/main/dns_server.c @@ -0,0 +1,163 @@ +/* + * MTG RFID Companion - Captive Portal DNS Server + * + * Lightweight UDP DNS server on port 53. Resolves every domain query + * to 192.168.4.1 to trigger automatic captive portal detection on smartphones. + */ + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "lwip/sockets.h" +#include "lwip/netdb.h" +#include "dns_server.h" + +static const char *TAG = "dns"; + +#define DNS_PORT 53 +#define DNS_MAX_LEN 512 + +static TaskHandle_t s_dns_task = NULL; +static int s_sock = -1; +static volatile bool s_running = false; + +typedef struct __attribute__((packed)) { + uint16_t id; + uint16_t flags; + uint16_t qdcount; + uint16_t ancount; + uint16_t nscount; + uint16_t arcount; +} dns_header_t; + +static void dns_server_task(void *pvParameters) +{ + uint8_t rx_buffer[DNS_MAX_LEN]; + uint8_t tx_buffer[DNS_MAX_LEN]; + struct sockaddr_in client_addr; + socklen_t addr_len = sizeof(client_addr); + + struct sockaddr_in server_addr = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_ANY), + .sin_port = htons(DNS_PORT), + }; + + s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + if (s_sock < 0) { + ESP_LOGE(TAG, "Unable to create DNS socket: errno %d", errno); + s_running = false; + vTaskDelete(NULL); + return; + } + + struct timeval timeout = { + .tv_sec = 1, + .tv_usec = 0, + }; + setsockopt(s_sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + + if (bind(s_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { + ESP_LOGE(TAG, "DNS socket bind failed: errno %d", errno); + close(s_sock); + s_sock = -1; + s_running = false; + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "Captive portal DNS server listening on UDP port 53"); + + while (s_running) { + int len = recvfrom(s_sock, rx_buffer, sizeof(rx_buffer), 0, + (struct sockaddr *)&client_addr, &addr_len); + + if (len < (int)sizeof(dns_header_t)) { + continue; + } + + dns_header_t *req_hdr = (dns_header_t *)rx_buffer; + int qdcount = ntohs(req_hdr->qdcount); + if (qdcount < 1) { + continue; + } + + /* Parse Question section to find end of question */ + int idx = sizeof(dns_header_t); + while (idx < len && rx_buffer[idx] != 0) { + idx += 1 + rx_buffer[idx]; + } + idx += 1; /* null terminator */ + idx += 4; /* QTYPE + QCLASS */ + + if (idx > len || idx + 16 > DNS_MAX_LEN) { + continue; + } + + /* Build Response Packet */ + memcpy(tx_buffer, rx_buffer, idx); + dns_header_t *resp_hdr = (dns_header_t *)tx_buffer; + resp_hdr->flags = htons(0x8180); /* Standard response, recursion available, no error */ + resp_hdr->ancount = htons(1); + resp_hdr->nscount = 0; + resp_hdr->arcount = 0; + + /* Answer Section */ + uint8_t *ans = tx_buffer + idx; + /* Name pointer to question at offset 12 (0xC00C) */ + *ans++ = 0xC0; + *ans++ = 0x0C; + /* Type: A (IPv4) */ + *ans++ = 0x00; + *ans++ = 0x01; + /* Class: IN */ + *ans++ = 0x00; + *ans++ = 0x01; + /* TTL: 60 seconds */ + *ans++ = 0x00; + *ans++ = 0x00; + *ans++ = 0x00; + *ans++ = 0x3C; + /* Data length: 4 bytes */ + *ans++ = 0x00; + *ans++ = 0x04; + /* IP Address: 192.168.4.1 */ + *ans++ = 192; + *ans++ = 168; + *ans++ = 4; + *ans++ = 1; + + int tx_len = ans - tx_buffer; + sendto(s_sock, tx_buffer, tx_len, 0, + (struct sockaddr *)&client_addr, addr_len); + } + + if (s_sock >= 0) { + close(s_sock); + s_sock = -1; + } + ESP_LOGI(TAG, "DNS server stopped"); + s_dns_task = NULL; + vTaskDelete(NULL); +} + +esp_err_t dns_server_start(void) +{ + if (s_running) { + return ESP_OK; + } + s_running = true; + BaseType_t ret = xTaskCreatePinnedToCore( + dns_server_task, "dns_server", 3072, NULL, 4, &s_dns_task, 0); + if (ret != pdPASS) { + s_running = false; + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +void dns_server_stop(void) +{ + s_running = false; +} diff --git a/main/dns_server.h b/main/dns_server.h new file mode 100644 index 0000000..ba6483c --- /dev/null +++ b/main/dns_server.h @@ -0,0 +1,29 @@ +/* + * MTG RFID Companion - Captive Portal DNS Server + * + * Runs a lightweight UDP DNS responder on port 53 during SoftAP mode, + * redirecting all DNS lookups to 192.168.4.1 for captive portal auto-popup. + */ + +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Start the captive portal DNS server task. + * @return ESP_OK on success. + */ +esp_err_t dns_server_start(void); + +/** + * @brief Stop the captive portal DNS server task. + */ +void dns_server_stop(void); + +#ifdef __cplusplus +} +#endif diff --git a/main/ota_manager.c b/main/ota_manager.c index dc32630..c0df77b 100644 --- a/main/ota_manager.c +++ b/main/ota_manager.c @@ -14,26 +14,21 @@ #include "esp_log.h" #include "esp_system.h" #include "esp_ota_ops.h" +#include "esp_partition.h" #include "esp_https_ota.h" #include "esp_http_client.h" #include "esp_crt_bundle.h" #include "esp_app_desc.h" +#include "nvs_flash.h" +#include "nvs.h" #include "cJSON.h" #include "ota_manager.h" static const char *TAG = "ota"; -#ifndef CONFIG_MTG_GITEA_URL -#define CONFIG_MTG_GITEA_URL "https://gitea.com" -#endif - -#ifndef CONFIG_MTG_GITEA_REPO_OWNER -#define CONFIG_MTG_GITEA_REPO_OWNER "user" -#endif - -#ifndef CONFIG_MTG_GITEA_REPO_NAME -#define CONFIG_MTG_GITEA_REPO_NAME "espMTG" -#endif +#define GITEA_RELEASES_PAGE_URL "https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases" +#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) @@ -41,6 +36,8 @@ static SemaphoreHandle_t s_ota_mux = NULL; static ota_status_t s_status = OTA_STATUS_IDLE; static char s_message[128] = "Idle"; static ota_release_info_t s_release = {0}; +static char s_running_sha256[65] = {0}; +static char s_running_elf_sha256[65] = {0}; static size_t s_bytes_read = 0; static size_t s_total_bytes = 0; static volatile bool s_cancel_requested = false; @@ -77,9 +74,20 @@ esp_err_t ota_manager_init(void) } } + /* Compute running image SHA-256 */ + uint8_t sha_bytes[32]; + if (esp_partition_get_sha256(running, sha_bytes) == ESP_OK) { + for (int i = 0; i < 32; i++) { + snprintf(s_running_sha256 + (i * 2), 3, "%02x", sha_bytes[i]); + } + } + + /* Compute running ELF SHA-256 */ + esp_app_get_elf_sha256(s_running_elf_sha256, sizeof(s_running_elf_sha256)); + const esp_app_desc_t *app_desc = esp_app_get_description(); - ESP_LOGI(TAG, "Running firmware '%s' version '%s' compiled on %s %s on partition '%s'", - app_desc->project_name, app_desc->version, app_desc->date, app_desc->time, + ESP_LOGI(TAG, "Running firmware: ver='%s', SHA256=%.8s..., ELF=%.8s... on partition '%s'", + app_desc->version, s_running_sha256, s_running_elf_sha256, running ? running->label : "unknown"); return ESP_OK; @@ -90,6 +98,11 @@ const char *ota_manager_get_current_version(void) return esp_app_get_description()->version; } +const char *ota_manager_get_running_sha256(void) +{ + return s_running_sha256; +} + ota_status_t ota_manager_get_status(void) { ota_status_t st; @@ -137,35 +150,44 @@ void ota_manager_reset(void) } } -/* Semver parsing: compares "v1.2.3" or "1.2.3" */ -static bool parse_semver(const char *ver, int *maj, int *min, int *patch) +static void extract_hash_from_body(const char *body, char *out_sha256, size_t sha_sz, char *out_commit, size_t com_sz) { - if (!ver) return false; - while (*ver && !isdigit((unsigned char)*ver)) { - ver++; + if (out_sha256 && sha_sz > 0) out_sha256[0] = '\0'; + if (out_commit && com_sz > 0) out_commit[0] = '\0'; + if (!body) return; + + /* Look for "SHA256:" or "Image-SHA256:" or "File-SHA256:" */ + const char *p = strstr(body, "SHA256:"); + if (!p) p = strstr(body, "sha256:"); + if (!p) p = strstr(body, "SHA-256:"); + if (p) { + p = strchr(p, ':'); + if (p) { + p++; + while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++; + size_t idx = 0; + while (isxdigit((unsigned char)*p) && idx < sha_sz - 1) { + out_sha256[idx++] = (char)tolower((unsigned char)*p++); + } + out_sha256[idx] = '\0'; + } } - *maj = 0; *min = 0; *patch = 0; - int n = sscanf(ver, "%d.%d.%d", maj, min, patch); - return (n >= 1); -} -static bool check_is_newer(const char *remote, const char *local) -{ - int r_maj = 0, r_min = 0, r_patch = 0; - int l_maj = 0, l_min = 0, l_patch = 0; - - bool r_ok = parse_semver(remote, &r_maj, &r_min, &r_patch); - bool l_ok = parse_semver(local, &l_maj, &l_min, &l_patch); - - if (r_ok && l_ok) { - if (r_maj > l_maj) return true; - if (r_maj < l_maj) return false; - if (r_min > l_min) return true; - if (r_min < l_min) return false; - return (r_patch > l_patch); + /* Look for "Commit:" */ + const char *c = strstr(body, "Commit:"); + if (!c) c = strstr(body, "commit:"); + if (c) { + c = strchr(c, ':'); + if (c) { + c++; + while (*c == ' ' || *c == '\t' || *c == '\r' || *c == '\n') c++; + size_t idx = 0; + while (isxdigit((unsigned char)*c) && idx < com_sz - 1) { + out_commit[idx++] = (char)tolower((unsigned char)*c++); + } + out_commit[idx] = '\0'; + } } - /* Fallback to string comparison */ - return (strcmp(remote, local) != 0); } typedef struct { @@ -191,11 +213,7 @@ static void ota_check_task(void *arg) { set_state(OTA_STATUS_CHECKING, "Connecting to Gitea..."); - char api_url[256]; - snprintf(api_url, sizeof(api_url), "%s/api/v1/repos/%s/%s/releases/latest", - CONFIG_MTG_GITEA_URL, CONFIG_MTG_GITEA_REPO_OWNER, CONFIG_MTG_GITEA_REPO_NAME); - - ESP_LOGI(TAG, "Querying Gitea release API: %s", api_url); + ESP_LOGI(TAG, "Querying Gitea release API: %s", GITEA_RELEASE_API_URL); http_rx_buf_t rx = { .buf = malloc(GITEA_API_BUF_SIZE), @@ -212,7 +230,7 @@ static void ota_check_task(void *arg) rx.buf[0] = '\0'; esp_http_client_config_t cfg = { - .url = api_url, + .url = GITEA_RELEASE_API_URL, .transport_type = HTTP_TRANSPORT_OVER_SSL, .timeout_ms = 10000, .event_handler = http_rx_event_handler, @@ -268,8 +286,21 @@ static void ota_check_task(void *arg) cJSON *tag_item = cJSON_GetObjectItem(root, "tag_name"); cJSON *title_item = cJSON_GetObjectItem(root, "name"); - const char *tag_str = (tag_item && tag_item->valuestring) ? tag_item->valuestring : "unknown"; + cJSON *body_item = cJSON_GetObjectItem(root, "body"); + cJSON *commit_item = cJSON_GetObjectItem(root, "target_commitish"); + + const char *tag_str = (tag_item && tag_item->valuestring) ? tag_item->valuestring : "latest"; const char *title_str = (title_item && title_item->valuestring) ? title_item->valuestring : tag_str; + const char *body_str = (body_item && body_item->valuestring) ? body_item->valuestring : ""; + const char *target_commit = (commit_item && commit_item->valuestring) ? commit_item->valuestring : ""; + + char remote_sha256[68] = {0}; + char remote_commit[68] = {0}; + extract_hash_from_body(body_str, remote_sha256, sizeof(remote_sha256), remote_commit, sizeof(remote_commit)); + + if (remote_commit[0] == '\0' && strlen(target_commit) >= 7) { + strncpy(remote_commit, target_commit, sizeof(remote_commit) - 1); + } if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY); strncpy(s_release.tag_name, tag_str, sizeof(s_release.tag_name) - 1); @@ -277,6 +308,21 @@ static void ota_check_task(void *arg) s_release.download_url[0] = '\0'; s_release.binary_size = 0; + /* Preferred remote hash: SHA256 if found, else Commit hash */ + if (strlen(remote_sha256) > 0) { + strncpy(s_release.remote_hash, remote_sha256, sizeof(s_release.remote_hash) - 1); + } else if (strlen(remote_commit) > 0) { + strncpy(s_release.remote_hash, remote_commit, sizeof(s_release.remote_hash) - 1); + } else { + strncpy(s_release.remote_hash, tag_str, sizeof(s_release.remote_hash) - 1); + } + + if (strlen(s_running_sha256) > 0) { + strncpy(s_release.local_hash, s_running_sha256, sizeof(s_release.local_hash) - 1); + } else { + strncpy(s_release.local_hash, esp_app_get_description()->version, sizeof(s_release.local_hash) - 1); + } + /* Search assets for mtg-rfid-companion.bin or *.bin */ cJSON *assets = cJSON_GetObjectItem(root, "assets"); if (assets && cJSON_IsArray(assets)) { @@ -290,16 +336,11 @@ static void ota_check_task(void *arg) if (aname && aname->valuestring && aurl && aurl->valuestring) { if (strstr(aname->valuestring, ".bin") != NULL) { const char *url_str = aurl->valuestring; - if (url_str[0] == '/') { - snprintf(s_release.download_url, sizeof(s_release.download_url), - "%s%s", CONFIG_MTG_GITEA_URL, url_str); - } else { - strncpy(s_release.download_url, url_str, sizeof(s_release.download_url) - 1); - } + strncpy(s_release.download_url, url_str, sizeof(s_release.download_url) - 1); if (asize && asize->valuedouble > 0) { s_release.binary_size = (size_t)asize->valuedouble; } - if (strstr(aname->valuestring, "mtg-rfid-companion") != NULL) { + if (strcmp(aname->valuestring, "mtg-rfid-companion.bin") == 0) { break; /* Exact match, stop looking */ } } @@ -309,29 +350,58 @@ static void ota_check_task(void *arg) /* Fallback URL if not in assets list */ if (s_release.download_url[0] == '\0') { - snprintf(s_release.download_url, sizeof(s_release.download_url), - "%s/%s/%s/releases/download/%s/mtg-rfid-companion.bin", - CONFIG_MTG_GITEA_URL, CONFIG_MTG_GITEA_REPO_OWNER, CONFIG_MTG_GITEA_REPO_NAME, - s_release.tag_name); + strncpy(s_release.download_url, GITEA_FIRMWARE_FALLBACK_URL, sizeof(s_release.download_url) - 1); } - const char *current_ver = ota_manager_get_current_version(); - s_release.is_newer = check_is_newer(s_release.tag_name, current_ver); + /* Verify if an update is available by checking hashes */ + bool is_up_to_date = false; + const esp_app_desc_t *running_app = esp_app_get_description(); + + /* 1. Match by SHA-256 (image hash) */ + if (strlen(remote_sha256) > 0 && strlen(s_running_sha256) > 0) { + if (strcasecmp(remote_sha256, s_running_sha256) == 0) { + is_up_to_date = true; + } + } + + /* 2. Match by commit hash prefix */ + if (!is_up_to_date && strlen(remote_commit) >= 7) { + if (strncasecmp(remote_commit, running_app->version, 7) == 0 || + strncasecmp(running_app->version, remote_commit, 7) == 0) { + is_up_to_date = true; + } + } + + /* 3. Match by NVS last installed hash */ + nvs_handle_t nvs_h; + if (!is_up_to_date && nvs_open("ota_store", NVS_READONLY, &nvs_h) == ESP_OK) { + char nvs_val[68] = {0}; + size_t nvs_sz = sizeof(nvs_val); + if (nvs_get_str(nvs_h, "last_hash", nvs_val, &nvs_sz) == ESP_OK) { + if (strlen(remote_sha256) > 0 && strcasecmp(nvs_val, remote_sha256) == 0) { + is_up_to_date = true; + } else if (strlen(remote_commit) > 0 && strncasecmp(nvs_val, remote_commit, 7) == 0) { + is_up_to_date = true; + } + } + nvs_close(nvs_h); + } + + s_release.is_newer = !is_up_to_date; if (s_ota_mux) xSemaphoreGive(s_ota_mux); - ESP_LOGI(TAG, "Discovered release '%s' (%s), download: %s, is_newer: %d (current: %s)", - s_release.tag_name, s_release.release_title, s_release.download_url, - s_release.is_newer, current_ver); + ESP_LOGI(TAG, "Gitea check: remote_hash='%.8s' (SHA256: '%.8s', Commit: '%.8s'), local_sha256='%.8s', ver='%s' => is_newer=%d", + s_release.remote_hash, remote_sha256, remote_commit, s_running_sha256, running_app->version, s_release.is_newer); cJSON_Delete(root); if (s_release.is_newer) { char msg[128]; - snprintf(msg, sizeof(msg), "New firmware %s available!", s_release.tag_name); + snprintf(msg, sizeof(msg), "Update available (hash: %.8s)", s_release.remote_hash); set_state(OTA_STATUS_UPDATE_AVAILABLE, msg); } else { char msg[128]; - snprintf(msg, sizeof(msg), "Firmware %s is up to date", current_ver); + snprintf(msg, sizeof(msg), "Firmware %.8s is up to date", s_running_sha256[0] ? s_running_sha256 : running_app->version); set_state(OTA_STATUS_UP_TO_DATE, msg); } @@ -371,6 +441,20 @@ static void ota_download_task(void *arg) return; } + /* Pre-check: verify incoming image ELF SHA-256 against currently running */ + esp_app_desc_t new_app_info; + if (esp_https_ota_get_img_desc(ota_handle, &new_app_info) == ESP_OK) { + const esp_app_desc_t *running = esp_app_get_description(); + 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); + set_state(OTA_STATUS_UP_TO_DATE, "Firmware is already up to date"); + s_ota_task_handle = NULL; + vTaskDelete(NULL); + return; + } + } + while (1) { if (s_cancel_requested) { ESP_LOGW(TAG, "OTA canceled by user"); @@ -398,6 +482,16 @@ static void ota_download_task(void *arg) if (esp_https_ota_is_complete_data_received(ota_handle)) { esp_err_t finish_err = esp_https_ota_finish(ota_handle); if (finish_err == ESP_OK) { + /* Store installed release hash in NVS */ + nvs_handle_t nvs_h; + if (nvs_open("ota_store", NVS_READWRITE, &nvs_h) == ESP_OK) { + if (s_release.remote_hash[0] != '\0') { + nvs_set_str(nvs_h, "last_hash", s_release.remote_hash); + nvs_commit(nvs_h); + } + nvs_close(nvs_h); + } + ESP_LOGI(TAG, "OTA upgrade successful! Rebooting in 2 seconds..."); set_state(OTA_STATUS_SUCCESS_REBOOTING, "Update complete! Rebooting..."); vTaskDelay(pdMS_TO_TICKS(2000)); diff --git a/main/ota_manager.h b/main/ota_manager.h index 9008a4f..7e17322 100644 --- a/main/ota_manager.h +++ b/main/ota_manager.h @@ -26,9 +26,11 @@ typedef enum { } ota_status_t; typedef struct { - char tag_name[32]; /* e.g. "v1.2.0" */ - char release_title[64]; /* e.g. "Release 1.2.0" */ + char tag_name[32]; /* e.g. "latest" */ + char release_title[64]; /* e.g. "Latest ESP32 Firmware" */ char download_url[256]; /* URL to mtg-rfid-companion.bin */ + char remote_hash[68]; /* Release SHA256 or commit hash */ + char local_hash[68]; /* Running firmware SHA256 */ size_t binary_size; /* in bytes, or 0 if unknown */ bool is_newer; } ota_release_info_t; @@ -43,6 +45,11 @@ esp_err_t ota_manager_init(void); */ const char *ota_manager_get_current_version(void); +/** + * @brief Current running firmware image SHA-256 (64 hex characters). + */ +const char *ota_manager_get_running_sha256(void); + /** * @brief Get current OTA state. */ diff --git a/main/ui_task.c b/main/ui_task.c index d9f184e..22e094d 100644 --- a/main/ui_task.c +++ b/main/ui_task.c @@ -988,13 +988,23 @@ static void view_device_info(void) storage_manager_count_cached(&json_c, &jpg_c); uint32_t free_heap = esp_get_free_heap_size(); + const char *ssid = wifi_manager_get_connected_ssid(); + int rssi = wifi_manager_get_rssi(); + char wifi_line[32]; + 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 IP:\n" + "WI-FI:\n" + "IP ADDR:\n" "HOTSPOT:\n" "STORAGE:\n" "CARDS:\n" @@ -1010,6 +1020,7 @@ static void view_device_info(void) 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" @@ -1020,6 +1031,7 @@ static void view_device_info(void) "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, @@ -1093,14 +1105,27 @@ static void view_ota(void) lv_obj_set_style_text_color(t, lv_color_hex(0x00E676), 0); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 8); + char target_str[32]; + if (strlen(rel->remote_hash) >= 7) { + snprintf(target_str, sizeof(target_str), "%.8s", rel->remote_hash); + } else { + snprintf(target_str, sizeof(target_str), "%s", rel->tag_name); + } lv_obj_t *v = lv_label_create(box); - lv_label_set_text_fmt(v, "TARGET: %s", rel->tag_name); + lv_label_set_text_fmt(v, "NEW: %s", target_str); lv_obj_set_style_text_font(v, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(v, lv_color_hex(0xFFCC00), 0); lv_obj_align(v, LV_ALIGN_TOP_MID, 0, 38); + const char *cur_sha = ota_manager_get_running_sha256(); + char cur_str[32]; + if (strlen(cur_sha) >= 7) { + snprintf(cur_str, sizeof(cur_str), "%.8s", cur_sha); + } else { + snprintf(cur_str, sizeof(cur_str), "%.8s", cur_ver); + } lv_obj_t *c = lv_label_create(box); - lv_label_set_text_fmt(c, "Current: %s", cur_ver); + lv_label_set_text_fmt(c, "Current: %s", cur_str); lv_obj_set_style_text_font(c, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(c, lv_color_white(), 0); lv_obj_align(c, LV_ALIGN_TOP_MID, 0, 70); @@ -1131,6 +1156,14 @@ static void view_ota(void) case OTA_STATUS_UP_TO_DATE: { const char *cur_ver = ota_manager_get_current_version(); + const char *cur_sha = ota_manager_get_running_sha256(); + char disp_ver[32]; + if (strlen(cur_sha) >= 7) { + snprintf(disp_ver, sizeof(disp_ver), "%.8s", cur_sha); + } else { + snprintf(disp_ver, sizeof(disp_ver), "%s", cur_ver); + } + lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 24, 180); lv_obj_align(box, LV_ALIGN_CENTER, 0, -10); @@ -1146,7 +1179,7 @@ static void view_ota(void) lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 15); lv_obj_t *v = lv_label_create(box); - lv_label_set_text_fmt(v, "Version: %s\n\nNo newer releases\nfound on Gitea.", cur_ver); + lv_label_set_text_fmt(v, "Hash: %s\n\nNo newer releases\nfound on Gitea.", disp_ver); lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(v, lv_color_white(), 0); lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_CENTER, 0); diff --git a/main/web_server.c b/main/web_server.c index 473136f..f72ef1f 100644 --- a/main/web_server.c +++ b/main/web_server.c @@ -14,6 +14,7 @@ #include "storage_manager.h" #include "scryfall_client.h" #include "wifi_manager.h" +#include "dns_server.h" #include "web_server.h" static const char *TAG = "web"; @@ -30,13 +31,25 @@ static const char SPA_PAGE[] = "body{font-family:system-ui,-apple-system,sans-serif;background:#141424;color:#eee;max-width:600px;margin:1.5em auto;padding:1em}" "h1{color:#ffd75a;margin-bottom:0.2em}h2{color:#ffd75a;font-size:1.15em;margin-top:1.2em}" ".uid{font-size:1.5em;font-family:monospace;letter-spacing:2px;color:#7fd77f;word-break:break-all;background:#1e1e32;padding:.4em .6em;border-radius:6px;border:1px solid #333}" - "input{font-size:1em;padding:.5em .8em;border-radius:6px;border:1px solid #555;background:#222;color:#eee;width:calc(100% - 1.6em);box-sizing:border-box}" + "input,select{font-size:1em;padding:.5em .8em;border-radius:6px;border:1px solid #555;background:#222;color:#eee;width:100%;box-sizing:border-box}" "button,.btn{font-size:.95em;padding:.5em 1em;border-radius:6px;border:1px solid #555;background:#2a2a44;color:#eee;cursor:pointer;margin:.3em 0}" "button:hover,.btn:hover{background:#3a3a5c}" ".btn-primary{background:#ffd75a;color:#111;border:none;font-weight:bold}" ".btn-primary:hover{background:#ffe380}" ".btn-del{background:#4a1e1e;color:#ff9999;border:1px solid #772222;font-size:.85em;padding:.4em .8em;cursor:pointer;border-radius:6px}" ".btn-del:hover{background:#772222;color:#fff}" + ".tabs{display:flex;gap:8px;margin-bottom:1.5em}" + ".tab-btn{flex:1;font-size:1em;padding:.6em;border-radius:8px;background:#1e1e32;color:#aaa;border:1px solid #33334c;cursor:pointer;font-weight:600;text-align:center}" + ".tab-btn.active{background:#ffd75a;color:#111;border-color:#ffd75a}" + ".tab-pane{display:none}" + ".tab-pane.active{display:block}" + ".status-badge{display:inline-block;padding:.3em .7em;border-radius:12px;font-size:.85em;font-weight:bold;margin-bottom:.8em}" + ".badge-ok{background:#1b4332;color:#7fd77f;border:1px solid #2d6a4f}" + ".badge-bad{background:#4a1e1e;color:#ff9999;border:1px solid #772222}" + ".input-grp{margin-bottom:1em}" + ".input-grp label{display:block;color:#ccc;font-size:.85em;margin-bottom:.3em}" + ".pwd-row{display:flex;gap:6px}" + ".pwd-row input{flex:1}" ".sug{list-style:none;padding:0;margin:.4em 0;background:#1f1f33;border-radius:6px;border:1px solid #333}" ".sug li{cursor:pointer;padding:.5em .8em;border-bottom:1px solid #333}" ".sug li:hover{background:#2a2a48;color:#ffd75a}" @@ -50,7 +63,13 @@ static const char SPA_PAGE[] = ".header-row{display:flex;align-items:center;justify-content:space-between}" "" "

MTG RFID Companion

" - "

Pair card sleeves and manage your scanned deck.

" + "

Manage card tags, decks, and device Wi-Fi connectivity.

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

Last Scanned Tag

" "

waiting...

" @@ -65,7 +84,55 @@ static const char SPA_PAGE[] = "" "
" "

Loading paired cards...

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

Active Connection

" + "
Checking Wi-Fi...
" + "
" + "
" + "
" + "
" + "

Connect to Wi-Fi

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

" + "
" + "
" + "

Saved Networks

" + "" + "
" + "

Loading saved networks...

" + "
" ""; static httpd_handle_t s_server = NULL; @@ -126,6 +279,12 @@ static esp_err_t handle_bind(httpd_req_t *req); static esp_err_t handle_cards(httpd_req_t *req); static esp_err_t handle_art(httpd_req_t *req); static esp_err_t handle_unbind(httpd_req_t *req); +static esp_err_t handle_captive_portal(httpd_req_t *req); +static esp_err_t handle_wifi_status(httpd_req_t *req); +static esp_err_t handle_wifi_scan(httpd_req_t *req); +static esp_err_t handle_wifi_saved(httpd_req_t *req); +static esp_err_t handle_wifi_connect(httpd_req_t *req); +static esp_err_t handle_wifi_forget(httpd_req_t *req); static const httpd_uri_t URI_ROOT = { .uri = "/", .method = HTTP_GET, .handler = handle_root, .user_ctx = NULL @@ -146,6 +305,46 @@ static const httpd_uri_t URI_UNBIND = { .uri = "/api/unbind", .method = HTTP_POST, .handler = handle_unbind, .user_ctx = NULL }; +/* Wi-Fi Management URIs */ +static const httpd_uri_t URI_WIFI_STATUS = { + .uri = "/api/wifi/status", .method = HTTP_GET, .handler = handle_wifi_status, .user_ctx = NULL +}; +static const httpd_uri_t URI_WIFI_SCAN = { + .uri = "/api/wifi/scan", .method = HTTP_GET, .handler = handle_wifi_scan, .user_ctx = NULL +}; +static const httpd_uri_t URI_WIFI_SAVED = { + .uri = "/api/wifi/saved", .method = HTTP_GET, .handler = handle_wifi_saved, .user_ctx = NULL +}; +static const httpd_uri_t URI_WIFI_CONNECT = { + .uri = "/api/wifi/connect", .method = HTTP_POST, .handler = handle_wifi_connect, .user_ctx = NULL +}; +static const httpd_uri_t URI_WIFI_FORGET = { + .uri = "/api/wifi/forget", .method = HTTP_POST, .handler = handle_wifi_forget, .user_ctx = NULL +}; + +/* Captive Portal Probe URIs */ +static const httpd_uri_t URI_CP_APPLE = { + .uri = "/hotspot-detect.html", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL +}; +static const httpd_uri_t URI_CP_204_1 = { + .uri = "/generate_204", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL +}; +static const httpd_uri_t URI_CP_204_2 = { + .uri = "/gen_204", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL +}; +static const httpd_uri_t URI_CP_WIN_1 = { + .uri = "/connecttest.txt", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL +}; +static const httpd_uri_t URI_CP_WIN_2 = { + .uri = "/ncsi.txt", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL +}; +static const httpd_uri_t URI_CP_FF_1 = { + .uri = "/canonical.html", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL +}; +static const httpd_uri_t URI_CP_FF_2 = { + .uri = "/success.txt", .method = HTTP_GET, .handler = handle_captive_portal, .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"); @@ -380,6 +579,193 @@ static esp_err_t handle_unbind(httpd_req_t *req) return ESP_OK; } +/* ------------------------------------------------------------------ */ +/* Captive Portal & Wi-Fi Management Handlers */ +/* ------------------------------------------------------------------ */ + +static esp_err_t handle_captive_portal(httpd_req_t *req) +{ + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; +} + +static esp_err_t handle_wifi_status(httpd_req_t *req) +{ + bool conn = wifi_manager_connected(); + const char *ssid = wifi_manager_get_connected_ssid(); + const char *ip = wifi_manager_get_ip(); + int rssi = wifi_manager_get_rssi(); + + char buf[160]; + snprintf(buf, sizeof(buf), + "{\"connected\":%s,\"ssid\":\"%s\",\"ip\":\"%s\",\"rssi\":%d}\n", + conn ? "true" : "false", + ssid ? ssid : "", + ip ? ip : "0.0.0.0", + rssi); + send_json(req, 200, buf); + return ESP_OK; +} + +static esp_err_t handle_wifi_scan(httpd_req_t *req) +{ + wifi_ap_record_t *records = NULL; + uint16_t count = 0; + esp_err_t err = wifi_manager_scan_networks(&records, &count); + cJSON *root = cJSON_CreateArray(); + if (err == ESP_OK && records != NULL && root != NULL) { + for (uint16_t i = 0; i < count; i++) { + cJSON *item = cJSON_CreateObject(); + if (item) { + cJSON_AddStringToObject(item, "ssid", (const char *)records[i].ssid); + cJSON_AddNumberToObject(item, "rssi", records[i].rssi); + const char *auth_str = "Open"; + if (records[i].authmode == WIFI_AUTH_WEP) auth_str = "WEP"; + else if (records[i].authmode == WIFI_AUTH_WPA_PSK) auth_str = "WPA"; + else if (records[i].authmode == WIFI_AUTH_WPA2_PSK) auth_str = "WPA2"; + else if (records[i].authmode == WIFI_AUTH_WPA_WPA2_PSK) auth_str = "WPA/WPA2"; + else if (records[i].authmode >= WIFI_AUTH_WPA3_PSK) auth_str = "WPA3"; + cJSON_AddStringToObject(item, "auth", auth_str); + cJSON_AddItemToArray(root, item); + } + } + } + if (records) { + free(records); + } + char *out = root ? cJSON_PrintUnformatted(root) : NULL; + if (root) cJSON_Delete(root); + if (out) { + send_json(req, 200, out); + free(out); + } else { + send_json(req, 200, "[]\n"); + } + return ESP_OK; +} + +static esp_err_t handle_wifi_saved(httpd_req_t *req) +{ + wifi_profile_t profiles[WIFI_MAX_PROFILES]; + int count = wifi_manager_get_saved_profiles(profiles, WIFI_MAX_PROFILES); + cJSON *root = cJSON_CreateArray(); + if (root) { + for (int i = 0; i < count; i++) { + cJSON *item = cJSON_CreateObject(); + if (item) { + cJSON_AddStringToObject(item, "ssid", profiles[i].ssid); + cJSON_AddItemToArray(root, item); + } + } + char *out = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (out) { + send_json(req, 200, out); + free(out); + return ESP_OK; + } + } + send_json(req, 200, "[]\n"); + return ESP_OK; +} + +static esp_err_t handle_wifi_connect(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 *ssid = get_form_param(form, "ssid"); + char *pass = get_form_param(form, "pass"); + free(form); + + if (ssid) { + size_t slen = strlen(ssid); + while (slen > 0 && (ssid[slen - 1] == ' ' || ssid[slen - 1] == '\r' || ssid[slen - 1] == '\n')) { + ssid[--slen] = '\0'; + } + } + + if (ssid == NULL || strlen(ssid) == 0) { + free(ssid); + free(pass); + send_json(req, 400, "{\"ok\":false,\"error\":\"missing ssid\"}"); + return ESP_OK; + } + + const char *p = pass ? pass : ""; + wifi_manager_connect_new(ssid, p, true); + + char ok[128]; + snprintf(ok, sizeof(ok), "{\"ok\":true,\"ssid\":\"%s\"}\n", ssid); + send_json(req, 200, ok); + + free(ssid); + free(pass); + return ESP_OK; +} + +static esp_err_t handle_wifi_forget(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 *ssid = get_form_param(form, "ssid"); + free(form); + + if (ssid) { + size_t slen = strlen(ssid); + while (slen > 0 && (ssid[slen - 1] == ' ' || ssid[slen - 1] == '\r' || ssid[slen - 1] == '\n')) { + ssid[--slen] = '\0'; + } + } + + if (ssid == NULL || strlen(ssid) == 0) { + free(ssid); + send_json(req, 400, "{\"ok\":false,\"error\":\"missing ssid\"}"); + return ESP_OK; + } + + wifi_manager_delete_profile(ssid); + char ok[128]; + snprintf(ok, sizeof(ok), "{\"ok\":true,\"ssid\":\"%s\"}\n", ssid); + send_json(req, 200, ok); + + free(ssid); + return ESP_OK; +} + /* ------------------------------------------------------------------ */ /* SoftAP + server */ /* ------------------------------------------------------------------ */ @@ -454,8 +840,8 @@ esp_err_t web_server_start(void) (void)esp_netif_dhcps_start(ap); /* harmless if someone else started it */ httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.stack_size = 6 * 1024; - config.max_uri_handlers = 12; + config.stack_size = 8 * 1024; + config.max_uri_handlers = 24; config.lru_purge_enable = true; config.keep_alive_enable = true; config.max_open_sockets = 4; @@ -466,6 +852,7 @@ esp_err_t web_server_start(void) return ret; } + /* Core Pairing & Card APIs */ 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)); @@ -473,6 +860,25 @@ esp_err_t web_server_start(void) ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ART)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_UNBIND)); + /* Wi-Fi Management APIs */ + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_STATUS)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_SCAN)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_SAVED)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_CONNECT)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_FORGET)); + + /* Captive Portal OS Probes */ + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_APPLE)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_204_1)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_204_2)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_WIN_1)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_WIN_2)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_FF_1)); + ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_FF_2)); + + /* Start Captive Portal DNS redirection server */ + dns_server_start(); + ESP_LOGI(TAG, "Web server ready on all interfaces (Hotspot: 192.168.4.1, Wi-Fi: %s)", wifi_manager_get_ip()); return ESP_OK; } diff --git a/main/wifi_manager.c b/main/wifi_manager.c index 628ff06..e5af657 100644 --- a/main/wifi_manager.c +++ b/main/wifi_manager.c @@ -1,49 +1,208 @@ /* * MTG RFID Companion - Wi-Fi manager (Core 0) + * + * Multi-profile station manager with NVS storage, live scanning, + * and boot auto-connect. */ #include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "freertos/event_groups.h" +#include "freertos/semphr.h" #include "esp_wifi.h" #include "esp_event.h" #include "esp_log.h" #include "esp_netif.h" #include "nvs_flash.h" +#include "nvs.h" #include "wifi_manager.h" static const char *TAG = "wifi"; #define WIFI_RETRY_MAX (UINT8_MAX) -#define WIFI_RECONNECT_DELAY_MS (3000) +#define NVS_WIFI_NS "wifi_store" static EventGroupHandle_t s_wifi_group = NULL; #define WIFI_CONNECTED_BIT BIT0 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; + +/* ------------------------------------------------------------------ */ +/* NVS Storage Helpers */ +/* ------------------------------------------------------------------ */ + +int wifi_manager_get_saved_profiles(wifi_profile_t *out_profiles, int max_count) +{ + if (out_profiles == NULL || max_count <= 0) return 0; + + nvs_handle_t h; + if (nvs_open(NVS_WIFI_NS, NVS_READONLY, &h) != ESP_OK) { + return 0; + } + + uint8_t count = 0; + if (nvs_get_u8(h, "count", &count) != ESP_OK) { + nvs_close(h); + return 0; + } + + int loaded = 0; + for (uint8_t i = 0; i < count && loaded < max_count; i++) { + char key_s[16], key_p[16]; + snprintf(key_s, sizeof(key_s), "s_%u", i); + snprintf(key_p, sizeof(key_p), "p_%u", i); + + size_t s_len = sizeof(out_profiles[loaded].ssid); + size_t p_len = sizeof(out_profiles[loaded].password); + + if (nvs_get_str(h, key_s, out_profiles[loaded].ssid, &s_len) == ESP_OK) { + if (nvs_get_str(h, key_p, out_profiles[loaded].password, &p_len) != ESP_OK) { + out_profiles[loaded].password[0] = '\0'; + } + loaded++; + } + } + + nvs_close(h); + return loaded; +} + +esp_err_t wifi_manager_save_profile(const char *ssid, const char *password) +{ + if (ssid == NULL || strlen(ssid) == 0) { + return ESP_ERR_INVALID_ARG; + } + + wifi_profile_t profiles[WIFI_MAX_PROFILES]; + int count = wifi_manager_get_saved_profiles(profiles, WIFI_MAX_PROFILES); + + /* Check if already exists - update password if so */ + int target_idx = -1; + for (int i = 0; i < count; i++) { + if (strcmp(profiles[i].ssid, ssid) == 0) { + target_idx = i; + break; + } + } + + if (target_idx >= 0) { + strncpy(profiles[target_idx].password, password ? password : "", sizeof(profiles[target_idx].password) - 1); + profiles[target_idx].password[sizeof(profiles[target_idx].password) - 1] = '\0'; + } else { + if (count < WIFI_MAX_PROFILES) { + target_idx = count; + count++; + } else { + /* Replace oldest (index 0), shift left */ + for (int i = 0; i < WIFI_MAX_PROFILES - 1; i++) { + profiles[i] = profiles[i + 1]; + } + target_idx = WIFI_MAX_PROFILES - 1; + } + strncpy(profiles[target_idx].ssid, ssid, sizeof(profiles[target_idx].ssid) - 1); + profiles[target_idx].ssid[sizeof(profiles[target_idx].ssid) - 1] = '\0'; + strncpy(profiles[target_idx].password, password ? password : "", sizeof(profiles[target_idx].password) - 1); + profiles[target_idx].password[sizeof(profiles[target_idx].password) - 1] = '\0'; + } + + /* Write back to NVS */ + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_WIFI_NS, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + + nvs_set_u8(h, "count", (uint8_t)count); + for (int i = 0; i < count; i++) { + char key_s[16], key_p[16]; + snprintf(key_s, sizeof(key_s), "s_%d", i); + snprintf(key_p, sizeof(key_p), "p_%d", i); + nvs_set_str(h, key_s, profiles[i].ssid); + nvs_set_str(h, key_p, profiles[i].password); + } + + err = nvs_commit(h); + nvs_close(h); + ESP_LOGI(TAG, "Saved profile '%s' to NVS (total saved: %d)", ssid, count); + return err; +} + +esp_err_t wifi_manager_delete_profile(const char *ssid) +{ + if (ssid == NULL) return ESP_ERR_INVALID_ARG; + + wifi_profile_t profiles[WIFI_MAX_PROFILES]; + int count = wifi_manager_get_saved_profiles(profiles, WIFI_MAX_PROFILES); + + int found = -1; + for (int i = 0; i < count; i++) { + if (strcmp(profiles[i].ssid, ssid) == 0) { + found = i; + break; + } + } + if (found < 0) { + return ESP_ERR_NOT_FOUND; + } + + /* Shift remaining */ + for (int i = found; i < count - 1; i++) { + profiles[i] = profiles[i + 1]; + } + count--; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_WIFI_NS, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + + nvs_erase_all(h); + nvs_set_u8(h, "count", (uint8_t)count); + for (int i = 0; i < count; i++) { + char key_s[16], key_p[16]; + snprintf(key_s, sizeof(key_s), "s_%d", i); + snprintf(key_p, sizeof(key_p), "p_%d", i); + nvs_set_str(h, key_s, profiles[i].ssid); + nvs_set_str(h, key_p, profiles[i].password); + } + + err = nvs_commit(h); + nvs_close(h); + ESP_LOGI(TAG, "Deleted profile '%s' from NVS (remaining: %d)", ssid, count); + return err; +} + +/* ------------------------------------------------------------------ */ +/* Event Handler & Diagnostics */ +/* ------------------------------------------------------------------ */ 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_LOGI(TAG, "station started, connecting to '%s'...", s_active_ssid); esp_wifi_connect(); } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { strncpy(s_sta_ip, "No Wi-Fi", sizeof(s_sta_ip)); - xEventGroupClearBits(s_wifi_group, WIFI_CONNECTED_BIT); + if (s_wifi_group) { + xEventGroupClearBits(s_wifi_group, WIFI_CONNECTED_BIT); + } if (s_retry_count < WIFI_RETRY_MAX) { - ESP_LOGW(TAG, "disconnected (%d), reconnecting...", s_retry_count); + ESP_LOGW(TAG, "disconnected from '%s' (attempt %d), reconnecting...", s_active_ssid, s_retry_count); 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; snprintf(s_sta_ip, sizeof(s_sta_ip), IPSTR, IP2STR(&event->ip_info.ip)); - ESP_LOGI(TAG, "connected, IP: %s", s_sta_ip); + ESP_LOGI(TAG, "connected to '%s'! Assigned IP: %s", s_active_ssid, s_sta_ip); s_retry_count = 0; - xEventGroupSetBits(s_wifi_group, WIFI_CONNECTED_BIT); + if (s_wifi_group) { + xEventGroupSetBits(s_wifi_group, WIFI_CONNECTED_BIT); + } } } @@ -55,6 +214,141 @@ const char *wifi_manager_get_ip(void) return s_sta_ip; } +const char *wifi_manager_get_connected_ssid(void) +{ + if (!wifi_manager_connected()) { + return "Disconnected"; + } + return s_active_ssid; +} + +int8_t wifi_manager_get_rssi(void) +{ + if (!wifi_manager_connected()) { + return 0; + } + wifi_ap_record_t ap_info; + if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) { + return ap_info.rssi; + } + return 0; +} + +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; +} + +bool wifi_manager_sta_configured(void) +{ + wifi_profile_t profiles[1]; + if (wifi_manager_get_saved_profiles(profiles, 1) > 0) { + return true; + } +#ifdef CONFIG_MTG_WIFI_SSID + const char *ssid = CONFIG_MTG_WIFI_SSID; + return strlen(ssid) > 0 && strstr(ssid, "YOUR_") == NULL; +#else + return false; +#endif +} + +/* ------------------------------------------------------------------ */ +/* Scanning & Connection APIs */ +/* ------------------------------------------------------------------ */ + +esp_err_t wifi_manager_scan_networks(wifi_ap_record_t **out_records, uint16_t *out_count) +{ + if (out_records == NULL || out_count == NULL) return ESP_ERR_INVALID_ARG; + *out_records = NULL; + *out_count = 0; + + if (s_wifi_mux) xSemaphoreTake(s_wifi_mux, portMAX_DELAY); + + wifi_scan_config_t scan_cfg = { + .show_hidden = true, + }; + + ESP_LOGI(TAG, "Scanning for Wi-Fi networks..."); + esp_err_t err = esp_wifi_scan_start(&scan_cfg, true); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_scan_start failed: %s", esp_err_to_name(err)); + if (s_wifi_mux) xSemaphoreGive(s_wifi_mux); + return err; + } + + uint16_t ap_count = 0; + ESP_ERROR_CHECK(esp_wifi_scan_get_ap_num(&ap_count)); + + if (ap_count == 0) { + if (s_wifi_mux) xSemaphoreGive(s_wifi_mux); + return ESP_OK; + } + + wifi_ap_record_t *records = malloc(sizeof(wifi_ap_record_t) * ap_count); + if (records == NULL) { + if (s_wifi_mux) xSemaphoreGive(s_wifi_mux); + return ESP_ERR_NO_MEM; + } + + esp_err_t ret = esp_wifi_scan_get_ap_records(&ap_count, records); + if (ret == ESP_OK) { + *out_records = records; + *out_count = ap_count; + ESP_LOGI(TAG, "Scan complete: found %u access points", ap_count); + } else { + free(records); + } + + if (s_wifi_mux) xSemaphoreGive(s_wifi_mux); + return ret; +} + +esp_err_t wifi_manager_connect_new(const char *ssid, const char *password, bool save_to_nvs) +{ + if (ssid == NULL || strlen(ssid) == 0) { + return ESP_ERR_INVALID_ARG; + } + + ESP_LOGI(TAG, "Connecting to new network: '%s'", ssid); + + wifi_config_t wifi_cfg = {0}; + strncpy((char *)wifi_cfg.sta.ssid, ssid, sizeof(wifi_cfg.sta.ssid) - 1); + if (password && strlen(password) > 0) { + strncpy((char *)wifi_cfg.sta.password, password, sizeof(wifi_cfg.sta.password) - 1); + wifi_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + } else { + wifi_cfg.sta.threshold.authmode = WIFI_AUTH_OPEN; + } + + strncpy(s_active_ssid, ssid, sizeof(s_active_ssid) - 1); + s_active_ssid[sizeof(s_active_ssid) - 1] = '\0'; + s_retry_count = 0; + + esp_wifi_disconnect(); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg)); + esp_err_t ret = esp_wifi_connect(); + + if (save_to_nvs) { + wifi_manager_save_profile(ssid, password); + } + + return ret; +} + +/* ------------------------------------------------------------------ */ +/* Subsystem Init & Boot Auto-Connect */ +/* ------------------------------------------------------------------ */ + esp_err_t wifi_manager_init(void) { esp_err_t ret = nvs_flash_init(); @@ -66,6 +360,7 @@ esp_err_t wifi_manager_init(void) return ret; } + s_wifi_mux = xSemaphoreCreateMutex(); s_wifi_group = xEventGroupCreate(); if (s_wifi_group == NULL) { return ESP_ERR_NO_MEM; @@ -89,58 +384,87 @@ esp_err_t wifi_manager_init(void) return ESP_OK; } -bool wifi_manager_sta_configured(void) -{ -#ifndef CONFIG_MTG_WIFI_SSID - return false; -#else - const char *ssid = CONFIG_MTG_WIFI_SSID; - return strlen(ssid) > 0 && strstr(ssid, "YOUR_") == NULL; -#endif -} - esp_err_t wifi_manager_start(void) { -#ifndef CONFIG_MTG_WIFI_SSID -#error "Missing CONFIG_MTG_WIFI_SSID - run idf.py menuconfig" -#endif + wifi_profile_t saved_profiles[WIFI_MAX_PROFILES]; + int profile_count = wifi_manager_get_saved_profiles(saved_profiles, WIFI_MAX_PROFILES); - /* Empty SSID, or an untouched sdkconfig.defaults.esp32 placeholder: - * keep station mode disabled so we don't hammer a bogus AP at boot. */ - if (!wifi_manager_sta_configured()) { - ESP_LOGW(TAG, "no SSID configured - Wi-Fi station skipped (set CONFIG_MTG_WIFI_SSID)"); - return ESP_OK; - } + const char *target_ssid = NULL; + const char *target_pass = NULL; - const char *ssid = CONFIG_MTG_WIFI_SSID; - const char *pass = CONFIG_MTG_WIFI_PASS; - - 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_set_mode(WIFI_MODE_APSTA)); ESP_ERROR_CHECK(esp_wifi_start()); - ESP_LOGI(TAG, "station mode started for '%s'", ssid); - return ESP_OK; -} + if (profile_count > 0) { + ESP_LOGI(TAG, "Found %d saved Wi-Fi profiles in NVS. Scanning for best match...", profile_count); -bool wifi_manager_connected(void) -{ - return s_wifi_group != NULL && (xEventGroupGetBits(s_wifi_group) & WIFI_CONNECTED_BIT) != 0; -} + wifi_scan_config_t scan_cfg = { .show_hidden = true }; + esp_wifi_scan_start(&scan_cfg, true); -esp_err_t wifi_manager_wait_connected(TickType_t timeout_ticks) -{ - if (s_wifi_group == NULL) { - return ESP_ERR_INVALID_STATE; + uint16_t ap_count = 0; + esp_wifi_scan_get_ap_num(&ap_count); + + int best_profile_idx = -1; + int best_rssi = -127; + + if (ap_count > 0) { + wifi_ap_record_t *ap_list = malloc(sizeof(wifi_ap_record_t) * ap_count); + if (ap_list && esp_wifi_scan_get_ap_records(&ap_count, ap_list) == ESP_OK) { + for (uint16_t a = 0; a < ap_count; a++) { + for (int p = 0; p < profile_count; p++) { + if (strcmp((char *)ap_list[a].ssid, saved_profiles[p].ssid) == 0) { + if (ap_list[a].rssi > best_rssi) { + best_rssi = ap_list[a].rssi; + best_profile_idx = p; + } + } + } + } + } + free(ap_list); + } + + if (best_profile_idx >= 0) { + ESP_LOGI(TAG, "Matched in-range network '%s' (RSSI %d dBm)", + saved_profiles[best_profile_idx].ssid, best_rssi); + target_ssid = saved_profiles[best_profile_idx].ssid; + target_pass = saved_profiles[best_profile_idx].password; + } else { + ESP_LOGW(TAG, "No saved networks currently detected in scan. Trying first saved profile '%s'...", + saved_profiles[0].ssid); + target_ssid = saved_profiles[0].ssid; + target_pass = saved_profiles[0].password; + } + } else { + /* Fall back to compile-time Kconfig defaults */ +#ifdef CONFIG_MTG_WIFI_SSID + if (wifi_manager_sta_configured()) { + ESP_LOGI(TAG, "No NVS profiles. Using Kconfig default '%s'...", CONFIG_MTG_WIFI_SSID); + target_ssid = CONFIG_MTG_WIFI_SSID; + target_pass = CONFIG_MTG_WIFI_PASS; + } +#endif } - EventBits_t bits = xEventGroupWaitBits(s_wifi_group, WIFI_CONNECTED_BIT, pdFALSE, pdTRUE, timeout_ticks); - return (bits & WIFI_CONNECTED_BIT) ? ESP_OK : ESP_ERR_TIMEOUT; + + if (target_ssid != NULL && target_ssid[0] != '\0') { + wifi_config_t wifi_cfg = {0}; + strncpy((char *)wifi_cfg.sta.ssid, target_ssid, sizeof(wifi_cfg.sta.ssid) - 1); + if (target_pass && target_pass[0] != '\0') { + strncpy((char *)wifi_cfg.sta.password, target_pass, sizeof(wifi_cfg.sta.password) - 1); + wifi_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + } else { + wifi_cfg.sta.threshold.authmode = WIFI_AUTH_OPEN; + } + + strncpy(s_active_ssid, target_ssid, sizeof(s_active_ssid) - 1); + s_active_ssid[sizeof(s_active_ssid) - 1] = '\0'; + + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg)); + ESP_LOGI(TAG, "Connecting to target '%s'...", target_ssid); + esp_wifi_connect(); + } else { + ESP_LOGI(TAG, "No Wi-Fi networks configured yet. Device running in SoftAP setup mode."); + } + + return ESP_OK; } \ No newline at end of file diff --git a/main/wifi_manager.h b/main/wifi_manager.h index f05b7bd..ccab756 100644 --- a/main/wifi_manager.h +++ b/main/wifi_manager.h @@ -1,38 +1,52 @@ /* * 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. + * Station-mode multi-profile management with NVS persistence, + * live network scanning, and boot auto-connect. */ #pragma once +#include +#include +#include #include "esp_err.h" +#include "esp_wifi_types.h" #include "freertos/FreeRTOS.h" #ifdef __cplusplus extern "C" { #endif +#define WIFI_MAX_PROFILES 5 +#define WIFI_SSID_MAX_LEN 32 +#define WIFI_PASS_MAX_LEN 64 + +typedef struct { + char ssid[WIFI_SSID_MAX_LEN + 1]; + char password[WIFI_PASS_MAX_LEN + 1]; +} wifi_profile_t; + /** - * @brief Initialize the Wi-Fi subsystem (netif, driver, events). + * @brief Initialize the Wi-Fi subsystem (netif, driver, events, NVS). * @return ESP_OK on success. */ esp_err_t wifi_manager_init(void); /** - * @brief Start station mode and attempt to connect to the configured AP. + * @brief Start station mode and attempt to connect. + * Checks NVS profiles first, scanning for the best in-range network, + * falling back to compile-time Kconfig defaults. */ esp_err_t wifi_manager_start(void); /** - * @brief True if a real SSID is configured (non-empty, not the - * `YOUR_...` placeholder). Station mode will actually run. + * @brief True if a real SSID is configured in NVS or Kconfig. */ bool wifi_manager_sta_configured(void); /** - * @brief True if the STA has a valid IP. + * @brief True if the STA currently has a valid IP address. */ bool wifi_manager_connected(void); @@ -46,6 +60,45 @@ esp_err_t wifi_manager_wait_connected(TickType_t timeout_ticks); */ const char *wifi_manager_get_ip(void); +/** + * @brief Get currently connected SSID name (or "Disconnected"). + */ +const char *wifi_manager_get_connected_ssid(void); + +/** + * @brief Get current Wi-Fi RSSI signal strength in dBm (or 0 if disconnected). + */ +int8_t wifi_manager_get_rssi(void); + +/** + * @brief Perform a Wi-Fi scan and return detected access points. + * Caller must free() *out_records when done. + * @param[out] out_records Pointer to allocated array of records. + * @param[out] out_count Number of records returned. + */ +esp_err_t wifi_manager_scan_networks(wifi_ap_record_t **out_records, uint16_t *out_count); + +/** + * @brief Save a Wi-Fi network profile into NVS. + */ +esp_err_t wifi_manager_save_profile(const char *ssid, const char *password); + +/** + * @brief Delete a saved network profile from NVS. + */ +esp_err_t wifi_manager_delete_profile(const char *ssid); + +/** + * @brief Retrieve all saved network profiles from NVS. + * @return Count of profiles retrieved. + */ +int wifi_manager_get_saved_profiles(wifi_profile_t *out_profiles, int max_count); + +/** + * @brief Connect to a new Wi-Fi network immediately, optionally saving to NVS. + */ +esp_err_t wifi_manager_connect_new(const char *ssid, const char *password, bool save_to_nvs); + #ifdef __cplusplus } #endif \ No newline at end of file diff --git a/sdkconfig.defaults b/sdkconfig.defaults index f992710..448a885 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,4 +1,4 @@ -# MTG RFID Companion - ESP32 SDK defaults +# MTG RFID Companion - ESP32 SDK Defaults CONFIG_IDF_TARGET="esp32" CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y CONFIG_PARTITION_TABLE_CUSTOM=y @@ -7,17 +7,25 @@ CONFIG_ESPTOOLPY_FLASHMODE_DIO=y CONFIG_VFS_SUPPORT_DIR=y CONFIG_FREERTOS_SMP=y -# tjpgd / esp_jpeg: use source tjpgd (granular line-by-line API), RGB565 out +# Wi-Fi Credentials (Station Mode) +CONFIG_MTG_WIFI_SSID="WiFimodem-FD58" +CONFIG_MTG_WIFI_PASS="gzhrt5yzm3" + +# Network Hostname & OTA Rollback +CONFIG_LWIP_LOCAL_HOSTNAME="mtg-companion" +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y + +# LVGL & Font Configuration +CONFIG_LV_COLOR_16_SWAP=y +CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_FONT_MONTSERRAT_48=y + +# tjpgd JPEG Decoder (RGB565 output) CONFIG_JD_USE_ROM=n CONFIG_JD_SZBUF=512 CONFIG_JD_FORMAT_RGB565=y CONFIG_JD_FASTDECODE_32BIT=y -# Some ESP32 boards ship a Boya flash chip; use the optimized driver -CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y - -# Set network hostname for DHCP / router discovery -CONFIG_LWIP_LOCAL_HOSTNAME="mtg-companion" - -# Enable OTA rollback protection -CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y \ No newline at end of file +# Flash Chip Driver Optimization +CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y \ No newline at end of file diff --git a/sdkconfig.defaults.esp32 b/sdkconfig.defaults.esp32 deleted file mode 100644 index 00c0ac2..0000000 --- a/sdkconfig.defaults.esp32 +++ /dev/null @@ -1,17 +0,0 @@ -# Target-specific preset for ESP32: station-mode Wi-Fi credentials. -# -# Fill in your network below and the device will auto-connect on boot. -# Leave the placeholder values untouched and station mode stays disabled -# (the SoftAP web-pairing server always runs either way). -# -# Precedence note: values already present in the generated `sdkconfig` win -# over these defaults. To apply changes here on an existing build, either run -# `idf.py menuconfig` ( -> "MTG RFID Companion") or remove `sdkconfig` and -# rebuild. -CONFIG_MTG_WIFI_SSID="WiFimodem-FD58" -CONFIG_MTG_WIFI_PASS="gzhrt5yzm3" - -CONFIG_LV_COLOR_16_SWAP=y -CONFIG_LV_FONT_MONTSERRAT_14=y -CONFIG_LV_FONT_MONTSERRAT_20=y -CONFIG_LV_FONT_MONTSERRAT_48=y \ No newline at end of file