Explorar o código

fix: improve repeater stats UX and persist ESP32 fallback clock

Jared Dohrman hai 3 meses
pai
achega
d412e3e84a

+ 3 - 3
docs/web-panel.md

@@ -187,9 +187,9 @@ The stats page is loaded separately from `/app` and is intended to keep the main
 
 The `/stats` page currently shows:
 
-- `Services`: MQTT, web, archive, card, neighbour count, and archive capacity
-- `Trends`: battery, heap free, packet activity, and signal
-- `Neighbours`: current neighbour table
+- `Services`: MQTT, web, archive, neighbour count, and, when mounted, card and archive capacity
+- `Trends`: battery, heap free, packet activity, signal, and noise floor
+- `Neighbours`: current neighbour table with ID, SNR, heard age, and advert age
 - `Events`: current boot/session events
 
 The trend graphs load sequentially rather than as one large payload:

+ 6 - 6
examples/simple_repeater/MyMesh.cpp

@@ -1627,17 +1627,14 @@ bool MyMesh::appendJsonNeighbours(char* reply, size_t reply_size, size_t& offset
     mesh::Utils::toHex(full_hex, neighbour->id.pub_key, PUB_KEY_SIZE);
     const uint32_t heard_secs_ago = now_secs - neighbour->heard_timestamp;
     const uint32_t advert_secs_ago = now_secs - neighbour->advert_timestamp;
-    ClientInfo* client = const_cast<ClientACL&>(acl).getClient(neighbour->id.pub_key, PUB_KEY_SIZE);
-    const bool route_known = (client != nullptr && client->out_path_len != OUT_PATH_UNKNOWN);
     offset += snprintf(&reply[offset], reply_size - offset,
-                       "%s{\"id\":\"%s\",\"full_id\":\"%s\",\"heard_secs_ago\":%lu,\"advert_secs_ago\":%lu,\"snr_db\":%.2f,\"route\":\"%s\"}",
+                       "%s{\"id\":\"%s\",\"full_id\":\"%s\",\"heard_secs_ago\":%lu,\"advert_secs_ago\":%lu,\"snr_db\":%.2f}",
                        i == 0 ? "" : ",",
                        hex,
                        full_hex,
                        static_cast<unsigned long>(heard_secs_ago),
                        static_cast<unsigned long>(advert_secs_ago),
-                       static_cast<double>(neighbour->snr) / 4.0,
-                       route_known ? "known" : "unknown");
+                       static_cast<double>(neighbour->snr) / 4.0);
     if (offset >= reply_size) {
       return false;
     }
@@ -2243,8 +2240,10 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
   const bool archive_available = (_archive != nullptr) && _archive->isMounted();
 #ifdef WITH_MQTT_UPLINK
   const bool mqtt_connected = mqtt.isAnyBrokerConnected();
+  const char* mqtt_state = mqtt.getAggregateBrokerState();
 #else
   const bool mqtt_connected = false;
+  const char* mqtt_state = "down";
 #endif
   const bool web_panel_up = web.isPanelRunning();
   const char* archive_name = (_archive != nullptr) ? _archive->getLogicalName() : "archive";
@@ -2267,7 +2266,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
                      "\"recv_errors\":%u,\"direct_dups\":%u,\"flood_dups\":%u,\"neighbors\":%u},"
                      "\"memory\":{\"heap_free\":%u,\"heap_min\":%u,\"heap_max\":%u,\"psram_free\":%u,\"psram_min\":%u,\"psram_max\":%u},"
                      "\"wifi\":{\"ssid\":\"%s\",\"status\":\"%s\",\"connected\":%s,\"state\":\"%s\",\"code\":%d,\"ip\":\"%s\",\"rssi\":%d,\"quality\":%d,\"signal\":\"%s\",\"powersave\":\"%s\"},"
-                     "\"services\":{\"mqtt_connected\":%s,\"web_enabled\":%s,\"web_panel_up\":%s,\"web_auth\":\"%s\","
+                     "\"services\":{\"mqtt_connected\":%s,\"mqtt_state\":\"%s\",\"web_enabled\":%s,\"web_panel_up\":%s,\"web_auth\":\"%s\","
                      "\"archive_available\":%s}",
                      (_stats_history.isEnabled() && _stats_history.isRecentHistoryAvailable()) ? "true" : "false",
                      _stats_history.isPsramBacked() ? "true" : "false",
