This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
86
README.md
86
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,5 +11,6 @@ idf_component_register(SRCS
|
||||
"buzzer.c"
|
||||
"app_state.c"
|
||||
"ota_manager.c"
|
||||
"dns_server.c"
|
||||
INCLUDE_DIRS "."
|
||||
)
|
||||
163
main/dns_server.c
Normal file
163
main/dns_server.c
Normal 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
29
main/dns_server.h
Normal 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
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"
|
||||
"</style></head><body>"
|
||||
"<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')\">🃏 Card Pairing</button>"
|
||||
"<button id=\"tb-wifi\" class=\"tab-btn\" onclick=\"tab('wifi')\">📶 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\">"
|
||||
"<h2 style=\"margin-top:0\">Last Scanned Tag</h2>"
|
||||
"<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>"
|
||||
"</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()\">🔍 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\">👁</button>"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"<div style=\"margin-top:.6em\">"
|
||||
"<button class=\"btn-primary\" style=\"width:100%\" onclick=\"connectWifi()\">Connect & 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>"
|
||||
"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=[];"
|
||||
"function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}"
|
||||
"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;"
|
||||
" }).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>";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 <string.h>
|
||||
#include <stdlib.h>
|
||||
#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);
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#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
|
||||
@@ -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
|
||||
# Flash Chip Driver Optimization
|
||||
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user