From dfee21a0c9d2ff5c4d4d4a5d10011d3cd1490d4e Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 16 Jul 2026 17:00:22 -0700 Subject: [PATCH] feat(webconfig): implement web configuration portal for ESP32 --- .gitignore | 1 + examples/simple_repeater/MyMesh.cpp | 120 ++++ examples/simple_repeater/MyMesh.h | 46 +- examples/simple_repeater/UITask.cpp | 47 ++ examples/simple_room_server/MyMesh.cpp | 120 ++++ examples/simple_room_server/MyMesh.h | 46 +- examples/simple_room_server/UITask.cpp | 47 ++ platformio.ini | 5 +- scripts/generate_webconfig_html.py | 79 ++ src/helpers/CommonCLI.h | 12 + src/helpers/CommonCLI_Observer.cpp | 15 + src/helpers/bridges/MQTTBridge.cpp | 31 + src/helpers/bridges/MQTTBridge.h | 11 + src/helpers/esp32/WebConfigServer.cpp | 760 ++++++++++++++++++++ src/helpers/esp32/WebConfigServer.h | 169 +++++ webui/index.html | 954 +++++++++++++++++++++++++ 16 files changed, 2460 insertions(+), 3 deletions(-) create mode 100644 scripts/generate_webconfig_html.py create mode 100644 src/helpers/esp32/WebConfigServer.cpp create mode 100644 src/helpers/esp32/WebConfigServer.h create mode 100644 webui/index.html diff --git a/.gitignore b/.gitignore index 29fa3674..10d7e1b8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ compile_commands.json venv/ # Script-generated cert bundles (see scripts/generate_cert_bundle.py) src/certs/x509_crt_bundle.bin +src/helpers/esp32/WebConfigHtml.h ssl_certs/cacert.pem platformio.local.ini .cursor/* diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 88a44fb5..63fd50a8 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1068,6 +1068,16 @@ void MyMesh::begin(FILESYSTEM *fs) { _alerter.setBridge(bridge); #endif +#if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP) + // First-boot setup portal: raised only when no WiFi has ever been configured, + // so an OTA onto a deployed (configured) node can never open an AP. + if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { + char wc_reply[160]; + startWebConfig(false, wc_reply); + Serial.println(wc_reply); + } +#endif + radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); @@ -1304,6 +1314,106 @@ void MyMesh::clearStats() { ((SimpleMeshTables *)getTables())->resetStats(); } +#ifdef WITH_WEBCONFIG +bool MyMesh::startWebConfig(bool force_ap, char* reply) { + if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { + strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly" + : "Err: webconfig already running"); + return true; + } + if (!_webconfig) { + _webconfig = new WebConfigServer(&_prefs, _cli.getObserverPrefs(), this, + self_id.pub_key, getFirmwareVer(), getRole(), + _cli.getBoard()->getManufacturerName()); + } + if (force_ap) { + // The setup AP owns WiFi outright; refuse while the bridge holds the STA. + if (bridge && bridge->isRunning()) { + strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first"); + return true; + } + _webconfig->startSetupMode(reply); + } else if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { + _webconfig->startSetupMode(reply); // unconfigured: same portal as first boot + } else { + _webconfig->startLanMode(reply); // reports "WiFi not connected" if down + } + return true; +} + +bool MyMesh::stopWebConfig(char* reply) { + if (!_webconfig || !_webconfig->isRunning()) { + strcpy(reply, "Err: webconfig not running"); + return true; + } + _webconfig->requestStop(); + strcpy(reply, "OK - webconfig stopping"); + return true; +} + +void MyMesh::onConfigBatchEnd() { + _wc_batch_active = false; + if (_wc_restart_pending) { + // A full restart re-applies every slot config; drop the per-slot requests. + _wc_restart_pending = false; + _wc_slot_restart_mask = 0; + restartBridge(); + } else if (_wc_slot_restart_mask) { + uint8_t mask = _wc_slot_restart_mask; + _wc_slot_restart_mask = 0; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (mask & (1u << i)) restartBridgeSlot(i); + } + } +} + +// Stats snapshot for GET /api/stats. Runs on the loop task (from tick()); +// same sources as the REQ_TYPE_GET_STATUS reply and `get mqtt.stats`. +void MyMesh::buildStatsJson(char* buf, size_t buf_size) { + char ip[20] = ""; + int wifi_rssi = 0; + if (WiFi.status() == WL_CONNECTED) { + strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1); + wifi_rssi = WiFi.RSSI(); + } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { + strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); + } + int pos = snprintf(buf, buf_size, + "{\"uptime_s\":%lu,\"batt_mv\":%u," + "\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu," + "\"noise\":%d,\"rssi\":%d,\"snr\":%.1f," + "\"airtime_s\":%lu,\"rx_airtime_s\":%lu," + "\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu," + "\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu," + "\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[", + (unsigned long)(uptime_millis / 1000), (unsigned)board.getBattMilliVolts(), + (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(), + (unsigned long)ESP.getMaxAllocHeap(), + (int)_radio->getNoiseFloor(), (int)radio_driver.getLastRSSI(), + radio_driver.getLastSNR(), + (unsigned long)(getTotalAirTime() / 1000), (unsigned long)(getReceiveAirTime() / 1000), + (unsigned long)radio_driver.getPacketsRecv(), (unsigned long)radio_driver.getPacketsSent(), + (unsigned long)radio_driver.getPacketsRecvErrors(), + (unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(), + (unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(), + (int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip, + bridge ? bridge->getQueueSize() : 0); + if (pos < 0 || pos >= (int)buf_size - 3) return; // truncated; snprintf terminated it + bool first = true; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + MQTTBridge::SlotStatusSnapshot s; + if (!MQTTBridge::getSlotStatusSnapshot(i, &s)) continue; + int n = snprintf(buf + pos, buf_size - pos, + "%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\",\"ok\":%lu,\"err\":%lu}", + first ? "" : ",", i + 1, s.name, s.state, s.publish_ok, s.publish_err); + if (n < 0 || n >= (int)(buf_size - pos)) break; + pos += n; + first = false; + } + snprintf(buf + pos, buf_size - pos, "]}"); +} +#endif + void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation @@ -1447,6 +1557,16 @@ void MyMesh::loop() { } #endif +#ifdef WITH_WEBCONFIG + if (_webconfig) { + _webconfig->tick(millis()); + if (!_webconfig->isRunning() && !_webconfig->isStopping()) { + delete _webconfig; // teardown finished (or start failed): reclaim the heap + _webconfig = NULL; + } + } +#endif + // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { acl.save(_fs); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index d2a45b81..127ad7d1 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -27,6 +27,7 @@ #ifdef WITH_MQTT_BRIDGE #include "helpers/bridges/MQTTBridge.h" #define WITH_BRIDGE +#include "helpers/esp32/WebConfigServer.h" // defines WITH_WEBCONFIG on ESP32 #endif #ifdef WITH_SNMP @@ -88,7 +89,11 @@ struct NeighbourInfo { #define PACKET_LOG_FILE "/packet_log" -class MyMesh : public mesh::Mesh, public CommonCLICallbacks { +class MyMesh : public mesh::Mesh, public CommonCLICallbacks +#ifdef WITH_WEBCONFIG + , public WebConfigServer::Callbacks +#endif +{ FILESYSTEM* _fs; uint32_t last_millis; uint64_t uptime_millis; @@ -135,6 +140,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #ifdef WITH_MQTT_BRIDGE AlertReporter _alerter; #endif +#ifdef WITH_WEBCONFIG + WebConfigServer* _webconfig = NULL; // heap-allocated while running, freed on stop + bool _wc_batch_active = false; // coalesce bridge restarts during a config batch + bool _wc_restart_pending = false; + uint8_t _wc_slot_restart_mask = 0; +#endif void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); @@ -297,6 +308,12 @@ public: void restartBridge() override { if (!bridge || !bridge->isRunning()) return; +#ifdef WITH_WEBCONFIG + if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd() + _wc_restart_pending = true; + return; + } +#endif bridge->end(); // Set device metadata before restarting bridge (same as in begin()) char device_id[65]; @@ -315,6 +332,12 @@ public: void restartBridgeSlot(int slot) override { #ifdef WITH_MQTT_BRIDGE if (!bridge || !bridge->isRunning()) return; +#ifdef WITH_WEBCONFIG + if (_wc_batch_active && slot >= 0 && slot < 8) { + _wc_slot_restart_mask |= (uint8_t)(1u << slot); + return; + } +#endif bridge->setSlotPreset(slot, _cli.getObserverPrefs()->mqtt_slot_preset[slot]); #else (void)slot; @@ -350,6 +373,27 @@ public: } #endif +#ifdef WITH_WEBCONFIG + // CommonCLICallbacks: `start webconfig [ap]` / `stop webconfig` + bool startWebConfig(bool force_ap, char* reply) override; + bool stopWebConfig(char* reply) override; + + // WebConfigServer::Callbacks - all invoked from tick() on the loop task + void execCommand(char* cmd, char* reply) override { + handleCommand(0, cmd, reply); + } + void rebootNow() override { + _cli.getBoard()->reboot(); + } + void onConfigBatchStart() override { + _wc_batch_active = true; + _wc_restart_pending = false; + _wc_slot_restart_mask = 0; + } + void onConfigBatchEnd() override; + void buildStatsJson(char* buf, size_t buf_size) override; +#endif + // To check if there is pending work bool hasPendingWork() const; diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 713c5bbb..3efaff67 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -8,6 +8,7 @@ #ifdef WITH_MQTT_BRIDGE #include +#include // defines WITH_WEBCONFIG on ESP32 #endif #define AUTO_OFF_MILLIS 20000 // 20 seconds @@ -77,6 +78,43 @@ void UITask::renderCurrScreen() { _display->setCursor((_display->width() - typeWidth) / 2, 48); _display->print(node_type); } else { // home screen +#ifdef WITH_WEBCONFIG + if (WebConfigServer::isRebootPending()) { + // save confirmed on-device: show ground truth even if the browser + // lost its connection before the confirmation reached it + _display->setTextSize(1); + _display->setColor(DisplayDriver::GREEN); + _display->setCursor(0, 14); + _display->print("Config saved!"); + _display->setColor(DisplayDriver::LIGHT); + _display->setCursor(0, 30); + _display->print("Rebooting..."); + return; + } + char wc_ssid[33], wc_ip[16]; + if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + // setup portal active: show join instructions instead of the home screen + _display->setTextSize(1); + _display->setColor(DisplayDriver::GREEN); + _display->setCursor(0, 0); + _display->print("Observer WiFi Setup"); + + _display->setColor(DisplayDriver::LIGHT); + _display->setCursor(0, 14); + _display->print("Join WiFi:"); + _display->setColor(DisplayDriver::YELLOW); + _display->setCursor(6, 24); + _display->print(wc_ssid); + + _display->setColor(DisplayDriver::LIGHT); + _display->setCursor(0, 40); + _display->print("Then browse to:"); + _display->setColor(DisplayDriver::YELLOW); + _display->setCursor(6, 50); + _display->print(wc_ip); + return; + } +#endif // node name _display->setCursor(0, 0); _display->setTextSize(1); @@ -126,6 +164,15 @@ void UITask::loop() { } #endif +#ifdef WITH_WEBCONFIG + // While the setup portal is up there's no user button to wake the screen + // reliably - keep it on so the join instructions stay visible. + if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) { + if (!_display->isOn()) _display->turnOn(); + _auto_off = millis() + AUTO_OFF_MILLIS; + } +#endif + if (_display->isOn()) { if (millis() >= _next_refresh) { _display->startFrame(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 81389ab1..b63036fb 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -783,6 +783,16 @@ void MyMesh::begin(FILESYSTEM *fs) { _alerter.begin(&_prefs, _cli.getObserverPrefs(), this, this); _alerter.setBridge(bridge); #endif + +#if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP) + // First-boot setup portal: raised only when no WiFi has ever been configured, + // so an OTA onto a deployed (configured) node can never open an AP. + if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { + char wc_reply[160]; + startWebConfig(false, wc_reply); + Serial.println(wc_reply); + } +#endif } void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { @@ -949,6 +959,106 @@ void MyMesh::formatPacketStatsReply(char *reply) { getNumRecvFlood(), getNumRecvDirect()); } +#ifdef WITH_WEBCONFIG +bool MyMesh::startWebConfig(bool force_ap, char* reply) { + if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { + strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly" + : "Err: webconfig already running"); + return true; + } + if (!_webconfig) { + _webconfig = new WebConfigServer(&_prefs, _cli.getObserverPrefs(), this, + self_id.pub_key, getFirmwareVer(), getRole(), + _cli.getBoard()->getManufacturerName()); + } + if (force_ap) { + // The setup AP owns WiFi outright; refuse while the bridge holds the STA. + if (bridge && bridge->isRunning()) { + strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first"); + return true; + } + _webconfig->startSetupMode(reply); + } else if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { + _webconfig->startSetupMode(reply); // unconfigured: same portal as first boot + } else { + _webconfig->startLanMode(reply); // reports "WiFi not connected" if down + } + return true; +} + +bool MyMesh::stopWebConfig(char* reply) { + if (!_webconfig || !_webconfig->isRunning()) { + strcpy(reply, "Err: webconfig not running"); + return true; + } + _webconfig->requestStop(); + strcpy(reply, "OK - webconfig stopping"); + return true; +} + +void MyMesh::onConfigBatchEnd() { + _wc_batch_active = false; + if (_wc_restart_pending) { + // A full restart re-applies every slot config; drop the per-slot requests. + _wc_restart_pending = false; + _wc_slot_restart_mask = 0; + restartBridge(); + } else if (_wc_slot_restart_mask) { + uint8_t mask = _wc_slot_restart_mask; + _wc_slot_restart_mask = 0; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (mask & (1u << i)) restartBridgeSlot(i); + } + } +} + +// Stats snapshot for GET /api/stats. Runs on the loop task (from tick()); +// same sources as the stats CLI replies and `get mqtt.stats`. +void MyMesh::buildStatsJson(char* buf, size_t buf_size) { + char ip[20] = ""; + int wifi_rssi = 0; + if (WiFi.status() == WL_CONNECTED) { + strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1); + wifi_rssi = WiFi.RSSI(); + } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { + strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); + } + int pos = snprintf(buf, buf_size, + "{\"uptime_s\":%lu,\"batt_mv\":%u," + "\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu," + "\"noise\":%d,\"rssi\":%d,\"snr\":%.1f," + "\"airtime_s\":%lu,\"rx_airtime_s\":%lu," + "\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu," + "\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu," + "\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[", + (unsigned long)(uptime_millis / 1000), (unsigned)board.getBattMilliVolts(), + (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(), + (unsigned long)ESP.getMaxAllocHeap(), + (int)_radio->getNoiseFloor(), (int)radio_driver.getLastRSSI(), + radio_driver.getLastSNR(), + (unsigned long)(getTotalAirTime() / 1000), (unsigned long)(getReceiveAirTime() / 1000), + (unsigned long)radio_driver.getPacketsRecv(), (unsigned long)radio_driver.getPacketsSent(), + (unsigned long)radio_driver.getPacketsRecvErrors(), + (unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(), + (unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(), + (int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip, + bridge ? bridge->getQueueSize() : 0); + if (pos < 0 || pos >= (int)buf_size - 3) return; // truncated; snprintf terminated it + bool first = true; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + MQTTBridge::SlotStatusSnapshot s; + if (!MQTTBridge::getSlotStatusSnapshot(i, &s)) continue; + int n = snprintf(buf + pos, buf_size - pos, + "%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\",\"ok\":%lu,\"err\":%lu}", + first ? "" : ",", i + 1, s.name, s.state, s.publish_ok, s.publish_err); + if (n < 0 || n >= (int)(buf_size - pos)) break; + pos += n; + first = false; + } + snprintf(buf + pos, buf_size - pos, "]}"); +} +#endif + void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation @@ -1113,6 +1223,16 @@ void MyMesh::loop() { MESH_DEBUG_PRINTLN("Radio params restored"); } +#ifdef WITH_WEBCONFIG + if (_webconfig) { + _webconfig->tick(millis()); + if (!_webconfig->isRunning() && !_webconfig->isStopping()) { + delete _webconfig; // teardown finished (or start failed): reclaim the heap + _webconfig = NULL; + } + } +#endif + // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { acl.save(_fs, MyMesh::saveFilter); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index cd556be3..466fb09d 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -28,6 +28,7 @@ #ifdef WITH_MQTT_BRIDGE #include "helpers/bridges/MQTTBridge.h" #define WITH_BRIDGE +#include "helpers/esp32/WebConfigServer.h" // defines WITH_WEBCONFIG on ESP32 #endif /* ------------------------------ Config -------------------------------- */ @@ -94,7 +95,11 @@ struct PostInfo { char text[MAX_POST_TEXT_LEN+1]; }; -class MyMesh : public mesh::Mesh, public CommonCLICallbacks { +class MyMesh : public mesh::Mesh, public CommonCLICallbacks +#ifdef WITH_WEBCONFIG + , public WebConfigServer::Callbacks +#endif +{ FILESYSTEM* _fs; uint32_t last_millis; uint64_t uptime_millis; @@ -129,6 +134,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #ifdef WITH_MQTT_BRIDGE AlertReporter _alerter; #endif +#ifdef WITH_WEBCONFIG + WebConfigServer* _webconfig = NULL; // heap-allocated while running, freed on stop + bool _wc_batch_active = false; // coalesce bridge restarts during a config batch + bool _wc_restart_pending = false; + uint8_t _wc_slot_restart_mask = 0; +#endif void addPost(ClientInfo* client, const char* postData); void pushPostToClient(ClientInfo* client, PostInfo& post); @@ -282,6 +293,12 @@ public: void restartBridge() override { if (!bridge || !bridge->isRunning()) return; +#ifdef WITH_WEBCONFIG + if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd() + _wc_restart_pending = true; + return; + } +#endif bridge->end(); char device_id[65]; mesh::LocalIdentity self_id = getSelfId(); @@ -299,6 +316,12 @@ public: void restartBridgeSlot(int slot) override { #ifdef WITH_MQTT_BRIDGE if (!bridge || !bridge->isRunning()) return; +#ifdef WITH_WEBCONFIG + if (_wc_batch_active && slot >= 0 && slot < 8) { + _wc_slot_restart_mask |= (uint8_t)(1u << slot); + return; + } +#endif bridge->setSlotPreset(slot, _cli.getObserverPrefs()->mqtt_slot_preset[slot]); #else (void)slot; @@ -324,4 +347,25 @@ public: return bridge->ntpDiag(reply, reply_size, verbose); } #endif + +#ifdef WITH_WEBCONFIG + // CommonCLICallbacks: `start webconfig [ap]` / `stop webconfig` + bool startWebConfig(bool force_ap, char* reply) override; + bool stopWebConfig(char* reply) override; + + // WebConfigServer::Callbacks - all invoked from tick() on the loop task + void execCommand(char* cmd, char* reply) override { + handleCommand(0, cmd, reply); + } + void rebootNow() override { + _cli.getBoard()->reboot(); + } + void onConfigBatchStart() override { + _wc_batch_active = true; + _wc_restart_pending = false; + _wc_slot_restart_mask = 0; + } + void onConfigBatchEnd() override; + void buildStatsJson(char* buf, size_t buf_size) override; +#endif }; diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index cad436e5..ddc222c3 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -8,6 +8,7 @@ #ifdef WITH_MQTT_BRIDGE #include +#include // defines WITH_WEBCONFIG on ESP32 #endif #define AUTO_OFF_MILLIS 20000 // 20 seconds @@ -77,6 +78,43 @@ void UITask::renderCurrScreen() { _display->setCursor((_display->width() - typeWidth) / 2, 48); _display->print(node_type); } else { // home screen +#ifdef WITH_WEBCONFIG + if (WebConfigServer::isRebootPending()) { + // save confirmed on-device: show ground truth even if the browser + // lost its connection before the confirmation reached it + _display->setTextSize(1); + _display->setColor(DisplayDriver::GREEN); + _display->setCursor(0, 14); + _display->print("Config saved!"); + _display->setColor(DisplayDriver::LIGHT); + _display->setCursor(0, 30); + _display->print("Rebooting..."); + return; + } + char wc_ssid[33], wc_ip[16]; + if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + // setup portal active: show join instructions instead of the home screen + _display->setTextSize(1); + _display->setColor(DisplayDriver::GREEN); + _display->setCursor(0, 0); + _display->print("Observer WiFi Setup"); + + _display->setColor(DisplayDriver::LIGHT); + _display->setCursor(0, 14); + _display->print("Join WiFi:"); + _display->setColor(DisplayDriver::YELLOW); + _display->setCursor(6, 24); + _display->print(wc_ssid); + + _display->setColor(DisplayDriver::LIGHT); + _display->setCursor(0, 40); + _display->print("Then browse to:"); + _display->setColor(DisplayDriver::YELLOW); + _display->setCursor(6, 50); + _display->print(wc_ip); + return; + } +#endif // node name _display->setCursor(0, 0); _display->setTextSize(1); @@ -126,6 +164,15 @@ void UITask::loop() { } #endif +#ifdef WITH_WEBCONFIG + // While the setup portal is up there's no user button to wake the screen + // reliably - keep it on so the join instructions stay visible. + if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) { + if (!_display->isOn()) _display->turnOn(); + _auto_off = millis() + AUTO_OFF_MILLIS; + } +#endif + if (_display->isOn()) { if (millis() >= _next_refresh) { _display->startFrame(); diff --git a/platformio.ini b/platformio.ini index d38402c7..b2d0c662 100644 --- a/platformio.ini +++ b/platformio.ini @@ -58,7 +58,9 @@ build_src_filter = extends = arduino_base platform = platformio/espressif32@6.11.0 monitor_filters = esp32_exception_decoder -extra_scripts = merge-bin.py +extra_scripts = + pre:scripts/generate_webconfig_html.py + merge-bin.py build_flags = ${arduino_base.build_flags} -D ESP32_PLATFORM ; Route esp_transport_ws_init through src/helpers/ESP32WsTransportFix.cpp to @@ -66,6 +68,7 @@ build_flags = ${arduino_base.build_flags} -Wl,--wrap=esp_transport_ws_init ; -D ESP32_CPU_FREQ=80 ; change it to your need build_src_filter = ${arduino_base.build_src_filter} + + ; empty TU unless WITH_MQTT_BRIDGE [esp32_ota] lib_deps = diff --git a/scripts/generate_webconfig_html.py b/scripts/generate_webconfig_html.py new file mode 100644 index 00000000..d587fa5d --- /dev/null +++ b/scripts/generate_webconfig_html.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +# +# Pre-build script: gzip webui/index.html into a PROGMEM C header so the +# webconfig portal can serve the page straight from flash with +# Content-Encoding: gzip. The generated header is .gitignored; this script +# regenerates it whenever the source page (or this script) is newer. +# +# Output: src/helpers/esp32/WebConfigHtml.h +# WEBCONFIG_HTML_GZ[] - gzipped page (PROGMEM) +# WEBCONFIG_HTML_GZ_LEN - byte length +# WEBCONFIG_HTML_ETAG - quoted strong ETag (sha256 prefix of the gz body) + +import gzip +import hashlib +import os +import sys + +Import("env") # noqa: F821 + +SOURCE = os.path.join("webui", "index.html") +OUTPUT = os.path.join("src", "helpers", "esp32", "WebConfigHtml.h") +# __file__ is not defined inside PIO/SCons-executed extra_scripts +SCRIPT = os.path.join("scripts", "generate_webconfig_html.py") + + +def status(msg): + sys.stderr.write("WebConfig HTML: %s\n" % msg) + + +def needs_rebuild(): + if not os.path.isfile(OUTPUT): + return True + out_mtime = os.path.getmtime(OUTPUT) + if os.path.getmtime(SOURCE) > out_mtime: + return True + if os.path.isfile(SCRIPT) and os.path.getmtime(SCRIPT) > out_mtime: + return True + return False + + +def main(): + if not os.path.isfile(SOURCE): + status("ERROR: %s not found" % SOURCE) + sys.exit(2) + + if not needs_rebuild(): + return + + with open(SOURCE, "rb") as f: + raw = f.read() + + # mtime=0 keeps the gzip output (and therefore the ETag) deterministic + gz = gzip.compress(raw, compresslevel=9, mtime=0) + etag = hashlib.sha256(gz).hexdigest()[:16] + + lines = [] + lines.append("// Auto-generated by scripts/generate_webconfig_html.py from %s" % SOURCE.replace(os.sep, "/")) + lines.append("// DO NOT EDIT - edit webui/index.html instead.") + lines.append("#pragma once") + lines.append("#include ") + lines.append("#include ") + lines.append("") + lines.append("const uint32_t WEBCONFIG_HTML_GZ_LEN = %d;" % len(gz)) + lines.append('const char WEBCONFIG_HTML_ETAG[] = "\\"%s\\"";' % etag) + lines.append("const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {") + for i in range(0, len(gz), 16): + chunk = gz[i:i + 16] + lines.append(" " + "".join("0x%02x," % b for b in chunk)) + lines.append("};") + lines.append("") + + os.makedirs(os.path.dirname(OUTPUT), exist_ok=True) + with open(OUTPUT, "w") as f: + f.write("\n".join(lines)) + + status("%s -> %s (%d bytes raw, %d bytes gzipped)" % (SOURCE, OUTPUT, len(raw), len(gz))) + + +main() diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index ef4a797c..f0c0f479 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -343,6 +343,18 @@ public: return false; }; + // Browser-based config portal (ESP32 WITH_MQTT_BRIDGE builds override). + // force_ap=true requests the SoftAP setup portal even when WiFi is configured. + // Returns true if handled (reply filled either way when true). + virtual bool startWebConfig(bool force_ap, char* reply) { + (void)force_ap; (void)reply; + return false; + }; + virtual bool stopWebConfig(char* reply) { + (void)reply; + return false; + }; + // Probe all configured NTP servers for connectivity (verbose=serial console gets a // detailed table; otherwise reply gets a compact " ok|fail" list). virtual bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) { diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 695aacef..75d14d48 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -979,6 +979,21 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, strcpy(reply, "ERR: online OTA not supported on this build"); #endif return true; + } else if (memcmp(command, "start webconfig", 15) == 0 && (command[15] == 0 || command[15] == ' ')) { + // Web config portal: `start webconfig` binds to the LAN IP (or raises the + // setup AP when WiFi is unconfigured); `start webconfig ap` forces the AP. + bool force_ap = (command[15] == ' ' && strcmp(&command[16], "ap") == 0); + if (command[15] == ' ' && !force_ap) { + strcpy(reply, "ERR: usage start webconfig [ap]"); + } else if (!_callbacks->startWebConfig(force_ap, reply)) { + strcpy(reply, "ERR: webconfig not supported on this build"); + } + return true; + } else if (strcmp(command, "stop webconfig") == 0) { + if (!_callbacks->stopWebConfig(reply)) { + strcpy(reply, "ERR: webconfig not supported on this build"); + } + return true; } else if (memcmp(command, "alert test", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { // Send a one-off test alert on the configured alert channel. const char* extra = command[10] == ' ' ? &command[11] : ""; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 15104a65..c7e7e66d 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -289,6 +289,37 @@ void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) { } } +// Structured per-slot status for the webconfig stats endpoint. Same state +// derivation as formatMqttStatusReply above. Returns false for out-of-range, +// unconfigured, or bridge-not-running slots. +bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out) { + if (out == nullptr || slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false; + if (s_mqtt_bridge_instance == nullptr || !s_mqtt_bridge_instance->_initialized) return false; + MQTTBridge* b = s_mqtt_bridge_instance; + const MQTTSlot& slot = b->_slots[slot_index]; + + if (!slot.enabled && slot.preset) { + out->name = slot.preset->name; + out->state = "inactive"; + } else if (!slot.enabled) { + return false; // unconfigured slot + } else { + out->name = slot.preset ? slot.preset->name : "custom"; + if (!b->isSlotReady(slot_index)) { + out->state = "wait"; + } else if (slot.connected) { + out->state = "ok"; + } else if (slot.circuit_breaker_tripped) { + out->state = "fail"; + } else { + out->state = "disc"; + } + } + out->publish_ok = slot.client ? slot.client->getPublishOk() : 0; + out->publish_err = slot.client ? slot.client->getPublishErr() : 0; + return true; +} + uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; } unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; } diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 69782bd4..ca065b6c 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -471,6 +471,17 @@ public: /** On-demand publish-health + heap snapshot for `get mqtt.stats` (per-slot ok/err, * outbox size, free/max heap, queue depth). */ static void formatMqttStatsReply(char* buf, size_t bufsize); + /** Structured per-slot snapshot for the webconfig stats endpoint. Same state + * logic as formatMqttStatusReply, same cross-task read semantics. Returns + * false when the bridge is not running, the index is out of range, or the + * slot is unconfigured (skip it). */ + struct SlotStatusSnapshot { + const char* name; // preset name or "custom" + const char* state; // "inactive" | "wait" | "ok" | "fail" | "disc" + unsigned long publish_ok; + unsigned long publish_err; + }; + static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out); /** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */ static bool isConfigValid(const MQTTPrefs* obs); static void formatSlotDiagReply(char* buf, size_t bufsize, int slot_index); diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp new file mode 100644 index 00000000..41e06307 --- /dev/null +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -0,0 +1,760 @@ +#if defined(ESP_PLATFORM) && defined(WITH_MQTT_BRIDGE) + +#include "WebConfigServer.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "WebConfigHtml.h" + +// Placeholder sent instead of stored secrets; POSTs carrying it are dropped +// so an untouched password field never overwrites the stored value. +static const char SECRET_SENTINEL[] = "********"; + +// Keys the web UI may drive through the CLI `set` handlers. Everything else +// is rejected, so a crafted request can't reach arbitrary commands (`erase`, +// `password`, ...) through the batch. +static const char* const ALLOWED_SET_KEYS[] = { + // NodePrefs (radio / node) + "name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay", + "cad", "radio.rxgain", "advert.interval", "flood.advert.interval", + // MQTTPrefs (WiFi / MQTT / misc observer) + "wifi.ssid", "wifi.pwd", "wifi.powersave", + "mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw", + "mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.ntp", "mqtt.owner", "mqtt.email", + "timezone", "timezone.offset", "snmp", "snmp.community", +}; +static const char* const ALLOWED_SLOT_KEYS[] = { + "preset", "server", "port", "username", "password", "token", "topic", "audience", +}; + +static bool isAllowedSetKey(const char* key) { + for (size_t i = 0; i < sizeof(ALLOWED_SET_KEYS) / sizeof(ALLOWED_SET_KEYS[0]); i++) { + if (strcmp(key, ALLOWED_SET_KEYS[i]) == 0) return true; + } + // mqtt<1-6>. + if (memcmp(key, "mqtt", 4) == 0 && key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS) + && key[5] == '.') { + for (size_t i = 0; i < sizeof(ALLOWED_SLOT_KEYS) / sizeof(ALLOWED_SLOT_KEYS[0]); i++) { + if (strcmp(&key[6], ALLOWED_SLOT_KEYS[i]) == 0) return true; + } + } + return false; +} + +static bool isSecretKey(const char* key) { + if (strcmp(key, "wifi.pwd") == 0) return true; + if (memcmp(key, "mqtt", 4) == 0 && key[5] == '.' + && (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true; + return false; +} + +// Constant-time-ish comparison so login timing doesn't leak a prefix match. +static bool fixedTimeEquals(const char* a, const char* b, size_t max_len) { + size_t la = strnlen(a, max_len), lb = strnlen(b, max_len); + uint8_t diff = (la == lb) ? 0 : 1; + for (size_t i = 0; i < max_len; i++) { + char ca = (i < la) ? a[i] : 0; + char cb = (i < lb) ? b[i] : 0; + diff |= (uint8_t)(ca ^ cb); + } + return diff == 0; +} + +// RAII lock; tolerates a null mutex (allocation failure) by not locking. +struct WCLock { + SemaphoreHandle_t h; + explicit WCLock(SemaphoreHandle_t s) : h(s) { if (h) xSemaphoreTake(h, portMAX_DELAY); } + ~WCLock() { if (h) xSemaphoreGive(h); } +}; + +WebConfigServer* WebConfigServer::_active = NULL; + +WebConfigServer::WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks, + const uint8_t* pub_key, const char* fw_ver, + const char* role, const char* board_name) + : _prefs(prefs), _obs(obs), _cb(callbacks), _pub_key(pub_key), + _fw_ver(fw_ver), _role(role), _board_name(board_name) { + _mux = xSemaphoreCreateMutex(); + _active = this; +} + +WebConfigServer::~WebConfigServer() { + if (_active == this) _active = NULL; + if (_mux) vSemaphoreDelete(_mux); +} + +bool WebConfigServer::isRebootPending() { + WebConfigServer* w = _active; + return w != NULL && w->_reboot_at != 0 && w->_batch_reboot && + w->_batch_state == BATCH_DONE; +} + +bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len) { + WebConfigServer* w = _active; + if (w == NULL || w->_mode != MODE_SETUP || w->_stopping) return false; + if (ssid && ssid_len > 0) { + strncpy(ssid, w->_ap_ssid, ssid_len - 1); + ssid[ssid_len - 1] = 0; + } + if (ip && ip_len > 0) { + snprintf(ip, ip_len, "%s", WiFi.softAPIP().toString().c_str()); + } + return true; +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +bool WebConfigServer::startSetupMode(char reply[]) { + if (_mode != MODE_OFF || _stopping) { + strcpy(reply, "Err: webconfig busy"); + return false; + } + // AP_STA (not pure AP) so the WiFi scan for the SSID picker works while + // the AP is up. STA stays unconnected - the bridge won't touch WiFi + // while wifi_ssid is empty, and `start webconfig ap` requires it stopped. + WiFi.mode(WIFI_AP_STA); + snprintf(_ap_ssid, sizeof(_ap_ssid), "MeshCore-Setup-%02X%02X", _pub_key[0], _pub_key[1]); +#ifdef WEBCONFIG_AP_PASSWORD + bool ap_ok = WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD); +#else + bool ap_ok = WiFi.softAP(_ap_ssid); +#endif + if (!ap_ok) { + WiFi.mode(WIFI_OFF); + strcpy(reply, "Err: failed to start AP"); + return false; + } + delay(100); // let the AP netif settle before reading its IP + IPAddress ip = WiFi.softAPIP(); + + _dns = new DNSServer(); + _dns->start(53, "*", ip); // captive portal: every name resolves to us + + createServer(); + _mode = MODE_SETUP; + _was_setup_ap = true; + _last_activity = millis(); + WiFi.scanNetworks(true); // pre-populate the SSID picker + + sprintf(reply, "WebConfig AP started: join '%s' then open http://%s/", _ap_ssid, ip.toString().c_str()); + return true; +} + +bool WebConfigServer::startLanMode(char reply[]) { + if (_mode != MODE_OFF || _stopping) { + strcpy(reply, "Err: webconfig busy"); + return false; + } + if (WiFi.status() != WL_CONNECTED) { + strcpy(reply, "Err: WiFi not connected"); + return false; + } + createServer(); + _mode = MODE_LAN; + _last_activity = millis(); + + int pos = sprintf(reply, "WebConfig started: http://%s/ (admin password login)", + WiFi.localIP().toString().c_str()); + if (heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL) < 60 * 1024) { + sprintf(reply + pos, " WARN: low heap"); + } + return true; +} + +void WebConfigServer::createServer() { + _server = new AsyncWebServer(80); + // iOS caches plain-HTTP GETs aggressively (keyed by URL, surviving even a + // device reflash behind the same IP), which poisons /api/config/result and + // friends with stale responses from earlier sessions. Forbid caching on + // every response; the HTML is small enough to refetch per visit. + DefaultHeaders::Instance().addHeader("Cache-Control", "no-store"); + registerRoutes(); + _server->begin(); +} + +void WebConfigServer::requestStop() { + if (_mode == MODE_OFF && !_stopping) return; + if (_server) _server->end(); + if (_dns) _dns->stop(); + _mode = MODE_OFF; + _stopping = true; + // Deleting an AsyncWebServer with live connections is a known crash source; + // give in-flight responses a grace period before freeing. + _delete_at = millis() + 2000; + if (_delete_at == 0) _delete_at = 1; +} + +void WebConfigServer::finalizeTeardown() { + delete _server; + _server = NULL; + delete _dns; + _dns = NULL; + if (_was_setup_ap) { + WiFi.softAPdisconnect(true); + // Nothing else owns WiFi when we raised the AP: either the node is + // unconfigured, or `start webconfig ap` required the bridge stopped. + if (_obs->wifi_ssid[0] == 0) { + WiFi.mode(WIFI_OFF); + } else { + WiFi.mode(WIFI_STA); + } + _was_setup_ap = false; + } + _stopping = false; + _delete_at = 0; + _reboot_at = 0; + _batch_state = BATCH_IDLE; + _batch_next = 0; + _batch_reboot_armed = false; + _session_token[0] = 0; + _stats_json[0] = 0; + if (_cb) _cb->onWebConfigStopped(); +} + +void WebConfigServer::tick(uint32_t now) { + if (_stopping) { + if (_delete_at && (int32_t)(now - _delete_at) >= 0) finalizeTeardown(); + return; + } + if (_mode == MODE_OFF) return; + + if (_dns) _dns->processNextRequest(); + + if (_batch_state == BATCH_PENDING) drainBatch(now); + + if (_reboot_at && (int32_t)(now - _reboot_at) >= 0) { + Serial.printf("WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback"); + _cb->rebootNow(); // does not return + } + + if ((int32_t)(_diag_until - now) > 0 && (now - _diag_last) >= 1000) { + _diag_last = now; + Serial.printf("WC: diag sta=%d heap=%u batch=%d/%d state=%d\n", + (int)WiFi.softAPgetStationNum(), (unsigned)ESP.getFreeHeap(), + (int)_batch_next, (int)_batch_count, (int)_batch_state); + } + + // Refresh the stats snapshot only while a client is actually polling. + if ((int32_t)(_stats_wanted_until - now) > 0 && (now - _stats_built_at) >= 2000) { + WCLock lock(_mux); + _cb->buildStatsJson(_stats_json, sizeof(_stats_json)); + _stats_built_at = now; + } + + // Idle timeout: only the setup AP auto-stops (a deployed node must not be + // left broadcasting an open AP). LAN mode runs until `stop webconfig`. + if (_mode == MODE_SETUP && WiFi.softAPgetStationNum() == 0 && + (now - _last_activity) > WEBCONFIG_AP_IDLE_TIMEOUT_MS) { + requestStop(); + } +} + +void WebConfigServer::drainBatch(uint32_t now) { + // One command per call, spaced out. Each `set` persists prefs with a flash + // write, and flash writes stall the WiFi task (flash cache off); running a + // whole batch back-to-back starves the softAP of beacons long enough for + // clients (iPhones especially) to drop off mid-save. + if (_batch_next == 0) { + _cb->onConfigBatchStart(); + } else if (_batch_next < _batch_count && (int32_t)(now - _batch_last_cmd) < 25) { + return; // let the WiFi task breathe between flash writes + } + if (_batch_next < _batch_count) { + BatchEntry& e = _batch[_batch_next++]; + e.reply[0] = 0; + uint32_t t0 = millis(); + _cb->execCommand(e.cmd, e.reply); + if (e.reply[0] == 0) strcpy(e.reply, "OK"); + _batch_last_cmd = millis(); + Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count, + e.key, (unsigned long)(_batch_last_cmd - t0)); + if (_batch_next < _batch_count) return; // more commands next tick + } + _cb->onConfigBatchEnd(); + WCLock lock(_mux); + _batch_state = BATCH_DONE; + if (_batch_reboot) { + // Fallback only: the real 3 s reboot timer is armed when the client reads + // /api/config/result (handleConfigResult), so the browser gets its + // confirmation before the AP/WiFi drops. This covers a client that + // disconnected and never polls — generous enough for a phone that got + // bounced off the AP mid-save to rejoin and fetch its confirmation. + _reboot_at = now + 30000; + if (_reboot_at == 0) _reboot_at = 1; + } +} + +// --------------------------------------------------------------------------- +// Routes / auth +// --------------------------------------------------------------------------- + +// Diagnostic trace of every request that reaches the server (async_tcp task). +// Distinguishes "client stopped sending" from "server stopped accepting" when +// a save's confirmation polls go missing on hardware. +static void wcLogReq(AsyncWebServerRequest* r) { + Serial.printf("WC: http %s %s\n", r->methodToString(), r->url().c_str()); +} + +void WebConfigServer::registerRoutes() { + _server->on("/", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleRoot(r); }); + _server->on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleStatus(r); }); + _server->on("/api/presets", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handlePresets(r); }); + _server->on("/api/login", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleLogin(r); }, + NULL, collectBody); + _server->on("/api/logout", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleLogout(r); }); + // NB: plain-string routes match sub-paths too ("/api/config" matches + // "/api/config/result") and handlers run in registration order, so the more + // specific route MUST be registered first or it never fires. This was why + // save confirmations were lost: result polls were answered with config JSON. + _server->on("/api/config/result", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigResult(r); }); + _server->on("/api/config", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigGet(r); }); + _server->on("/api/config", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigPost(r); }, + NULL, collectBody); + _server->on("/api/stats", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleStats(r); }); + _server->on("/api/scan", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleScan(r); }); + _server->on("/api/reboot", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleReboot(r); }); + _server->on("/api/portal/exit", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handlePortalExit(r); }); + _server->onNotFound([this](AsyncWebServerRequest* r) { wcLogReq(r); handleNotFound(r); }); +} + +// Accumulate a small JSON body into request->_tempObject (freed automatically +// by the request destructor). Oversized bodies are left unbuffered and +// rejected in the completion handler. +void WebConfigServer::collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len, + size_t index, size_t total) { + if (total == 0 || total > MAX_BODY) return; + if (index == 0) { + req->_tempObject = malloc(total + 1); + if (req->_tempObject) ((char*)req->_tempObject)[total] = 0; + } + if (req->_tempObject) memcpy((uint8_t*)req->_tempObject + index, data, len); +} + +bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) { + _last_activity = millis(); + if (_mode == MODE_SETUP) return true; // physical proximity implied, nothing configured + if (_mode != MODE_LAN) return false; + if (_session_token[0] == 0) return false; + if (!req->hasHeader("Cookie")) return false; + const String& cookies = req->getHeader("Cookie")->value(); + int idx = cookies.indexOf("wcs="); + if (idx < 0 || (int)cookies.length() < idx + 4 + 32) return false; + String token = cookies.substring(idx + 4, idx + 4 + 32); + uint32_t now = millis(); + if ((uint32_t)(now - _session_last_seen) > WEBCONFIG_SESSION_TTL_MS) return false; + if (!fixedTimeEquals(token.c_str(), _session_token, 32)) return false; + _session_last_seen = now; // sliding expiry + return true; +} + +// --------------------------------------------------------------------------- +// Handlers (async_tcp task - no CLI/prefs writes, no radio access) +// --------------------------------------------------------------------------- + +void WebConfigServer::handleRoot(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + _last_activity = millis(); + if (req->hasHeader("If-None-Match") && + req->getHeader("If-None-Match")->value() == WEBCONFIG_HTML_ETAG) { + req->send(304); + return; + } + AsyncWebServerResponse* res = + req->beginResponse(200, "text/html", WEBCONFIG_HTML_GZ, WEBCONFIG_HTML_GZ_LEN); + res->addHeader("Content-Encoding", "gzip"); + res->addHeader("ETag", WEBCONFIG_HTML_ETAG); + req->send(res); +} + +void WebConfigServer::handleStatus(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + bool authed = checkAuth(req); + + DynamicJsonDocument doc(512); + doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan"; + doc["auth"] = authed; + doc["needs_setup"] = (_obs->wifi_ssid[0] == 0); + doc["name"] = (const char*)_prefs->node_name; + char node_id[17]; + for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]); + doc["node_id"] = node_id; + doc["fw"] = _fw_ver; + doc["role"] = _role; + doc["board"] = _board_name; + doc["uptime_s"] = millis() / 1000; + doc["runtime_slots"] = RUNTIME_MQTT_SLOTS; + doc["max_slots"] = MAX_MQTT_SLOTS; + + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handleLogin(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + _last_activity = millis(); + if (_mode == MODE_SETUP) { // no auth in setup mode + req->send(200, "application/json", "{\"ok\":true}"); + return; + } + uint32_t now = millis(); + if (_login_lock_until && (int32_t)(now - _login_lock_until) < 0) { + req->send(429, "application/json", "{\"error\":\"locked, retry in 30s\"}"); + return; + } + const char* body = (const char*)req->_tempObject; + DynamicJsonDocument doc(256); + if (!body || deserializeJson(doc, body) != DeserializationError::Ok) { + req->send(400, "application/json", "{\"error\":\"bad request\"}"); + return; + } + const char* pwd = doc["password"] | ""; + if (!fixedTimeEquals(pwd, _prefs->password, sizeof(_prefs->password))) { + if (++_login_fails >= 5) { + _login_lock_until = now + 30000; + if (_login_lock_until == 0) _login_lock_until = 1; + _login_fails = 0; + } + req->send(401, "application/json", "{\"error\":\"wrong password\"}"); + return; + } + _login_fails = 0; + _login_lock_until = 0; + for (int i = 0; i < 4; i++) sprintf(&_session_token[i * 8], "%08lx", (unsigned long)esp_random()); + _session_last_seen = now; + + AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}"); + char cookie[80]; + sprintf(cookie, "wcs=%s; HttpOnly; SameSite=Lax; Path=/", _session_token); + res->addHeader("Set-Cookie", cookie); + req->send(res); +} + +void WebConfigServer::handleLogout(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + _session_token[0] = 0; + AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}"); + res->addHeader("Set-Cookie", "wcs=; Max-Age=0; Path=/"); + req->send(res); +} + +void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + + DynamicJsonDocument doc(6144); + { + WCLock lock(_mux); + + JsonObject radio = doc.createNestedObject("radio"); + // round via double so float error doesn't leak into the JSON + // (910.525f would otherwise serialize as 910.5250244) + radio["freq"] = (double)roundf(_prefs->freq * 1000.0f) / 1000.0; + radio["bw"] = (double)roundf(_prefs->bw * 100.0f) / 100.0; + radio["sf"] = _prefs->sf; + radio["cr"] = _prefs->cr; + radio["tx"] = _prefs->tx_power_dbm; + radio["af"] = _prefs->airtime_factor; + radio["rxdelay"] = _prefs->rx_delay_base; + radio["txdelay"] = _prefs->tx_delay_factor; + radio["cad"] = (bool)_prefs->cad_enabled; + radio["rxgain"] = (bool)_prefs->rx_boosted_gain; + radio["name"] = (const char*)_prefs->node_name; + radio["lat"] = _prefs->node_lat; + radio["lon"] = _prefs->node_lon; + radio["advert_interval"] = _prefs->advert_interval * 2; // stored as mins/2 + radio["flood_advert_interval"] = _prefs->flood_advert_interval; // hours + + JsonObject wifi = doc.createNestedObject("wifi"); + wifi["ssid"] = (const char*)_obs->wifi_ssid; + wifi["pwd"] = _obs->wifi_password[0] ? SECRET_SENTINEL : ""; + wifi["powersave"] = _obs->wifi_power_save == 0 ? "min" + : _obs->wifi_power_save == 2 ? "max" : "none"; + + JsonObject mqtt = doc.createNestedObject("mqtt"); + mqtt["origin"] = (const char*)_obs->mqtt_origin; + mqtt["iata"] = (const char*)_obs->mqtt_iata; + mqtt["status"] = (bool)_obs->mqtt_status_enabled; + mqtt["packets"] = (bool)_obs->mqtt_packets_enabled; + mqtt["raw"] = (bool)_obs->mqtt_raw_enabled; + mqtt["tx"] = _obs->mqtt_tx_enabled == 2 ? "advert" + : _obs->mqtt_tx_enabled == 1 ? "on" : "off"; + mqtt["rx"] = (bool)_obs->mqtt_rx_enabled; + mqtt["interval"] = _obs->mqtt_status_interval / 60000; // CLI takes minutes + mqtt["timezone"] = (const char*)_obs->timezone_string; + mqtt["timezone_offset"] = _obs->timezone_offset; + mqtt["ntp"] = (const char*)_obs->mqtt_ntp_server; + mqtt["owner"] = (const char*)_obs->mqtt_owner_public_key; + mqtt["email"] = (const char*)_obs->mqtt_email; + mqtt["snmp"] = (bool)_obs->snmp_enabled; + mqtt["snmp_community"] = (const char*)_obs->snmp_community; + + JsonArray slots = mqtt.createNestedArray("slots"); + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + JsonObject s = slots.createNestedObject(); + s["preset"] = (const char*)_obs->mqtt_slot_preset[i]; + s["server"] = (const char*)_obs->mqtt_slot_host[i]; + s["port"] = _obs->mqtt_slot_port[i]; + s["username"] = (const char*)_obs->mqtt_slot_username[i]; + s["password"] = _obs->mqtt_slot_password[i][0] ? SECRET_SENTINEL : ""; + s["token"] = _obs->mqtt_slot_token[i][0] ? SECRET_SENTINEL : ""; + s["topic"] = (const char*)_obs->mqtt_slot_topic[i]; + s["audience"] = (const char*)_obs->mqtt_slot_audience[i]; + } + } + + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + if (req->contentLength() > MAX_BODY) { + req->send(413, "application/json", "{\"error\":\"body too large\"}"); + return; + } + const char* body = (const char*)req->_tempObject; + DynamicJsonDocument doc(6144); + if (!body || deserializeJson(doc, body) != DeserializationError::Ok) { + req->send(400, "application/json", "{\"error\":\"bad json\"}"); + return; + } + bool reboot_after = doc["reboot"] | false; + JsonObject set = doc["set"]; + + WCLock lock(_mux); + // A DONE batch stays readable until the next POST claims the slot, so a + // client that lost the result response can re-poll instead of failing. + if (_batch_state == BATCH_PENDING) { + req->send(409, "application/json", "{\"error\":\"busy\"}"); + return; + } + + int count = 0; + for (JsonPair kv : set) { + const char* key = kv.key().c_str(); + const char* val = kv.value().as(); + if (!val || !isAllowedSetKey(key)) { + char err[96]; + snprintf(err, sizeof(err), "{\"error\":\"bad key\",\"key\":\"%.32s\"}", key); + req->send(400, "application/json", err); + return; + } + if (isSecretKey(key) && strcmp(val, SECRET_SENTINEL) == 0) continue; // unchanged + if (count >= MAX_BATCH) { + req->send(400, "application/json", "{\"error\":\"too many changes\"}"); + return; + } + BatchEntry& e = _batch[count]; + strncpy(e.key, key, sizeof(e.key) - 1); + e.key[sizeof(e.key) - 1] = 0; + // Build "set ", stripping CR/LF so a value can't smuggle in + // a second command. + int pos = snprintf(e.cmd, sizeof(e.cmd), "set %s ", key); + for (const char* p = val; *p && pos < (int)sizeof(e.cmd) - 1; p++) { + if (*p == '\r' || *p == '\n') continue; + e.cmd[pos++] = *p; + } + e.cmd[pos] = 0; + count++; + } + if (count == 0 && !reboot_after) { + req->send(400, "application/json", "{\"error\":\"no changes\"}"); + return; + } + _batch_count = count; + _batch_next = 0; + _batch_reboot = reboot_after; + _batch_reboot_armed = false; + _batch_state = BATCH_PENDING; // tick() picks it up on the loop task + uint32_t du = millis() + 60000; + if (du == 0) du = 1; + _diag_until = du; + Serial.printf("WC: config POST accepted, %d cmds, reboot=%d\n", count, (int)reboot_after); + + char msg[64]; + snprintf(msg, sizeof(msg), "{\"state\":\"pending\",\"count\":%d}", count); + req->send(202, "application/json", msg); +} + +void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { Serial.println("WC: result read -> 503 (mode off)"); req->send(503); return; } + if (!checkAuth(req)) { Serial.println("WC: result read -> 401"); req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + + // Entry print BEFORE the lock (racy state read is fine for diag): if this + // fires but no branch print follows, the handler is blocked on _mux. + Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state); + WCLock lock(_mux); + if (_batch_state == BATCH_IDLE) { + Serial.println("WC: result read -> idle"); + req->send(200, "application/json", "{\"state\":\"idle\"}"); + return; + } + if (_batch_state == BATCH_PENDING) { + req->send(200, "application/json", "{\"state\":\"pending\"}"); + return; + } + Serial.printf("WC: result read -> done (reboot=%d armed=%d)\n", + (int)_batch_reboot, (int)_batch_reboot_armed); + DynamicJsonDocument doc(6144); + doc["state"] = "done"; + doc["reboot"] = _batch_reboot; + JsonArray results = doc.createNestedArray("results"); + for (int i = 0; i < _batch_count; i++) { + JsonObject r = results.createNestedObject(); + r["key"] = (const char*)_batch[i].key; + r["reply"] = (const char*)_batch[i].reply; + } + // State stays DONE (re-readable) until the next POST claims the slot. + if (_batch_reboot && !_batch_reboot_armed) { + // Confirmation delivered — reboot 3 s from now (replaces the 15 s + // drain-time fallback) so the UI can show its countdown first. Armed + // once; re-reads must not keep pushing the deadline out. + _batch_reboot_armed = true; + _reboot_at = millis() + 3000; + if (_reboot_at == 0) _reboot_at = 1; + } + + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handleStats(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + uint32_t until = millis() + 15000; + if (until == 0) until = 1; + _stats_wanted_until = until; // tick() refreshes the snapshot while polled + + WCLock lock(_mux); + if (_stats_json[0] == 0) { + req->send(200, "application/json", "{\"state\":\"pending\"}"); + return; + } + req->send(200, "application/json", _stats_json); +} + +void WebConfigServer::handleScan(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + + int n = WiFi.scanComplete(); + if (req->hasParam("rescan") && n >= 0) { + WiFi.scanDelete(); + n = WIFI_SCAN_FAILED; + } + if (n == WIFI_SCAN_FAILED) { + WiFi.scanNetworks(true); + req->send(200, "application/json", "{\"state\":\"scanning\"}"); + return; + } + if (n < 0) { // WIFI_SCAN_RUNNING + req->send(200, "application/json", "{\"state\":\"scanning\"}"); + return; + } + DynamicJsonDocument doc(3072); + doc["state"] = "done"; + JsonArray nets = doc.createNestedArray("networks"); + for (int i = 0; i < n && i < 20; i++) { + JsonObject net = nets.createNestedObject(); + net["ssid"] = WiFi.SSID(i); + net["rssi"] = WiFi.RSSI(i); + net["enc"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN; + } + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handlePresets(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + _last_activity = millis(); + DynamicJsonDocument doc(3072); + JsonArray arr = doc.createNestedArray("presets"); + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + const MQTTPresetDef& p = MQTT_PRESETS[i]; + JsonObject o = arr.createNestedObject(); + o["name"] = p.name; + // What the UI must collect for this preset to connect + if (p.topic_style == MQTT_TOPIC_MESHRANK) { + o["needs"] = "token"; + } else if (mqttPresetNeedsSlotCredentials(&p)) { + o["needs"] = "userpass"; + } else { + o["needs"] = "none"; + } + } + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handleReboot(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + _reboot_at = millis() + 1500; + if (_reboot_at == 0) _reboot_at = 1; + req->send(200, "application/json", "{\"ok\":true}"); +} + +void WebConfigServer::handlePortalExit(AsyncWebServerRequest* req) { + // Setup mode only: switch captive probes to native "success" replies so the + // OS sign-in sheet can be dismissed (iOS: "Done") without dropping the WiFi; + // the user then continues at http:/// in their real browser, + // which survives the phone sleeping (the captive sheet does not). + if (_mode != MODE_SETUP) { req->send(404); return; } + _captive_release = true; + _last_activity = millis(); + char body[64]; + snprintf(body, sizeof(body), "{\"ok\":true,\"url\":\"http://%s/\"}", + WiFi.softAPIP().toString().c_str()); + req->send(200, "application/json", body); +} + +void WebConfigServer::handleNotFound(AsyncWebServerRequest* req) { + // Captive-portal probes (/generate_204, /hotspot-detect.html, /ncsi.txt, + // /connecttest.txt, ...) all land here; a redirect to the portal makes the + // phone pop its sign-in sheet. + if (_mode == MODE_SETUP && req->method() == HTTP_GET) { + if (_captive_release) { + // Answer each OS's connectivity check natively so the sheet reports + // success and can be closed. Deliberately does NOT bump _last_activity: + // background probes must not hold the portal open past the idle timeout. + const String& url = req->url(); + if (url.indexOf("generate_204") >= 0 || url.indexOf("gen_204") >= 0) { + req->send(204); // Android + } else if (url.indexOf("hotspot-detect") >= 0 || url.indexOf("success") >= 0) { + req->send(200, "text/html", // Apple CNA + "SuccessSuccess"); + } else if (url.indexOf("ncsi.txt") >= 0) { + req->send(200, "text/plain", "Microsoft NCSI"); // Windows + } else if (url.indexOf("connecttest.txt") >= 0) { + req->send(200, "text/plain", "Microsoft Connect Test"); + } else { + req->send(404); + } + return; + } + _last_activity = millis(); + req->redirect(String("http://") + WiFi.softAPIP().toString() + "/"); + return; + } + req->send(404); +} + +#endif // ESP_PLATFORM && WITH_MQTT_BRIDGE diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h new file mode 100644 index 00000000..1dd5f31f --- /dev/null +++ b/src/helpers/esp32/WebConfigServer.h @@ -0,0 +1,169 @@ +#pragma once + +// Browser-based configuration portal for ESP32 MQTT observer builds. +// +// Two modes: +// - SETUP: open SoftAP + captive portal, raised automatically on first boot +// when no WiFi is configured (wifi_ssid empty), or manually via +// `start webconfig ap`. Save -> reboot; auto-stops after an idle timeout. +// - LAN: bound to the existing STA connection (owned by the MQTT bridge +// task), started via `start webconfig`, admin-password login required. +// +// Concurrency model: AsyncWebServer handlers run on the async_tcp task and +// must never touch the CLI, prefs persistence, or the radio. Config writes +// are marshaled into a single-slot command batch that tick() - called from +// MyMesh::loop() on the Arduino loop task - drains through the existing CLI +// `set` handlers. Prefs-struct reads and batch state are guarded by _mux. +// +// No persisted state: everything here is RAM-only, so prefs-file layouts +// (fleet-critical) are untouched. + +#if defined(ESP_PLATFORM) && defined(WITH_MQTT_BRIDGE) + +#define WITH_WEBCONFIG 1 + +#include +#include +#include + +class AsyncWebServer; +class AsyncWebServerRequest; +class DNSServer; +struct NodePrefs; +struct MQTTPrefs; + +#ifndef WEBCONFIG_AP_IDLE_TIMEOUT_MS + #define WEBCONFIG_AP_IDLE_TIMEOUT_MS (10UL * 60UL * 1000UL) +#endif +#ifndef WEBCONFIG_SESSION_TTL_MS + #define WEBCONFIG_SESSION_TTL_MS (20UL * 60UL * 1000UL) +#endif + +class WebConfigServer { +public: + enum Mode : uint8_t { MODE_OFF = 0, MODE_SETUP, MODE_LAN }; + + class Callbacks { + public: + // Run one CLI command. Called from tick() only (loop task); reply is 160 bytes. + virtual void execCommand(char* cmd, char* reply) = 0; + virtual void rebootNow() = 0; + // Bracket a config batch so bridge restarts triggered by individual + // `set` handlers can be coalesced into one. + virtual void onConfigBatchStart() {} + virtual void onConfigBatchEnd() {} + // Fill buf with the stats JSON snapshot. Called from tick() (loop task). + virtual void buildStatsJson(char* buf, size_t buf_size) = 0; + // Teardown finished (server + DNS freed, WiFi mode restored). + virtual void onWebConfigStopped() {} + }; + + WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks, + const uint8_t* pub_key, const char* fw_ver, + const char* role, const char* board_name); + ~WebConfigServer(); + + // For the device display: true while a setup-mode portal is active. + // Fills the AP SSID and portal IP; either buffer may be NULL to just poll. + // Call from the loop task only (same task that changes the mode). + static bool getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len); + + // For the device display: true once a config save completed and the node is + // about to reboot — ground truth for the user even if the browser lost its + // connection before the confirmation arrived. Loop task only. + static bool isRebootPending(); + + bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal + bool startLanMode(char reply[]); // bind to existing STA connection + void requestStop(); // stop listening now, free after grace period + void tick(uint32_t now); // call every loop iteration + + Mode mode() const { return _mode; } + bool isRunning() const { return _mode != MODE_OFF; } + bool isStopping() const { return _stopping; } + +private: + static const int MAX_BATCH = 24; + static const size_t MAX_BODY = 4096; + enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE }; + struct BatchEntry { + char key[24]; // allowlisted `set` key (echoed back to the UI) + char cmd[160]; // full CLI command (may contain secrets - never echoed) + char reply[160]; + }; + + NodePrefs* _prefs; + MQTTPrefs* _obs; + Callbacks* _cb; + const uint8_t* _pub_key; + const char* _fw_ver; + const char* _role; + const char* _board_name; + + AsyncWebServer* _server = NULL; + DNSServer* _dns = NULL; + SemaphoreHandle_t _mux; + Mode _mode = MODE_OFF; + bool _stopping = false; + bool _was_setup_ap = false; + char _ap_ssid[33] = {0}; + + // Most-recently-created instance, for the display's getSetupInfo() poll. + static WebConfigServer* _active; + + // Command batch: filled by async_tcp under _mux, drained by tick(). + volatile BatchState _batch_state = BATCH_IDLE; + uint8_t _batch_count = 0; + uint8_t _batch_next = 0; // drain progress (one command per tick) + uint32_t _batch_last_cmd = 0; + bool _batch_reboot = false; + bool _batch_reboot_armed = false; + BatchEntry _batch[MAX_BATCH]; + + // LAN-mode session (single slot; new login evicts the old session) + char _session_token[33] = {0}; + uint32_t _session_last_seen = 0; + uint8_t _login_fails = 0; + uint32_t _login_lock_until = 0; + + // Once set (via /api/portal/exit), OS captive probes get native "success" + // replies so the phone's sign-in sheet can be dismissed without dropping the + // WiFi, letting the user continue in their real browser. async_tcp-task only. + volatile bool _captive_release = false; + + // Save-path diagnostics: 1 Hz serial trace for 60 s after each config POST + // (AP station count, heap, batch state) to pinpoint client drops on hardware. + volatile uint32_t _diag_until = 0; + uint32_t _diag_last = 0; + + volatile uint32_t _last_activity = 0; + uint32_t _reboot_at = 0; // 0 = none scheduled + uint32_t _delete_at = 0; // deferred teardown deadline + volatile uint32_t _stats_wanted_until = 0; + uint32_t _stats_built_at = 0; + char _stats_json[1024] = {0}; + + void createServer(); + void registerRoutes(); + void drainBatch(uint32_t now); + void finalizeTeardown(); + bool checkAuth(AsyncWebServerRequest* req); + static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len, + size_t index, size_t total); + + void handleRoot(AsyncWebServerRequest* req); + void handleStatus(AsyncWebServerRequest* req); + void handleLogin(AsyncWebServerRequest* req); + void handleLogout(AsyncWebServerRequest* req); + void handleConfigGet(AsyncWebServerRequest* req); + void handleConfigPost(AsyncWebServerRequest* req); + void handleConfigResult(AsyncWebServerRequest* req); + void handleStats(AsyncWebServerRequest* req); + void handleScan(AsyncWebServerRequest* req); + void handlePresets(AsyncWebServerRequest* req); + void handleReboot(AsyncWebServerRequest* req); + void handlePortalExit(AsyncWebServerRequest* req); + void handleNotFound(AsyncWebServerRequest* req); +}; + +#endif // ESP_PLATFORM && WITH_MQTT_BRIDGE diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 00000000..a9024350 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,954 @@ + + + + + +MeshCore Config + + + +
+ + + + +
+