@@ -2327,6 +2326,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
                      wifi_signal,
                      wifi_powersave,
                      mqtt_connected ? "true" : "false",
+                     mqtt_state,
                      web.isWebEnabled() ? "true" : "false",
                      web_panel_up ? "true" : "false",
                      web.isPanelUnlocked() ? "unlocked" : "locked",

+ 51 - 10
src/helpers/ESP32Board.h

@@ -8,6 +8,7 @@
 #include <rom/rtc.h>
 #include <sys/time.h>
 #include <Wire.h>
+#include <Preferences.h>
 #include "driver/rtc_io.h"
 
 class ESP32Board : public mesh::MainBoard {
@@ -129,16 +130,55 @@ public:
 };
 
 class ESP32RTCClock : public mesh::RTCClock {
+  Preferences _prefs;
+  bool _prefs_ready = false;
+  uint32_t _last_persisted_time = 0;
+  unsigned long _last_persist_ms = 0;
+
+  static constexpr uint32_t kDefaultEpoch = 1715770351;  // 15 May 2024, 8:50pm
+  static constexpr unsigned long kPersistIntervalMs = 15UL * 60UL * 1000UL;
+
+  void applyTime(uint32_t time) {
+    struct timeval tv;
+    tv.tv_sec = time;
+    tv.tv_usec = 0;
+    settimeofday(&tv, NULL);
+  }
+
+  void persistCurrentTime(bool force) {
+    if (!_prefs_ready) {
+      return;
+    }
+
+    const unsigned long now_ms = millis();
+    const uint32_t now = getCurrentTime();
+    if (!force) {
+      if (now == 0 || now == _last_persisted_time) {
+        return;
+      }
+      if ((now_ms - _last_persist_ms) < kPersistIntervalMs) {
+        return;
+      }
+    }
+
+    _prefs.putULong("epoch", now);
+    _last_persisted_time = now;
+    _last_persist_ms = now_ms;
+  }
+
 public:
   ESP32RTCClock() { }
   void begin() {
+    _prefs_ready = _prefs.begin("mesh-clock", false);
+    const uint32_t persisted_epoch = _prefs_ready ? _prefs.getULong("epoch", 0) : 0;
     esp_reset_reason_t reason = esp_reset_reason();
-    if (reason == ESP_RST_POWERON) {
+    if (persisted_epoch > kDefaultEpoch) {
+      applyTime(persisted_epoch);
+      _last_persisted_time = persisted_epoch;
+      _last_persist_ms = millis();
+    } else if (reason == ESP_RST_POWERON) {
       // start with some date/time in the recent past
-      struct timeval tv;
-      tv.tv_sec = 1715770351;  // 15 May 2024, 8:50pm
-      tv.tv_usec = 0;
-      settimeofday(&tv, NULL);
+      applyTime(kDefaultEpoch);
     }
   }
   uint32_t getCurrentTime() override {
@@ -146,11 +186,12 @@ public:
     time(&_now);
     return _now;
   }
-  void setCurrentTime(uint32_t time) override { 
-    struct timeval tv;
-    tv.tv_sec = time;
-    tv.tv_usec = 0;
-    settimeofday(&tv, NULL);
+  void setCurrentTime(uint32_t time) override {
+    applyTime(time);
+    persistCurrentTime(true);
+  }
+  void tick() override {
+    persistCurrentTime(false);
   }
 };
 

+ 74 - 22
src/helpers/StatsHistory.cpp

@@ -188,6 +188,9 @@ int buildPointValue(const HistorySample& sample, const HistorySample* previous,
   if (strcmp(series, "signal") == 0) {
     return static_cast<int>(sample.last_rssi_x4);
   }
+  if (strcmp(series, "noise_floor") == 0) {
+    return static_cast<int>(sample.noise_floor * 4);
+  }
   if (strcmp(series, "packets") == 0) {
     if (previous == nullptr) {
       return 0;
@@ -212,6 +215,9 @@ const char* seriesTitle(const char* series) {
   if (strcmp(series, "signal") == 0) {
     return "Signal";
   }
+  if (strcmp(series, "noise_floor") == 0) {
+    return "Noise Floor";
+  }
   return "";
 }
 
@@ -228,6 +234,9 @@ const char* seriesUnit(const char* series) {
   if (strcmp(series, "signal") == 0) {
     return "rssi_x4";
   }
+  if (strcmp(series, "noise_floor") == 0) {
+    return "noise_floor_x4";
+  }
   return "";
 }
 
@@ -329,6 +338,9 @@ bool StatsHistory::activate() {
       restoreEventsLog();
     }
   }
+  for (size_t i = 0; i < _pending_event_count; ++i) {
+    storeEvent(_pending_events[i], false);
+  }
   return true;
 }
 
@@ -432,6 +444,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
   unsigned queue_len = 0;
   int last_rssi_x4 = 0;
   int last_snr_x4 = 0;
+  int noise_floor = 0;
   unsigned long packets_sent = 0;
   unsigned long packets_recv = 0;
   unsigned long heap_free = 0;
@@ -443,26 +456,58 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
   unsigned flood_dups = 0;
   unsigned flags = 0;
 
-  const int parsed = sscanf(line,
-                            "%lu,%lu,%u,%u,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u",
-                            &epoch_secs,
-                            &uptime_secs,
-                            &battery_mv,
-                            &queue_len,
-                            &last_rssi_x4,
-                            &last_snr_x4,
-                            &packets_sent,
-                            &packets_recv,
-                            &heap_free,
-                            &psram_free,
-                            &error_flags,
-                            &recv_errors,
-                            &neighbour_count,
-                            &direct_dups,
-                            &flood_dups,
-                            &flags);
-  if (parsed != 16) {
-    return false;
+  int parsed = sscanf(line,
+                      "%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u",
+                      &epoch_secs,
+                      &uptime_secs,
+                      &battery_mv,
+                      &queue_len,
+                      &last_rssi_x4,
+                      &last_snr_x4,
+                      &noise_floor,
+                      &packets_sent,
+                      &packets_recv,
+                      &heap_free,
+                      &psram_free,
+                      &error_flags,
+                      &recv_errors,
+                      &neighbour_count,
+                      &direct_dups,
+                      &flood_dups,
+                      &flags);
+  if (parsed != 17) {
+    noise_floor = 0;
+    packets_sent = 0;
+    packets_recv = 0;
+    heap_free = 0;
+    psram_free = 0;
+    error_flags = 0;
+    recv_errors = 0;
+    neighbour_count = 0;
+    direct_dups = 0;
+    flood_dups = 0;
+    flags = 0;
+    parsed = sscanf(line,
+                    "%lu,%lu,%u,%u,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u",
+                    &epoch_secs,
+                    &uptime_secs,
+                    &battery_mv,
+                    &queue_len,
+                    &last_rssi_x4,
+                    &last_snr_x4,
+                    &packets_sent,
+                    &packets_recv,
+                    &heap_free,
+                    &psram_free,
+                    &error_flags,
+                    &recv_errors,
+                    &neighbour_count,
+                    &direct_dups,
+                    &flood_dups,
+                    &flags);
+    if (parsed != 16) {
+      return false;
+    }
   }
 
   memset(&sample, 0, sizeof(sample));
@@ -483,6 +528,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
   sample.flood_dups = static_cast<uint16_t>(flood_dups);
   sample.last_rssi_x4 = static_cast<int16_t>(last_rssi_x4);
   sample.last_snr_x4 = static_cast<int16_t>(last_snr_x4);
+  sample.noise_floor = static_cast<int16_t>(noise_floor);
   sample.flags = static_cast<uint8_t>(flags);
   sample.battery_pct = -1;
   return true;
@@ -687,7 +733,7 @@ void StatsHistory::storeEvent(const HistoryEvent& event, bool queue_pending) {
 }
 
 void StatsHistory::recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptime_secs, int16_t value) {
-  if (!_enabled || !_activated || (_live_only && !isAccessActive(millis())) || _event_capacity == 0 || !ensureBuffers()) {
+  if (!_enabled) {
     return;
   }
 
@@ -696,6 +742,11 @@ void StatsHistory::recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptim
   event.epoch_secs = epoch_secs;
   event.uptime_secs = uptime_secs;
   event.value = value;
+
+  if (!_activated || (_live_only && !isAccessActive(millis())) || _event_capacity == 0 || !ensureBuffers()) {
+    appendPendingEvent(event);
+    return;
+  }
   storeEvent(event, true);
 }
 
@@ -739,13 +790,14 @@ void StatsHistory::flushSummaryLog() {
 
   char line[256];
   snprintf(line, sizeof(line),
-           "%lu,%lu,%u,%u,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n",
+           "%lu,%lu,%u,%u,%d,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n",
            static_cast<unsigned long>(latest.epoch_secs),
            static_cast<unsigned long>(latest.uptime_secs),
            static_cast<unsigned>(latest.battery_mv),
            static_cast<unsigned>(latest.queue_len),
            static_cast<int>(latest.last_rssi_x4),
            static_cast<int>(latest.last_snr_x4),
+           static_cast<int>(latest.noise_floor),
            static_cast<unsigned>(latest.packets_sent),
            static_cast<unsigned>(latest.packets_recv),
            static_cast<unsigned>(latest.heap_free),

+ 24 - 0
src/helpers/mqtt/MQTTUplink.cpp

@@ -957,6 +957,29 @@ bool MQTTUplink::isAnyBrokerConnected() const {
   return false;
 }
 
+const char* MQTTUplink::getAggregateBrokerState() const {
+  uint8_t enabled_count = 0;
+  uint8_t connected_count = 0;
+
+  for (const BrokerState& broker : _brokers) {
+    if (broker.spec == nullptr || (broker.spec->bit & _prefs.enabled_mask) == 0) {
+      continue;
+    }
+    enabled_count++;
+    if (broker.connected) {
+      connected_count++;
+    }
+  }
+
+  if (enabled_count == 0 || connected_count == 0) {
+    return "down";
+  }
+  if (connected_count < enabled_count) {
+    return "degraded";
+  }
+  return "up";
+}
+
 #else
 
 MQTTUplink::MQTTUplink(mesh::RTCClock&, mesh::LocalIdentity&)
@@ -983,6 +1006,7 @@ bool MQTTUplink::setOwnerPublicKey(const char*) { return false; }
 bool MQTTUplink::setOwnerEmail(const char*) { return false; }
 bool MQTTUplink::sendStatusNow() { return false; }
 bool MQTTUplink::isAnyBrokerConnected() const { return false; }
+const char* MQTTUplink::getAggregateBrokerState() const { return "down"; }
 
 #endif
 

+ 1 - 0
src/helpers/mqtt/MQTTUplink.h

@@ -62,6 +62,7 @@ public:
   const char* getOwnerEmail() const { return _prefs.owner_email; }
   bool sendStatusNow();
   bool isAnyBrokerConnected() const;
+  const char* getAggregateBrokerState() const;
   void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; }
 
 private:

+ 151 - 37
src/helpers/web/WebPanelServer.cpp

@@ -256,6 +256,11 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML(
   </main>
   <script>
     const statusEl = document.getElementById("status");
+    const LAST_PAGE_KEY = "repeater-last-page";
+    function getPreferredPage() {
+      const stored = localStorage.getItem(LAST_PAGE_KEY);
+      return stored === "/stats" ? "/stats" : "/app";
+    }
     async function login() {
       const pwd = document.getElementById("password").value;
       const res = await fetch("/login", { method:"POST", body: pwd });
@@ -265,7 +270,7 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML(
         return;
       }
       sessionStorage.setItem("repeater-token", text.trim());
-      window.location.replace("/app");
+      window.location.replace(getPreferredPage());
     }
     document.getElementById("loginBtn").onclick = () => login();
     document.getElementById("password").addEventListener("keydown", (event) => {
@@ -526,7 +531,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       <h1>Repeater Config</h1>
       <p>Use the repeater admin password to unlock the command console.</p>
       <div class="row" style="margin-top:14px">
-        <input id="password" type="password" placeholder="Admin password">
+        <input id="password" type="password" placeholder="Admin password" maxlength="15">
         <button id="loginBtn">Unlock</button>
       </div>
       <div id="status"></div>
@@ -672,7 +677,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
           <div class="field-card">
             <label class="label" for="adminPassword">Admin Password</label>
             <div class="inline-actions">
-              <input id="adminPassword" type="password" placeholder="new admin password">
+              <input id="adminPassword" type="password" placeholder="new admin password" maxlength="15">
 	            <span class="placeholder-slot" aria-hidden="true"></span>
               <button class="savebtn" data-prefix="password " data-input="adminPassword">Save</button>
             </div>
@@ -680,7 +685,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
           <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">
+              <input id="guestPassword" type="password" placeholder="new guest password" maxlength="15">
 	            <span class="placeholder-slot" aria-hidden="true"></span>
               <button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
             </div>
@@ -987,6 +992,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
   <script>
     const RADIO_PRESETS_URL = "https://api.meshcore.nz/api/v1/config";
     const isStatsPage = window.location.pathname === "/stats";
+    const LAST_PAGE_KEY = "repeater-last-page";
     let token = sessionStorage.getItem("repeater-token") || "";
     let commandQueue = Promise.resolve();
     let radioPresetEntries = [];
@@ -995,11 +1001,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
     const replyEl = document.getElementById("reply");
     const themeToggleEl = document.getElementById("themeToggle");
     const rootEl = document.documentElement;
+    function rememberCurrentPage() {
+      localStorage.setItem(LAST_PAGE_KEY, isStatsPage ? "/stats" : "/app");
+    }
     function redirectToLogin() {
+      rememberCurrentPage();
       sessionStorage.removeItem("repeater-token");
       token = "";
       window.location.replace("/");
     }
+    rememberCurrentPage();
     function getPreferredTheme() {
       const saved = localStorage.getItem("repeater-theme");
       if (saved === "light" || saved === "dark") return saved;
@@ -1304,9 +1315,21 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       if (percent >= 40) return "warn";
       return "bad";
     }
-    function renderMeter(label, value, percent, note, invert) {
+    function toneForThreshold(value, greenMin, yellowMin) {
+      if (!Number.isFinite(value)) return "bad";
+      if (value >= greenMin) return "";
+      if (value >= yellowMin) return "warn";
+      return "bad";
+    }
+    function toneForThresholdDescending(value, greenMax, yellowMax) {
+      if (!Number.isFinite(value)) return "bad";
+      if (value <= greenMax) return "";
+      if (value <= yellowMax) return "warn";
+      return "bad";
+    }
+    function renderMeter(label, value, percent, note, toneOrInvert, invertFill = false) {
       const pct = clamp(Math.round(percent), 0, 100);
-      const tone = toneForPercent(pct, invert);
+      const tone = typeof toneOrInvert === "string" ? toneOrInvert : toneForPercent(pct, !!toneOrInvert);
       return `<div class="hud-row">
         <div class="hud-kpi">
           <div>
@@ -1315,7 +1338,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
           </div>
           <div class="hud-sub">${escapeHtml(note)}</div>
         </div>
-        <div class="meter"><div class="meter-fill${tone ? " " + tone : ""}" style="width:${pct}%"></div></div>
+        <div class="meter"><div class="meter-fill${tone ? " " + tone : ""}" style="width:${pct}%;${invertFill ? "margin-left:auto;" : ""}"></div></div>
       </div>`;
     }
     function renderMetric(label, value) {
@@ -1399,12 +1422,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
     }
     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 rssiPct = pctRange(wifi.rssi, -90, -50);
       const statusNote = wifi.status === "connected" ? (wifi.signal || "linked") : (wifi.state || "idle");
+      const qualityTone = Number.isFinite(wifi.rssi)
+        ? toneForThreshold(wifi.rssi, -67, -75)
+        : toneForThreshold(qualityPct, 67, 40);
+      const rssiTone = toneForThreshold(wifi.rssi, -67, -75);
       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)}
+        ${renderMeter("Signal Quality", Number.isFinite(wifi.quality) ? wifi.quality + "%" : "--", qualityPct, statusNote, qualityTone)}
+        ${renderMeter("RSSI", Number.isFinite(wifi.rssi) ? wifi.rssi + " dBm" : "--", rssiPct, wifi.ssid || "-", rssiTone)}
         <div class="metric-grid">
           ${renderMetric("Status", wifi.status || "--")}
           ${renderMetric("State", wifi.state || "--")}
@@ -1416,16 +1443,19 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       </section>`;
     }
     function renderRadioCard(radio) {
-      const rssiPct = pctRange(radio.last_rssi, -130, -90);
-      const snrPct = pctRange(radio.last_snr, -20, 20);
-      const noisePct = pctRange(radio.noise_floor, -60, -113);
+      const rssiPct = pctRange(radio.last_rssi, -125, -30);
+      const snrPct = pctRange(radio.last_snr, -15, 15);
+      const noisePct = 100 - pctRange(radio.noise_floor, -130, -50);
       const totalAir = (radio.tx_air_secs || 0) + (radio.rx_air_secs || 0);
       const txShare = pctRatio(radio.tx_air_secs || 0, totalAir);
+      const rssiTone = toneForThreshold(radio.last_rssi, -90, -110);
+      const snrTone = toneForThreshold(radio.last_snr, 5, -5);
+      const noiseTone = toneForThresholdDescending(radio.noise_floor, -110, -95);
       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)}
+        ${renderMeter("RSSI", (radio.last_rssi ?? "--") + " dBm", rssiPct, "signal strength", rssiTone)}
+        ${renderMeter("SNR", Number.isFinite(radio.last_snr) ? radio.last_snr.toFixed(1) + " dB" : "--", snrPct, "link quality", snrTone)}
+        ${renderMeter("Noise Floor", (radio.noise_floor ?? "--") + " dBm", noisePct, "ambient RF", noiseTone)}
         <div class="metric-grid">
           ${renderMetric("TX Air", String(radio.tx_air_secs ?? 0) + " s")}
           ${renderMetric("RX Air", String(radio.rx_air_secs ?? 0) + " s")}
@@ -1499,17 +1529,19 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       const archiveUsed = archive.used_bytes || 0;
       const archiveFree = Math.max(0, archiveTotal - archiveUsed);
       const archiveFreePct = archiveTotal > 0 ? Math.round((archiveFree / archiveTotal) * 100) : 0;
+      const archiveMetrics = archive.available ? `
+          ${renderMetric("Card", archive.type || "--")}
+          ${renderMetric("Archive Total", formatArchiveGigabytes(archiveTotal))}
+          ${renderMetric("Archive Used", formatArchiveUsed(archiveUsed))}
+          ${renderMetric("Archive Free", archiveTotal > 0 ? (formatArchiveGigabytes(archiveFree) + " (" + archiveFreePct + "%)") : "--")}` : "";
       return `<section class="hud-card">
         <h3>Services</h3>
         <div class="metric-grid">
-          ${renderMetric("MQTT", services.mqtt_connected ? "up" : "down")}
+          ${renderMetric("MQTT", services.mqtt_state || (services.mqtt_connected ? "up" : "down"))}
           ${renderMetric("Web", services.web_panel_up ? services.web_auth || "up" : "down")}
           ${renderMetric("Archive", archive.available ? archive.logical || "archive" : "unavailable")}
           ${renderMetric("Neighbours", packets.neighbors || 0)}
-          ${renderMetric("Card", archive.type || "--")}
-          ${renderMetric("Archive Total", formatArchiveGigabytes(archiveTotal))}
-          ${renderMetric("Archive Used", formatArchiveUsed(archiveUsed))}
-          ${renderMetric("Archive Free", archiveTotal > 0 ? (formatArchiveGigabytes(archiveFree) + " (" + archiveFreePct + "%)") : "--")}
+          ${archiveMetrics}
         </div>
       </section>`;
     }
@@ -1560,7 +1592,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
         <td>${Number.isFinite(neighbour.snr_db) ? neighbour.snr_db.toFixed(2) + " dB" : "--"}</td>
         <td>${escapeHtml(formatDuration(neighbour.heard_secs_ago || 0))}</td>
         <td>${escapeHtml(formatDuration(neighbour.advert_secs_ago || 0))}</td>
-        <td>${escapeHtml(neighbour.route || "unknown")}</td>
       </tr>`).join("");
       return `<section class="hud-card">
         <h3>Neighbours</h3>
@@ -1572,7 +1603,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
                 <th>SNR</th>
                 <th>Heard</th>
                 <th>Advert</th>
-                <th>Route</th>
               </tr>
             </thead>
             <tbody>${rows}</tbody>
@@ -1632,6 +1662,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       if (key === "memory") return formatBytes(value);
       if (key === "packets") return Math.round(value) + " pkts";
       if (key === "signal") return (value / 4).toFixed(1) + " dBm";
+      if (key === "noise_floor") return (value / 4).toFixed(1) + " dBm";
       return String(value);
     }
     function formatArchiveGigabytes(value) {
@@ -1653,6 +1684,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
     function sparkStrokeColor(key, points) {
       if (key === "packets") return "#d97706";
       if (key === "signal") return "#3b82f6";
+      if (key === "noise_floor") return "#ef4444";
       if (key === "memory") {
         const values = Array.isArray(points) ? points.map((item) => item && item[1]).filter((value) => Number.isFinite(value)) : [];
         const current = values.length ? values[values.length - 1] : NaN;
@@ -1666,6 +1698,47 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       }
       return "#2f8f4e";
     }
+    function sparkValueRange(key, values) {
+      if (key === "signal") {
+        return { min:(-125 * 4), max:(-30 * 4) };
+      }
+      if (key === "noise_floor") {
+        return { min:(-130 * 4), max:(-50 * 4) };
+      }
+      let minValue = Math.min(...values);
+      let maxValue = Math.max(...values);
+      if (minValue === maxValue) {
+        minValue -= 1;
+        maxValue += 1;
+      }
+      return { min:minValue, max:maxValue };
+    }
+    function sparkGuideValues(key) {
+      if (key === "signal") {
+        return [-120, -110, -100, -90, -80, -70, -60, -50, -40].map((value) => value * 4);
+      }
+      if (key === "noise_floor") {
+        return [-120, -110, -100, -90, -80, -70, -60, -50].map((value) => value * 4);
+      }
+      return [];
+    }
+    function sparkBands(key) {
+      if (key === "signal") {
+        return [
+          { from:(-125 * 4), to:(-110 * 4), color:"rgba(191,75,75,0.14)" },
+          { from:(-110 * 4), to:(-90 * 4), color:"rgba(215,165,49,0.14)" },
+          { from:(-90 * 4), to:(-30 * 4), color:"rgba(47,143,78,0.12)" }
+        ];
+      }
+      if (key === "noise_floor") {
+        return [
+          { from:(-130 * 4), to:(-110 * 4), color:"rgba(47,143,78,0.12)" },
+          { from:(-110 * 4), to:(-95 * 4), color:"rgba(215,165,49,0.14)" },
+          { from:(-95 * 4), to:(-50 * 4), color:"rgba(191,75,75,0.14)" }
+        ];
+      }
+      return [];
+    }
     function drawSparkline(canvas, points, key, hoverIndex) {
       if (!canvas || !canvas.getContext) return;
       const ctx = canvas.getContext("2d");
@@ -1675,20 +1748,57 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       canvas.height = height * (window.devicePixelRatio || 1);
       ctx.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0);
       ctx.clearRect(0, 0, width, height);
-      if (!Array.isArray(points) || points.length < 2) return;
+      if (!Array.isArray(points) || points.length < 1) return;
       const values = points.map((item) => item[1]).filter((value) => Number.isFinite(value));
-      if (values.length < 2) return;
-      let minValue = Math.min(...values);
-      let maxValue = Math.max(...values);
-      if (minValue === maxValue) {
-        minValue -= 1;
-        maxValue += 1;
-      }
+      if (values.length < 1) return;
+      const range = sparkValueRange(key, values);
+      const minValue = range.min;
+      const maxValue = range.max;
+      const plotLeft = 4;
+      const plotRight = width - 4;
+      const plotTop = 8;
+      const plotBottom = height - 8;
+      const scaleY = (value) => {
+        const clamped = clamp(value, minValue, maxValue);
+        if (key === "noise_floor") {
+          return plotTop + (((clamped - minValue) / (maxValue - minValue)) * (plotBottom - plotTop));
+        }
+        return plotBottom - (((clamped - minValue) / (maxValue - minValue)) * (plotBottom - plotTop));
+      };
+      sparkBands(key).forEach((band) => {
+        const y1 = scaleY(band.from);
+        const y2 = scaleY(band.to);
+        const top = Math.min(y1, y2);
+        const bandHeight = Math.max(1, Math.abs(y2 - y1));
+        ctx.fillStyle = band.color;
+        ctx.fillRect(plotLeft, top, plotRight - plotLeft, bandHeight);
+      });
+      sparkGuideValues(key).forEach((guide) => {
+        const y = scaleY(guide);
+        ctx.strokeStyle = "rgba(75,85,99,0.18)";
+        ctx.lineWidth = 1;
+        ctx.beginPath();
+        ctx.moveTo(plotLeft, y);
+        ctx.lineTo(plotRight, y);
+        ctx.stroke();
+      });
       const coords = points.map((point, index) => ({
-        x: (index / Math.max(1, points.length - 1)) * (width - 8) + 4,
-        y: height - 8 - (((point[1] - minValue) / (maxValue - minValue)) * (height - 16))
+        x: (index / Math.max(1, points.length - 1)) * (plotRight - plotLeft) + plotLeft,
+        y: scaleY(point[1])
       }));
       const strokeColor = sparkStrokeColor(key, points);
+      if (key === "packets") {
+        const slotWidth = (plotRight - plotLeft) / Math.max(points.length, 1);
+        const barWidth = Math.max(3, Math.min(18, slotWidth * 0.68));
+        coords.forEach((point, index) => {
+          const left = clamp(point.x - (barWidth / 2), plotLeft, plotRight - barWidth);
+          const top = point.y;
+          const barHeight = Math.max(1, plotBottom - top);
+          ctx.fillStyle = Number.isInteger(hoverIndex) && hoverIndex === index ? "#f59e0b" : strokeColor;
+          ctx.fillRect(left, top, barWidth, barHeight);
+        });
+        return;
+      }
       ctx.lineWidth = 2;
       ctx.strokeStyle = strokeColor;
       ctx.beginPath();
@@ -1707,7 +1817,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
     }
     function bindSparkHover(canvas, points, key) {
       const tooltip = document.getElementById("tooltip-" + key);
-      if (!canvas || !tooltip || !Array.isArray(points) || points.length < 2) return;
+      if (!canvas || !tooltip || !Array.isArray(points) || points.length < 1) return;
       const updateHover = (index) => {
         drawSparkline(canvas, points, key, index);
         if (index == null || index < 0 || index >= points.length) {
@@ -1786,7 +1896,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
     function initTrendCards() {
       const trendsEl = document.getElementById("statsTrends");
       if (!trendsEl) return;
-      trendsEl.innerHTML = ["battery", "memory", "packets", "signal"].map((key) =>
+      trendsEl.innerHTML = ["battery", "memory", "signal", "noise_floor", "packets"].map((key) =>
         `<section class="trend-card" id="trend-${key}">
           <div class="trend-title">${escapeHtml(key)}</div>
           <div class="spark-axis"><span></span><span>Loading...</span></div>
@@ -1844,7 +1954,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       });
     }
     function runPrefixed(prefix, inputId) {
-      const value = document.getElementById(inputId).value;
+      const input = document.getElementById(inputId);
+      if (!input) return;
+      const maxLength = Number.isFinite(input.maxLength) && input.maxLength > 0 ? input.maxLength : null;
+      const value = maxLength ? input.value.slice(0, maxLength) : input.value;
+      if (value !== input.value) input.value = value;
       runCommand(prefix + value);
     }
     async function loadField(cmd, inputId, format, options = {}) {
@@ -2069,7 +2183,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
         return;
       }
 
-      const seriesOrder = ["battery", "memory", "packets", "signal"];
+      const seriesOrder = ["battery", "memory", "signal", "noise_floor", "packets"];
       for (const key of seriesOrder) {
         try {
           const payload = await fetchJson("/api/stats?series=" + encodeURIComponent(key));