on device wifi
All checks were successful
ESP32 Build & Release / build (push) Successful in 6m41s

This commit is contained in:
2026-09-12 13:09:47 +02:00
parent 80eab1752c
commit d691febfe4
13 changed files with 1340 additions and 224 deletions

View File

@@ -112,39 +112,46 @@ jobs:
# ------------------------------------------------------------ # ------------------------------------------------------------
# Prepare release artifacts # Prepare release artifacts
# ------------------------------------------------------------ # ------------------------------------------------------------
# ------------------------------------------------------------
# Prepare release artifacts & calculate hashes
# ------------------------------------------------------------
- name: Prepare release - name: Prepare release
shell: bash shell: bash
run: | run: |
mkdir -p release mkdir -p release
echo "Build artifacts:" # Primary OTA application binary
find build -maxdepth 2 -type f \ if [ -f build/mtg-rfid-companion.bin ]; then
\( -name "*.bin" -o -name "*.elf" -o -name "flasher_args.json" \) \ cp build/mtg-rfid-companion.bin release/mtg-rfid-companion.bin
-print cp build/mtg-rfid-companion.bin release/firmware.bin
else
# Find merged binary echo "ERROR: build/mtg-rfid-companion.bin not found!"
MERGED_BIN=$(find build -maxdepth 1 -type f -name "*.bin" | head -n 1)
if [ -z "$MERGED_BIN" ]; then
echo "ERROR: No merged .bin found"
exit 1 exit 1
fi fi
echo "Using merged binary: $MERGED_BIN" # Merged binary for USB factory flashing
if [ -f build/merged-binary.bin ]; then
cp "$MERGED_BIN" release/firmware.bin cp build/merged-binary.bin release/merged-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/
fi fi
echo "" # ELF for debugging
echo "Release contents:" 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/ ls -lh release/
# ------------------------------------------------------------ # ------------------------------------------------------------
@@ -191,6 +198,12 @@ jobs:
Commit: Commit:
${{ github.sha }} ${{ github.sha }}
SHA256:
${{ env.IMG_SHA256 }}
File-SHA256:
${{ env.BIN_SHA256 }}
ESP-IDF: ESP-IDF:
5.5 5.5

View File

@@ -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`). * 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. * Click to switch to the table turn counter.
#### 6. Pairing Cards from your Phone (No App Needed!) #### 6. Pairing Cards & On-Device Wi-Fi Setup (No App Needed!)
When you scan a new card tag that hasn't been programmed yet: The device runs a built-in web portal that works on any smartphone, tablet, or laptop:
1. The screen displays:
``` * **Connecting to the Portal:**
PAIR VIA WI-FI: * **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).
http://192.168.0.57 * **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`).
(or Hotspot: MTG-Companion @ 192.168.4.1)
``` * **🎴 Card Pairing Tab:**
2. Open that address in your smartphone or laptop browser. 1. Tap an unmapped card sleeve to the reader—its unique tag ID appears instantly in the form.
3. Tap the card sleeve to the device—its unique ID pops up in the web form automatically. 2. Type the card name (e.g. `"Sol Ring"`); Scryfall auto-complete displays live suggestions.
4. Start typing the card name (e.g. `"Sol Ring"`). Real-time Scryfall suggestions appear. 3. Click **Pair Card**. The device downloads high-resolution illustration art and official oracle text into permanent flash memory.
5. Click **Pair Card**. The device downloads the high-res card art and text and saves it to its permanent memory. 4. Browse and manage all paired cards in your deck with illustration thumbnails and 1-click unbind.
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.
* **📶 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 #### 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). The companion automatically fetches firmware releases directly from:
* 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. **`https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases`**
* While updating, a live progress bar shows the download. When finished, the device verifies the update and restarts automatically.
* **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): > 🛟 **Offline Play Always Works:**
1. **Update Wi-Fi in Gitea:** Open your Gitea repository in your browser (even on your phone) and edit `sdkconfig.defaults`: > 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.
```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.
--- ---
@@ -235,14 +237,13 @@ idf.py --version
``` ```
#### 2. Configuration #### 2. Configuration
Configure Wi-Fi credentials and Gitea instance details: Configure initial Wi-Fi credentials (or use on-device Wi-Fi setup):
```powershell ```powershell
idf.py menuconfig idf.py menuconfig
# Navigate to: "MTG RFID Companion" # Navigate to: "MTG RFID Companion"
# - Wi-Fi SSID & Password # - Wi-Fi SSID & Password (Optional - can also be configured via captive portal)
# - Gitea instance base URL (e.g. https://git.example.com)
# - Gitea repository owner & repo name
``` ```
*(The firmware is pre-configured to check updates directly from `https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases`)*
#### 3. Build & Flash (Initial USB Provisioning) #### 3. Build & Flash (Initial USB Provisioning)
```powershell ```powershell
@@ -256,9 +257,10 @@ idf.py -p COM4 erase-flash flash monitor
### Automated CI / Gitea Actions Workflow ### 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): 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. * **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.
--- ---