MeshCore

+
connecting…
+
+ +
+
+ + +
+
+

Admin login

+

Enter this node's admin password (the same one used for remote CLI admin).

+
+
+
+ +
+
+
+
+ + +
+
In the WiFi sign-in popup? It closes if your phone sleeps. + Open in your browser to keep your progress.
+
+ +
+
+

Step 1 · WiFi

+

Connect this node to your WiFi network so it can reach the MQTT servers.

+
+
+ + +
+
+ +
Leave blank for an open network. 2.4 GHz networks only.
+
+ +
Shown on the mesh and in MQTT status messages.
+
+ +
+ +
+
+

Step 2 · Radio

+

Nodes only hear each other on identical radio settings. Pick the preset your local mesh uses.

+
+ +
+
+ +
Maximum legal power varies by region — check local rules.
+
Need settings that aren't listed? Choose Keep current settings, finish setup, then fine-tune in the Advanced editor.
+
+
+ + +
+
+ +
+
+

Step 3 · MQTT

+
+ +
How this observer identifies itself in published status/packet messages. Informational only — not used for topics or authentication. Prefilled with the node name.
+
+ +
Nearest airport code, e.g. DEN. Used in topic paths.
+
+ +
Optional — 64-char hex public key of your companion node. Included in auth JWTs so services that support it can let you claim this node.
+
+ +
Optional — also included in auth JWTs for claiming this node on some services.
+

