Jelajahi Sumber

feat(web,mqtt): add repeater dashboard, MQTT settings, and stabilise HTTPS/TLS startup

Jared Dohrman 4 bulan lalu
induk
melakukan
b5374a705a

+ 3 - 1
boards/t_beams3_supreme.json

@@ -7,6 +7,7 @@
       },
       "core": "esp32",
       "extra_flags": [
+        "-DBOARD_HAS_PSRAM",
         "-DARDUINO_USB_MODE=1",
         "-DARDUINO_USB_CDC_ON_BOOT=1",
         "-DARDUINO_RUNNING_CORE=1",
@@ -15,6 +16,7 @@
       "f_cpu": "240000000L",
       "f_flash": "80000000L",
       "flash_mode": "qio",
+      "psram_type": "qspi",
       "hwids": [
         [
           "0x303A",
@@ -48,4 +50,4 @@
     },
     "url": "https://www.lilygo.cc/products/t-beamsupreme-m",
     "vendor": "LilyGo"
-  }
+  }

+ 28 - 2
examples/companion_radio/MyMesh.cpp

@@ -185,6 +185,29 @@ static const char* getWifiStateLabel(wl_status_t status) {
       return "unknown";
   }
 }
+
+static int getWifiQualityPercent(int rssi_dbm) {
+  if (rssi_dbm <= -100) {
+    return 0;
+  }
+  if (rssi_dbm >= -50) {
+    return 100;
+  }
+  return 2 * (rssi_dbm + 100);
+}
+
+static const char* getWifiQualityLabel(int rssi_dbm) {
+  if (rssi_dbm >= -60) {
+    return "excellent";
+  }
+  if (rssi_dbm >= -67) {
+    return "good";
+  }
+  if (rssi_dbm >= -75) {
+    return "fair";
+  }
+  return "poor";
+}
 #endif
 
 void MyMesh::writeOKFrame() {
@@ -2049,8 +2072,11 @@ void MyMesh::checkCLIRescueCmd() {
       if (_prefs.wifi_ssid[0] == 0) {
         Serial.println("  > ssid:- status:off code:255 state:unconfigured");
       } else if (status == WL_CONNECTED) {
-        Serial.printf("  > ssid:%s status:connected code:%d state:%s ip:%s\n", _prefs.wifi_ssid,
-                      static_cast<int>(status), getWifiStateLabel(status), WiFi.localIP().toString().c_str());
+        const int rssi_dbm = WiFi.RSSI();
+        Serial.printf("  > ssid:%s status:connected code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s\n",
+                      _prefs.wifi_ssid, static_cast<int>(status), getWifiStateLabel(status),
+                      WiFi.localIP().toString().c_str(), rssi_dbm, getWifiQualityPercent(rssi_dbm),
+                      getWifiQualityLabel(rssi_dbm));
       } else {
         const char* overall = (status == WL_IDLE_STATUS) ? "connecting" : "disconnected";
         Serial.printf("  > ssid:%s status:%s code:%d state:%s\n", _prefs.wifi_ssid, overall,

+ 9 - 6
examples/simple_repeater/MyMesh.cpp

@@ -1081,16 +1081,16 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) {
 #endif
 }
 
-void MyMesh::formatStatsReply(char *reply) {
-  StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
+void MyMesh::formatStatsReply(char *reply, size_t reply_size) {
+  StatsFormatHelper::formatCoreStats(reply, reply_size, board, *_ms, _err_flags, _mgr);
 }
 
-void MyMesh::formatRadioStatsReply(char *reply) {
-  StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
+void MyMesh::formatRadioStatsReply(char *reply, size_t reply_size) {
+  StatsFormatHelper::formatRadioStats(reply, reply_size, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
 }
 
-void MyMesh::formatPacketStatsReply(char *reply) {
-  StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), 
+void MyMesh::formatPacketStatsReply(char *reply, size_t reply_size) {
+  StatsFormatHelper::formatPacketStats(reply, reply_size, radio_driver, getNumSentFlood(), getNumSentDirect(),
                                        getNumRecvFlood(), getNumRecvDirect());
 }
 
@@ -1439,6 +1439,9 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size)
       matches_exact("start ota") ||
       matches_exact("get wifi.status") ||
       matches_exact("get wifi.powersaving") ||
+      matches_exact("stats-core") ||
+      matches_exact("stats-radio") ||
+      matches_exact("stats-packets") ||
       matches_exact("memory") ||
       matches_exact("get mqtt.iata") ||
       matches_exact("get mqtt.owner") ||

+ 3 - 3
examples/simple_repeater/MyMesh.h

@@ -212,9 +212,9 @@ public:
   void setTxPower(int8_t power_dbm) override;
   void formatNeighborsReply(char *reply) override;
   void removeNeighbor(const uint8_t* pubkey, int key_len) override;
-  void formatStatsReply(char *reply) override;
-  void formatRadioStatsReply(char *reply) override;
-  void formatPacketStatsReply(char *reply) override;
+  void formatStatsReply(char *reply, size_t reply_size) override;
+  void formatRadioStatsReply(char *reply, size_t reply_size) override;
+  void formatPacketStatsReply(char *reply, size_t reply_size) override;
   void formatMemoryReply(char *reply, size_t reply_size) override;
 
   mesh::LocalIdentity& getSelfId() override { return self_id; }

+ 6 - 6
examples/simple_room_server/MyMesh.cpp

@@ -750,16 +750,16 @@ void MyMesh::clearStats() {
   ((SimpleMeshTables *)getTables())->resetStats();
 }
 
-void MyMesh::formatStatsReply(char *reply) {
-  StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
+void MyMesh::formatStatsReply(char *reply, size_t reply_size) {
+  StatsFormatHelper::formatCoreStats(reply, reply_size, board, *_ms, _err_flags, _mgr);
 }
 
-void MyMesh::formatRadioStatsReply(char *reply) {
-  StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
+void MyMesh::formatRadioStatsReply(char *reply, size_t reply_size) {
+  StatsFormatHelper::formatRadioStats(reply, reply_size, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
 }
 
-void MyMesh::formatPacketStatsReply(char *reply) {
-  StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), 
+void MyMesh::formatPacketStatsReply(char *reply, size_t reply_size) {
+  StatsFormatHelper::formatPacketStats(reply, reply_size, radio_driver, getNumSentFlood(), getNumSentDirect(),
                                        getNumRecvFlood(), getNumRecvDirect());
 }
 

+ 3 - 3
examples/simple_room_server/MyMesh.h

@@ -193,9 +193,9 @@ public:
   void formatNeighborsReply(char *reply) override {
     strcpy(reply, "not supported");
   }
-  void formatStatsReply(char *reply) override;
-  void formatRadioStatsReply(char *reply) override;
-  void formatPacketStatsReply(char *reply) override;
+  void formatStatsReply(char *reply, size_t reply_size) override;
+  void formatRadioStatsReply(char *reply, size_t reply_size) override;
+  void formatPacketStatsReply(char *reply, size_t reply_size) override;
   void formatMemoryReply(char *reply, size_t reply_size) override;
 
   mesh::LocalIdentity& getSelfId() override { return self_id; }

+ 6 - 6
examples/simple_sensor/SensorMesh.cpp

@@ -820,16 +820,16 @@ void SensorMesh::setTxPower(int8_t power_dbm) {
   radio_set_tx_power(power_dbm);
 }
 
-void SensorMesh::formatStatsReply(char *reply) {
-  StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
+void SensorMesh::formatStatsReply(char *reply, size_t reply_size) {
+  StatsFormatHelper::formatCoreStats(reply, reply_size, board, *_ms, _err_flags, _mgr);
 }
 
-void SensorMesh::formatRadioStatsReply(char *reply) {
-  StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
+void SensorMesh::formatRadioStatsReply(char *reply, size_t reply_size) {
+  StatsFormatHelper::formatRadioStats(reply, reply_size, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
 }
 
-void SensorMesh::formatPacketStatsReply(char *reply) {
-  StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), 
+void SensorMesh::formatPacketStatsReply(char *reply, size_t reply_size) {
+  StatsFormatHelper::formatPacketStats(reply, reply_size, radio_driver, getNumSentFlood(), getNumSentDirect(),
                                        getNumRecvFlood(), getNumRecvDirect());
 }
 

+ 3 - 3
examples/simple_sensor/SensorMesh.h

@@ -70,9 +70,9 @@ public:
   void formatNeighborsReply(char *reply) override {
     strcpy(reply, "not supported");
   }
-  void formatStatsReply(char *reply) override;
-  void formatRadioStatsReply(char *reply) override;
-  void formatPacketStatsReply(char *reply) override;
+  void formatStatsReply(char *reply, size_t reply_size) override;
+  void formatRadioStatsReply(char *reply, size_t reply_size) override;
+  void formatPacketStatsReply(char *reply, size_t reply_size) override;
   void formatMemoryReply(char *reply, size_t reply_size) override;
   mesh::LocalIdentity& getSelfId() override { return self_id; }
   void saveIdentity(const mesh::LocalIdentity& new_id) override;

+ 1 - 0
platformio.ini

@@ -48,6 +48,7 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
 build_src_filter =
   +<*.cpp>
   +<helpers/*.cpp>
+  +<helpers/web/*.cpp>
   +<helpers/radiolib/*.cpp>
   +<helpers/bridges/BridgeBase.cpp>
   +<helpers/ui/MomentaryButton.cpp>

+ 4 - 1
src/Dispatcher.cpp

@@ -88,6 +88,9 @@ void Dispatcher::loop() {
       total_air_time += t;
       //Serial.print("  airtime="); Serial.println(t);
 
+      // A completed transmit means the radio recovered from any prior CAD stall.
+      _err_flags &= ~ERR_EVENT_CAD_TIMEOUT;
+
       updateTxBudget();
 
       if (t > tx_budget_ms) {
@@ -386,4 +389,4 @@ unsigned long Dispatcher::futureMillis(int millis_from_now) const {
   return _ms->getMillis() + millis_from_now;
 }
 
-}
+}

+ 7 - 7
src/helpers/CommonCLI.cpp

@@ -876,13 +876,13 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
     } else if (sender_timestamp == 0 && memcmp(command, "log", 3) == 0) {
       _callbacks->dumpLogFile();
       strcpy(reply, "   EOF");
-    } else if (sender_timestamp == 0 && memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) {
-      _callbacks->formatPacketStatsReply(reply);
-    } else if (sender_timestamp == 0 && memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) {
-      _callbacks->formatRadioStatsReply(reply);
-    } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) {
-      _callbacks->formatStatsReply(reply);
-    } else if (sender_timestamp == 0 && memcmp(command, "memory", 6) == 0 && (command[6] == 0 || command[6] == ' ')) {
+    } else if (memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) {
+      _callbacks->formatPacketStatsReply(reply, 160);
+    } else if (memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) {
+      _callbacks->formatRadioStatsReply(reply, 160);
+    } else if (memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) {
+      _callbacks->formatStatsReply(reply, 160);
+    } else if (memcmp(command, "memory", 6) == 0 && (command[6] == 0 || command[6] == ' ')) {
       _callbacks->formatMemoryReply(reply, 160);
     } else {
       strcpy(reply, "Unknown command");

+ 3 - 3
src/helpers/CommonCLI.h

@@ -80,9 +80,9 @@ public:
   virtual void removeNeighbor(const uint8_t* pubkey, int key_len) {
     // no op by default
   };
-  virtual void formatStatsReply(char *reply) = 0;
-  virtual void formatRadioStatsReply(char *reply) = 0;
-  virtual void formatPacketStatsReply(char *reply) = 0;
+  virtual void formatStatsReply(char *reply, size_t reply_size) = 0;
+  virtual void formatRadioStatsReply(char *reply, size_t reply_size) = 0;
+  virtual void formatPacketStatsReply(char *reply, size_t reply_size) = 0;
   virtual void formatMemoryReply(char *reply, size_t reply_size) = 0;
   virtual mesh::LocalIdentity& getSelfId() = 0;
   virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0;

+ 9 - 6
src/helpers/StatsFormatHelper.h

@@ -5,12 +5,13 @@
 
 class StatsFormatHelper {
 public:
-  static void formatCoreStats(char* reply, 
-                             mesh::MainBoard& board, 
-                             mesh::MillisecondClock& ms, 
+  static void formatCoreStats(char* reply,
+                             size_t reply_size,
+                             mesh::MainBoard& board,
+                             mesh::MillisecondClock& ms,
                              uint16_t err_flags,
                              mesh::PacketManager* mgr) {
-    sprintf(reply, 
+    snprintf(reply, reply_size,
       "{\"battery_mv\":%u,\"uptime_secs\":%u,\"errors\":%u,\"queue_len\":%u}",
       board.getBattMilliVolts(),
       ms.getMillis() / 1000,
@@ -21,11 +22,12 @@ public:
 
   template<typename RadioDriverType>
   static void formatRadioStats(char* reply,
+                              size_t reply_size,
                               mesh::Radio* radio,
                               RadioDriverType& driver,
                               uint32_t total_air_time_ms,
                               uint32_t total_rx_air_time_ms) {
-    sprintf(reply, 
+    snprintf(reply, reply_size,
       "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u}",
       (int16_t)radio->getNoiseFloor(),
       (int16_t)driver.getLastRSSI(),
@@ -37,12 +39,13 @@ public:
 
   template<typename RadioDriverType>
   static void formatPacketStats(char* reply,
+                               size_t reply_size,
                                RadioDriverType& driver,
                                uint32_t n_sent_flood,
                                uint32_t n_sent_direct,
                                uint32_t n_recv_flood,
                                uint32_t n_recv_direct) {
-    sprintf(reply, 
+    snprintf(reply, reply_size,
       "{\"recv\":%u,\"sent\":%u,\"flood_tx\":%u,\"direct_tx\":%u,\"flood_rx\":%u,\"direct_rx\":%u,\"recv_errors\":%u}",
       driver.getPacketsRecv(),
       driver.getPacketsSent(),

+ 3 - 1
src/helpers/mqtt/MQTTPrefs.cpp

@@ -65,7 +65,9 @@ bool MQTTPrefsStore::save(FILESYSTEM* fs, const MQTTPrefs& prefs) {
   if (fs == nullptr) {
     return false;
   }
-  fs->remove(kFilename);
+  if (fs->exists(kFilename) && !fs->remove(kFilename)) {
+    return false;
+  }
 #if defined(RP2040_PLATFORM)
   File file = fs->open(kFilename, "w");
 #else

+ 41 - 547
src/helpers/mqtt/MQTTUplink.cpp

@@ -1,8 +1,5 @@
 #include "MQTTUplink.h"
 #include "MQTTCaCerts.h"
-#if defined(WITH_WEB_PANEL) && WITH_WEB_PANEL
-  #include "generated/WebPanelCert.h"
-#endif
 
 #ifdef WITH_MQTT_UPLINK
 
@@ -51,17 +48,35 @@
 #endif
 
 namespace {
-constexpr unsigned long kWifiStartupDelayMillis = 750;
 constexpr unsigned long kWifiRetryMillis = 15000;
 constexpr unsigned long kWifiConnectTimeoutMillis = 45000;
 constexpr unsigned long kBrokerRetryMillis = 10000;
 constexpr time_t kTokenLifetimeSecs = 3600;
 constexpr time_t kTokenRefreshSlackSecs = 300;
 constexpr time_t kMinSaneEpoch = 1735689600;  // 2025-01-01T00:00:00Z
-constexpr size_t kWebServerStackSize = 8192;
-constexpr size_t kWebPasswordBufferSize = 80;
-constexpr size_t kWebCommandBufferSize = 192;
-constexpr size_t kWebReplyBufferSize = 256;
+
+int getWifiQualityPercent(int rssi_dbm) {
+  if (rssi_dbm <= -100) {
+    return 0;
+  }
+  if (rssi_dbm >= -50) {
+    return 100;
+  }
+  return 2 * (rssi_dbm + 100);
+}
+
+const char* getWifiQualityLabel(int rssi_dbm) {
+  if (rssi_dbm >= -60) {
+    return "excellent";
+  }
+  if (rssi_dbm >= -67) {
+    return "good";
+  }
+  if (rssi_dbm >= -75) {
+    return "fair";
+  }
+  return "poor";
+}
 
 char* allocScratchBuffer(size_t size) {
   void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
@@ -77,352 +92,6 @@ void freeScratchBuffer(void* ptr) {
   }
 }
 
-#if WITH_WEB_PANEL
-const char kWebPanelHtml[] PROGMEM = R"HTML(
-<!doctype html>
-<html>
-<head>
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width,initial-scale=1">
-  <title>Repeater Config</title>
-  <style>
-    :root {
-      color-scheme: light;
-      --accent:#2f8f4e;
-      --accent-hover:#3fae61;
-      --background:#f4f6f9;
-      --text:#1f2937;
-      --text-muted:#4b5563;
-      --border:rgba(0, 0, 0, 0.08);
-      --surface1:#ffffff;
-      --surface2:#f0f3f7;
-      --card-bg:#ffffff;
-      --input-bg:#ffffff;
-      --terminal-bg:#f0f3f7;
-      --terminal-border:rgba(0, 0, 0, 0.08);
-      --terminal-cmd:#2f8f4e;
-      --status-red:#c94a4a;
-      --button-text:#ffffff;
-      --button-secondary-text:#1f2937;
-    }
-    :root[data-theme="dark"] {
-      color-scheme: dark;
-      --accent:#36a167;
-      --accent-hover:#49c27d;
-      --background:#222222;
-      --text:#e6eaf0;
-      --text-muted:#9aa4b2;
-      --border:rgba(255, 255, 255, 0.08);
-      --surface1:#303030;
-      --surface2:#343434;
-      --card-bg:#303030;
-      --input-bg:#343434;
-      --terminal-bg:#222222;
-      --terminal-border:rgba(255, 255, 255, 0.08);
-      --terminal-cmd:#8fd3ff;
-      --status-red:#d45a5a;
-      --button-text:#ffffff;
-      --button-secondary-text:#e6eaf0;
-    }
-    html { min-height:100%; background:linear-gradient(180deg,var(--background),var(--surface2)); background-repeat:no-repeat; background-attachment:fixed; }
-    body { min-height:100vh; margin:0; font:16px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; background:transparent; color:var(--text); transition:background .2s ease,color .2s ease; }
-    main { max-width:920px; margin:0 auto; padding:24px; }
-    .theme-fab { position:fixed; top:18px; right:18px; width:48px; height:48px; border-radius:999px; display:flex; align-items:center; justify-content:center; z-index:20; box-shadow:0 12px 28px rgba(0,0,0,.18); font-size:22px; line-height:1; }
-    .card { background:var(--card-bg); border:1px solid var(--border); border-radius:14px; padding:18px; margin-bottom:18px; }
-    h1,h2,h3 { margin:0 0 12px; font-size:18px; }
-    p { color:var(--text-muted); margin:8px 0 0; }
-    input, textarea, button, select { width:100%; box-sizing:border-box; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text); padding:12px; font:inherit; }
-    textarea { min-height:100px; resize:vertical; }
-    button { width:auto; cursor:pointer; background:var(--accent); color:var(--button-text); border:none; font-weight:700; transition:background .2s ease,color .2s ease,border-color .2s ease; }
-    button:hover { background:var(--accent-hover); }
-    .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
-    .row3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
-    .quick { display:flex; flex-wrap:wrap; gap:10px; }
-    .quick button, .iconbtn, .themebtn { background:var(--surface2); color:var(--button-secondary-text); border:1px solid var(--border); }
-    .quick button:hover, .iconbtn:hover, .themebtn:hover { background:var(--surface1); }
-    .stack { display:grid; gap:12px; }
-    .label { font-size:12px; color:var(--text-muted); margin-bottom:6px; display:block; }
-    .fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; }
-    .iconbtn { width:44px; padding:12px 0; }
-    .themebtn { padding:10px 14px; }
-    #app { display:none; }
-    #status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; }
-    .terminal { background:var(--terminal-bg); border:1px solid var(--terminal-border); border-radius:12px; padding:14px; min-height:180px; max-height:320px; overflow:auto; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; }
-    .term-entry { margin:0 0 12px; }
-    .term-cmd { color:var(--terminal-cmd); }
-    .term-out { white-space:pre-wrap; color:var(--text); }
-    .term-out.err { color:var(--status-red); }
-  </style>
-</head>
-<body>
-  <main>
-    <button id="themeToggle" class="themebtn theme-fab" aria-label="Toggle theme" title="Toggle theme">☾</button>
-    <section class="card" id="login">
-      <h1>Repeater Config</h1>
-      <p>Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.</p>
-      <div class="row" style="margin-top:14px">
-        <input id="password" type="password" placeholder="Admin password">
-        <button id="loginBtn">Unlock</button>
-      </div>
-      <div id="status"></div>
-    </section>
-
-    <section class="card" id="app">
-      <h2>Run CLI Command</h2>
-      <div class="row">
-        <input id="command" placeholder="get mqtt.status">
-        <button id="runBtn">Run</button>
-      </div>
-      <p>Only the allowlisted commands exposed by this panel will run here.</p>
-      <div id="reply" class="terminal"></div>
-    </section>
-
-    <section class="card" id="app2" style="display:none">
-      <h2>Quick Commands</h2>
-      <div class="stack">
-        <div>
-          <span class="label">WiFi</span>
-          <div class="quick">
-            <button data-cmd="get wifi.status">wifi.status</button>
-            <button data-cmd="get wifi.powersaving">wifi.powersaving</button>
-          </div>
-        </div>
-        <div>
-          <span class="label">MQTT</span>
-          <div class="quick">
-            <button data-cmd="get mqtt.status">mqtt.status</button>
-            <button data-cmd="get mqtt.iata">mqtt.iata</button>
-            <button data-cmd="get mqtt.owner">mqtt.owner</button>
-            <button data-cmd="get mqtt.email">mqtt.email</button>
-          </div>
-        </div>
-      </div>
-    </section>
-
-    <section class="card" id="app3" style="display:none">
-      <h2>Web Forms</h2>
-      <div class="stack">
-        <div class="row">
-          <div>
-            <label class="label" for="nodeName">Device Name</label>
-            <div class="fieldline">
-              <input id="nodeName" placeholder="MeshCore-HOWL">
-              <button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">&#8635;</button>
-            </div>
-          </div>
-          <div style="align-self:end">
-            <button data-prefix="set name " data-input="nodeName">Save name</button>
-          </div>
-        </div>
-        <div class="row">
-          <div>
-            <label class="label" for="nodeLat">Latitude</label>
-            <div class="fieldline">
-              <input id="nodeLat" placeholder="-37.8136">
-              <button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">&#8635;</button>
-            </div>
-          </div>
-          <div>
-            <label class="label" for="nodeLon">Longitude</label>
-            <div class="fieldline">
-              <input id="nodeLon" placeholder="144.9631">
-              <button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">&#8635;</button>
-            </div>
-          </div>
-        </div>
-        <div class="quick">
-          <button data-prefix="set lat " data-input="nodeLat">Save latitude</button>
-          <button data-prefix="set lon " data-input="nodeLon">Save longitude</button>
-        </div>
-        <div class="row">
-          <div>
-            <label class="label" for="guestPassword">Guest Password</label>
-            <input id="guestPassword" type="password" placeholder="new guest password">
-          </div>
-        </div>
-        <div class="quick">
-          <button data-prefix="set guest.password " data-input="guestPassword">Save guest password</button>
-        </div>
-        <div>
-          <label class="label" for="privateKey">Private Key</label>
-          <input id="privateKey" placeholder="64-hex-char private key">
-          <p>Changing the private key requires a reboot to apply.</p>
-          <button data-prefix="set prv.key " data-input="privateKey">Save private key</button>
-        </div>
-        <div class="row3">
-          <div>
-            <label class="label" for="advertInterval">Advert Interval (minutes)</label>
-            <div class="fieldline">
-              <input id="advertInterval" placeholder="2">
-              <button class="iconbtn" data-load-cmd="get advert.interval" data-load-input="advertInterval" title="Refresh advert interval">&#8635;</button>
-            </div>
-          </div>
-          <div>
-            <label class="label" for="floodInterval">Flood Interval (hours)</label>
-            <div class="fieldline">
-              <input id="floodInterval" placeholder="12">
-              <button class="iconbtn" data-load-cmd="get flood.advert.interval" data-load-input="floodInterval" title="Refresh flood interval">&#8635;</button>
-            </div>
-          </div>
-          <div>
-            <label class="label" for="floodMax">Flood Max</label>
-            <div class="fieldline">
-              <input id="floodMax" placeholder="64">
-              <button class="iconbtn" data-load-cmd="get flood.max" data-load-input="floodMax" title="Refresh flood max">&#8635;</button>
-            </div>
-          </div>
-        </div>
-        <div class="quick">
-          <button data-prefix="set advert.interval " data-input="advertInterval">Save advert interval</button>
-          <button data-prefix="set flood.advert.interval " data-input="floodInterval">Save flood interval</button>
-          <button data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
-        </div>
-        <div>
-          <label class="label" for="ownerInfo">Owner Info</label>
-          <div class="fieldline">
-            <textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea>
-            <button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">&#8635;</button>
-          </div>
-          <button id="saveOwnerInfo">Save owner info</button>
-        </div>
-      </div>
-    </section>
-
-    <section class="card" id="app4" style="display:none">
-      <h2>Actions</h2>
-      <div class="quick">
-        <button id="advertBtn">Advert</button>
-        <button id="rebootBtn">Reboot</button>
-        <button id="otaBtn">Start OTA</button>
-        <button id="logoutBtn" class="themebtn">Logout</button>
-      </div>
-    </section>
-  </main>
-  <script>
-    let token = "";
-    const statusEl = document.getElementById("status");
-    const replyEl = document.getElementById("reply");
-    const themeToggleEl = document.getElementById("themeToggle");
-    const rootEl = document.documentElement;
-    function getPreferredTheme() {
-      const saved = localStorage.getItem("repeater-theme");
-      if (saved === "light" || saved === "dark") return saved;
-      return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
-    }
-    function applyTheme(theme) {
-      rootEl.dataset.theme = theme;
-      themeToggleEl.textContent = theme === "dark" ? "☀" : "☾";
-      themeToggleEl.title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
-      themeToggleEl.setAttribute("aria-label", themeToggleEl.title);
-    }
-    function toggleTheme() {
-      const next = rootEl.dataset.theme === "dark" ? "light" : "dark";
-      localStorage.setItem("repeater-theme", next);
-      applyTheme(next);
-    }
-    applyTheme(getPreferredTheme());
-    themeToggleEl.onclick = toggleTheme;
-    function appendHistory(cmd, text, ok) {
-      const entry = document.createElement("div");
-      entry.className = "term-entry";
-      const cmdLine = document.createElement("div");
-      cmdLine.className = "term-cmd";
-      cmdLine.textContent = "> " + cmd;
-      const outLine = document.createElement("div");
-      outLine.className = "term-out" + (ok ? "" : " err");
-      outLine.textContent = text && text.length ? text : "OK";
-      entry.appendChild(cmdLine);
-      entry.appendChild(outLine);
-      replyEl.appendChild(entry);
-      replyEl.scrollTop = replyEl.scrollHeight;
-    }
-    function parseReplyValue(text) {
-      return (text || "").replace(/^>\s*/, "").trim();
-    }
-    function showAuthedUi(show) {
-      document.getElementById("login").style.display = show ? "none" : "block";
-      document.getElementById("app").style.display = show ? "block" : "none";
-      document.getElementById("app2").style.display = show ? "block" : "none";
-      document.getElementById("app3").style.display = show ? "block" : "none";
-      document.getElementById("app4").style.display = show ? "block" : "none";
-      if (!show) {
-        document.getElementById("password").value = "";
-        statusEl.textContent = "";
-      }
-    }
-    async function runCommand(cmd) {
-      if (!token) return { ok:false, text:"" };
-      document.getElementById("command").value = cmd;
-      const res = await fetch("/api/command", { method:"POST", headers:{ "X-Auth-Token": token }, body: cmd });
-      const text = await res.text();
-      appendHistory(cmd, text, res.ok);
-      return { ok:res.ok, text };
-    }
-    function runPrefixed(prefix, inputId) {
-      const value = document.getElementById(inputId).value;
-      runCommand(prefix + value);
-    }
-    async function loadField(cmd, inputId, format) {
-      const result = await runCommand(cmd);
-      if (!result.ok) return;
-      let value = parseReplyValue(result.text);
-      if (format === "multiline") {
-        value = value.replace(/\|/g, "\n");
-      }
-      document.getElementById(inputId).value = value;
-    }
-    document.getElementById("loginBtn").onclick = async () => {
-      const pwd = document.getElementById("password").value;
-      const res = await fetch("/login", { method:"POST", body: pwd });
-      const text = await res.text();
-      if (!res.ok) {
-        statusEl.textContent = text || "Access denied";
-        return;
-      }
-      token = text.trim();
-      showAuthedUi(true);
-      await loadField("get name", "nodeName");
-    };
-    document.getElementById("password").addEventListener("keydown", (event) => {
-      if (event.key === "Enter") {
-        event.preventDefault();
-        document.getElementById("loginBtn").click();
-      }
-    });
-    document.getElementById("runBtn").onclick = () => runCommand(document.getElementById("command").value);
-    document.querySelectorAll("[data-cmd]").forEach((btn) => btn.onclick = () => runCommand(btn.dataset.cmd));
-    document.querySelectorAll("[data-prefix]").forEach((btn) => btn.onclick = () => runPrefixed(btn.dataset.prefix, btn.dataset.input));
-    document.querySelectorAll("[data-load-cmd]").forEach((btn) => btn.onclick = () => loadField(btn.dataset.loadCmd, btn.dataset.loadInput, btn.dataset.loadFormat));
-    document.getElementById("saveOwnerInfo").onclick = () => {
-      const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
-      runCommand("set owner.info " + value);
-    };
-    document.getElementById("rebootBtn").onclick = async () => {
-      if (confirm("Reboot the repeater now?")) {
-        await runCommand("reboot");
-      }
-    };
-    document.getElementById("advertBtn").onclick = async () => {
-      if (confirm("Send an advert now?")) {
-        await runCommand("advert");
-      }
-    };
-    document.getElementById("otaBtn").onclick = async () => {
-      if (confirm("Start OTA mode now?")) {
-        await runCommand("start ota");
-      }
-    };
-    document.getElementById("logoutBtn").onclick = () => {
-      token = "";
-      showAuthedUi(false);
-    };
-    showAuthedUi(false);
-  </script>
-</body>
-</html>
-)HTML";
-#endif
-
 const char* getWifiStateLabel(const MQTTPrefs& prefs, bool wifi_started) {
   if (prefs.wifi_ssid[0] == 0) {
     return "off";
@@ -449,18 +118,11 @@ const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[3] = {
 
 MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity)
     : _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _wifi_started(false), _sntp_started(false),
-      _have_time_sync(false), _wifi_sta_started_at(0), _last_wifi_attempt(0), _last_status_publish(0), _last_status{},
+      _have_time_sync(false), _last_wifi_attempt(0), _last_status_publish(0), _last_status{},
       _node_name(nullptr),
       _web_runner(nullptr)
-#if WITH_WEB_PANEL
-      , _web_server(nullptr)
-#endif
        {
   memset(_device_id, 0, sizeof(_device_id));
-#if WITH_WEB_PANEL
-  memset(_web_token, 0, sizeof(_web_token));
-  _web_route_context.self = this;
-#endif
   MQTTPrefsStore::setDefaults(_prefs);
   for (size_t i = 0; i < 3; ++i) {
     memset(&_brokers[i], 0, sizeof(_brokers[i]));
@@ -469,6 +131,11 @@ MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity)
   MQTT_LOG("uplink init");
 }
 
+void MQTTUplink::setWebCommandRunner(MQTTWebCommandRunner* runner) {
+  _web_runner = runner;
+  _web_panel.setCommandRunner(runner);
+}
+
 bool MQTTUplink::savePrefs() {
   return MQTTPrefsStore::save(_fs, _prefs);
 }
@@ -494,7 +161,6 @@ void MQTTUplink::reconnectWifi() {
   _wifi_started = false;
   _sntp_started = false;
   _have_time_sync = false;
-  _wifi_sta_started_at = 0;
   _last_wifi_attempt = 0;
 }
 
@@ -847,165 +513,8 @@ void MQTTUplink::handleMqttEvent(void* handler_args, esp_event_base_t, int32_t e
   }
 }
 
-#if WITH_WEB_PANEL
-esp_err_t MQTTUplink::handleWebIndex(httpd_req_t* req) {
-  auto* ctx = static_cast<WebRouteContext*>(req->user_ctx);
-  if (ctx == nullptr || ctx->self == nullptr) {
-    return httpd_resp_send_500(req);
-  }
-  httpd_resp_set_type(req, "text/html; charset=utf-8");
-  httpd_resp_set_hdr(req, "Cache-Control", "no-store");
-  return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN);
-}
-#endif
-
-#if WITH_WEB_PANEL
-esp_err_t MQTTUplink::handleWebLogin(httpd_req_t* req) {
-  auto* ctx = static_cast<WebRouteContext*>(req->user_ctx);
-  if (ctx == nullptr || ctx->self == nullptr || ctx->self->_web_runner == nullptr) {
-    return httpd_resp_send_500(req);
-  }
-
-  char* password = allocScratchBuffer(kWebPasswordBufferSize);
-  if (password == nullptr) {
-    return httpd_resp_send_500(req);
-  }
-
-  if (!ctx->self->readRequestBody(req, password, kWebPasswordBufferSize)) {
-    freeScratchBuffer(password);
-    return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request");
-  }
-
-  if (strcmp(password, ctx->self->_web_runner->getWebAdminPassword()) != 0) {
-    freeScratchBuffer(password);
-    WEB_LOG("login denied");
-    return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Bad password");
-  }
-
-  freeScratchBuffer(password);
-  ctx->self->refreshWebToken();
-  WEB_LOG("login accepted");
-  httpd_resp_set_type(req, "text/plain; charset=utf-8");
-  httpd_resp_set_hdr(req, "Cache-Control", "no-store");
-  return httpd_resp_sendstr(req, ctx->self->_web_token);
-}
-#endif
-
-#if WITH_WEB_PANEL
-esp_err_t MQTTUplink::handleWebCommand(httpd_req_t* req) {
-  auto* ctx = static_cast<WebRouteContext*>(req->user_ctx);
-  if (ctx == nullptr || ctx->self == nullptr || ctx->self->_web_runner == nullptr) {
-    return httpd_resp_send_500(req);
-  }
-  if (!ctx->self->isWebAuthorized(req)) {
-    return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
-  }
-
-  char* command = allocScratchBuffer(kWebCommandBufferSize);
-  char* reply = allocScratchBuffer(kWebReplyBufferSize);
-  if (command == nullptr || reply == nullptr) {
-    freeScratchBuffer(command);
-    freeScratchBuffer(reply);
-    return httpd_resp_send_500(req);
-  }
-
-  if (!ctx->self->readRequestBody(req, command, kWebCommandBufferSize)) {
-    freeScratchBuffer(command);
-    freeScratchBuffer(reply);
-    return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request");
-  }
-
-  memset(reply, 0, kWebReplyBufferSize);
-  ctx->self->_web_runner->runWebCommand(command, reply, kWebReplyBufferSize);
-  httpd_resp_set_type(req, "text/plain; charset=utf-8");
-  httpd_resp_set_hdr(req, "Cache-Control", "no-store");
-  esp_err_t rc = httpd_resp_send(req, reply[0] ? reply : "OK", HTTPD_RESP_USE_STRLEN);
-  freeScratchBuffer(command);
-  freeScratchBuffer(reply);
-  return rc;
-}
-#endif
-
-#if WITH_WEB_PANEL
-bool MQTTUplink::readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const {
-  if (req == nullptr || buffer == nullptr || buffer_size == 0 || req->content_len <= 0 ||
-      req->content_len >= static_cast<int>(buffer_size)) {
-    return false;
-  }
-
-  int remaining = req->content_len;
-  int offset = 0;
-  while (remaining > 0) {
-    int read = httpd_req_recv(req, &buffer[offset], remaining);
-    if (read <= 0) {
-      return false;
-    }
-    offset += read;
-    remaining -= read;
-  }
-  buffer[offset] = 0;
-  return true;
-}
-
-void MQTTUplink::refreshWebToken() {
-  uint8_t token[16];
-  esp_fill_random(token, sizeof(token));
-  bytesToHexUpper(token, sizeof(token), _web_token, sizeof(_web_token));
-}
-
-bool MQTTUplink::isWebAuthorized(httpd_req_t* req) const {
-  if (_web_token[0] == 0) {
-    return false;
-  }
-  char token[40];
-  if (httpd_req_get_hdr_value_str(req, "X-Auth-Token", token, sizeof(token)) != ESP_OK) {
-    return false;
-  }
-  return strcmp(token, _web_token) == 0;
-}
-
-bool MQTTUplink::startWebServer() {
-  if (_web_server != nullptr || _web_runner == nullptr || _prefs.web_enabled == 0) {
-    return _web_server != nullptr;
-  }
-
-  httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
-  config.httpd.max_open_sockets = 2;
-  config.httpd.max_uri_handlers = 3;
-  config.httpd.max_resp_headers = 4;
-  config.httpd.backlog_conn = 2;
-  config.httpd.recv_wait_timeout = 2;
-  config.httpd.send_wait_timeout = 2;
-  config.httpd.stack_size = kWebServerStackSize;
-  config.cacert_pem = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerCertPem);
-  config.cacert_len = sizeof(mqtt_web_panel_cert::kServerCertPem);
-  config.prvtkey_pem = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerKeyPem);
-  config.prvtkey_len = sizeof(mqtt_web_panel_cert::kServerKeyPem);
-
-  esp_err_t rc = httpd_ssl_start(&_web_server, &config);
-  if (rc != ESP_OK) {
-    _web_server = nullptr;
-    WEB_LOG("server start failed rc=0x%x", static_cast<unsigned>(rc));
-    return false;
-  }
-
-  httpd_uri_t index_uri = {.uri = "/", .method = HTTP_GET, .handler = &MQTTUplink::handleWebIndex, .user_ctx = &_web_route_context};
-  httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &MQTTUplink::handleWebLogin, .user_ctx = &_web_route_context};
-  httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &MQTTUplink::handleWebCommand, .user_ctx = &_web_route_context};
-  httpd_register_uri_handler(_web_server, &index_uri);
-  httpd_register_uri_handler(_web_server, &login_uri);
-  httpd_register_uri_handler(_web_server, &command_uri);
-  WEB_LOG("server started on https://%s/", WiFi.localIP().toString().c_str());
-  return true;
-}
-
 void MQTTUplink::stopWebServer() {
-  if (_web_server != nullptr) {
-    WEB_LOG("server stopped");
-    httpd_ssl_stop(_web_server);
-    _web_server = nullptr;
-  }
-  _web_token[0] = 0;
+  _web_panel.stop();
 }
 
 void MQTTUplink::ensureWebServer() {
@@ -1013,12 +522,8 @@ void MQTTUplink::ensureWebServer() {
     stopWebServer();
     return;
   }
-  startWebServer();
+  _web_panel.start();
 }
-#else
-void MQTTUplink::ensureWebServer() { }
-void MQTTUplink::stopWebServer() { }
-#endif
 
 void MQTTUplink::ensureWifi() {
   if (_prefs.wifi_ssid[0] == 0) {
@@ -1037,7 +542,6 @@ void MQTTUplink::ensureWifi() {
       _wifi_started = false;
       _sntp_started = false;
       _have_time_sync = false;
-      _wifi_sta_started_at = 0;
     }
     return;
   }
@@ -1049,12 +553,7 @@ void MQTTUplink::ensureWifi() {
   unsigned long now_ms = millis();
   wl_status_t status = WiFi.status();
   if (_wifi_started) {
-    if (_last_wifi_attempt == 0) {
-      if (now_ms - _wifi_sta_started_at < kWifiStartupDelayMillis) {
-        return;
-      }
-    } else if (status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) {
-      return;
+    if (_last_wifi_attempt != 0 && status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) {
       return;
     }
     if (now_ms - _last_wifi_attempt < kWifiRetryMillis) {
@@ -1066,11 +565,7 @@ void MQTTUplink::ensureWifi() {
     WiFi.mode(WIFI_STA);
     WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave));
     _wifi_started = true;
-    _wifi_sta_started_at = now_ms;
-    _last_wifi_attempt = 0;
-    WIFI_LOG("sta start powersaving=%s settle_ms=%lu", getPowerSaveLabel(_prefs.wifi_powersave),
-             static_cast<unsigned long>(kWifiStartupDelayMillis));
-    return;
+    WIFI_LOG("sta start powersaving=%s", getPowerSaveLabel(_prefs.wifi_powersave));
   } else {
     WIFI_LOG("retry status=%d", static_cast<int>(status));
   }
@@ -1199,9 +694,6 @@ void MQTTUplink::begin(FILESYSTEM* fs) {
   _fs = fs;
   MQTTPrefsStore::load(_fs, _prefs);
   refreshIdentityStrings();
-#if WITH_WEB_PANEL
-  refreshWebToken();
-#endif
   _running = true;
   _last_status_publish = millis();
   MQTT_LOG("begin iata=%s enabled_mask=0x%02X wifi_ssid=%s", _prefs.iata, _prefs.enabled_mask, _prefs.wifi_ssid);
@@ -1221,7 +713,6 @@ void MQTTUplink::end() {
   _wifi_started = false;
   _sntp_started = false;
   _have_time_sync = false;
-  _wifi_sta_started_at = 0;
   _last_wifi_attempt = 0;
   _running = false;
 }
@@ -1335,13 +826,13 @@ void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const {
     return;
   }
 
-  if (_web_server == nullptr || !_wifi_started || WiFi.status() != WL_CONNECTED) {
+  if (!_web_panel.isRunning() || !_wifi_started || WiFi.status() != WL_CONNECTED) {
     snprintf(reply, reply_size, "> web:down");
     return;
   }
 
   snprintf(reply, reply_size, "> web:up url:https://%s/ auth:%s", WiFi.localIP().toString().c_str(),
-           _web_token[0] ? "unlocked" : "locked");
+           _web_panel.hasSessionToken() ? "unlocked" : "locked");
 #else
   (void)reply_size;
   snprintf(reply, reply_size, "> web:unsupported");
@@ -1542,8 +1033,11 @@ void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const {
   }
 
   if (wifi_status == WL_CONNECTED) {
-    snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s ip:%s", _prefs.wifi_ssid, status,
-             static_cast<int>(wifi_status), state, WiFi.localIP().toString().c_str());
+    const int rssi_dbm = WiFi.RSSI();
+    snprintf(reply, reply_size,
+             "> ssid:%s status:%s code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s",
+             _prefs.wifi_ssid, status, static_cast<int>(wifi_status), state, WiFi.localIP().toString().c_str(),
+             rssi_dbm, getWifiQualityPercent(rssi_dbm), getWifiQualityLabel(rssi_dbm));
   } else {
     snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-",
              status, static_cast<int>(wifi_status), state);
@@ -1554,7 +1048,7 @@ void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const {
 
 MQTTUplink::MQTTUplink(mesh::RTCClock&, mesh::LocalIdentity&)
     : _fs(nullptr), _rtc(nullptr), _identity(nullptr), _running(false), _wifi_started(false), _sntp_started(false),
-      _have_time_sync(false), _wifi_sta_started_at(0), _last_wifi_attempt(0), _last_status_publish(0), _last_status{},
+      _have_time_sync(false), _last_wifi_attempt(0), _last_status_publish(0), _last_status{},
       _node_name(nullptr),
       _web_runner(nullptr) {
 }

+ 4 - 33
src/helpers/mqtt/MQTTUplink.h

@@ -4,6 +4,7 @@
 
 #include <Mesh.h>
 #include <helpers/CommonCLI.h>
+#include <helpers/web/WebPanelServer.h>
 #include <target.h>
 
 #include "JWTHelper.h"
@@ -14,18 +15,10 @@
 #if !defined(WITH_WEB_PANEL)
   #define WITH_WEB_PANEL 1
 #endif
-#if WITH_WEB_PANEL
-  #include <esp_https_server.h>
-#endif
 #include <mqtt_client.h>
 #endif
 
-class MQTTWebCommandRunner {
-public:
-  virtual ~MQTTWebCommandRunner() = default;
-  virtual void runWebCommand(const char* command, char* reply, size_t reply_size) = 0;
-  virtual const char* getWebAdminPassword() const = 0;
-};
+using MQTTWebCommandRunner = WebPanelCommandRunner;
 
 struct MQTTStatusSnapshot {
   int battery_mv;
@@ -72,7 +65,7 @@ public:
   bool setIata(const char* iata);
   const char* getIata() const { return _prefs.iata; }
   void setNodeNameSource(const char* node_name) { _node_name = node_name; }
-  void setWebCommandRunner(MQTTWebCommandRunner* runner) { _web_runner = runner; }
+  void setWebCommandRunner(MQTTWebCommandRunner* runner);
   bool setWifiSSID(const char* ssid);
   bool setWifiPassword(const char* pwd);
   const char* getWifiSSID() const { return _prefs.wifi_ssid; }
@@ -109,12 +102,6 @@ private:
     char raw_topic[128];
     char offline_payload[256];
   };
-
-#if WITH_WEB_PANEL
-  struct WebRouteContext {
-    MQTTUplink* self;
-  };
-#endif
 #endif
 
   FILESYSTEM* _fs;
@@ -125,13 +112,13 @@ private:
   bool _wifi_started;
   bool _sntp_started;
   bool _have_time_sync;
-  unsigned long _wifi_sta_started_at;
   unsigned long _last_wifi_attempt;
   unsigned long _last_status_publish;
   MQTTStatusSnapshot _last_status;
   char _device_id[65];
   const char* _node_name;
   MQTTWebCommandRunner* _web_runner;
+  WebPanelServer _web_panel;
 
 #if defined(ESP_PLATFORM)
   static constexpr uint8_t kEastmeshBit = 0x01;
@@ -140,26 +127,10 @@ private:
   static const BrokerSpec kBrokerSpecs[3];
 
   BrokerState _brokers[3];
-#if WITH_WEB_PANEL
-  httpd_handle_t _web_server;
-  char _web_token[33];
-  WebRouteContext _web_route_context;
-#endif
 
   static void handleMqttEvent(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data);
-#if WITH_WEB_PANEL
-  static esp_err_t handleWebIndex(httpd_req_t* req);
-  static esp_err_t handleWebLogin(httpd_req_t* req);
-  static esp_err_t handleWebCommand(httpd_req_t* req);
-#endif
   void ensureWebServer();
   void stopWebServer();
-  bool startWebServer();
-#if WITH_WEB_PANEL
-  bool isWebAuthorized(httpd_req_t* req) const;
-  bool readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const;
-  void refreshWebToken();
-#endif
   void ensureWifi();
   void updateTimeSync();
   bool hasEnabledBroker() const;

+ 1294 - 0
src/helpers/web/WebPanelServer.cpp

@@ -0,0 +1,1294 @@
+#include "WebPanelServer.h"
+
+#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
+
+#include <Arduino.h>
+#include <esp_heap_caps.h>
+#include <esp_idf_version.h>
+#include <esp_system.h>
+#include <string.h>
+
+#include "../mqtt/generated/WebPanelCert.h"
+
+namespace {
+
+constexpr size_t kWebServerStackSize = 8192;
+constexpr size_t kWebPasswordBufferSize = 80;
+constexpr size_t kWebCommandBufferSize = 192;
+constexpr size_t kWebReplyBufferSize = 256;
+constexpr size_t kWebJsonBufferSize = 2048;
+
+#if defined(MQTT_DEBUG) && MQTT_DEBUG
+  #define WEB_PANEL_LOG(fmt, ...) Serial.printf("[WEB] " fmt "\n", ##__VA_ARGS__)
+#else
+  #define WEB_PANEL_LOG(...) do { } while (0)
+#endif
+
+char* allocScratchBuffer(size_t size) {
+  void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+  if (ptr == nullptr) {
+    ptr = heap_caps_malloc(size, MALLOC_CAP_8BIT);
+  }
+  return static_cast<char*>(ptr);
+}
+
+void freeScratchBuffer(void* ptr) {
+  if (ptr != nullptr) {
+    heap_caps_free(ptr);
+  }
+}
+
+void bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size) {
+  if (dst_size == 0) {
+    return;
+  }
+  size_t di = 0;
+  for (size_t i = 0; i < len && di + 2 < dst_size; ++i) {
+    snprintf(&dst[di], dst_size - di, "%02X", src[i]);
+    di += 2;
+  }
+  dst[(di < dst_size) ? di : (dst_size - 1)] = 0;
+}
+
+size_t appendJsonEscaped(char* dst, size_t dst_size, size_t offset, const char* src) {
+  if (dst == nullptr || dst_size == 0) {
+    return offset;
+  }
+  for (size_t i = 0; src != nullptr && src[i] != 0 && offset + 2 < dst_size; ++i) {
+    char c = src[i];
+    if (c == '\\' || c == '"') {
+      if (offset + 2 >= dst_size) break;
+      dst[offset++] = '\\';
+      dst[offset++] = c;
+    } else if (c == '\n') {
+      if (offset + 2 >= dst_size) break;
+      dst[offset++] = '\\';
+      dst[offset++] = 'n';
+    } else if (c == '\r') {
+      if (offset + 2 >= dst_size) break;
+      dst[offset++] = '\\';
+      dst[offset++] = 'r';
+    } else if (c == '\t') {
+      if (offset + 2 >= dst_size) break;
+      dst[offset++] = '\\';
+      dst[offset++] = 't';
+    } else {
+      dst[offset++] = c;
+    }
+  }
+  dst[(offset < dst_size) ? offset : (dst_size - 1)] = 0;
+  return offset;
+}
+
+bool appendJsonField(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) {
+  int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":\"", comma ? "," : "", key);
+  if (written < 0 || offset + static_cast<size_t>(written) >= dst_size) {
+    return false;
+  }
+  offset += static_cast<size_t>(written);
+  offset = appendJsonEscaped(dst, dst_size, offset, value != nullptr ? value : "");
+  if (offset + 2 >= dst_size) {
+    return false;
+  }
+  dst[offset++] = '"';
+  dst[offset] = 0;
+  return true;
+}
+
+bool appendJsonFieldRaw(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) {
+  int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":", comma ? "," : "", key);
+  if (written < 0 || offset + static_cast<size_t>(written) >= dst_size) {
+    return false;
+  }
+  offset += static_cast<size_t>(written);
+  if (value == nullptr) {
+    value = "null";
+  }
+  written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s", value);
+  if (written < 0 || offset + static_cast<size_t>(written) >= dst_size) {
+    return false;
+  }
+  offset += static_cast<size_t>(written);
+  return true;
+}
+
+const char kWebPanelHtml[] PROGMEM = R"HTML(
+<!doctype html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width,initial-scale=1">
+  <title>Repeater Config</title>
+  <style>
+    :root {
+      color-scheme: light;
+      --accent:#2f8f4e;
+      --accent-hover:#3fae61;
+      --background:#f4f6f9;
+      --text:#1f2937;
+      --text-muted:#4b5563;
+      --border:rgba(0, 0, 0, 0.08);
+      --surface1:#ffffff;
+      --surface2:#f0f3f7;
+      --card-bg:#ffffff;
+      --input-bg:#ffffff;
+      --terminal-bg:#f0f3f7;
+      --terminal-border:rgba(0, 0, 0, 0.08);
+      --terminal-cmd:#2f8f4e;
+      --status-red:#c94a4a;
+      --button-text:#ffffff;
+      --button-secondary-text:#1f2937;
+    }
+    :root[data-theme="dark"] {
+      color-scheme: dark;
+      --accent:#36a167;
+      --accent-hover:#49c27d;
+      --background:#222222;
+      --text:#e6eaf0;
+      --text-muted:#9aa4b2;
+      --border:rgba(255, 255, 255, 0.08);
+      --surface1:#303030;
+      --surface2:#343434;
+      --card-bg:#303030;
+      --input-bg:#343434;
+      --terminal-bg:#222222;
+      --terminal-border:rgba(255, 255, 255, 0.08);
+      --terminal-cmd:#8fd3ff;
+      --status-red:#d45a5a;
+      --button-text:#ffffff;
+      --button-secondary-text:#e6eaf0;
+    }
+    html { min-height:100%; background:linear-gradient(180deg,var(--background),var(--surface2)); background-repeat:no-repeat; background-attachment:fixed; }
+    body { min-height:100vh; margin:0; font:16px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; background:transparent; color:var(--text); transition:background .2s ease,color .2s ease; }
+    main { max-width:920px; margin:0 auto; padding:24px; }
+    .theme-fab { position:fixed; top:18px; right:18px; width:48px; height:48px; border-radius:999px; display:flex; align-items:center; justify-content:center; z-index:20; box-shadow:0 12px 28px rgba(0,0,0,.18); font-size:22px; line-height:1; }
+    .card { background:var(--card-bg); border:1px solid var(--border); border-radius:14px; padding:18px; margin-bottom:18px; }
+    h1,h2,h3 { margin:0 0 12px; font-size:18px; }
+    p { color:var(--text-muted); margin:8px 0 0; }
+    input, textarea, button, select { width:100%; box-sizing:border-box; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text); padding:12px; font:inherit; }
+    textarea { min-height:100px; resize:vertical; }
+    button { width:auto; cursor:pointer; background:var(--accent); color:var(--button-text); border:none; font-weight:700; transition:background .2s ease,color .2s ease,border-color .2s ease; }
+    button:hover { background:var(--accent-hover); }
+    .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
+    .row-command { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; }
+    .row3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
+    .quick { display:flex; flex-wrap:wrap; gap:10px; }
+    .quick button, .iconbtn, .themebtn { background:var(--surface2); color:var(--button-secondary-text); border:1px solid var(--border); }
+    .quick button:hover, .iconbtn:hover, .themebtn:hover { background:var(--surface1); }
+    button.action-advert { background:linear-gradient(135deg,#d97706,#f59e0b); color:#fff7ed; border:none; }
+    button.action-advert:hover { background:linear-gradient(135deg,#ea8f17,#ffb938); }
+    button.action-caution { background:linear-gradient(135deg,#b94747,#d66a5f); color:#fff5f5; border:none; }
+    button.action-caution:hover { background:linear-gradient(135deg,#c75656,#e57b70); }
+    button.action-dreamy { background:linear-gradient(135deg,#4e7ac7,#8b7cf6); color:#f7f7ff; border:none; }
+    button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); }
+    .stack { display:grid; gap:12px; }
+    .field-card { display:grid; gap:10px; }
+    .inline-actions { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; align-items:center; }
+    .label { font-size:12px; color:var(--text-muted); margin-bottom:6px; display:block; }
+    .fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; }
+    .iconbtn { width:44px; padding:12px 0; }
+    .savebtn { width:100%; background:var(--accent); color:var(--button-text); border:none; }
+    .savebtn:hover { background:var(--accent-hover); }
+    .themebtn { padding:10px 14px; }
+    #app { display:none; }
+    #status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; }
+    .terminal { background:var(--terminal-bg); border:1px solid var(--terminal-border); border-radius:12px; padding:14px; min-height:180px; max-height:320px; overflow:auto; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; }
+    .term-entry { margin:0 0 12px; }
+    .term-cmd { color:var(--terminal-cmd); }
+    .term-out { white-space:pre-wrap; color:var(--text); }
+    .term-out.err { color:var(--status-red); }
+    .stats-shell { display:grid; gap:12px; }
+    .stats-empty, .stats-error { background:var(--surface2); border:1px dashed var(--border); border-radius:12px; padding:16px; color:var(--text-muted); }
+    .stats-error { color:var(--status-red); }
+    .actions-bar { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:12px; }
+    .actions-group { display:flex; gap:10px; flex-wrap:wrap; }
+    .actions-group.left { justify-self:start; }
+    .actions-group.center { justify-self:center; }
+    .actions-group.right { justify-self:end; }
+    .hud-grid-1 { display:grid; grid-template-columns:1fr; gap:12px; }
+    .hud-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
+    .hud-grid-2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
+    .hud-grid-3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
+    .hud-card { background:linear-gradient(180deg,var(--surface2),var(--surface1)); border:1px solid var(--border); border-radius:14px; padding:14px; display:grid; gap:12px; }
+    .hud-card h3 { margin:0; font-size:13px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.08em; }
+    .hud-row { display:grid; gap:10px; }
+    .hud-kpi { display:flex; align-items:flex-end; justify-content:space-between; gap:10px; }
+    .hud-value { font-size:28px; font-weight:700; line-height:1; color:var(--text); }
+    .hud-sub { font-size:12px; color:var(--text-muted); }
+    .meter { height:10px; background:var(--background); border:1px solid var(--border); border-radius:999px; overflow:hidden; }
+    .meter-fill { height:100%; border-radius:999px; background:linear-gradient(90deg,var(--accent),var(--accent-hover)); }
+    .meter-fill.warn { background:linear-gradient(90deg,#d7a531,#e9bf52); }
+    .meter-fill.bad { background:linear-gradient(90deg,#bf4b4b,#dd6a6a); }
+    .metric-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
+    .core-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
+    .core-grid .hud-row { align-content:start; }
+    .core-metrics { grid-template-columns:repeat(4,minmax(0,1fr)); }
+    .metric { background:rgba(255,255,255,.45); border:1px solid var(--border); border-radius:12px; padding:10px; }
+    :root[data-theme="dark"] .metric { background:rgba(0,0,0,.16); }
+    .metric-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; }
+    .metric-value { margin-top:4px; font-size:16px; font-weight:700; color:var(--text); }
+    @media (max-width:760px) {
+      body { font-size:15px; }
+      main { padding:16px; }
+      .card { padding:16px; margin-bottom:14px; }
+      .row, .row3, .row-command, .metric-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics { grid-template-columns:1fr; }
+      .inline-actions { grid-template-columns:minmax(0,1fr) auto auto; }
+      .fieldline { grid-template-columns:minmax(0,1fr) auto; align-items:center; }
+      .row-command button { width:100%; }
+      .fieldline .iconbtn { width:44px; }
+      .row-command button { justify-self:stretch; }
+      .hud-kpi { align-items:flex-start; flex-direction:column; }
+      .quick { gap:8px; }
+      #quickCommandsPanel .quick { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
+      .quick button, .themebtn { width:100%; }
+      .actions-bar { display:grid; grid-template-columns:1fr; gap:10px; }
+      .actions-group { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
+      .actions-group.left, .actions-group.center, .actions-group.right { justify-self:stretch; }
+      .actions-group.center { grid-template-columns:1fr; justify-items:center; }
+      #actionsPanel .actions-group button { width:100%; }
+      #actionsPanel .actions-group.center button { width:auto; min-width:56px; }
+      .row > div + div, .row3 > div + div { margin-top:4px; }
+      .row > div[style*="align-self:end"] { padding-top:4px; }
+    }
+  </style>
+</head>
+<body>
+  <main>
+    <section class="card" id="login">
+      <h1>Repeater Config</h1>
+      <p>Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.</p>
+      <div class="row" style="margin-top:14px">
+        <input id="password" type="password" placeholder="Admin password">
+        <button id="loginBtn">Unlock</button>
+      </div>
+      <div id="status"></div>
+    </section>
+
+    <section class="card" id="actionsPanel" style="display:none">
+      <h2>Actions</h2>
+      <div class="actions-bar">
+        <div class="actions-group left">
+          <button id="advertBtn" class="action-advert">Advert</button>
+          <button id="otaBtn" class="action-dreamy">Start OTA</button>
+        </div>
+        <div class="actions-group center">
+          <button id="themeToggle" class="themebtn" aria-label="Toggle theme" title="Toggle theme">☾</button>
+        </div>
+        <div class="actions-group right">
+          <button id="rebootBtn" class="action-caution">Reboot</button>
+          <button id="logoutBtn" class="themebtn action-caution">Logout</button>
+        </div>
+      </div>
+    </section>
+
+    <section class="card" id="quickCommandsPanel" style="display:none">
+      <h2>Quick "get" Commands</h2>
+      <div class="stack">
+        <div>
+          <span class="label">WiFi</span>
+          <div class="quick">
+            <button data-cmd="get wifi.status">wifi.status</button>
+            <button data-cmd="get wifi.powersaving">wifi.powersaving</button>
+          </div>
+        </div>
+        <div>
+          <span class="label">MQTT</span>
+          <div class="quick">
+            <button data-cmd="get mqtt.status">mqtt.status</button>
+            <button data-cmd="get mqtt.iata">mqtt.iata</button>
+            <button data-cmd="get mqtt.owner">mqtt.owner</button>
+            <button data-cmd="get mqtt.email">mqtt.email</button>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <section class="card" id="cliPanel">
+      <h2>Run CLI Command</h2>
+      <div class="row-command">
+        <input id="command" placeholder="get mqtt.status">
+        <button id="runBtn">Run</button>
+      </div>
+      <p>Only the allowlisted commands exposed by this panel will run here.</p>
+      <div id="reply" class="terminal"></div>
+    </section>
+
+    <section class="card" id="repeaterSettingsPanel" style="display:none">
+      <h2>Repeater Settings</h2>
+      <div class="stack">
+        <div class="field-card">
+          <label class="label" for="nodeName">Device Name</label>
+          <div class="inline-actions">
+            <input id="nodeName" placeholder="MeshCore-HOWL">
+            <button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">&#8635;</button>
+            <button class="savebtn" data-prefix="set name " data-input="nodeName">Save</button>
+          </div>
+        </div>
+        <div class="row">
+          <div class="field-card">
+            <div>
+            <label class="label" for="nodeLat">Latitude</label>
+            <div class="fieldline">
+              <input id="nodeLat" placeholder="0.0">
+              <button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">&#8635;</button>
+            </div>
+            </div>
+            <button class="savebtn" data-prefix="set lat " data-input="nodeLat">Save latitude</button>
+          </div>
+          <div class="field-card">
+            <div>
+            <label class="label" for="nodeLon">Longitude</label>
+            <div class="fieldline">
+              <input id="nodeLon" placeholder="0.0">
+              <button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">&#8635;</button>
+            </div>
+            </div>
+            <button class="savebtn" data-prefix="set lon " data-input="nodeLon">Save longitude</button>
+          </div>
+        </div>
+        <div class="field-card">
+          <label class="label" for="guestPassword">Guest Password</label>
+          <div class="inline-actions">
+            <input id="guestPassword" type="password" placeholder="new guest password">
+            <span></span>
+            <button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
+          </div>
+        </div>
+        <div class="field-card">
+          <label class="label" for="privateKey">Private Key</label>
+          <div class="inline-actions">
+            <input id="privateKey" placeholder="64-hex-char private key">
+            <span></span>
+            <button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button>
+          </div>
+          <p>Changing the private key requires a reboot to apply.</p>
+        </div>
+        <div class="row3">
+          <div class="field-card">
+            <div>
+            <label class="label" for="advertInterval">Advert Interval (minutes)</label>
+            <div class="fieldline">
+              <input id="advertInterval" placeholder="2">
+              <button class="iconbtn" data-load-cmd="get advert.interval" data-load-input="advertInterval" title="Refresh advert interval">&#8635;</button>
+            </div>
+            </div>
+            <button class="savebtn" data-prefix="set advert.interval " data-input="advertInterval">Save advert interval</button>
+          </div>
+          <div class="field-card">
+            <div>
+            <label class="label" for="floodInterval">Flood Interval (hours)</label>
+            <div class="fieldline">
+              <input id="floodInterval" placeholder="12">
+              <button class="iconbtn" data-load-cmd="get flood.advert.interval" data-load-input="floodInterval" title="Refresh flood interval">&#8635;</button>
+            </div>
+            </div>
+            <button class="savebtn" data-prefix="set flood.advert.interval " data-input="floodInterval">Save flood interval</button>
+          </div>
+          <div class="field-card">
+            <div>
+            <label class="label" for="floodMax">Flood Max</label>
+            <div class="fieldline">
+              <input id="floodMax" placeholder="64">
+              <button class="iconbtn" data-load-cmd="get flood.max" data-load-input="floodMax" title="Refresh flood max">&#8635;</button>
+            </div>
+            </div>
+            <button class="savebtn" data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
+          </div>
+        </div>
+        <div class="field-card">
+          <label class="label" for="ownerInfo">Owner Info</label>
+          <div class="inline-actions">
+            <textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea>
+            <button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">&#8635;</button>
+            <button id="saveOwnerInfo" class="savebtn">Save</button>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <section class="card" id="mqttSettingsPanel" style="display:none">
+      <h2>MQTT Settings</h2>
+      <div class="stack">
+        <div class="field-card">
+          <label class="label" for="mqttIata">MQTT IATA</label>
+          <div class="inline-actions">
+            <select id="mqttIata">
+              <optgroup label="ACT">
+                <option value="CBR">CBR - Canberra</option>
+              </optgroup>
+              <optgroup label="New South Wales">
+                <option value="ABX">ABX - Albury</option>
+                <option value="ARM">ARM - Armidale</option>
+                <option value="BHQ">BHQ - Broken Hill</option>
+                <option value="BNK">BNK - Ballina</option>
+                <option value="CFS">CFS - Coffs Harbour</option>
+                <option value="DBO">DBO - Dubbo</option>
+                <option value="GFF">GFF - Griffith</option>
+                <option value="GFN">GFN - Grafton</option>
+                <option value="LDH">LDH - Lord Howe Island</option>
+                <option value="LSY">LSY - Lismore</option>
+                <option value="MIM">MIM - Merimbula</option>
+                <option value="MRZ">MRZ - Moree</option>
+                <option value="MYA">MYA - Moruya</option>
+                <option value="NTL">NTL - Newcastle</option>
+                <option value="OAG">OAG - Orange</option>
+                <option value="PQQ">PQQ - Port Macquarie</option>
+                <option value="SYD">SYD - Sydney</option>
+                <option value="WGA">WGA - Wagga Wagga</option>
+              </optgroup>
+              <optgroup label="Queensland">
+                <option value="ABM">ABM - Bamaga</option>
+                <option value="BNE">BNE - Brisbane</option>
+                <option value="CNS">CNS - Cairns</option>
+                <option value="HTI">HTI - Hamilton Island</option>
+                <option value="HVB">HVB - Hervey Bay</option>
+                <option value="ISA">ISA - Mount Isa</option>
+                <option value="LRE">LRE - Longreach</option>
+                <option value="MCY">MCY - Sunshine Coast</option>
+                <option value="MKY">MKY - Mackay</option>
+                <option value="OOL">OOL - Gold Coast</option>
+                <option value="PPP">PPP - Proserpine</option>
+                <option value="ROK">ROK - Rockhampton</option>
+                <option value="TSV">TSV - Townsville</option>
+                <option value="WEI">WEI - Weipa</option>
+                <option value="WTB">WTB - Toowoomba Wellcamp</option>
+              </optgroup>
+              <optgroup label="South Australia">
+                <option value="ADL">ADL - Adelaide</option>
+                <option value="KGC">KGC - Kingscote</option>
+                <option value="MGB">MGB - Mount Gambier</option>
+                <option value="PLO">PLO - Port Lincoln</option>
+                <option value="WYA">WYA - Whyalla</option>
+              </optgroup>
+              <optgroup label="Tasmania">
+                <option value="BWT">BWT - Burnie</option>
+                <option value="DPO">DPO - Devonport</option>
+                <option value="FLS">FLS - Flinders Island</option>
+                <option value="HBA">HBA - Hobart</option>
+                <option value="KNS">KNS - King Island</option>
+                <option value="LST">LST - Launceston</option>
+              </optgroup>
+              <optgroup label="Victoria">
+                <option value="AVV">AVV - Avalon</option>
+                <option value="GEX">GEX - Geelong West</option>
+                <option value="MEB">MEB - Essendon Fields</option>
+                <option value="MEL" selected>MEL - Melbourne</option>
+                <option value="MQL">MQL - Mildura</option>
+              </optgroup>
+            </select>
+            <button class="iconbtn" data-load-cmd="get mqtt.iata" data-load-input="mqttIata" title="Refresh MQTT IATA">&#8635;</button>
+            <button class="savebtn" data-prefix="set mqtt.iata " data-input="mqttIata">Save</button>
+          </div>
+        </div>
+        <div class="field-card">
+          <label class="label" for="mqttOwner">MQTT Owner</label>
+          <div class="inline-actions">
+            <input id="mqttOwner" placeholder="64-hex-char owner key">
+            <button class="iconbtn" data-load-cmd="get mqtt.owner" data-load-input="mqttOwner" title="Refresh MQTT owner">&#8635;</button>
+            <button class="savebtn" data-prefix="set mqtt.owner " data-input="mqttOwner">Save</button>
+          </div>
+        </div>
+        <div class="field-card">
+          <label class="label" for="mqttEmail">MQTT Email</label>
+          <div class="inline-actions">
+            <input id="mqttEmail" type="email" placeholder="owner@example.com">
+            <button class="iconbtn" data-load-cmd="get mqtt.email" data-load-input="mqttEmail" title="Refresh MQTT email">&#8635;</button>
+            <button class="savebtn" data-prefix="set mqtt.email " data-input="mqttEmail">Save</button>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <section class="card" id="statsPanel" style="display:none">
+      <h2>Stats</h2>
+      <div class="quick" style="margin-bottom:12px">
+        <button id="getStatsBtn">Get Stats</button>
+      </div>
+      <div id="statsDashboard" class="stats-shell">
+        <div class="stats-empty">Press Get Stats to load the dashboard.</div>
+      </div>
+    </section>
+
+  </main>
+  <script>
+    let token = "";
+    let commandQueue = Promise.resolve();
+    const statusEl = document.getElementById("status");
+    const replyEl = document.getElementById("reply");
+    const themeToggleEl = document.getElementById("themeToggle");
+    const rootEl = document.documentElement;
+    function getPreferredTheme() {
+      const saved = localStorage.getItem("repeater-theme");
+      if (saved === "light" || saved === "dark") return saved;
+      return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+    }
+    function applyTheme(theme) {
+      rootEl.dataset.theme = theme;
+      themeToggleEl.textContent = theme === "dark" ? "☀" : "☾";
+      themeToggleEl.title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
+      themeToggleEl.setAttribute("aria-label", themeToggleEl.title);
+    }
+    function toggleTheme() {
+      const next = rootEl.dataset.theme === "dark" ? "light" : "dark";
+      localStorage.setItem("repeater-theme", next);
+      applyTheme(next);
+    }
+    applyTheme(getPreferredTheme());
+    themeToggleEl.onclick = toggleTheme;
+    function appendHistory(cmd, text, ok) {
+      const entry = document.createElement("div");
+      entry.className = "term-entry";
+      const cmdLine = document.createElement("div");
+      cmdLine.className = "term-cmd";
+      cmdLine.textContent = "> " + cmd;
+      const outLine = document.createElement("div");
+      outLine.className = "term-out" + (ok ? "" : " err");
+      outLine.textContent = text && text.length ? text : "OK";
+      entry.appendChild(cmdLine);
+      entry.appendChild(outLine);
+      replyEl.appendChild(entry);
+      replyEl.scrollTop = replyEl.scrollHeight;
+    }
+    function parseReplyValue(text) {
+      return (text || "").replace(/^>\s*/, "").trim();
+    }
+    function clamp(value, min, max) {
+      return Math.min(max, Math.max(min, value));
+    }
+    function pctRange(value, min, max) {
+      if (!Number.isFinite(value) || max <= min) return 0;
+      return clamp(((value - min) * 100) / (max - min), 0, 100);
+    }
+    function pctRatio(value, total) {
+      if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
+      return clamp((value * 100) / total, 0, 100);
+    }
+    function escapeHtml(value) {
+      return String(value == null ? "" : value)
+          .replace(/&/g, "&amp;")
+          .replace(/</g, "&lt;")
+          .replace(/>/g, "&gt;")
+          .replace(/\"/g, "&quot;")
+          .replace(/'/g, "&#39;");
+    }
+    function formatDuration(seconds) {
+      if (!Number.isFinite(seconds)) return "--";
+      const secs = Math.max(0, Math.round(seconds));
+      if (secs < 3600) {
+        return Math.floor(secs / 60) + "m " + (secs % 60) + "s";
+      }
+      if (secs < 86400) {
+        return Math.floor(secs / 3600) + "h " + Math.floor((secs % 3600) / 60) + "m";
+      }
+      return Math.floor(secs / 86400) + "d " + Math.floor((secs % 86400) / 3600) + "h";
+    }
+    function formatBytes(value) {
+      if (!Number.isFinite(value)) return "--";
+      const abs = Math.abs(value);
+      if (abs >= 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + " MB";
+      if (abs >= 1024) return (value / 1024).toFixed(1) + " KB";
+      return Math.round(value) + " B";
+    }
+    function toneForPercent(percent, invert) {
+      if (invert) {
+        if (percent >= 70) return "bad";
+        if (percent >= 35) return "warn";
+        return "";
+      }
+      if (percent >= 75) return "";
+      if (percent >= 40) return "warn";
+      return "bad";
+    }
+    function renderMeter(label, value, percent, note, invert) {
+      const pct = clamp(Math.round(percent), 0, 100);
+      const tone = toneForPercent(pct, invert);
+      return `<div class="hud-row">
+        <div class="hud-kpi">
+          <div>
+            <div class="metric-label">${escapeHtml(label)}</div>
+            <div class="hud-value">${escapeHtml(value)}</div>
+          </div>
+          <div class="hud-sub">${escapeHtml(note)}</div>
+        </div>
+        <div class="meter"><div class="meter-fill${tone ? " " + tone : ""}" style="width:${pct}%"></div></div>
+      </div>`;
+    }
+    function renderMetric(label, value) {
+      return `<div class="metric">
+        <div class="metric-label">${escapeHtml(label)}</div>
+        <div class="metric-value">${escapeHtml(value)}</div>
+      </div>`;
+    }
+    function renderMissingCard(title, message) {
+      return `<section class="hud-card">
+        <h3>${escapeHtml(title)}</h3>
+        <div class="stats-empty">${escapeHtml(message)}</div>
+      </section>`;
+    }
+    function parseJsonReply(text) {
+      const value = parseReplyValue(text);
+      if (!value) return null;
+      try {
+        return JSON.parse(value);
+      } catch (_) {
+        return null;
+      }
+    }
+    function parseKeyedReply(text, keys) {
+      const value = parseReplyValue(text);
+      if (!value) return null;
+      const found = [];
+      for (const key of keys) {
+        const marker = key + ":";
+        const index = value.indexOf(marker);
+        if (index >= 0) {
+          found.push({ key, index });
+        }
+      }
+      if (!found.length) return null;
+      found.sort((a, b) => a.index - b.index);
+      const parsed = {};
+      for (let i = 0; i < found.length; i++) {
+        const current = found[i];
+        const start = current.index + current.key.length + 1;
+        const end = i + 1 < found.length ? found[i + 1].index : value.length;
+        parsed[current.key] = value.slice(start, end).trim();
+      }
+      return parsed;
+    }
+    function parseWifiStatusReply(text) {
+      const parsed = parseKeyedReply(text, ["ssid", "status", "code", "state", "ip", "rssi", "quality", "signal"]);
+      if (!parsed) return null;
+      const rssi = Number.parseInt(parsed.rssi, 10);
+      const quality = Number.parseInt(String(parsed.quality || "").replace("%", ""), 10);
+      const code = Number.parseInt(parsed.code, 10);
+      return {
+        ssid: parsed.ssid || "-",
+        status: parsed.status || "unknown",
+        code: Number.isFinite(code) ? code : null,
+        state: parsed.state || "unknown",
+        ip: parsed.ip || "--",
+        rssi: Number.isFinite(rssi) ? rssi : null,
+        quality: Number.isFinite(quality) ? quality : null,
+        signal: parsed.signal || "--"
+      };
+    }
+    function renderCoreCard(core) {
+      const batteryPct = pctRange(core.battery_mv, 3000, 4200);
+      const queuePct = pctRange(core.queue_len, 0, 12);
+      const errorsPct = core.errors > 0 ? 100 : 0;
+      return `<section class="hud-card">
+        <h3>Core</h3>
+        <div class="core-grid">
+          ${renderMeter("Battery", Math.round(batteryPct) + "%", batteryPct, (core.battery_mv || 0) + " mV", false)}
+          ${renderMeter("Queue", String(core.queue_len ?? 0), queuePct, "outbound packets", true)}
+          ${renderMeter("Errors", String(core.errors ?? 0), errorsPct, "sticky error flags", true)}
+        </div>
+        <div class="metric-grid core-metrics">
+          ${renderMetric("Uptime", formatDuration(core.uptime_secs))}
+          ${renderMetric("Battery", (core.battery_mv || 0) + " mV")}
+          ${renderMetric("Queue", String(core.queue_len ?? 0))}
+          ${renderMetric("Errors", String(core.errors ?? 0))}
+        </div>
+      </section>`;
+    }
+    function renderWifiCard(wifi, powersave) {
+      const qualityPct = Number.isFinite(wifi.quality) ? clamp(wifi.quality, 0, 100) : pctRange(wifi.rssi, -100, -50);
+      const rssiPct = pctRange(wifi.rssi, -100, -50);
+      const statusNote = wifi.status === "connected" ? (wifi.signal || "linked") : (wifi.state || "idle");
+      return `<section class="hud-card">
+        <h3>Wi-Fi</h3>
+        ${renderMeter("Signal Quality", Number.isFinite(wifi.quality) ? wifi.quality + "%" : "--", qualityPct, statusNote, false)}
+        ${renderMeter("RSSI", Number.isFinite(wifi.rssi) ? wifi.rssi + " dBm" : "--", rssiPct, wifi.ssid || "-", false)}
+        <div class="metric-grid">
+          ${renderMetric("Status", wifi.status || "--")}
+          ${renderMetric("State", wifi.state || "--")}
+          ${renderMetric("SSID", wifi.ssid || "-")}
+          ${renderMetric("IP", wifi.ip || "--")}
+          ${renderMetric("Power Save", powersave || "--")}
+          ${renderMetric("Code", wifi.code == null ? "--" : wifi.code)}
+        </div>
+      </section>`;
+    }
+    function renderRadioCard(radio) {
+      const rssiPct = pctRange(radio.last_rssi, -120, -20);
+      const snrPct = pctRange(radio.last_snr, -20, 20);
+      const noisePct = pctRange(radio.noise_floor, -130, -60);
+      const totalAir = (radio.tx_air_secs || 0) + (radio.rx_air_secs || 0);
+      const txShare = pctRatio(radio.tx_air_secs || 0, totalAir);
+      return `<section class="hud-card">
+        <h3>Radio</h3>
+        ${renderMeter("RSSI", (radio.last_rssi ?? "--") + " dBm", rssiPct, "signal strength", false)}
+        ${renderMeter("SNR", Number.isFinite(radio.last_snr) ? radio.last_snr.toFixed(1) + " dB" : "--", snrPct, "link quality", false)}
+        ${renderMeter("Noise Floor", (radio.noise_floor ?? "--") + " dBm", noisePct, "ambient RF", false)}
+        <div class="metric-grid">
+          ${renderMetric("TX Air", String(radio.tx_air_secs ?? 0) + " s")}
+          ${renderMetric("RX Air", String(radio.rx_air_secs ?? 0) + " s")}
+          ${renderMetric("TX Share", Math.round(txShare) + "%")}
+          ${renderMetric("Total Air", String(totalAir) + " s")}
+        </div>
+      </section>`;
+    }
+    function renderPacketsCard(packets) {
+      const sent = packets.sent || 0;
+      const recv = packets.recv || 0;
+      const floodTx = packets.flood_tx || 0;
+      const directTx = packets.direct_tx || 0;
+      const floodRx = packets.flood_rx || 0;
+      const directRx = packets.direct_rx || 0;
+      return `<section class="hud-card">
+        <h3>Packets</h3>
+        ${renderMeter("TX Flood Share", Math.round(pctRatio(floodTx, sent)) + "%", pctRatio(floodTx, sent), floodTx + " flood / " + directTx + " direct", false)}
+        ${renderMeter("RX Flood Share", Math.round(pctRatio(floodRx, recv)) + "%", pctRatio(floodRx, recv), floodRx + " flood / " + directRx + " direct", false)}
+        <div class="metric-grid">
+          ${renderMetric("Sent", sent)}
+          ${renderMetric("Recv", recv)}
+          ${renderMetric("TX Direct", directTx)}
+          ${renderMetric("RX Direct", directRx)}
+          ${renderMetric("Recv Errors", packets.recv_errors || 0)}
+          ${renderMetric("Balance", recv - sent)}
+        </div>
+      </section>`;
+    }
+    function renderMemoryCard(memory) {
+      const heapFree = memory.heap_free || 0;
+      const heapMax = memory.heap_max || 0;
+      const psramFree = memory.psram_free || 0;
+      const psramMax = memory.psram_max || 0;
+      return `<section class="hud-card">
+        <h3>Memory</h3>
+        ${renderMeter("Heap Largest Block", formatBytes(heapMax), pctRatio(heapMax, heapFree), "largest alloc vs free", false)}
+        ${renderMeter("PSRAM Largest Block", formatBytes(psramMax), pctRatio(psramMax, psramFree), "largest alloc vs free", false)}
+        <div class="metric-grid">
+          ${renderMetric("Heap Free", formatBytes(heapFree))}
+          ${renderMetric("Heap Min", formatBytes(memory.heap_min || 0))}
+          ${renderMetric("PSRAM Free", formatBytes(psramFree))}
+          ${renderMetric("PSRAM Min", formatBytes(memory.psram_min || 0))}
+        </div>
+      </section>`;
+    }
+    function renderStatsDashboard(results, errors) {
+      const dashboardEl = document.getElementById("statsDashboard");
+      const notices = errors.length ? `<div class="stats-error">${errors.map((item) => escapeHtml(item)).join("<br>")}</div>` : "";
+      const coreCards = [
+        results.core ? renderCoreCard(results.core) : renderMissingCard("Core", "stats-core unavailable")
+      ];
+      const middleCards = [
+        results.radio ? renderRadioCard(results.radio) : renderMissingCard("Radio", "stats-radio unavailable"),
+        results.memory ? renderMemoryCard(results.memory) : renderMissingCard("Memory", "memory unavailable")
+      ];
+      const lowerCards = [
+        results.wifi ? renderWifiCard(results.wifi, results.wifi_powersave) : renderMissingCard("Wi-Fi", "wifi.status unavailable"),
+        results.packets ? renderPacketsCard(results.packets) : renderMissingCard("Packets", "stats-packets unavailable")
+      ];
+      dashboardEl.innerHTML = notices +
+        `<div class="hud-grid-1">${coreCards.join("")}</div>` +
+        `<div class="hud-grid-2">${middleCards.join("")}</div>` +
+        `<div class="hud-grid-2">${lowerCards.join("")}</div>`;
+    }
+    function showAuthedUi(show) {
+      document.getElementById("login").style.display = show ? "none" : "block";
+      document.getElementById("cliPanel").style.display = show ? "block" : "none";
+      document.getElementById("quickCommandsPanel").style.display = show ? "block" : "none";
+      document.getElementById("mqttSettingsPanel").style.display = show ? "block" : "none";
+      document.getElementById("statsPanel").style.display = show ? "block" : "none";
+      document.getElementById("actionsPanel").style.display = show ? "block" : "none";
+      document.getElementById("repeaterSettingsPanel").style.display = show ? "block" : "none";
+      if (!show) {
+        commandQueue = Promise.resolve();
+        document.getElementById("password").value = "";
+        statusEl.textContent = "";
+        document.getElementById("statsDashboard").innerHTML = '<div class="stats-empty">Press Get Stats to load the dashboard.</div>';
+      }
+    }
+    function queueCommand(task) {
+      const next = commandQueue.then(task, task);
+      commandQueue = next.catch(() => {});
+      return next;
+    }
+    async function runCommand(cmd, options = {}) {
+      if (!token) return { ok:false, text:"" };
+      const recordHistory = options.recordHistory !== false;
+      const updateInput = options.updateInput !== false;
+      return queueCommand(async () => {
+        if (updateInput) {
+          document.getElementById("command").value = cmd;
+        }
+        const res = await fetch("/api/command", { method:"POST", headers:{ "X-Auth-Token": token }, body: cmd });
+        const text = await res.text();
+        if (recordHistory) {
+          appendHistory(cmd, text, res.ok);
+        }
+        return { ok:res.ok, text };
+      });
+    }
+    function runPrefixed(prefix, inputId) {
+      const value = document.getElementById(inputId).value;
+      runCommand(prefix + value);
+    }
+    async function loadField(cmd, inputId, format) {
+      const result = await runCommand(cmd);
+      if (!result.ok) return;
+      let value = parseReplyValue(result.text);
+      if (format === "multiline") {
+        value = value.replace(/\|/g, "\n");
+      }
+      document.getElementById(inputId).value = value;
+    }
+    async function fetchJson(path) {
+      if (!token) return null;
+      return queueCommand(async () => {
+        const res = await fetch(path, { headers:{ "X-Auth-Token": token } });
+        const text = await res.text();
+        if (!res.ok) {
+          throw new Error(text || "request failed");
+        }
+        return JSON.parse(text);
+      });
+    }
+    function applyBootstrapData(data) {
+      if (!data) return;
+      if (typeof data.name === "string") document.getElementById("nodeName").value = data.name;
+      if (typeof data.mqtt_iata === "string" && data.mqtt_iata.length) document.getElementById("mqttIata").value = data.mqtt_iata;
+      if (typeof data.mqtt_owner === "string") document.getElementById("mqttOwner").value = data.mqtt_owner;
+      if (typeof data.mqtt_email === "string") document.getElementById("mqttEmail").value = data.mqtt_email;
+      if (typeof data.advert_interval === "string") document.getElementById("advertInterval").value = data.advert_interval;
+      if (typeof data.flood_interval === "string") document.getElementById("floodInterval").value = data.flood_interval;
+      if (typeof data.flood_max === "string") document.getElementById("floodMax").value = data.flood_max;
+    }
+    async function refreshStats() {
+      const getStatsBtn = document.getElementById("getStatsBtn");
+      getStatsBtn.disabled = true;
+      getStatsBtn.textContent = "Loading...";
+      document.getElementById("statsDashboard").innerHTML = '<div class="stats-empty">Loading dashboard...</div>';
+      const results = {};
+      const errors = [];
+      try {
+        const payload = await fetchJson("/api/stats");
+        if (!payload) throw new Error("no stats payload");
+        if (typeof payload.wifi === "string") {
+          const parsed = parseWifiStatusReply(payload.wifi);
+          if (parsed != null) results.wifi = parsed;
+          else errors.push("get wifi.status: invalid reply");
+        }
+        if (typeof payload.wifi_powersave === "string") {
+          results.wifi_powersave = parseReplyValue(payload.wifi_powersave) || "--";
+        }
+        for (const key of ["core", "radio", "packets", "memory"]) {
+          if (typeof payload[key] === "string") {
+            const parsed = parseJsonReply(payload[key]);
+            if (parsed != null) results[key] = parsed;
+            else errors.push(key + ": invalid reply");
+          }
+        }
+      } catch (error) {
+        errors.push(error && error.message ? error.message : "stats request failed");
+      }
+      renderStatsDashboard(results, errors);
+      getStatsBtn.disabled = false;
+      getStatsBtn.textContent = "Get Stats";
+    }
+    document.getElementById("loginBtn").onclick = async () => {
+      const pwd = document.getElementById("password").value;
+      const res = await fetch("/login", { method:"POST", body: pwd });
+      const text = await res.text();
+      if (!res.ok) {
+        statusEl.textContent = text || "Access denied";
+        return;
+      }
+      token = text.trim();
+      showAuthedUi(true);
+      try {
+        applyBootstrapData(await fetchJson("/api/bootstrap"));
+      } catch (_) {
+        await Promise.all([
+          loadField("get name", "nodeName"),
+          loadField("get mqtt.iata", "mqttIata"),
+          loadField("get mqtt.owner", "mqttOwner"),
+          loadField("get mqtt.email", "mqttEmail"),
+          loadField("get advert.interval", "advertInterval"),
+          loadField("get flood.advert.interval", "floodInterval"),
+          loadField("get flood.max", "floodMax")
+        ]);
+      }
+    };
+    document.getElementById("password").addEventListener("keydown", (event) => {
+      if (event.key === "Enter") {
+        event.preventDefault();
+        document.getElementById("loginBtn").click();
+      }
+    });
+    document.getElementById("runBtn").onclick = () => runCommand(document.getElementById("command").value);
+    document.getElementById("getStatsBtn").onclick = () => refreshStats();
+    document.getElementById("command").addEventListener("keydown", (event) => {
+      if (event.key === "Enter") {
+        event.preventDefault();
+        document.getElementById("runBtn").click();
+      }
+    });
+    document.querySelectorAll("[data-cmd]").forEach((btn) => btn.onclick = () => runCommand(btn.dataset.cmd));
+    document.querySelectorAll("[data-prefix]").forEach((btn) => btn.onclick = () => runPrefixed(btn.dataset.prefix, btn.dataset.input));
+    document.querySelectorAll("[data-load-cmd]").forEach((btn) => btn.onclick = () => loadField(btn.dataset.loadCmd, btn.dataset.loadInput, btn.dataset.loadFormat));
+    document.getElementById("saveOwnerInfo").onclick = () => {
+      const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
+      runCommand("set owner.info " + value);
+    };
+    document.getElementById("rebootBtn").onclick = async () => {
+      if (confirm("Reboot the repeater now?")) {
+        await runCommand("reboot");
+      }
+    };
+    document.getElementById("advertBtn").onclick = async () => {
+      if (confirm("Send an advert now?")) {
+        await runCommand("advert");
+      }
+    };
+    document.getElementById("otaBtn").onclick = async () => {
+      if (confirm("Start OTA mode now?")) {
+        await runCommand("start ota");
+      }
+    };
+    document.getElementById("logoutBtn").onclick = () => {
+      token = "";
+      showAuthedUi(false);
+    };
+    showAuthedUi(false);
+  </script>
+</body>
+</html>
+)HTML";
+
+}  // namespace
+
+WebPanelServer::WebPanelServer()
+    : _runner(nullptr), _server(nullptr), _token{0}, _route_context{this} {
+}
+
+void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) {
+  _runner = runner;
+}
+
+bool WebPanelServer::start() {
+  if (_server != nullptr || _runner == nullptr) {
+    return _server != nullptr;
+  }
+
+  if (_token[0] == 0) {
+    refreshToken();
+  }
+
+  httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
+  config.httpd.max_open_sockets = 2;
+  config.httpd.max_uri_handlers = 5;
+  config.httpd.max_resp_headers = 4;
+  config.httpd.backlog_conn = 2;
+  config.httpd.recv_wait_timeout = 2;
+  config.httpd.send_wait_timeout = 2;
+  config.httpd.stack_size = kWebServerStackSize;
+#if defined(ESP_IDF_VERSION_MAJOR) && ESP_IDF_VERSION_MAJOR >= 5
+  config.servercert = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerCertPem);
+  config.servercert_len = sizeof(mqtt_web_panel_cert::kServerCertPem);
+#else
+  // IDF 4.x uses the misnamed CA slot for the server certificate.
+  config.cacert_pem = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerCertPem);
+  config.cacert_len = sizeof(mqtt_web_panel_cert::kServerCertPem);
+#endif
+  config.prvtkey_pem = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerKeyPem);
+  config.prvtkey_len = sizeof(mqtt_web_panel_cert::kServerKeyPem);
+
+  esp_err_t rc = httpd_ssl_start(&_server, &config);
+  if (rc != ESP_OK) {
+    _server = nullptr;
+    WEB_PANEL_LOG("server start failed rc=0x%x", static_cast<unsigned>(rc));
+    return false;
+  }
+
+  httpd_uri_t index_uri = {.uri = "/", .method = HTTP_GET, .handler = &WebPanelServer::handleIndex, .user_ctx = &_route_context};
+  httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &WebPanelServer::handleLogin, .user_ctx = &_route_context};
+  httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &WebPanelServer::handleCommand, .user_ctx = &_route_context};
+  httpd_uri_t bootstrap_uri = {.uri = "/api/bootstrap", .method = HTTP_GET, .handler = &WebPanelServer::handleBootstrap, .user_ctx = &_route_context};
+  httpd_uri_t stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStats, .user_ctx = &_route_context};
+  httpd_register_uri_handler(_server, &index_uri);
+  httpd_register_uri_handler(_server, &login_uri);
+  httpd_register_uri_handler(_server, &command_uri);
+  httpd_register_uri_handler(_server, &bootstrap_uri);
+  httpd_register_uri_handler(_server, &stats_uri);
+  WEB_PANEL_LOG("server started on https://%s/", WiFi.localIP().toString().c_str());
+  return true;
+}
+
+void WebPanelServer::stop() {
+  if (_server != nullptr) {
+    WEB_PANEL_LOG("server stopped");
+    httpd_ssl_stop(_server);
+    _server = nullptr;
+  }
+  _token[0] = 0;
+}
+
+bool WebPanelServer::isRunning() const {
+  return _server != nullptr;
+}
+
+bool WebPanelServer::hasSessionToken() const {
+  return _token[0] != 0;
+}
+
+esp_err_t WebPanelServer::handleIndex(httpd_req_t* req) {
+  auto* ctx = static_cast<RouteContext*>(req->user_ctx);
+  if (ctx == nullptr || ctx->self == nullptr) {
+    return httpd_resp_send_500(req);
+  }
+  httpd_resp_set_type(req, "text/html; charset=utf-8");
+  httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+  return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN);
+}
+
+esp_err_t WebPanelServer::handleLogin(httpd_req_t* req) {
+  auto* ctx = static_cast<RouteContext*>(req->user_ctx);
+  if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
+    return httpd_resp_send_500(req);
+  }
+
+  char* password = allocScratchBuffer(kWebPasswordBufferSize);
+  if (password == nullptr) {
+    return httpd_resp_send_500(req);
+  }
+
+  if (!ctx->self->readRequestBody(req, password, kWebPasswordBufferSize)) {
+    freeScratchBuffer(password);
+    return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request");
+  }
+
+  if (strcmp(password, ctx->self->_runner->getWebAdminPassword()) != 0) {
+    freeScratchBuffer(password);
+    WEB_PANEL_LOG("login denied");
+    return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Bad password");
+  }
+
+  freeScratchBuffer(password);
+  ctx->self->refreshToken();
+  WEB_PANEL_LOG("login accepted");
+  httpd_resp_set_type(req, "text/plain; charset=utf-8");
+  httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+  return httpd_resp_sendstr(req, ctx->self->_token);
+}
+
+esp_err_t WebPanelServer::handleCommand(httpd_req_t* req) {
+  auto* ctx = static_cast<RouteContext*>(req->user_ctx);
+  if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
+    return httpd_resp_send_500(req);
+  }
+  if (!ctx->self->isAuthorized(req)) {
+    return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
+  }
+
+  char* command = allocScratchBuffer(kWebCommandBufferSize);
+  char* reply = allocScratchBuffer(kWebReplyBufferSize);
+  if (command == nullptr || reply == nullptr) {
+    freeScratchBuffer(command);
+    freeScratchBuffer(reply);
+    return httpd_resp_send_500(req);
+  }
+
+  if (!ctx->self->readRequestBody(req, command, kWebCommandBufferSize)) {
+    freeScratchBuffer(command);
+    freeScratchBuffer(reply);
+    return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request");
+  }
+
+  memset(reply, 0, kWebReplyBufferSize);
+  ctx->self->_runner->runWebCommand(command, reply, kWebReplyBufferSize);
+  httpd_resp_set_type(req, "text/plain; charset=utf-8");
+  httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+  esp_err_t rc = httpd_resp_send(req, reply[0] ? reply : "OK", HTTPD_RESP_USE_STRLEN);
+  freeScratchBuffer(command);
+  freeScratchBuffer(reply);
+  return rc;
+}
+
+esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) {
+  auto* ctx = static_cast<RouteContext*>(req->user_ctx);
+  if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
+    return httpd_resp_send_500(req);
+  }
+  if (!ctx->self->isAuthorized(req)) {
+    return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
+  }
+
+  char* reply = allocScratchBuffer(kWebReplyBufferSize);
+  char* json = allocScratchBuffer(kWebJsonBufferSize);
+  if (reply == nullptr || json == nullptr) {
+    freeScratchBuffer(reply);
+    freeScratchBuffer(json);
+    return httpd_resp_send_500(req);
+  }
+
+  const struct {
+    const char* key;
+    const char* command;
+  } fields[] = {
+      {"name", "get name"},
+      {"mqtt_iata", "get mqtt.iata"},
+      {"mqtt_owner", "get mqtt.owner"},
+      {"mqtt_email", "get mqtt.email"},
+      {"advert_interval", "get advert.interval"},
+      {"flood_interval", "get flood.advert.interval"},
+      {"flood_max", "get flood.max"},
+  };
+
+  size_t offset = 0;
+  json[offset++] = '{';
+  json[offset] = 0;
+  for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) {
+    memset(reply, 0, kWebReplyBufferSize);
+    ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize);
+    const char* value = reply;
+    if (value[0] == '>' && value[1] == ' ') {
+      value += 2;
+    }
+    if (strcmp(value, "-") == 0) {
+      value = "";
+    }
+    if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, value, i != 0)) {
+      freeScratchBuffer(reply);
+      freeScratchBuffer(json);
+      return httpd_resp_send_500(req);
+    }
+  }
+  if (offset + 2 >= kWebJsonBufferSize) {
+    freeScratchBuffer(reply);
+    freeScratchBuffer(json);
+    return httpd_resp_send_500(req);
+  }
+  json[offset++] = '}';
+  json[offset] = 0;
+
+  httpd_resp_set_type(req, "application/json; charset=utf-8");
+  httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+  esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
+  freeScratchBuffer(reply);
+  freeScratchBuffer(json);
+  return rc;
+}
+
+esp_err_t WebPanelServer::handleStats(httpd_req_t* req) {
+  auto* ctx = static_cast<RouteContext*>(req->user_ctx);
+  if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
+    return httpd_resp_send_500(req);
+  }
+  if (!ctx->self->isAuthorized(req)) {
+    return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
+  }
+
+  char* reply = allocScratchBuffer(kWebReplyBufferSize);
+  char* json = allocScratchBuffer(kWebJsonBufferSize);
+  if (reply == nullptr || json == nullptr) {
+    freeScratchBuffer(reply);
+    freeScratchBuffer(json);
+    return httpd_resp_send_500(req);
+  }
+
+  const struct {
+    const char* key;
+    const char* command;
+  } fields[] = {
+      {"wifi", "get wifi.status"},
+      {"wifi_powersave", "get wifi.powersaving"},
+      {"core", "stats-core"},
+      {"radio", "stats-radio"},
+      {"packets", "stats-packets"},
+      {"memory", "memory"},
+  };
+
+  size_t offset = 0;
+  json[offset++] = '{';
+  json[offset] = 0;
+  for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) {
+    memset(reply, 0, kWebReplyBufferSize);
+    ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize);
+    if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, reply, i != 0)) {
+      freeScratchBuffer(reply);
+      freeScratchBuffer(json);
+      return httpd_resp_send_500(req);
+    }
+  }
+  if (offset + 2 >= kWebJsonBufferSize) {
+    freeScratchBuffer(reply);
+    freeScratchBuffer(json);
+    return httpd_resp_send_500(req);
+  }
+  json[offset++] = '}';
+  json[offset] = 0;
+
+  httpd_resp_set_type(req, "application/json; charset=utf-8");
+  httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+  esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
+  freeScratchBuffer(reply);
+  freeScratchBuffer(json);
+  return rc;
+}
+
+bool WebPanelServer::readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const {
+  if (req == nullptr || buffer == nullptr || buffer_size == 0 || req->content_len <= 0 ||
+      req->content_len >= static_cast<int>(buffer_size)) {
+    return false;
+  }
+
+  int remaining = req->content_len;
+  int offset = 0;
+  while (remaining > 0) {
+    int read = httpd_req_recv(req, &buffer[offset], remaining);
+    if (read <= 0) {
+      return false;
+    }
+    offset += read;
+    remaining -= read;
+  }
+  buffer[offset] = 0;
+  return true;
+}
+
+void WebPanelServer::refreshToken() {
+  uint8_t token[16];
+  esp_fill_random(token, sizeof(token));
+  bytesToHexUpper(token, sizeof(token), _token, sizeof(_token));
+}
+
+bool WebPanelServer::isAuthorized(httpd_req_t* req) const {
+  if (_token[0] == 0) {
+    return false;
+  }
+  char token[40];
+  if (httpd_req_get_hdr_value_str(req, "X-Auth-Token", token, sizeof(token)) != ESP_OK) {
+    return false;
+  }
+  return strcmp(token, _token) == 0;
+}
+
+#else
+
+WebPanelServer::WebPanelServer()
+    : _runner(nullptr) {
+}
+
+void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) {
+  _runner = runner;
+}
+
+bool WebPanelServer::start() {
+  return false;
+}
+
+void WebPanelServer::stop() {
+}
+
+bool WebPanelServer::isRunning() const {
+  return false;
+}
+
+bool WebPanelServer::hasSessionToken() const {
+  return false;
+}
+
+#endif