View File

@@ -11,5 +11,6 @@ idf_component_register(SRCS
"buzzer.c" "buzzer.c"
"app_state.c" "app_state.c"
"ota_manager.c" "ota_manager.c"
"dns_server.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
) )

163
main/dns_server.c Normal file
View File

@@ -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 <string.h>
#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;
}

29
main/dns_server.h Normal file
View File

@@ -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

View File

@@ -14,26 +14,21 @@
#include "esp_log.h" #include "esp_log.h"
#include "esp_system.h" #include "esp_system.h"
#include "esp_ota_ops.h" #include "esp_ota_ops.h"
#include "esp_partition.h"
#include "esp_https_ota.h" #include "esp_https_ota.h"
#include "esp_http_client.h" #include "esp_http_client.h"
#include "esp_crt_bundle.h" #include "esp_crt_bundle.h"
#include "esp_app_desc.h" #include "esp_app_desc.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "cJSON.h" #include "cJSON.h"
#include "ota_manager.h" #include "ota_manager.h"
static const char *TAG = "ota"; static const char *TAG = "ota";
#ifndef CONFIG_MTG_GITEA_URL #define GITEA_RELEASES_PAGE_URL "https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases"
#define CONFIG_MTG_GITEA_URL "https://gitea.com" #define GITEA_RELEASE_API_URL "https://git.rasmusbendtsen.dk/api/v1/repos/rasmus/MTGcompanion/releases/latest"
#endif #define GITEA_FIRMWARE_FALLBACK_URL "https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases/download/latest/mtg-rfid-companion.bin"
#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_API_BUF_SIZE (8192) #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 ota_status_t s_status = OTA_STATUS_IDLE;
static char s_message[128] = "Idle"; static char s_message[128] = "Idle";
static ota_release_info_t s_release = {0}; 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_bytes_read = 0;
static size_t s_total_bytes = 0; static size_t s_total_bytes = 0;
static volatile bool s_cancel_requested = false; 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(); 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'", ESP_LOGI(TAG, "Running firmware: ver='%s', SHA256=%.8s..., ELF=%.8s... on partition '%s'",
app_desc->project_name, app_desc->version, app_desc->date, app_desc->time, app_desc->version, s_running_sha256, s_running_elf_sha256,
running ? running->label : "unknown"); running ? running->label : "unknown");
return ESP_OK; return ESP_OK;
@@ -90,6 +98,11 @@ const char *ota_manager_get_current_version(void)
return esp_app_get_description()->version; 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 ota_manager_get_status(void)
{ {
ota_status_t st; ota_status_t st;
@@ -137,35 +150,44 @@ void ota_manager_reset(void)
} }
} }
/* Semver parsing: compares "v1.2.3" or "1.2.3" */ static void extract_hash_from_body(const char *body, char *out_sha256, size_t sha_sz, char *out_commit, size_t com_sz)
static bool parse_semver(const char *ver, int *maj, int *min, int *patch)
{ {
if (!ver) return false; if (out_sha256 && sha_sz > 0) out_sha256[0] = '\0';
while (*ver && !isdigit((unsigned char)*ver)) { if (out_commit && com_sz > 0) out_commit[0] = '\0';
ver++; 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';
}
}
/* 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';
} }
*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);
} }
/* Fallback to string comparison */
return (strcmp(remote, local) != 0);
} }
typedef struct { typedef struct {
@@ -191,11 +213,7 @@ static void ota_check_task(void *arg)
{ {
set_state(OTA_STATUS_CHECKING, "Connecting to Gitea..."); set_state(OTA_STATUS_CHECKING, "Connecting to Gitea...");
char api_url[256]; ESP_LOGI(TAG, "Querying Gitea release API: %s", GITEA_RELEASE_API_URL);
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);
http_rx_buf_t rx = { http_rx_buf_t rx = {
.buf = malloc(GITEA_API_BUF_SIZE), .buf = malloc(GITEA_API_BUF_SIZE),
@@ -212,7 +230,7 @@ static void ota_check_task(void *arg)
rx.buf[0] = '\0'; rx.buf[0] = '\0';
esp_http_client_config_t cfg = { esp_http_client_config_t cfg = {
.url = api_url, .url = GITEA_RELEASE_API_URL,
.transport_type = HTTP_TRANSPORT_OVER_SSL, .transport_type = HTTP_TRANSPORT_OVER_SSL,
.timeout_ms = 10000, .timeout_ms = 10000,
.event_handler = http_rx_event_handler, .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 *tag_item = cJSON_GetObjectItem(root, "tag_name");
cJSON *title_item = cJSON_GetObjectItem(root, "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 *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); if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY);
strncpy(s_release.tag_name, tag_str, sizeof(s_release.tag_name) - 1); 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.download_url[0] = '\0';
s_release.binary_size = 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 */ /* Search assets for mtg-rfid-companion.bin or *.bin */
cJSON *assets = cJSON_GetObjectItem(root, "assets"); cJSON *assets = cJSON_GetObjectItem(root, "assets");
if (assets && cJSON_IsArray(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 (aname && aname->valuestring && aurl && aurl->valuestring) {
if (strstr(aname->valuestring, ".bin") != NULL) { if (strstr(aname->valuestring, ".bin") != NULL) {
const char *url_str = aurl->valuestring; 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) { if (asize && asize->valuedouble > 0) {
s_release.binary_size = (size_t)asize->valuedouble; 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 */ break; /* Exact match, stop looking */
} }
} }
@@ -309,29 +350,58 @@ static void ota_check_task(void *arg)
/* Fallback URL if not in assets list */ /* Fallback URL if not in assets list */
if (s_release.download_url[0] == '\0') { if (s_release.download_url[0] == '\0') {
snprintf(s_release.download_url, sizeof(s_release.download_url), strncpy(s_release.download_url, GITEA_FIRMWARE_FALLBACK_URL, sizeof(s_release.download_url) - 1);
"%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);
} }
const char *current_ver = ota_manager_get_current_version(); /* Verify if an update is available by checking hashes */
s_release.is_newer = check_is_newer(s_release.tag_name, current_ver); 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); if (s_ota_mux) xSemaphoreGive(s_ota_mux);
ESP_LOGI(TAG, "Discovered release '%s' (%s), download: %s, is_newer: %d (current: %s)", ESP_LOGI(TAG, "Gitea check: remote_hash='%.8s' (SHA256: '%.8s', Commit: '%.8s'), local_sha256='%.8s', ver='%s' => is_newer=%d",
s_release.tag_name, s_release.release_title, s_release.download_url, s_release.remote_hash, remote_sha256, remote_commit, s_running_sha256, running_app->version, s_release.is_newer);
s_release.is_newer, current_ver);
cJSON_Delete(root); cJSON_Delete(root);
if (s_release.is_newer) { if (s_release.is_newer) {
char msg[128]; 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); set_state(OTA_STATUS_UPDATE_AVAILABLE, msg);
} else { } else {
char msg[128]; 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); set_state(OTA_STATUS_UP_TO_DATE, msg);
} }
@@ -371,6 +441,20 @@ static void ota_download_task(void *arg)
return; 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) { while (1) {
if (s_cancel_requested) { if (s_cancel_requested) {
ESP_LOGW(TAG, "OTA canceled by user"); 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)) { if (esp_https_ota_is_complete_data_received(ota_handle)) {
esp_err_t finish_err = esp_https_ota_finish(ota_handle); esp_err_t finish_err = esp_https_ota_finish(ota_handle);
if (finish_err == ESP_OK) { 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..."); ESP_LOGI(TAG, "OTA upgrade successful! Rebooting in 2 seconds...");
set_state(OTA_STATUS_SUCCESS_REBOOTING, "Update complete! Rebooting..."); set_state(OTA_STATUS_SUCCESS_REBOOTING, "Update complete! Rebooting...");
vTaskDelay(pdMS_TO_TICKS(2000)); vTaskDelay(pdMS_TO_TICKS(2000));

View File

@@ -26,9 +26,11 @@ typedef enum {
} ota_status_t; } ota_status_t;
typedef struct { typedef struct {
char tag_name[32]; /* e.g. "v1.2.0" */ char tag_name[32]; /* e.g. "latest" */
char release_title[64]; /* e.g. "Release 1.2.0" */ char release_title[64]; /* e.g. "Latest ESP32 Firmware" */
char download_url[256]; /* URL to mtg-rfid-companion.bin */ 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 */ size_t binary_size; /* in bytes, or 0 if unknown */
bool is_newer; bool is_newer;
} ota_release_info_t; } ota_release_info_t;
@@ -43,6 +45,11 @@ esp_err_t ota_manager_init(void);
*/ */
const char *ota_manager_get_current_version(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. * @brief Get current OTA state.
*/ */

View File

@@ -988,13 +988,23 @@ static void view_device_info(void)
storage_manager_count_cached(&json_c, &jpg_c); storage_manager_count_cached(&json_c, &jpg_c);
uint32_t free_heap = esp_get_free_heap_size(); 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_t *lbl_keys = lv_label_create(box);
lv_obj_set_width(lbl_keys, 75); 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_font(lbl_keys, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(lbl_keys, lv_color_hex(0x00E5FF), 0); lv_obj_set_style_text_color(lbl_keys, lv_color_hex(0x00E5FF), 0);
lv_label_set_text(lbl_keys, lv_label_set_text(lbl_keys,
"VERSION:\n" "VERSION:\n"
"WI-FI IP:\n" "WI-FI:\n"
"IP ADDR:\n"
"HOTSPOT:\n" "HOTSPOT:\n"
"STORAGE:\n" "STORAGE:\n"
"CARDS:\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_color(lbl_vals, lv_color_hex(COLOR_AMBER), 0);
lv_obj_set_style_text_align(lbl_vals, LV_TEXT_ALIGN_RIGHT, 0); lv_obj_set_style_text_align(lbl_vals, LV_TEXT_ALIGN_RIGHT, 0);
lv_label_set_text_fmt(lbl_vals, lv_label_set_text_fmt(lbl_vals,
"%s\n"
"%s\n" "%s\n"
"%s\n" "%s\n"
"192.168.4.1\n" "192.168.4.1\n"
@@ -1020,6 +1031,7 @@ static void view_device_info(void)
"ESP32+ST7789\n" "ESP32+ST7789\n"
"MFRC522", "MFRC522",
ota_manager_get_current_version(), ota_manager_get_current_version(),
wifi_line,
wifi_manager_get_ip(), wifi_manager_get_ip(),
(int)(used_b / 1024), (int)(total_b / 1024), (int)(used_b / 1024), (int)(total_b / 1024),
json_c, 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_set_style_text_color(t, lv_color_hex(0x00E676), 0);
lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 8); 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_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_font(v, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(v, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_text_color(v, lv_color_hex(0xFFCC00), 0);
lv_obj_align(v, LV_ALIGN_TOP_MID, 0, 38); 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_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_font(c, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(c, lv_color_white(), 0); lv_obj_set_style_text_color(c, lv_color_white(), 0);
lv_obj_align(c, LV_ALIGN_TOP_MID, 0, 70); 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: { case OTA_STATUS_UP_TO_DATE: {
const char *cur_ver = ota_manager_get_current_version(); 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_t *box = lv_obj_create(lv_scr_act());
lv_obj_set_size(box, DISP_W - 24, 180); lv_obj_set_size(box, DISP_W - 24, 180);
lv_obj_align(box, LV_ALIGN_CENTER, 0, -10); 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_align(t, LV_ALIGN_TOP_MID, 0, 15);
lv_obj_t *v = lv_label_create(box); 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_font(v, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(v, lv_color_white(), 0); lv_obj_set_style_text_color(v, lv_color_white(), 0);
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_CENTER, 0);

View File

@@ -14,6 +14,7 @@
#include "storage_manager.h" #include "storage_manager.h"
#include "scryfall_client.h" #include "scryfall_client.h"
#include "wifi_manager.h" #include "wifi_manager.h"
#include "dns_server.h"
#include "web_server.h" #include "web_server.h"
static const char *TAG = "web"; 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}" "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}" "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}" ".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,.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}" "button:hover,.btn:hover{background:#3a3a5c}"
".btn-primary{background:#ffd75a;color:#111;border:none;font-weight:bold}" ".btn-primary{background:#ffd75a;color:#111;border:none;font-weight:bold}"
".btn-primary:hover{background:#ffe380}" ".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{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}" ".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{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{cursor:pointer;padding:.5em .8em;border-bottom:1px solid #333}"
".sug li:hover{background:#2a2a48;color:#ffd75a}" ".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}" ".header-row{display:flex;align-items:center;justify-content:space-between}"
"</style></head><body>" "</style></head><body>"
"<h1>MTG RFID Companion</h1>" "<h1>MTG RFID Companion</h1>"
"<p style=\"color:#aaa;margin-top:0\">Pair card sleeves and manage your scanned deck.</p>" "<p style=\"color:#aaa;margin-top:0\">Manage card tags, decks, and device Wi-Fi connectivity.</p>"
"<div class=\"tabs\">"
"<button id=\"tb-cards\" class=\"tab-btn active\" onclick=\"tab('cards')\">&#127183; Card Pairing</button>"
"<button id=\"tb-wifi\" class=\"tab-btn\" onclick=\"tab('wifi')\">&#128246; Wi-Fi Settings</button>"
"</div>"
"<!-- Tab: Cards -->"
"<div id=\"pane-cards\" class=\"tab-pane active\">"
"<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">" "<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">"
"<h2 style=\"margin-top:0\">Last Scanned Tag</h2>" "<h2 style=\"margin-top:0\">Last Scanned Tag</h2>"
"<p class=\"uid\" id=\"uid\">waiting...</p>" "<p class=\"uid\" id=\"uid\">waiting...</p>"
@@ -65,7 +84,55 @@ static const char SPA_PAGE[] =
"<button class=\"btn\" style=\"font-size:.8em\" onclick=\"loadCards()\">Refresh</button>" "<button class=\"btn\" style=\"font-size:.8em\" onclick=\"loadCards()\">Refresh</button>"
"</div>" "</div>"
"<div id=\"cards-box\"><p class=\"card-none\">Loading paired cards...</p></div>" "<div id=\"cards-box\"><p class=\"card-none\">Loading paired cards...</p></div>"
"</div>"
"<!-- Tab: Wi-Fi -->"
"<div id=\"pane-wifi\" class=\"tab-pane\">"
"<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">"
"<h2 style=\"margin-top:0\">Active Connection</h2>"
"<div id=\"wf-badge\" class=\"status-badge badge-bad\">Checking Wi-Fi...</div>"
"<div id=\"wf-info\" style=\"font-size:.9em;color:#aaa;line-height:1.6\"></div>"
"</div>"
"<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">"
"<div class=\"header-row\">"
"<h2 style=\"margin-top:0\">Connect to Wi-Fi</h2>"
"<button class=\"btn\" style=\"font-size:.8em\" id=\"wf-scan-btn\" onclick=\"scanWifi()\">&#128269; Scan Networks</button>"
"</div>"
"<div class=\"input-grp\">"
"<label>Discovered Networks</label>"
"<select id=\"wf-select\" onchange=\"pickNet()\">"
"<option value=\"\">-- Click Scan Networks above --</option>"
"</select>"
"</div>"
"<div class=\"input-grp\">"
"<label>Network Name (SSID)</label>"
"<input id=\"wf-ssid\" placeholder=\"e.g. MyHomeWifi or Mobile Hotspot\">"
"</div>"
"<div class=\"input-grp\">"
"<label>Password</label>"
"<div class=\"pwd-row\">"
"<input type=\"password\" id=\"wf-pass\" placeholder=\"Network password (empty if open)\">"
"<button type=\"button\" class=\"btn\" onclick=\"togglePass()\" style=\"margin:0;width:auto\">&#128065;</button>"
"</div>"
"</div>"
"<div style=\"margin-top:.6em\">"
"<button class=\"btn-primary\" style=\"width:100%\" onclick=\"connectWifi()\">Connect &amp; Save</button>"
"</div>"
"<p id=\"wf-status\" style=\"margin-top:.6em\"></p>"
"</div>"
"<div class=\"header-row\">"
"<h2>Saved Networks</h2>"
"<button class=\"btn\" style=\"font-size:.8em\" onclick=\"loadSavedWifi()\">Refresh</button>"
"</div>"
"<div id=\"saved-box\"><p class=\"card-none\">Loading saved networks...</p></div>"
"</div>"
"<script>" "<script>"
"function tab(t){"
"document.getElementById('tb-cards').className='tab-btn'+(t==='cards'?' active':'');"
"document.getElementById('tb-wifi').className='tab-btn'+(t==='wifi'?' active':'');"
"document.getElementById('pane-cards').className='tab-pane'+(t==='cards'?' active':'');"
"document.getElementById('pane-wifi').className='tab-pane'+(t==='wifi'?' active':'');"
"if(t==='wifi'){refreshWifiStatus();loadSavedWifi();}"
"}"
"var lastUid='';var suggestions=[];" "var lastUid='';var suggestions=[];"
"function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}" "function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}"
"function show(cls,msg){document.getElementById('status').className=cls;document.getElementById('status').textContent=msg;}" "function show(cls,msg){document.getElementById('status').className=cls;document.getElementById('status').textContent=msg;}"
@@ -115,7 +182,93 @@ static const char SPA_PAGE[] =
" box.innerHTML=html;" " box.innerHTML=html;"
" }).catch(function(e){document.getElementById('cards-box').innerHTML='<p class=\"bad\">Failed to load cards: '+e+'</p>';});" " }).catch(function(e){document.getElementById('cards-box').innerHTML='<p class=\"bad\">Failed to load cards: '+e+'</p>';});"
"}" "}"
"poll();loadCards();" "function showWf(cls,msg){var el=document.getElementById('wf-status');el.className=cls;el.textContent=msg;}"
"function togglePass(){var f=document.getElementById('wf-pass');f.type=f.type==='password'?'text':'password';}"
"function refreshWifiStatus(){"
" fetch('/api/wifi/status').then(function(r){return r.json();}).then(function(j){"
" var b=document.getElementById('wf-badge');var d=document.getElementById('wf-info');"
" if(j.connected){"
" b.className='status-badge badge-ok';b.textContent='Connected to '+j.ssid;"
" d.innerHTML='IP Address: <strong>'+j.ip+'</strong><br>Signal: '+j.rssi+' dBm<br>Hotspot Portal: 192.168.4.1';"
" }else{"
" b.className='status-badge badge-bad';b.textContent='Disconnected';"
" d.innerHTML='Device Wi-Fi is not connected to any network.<br>Hotspot Portal: <strong>192.168.4.1</strong>';"
" }"
" }).catch(function(){});"
"}"
"function scanWifi(){"
" var btn=document.getElementById('wf-scan-btn');"
" btn.disabled=true;btn.textContent='Scanning...';"
" var sel=document.getElementById('wf-select');"
" sel.innerHTML='<option>Scanning in progress...</option>';"
" fetch('/api/wifi/scan').then(function(r){return r.json();}).then(function(list){"
" btn.disabled=false;btn.textContent='🔍 Scan Networks';"
" if(!list||list.length===0){"
" sel.innerHTML='<option value=\"\">No networks found</option>';return;"
" }"
" var h='<option value=\"\">-- Select a discovered network --</option>';"
" list.forEach(function(ap){"
" var lock=ap.auth==='Open'?'':' 🔒';"
" h+='<option value=\"'+esc(ap.ssid)+'\">'+esc(ap.ssid)+' ('+ap.rssi+' dBm, '+ap.auth+lock+')</option>';"
" });"
" sel.innerHTML=h;"
" }).catch(function(e){"
" btn.disabled=false;btn.textContent='🔍 Scan Networks';"
" sel.innerHTML='<option value=\"\">Scan failed: '+e+'</option>';"
" });"
"}"
"function pickNet(){"
" var val=document.getElementById('wf-select').value;"
" if(val){"
" document.getElementById('wf-ssid').value=val;"
" document.getElementById('wf-pass').focus();"
" }"
"}"
"function connectWifi(){"
" var s=document.getElementById('wf-ssid').value.trim();"
" var p=document.getElementById('wf-pass').value;"
" if(!s){showWf('bad','Please enter or select a network SSID');return;}"
" showWf('','Connecting to '+s+'... Please wait 5-10s.');"
" fetch('/api/wifi/connect',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
" body:'ssid='+encodeURIComponent(s)+'&pass='+encodeURIComponent(p)})"
" .then(function(r){return r.json();}).then(function(j){"
" if(j.ok){"
" showWf('ok','Saved & initiated connection to '+s+'. Checking status...');"
" loadSavedWifi();"
" setTimeout(function(){refreshWifiStatus();showWf('ok','Connected profile saved!');},6000);"
" }else{"
" showWf('bad','Failed to connect: '+(j.error||'unknown'));"
" }"
" }).catch(function(e){showWf('bad','Network error: '+e);});"
"}"
"function loadSavedWifi(){"
" fetch('/api/wifi/saved').then(function(r){return r.json();}).then(function(list){"
" var box=document.getElementById('saved-box');"
" if(!list||list.length===0){"
" box.innerHTML='<p class=\"card-none\">No saved networks. Connect to a network above.</p>';return;"
" }"
" var h='';"
" list.forEach(function(item){"
" h+='<div class=\"card-item\">';"
" h+='<div class=\"card-info\"><div class=\"card-title\">'+esc(item.ssid)+'</div><div class=\"card-uid\">Saved Profile</div></div>';"
" h+='<button class=\"btn-del\" onclick=\"forgetWifi(\\''+esc(item.ssid)+'\\')\">Forget</button>';"
" h+='</div>';"
" });"
" box.innerHTML=h;"
" }).catch(function(e){"
" document.getElementById('saved-box').innerHTML='<p class=\"bad\">Error loading saved networks: '+e+'</p>';"
" });"
"}"
"function forgetWifi(s){"
" if(!confirm('Forget network \"'+s+'\"?'))return;"
" fetch('/api/wifi/forget',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
" body:'ssid='+encodeURIComponent(s)})"
" .then(function(r){return r.json();}).then(function(j){"
" if(j.ok){loadSavedWifi();refreshWifiStatus();}"
" else{alert('Failed to forget '+s);}"
" }).catch(function(e){alert('Error: '+e);});"
"}"
"poll();loadCards();refreshWifiStatus();"
"</script></body></html>"; "</script></body></html>";
static httpd_handle_t s_server = NULL; 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_cards(httpd_req_t *req);
static esp_err_t handle_art(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_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 = { static const httpd_uri_t URI_ROOT = {
.uri = "/", .method = HTTP_GET, .handler = handle_root, .user_ctx = NULL .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 .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) static void send_json(httpd_req_t *req, int status, const char *body)
{ {
httpd_resp_set_status(req, status == 200 ? "200 OK" : "400 Bad Request"); httpd_resp_set_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; 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 */ /* 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 */ (void)esp_netif_dhcps_start(ap); /* harmless if someone else started it */
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 6 * 1024; config.stack_size = 8 * 1024;
config.max_uri_handlers = 12; config.max_uri_handlers = 24;
config.lru_purge_enable = true; config.lru_purge_enable = true;
config.keep_alive_enable = true; config.keep_alive_enable = true;
config.max_open_sockets = 4; config.max_open_sockets = 4;
@@ -466,6 +852,7 @@ esp_err_t web_server_start(void)
return ret; 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_ROOT));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LAST_UID)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LAST_UID));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_BIND)); 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_ART));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_UNBIND)); 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()); ESP_LOGI(TAG, "Web server ready on all interfaces (Hotspot: 192.168.4.1, Wi-Fi: %s)", wifi_manager_get_ip());
return ESP_OK; return ESP_OK;
} }

View File

@@ -1,50 +1,209 @@
/* /*
* MTG RFID Companion - Wi-Fi manager (Core 0) * MTG RFID Companion - Wi-Fi manager (Core 0)
*
* Multi-profile station manager with NVS storage, live scanning,
* and boot auto-connect.
*/ */
#include <string.h> #include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "freertos/event_groups.h"
#include "freertos/semphr.h"
#include "esp_wifi.h" #include "esp_wifi.h"
#include "esp_event.h" #include "esp_event.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_netif.h" #include "esp_netif.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "nvs.h"
#include "wifi_manager.h" #include "wifi_manager.h"
static const char *TAG = "wifi"; static const char *TAG = "wifi";
#define WIFI_RETRY_MAX (UINT8_MAX) #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; static EventGroupHandle_t s_wifi_group = NULL;
#define WIFI_CONNECTED_BIT BIT0 #define WIFI_CONNECTED_BIT BIT0
static int s_retry_count = 0; static int s_retry_count = 0;
static char s_sta_ip[20] = "No Wi-Fi"; 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) static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data)
{ {
(void)arg; (void)arg;
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { 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(); esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
strncpy(s_sta_ip, "No Wi-Fi", sizeof(s_sta_ip)); strncpy(s_sta_ip, "No Wi-Fi", sizeof(s_sta_ip));
if (s_wifi_group) {
xEventGroupClearBits(s_wifi_group, WIFI_CONNECTED_BIT); xEventGroupClearBits(s_wifi_group, WIFI_CONNECTED_BIT);
}
if (s_retry_count < WIFI_RETRY_MAX) { 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(); esp_wifi_connect();
s_retry_count++; s_retry_count++;
} }
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)data; 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)); 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; s_retry_count = 0;
if (s_wifi_group) {
xEventGroupSetBits(s_wifi_group, WIFI_CONNECTED_BIT); xEventGroupSetBits(s_wifi_group, WIFI_CONNECTED_BIT);
} }
}
} }
const char *wifi_manager_get_ip(void) const char *wifi_manager_get_ip(void)
@@ -55,6 +214,141 @@ const char *wifi_manager_get_ip(void)
return s_sta_ip; 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 wifi_manager_init(void)
{ {
esp_err_t ret = nvs_flash_init(); esp_err_t ret = nvs_flash_init();
@@ -66,6 +360,7 @@ esp_err_t wifi_manager_init(void)
return ret; return ret;
} }
s_wifi_mux = xSemaphoreCreateMutex();
s_wifi_group = xEventGroupCreate(); s_wifi_group = xEventGroupCreate();
if (s_wifi_group == NULL) { if (s_wifi_group == NULL) {
return ESP_ERR_NO_MEM; return ESP_ERR_NO_MEM;
@@ -89,58 +384,87 @@ esp_err_t wifi_manager_init(void)
return ESP_OK; 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) esp_err_t wifi_manager_start(void)
{ {
#ifndef CONFIG_MTG_WIFI_SSID wifi_profile_t saved_profiles[WIFI_MAX_PROFILES];
#error "Missing CONFIG_MTG_WIFI_SSID - run idf.py menuconfig" int profile_count = wifi_manager_get_saved_profiles(saved_profiles, WIFI_MAX_PROFILES);
#endif
/* Empty SSID, or an untouched sdkconfig.defaults.esp32 placeholder: const char *target_ssid = NULL;
* keep station mode disabled so we don't hammer a bogus AP at boot. */ const char *target_pass = NULL;
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 *ssid = CONFIG_MTG_WIFI_SSID; ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
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_start()); ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "station mode started for '%s'", ssid); if (profile_count > 0) {
ESP_LOGI(TAG, "Found %d saved Wi-Fi profiles in NVS. Scanning for best match...", profile_count);
wifi_scan_config_t scan_cfg = { .show_hidden = true };
esp_wifi_scan_start(&scan_cfg, true);
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
}
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; return ESP_OK;
} }
bool wifi_manager_connected(void)
{
return s_wifi_group != NULL && (xEventGroupGetBits(s_wifi_group) & WIFI_CONNECTED_BIT) != 0;
}
esp_err_t wifi_manager_wait_connected(TickType_t timeout_ticks)
{
if (s_wifi_group == NULL) {
return ESP_ERR_INVALID_STATE;
}
EventBits_t bits = xEventGroupWaitBits(s_wifi_group, WIFI_CONNECTED_BIT, pdFALSE, pdTRUE, timeout_ticks);
return (bits & WIFI_CONNECTED_BIT) ? ESP_OK : ESP_ERR_TIMEOUT;
}