Servers

+
+
+
+ + +
+
+ +
+
+

Step 4 · Review & save

+
+
+
Saving reboots the node into normal operation. To change settings later, run start webconfig from the serial console.
+
+ + +
+
+
+ +

Advanced editor

+
+ + +
+
+ + + + +
+ +
+
+

Node

+
+
+
+
+
+
+
+

LoRa radio reboot to apply

+
Frequency, bandwidth, SF and CR must match your mesh exactly — a wrong value takes this node off the air until fixed over serial.
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Advanced

+
+
+
+
+
+
CAD (listen before transmit)Channel activity detection +
+
RX boosted gainSX126x receivers only +
+
+
+
0 = off
+
+
0 = off, else 3–168
+
+
+
+ +
+
+

Identity

+
+
How this observer identifies itself in published messages; informational only. Blank = node name.
+
+
+
64-char hex public key of the owner's companion node (optional).
+
+
+
+

Publishing

+
Status messagesPeriodic node status +
+
Packet metadataDecoded packet summaries +
+
Raw packetsFull undecoded frames +
+
RX from MQTTAccept downlink packets +
+
+
+
+
+
+
+
+

Servers

+
+
+
+

Time & SNMP

+
+
Enter none to clear.
+
+
+
+
+
SNMP agentRestart required +
+
+
+
+ +
+
+

WiFi connection

+
Changing WiFi restarts the connection — this page will drop and the node reappears on the new network. Find its new IP from your router or the serial console.
+
+
+ + +
+
+
+
+
+
+

Maintenance

+
+ + +
+
+
+ +
+
+

Device

+
+
+
+

Free heap (KB)

+

Noise floor (dBm)

+
+
+

MQTT servers

+
No data yet.
+
+
+
+
+ + +
+ + + +
+ + +
+
Nearby networks + +
+
+
+ +
+ + +
+
+

Continue in your browser

+
    +
  1. Tap Done (or Cancel) in the corner of this window.
    + If asked, choose Use Without Internet / Stay connected.
  2. +
  3. Open Safari or Chrome on this device.
  4. +
  5. Go to http://192.168.4.1
  6. +
+
Stay connected to the MeshCore-Setup WiFi network. Your progress here isn't carried over — the setup restarts in the browser, where it survives the screen locking.
+

Go back

+
+
+ + +
+
+ +

Rebooting…

+

+

+
+
+ + + +