+ 55 - 0
src/helpers/web/WebPanelServer.h

@@ -0,0 +1,55 @@
+#pragma once
+
+#include <stddef.h>
+
+#if defined(ESP_PLATFORM)
+  #include <WiFi.h>
+  #if !defined(WITH_WEB_PANEL)
+    #define WITH_WEB_PANEL 1
+  #endif
+  #if WITH_WEB_PANEL
+    #include <esp_https_server.h>
+  #endif
+#endif
+
+class WebPanelCommandRunner {
+public:
+  virtual ~WebPanelCommandRunner() = default;
+  virtual void runWebCommand(const char* command, char* reply, size_t reply_size) = 0;
+  virtual const char* getWebAdminPassword() const = 0;
+};
+
+class WebPanelServer {
+public:
+  WebPanelServer();
+
+  void setCommandRunner(WebPanelCommandRunner* runner);
+  bool start();
+  void stop();
+  bool isRunning() const;
+  bool hasSessionToken() const;
+
+private:
+#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
+  struct RouteContext {
+    WebPanelServer* self;
+  };
+
+  WebPanelCommandRunner* _runner;
+  httpd_handle_t _server;
+  char _token[33];
+  RouteContext _route_context;
+
+  static esp_err_t handleIndex(httpd_req_t* req);
+  static esp_err_t handleLogin(httpd_req_t* req);
+  static esp_err_t handleCommand(httpd_req_t* req);
+  static esp_err_t handleBootstrap(httpd_req_t* req);
+  static esp_err_t handleStats(httpd_req_t* req);
+
+  bool readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const;
+  void refreshToken();
+  bool isAuthorized(httpd_req_t* req) const;
+#else
+  WebPanelCommandRunner* _runner;
+#endif
+};

+ 1 - 1
variants/eastmesh_mqtt/platformio.ini

@@ -8,7 +8,7 @@ build_flags =
   -D ADMIN_PASSWORD='"password"'
   -D MAX_NEIGHBOURS=50
   -D WITH_MQTT_UPLINK=1
-  ;  -D MQTT_DEBUG=1
+  -D MQTT_DEBUG=1
   -D WIFI_SSID='"myssid"'
   -D WIFI_PWD='"mypwd"'
   -D MQTT_DEFAULT_IATA='"MEL"'