View File

@@ -1,38 +1,52 @@
/* /*
* MTG RFID Companion - Wi-Fi manager (Core 0) * MTG RFID Companion - Wi-Fi manager (Core 0)
* *
* Phase 5 Step 5.1: Station-mode auto-connect with disconnect event * Station-mode multi-profile management with NVS persistence,
* handling and reconnect back-off. * live network scanning, and boot auto-connect.
*/ */
#pragma once #pragma once
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include "esp_err.h" #include "esp_err.h"
#include "esp_wifi_types.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
#endif #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. * @return ESP_OK on success.
*/ */
esp_err_t wifi_manager_init(void); 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); esp_err_t wifi_manager_start(void);
/** /**
* @brief True if a real SSID is configured (non-empty, not the * @brief True if a real SSID is configured in NVS or Kconfig.
* `YOUR_...` placeholder). Station mode will actually run.
*/ */
bool wifi_manager_sta_configured(void); 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); 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); 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 #ifdef __cplusplus
} }
#endif #endif

View File

@@ -1,4 +1,4 @@
# MTG RFID Companion - ESP32 SDK defaults # MTG RFID Companion - ESP32 SDK Defaults
CONFIG_IDF_TARGET="esp32" CONFIG_IDF_TARGET="esp32"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM=y
@@ -7,17 +7,25 @@ CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
CONFIG_VFS_SUPPORT_DIR=y CONFIG_VFS_SUPPORT_DIR=y
CONFIG_FREERTOS_SMP=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_USE_ROM=n
CONFIG_JD_SZBUF=512 CONFIG_JD_SZBUF=512
CONFIG_JD_FORMAT_RGB565=y CONFIG_JD_FORMAT_RGB565=y
CONFIG_JD_FASTDECODE_32BIT=y CONFIG_JD_FASTDECODE_32BIT=y
# Some ESP32 boards ship a Boya flash chip; use the optimized driver # Flash Chip Driver Optimization
CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y 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

View File

@@ -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