Files
MeshCore/src/helpers/WiFiSetupPortal.cpp
T
mikecarper 9fd580c89e Harden marathon-tested OTA and hardware paths
Add durable TempRadio, ACL, clock, bridge, GPS/I2C, USB logging, and Indicator recovery safeguards together with expanded automation, build/profile contracts, release metadata, and the hardware validation ledger.
2026-08-30 19:26:15 -07:00

453 lines
16 KiB
C++

#include "WiFiSetupPortal.h"
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <WiFi.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <esp_wifi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <strings.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/UsbLogging.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <helpers/esp32/WiFiStationPolicy.h>
namespace {
static const IPAddress SETUP_IP(192, 168, 4, 1);
static const IPAddress SETUP_MASK(255, 255, 255, 0);
static const uint32_t CONNECT_TIMEOUT_MS = 20000;
// The rendered success page remains in the browser after the setup SSID
// disappears; allow one second for the HTTP response to flush first.
static const uint32_t AP_CLOSE_DELAY_MS = 1000;
static const char SETUP_PAGE[] = R"HTML(<!doctype html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<title>MeshCore WiFi setup</title><style>
body{font-family:sans-serif;max-width:28rem;margin:2rem auto;padding:0 1rem;color:#222}
label{display:block;margin-top:1rem}input,button{box-sizing:border-box;width:100%;font-size:1rem;padding:.7rem;margin-top:.35rem}
button{margin-top:1.2rem}small{color:#555}</style></head><body>
<h2>MeshCore WiFi setup</h2><p>Enter the WiFi network this node should join.</p>
<form method="post" action="/save">
<label>SSID<input name="ssid" maxlength="31" required autocomplete="off"></label>
<label>Password<input name="password" type="password" maxlength="64" autocomplete="off"></label>
<small>Leave blank for an open network. A 64-character key must be hexadecimal.</small>
<button type="submit">Save and connect</button></form></body></html>)HTML";
struct PortalImpl {
WiFiServer server{80};
DNSServer dns;
TaskHandle_t task = nullptr;
WiFiSetupPortal::SaveCallback save_callback = nullptr;
void* callback_context = nullptr;
volatile bool* active = nullptr;
uint32_t close_ap_at = 0;
char ap_name[33] = "MeshCore-Setup";
char recovery_ssid[32] = "";
char recovery_password[65] = "";
uint32_t recovery_interval_ms = 0;
uint32_t next_recovery_at = 0;
uint32_t recovery_deadline = 0;
bool recovery_connecting = false;
};
static bool readLine(WiFiClient& client, char* line, size_t capacity) {
size_t length = 0;
uint32_t deadline = millis() + 5000;
while (client.connected()) {
while (client.available()) {
int c = client.read();
if (c < 0) break;
if (c == '\n') {
if (length && line[length - 1] == '\r') length--;
line[length] = 0;
return true;
}
if (length + 1 < capacity) line[length++] = static_cast<char>(c);
}
if (static_cast<int32_t>(millis() - deadline) >= 0) break;
delay(1);
}
return false;
}
static void sendResponse(WiFiClient& client, int code, const char* reason,
const char* content_type, const char* body) {
size_t length = strlen(body);
client.printf("HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %u\r\n"
"Connection: close\r\nCache-Control: no-store\r\n\r\n",
code, reason, content_type, static_cast<unsigned>(length));
client.write(reinterpret_cast<const uint8_t*>(body), length);
}
static int hexValue(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static bool decodeFormValue(const char* encoded, size_t encoded_len,
char* output, size_t output_size) {
if (!output || output_size == 0) return false;
size_t out = 0;
for (size_t i = 0; i < encoded_len; i++) {
char c = encoded[i];
if (c == '+') {
c = ' ';
} else if (c == '%' && i + 2 < encoded_len) {
int hi = hexValue(encoded[i + 1]);
int lo = hexValue(encoded[i + 2]);
if (hi < 0 || lo < 0) return false;
c = static_cast<char>((hi << 4) | lo);
i += 2;
}
if (c == 0 || out + 1 >= output_size) return false;
output[out++] = c;
}
output[out] = 0;
return true;
}
static bool getFormField(const char* body, const char* name,
char* output, size_t output_size) {
size_t name_len = strlen(name);
const char* field = body;
while (field && *field) {
const char* end = strchr(field, '&');
size_t field_len = end ? static_cast<size_t>(end - field) : strlen(field);
if (field_len >= name_len + 1 && field[name_len] == '='
&& strncmp(field, name, name_len) == 0) {
return decodeFormValue(field + name_len + 1, field_len - name_len - 1,
output, output_size);
}
field = end ? end + 1 : nullptr;
}
if (output_size) output[0] = 0;
return false;
}
static bool hasFormField(const char* body, const char* name) {
if (!body || !name) return false;
const size_t name_len = strlen(name);
const char* field = body;
while (*field) {
const char* end = strchr(field, '&');
const size_t field_len = end
? static_cast<size_t>(end - field) : strlen(field);
if (field_len >= name_len + 1 && field[name_len] == '='
&& strncmp(field, name, name_len) == 0) {
return true;
}
field = end ? end + 1 : nullptr;
if (!field) break;
}
return false;
}
static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) {
char ssid[32];
char password[65];
if (!getFormField(body, "ssid", ssid, sizeof(ssid)) || ssid[0] == 0) {
sendResponse(client, 400, "Bad Request", "text/plain", "A valid SSID is required.");
return;
}
// Browsers omit an empty password only if the form is modified. Treat either
// an empty field or an absent one as an open network.
if (hasFormField(body, "password")) {
if (!getFormField(body, "password", password, sizeof(password))) {
sendResponse(client, 400, "Bad Request", "text/plain",
"WiFi password must be 0-63 characters or exactly 64 hexadecimal characters.");
return;
}
} else {
password[0] = 0;
}
if (!mesh::cli::standaloneWiFiPasswordValid(password)) {
sendResponse(client, 400, "Bad Request", "text/plain",
"WiFi password must be 0-63 characters or exactly 64 hexadecimal characters.");
return;
}
// A submitted configuration takes priority over a background retry of the
// previously saved network.
impl->recovery_connecting = false;
mesh::usbLoggingPort().printf(
"WiFi setup: connecting to '%s'...\n", ssid);
mesh::wifi::beginStation(ssid, password);
uint32_t deadline = millis() + CONNECT_TIMEOUT_MS;
while (impl->active && *impl->active && WiFi.status() != WL_CONNECTED
&& static_cast<int32_t>(millis() - deadline) < 0) {
impl->dns.processNextRequest();
delay(50);
}
if (WiFi.status() != WL_CONNECTED) {
WiFi.disconnect();
sendResponse(client, 200, "OK", "text/html",
"<!doctype html><meta name=viewport content='width=device-width'><h2>Connection failed</h2>"
"<p>Check the SSID and password, then <a href='/'>try again</a>.</p>");
mesh::usbLoggingPort().println(
"WiFi setup: connection failed; setup AP remains active");
return;
}
if (impl->save_callback && !impl->save_callback(impl->callback_context, ssid, password)) {
WiFi.disconnect();
sendResponse(client, 500, "Internal Server Error", "text/plain",
"Connected, but the credentials could not be saved.");
mesh::usbLoggingPort().println(
"WiFi setup: connected, but credential persistence failed");
return;
}
char response[600];
String ip = WiFi.localIP().toString();
#ifdef WITH_MQTT_BRIDGE
snprintf(response, sizeof(response),
"<!doctype html><meta name=viewport content='width=device-width'><h2>WiFi connected</h2>"
"<p>MeshCore joined the selected network.</p><p>IP address: <strong>%s</strong></p>"
"<p>Reconnect this phone or computer to that WiFi network, then open "
"<strong>http://%s/</strong> to finish MQTT setup.</p>"
"<p>The setup SSID will close now; this page and address will remain visible.</p>",
ip.c_str(), ip.c_str());
#else
snprintf(response, sizeof(response),
"<!doctype html><meta name=viewport content='width=device-width'><h2>Connected</h2>"
"<p>MeshCore joined the WiFi network.</p>"
"<p>New network address: <strong>http://%s/</strong></p>"
"<p>The setup SSID will close now; this page and address will remain visible.</p>",
ip.c_str());
#endif
sendResponse(client, 200, "OK", "text/html", response);
mesh::usbLoggingPort().printf(
"WiFi setup: connected; IP %s\n", ip.c_str());
impl->close_ap_at = millis() + AP_CLOSE_DELAY_MS;
}
static void handleClient(PortalImpl* impl, WiFiClient& client) {
char line[256];
if (!readLine(client, line, sizeof(line))) return;
bool post_save = strncmp(line, "POST /save ", 11) == 0;
bool get_request = strncmp(line, "GET ", 4) == 0;
size_t content_length = 0;
do {
if (!readLine(client, line, sizeof(line))) return;
if (strncasecmp(line, "Content-Length:", 15) == 0) {
content_length = static_cast<size_t>(strtoul(line + 15, nullptr, 10));
}
} while (line[0]);
if (post_save) {
if (content_length == 0 || content_length >= 256) {
sendResponse(client, 400, "Bad Request", "text/plain", "Invalid form data.");
return;
}
char body[256];
size_t received = client.readBytes(body, content_length);
if (received != content_length) {
sendResponse(client, 408, "Request Timeout", "text/plain", "Incomplete form data.");
return;
}
body[received] = 0;
handleSave(impl, client, body);
} else if (get_request) {
// Serve the form for every GET path. Together with wildcard DNS this makes
// common captive-portal probes land on the setup page.
sendResponse(client, 200, "OK", "text/html", SETUP_PAGE);
} else {
sendResponse(client, 404, "Not Found", "text/plain", "Not found.");
}
}
static void portalTask(void* arg) {
PortalImpl* impl = static_cast<PortalImpl*>(arg);
while (impl->active && *impl->active) {
impl->dns.processNextRequest();
WiFiClient client = impl->server.available();
if (client) {
client.setTimeout(5000);
handleClient(impl, client);
client.stop();
}
uint32_t now = millis();
if (impl->recovery_connecting) {
if (WiFi.status() == WL_CONNECTED) {
String ip = WiFi.localIP().toString();
mesh::usbLoggingPort().printf(
"WiFi setup: saved network recovered; IP %s\n", ip.c_str());
impl->recovery_connecting = false;
break;
}
if (static_cast<int32_t>(now - impl->recovery_deadline) >= 0) {
WiFi.disconnect();
impl->recovery_connecting = false;
mesh::usbLoggingPort().println(
"WiFi setup: saved network still unavailable; setup AP remains active");
}
} else if (impl->recovery_ssid[0] && impl->recovery_interval_ms
&& static_cast<int32_t>(now - impl->next_recovery_at) >= 0) {
mesh::usbLoggingPort().printf(
"WiFi setup: retrying saved network '%s'...\n",
impl->recovery_ssid);
mesh::wifi::beginStation(
impl->recovery_ssid, impl->recovery_password);
impl->recovery_connecting = true;
impl->recovery_deadline = now + CONNECT_TIMEOUT_MS;
impl->next_recovery_at = now + impl->recovery_interval_ms;
}
if (impl->close_ap_at != 0
&& static_cast<int32_t>(now - impl->close_ap_at) >= 0) {
break;
}
delay(10);
}
impl->dns.stop();
impl->server.stop();
WiFi.softAPdisconnect(true);
if (impl->active) *impl->active = false;
impl->task = nullptr;
vTaskDelete(nullptr);
}
} // namespace
WiFiSetupPortal::WiFiSetupPortal() : _active(false), _impl(nullptr) {}
bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, void* context) {
if (_active) return true;
PortalImpl* impl = static_cast<PortalImpl*>(_impl);
if (!impl) {
impl = new PortalImpl();
if (!impl) return false;
_impl = impl;
}
if (impl->task != nullptr) return false;
impl->save_callback = save_callback;
impl->callback_context = context;
impl->active = &_active;
impl->close_ap_at = 0;
impl->recovery_ssid[0] = 0;
impl->recovery_interval_ms = 0;
impl->recovery_connecting = false;
strncpy(impl->ap_name, ap_name && ap_name[0] ? ap_name : "MeshCore-Setup",
sizeof(impl->ap_name) - 1);
impl->ap_name[sizeof(impl->ap_name) - 1] = 0;
WiFi.mode(WIFI_AP_STA);
if (!WiFi.softAPConfig(SETUP_IP, SETUP_IP, SETUP_MASK)
|| !WiFi.softAP(impl->ap_name, nullptr,
mesh::wifi::accessPointChannel())
|| mesh::wifi::applyAccessPointProtocolMask() != ESP_OK
|| esp_wifi_set_protocol(
WIFI_IF_STA,
mesh::wifi::kProtocolMask)
!= ESP_OK) {
WiFi.softAPdisconnect(true);
return false;
}
if (!impl->dns.start(53, "*", SETUP_IP)) {
WiFi.softAPdisconnect(true);
return false;
}
impl->server.begin();
impl->server.setNoDelay(true);
_active = true;
if (xTaskCreatePinnedToCore(portalTask, "wifi-setup", 6144, impl, 1,
&impl->task, 0) != pdPASS) {
_active = false;
impl->dns.stop();
impl->server.stop();
WiFi.softAPdisconnect(true);
impl->task = nullptr;
return false;
}
mesh::usbLoggingPort().printf(
"WiFi setup: join open AP '%s' and open http://%s/\n",
impl->ap_name, SETUP_IP.toString().c_str());
return true;
}
void WiFiSetupPortal::stop() {
_active = false;
}
bool WiFiSetupPortal::isStopping() const {
const PortalImpl* impl = static_cast<const PortalImpl*>(_impl);
return impl && impl->task != nullptr;
}
void WiFiSetupPortal::configureRecovery(const char* ssid, const char* password,
uint32_t interval_ms,
uint32_t initial_delay_ms) {
PortalImpl* impl = static_cast<PortalImpl*>(_impl);
if (!impl) return;
impl->recovery_ssid[0] = 0;
strncpy(impl->recovery_password, password ? password : "", sizeof(impl->recovery_password) - 1);
impl->recovery_password[sizeof(impl->recovery_password) - 1] = 0;
impl->recovery_interval_ms = interval_ms;
impl->recovery_connecting = false;
impl->next_recovery_at = millis()
+ (initial_delay_ms ? initial_delay_ms : interval_ms);
if (ssid) {
strncpy(impl->recovery_ssid, ssid, sizeof(impl->recovery_ssid) - 1);
impl->recovery_ssid[sizeof(impl->recovery_ssid) - 1] = 0;
}
}
bool WiFiSetupPortal::loadStoredCredentials(char* ssid, size_t ssid_size,
char* password, size_t password_size) {
if (!ssid || ssid_size == 0 || !password || password_size == 0) return false;
Preferences prefs;
// Opening read-only reports ESP_ERR_NVS_NOT_FOUND on a normal fresh install.
// Read-write creates the namespace once; guard absent String keys because
// Preferences::getString() also logs NOT_FOUND instead of returning quietly.
if (!prefs.begin("mesh-wifi", false)) return false;
String stored_ssid = prefs.isKey("ssid")
? prefs.getString("ssid", "") : String();
String stored_password = prefs.isKey("password")
? prefs.getString("password", "") : String();
prefs.end();
if (stored_ssid.length() == 0 || stored_ssid.length() >= ssid_size
|| stored_password.length() >= password_size
|| !mesh::cli::standaloneWiFiPasswordValid(stored_password.c_str())) return false;
strncpy(ssid, stored_ssid.c_str(), ssid_size - 1);
ssid[ssid_size - 1] = 0;
strncpy(password, stored_password.c_str(), password_size - 1);
password[password_size - 1] = 0;
return true;
}
bool WiFiSetupPortal::saveStoredCredentials(const char* ssid, const char* password) {
const char* saved_password = password ? password : "";
if (!ssid || !ssid[0] || strlen(ssid) > 31
|| !mesh::cli::standaloneWiFiPasswordValid(saved_password)) return false;
Preferences prefs;
if (!prefs.begin("mesh-wifi", false)) return false;
bool ok = prefs.putString("ssid", ssid) == strlen(ssid);
prefs.putString("password", saved_password); // An empty String reports zero bytes even on success.
ok = ok && prefs.getString("password", "\x01") == saved_password;
prefs.end();
return ok;
}
bool WiFiSetupPortal::isPlaceholderSSID(const char* ssid) {
if (!ssid || !ssid[0]) return true;
return strcasecmp(ssid, "ssid") == 0
|| strcasecmp(ssid, "myssid") == 0
|| strcasecmp(ssid, "wifi_ssid") == 0
|| strcasecmp(ssid, "yourssid") == 0;
}
WiFiSetupPortal& wifiSetupPortal() {
static WiFiSetupPortal portal;
return portal;
}
#endif