feat(webconfig): implement web configuration portal for ESP32

This commit is contained in:
agessaman
2026-07-16 17:00:22 -07:00
parent d7a7e1b642
commit dfee21a0c9
16 changed files with 2460 additions and 3 deletions
+1
View File
@@ -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/*
+120
View File
@@ -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);
+45 -1
View File
@@ -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;
+47
View File
@@ -8,6 +8,7 @@
#ifdef WITH_MQTT_BRIDGE
#include <WiFi.h>
#include <helpers/esp32/WebConfigServer.h> // 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();
+120
View File
@@ -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);
+45 -1
View File
@@ -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
};
+47
View File
@@ -8,6 +8,7 @@
#ifdef WITH_MQTT_BRIDGE
#include <WiFi.h>
#include <helpers/esp32/WebConfigServer.h> // 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();
+4 -1
View File
@@ -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}
+<helpers/esp32/WebConfigServer.cpp> ; empty TU unless WITH_MQTT_BRIDGE
[esp32_ota]
lib_deps =
+79
View File
@@ -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 <stdint.h>")
lines.append("#include <pgmspace.h>")
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()
+12
View File
@@ -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 "<server> ok|fail" list).
virtual bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) {
+15
View File
@@ -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] : "";
+31
View File
@@ -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; }
+11
View File
@@ -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);
+760
View File
@@ -0,0 +1,760 @@
#if defined(ESP_PLATFORM) && defined(WITH_MQTT_BRIDGE)
#include "WebConfigServer.h"
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <DNSServer.h>
#include <ArduinoJson.h>
#include <esp_system.h>
#include <esp_heap_caps.h>
#include <helpers/CommonCLI.h>
#include <helpers/MQTTPresets.h>
#include <helpers/bridges/MQTTBridge.h>
#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>.<field>
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<const char*>();
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 <key> <value>", 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://<softAP IP>/ 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
"<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>");
} 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
+169
View File
@@ -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 <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
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
+954
View File
@@ -0,0 +1,954 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
<title>MeshCore Config</title>
<style>
:root{
--bg:#f4f6f8; --card:#fff; --ink:#1a2330; --mut:#66738a; --line:#e2e7ee;
--acc:#2b7de9; --acc-ink:#fff; --ok:#1d9d5f; --err:#d64545; --warn:#c8871a;
--chip:#eef2f7; --in-bg:#fff; --in-line:#c8d2de; --shadow:0 1px 3px rgba(16,28,45,.08);
}
@media (prefers-color-scheme:dark){:root{
--bg:#10151c; --card:#1a212b; --ink:#e6ebf2; --mut:#8b98ab; --line:#2a3442;
--acc:#4a94f0; --chip:#232d3a; --in-bg:#141a22; --in-line:#3a4656; --shadow:0 1px 3px rgba(0,0,0,.4);
}}
*{box-sizing:border-box;margin:0}
html{-webkit-text-size-adjust:100%}
body{font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink);padding-bottom:90px;overflow-x:hidden}
h1{font-size:17px;font-weight:650}
h2{font-size:14px;font-weight:650;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);margin:22px 0 10px}
h2:first-child{margin-top:0}
a{color:var(--acc)}
header{display:flex;align-items:center;gap:10px;padding:12px 16px;background:var(--card);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}
header svg{flex:none}
.hmeta{min-width:0}
.hmeta div{font-size:12px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.badge{margin-left:auto;flex:none;font-size:11px;font-weight:650;padding:3px 9px;border-radius:99px;background:var(--chip);color:var(--mut)}
.badge.setup{background:#f3e8d3;color:#8a5c00}
@media (prefers-color-scheme:dark){.badge.setup{background:#3a2f14;color:#e0b45c}}
main{max-width:640px;margin:0 auto;padding:16px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:var(--shadow);margin-bottom:14px}
.tabs{display:flex;gap:4px;background:var(--chip);padding:4px;border-radius:10px;margin-bottom:14px}
.tabs button{flex:1;border:0;background:none;color:var(--mut);font:inherit;font-size:13.5px;font-weight:600;padding:7px 4px;border-radius:7px;cursor:pointer}
.tabs button.on{background:var(--card);color:var(--ink);box-shadow:var(--shadow)}
.f{margin-bottom:13px}
.f label{display:block;font-size:12.5px;font-weight:600;margin-bottom:4px}
.f .hint{font-size:11.5px;color:var(--mut);margin-top:3px}
input[type=text],input[type=password],input[type=number],input[type=email],select{
width:100%;padding:8px 10px;font:inherit;font-size:16px;color:var(--ink);background:var(--in-bg);
border:1px solid var(--in-line);border-radius:8px;outline:none} /* >=16px: stops iOS zoom-on-focus (sideways-scroll in captive sheet) */
input:focus,select:focus{border-color:var(--acc);box-shadow:0 0 0 2px color-mix(in srgb,var(--acc) 25%,transparent)}
input.dirty,select.dirty{border-color:var(--warn)}
.row{display:flex;gap:10px}.row>.f{flex:1;min-width:0}
.sw{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0}
.sw span b{display:block;font-size:13.5px;font-weight:600}
.sw span i{font-style:normal;font-size:11.5px;color:var(--mut)}
.tgl{position:relative;width:42px;height:24px;flex:none}
.tgl input{position:absolute;opacity:0;width:100%;height:100%;margin:0;cursor:pointer}
.tgl u{position:absolute;inset:0;background:var(--in-line);border-radius:99px;transition:.15s;pointer-events:none}
.tgl u:before{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;background:#fff;border-radius:50%;transition:.15s}
.tgl input:checked+u{background:var(--acc)}
.tgl input:checked+u:before{left:21px}
button.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:0;font:inherit;font-size:14px;font-weight:650;
padding:9px 16px;border-radius:9px;cursor:pointer;background:var(--acc);color:var(--acc-ink)}
button.btn.sec{background:var(--chip);color:var(--ink)}
button.btn:disabled{opacity:.5;cursor:default}
button.btn.sm{font-size:12.5px;padding:6px 10px}
.chip{display:inline-block;font-size:11px;font-weight:650;padding:2px 8px;border-radius:99px;margin-left:6px;vertical-align:1px}
.chip.ok{background:color-mix(in srgb,var(--ok) 15%,transparent);color:var(--ok)}
.chip.err{background:color-mix(in srgb,var(--err) 15%,transparent);color:var(--err)}
.savebar{position:fixed;left:0;right:0;bottom:0;z-index:20;background:var(--card);border-top:1px solid var(--line);
padding:10px 16px calc(10px + env(safe-area-inset-bottom));display:none;align-items:center;gap:12px}
.savebar.show{display:flex}
.savebar b{font-size:13.5px}
.savebar .sp{flex:1}
.slot{border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:10px}
.slot .st{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.slot .st b{font-size:13.5px}
.pill{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:99px;background:var(--chip);color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
.pill.ok{background:color-mix(in srgb,var(--ok) 15%,transparent);color:var(--ok)}
.pill.fail,.pill.disc{background:color-mix(in srgb,var(--err) 15%,transparent);color:var(--err)}
.pill.wait{background:color-mix(in srgb,var(--warn) 15%,transparent);color:var(--warn)}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px}
.tile{background:var(--chip);border-radius:10px;padding:10px 12px}
.tile i{font-style:normal;display:block;font-size:11px;font-weight:650;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
.tile b{font-size:18px;font-weight:650}
.tile s{text-decoration:none;font-size:11.5px;color:var(--mut)}
canvas{width:100%;height:56px;display:block}
.net{display:flex;align-items:center;gap:10px;width:100%;border:0;background:none;color:var(--ink);font:inherit;
padding:10px 6px;border-bottom:1px solid var(--line);cursor:pointer;text-align:left}
.net:last-child{border-bottom:0}
.net .ss{flex:1;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.net small{color:var(--mut)}
/* Inline scan panel (NOT an overlay): the iOS CNA sheet ignores programmatic
blur(), and when the keyboard is up iOS scrolls fixed overlays offscreen to
keep the focused field visible. A panel in normal flow directly under the
SSID field sits in that guaranteed-visible zone instead. */
.scanpanel{border:1px solid var(--line);border-radius:10px;background:var(--card);box-shadow:var(--shadow);margin:-2px 0 13px}
.scanpanel .sp-head{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:650;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);padding:10px 12px 4px}
.scanpanel #scan-list{max-height:45vh;overflow:auto;padding:0 8px 6px}
.steps{display:flex;gap:6px;margin-bottom:16px}
.steps i{flex:1;height:4px;border-radius:2px;background:var(--line)}
.steps i.on{background:var(--acc)}
.toast{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:40;background:var(--ink);color:var(--bg);
font-size:13.5px;font-weight:600;padding:9px 16px;border-radius:9px;opacity:0;transition:.25s;pointer-events:none;max-width:90vw}
.toast.show{opacity:1}
.overlay{position:fixed;inset:0;z-index:50;background:var(--bg);display:none;align-items:center;justify-content:center;text-align:center;padding:24px}
.overlay.show{display:flex}
.spin{width:22px;height:22px;border:3px solid var(--line);border-top-color:var(--acc);border-radius:50%;animation:r 1s linear infinite;display:inline-block}
@keyframes r{to{transform:rotate(360deg)}}
.err-text{color:var(--err);font-size:13px;margin-top:8px;min-height:18px}
.kv{font-size:13.5px;display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)}
.kv:last-child{border-bottom:0}
.kv i{font-style:normal;color:var(--mut)}
.kv b{font-weight:600;text-align:right;overflow:hidden;text-overflow:ellipsis}
.hide{display:none!important}
.note{font-size:12.5px;color:var(--mut);background:var(--chip);border-radius:8px;padding:9px 11px;margin-bottom:13px}
</style>
</head>
<body>
<header>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="var(--acc)" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="2.2" fill="var(--acc)" stroke="none"/>
<path d="M7.7 7.7a6 6 0 0 0 0 8.6M16.3 7.7a6 6 0 0 1 0 8.6M4.9 4.9a10 10 0 0 0 0 14.2M19.1 4.9a10 10 0 0 1 0 14.2"/>
</svg>
<div class="hmeta">
<h1 id="h-name">MeshCore</h1>
<div id="h-sub">connecting&hellip;</div>
</div>
<span class="badge hide" id="h-badge"></span>
</header>
<main>
<!-- ============ LOGIN ============ -->
<section id="v-login" class="hide">
<div class="card">
<h2>Admin login</h2>
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">Enter this node's admin password (the same one used for remote CLI admin).</p>
<form id="login-form">
<div class="f"><label for="login-pwd">Password</label>
<input type="password" id="login-pwd" autocomplete="current-password" autofocus></div>
<button class="btn" type="submit" id="login-btn">Sign in</button>
<div class="err-text" id="login-err"></div>
</form>
</div>
</section>
<!-- ============ SETUP WIZARD ============ -->
<section id="v-wizard" class="hide">
<div class="note" id="cna-note">In the WiFi sign-in popup? It closes if your phone sleeps.
<a href="#" onclick="popOut();return false" style="white-space:nowrap">Open in your browser</a> to keep your progress.</div>
<div class="steps"><i id="stp1" class="on"></i><i id="stp2"></i><i id="stp3"></i><i id="stp4"></i></div>
<div id="wz1">
<div class="card">
<h2>Step 1 &middot; WiFi</h2>
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">Connect this node to your WiFi network so it can reach the MQTT servers.</p>
<div class="f"><label>Network name (SSID)</label>
<div class="row" style="gap:8px">
<input type="text" data-k="wifi.ssid" id="wz-ssid" autocapitalize="off" autocorrect="off" style="flex:1">
<button class="btn sec" type="button" onmousedown="event.preventDefault()" onclick="openScan('wz-ssid')">Scan</button>
</div></div>
<div class="f"><label>Password</label>
<input type="password" data-k="wifi.pwd" autocomplete="off">
<div class="hint">Leave blank for an open network. 2.4&thinsp;GHz networks only.</div></div>
<div class="f"><label>Node name</label>
<input type="text" data-k="name" maxlength="31">
<div class="hint">Shown on the mesh and in MQTT status messages.</div></div>
</div>
<button class="btn" onclick="wzGo(2)">Next: Radio</button>
</div>
<div id="wz2" class="hide">
<div class="card">
<h2>Step 2 &middot; Radio</h2>
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">Nodes only hear each other on identical radio settings. Pick the preset your local mesh uses.</p>
<div class="f"><label>Region preset</label>
<select id="wz-rp" onchange="wzRadioSel(this)"></select>
<div class="hint" id="wz-rp-cur"></div></div>
<div class="f"><label>TX power (dBm)</label>
<input type="number" data-k="tx" min="-9" max="30" style="max-width:120px">
<div class="hint">Maximum legal power varies by region &mdash; check local rules.</div></div>
<div class="note">Need settings that aren't listed? Choose <b>Keep current settings</b>, finish setup, then fine-tune in the <a href="#" onclick="enterApp();return false">Advanced editor</a>.</div>
</div>
<div class="row">
<button class="btn sec" onclick="wzGo(1)">Back</button>
<button class="btn" style="flex:1" onclick="wzGo(3)">Next: MQTT</button>
</div>
</div>
<div id="wz3" class="hide">
<div class="card">
<h2>Step 3 &middot; MQTT</h2>
<div class="f"><label>Observer name (origin)</label>
<input type="text" data-k="mqtt.origin" autocapitalize="off">
<div class="hint">How this observer identifies itself in published status/packet messages. Informational only &mdash; not used for topics or authentication. Prefilled with the node name.</div></div>
<div class="f"><label>IATA region code</label>
<input type="text" data-k="mqtt.iata" maxlength="4" autocapitalize="characters" style="text-transform:uppercase;max-width:120px">
<div class="hint">Nearest airport code, e.g. <b>DEN</b>. Used in topic paths.</div></div>
<div class="f"><label>Owner public key</label>
<input type="text" data-k="mqtt.owner" maxlength="64" autocapitalize="off" autocorrect="off" autocomplete="off">
<div class="hint">Optional &mdash; 64-char hex public key of your companion node. Included in auth JWTs so services that support it can let you claim this node.</div></div>
<div class="f"><label>Owner email</label>
<input type="email" data-k="mqtt.email" autocapitalize="off">
<div class="hint">Optional &mdash; also included in auth JWTs for claiming this node on some services.</div></div>
<h2>Servers</h2>
<div id="wz-slots"></div>
</div>
<div class="row">
<button class="btn sec" onclick="wzGo(2)">Back</button>
<button class="btn" style="flex:1" onclick="wzGo(4)">Review</button>
</div>
</div>
<div id="wz4" class="hide">
<div class="card">
<h2>Step 4 &middot; Review &amp; save</h2>
<div id="wz-review"></div>
</div>
<div class="note">Saving reboots the node into normal operation. To change settings later, run <b>start webconfig</b> from the serial console.</div>
<div class="row">
<button class="btn sec" onclick="wzGo(3)">Back</button>
<button class="btn" style="flex:1" id="wz-save" onclick="wizardSave()">Save &amp; reboot</button>
</div>
<div class="err-text" id="wz-err"></div>
</div>
<p style="text-align:center;margin-top:16px"><a href="#" onclick="enterApp();return false" style="font-size:13px">Advanced editor</a></p>
</section>
<!-- ============ MAIN APP ============ -->
<section id="v-app" class="hide">
<div class="tabs" id="tabs">
<button data-t="radio" class="on">Radio</button>
<button data-t="mqtt">MQTT</button>
<button data-t="wifi">WiFi</button>
<button data-t="stats">Stats</button>
</div>
<div id="t-radio">
<div class="card">
<h2>Node</h2>
<div class="f"><label>Name</label><input type="text" data-k="name" maxlength="31"></div>
<div class="row">
<div class="f"><label>Latitude</label><input type="number" data-k="lat" step="any"></div>
<div class="f"><label>Longitude</label><input type="number" data-k="lon" step="any"></div>
</div>
</div>
<div class="card">
<h2>LoRa radio <span class="chip err hide" id="radio-warn">reboot to apply</span></h2>
<div class="note">Frequency, bandwidth, SF and CR must match your mesh exactly &mdash; a wrong value takes this node off the air until fixed over serial.</div>
<div class="row">
<div class="f"><label>Frequency (MHz)</label><input type="number" data-rg="freq" step="0.001" min="150" max="2500"></div>
<div class="f"><label>Bandwidth (kHz)</label><input type="number" data-rg="bw" step="0.01" min="7" max="500"></div>
</div>
<div class="row">
<div class="f"><label>Spreading factor</label>
<select data-rg="sf"><option>5</option><option>6</option><option>7</option><option>8</option><option>9</option><option>10</option><option>11</option><option>12</option></select></div>
<div class="f"><label>Coding rate</label>
<select data-rg="cr"><option>5</option><option>6</option><option>7</option><option>8</option></select></div>
</div>
<div class="f"><label>TX power (dBm)</label><input type="number" data-k="tx" min="-9" max="30" style="max-width:120px"></div>
</div>
<div class="card">
<h2>Advanced</h2>
<div class="row">
<div class="f"><label>Airtime factor</label><input type="number" data-k="af" step="0.1" min="0"></div>
<div class="f"><label>RX delay base</label><input type="number" data-k="rxdelay" step="0.1" min="0" max="20"></div>
</div>
<div class="f"><label>TX delay factor</label><input type="number" data-k="txdelay" step="0.1" min="0" max="2" style="max-width:120px"></div>
<div class="sw"><span><b>CAD (listen before transmit)</b><i>Channel activity detection</i></span>
<span class="tgl"><input type="checkbox" data-k="cad"><u></u></span></div>
<div class="sw"><span><b>RX boosted gain</b><i>SX126x receivers only</i></span>
<span class="tgl"><input type="checkbox" data-k="radio.rxgain"><u></u></span></div>
<div class="row" style="margin-top:6px">
<div class="f"><label>Local advert (min)</label><input type="number" data-k="advert.interval" min="0" max="240">
<div class="hint">0 = off</div></div>
<div class="f"><label>Flood advert (hrs)</label><input type="number" data-k="flood.advert.interval" min="0" max="168">
<div class="hint">0 = off, else 3&ndash;168</div></div>
</div>
</div>
</div>
<div id="t-mqtt" class="hide">
<div class="card">
<h2>Identity</h2>
<div class="f"><label>Observer name (origin)</label><input type="text" data-k="mqtt.origin" autocapitalize="off">
<div class="hint">How this observer identifies itself in published messages; informational only. Blank = node name.</div></div>
<div class="f"><label>IATA region code</label><input type="text" data-k="mqtt.iata" maxlength="4" autocapitalize="characters" style="text-transform:uppercase;max-width:120px"></div>
<div class="f"><label>Owner public key</label><input type="text" data-k="mqtt.owner" maxlength="64" autocapitalize="off" autocorrect="off">
<div class="hint">64-char hex public key of the owner's companion node (optional).</div></div>
<div class="f"><label>Owner email</label><input type="email" data-k="mqtt.email"></div>
</div>
<div class="card">
<h2>Publishing</h2>
<div class="sw"><span><b>Status messages</b><i>Periodic node status</i></span>
<span class="tgl"><input type="checkbox" data-k="mqtt.status"><u></u></span></div>
<div class="sw"><span><b>Packet metadata</b><i>Decoded packet summaries</i></span>
<span class="tgl"><input type="checkbox" data-k="mqtt.packets"><u></u></span></div>
<div class="sw"><span><b>Raw packets</b><i>Full undecoded frames</i></span>
<span class="tgl"><input type="checkbox" data-k="mqtt.raw"><u></u></span></div>
<div class="sw"><span><b>RX from MQTT</b><i>Accept downlink packets</i></span>
<span class="tgl"><input type="checkbox" data-k="mqtt.rx"><u></u></span></div>
<div class="row" style="margin-top:6px">
<div class="f"><label>TX to MQTT</label>
<select data-k="mqtt.tx"><option value="off">Off</option><option value="on">On</option><option value="advert">Adverts only</option></select></div>
<div class="f"><label>Status interval (min)</label><input type="number" data-k="mqtt.interval" min="1" max="60"></div>
</div>
</div>
<div class="card">
<h2>Servers</h2>
<div id="app-slots"></div>
</div>
<div class="card">
<h2>Time &amp; SNMP</h2>
<div class="f"><label>NTP server</label><input type="text" data-k="mqtt.ntp" autocapitalize="off" placeholder="pool.ntp.org">
<div class="hint">Enter <b>none</b> to clear.</div></div>
<div class="row">
<div class="f"><label>Timezone (POSIX)</label><input type="text" data-k="timezone" autocapitalize="off" placeholder="MST7MDT,M3.2.0,M11.1.0"></div>
<div class="f" style="max-width:110px"><label>UTC offset</label><input type="number" data-k="timezone.offset" min="-12" max="14"></div>
</div>
<div class="sw"><span><b>SNMP agent</b><i>Restart required</i></span>
<span class="tgl"><input type="checkbox" data-k="snmp"><u></u></span></div>
<div class="f"><label>SNMP community</label><input type="text" data-k="snmp.community" autocapitalize="off"></div>
</div>
</div>
<div id="t-wifi" class="hide">
<div class="card">
<h2>WiFi connection</h2>
<div class="note">Changing WiFi restarts the connection &mdash; this page will drop and the node reappears on the new network. Find its new IP from your router or the serial console.</div>
<div class="f"><label>Network name (SSID)</label>
<div class="row" style="gap:8px">
<input type="text" data-k="wifi.ssid" id="app-ssid" autocapitalize="off" autocorrect="off" style="flex:1">
<button class="btn sec" type="button" onmousedown="event.preventDefault()" onclick="openScan('app-ssid')">Scan</button>
</div></div>
<div class="f"><label>Password</label><input type="password" data-k="wifi.pwd" autocomplete="off"></div>
<div class="f"><label>Power save</label>
<select data-k="wifi.powersave"><option value="min">Minimum (default)</option><option value="none">None (lowest latency)</option><option value="max">Maximum (battery)</option></select></div>
</div>
<div class="card">
<h2>Maintenance</h2>
<div class="row">
<button class="btn sec" style="flex:1" onclick="doReboot()">Reboot node</button>
<button class="btn sec" style="flex:1" id="logout-btn" onclick="doLogout()">Sign out</button>
</div>
</div>
</div>
<div id="t-stats" class="hide">
<div class="card">
<h2>Device <span id="stats-age" style="float:right;font-weight:500;text-transform:none;letter-spacing:0"></span></h2>
<div class="tiles" id="tiles"></div>
</div>
<div class="card">
<h2>Free heap (KB)</h2><canvas id="spark-heap" height="56"></canvas>
<h2>Noise floor (dBm)</h2><canvas id="spark-noise" height="56"></canvas>
</div>
<div class="card">
<h2>MQTT servers</h2>
<div id="stat-slots" style="font-size:13.5px;color:var(--mut)">No data yet.</div>
</div>
</div>
</section>
</main>
<!-- save bar -->
<div class="savebar" id="savebar">
<b id="save-n"></b><span class="sp"></span>
<button class="btn sec sm" onclick="revertAll()">Revert</button>
<button class="btn sm" id="save-btn" onclick="saveChanges()">Save changes</button>
</div>
<!-- inline scan panel: moved under whichever SSID field requested it -->
<div class="scanpanel hide" id="scan-panel">
<div class="sp-head">Nearby networks<span style="flex:1"></span>
<button class="btn sec sm" type="button" onmousedown="event.preventDefault()" onclick="startScan(true)">Rescan</button>
<button class="btn sec sm" type="button" onmousedown="event.preventDefault()" onclick="closeScan()">Close</button></div>
<div id="scan-list"><div style="text-align:center;padding:18px"><span class="spin"></span></div></div>
</div>
<div class="toast" id="toast"></div>
<!-- captive pop-out overlay -->
<div class="overlay" id="exit-ov">
<div style="max-width:420px;text-align:left">
<h1 style="margin-bottom:12px;text-align:center">Continue in your browser</h1>
<ol style="font-size:15px;line-height:1.9;padding-left:22px;color:var(--fg)">
<li>Tap <b>Done</b> (or <b>Cancel</b>) in the corner of this window.<br>
<span style="font-size:13px;color:var(--mut)">If asked, choose <b>Use Without Internet</b> / <b>Stay connected</b>.</span></li>
<li>Open Safari or Chrome on this device.</li>
<li>Go to <b style="font-size:17px" id="exit-url">http://192.168.4.1</b></li>
</ol>
<div class="note" style="margin-top:14px">Stay connected to the <b>MeshCore-Setup</b> WiFi network. Your progress here isn't carried over &mdash; the setup restarts in the browser, where it survives the screen locking.</div>
<p style="text-align:center;margin-top:10px"><a href="#" onclick="document.getElementById('exit-ov').classList.remove('show');return false" style="font-size:13px">Go back</a></p>
</div>
</div>
<!-- reboot overlay -->
<div class="overlay" id="reboot-ov">
<div>
<span class="spin" style="width:34px;height:34px"></span>
<h1 style="margin:16px 0 8px" id="reboot-title">Rebooting&hellip;</h1>
<p id="reboot-msg" style="color:var(--mut);max-width:420px"></p>
<p style="margin-top:14px;font-size:26px;font-weight:650" id="reboot-count"></p>
</div>
</div>
<script>
"use strict";
var SENTINEL="********";
var st={mode:"",authed:false,cfg:null,orig:{},dirty:{},presets:[],nslots:6,statsTimer:0,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0};
function $(s){return document.querySelector(s)}
function $$(s){return Array.prototype.slice.call(document.querySelectorAll(s))}
function toast(m){var t=$("#toast");t.textContent=m;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(function(){t.classList.remove("show")},2600)}
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]})}
function api(path,opts){
opts=opts||{};
if(!opts.cache)opts.cache="no-store"; // iOS caches plain-HTTP API GETs across sessions
if(opts.timeout&&window.AbortController){
// The CNA sheet can leave a fetch hanging forever on a dead connection;
// abort so the caller's retry loop keeps moving.
var ctl=new AbortController();opts.signal=ctl.signal;
setTimeout(function(){ctl.abort()},opts.timeout);
}
return fetch(path,opts).then(function(r){
if(r.status===401){showLogin();throw new Error("auth")}
return r.json().then(function(j){if(!r.ok&&r.status!==202)throw new Error(j.error||("HTTP "+r.status));return j});
});
}
function post(path,body){return api(path,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)})}
/* ---------- boot ---------- */
function boot(){
api("/api/status").then(function(s){
st.mode=s.mode;st.authed=s.auth;st.nslots=s.runtime_slots||6;
$("#h-name").textContent=s.name||"MeshCore";
$("#h-sub").textContent=s.role+" · "+s.fw+" · "+s.board;
var b=$("#h-badge");b.classList.remove("hide");
if(s.mode==="setup"){b.textContent="SETUP";b.classList.add("setup")}else{b.textContent="LAN"}
return api("/api/presets").catch(function(){return{presets:[]}});
}).then(function(p){
st.presets=p.presets||[];
if(st.mode==="setup"){showWizard()}
else if(!st.authed){showLogin()}
else{enterApp()}
}).catch(function(e){
if(e.message!=="auth"){$("#h-sub").textContent="connection failed — retrying";setTimeout(boot,2000)}
});
}
function show(v){["#v-login","#v-wizard","#v-app"].forEach(function(id){$(id).classList.add("hide")});$(v).classList.remove("hide");updateSaveBar()}
function showLogin(){show("#v-login")}
/* ---------- login ---------- */
$("#login-form").addEventListener("submit",function(ev){
ev.preventDefault();
var btn=$("#login-btn");btn.disabled=true;$("#login-err").textContent="";
fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({password:$("#login-pwd").value})})
.then(function(r){return r.json().then(function(j){return{s:r.status,j:j}})})
.then(function(x){
btn.disabled=false;
if(x.s===200){st.authed=true;$("#login-pwd").value="";enterApp()}
else $("#login-err").textContent=x.j.error||"login failed";
}).catch(function(){btn.disabled=false;$("#login-err").textContent="connection error"});
});
function doLogout(){post("/api/logout",{}).then(function(){st.authed=false;showLogin()}).catch(function(){})}
/* ---------- config load / bind ---------- */
function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
var c=st.cfg,m,r=c.radio,q=c.mqtt,w=c.wifi;
if((m=k.match(/^mqtt([1-9])\.(\w+)$/))){var s=q.slots[+m[1]-1]||{};var v=s[m[2]];return v==null?"":String(v)}
switch(k){
case"name":return r.name;case"lat":return String(r.lat);case"lon":return String(r.lon);
case"tx":return String(r.tx);case"af":return String(r.af);
case"rxdelay":return String(r.rxdelay);case"txdelay":return String(r.txdelay);
case"cad":return r.cad?"on":"off";case"radio.rxgain":return r.rxgain?"on":"off";
case"advert.interval":return String(r.advert_interval);
case"flood.advert.interval":return String(r.flood_advert_interval);
case"wifi.ssid":return w.ssid;case"wifi.pwd":return w.pwd;case"wifi.powersave":return w.powersave;
case"mqtt.origin":return q.origin;case"mqtt.iata":return q.iata;
case"mqtt.status":return q.status?"on":"off";case"mqtt.packets":return q.packets?"on":"off";
case"mqtt.raw":return q.raw?"on":"off";case"mqtt.tx":return q.tx;case"mqtt.rx":return q.rx?"on":"off";
case"mqtt.interval":return String(q.interval);case"mqtt.ntp":return q.ntp;
case"mqtt.owner":return q.owner;case"mqtt.email":return q.email;
case"timezone":return q.timezone;case"timezone.offset":return String(q.timezone_offset);
case"snmp":return q.snmp?"on":"off";case"snmp.community":return q.snmp_community;
}
return"";
}
function elVal(el){
if(el.type==="checkbox")return el.checked?"on":"off";
return el.value;
}
function setEl(el,v){
if(el.type==="checkbox")el.checked=(v==="on");
else el.value=v;
}
function radioCombo(){
var p={};$$("[data-rg]").forEach(function(el){p[el.dataset.rg]=el.value});
return p.freq+","+p.bw+","+p.sf+","+p.cr;
}
function loadConfig(){
return api("/api/config").then(function(c){
st.cfg=c;st.orig={};st.dirty={};
buildSlots("#app-slots");buildSlots("#wz-slots");
$$("[data-k]").forEach(function(el){
var k=el.dataset.k,v=cfgVal(k);
st.orig[k]=v;setEl(el,v);el.classList.remove("dirty");
var lab=el.closest(".f");var ch=lab&&lab.querySelector(".chip");if(ch)ch.remove();
});
$$("[data-rg]").forEach(function(el){el.value=String(c.radio[el.dataset.rg]);el.classList.remove("dirty")});
st.orig.radio=radioCombo();
for(var i=1;i<=st.nslots;i++){refreshSlotFields($("#app-slots"),i);refreshSlotFields($("#wz-slots"),i)}
updateSaveBar();
});
}
function markDirty(k,v,el){
if(v===st.orig[k]){delete st.dirty[k]}else{st.dirty[k]=v}
if(el)el.classList.toggle("dirty",k in st.dirty);
updateSaveBar();
}
document.addEventListener("input",function(ev){
var el=ev.target;
if(el.dataset.k){
// keep duplicate fields (wizard vs app share keys) in sync
$$('[data-k="'+el.dataset.k+'"]').forEach(function(o){if(o!==el)setEl(o,elVal(el))});
markDirty(el.dataset.k,elVal(el),el);
}else if(el.dataset.rg){
var v=radioCombo();
$$("[data-rg]").forEach(function(o){o.classList.toggle("dirty",v!==st.orig.radio)});
markDirty("radio",v);
var s=$("#wz-rp"); // keep the wizard preset dropdown honest
if(s&&s.options.length){
var m=-1;RADIO_PRESETS.forEach(function(p,i){if(comboEq(presetCombo(p),v))m=i});
s.value=m>=0?String(m):(("radio" in st.dirty)?"keep":s.value);
}
}
});
function updateSaveBar(){
var n=Object.keys(st.dirty).length;
var appVisible=!$("#v-app").classList.contains("hide");
$("#savebar").classList.toggle("show",n>0&&appVisible);
$("#save-n").textContent=n+(n===1?" change":" changes");
}
function revertAll(){loadConfig().then(function(){toast("Reverted")})}
/* ---------- slots ---------- */
function slotFieldHtml(i,f,type,label,hint){
return'<div class="f sf-'+f+'"><label>'+label+'</label><input type="'+type+'" data-k="mqtt'+i+'.'+f+'" autocapitalize="off" autocorrect="off" autocomplete="off">'+(hint?'<div class="hint">'+hint+'</div>':'')+'</div>';
}
function buildSlots(sel){
var host=$(sel);if(!host)return;
var html="";
for(var i=1;i<=st.nslots;i++){
var opts='<option value="none">— none —</option>';
st.presets.forEach(function(p){opts+='<option value="'+esc(p.name)+'">'+esc(p.name)+'</option>'});
opts+='<option value="custom">custom…</option>';
html+='<div class="slot" data-slot="'+i+'">'
+'<div class="st"><b>Slot '+i+'</b></div>'
+'<div class="f"><label>Preset</label><select data-k="mqtt'+i+'.preset">'+opts+'</select></div>'
+slotFieldHtml(i,"token","password","Access token","")
+slotFieldHtml(i,"username","text","Username","")
+slotFieldHtml(i,"password","password","Password","")
+slotFieldHtml(i,"server","text","Server host","")
+slotFieldHtml(i,"port","number","Port","")
+slotFieldHtml(i,"topic","text","Topic template","Placeholders: {iata} {device} {type} {token}")
+slotFieldHtml(i,"audience","text","JWT audience","Optional — blank for user/pass auth")
+'</div>';
}
host.innerHTML=html;
}
function refreshSlotFields(host,i){
if(!host)return;
var card=host.querySelector('[data-slot="'+i+'"]');if(!card)return;
var presetEl=card.querySelector('[data-k="mqtt'+i+'.preset"]');
var name=presetEl.value;
var def=null;st.presets.forEach(function(p){if(p.name===name)def=p});
var needs=name==="custom"?"custom":(def?def.needs:"none");
var showMap={token:needs==="token",username:needs==="userpass"||needs==="custom",
password:needs==="userpass"||needs==="custom",server:needs==="custom",
port:needs==="custom",topic:needs==="custom",audience:needs==="custom"};
Object.keys(showMap).forEach(function(f){
var w=card.querySelector(".sf-"+f);if(w)w.classList.toggle("hide",!showMap[f]);
});
}
document.addEventListener("change",function(ev){
var el=ev.target,m;
if(el.dataset.k&&(m=el.dataset.k.match(/^mqtt([1-9])\.preset$/))){
["#app-slots","#wz-slots"].forEach(function(sel){refreshSlotFields($(sel),+m[1])});
}
});
/* ---------- save / results ---------- */
function chipFor(el,ok,msg){
var lab=el.closest(".f");if(!lab)return;
var old=lab.querySelector(".chip");if(old)old.remove();
var c=document.createElement("span");
c.className="chip "+(ok?"ok":"err");c.textContent=msg;
lab.querySelector("label").appendChild(c);
}
function saveChanges(extra,cb){
var setmap={};
// deterministic order: presets first, so slot credential sets follow their preset
Object.keys(st.dirty).sort(function(a,b){
var pa=/\.preset$/.test(a)?0:1,pb=/\.preset$/.test(b)?0:1;
return pa-pb||a.localeCompare(b);
}).forEach(function(k){setmap[k]=st.dirty[k]});
var body={set:setmap};
if(extra&&extra.reboot)body.reboot=true;
if(Object.keys(setmap).length===0&&!body.reboot){toast("Nothing to save");return}
$("#save-btn").disabled=true;
post("/api/config",body).then(function(){pollResult(cb,0,0,true)})
.catch(function(e){
if(e.message==="auth"){$("#save-btn").disabled=false;if(cb)cb(null,false);return}
// The POST may have landed even though its response was lost (the save
// itself briefly stalls the AP's WiFi) — poll for the result regardless.
pollResult(cb,0,0,false);
});
}
// acked: the node returned 202 for the POST, so the batch WILL be applied and
// (for reboot saves) the node reboots on its own even if we never read the
// result. Lets callers distinguish "confirmation lost" from "save never sent".
function pollResult(cb,errs,idles,acked){
errs=errs||0;idles=idles||0;
function fail(){$("#save-btn").disabled=false;
if(cb)cb(null,acked);else toast(acked?"Saved, but confirmation was lost — reload to verify":"Save failed — check connection and retry")}
api("/api/config/result",{timeout:4000}).then(function(r){
if(r.state==="pending"){setTimeout(function(){pollResult(cb,0,idles,true)},400);return}
if(r.state!=="done"){
// idle: the POST likely never arrived — but give it a moment in case
// it's still in flight after a connection blip
if(idles<4){setTimeout(function(){pollResult(cb,errs,idles+1,acked)},700);return}
fail();return;
}
$("#save-btn").disabled=false;
var fails=0,radioReboot=false;
(r.results||[]).forEach(function(it){
var ok=/^OK/i.test(it.reply);
if(!ok)fails++;
if(it.key==="radio"){
if(/reboot/i.test(it.reply))radioReboot=true;
$$("#v-app [data-rg]").forEach(function(o){o.classList.remove("dirty")});
}
var el=document.querySelector('#v-app [data-k="'+it.key+'"]');
if(el)chipFor(el,ok,ok?"OK":it.reply.replace(/^Err(or)?[:,]?\s*/i,""));
});
$("#radio-warn").classList.toggle("hide",!radioReboot);
if(cb){cb(r);return}
if(fails)toast(fails+" setting(s) rejected — see fields");
else toast("Saved ✓");
st.dirty={};
loadConfigSoft();
}).catch(function(e){
if(e.message==="auth"){fail();return}
// Transient network error: the save itself can knock the phone off the
// AP for a moment (flash writes stall WiFi). Keep polling — the device
// holds the result until we manage to read it.
if(errs<20){setTimeout(function(){pollResult(cb,errs+1,idles,acked)},700);return}
fail();
});
}
function loadConfigSoft(){ // re-sync origin values without clobbering result chips
api("/api/config").then(function(c){
st.cfg=c;
$$("[data-k]").forEach(function(el){var k=el.dataset.k;st.orig[k]=cfgVal(k);setEl(el,st.orig[k]);el.classList.remove("dirty")});
$$("[data-rg]").forEach(function(el){el.value=String(c.radio[el.dataset.rg])});
st.orig.radio=radioCombo();
updateSaveBar();
}).catch(function(){});
}
/* ---------- tabs / app ---------- */
function enterApp(){
show("#v-app");
loadConfig().catch(function(e){if(e.message!=="auth")toast("Failed to load config")});
}
$("#tabs").addEventListener("click",function(ev){
var b=ev.target.closest("button");if(!b)return;
$$("#tabs button").forEach(function(x){x.classList.toggle("on",x===b)});
["radio","mqtt","wifi","stats"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)});
if(b.dataset.t==="stats")startStats();else stopStats();
});
/* ---------- stats ---------- */
function fmtUp(s){var d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);
return d>0?d+"d "+h+"h":(h>0?h+"h "+m+"m":m+"m")}
function tile(i,b,s){return'<div class="tile"><i>'+i+'</i><b>'+b+'</b>'+(s?' <s>'+s+'</s>':'')+'</div>'}
function startStats(){stopStats();pollStats();st.statsTimer=setInterval(pollStats,3000)}
function stopStats(){if(st.statsTimer){clearInterval(st.statsTimer);st.statsTimer=0}}
function pollStats(){
api("/api/stats").then(function(s){
if(s.state==="pending"){$("#stats-age").textContent="collecting…";return}
$("#stats-age").textContent="";
var kb=Math.round(s.heap_free/1024);
$("#tiles").innerHTML=
tile("Uptime",fmtUp(s.uptime_s))+
tile("Battery",(s.batt_mv/1000).toFixed(2),"V")+
tile("Free heap",kb,"KB · min "+Math.round(s.heap_min/1024))+
tile("Max alloc",Math.round(s.heap_max_alloc/1024),"KB")+
tile("Noise floor",s.noise,"dBm")+
tile("Last RSSI",s.rssi,"dBm · SNR "+s.snr)+
tile("Airtime TX",fmtUp(s.airtime_s))+
tile("Airtime RX",fmtUp(s.rx_airtime_s))+
tile("Packets RX",s.recv,"err "+s.rx_err)+
tile("Packets TX",s.sent)+
tile("Flood RX/TX",s.recv_flood+" / "+s.sent_flood)+
tile("Direct RX/TX",s.recv_direct+" / "+s.sent_direct)+
tile("TX queue",s.tx_queue)+
tile("MQTT queue",s.mqtt_queue)+
tile("WiFi RSSI",s.wifi_rssi,"dBm")+
tile("IP",s.ip||"—");
push(st.hist.heap,kb);push(st.hist.noise,s.noise);
spark($("#spark-heap"),st.hist.heap);spark($("#spark-noise"),st.hist.noise);
var sl=s.slots||[];
$("#stat-slots").innerHTML=sl.length?sl.map(function(x){
return'<div class="kv"><i>Slot '+x.n+" · "+esc(x.name)+'</i><b><span class="pill '+esc(x.state)+'">'+esc(x.state)+'</span> '+x.ok+' ok / '+x.err+' err</b></div>';
}).join(""):"No servers configured.";
}).catch(function(){});
}
function push(a,v){a.push(v);if(a.length>60)a.shift()}
function spark(cv,data){
var dpr=window.devicePixelRatio||1,w=cv.clientWidth,h=56;
cv.width=w*dpr;cv.height=h*dpr;
var g=cv.getContext("2d");g.scale(dpr,dpr);g.clearRect(0,0,w,h);
if(data.length<2)return;
var mn=Math.min.apply(null,data),mx=Math.max.apply(null,data);
if(mx===mn){mx+=1;mn-=1}
g.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue("--acc").trim()||"#2b7de9";
g.lineWidth=2;g.lineJoin="round";g.beginPath();
data.forEach(function(v,i){
var x=i/(data.length-1)*(w-4)+2,y=h-4-(v-mn)/(mx-mn)*(h-10);
i?g.lineTo(x,y):g.moveTo(x,y);
});
g.stroke();
g.fillStyle=g.strokeStyle;g.font="10px system-ui";g.textAlign="right";
g.fillText(data[data.length-1],w-2,10);
}
/* ---------- wifi scan ---------- */
function dropKeyboard(target){
var el=target&&document.getElementById(target);if(el)el.blur();
var ae=document.activeElement;
if(ae&&ae!==document.body&&ae.blur)ae.blur();
}
function openScan(target){
st.scanTarget=target;
// Move the panel into the flow right below the target SSID field: iOS
// keeps the focused field visible above the keyboard, so the panel under
// it stays visible even when the CNA sheet refuses to drop the keyboard.
var el=document.getElementById(target),panel=$("#scan-panel");
var f=el&&el.closest(".f");
if(f&&f.parentNode)f.parentNode.insertBefore(panel,f.nextSibling);
panel.classList.remove("hide");
// best-effort keyboard drop (works in real Safari; the CNA sheet ignores it)
dropKeyboard(target);
setTimeout(function(){dropKeyboard(target)},150);
startScan(false)}
function closeScan(){$("#scan-panel").classList.add("hide");clearTimeout(st.scanTimer)}
function startScan(force){
$("#scan-list").innerHTML='<div style="text-align:center;padding:18px"><span class="spin"></span></div>';
pollScan(force?"/api/scan?rescan=1":"/api/scan");
}
function bars(rssi){var n=rssi>-55?4:rssi>-67?3:rssi>-78?2:1;var o="";
for(var i=1;i<=4;i++)o+='<span style="display:inline-block;width:3px;margin-right:1px;border-radius:1px;height:'+(3+i*3)+'px;background:'+(i<=n?"var(--acc)":"var(--line)")+'"></span>';
return'<span style="display:inline-flex;align-items:flex-end;height:15px">'+o+"</span>"}
function pollScan(url){
api(url).then(function(r){
if(r.state==="scanning"){st.scanTimer=setTimeout(function(){pollScan("/api/scan")},1500);return}
var nets=(r.networks||[]).filter(function(n){return n.ssid});
nets.sort(function(a,b){return b.rssi-a.rssi});
var seen={};nets=nets.filter(function(n){if(seen[n.ssid])return false;seen[n.ssid]=1;return true});
$("#scan-list").innerHTML=nets.length?nets.map(function(n){
return'<button type="button" class="net" data-ssid="'+esc(n.ssid)+'">'+bars(n.rssi)
+'<span class="ss">'+esc(n.ssid)+"</span>"+(n.enc?"&#128274;":"")+"<small>"+n.rssi+"</small></button>";
}).join(""):'<p style="color:var(--mut);padding:12px">No networks found.</p>';
}).catch(function(){$("#scan-list").innerHTML='<p style="color:var(--mut);padding:12px">Scan failed.</p>'});
}
$("#scan-list").addEventListener("click",function(ev){
var b=ev.target.closest(".net");if(!b)return;
var el=document.getElementById(st.scanTarget);
el.value=b.dataset.ssid;
el.dispatchEvent(new Event("input",{bubbles:true}));
closeScan();
});
/* ---------- wizard ---------- */
// Regional radio presets, baked in because the setup AP has no internet.
// Source: https://api.meshcore.nz/api/v1/config (config.meshcore.io), Jul 2026.
// f=MHz, bw=kHz — submitted as `set radio f,bw,sf,cr`.
var RADIO_PRESETS=[
{t:"USA/Canada (Recommended)",f:"910.525",bw:"62.5",sf:"7",cr:"5"},
{t:"Australia",f:"915.800",bw:"250",sf:"10",cr:"5"},
{t:"Australia (Narrow)",f:"916.575",bw:"62.5",sf:"7",cr:"8"},
{t:"Australia (Mid)",f:"915.075",bw:"125",sf:"9",cr:"5"},
{t:"Australia: SA, WA",f:"923.125",bw:"62.5",sf:"8",cr:"8"},
{t:"Australia: QLD",f:"923.125",bw:"62.5",sf:"8",cr:"5"},
{t:"Brazil",f:"923.125",bw:"62.5",sf:"8",cr:"8"},
{t:"EU/UK (Narrow)",f:"869.618",bw:"62.5",sf:"8",cr:"8"},
{t:"EU/UK (Deprecated)",f:"869.525",bw:"250",sf:"11",cr:"5"},
{t:"Czech Republic (Narrow)",f:"869.432",bw:"62.5",sf:"7",cr:"5"},
{t:"EU 433MHz (Long Range)",f:"433.650",bw:"250",sf:"11",cr:"5"},
{t:"EU 433MHz (Narrow)",f:"433.650",bw:"62.5",sf:"8",cr:"8"},
{t:"Netherlands",f:"869.618",bw:"62.5",sf:"7",cr:"5"},
{t:"New Zealand",f:"917.375",bw:"250",sf:"11",cr:"5"},
{t:"New Zealand (Narrow)",f:"917.375",bw:"62.5",sf:"7",cr:"5"},
{t:"Portugal 433",f:"433.375",bw:"62.5",sf:"9",cr:"6"},
{t:"Portugal 868",f:"869.618",bw:"62.5",sf:"7",cr:"6"},
{t:"Switzerland",f:"869.618",bw:"62.5",sf:"8",cr:"8"},
{t:"Vietnam (Narrow)",f:"920.250",bw:"62.5",sf:"8",cr:"5"},
{t:"Vietnam (Deprecated)",f:"920.250",bw:"250",sf:"11",cr:"5"}
];
function presetCombo(p){return p.f+","+p.bw+","+p.sf+","+p.cr}
function comboEq(a,b){ // numeric compare: "62.5"=="62.50", "250"=="250.0"
if(!a||!b)return false;
var x=a.split(","),y=b.split(",");
for(var i=0;i<4;i++)if(Number(x[i])!==Number(y[i]))return false;
return true;
}
function presetDesc(p){return p.f+" MHz &middot; BW"+p.bw+" &middot; SF"+p.sf+" &middot; CR"+p.cr}
function wzRadioFill(){
var sel=$("#wz-rp"),cur=st.orig.radio||"";
var html='<option value="">Select your region&hellip;</option>';
var match=-1;
RADIO_PRESETS.forEach(function(p,i){
if(comboEq(presetCombo(p),cur))match=i;
html+='<option value="'+i+'">'+esc(p.t)+' &mdash; '+presetDesc(p)+'</option>';
});
html+='<option value="keep">Keep current settings</option>';
sel.innerHTML=html;
// preselect when the node already matches a known preset; otherwise force a choice
if(match>=0)sel.value=String(match);
var c=(st.cfg&&st.cfg.radio)||{};
$("#wz-rp-cur").innerHTML="Currently: <b>"+esc(c.freq)+" MHz &middot; BW"+esc(c.bw)+" &middot; SF"+esc(c.sf)+" &middot; CR"+esc(c.cr)+"</b>"
+(match<0?" (doesn't match a regional preset)":"");
sel.classList.remove("dirty");
}
function wzRadioSel(sel){
var v=sel.value,combo;
if(v===""||v==="keep"){combo=st.orig.radio}
else{var p=RADIO_PRESETS[+v];combo=presetCombo(p)}
// drive the shared `radio` key through the same path as the advanced editor
$$("[data-rg]").forEach(function(el){
var parts=combo.split(","),idx={freq:0,bw:1,sf:2,cr:3}[el.dataset.rg];
el.value=parts[idx];
el.classList.toggle("dirty",combo!==st.orig.radio);
});
sel.classList.toggle("dirty",combo!==st.orig.radio);
markDirty("radio",combo);
}
function showWizard(){
show("#v-wizard");
loadConfig().then(wzRadioFill).catch(function(){});
}
function wzGo(n){
[1,2,3,4].forEach(function(i){
$("#wz"+i).classList.toggle("hide",i!==n);
$("#stp"+i).classList.toggle("on",i<=n);
});
if(n===3){
// Prefill observer name with the node name — only while still blank, so
// an existing config or a manual edit is never clobbered.
var o=$('#wz3 [data-k="mqtt.origin"]');
var nm=st.dirty.name||st.orig.name||"";
if(o&&!o.value&&nm){o.value=nm;o.dispatchEvent(new Event("input",{bubbles:true}))}
}
if(n===4)buildReview();
window.scrollTo(0,0);
}
function popOut(){
// Tell the node to start answering OS captive probes with "success" so the
// sign-in sheet can be dismissed without dropping the WiFi, then walk the
// user through reopening the portal in their real browser.
api("/api/portal/exit",{method:"POST"}).then(function(r){
if(r&&r.url)$("#exit-url").textContent=r.url;
$("#exit-ov").classList.add("show");
}).catch(function(){toast("Couldn't reach the node — try again")});
}
function buildReview(){
var rows=[],d=st.dirty;
function row(l,v){rows.push('<div class="kv"><i>'+l+"</i><b>"+esc(v)+"</b></div>")}
row("WiFi network",d["wifi.ssid"]||st.orig["wifi.ssid"]||"(not set)");
row("WiFi password",d["wifi.pwd"]?"••••••":(st.orig["wifi.pwd"]?"(unchanged)":"(open network)"));
if(d.name||st.orig.name)row("Node name",d.name||st.orig.name);
var rc=d.radio||st.orig.radio||"",rp=null;
RADIO_PRESETS.forEach(function(p){if(comboEq(presetCombo(p),rc))rp=p});
row("Radio",(rp?rp.t+" — ":"")+rc.split(",").join(" / ")+(d.radio?"":" (unchanged)"));
if(d.tx)row("TX power",d.tx+" dBm");
row("Observer name",d["mqtt.origin"]||st.orig["mqtt.origin"]||"(not set)");
row("IATA",(d["mqtt.iata"]||st.orig["mqtt.iata"]||"(not set)").toUpperCase());
var own=d["mqtt.owner"]||st.orig["mqtt.owner"];
if(own)row("Owner key",own.length>12?own.slice(0,12)+"…":own);
var em=d["mqtt.email"]||st.orig["mqtt.email"];
if(em)row("Owner email",em);
for(var i=1;i<=st.nslots;i++){
var p=d["mqtt"+i+".preset"]||st.orig["mqtt"+i+".preset"];
if(p&&p!=="none")row("Slot "+i,p);
}
var extra=Object.keys(d).filter(function(k){return!/^(wifi\.|name$|radio$|tx$|mqtt\.origin|mqtt\.iata|mqtt\.owner|mqtt\.email|mqtt[1-9]\.)/.test(k)}).length;
if(extra)row("Other changes",extra+" setting(s)");
$("#wz-review").innerHTML=rows.join("");
}
function wizardSave(){
if(!st.dirty["wifi.ssid"]&&!st.orig["wifi.ssid"]){$("#wz-err").textContent="Please set a WiFi network (step 1).";return}
if($("#wz-rp").value===""){$("#wz-err").textContent="Please choose a radio preset (step 2) — nodes ship with non-US defaults.";return}
$("#wz-err").textContent="";
var b=$("#wz-save");b.disabled=true;
saveChanges({reboot:true},function(r,acked){
b.disabled=false;
if(!r){
if(acked){
// The node accepted the batch (202): it applies the settings and
// reboots on its own even though we couldn't read the confirmation.
showReboot("The confirmation reply was lost, but the node accepted the settings — its screen shows “Config saved!” and it reboots by itself within 30 seconds. Reconnect your phone or laptop to your normal WiFi. Find the node's IP on your router, or run “start webconfig” over serial to manage it again.",
false,"Settings sent ✓ — node rebooting");
st.dirty={};
return;
}
$("#wz-err").textContent="Couldn't reach the node to save. Check this device is still on the MeshCore-Setup WiFi, then retry.";
return;
}
var fails=(r.results||[]).filter(function(x){return!/^OK/i.test(x.reply)});
if(fails.length){
$("#wz-err").textContent=fails.map(function(x){return x.key+": "+x.reply}).join(" · ");
return;
}
showReboot("The node is joining “"+(st.dirty["wifi.ssid"]||st.orig["wifi.ssid"])
+"”. Reconnect your phone or laptop to your normal WiFi. Find the node's IP on your router, or run “start webconfig” over serial to manage it again.",
false,"Settings saved ✓ — rebooting in a moment");
st.dirty={};
});
}
/* ---------- reboot ---------- */
function doReboot(){
if(!confirm("Reboot this node now?"))return;
post("/api/reboot",{}).then(function(){
showReboot("The node is restarting. This page will try to reconnect automatically.",true);
}).catch(function(e){if(e.message!=="auth")toast("Reboot failed: "+e.message)});
}
function showReboot(msg,reconnect,title){
$("#reboot-title").textContent=title||"Rebooting…";
$("#reboot-msg").textContent=msg;
$("#reboot-ov").classList.add("show");
var n=30;$("#reboot-count").textContent=n;
var t=setInterval(function(){
n--;$("#reboot-count").textContent=n>0?n:"";
if(n<=5&&reconnect){
fetch("/api/status",{cache:"no-store"}).then(function(r){if(r.ok){clearInterval(t);location.reload()}}).catch(function(){});
}
if(n<=0){clearInterval(t);
$("#reboot-count").innerHTML=reconnect?'<a href="/">Reload</a>':"You can close this page.";
}
},1000);
}
boot();
</script>
</body>
</html>