Explorar o código

Merge pull request #46 from xJARiD/develop

feat: add mqtt client version to repeater app and cli
xJARiD hai 3 meses
pai
achega
134262a18d

+ 1 - 0
docs/api.md

@@ -153,6 +153,7 @@ Notes:
 - this summary view is also what the repo's web panel requests first before loading trend series
 - this summary view is also what the repo's web panel requests first before loading trend series
 - if `web.stats` is disabled, the endpoint returns `503 Service Unavailable`
 - if `web.stats` is disabled, the endpoint returns `503 Service Unavailable`
 - supported boards may also include an optional `sensors` object in the summary payload for current GPS and environmental telemetry
 - supported boards may also include an optional `sensors` object in the summary payload for current GPS and environmental telemetry
+- the `core` object includes raw `battery_mv`, board-reported `battery_pct` when available, a UI-ready `battery_display_pct`, and board-specific `battery_min_mv` / `battery_max_mv` range hints used by `/stats` when the board does not expose its own battery percentage
 
 
 ### `GET /api/stats?series=<name>`
 ### `GET /api/stats?series=<name>`
 
 

+ 3 - 2
docs/custom-cli.md

@@ -12,6 +12,7 @@ These commands are available on `*_repeater_mqtt` firmware targets.
 
 
 - `get mqtt.status`: shows WiFi, NTP, IATA, endpoint status, status publishing state, and TX state.
 - `get mqtt.status`: shows WiFi, NTP, IATA, endpoint status, status publishing state, and TX state.
 - `get mqtt.statuscfg`: shows whether periodic status messages are enabled as a simple `on` or `off` value. Most users can just use `get mqtt.status`.
 - `get mqtt.statuscfg`: shows whether periodic status messages are enabled as a simple `on` or `off` value. Most users can just use `get mqtt.status`.
+- `get mqtt.client_version`: shows the MQTT `client_version` string published by the repeater.
 - `get mqtt.iata`: shows the IATA/location code used in MQTT topics.
 - `get mqtt.iata`: shows the IATA/location code used in MQTT topics.
 - `set mqtt.iata <code>`: sets the IATA/location code, for example `MEL`.
 - `set mqtt.iata <code>`: sets the IATA/location code, for example `MEL`.
 - `set mqtt.iata UNSET`: marks MQTT IATA as not configured yet. While it is `UNSET`, enabled MQTT brokers stay disconnected until a real code is saved.
 - `set mqtt.iata UNSET`: marks MQTT IATA as not configured yet. While it is `UNSET`, enabled MQTT brokers stay disconnected until a real code is saved.
@@ -68,7 +69,7 @@ Legacy dotted aliases are also accepted:
 ### Web Panel Controls
 ### Web Panel Controls
 
 
 - `get web`
 - `get web`
-- `get web.status`: shows whether the local HTTPS panel is available. After `start ota`, this reports `web:suspended ota` until the repeater reboots.
+- `get web.status`: shows whether the local HTTPS panel is available.
 - `get web.stats.status`: shows whether the dedicated stats page and history subsystem are enabled, whether recent history is active, whether PSRAM-backed history is available, and whether the SD-backed archive is mounted. When enabled, the history capture now covers supported environment telemetry too, not just the original battery/radio series. GPS-enabled boards also record per-minute satellites samples for the `/stats` history view.
 - `get web.stats.status`: shows whether the dedicated stats page and history subsystem are enabled, whether recent history is active, whether PSRAM-backed history is available, and whether the SD-backed archive is mounted. When enabled, the history capture now covers supported environment telemetry too, not just the original battery/radio series. GPS-enabled boards also record per-minute satellites samples for the `/stats` history view.
 - `set web on|off`
 - `set web on|off`
 - `set.web on|off`: enables or disables the local HTTPS panel.
 - `set.web on|off`: enables or disables the local HTTPS panel.
@@ -122,7 +123,7 @@ Notes:
 - the panel still uses the repeater admin password for access
 - the panel still uses the repeater admin password for access
 - commands run with the same care as if you typed them into the repeater CLI directly
 - commands run with the same care as if you typed them into the repeater CLI directly
 - this is intended for local admin use on a trusted network
 - this is intended for local admin use on a trusted network
-- `start ota` suspends the local repeater web panel until reboot so the OTA HTTP listener can take over port `80`
+- `start ota` releases the local HTTP redirect listener on port `80` so the OTA HTTP listener can take over without stopping the rest of the repeater services
 
 
 ## Companion WiFi Rescue Commands
 ## Companion WiFi Rescue Commands
 
 

+ 13 - 2
docs/web-panel.md

@@ -130,7 +130,7 @@ This section runs common read-only commands for:
 - Wi-Fi
 - Wi-Fi
 - MQTT
 - MQTT
 
 
-These are useful for quick checks without typing into the CLI field.
+These are useful for quick checks without typing into the CLI field. The MQTT quick actions include `mqtt.status`, `mqtt.client_version`, `mqtt.iata`, `mqtt.owner`, and `mqtt.email`.
 
 
 ## Run CLI Command
 ## Run CLI Command
 
 
@@ -149,6 +149,7 @@ This makes it easy to see exactly what the panel sent to the repeater.
 This section includes:
 This section includes:
 
 
 - Device Name
 - Device Name
+- Clock UTC
 - Latitude
 - Latitude
 - Longitude
 - Longitude
 - Guest Password
 - Guest Password
@@ -165,6 +166,14 @@ Notes:
 - the refresh buttons load the current value from the repeater
 - the refresh buttons load the current value from the repeater
 - the save buttons send the matching CLI command immediately
 - the save buttons send the matching CLI command immediately
 
 
+## Info
+
+This section shows:
+
+- `Version`: firmware version with build date
+- `Client Version`: MQTT client version string
+- `Public Key`
+
 ## Ghost Node Mode
 ## Ghost Node Mode
 
 
 Ghost Node Mode is a convenience control on `/app` for a repeater that should stay on Wi-Fi and MQTT, but should not actively behave like another nearby repeater.
 Ghost Node Mode is a convenience control on `/app` for a repeater that should stay on Wi-Fi and MQTT, but should not actively behave like another nearby repeater.
@@ -224,6 +233,8 @@ For boards that expose extra telemetry, the optional `Environment` summary card
 
 
 Metrics with no current value are hidden rather than showing placeholder rows, so the cards vary by board and by current sensor state.
 Metrics with no current value are hidden rather than showing placeholder rows, so the cards vary by board and by current sensor state.
 
 
+The `Core` battery meter prefers a board-reported battery percentage when the target exposes one. Otherwise it scales the displayed percentage from the board's configured battery voltage range rather than assuming a fixed single-cell `3000-4200 mV` pack.
+
 The trend graphs load sequentially rather than as one large payload:
 The trend graphs load sequentially rather than as one large payload:
 
 
 1. summary/status
 1. summary/status
@@ -301,7 +312,7 @@ On mobile:
 
 
 1. Press `Start OTA`.
 1. Press `Start OTA`.
 2. Confirm the action.
 2. Confirm the action.
-3. The local repeater web panel is suspended until reboot so OTA can take over HTTP on port `80`.
+3. The local HTTP redirect listener on port `80` is released so OTA can take over that port.
 4. Continue with your normal OTA workflow.
 4. Continue with your normal OTA workflow.
 
 
 ### Use Historical Stats
 ### Use Historical Stats

+ 21 - 4
examples/simple_repeater/MyMesh.cpp

@@ -28,6 +28,15 @@
 
 
 namespace {
 namespace {
 
 
+int clampBatteryPercentFromRange(uint16_t battery_mv, uint16_t min_mv, uint16_t max_mv) {
+  if (max_mv <= min_mv) {
+    return 0;
+  }
+  const long scaled = (static_cast<long>(battery_mv) - static_cast<long>(min_mv)) * 100L /
+                      static_cast<long>(max_mv - min_mv);
+  return std::max(0L, std::min(100L, scaled));
+}
+
 WebSensorSnapshot collectWebSensorSnapshot(mesh::MainBoard& board, SensorManager& sensors, uint16_t battery_mv) {
 WebSensorSnapshot collectWebSensorSnapshot(mesh::MainBoard& board, SensorManager& sensors, uint16_t battery_mv) {
   WebSensorSnapshot snapshot;
   WebSensorSnapshot snapshot;
   snapshot.has_battery = true;
   snapshot.has_battery = true;
@@ -2047,9 +2056,6 @@ void MyMesh::clearStats() {
 }
 }
 
 
 void MyMesh::prepareForOTAStart() {
 void MyMesh::prepareForOTAStart() {
-#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
-  web.suspendForOTA();
-#endif
 }
 }
 
 
 void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) {
 void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) {
@@ -2279,6 +2285,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
     sprintf(reply, "> %s", mqtt.isStatusEnabled() ? "on" : "off");
     sprintf(reply, "> %s", mqtt.isStatusEnabled() ? "on" : "off");
   } else if (strcmp(command, "get mqtt.status") == 0) {
   } else if (strcmp(command, "get mqtt.status") == 0) {
     mqtt.formatStatusReply(reply, 160);
     mqtt.formatStatusReply(reply, 160);
+  } else if (strcmp(command, "get mqtt.client_version") == 0) {
+    sprintf(reply, "> %s", mqtt.getClientVersion());
   } else if (memcmp(command, "get mqtt.iata", 13) == 0) {
   } else if (memcmp(command, "get mqtt.iata", 13) == 0) {
     sprintf(reply, "> %s", mqtt.getIata());
     sprintf(reply, "> %s", mqtt.getIata());
   } else if (memcmp(command, "get mqtt.owner", 14) == 0) {
   } else if (memcmp(command, "get mqtt.owner", 14) == 0) {
@@ -2481,6 +2489,11 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
 
 
   const uint16_t battery_mv = getBatteryMilliVolts(true);
   const uint16_t battery_mv = getBatteryMilliVolts(true);
   const int battery_pct = board.getBatteryPercent();
   const int battery_pct = board.getBatteryPercent();
+  const uint16_t battery_min_mv = board.getBatteryMinMilliVolts();
+  const uint16_t battery_max_mv = board.getBatteryMaxMilliVolts();
+  const int battery_display_pct =
+      (battery_pct >= 0) ? std::max(0, std::min(100, battery_pct))
+                         : clampBatteryPercentFromRange(battery_mv, battery_min_mv, battery_max_mv);
   const WebSensorSnapshot sensor_snapshot = collectWebSensorSnapshot(board, sensors, battery_mv);
   const WebSensorSnapshot sensor_snapshot = collectWebSensorSnapshot(board, sensors, battery_mv);
   const bool archive_available = (_archive != nullptr) && _archive->isMounted();
   const bool archive_available = (_archive != nullptr) && _archive->isMounted();
 #ifdef WITH_MQTT_UPLINK
 #ifdef WITH_MQTT_UPLINK
@@ -2510,7 +2523,8 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
                      "\"events\":%u,\"event_capacity\":%u},"
                      "\"events\":%u,\"event_capacity\":%u},"
                      "\"archive\":{\"logical\":\"%s\",\"available\":%s,\"path\":\"%s\",\"type\":\"%s\","
                      "\"archive\":{\"logical\":\"%s\",\"available\":%s,\"path\":\"%s\",\"type\":\"%s\","
                      "\"total_bytes\":%llu,\"used_bytes\":%llu},"
                      "\"total_bytes\":%llu,\"used_bytes\":%llu},"
-                     "\"core\":{\"battery_mv\":%u,\"battery_pct\":%d,\"uptime_secs\":%lu,\"errors\":%u,\"queue_len\":%u,"
+                     "\"core\":{\"battery_mv\":%u,\"battery_pct\":%d,\"battery_display_pct\":%d,\"battery_min_mv\":%u,\"battery_max_mv\":%u,"
+                     "\"uptime_secs\":%lu,\"errors\":%u,\"queue_len\":%u,"
                      "\"external_power\":%s,\"charging\":%s,\"vbus\":%s},"
                      "\"external_power\":%s,\"charging\":%s,\"vbus\":%s},"
                      "\"radio\":{\"noise_floor\":%d,\"last_rssi\":%.2f,\"last_snr\":%.2f,\"tx_air_secs\":%lu,\"rx_air_secs\":%lu},"
                      "\"radio\":{\"noise_floor\":%d,\"last_rssi\":%.2f,\"last_snr\":%.2f,\"tx_air_secs\":%lu,\"rx_air_secs\":%lu},"
                      "\"packets\":{\"recv\":%u,\"sent\":%u,\"flood_tx\":%u,\"direct_tx\":%u,\"flood_rx\":%u,\"direct_rx\":%u,"
                      "\"packets\":{\"recv\":%u,\"sent\":%u,\"flood_tx\":%u,\"direct_tx\":%u,\"flood_rx\":%u,\"direct_rx\":%u,"
@@ -2539,6 +2553,9 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
                      static_cast<unsigned long long>(_archive != nullptr ? _archive->getUsedBytes() : 0),
                      static_cast<unsigned long long>(_archive != nullptr ? _archive->getUsedBytes() : 0),
                      battery_mv,
                      battery_mv,
                      battery_pct,
                      battery_pct,
+                     battery_display_pct,
+                     battery_min_mv,
+                     battery_max_mv,
                      static_cast<unsigned long>(uptime_millis / 1000),
                      static_cast<unsigned long>(uptime_millis / 1000),
                      _err_flags,
                      _err_flags,
                      static_cast<unsigned>(_mgr->getOutboundTotal()),
                      static_cast<unsigned>(_mgr->getOutboundTotal()),

+ 30 - 0
release-notes.yml

@@ -425,3 +425,33 @@ releases:
         area: docs
         area: docs
         text: "Updated the custom CLI docs to note that repeater MQTT background battery sampling is now rate-limited while explicit status and telemetry requests still refresh immediately."
         text: "Updated the custom CLI docs to note that repeater MQTT background battery sampling is now rate-limited while explicit status and telemetry requests still refresh immediately."
     breaking_changes: []
     breaking_changes: []
+
+  - track: repeater-mqtt
+    version: "1.3.10"
+    tag: "repeater-mqtt-eastmesh-v1.3.10"
+    date: "2026-04-22"
+    previous_version: "1.3.9"
+    summary: "Made `/stats` battery display board-aware and corrected count-trend rendering for restored packet activity and GPS satellites."
+    changes:
+      - type: changed
+        area: stats
+        text: "Updated the `/stats` Core battery meter to prefer a board-reported battery percentage when available and otherwise scale from a board-specific battery voltage range instead of a fixed single-cell assumption."
+      - type: added
+        area: board-support
+        text: "Added default board battery range hints to the board abstraction and set the T-Beam 1W repeater battery profile to a 2S `6000-8400 mV` range for the web stats path."
+      - type: fixed
+        area: web-panel
+        text: "Corrected `/stats` packet activity and GPS satellites bar charts so restored zero values no longer draw phantom bars and the final bar uses the same spacing as the rest of the series."
+      - type: added
+        area: mqtt
+        text: "Added a `get mqtt.client_version` repeater CLI command and exposed the same value in the `/app` quick MQTT command buttons."
+      - type: changed
+        area: web-panel
+        text: "Reworked the `/app` Info panel to show `Version` and `Client Version`, and moved `Clock UTC` into Repeater Settings beside Device Name."
+      - type: fixed
+        area: web-panel
+        text: "Narrowed `start ota` handling so the repeater only releases the local HTTP redirect listener on port `80`, allowing ElegantOTA to start without unnecessarily stopping the rest of the repeater services."
+      - type: docs
+        area: docs
+        text: "Updated the web panel, API, and custom CLI docs to describe the board-aware battery display, the new MQTT client-version command, and the revised `/app` layout."
+    breaking_changes: []

+ 2 - 0
src/MeshCore.h

@@ -45,6 +45,8 @@ class MainBoard {
 public:
 public:
   virtual uint16_t getBattMilliVolts() = 0;
   virtual uint16_t getBattMilliVolts() = 0;
   virtual int getBatteryPercent() { return -1; }
   virtual int getBatteryPercent() { return -1; }
+  virtual uint16_t getBatteryMinMilliVolts() const { return 3000; }
+  virtual uint16_t getBatteryMaxMilliVolts() const { return 4200; }
   virtual bool isCharging() { return false; }
   virtual bool isCharging() { return false; }
   virtual bool isVbusPresent() { return false; }
   virtual bool isVbusPresent() { return false; }
   virtual float getMCUTemperature() { return NAN; }
   virtual float getMCUTemperature() { return NAN; }

+ 11 - 2
src/helpers/ESP32Board.cpp

@@ -12,7 +12,11 @@
 
 
 bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
 bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
   inhibit_sleep = true;   // prevent sleep during OTA
   inhibit_sleep = true;   // prevent sleep during OTA
-  WiFi.softAP("MeshCore-OTA", NULL);
+  WiFi.mode(WIFI_AP_STA);
+  if (!WiFi.softAP("MeshCore-OTA", NULL)) {
+    strcpy(reply, "Error - OTA AP start failed");
+    return false;
+  }
 
 
   sprintf(reply, "Started: http://%s/update", WiFi.softAPIP().toString().c_str());
   sprintf(reply, "Started: http://%s/update", WiFi.softAPIP().toString().c_str());
   MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
   MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
@@ -22,7 +26,12 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
   static char home_buf[90];
   static char home_buf[90];
   sprintf(home_buf, "<H2>Hi! I am a MeshCore Repeater. ID: %s</H2>", id);
   sprintf(home_buf, "<H2>Hi! I am a MeshCore Repeater. ID: %s</H2>", id);
 
 
-  AsyncWebServer* server = new AsyncWebServer(80);
+  static AsyncWebServer* server = nullptr;
+  if (server != nullptr) {
+    delete server;
+    server = nullptr;
+  }
+  server = new AsyncWebServer(80);
 
 
   server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
   server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
     request->send(200, "text/html", home_buf);
     request->send(200, "text/html", home_buf);

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

@@ -116,6 +116,10 @@ MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity)
   MQTT_LOG("uplink init");
   MQTT_LOG("uplink init");
 }
 }
 
 
+const char* MQTTUplink::getClientVersion() const {
+  return CLIENT_VERSION;
+}
+
 bool MQTTUplink::savePrefs() {
 bool MQTTUplink::savePrefs() {
   return MQTTPrefsStore::save(_fs, _prefs);
   return MQTTPrefsStore::save(_fs, _prefs);
 }
 }

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

@@ -55,6 +55,7 @@ public:
   bool isTxEnabled() const { return _prefs.tx_enabled != 0; }
   bool isTxEnabled() const { return _prefs.tx_enabled != 0; }
   bool setIata(const char* iata);
   bool setIata(const char* iata);
   const char* getIata() const { return _prefs.iata; }
   const char* getIata() const { return _prefs.iata; }
+  const char* getClientVersion() const;
   void setNodeNameSource(const char* node_name) { _node_name = node_name; }
   void setNodeNameSource(const char* node_name) { _node_name = node_name; }
   bool setOwnerPublicKey(const char* owner_public_key);
   bool setOwnerPublicKey(const char* owner_public_key);
   const char* getOwnerPublicKey() const { return _prefs.owner_public_key; }
   const char* getOwnerPublicKey() const { return _prefs.owner_public_key; }

+ 46 - 22
src/helpers/web/WebPanelServer.cpp

@@ -576,6 +576,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
           <span class="label">MQTT</span>
           <span class="label">MQTT</span>
           <div class="quick">
           <div class="quick">
             <button data-cmd="get mqtt.status">mqtt.status</button>
             <button data-cmd="get mqtt.status">mqtt.status</button>
+            <button data-cmd="get mqtt.client_version">mqtt.client_version</button>
             <button data-cmd="get mqtt.iata">mqtt.iata</button>
             <button data-cmd="get mqtt.iata">mqtt.iata</button>
             <button data-cmd="get mqtt.owner">mqtt.owner</button>
             <button data-cmd="get mqtt.owner">mqtt.owner</button>
             <button data-cmd="get mqtt.email">mqtt.email</button>
             <button data-cmd="get mqtt.email">mqtt.email</button>
@@ -599,18 +600,15 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       <div class="stack">
       <div class="stack">
         <div class="row">
         <div class="row">
           <div class="field-card">
           <div class="field-card">
-            <label class="label" for="roleValue">Role</label>
+            <label class="label" for="versionValue">Version</label>
             <div class="fieldline">
             <div class="fieldline">
-              <input id="roleValue" readonly disabled>
-              <span class="placeholder-slot" aria-hidden="true"></span>
+              <input id="versionValue" readonly disabled>
             </div>
             </div>
           </div>
           </div>
           <div class="field-card">
           <div class="field-card">
-            <label class="label" for="clockUtc">Clock UTC</label>
-            <div class="inline-actions">
-              <input id="clockUtc" readonly disabled>
-              <button class="iconbtn" data-load-cmd="clock" data-load-input="clockUtc" title="Refresh clock UTC">&#8635;</button>
-              <button id="syncClockBtn" class="savebtn">Sync</button>
+            <label class="label" for="clientVersionValue">Client Version</label>
+            <div class="fieldline">
+              <input id="clientVersionValue" readonly disabled>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
@@ -629,12 +627,22 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       <div class="stack">
       <div class="stack">
         <div class="section-group">
         <div class="section-group">
           <h3>Repeater Settings</h3>
           <h3>Repeater Settings</h3>
-          <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 class="row">
+            <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="field-card">
+              <label class="label" for="clockUtc">Clock UTC</label>
+              <div class="inline-actions">
+                <input id="clockUtc" readonly disabled>
+                <button class="iconbtn" data-load-cmd="clock" data-load-input="clockUtc" title="Refresh clock UTC">&#8635;</button>
+                <button id="syncClockBtn" class="savebtn">Sync</button>
+              </div>
             </div>
             </div>
           </div>
           </div>
           <div class="row">
           <div class="row">
@@ -1457,13 +1465,20 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       };
       };
     }
     }
     function renderCoreCard(core) {
     function renderCoreCard(core) {
-      const batteryPct = pctRange(core.battery_mv, 3000, 4200);
+      const batteryMin = Number.isFinite(core.battery_min_mv) ? core.battery_min_mv : 3000;
+      const batteryMax = Number.isFinite(core.battery_max_mv) ? core.battery_max_mv : 4200;
+      const batteryPct = Number.isFinite(core.battery_display_pct)
+          ? clamp(core.battery_display_pct, 0, 100)
+          : pctRange(core.battery_mv, batteryMin, batteryMax);
       const queuePct = pctRange(core.queue_len, 0, 12);
       const queuePct = pctRange(core.queue_len, 0, 12);
       const errorsPct = core.errors > 0 ? 100 : 0;
       const errorsPct = core.errors > 0 ? 100 : 0;
+      const batteryDetail = batteryMin < batteryMax
+          ? (core.battery_mv || 0) + " mV (" + batteryMin + "-" + batteryMax + " mV)"
+          : (core.battery_mv || 0) + " mV";
       return `<section class="hud-card">
       return `<section class="hud-card">
         <h3>Core</h3>
         <h3>Core</h3>
         <div class="core-grid">
         <div class="core-grid">
-          ${renderMeter("Battery", Math.round(batteryPct) + "%", batteryPct, (core.battery_mv || 0) + " mV", false)}
+          ${renderMeter("Battery", Math.round(batteryPct) + "%", batteryPct, batteryDetail, false)}
           ${renderMeter("Queue", String(core.queue_len ?? 0), queuePct, "outbound packets", true)}
           ${renderMeter("Queue", String(core.queue_len ?? 0), queuePct, "outbound packets", true)}
           ${renderMeter("Errors", String(core.errors ?? 0), errorsPct, "sticky error flags", true)}
           ${renderMeter("Errors", String(core.errors ?? 0), errorsPct, "sticky error flags", true)}
         </div>
         </div>
@@ -1798,6 +1813,10 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       return baseColor || "#2f8f4e";
       return baseColor || "#2f8f4e";
     }
     }
     function sparkValueRange(key, values) {
     function sparkValueRange(key, values) {
+      if (key === "packets" || key === "gps_satellites") {
+        const maxValue = Math.max(1, ...values);
+        return { min:0, max:maxValue };
+      }
       if (key === "signal") {
       if (key === "signal") {
         return { min:(-125 * 4), max:(-30 * 4) };
         return { min:(-125 * 4), max:(-30 * 4) };
       }
       }
@@ -1881,21 +1900,25 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
         ctx.lineTo(plotRight, y);
         ctx.lineTo(plotRight, y);
         ctx.stroke();
         ctx.stroke();
       });
       });
+      const slotWidth = (plotRight - plotLeft) / Math.max(points.length, 1);
       const coords = points.map((point, index) => ({
       const coords = points.map((point, index) => ({
-        x: (index / Math.max(1, points.length - 1)) * (plotRight - plotLeft) + plotLeft,
+        x: (key === "packets" || key === "gps_satellites")
+            ? (plotLeft + (slotWidth * index) + (slotWidth / 2))
+            : ((index / Math.max(1, points.length - 1)) * (plotRight - plotLeft) + plotLeft),
         y: scaleY(point[1])
         y: scaleY(point[1])
       }));
       }));
       const strokeColor = sparkStrokeColor(key, points);
       const strokeColor = sparkStrokeColor(key, points);
       if (key === "packets" || key === "gps_satellites") {
       if (key === "packets" || key === "gps_satellites") {
-        const slotWidth = (plotRight - plotLeft) / Math.max(points.length, 1);
         const barWidth = Math.max(3, Math.min(18, slotWidth * 0.68));
         const barWidth = Math.max(3, Math.min(18, slotWidth * 0.68));
         const hoverColor = sparkHoverColor(key, strokeColor);
         const hoverColor = sparkHoverColor(key, strokeColor);
         coords.forEach((point, index) => {
         coords.forEach((point, index) => {
-          const left = clamp(point.x - (barWidth / 2), plotLeft, plotRight - barWidth);
+          const left = plotLeft + (slotWidth * index) + ((slotWidth - barWidth) / 2);
           const top = point.y;
           const top = point.y;
-          const barHeight = Math.max(1, plotBottom - top);
+          const barHeight = Math.max(0, plotBottom - top);
           ctx.fillStyle = Number.isInteger(hoverIndex) && hoverIndex === index ? hoverColor : strokeColor;
           ctx.fillStyle = Number.isInteger(hoverIndex) && hoverIndex === index ? hoverColor : strokeColor;
-          ctx.fillRect(left, top, barWidth, barHeight);
+          if (barHeight > 0) {
+            ctx.fillRect(left, top, barWidth, barHeight);
+          }
         });
         });
         return;
         return;
       }
       }
@@ -2516,7 +2539,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       const quiet = { recordHistory:false, updateInput:false };
       const quiet = { recordHistory:false, updateInput:false };
       try {
       try {
         await loadSection("Loading info...", [
         await loadSection("Loading info...", [
-          () => loadField("get role", "roleValue", null, quiet),
+          () => loadField("ver", "versionValue", null, quiet),
+          () => loadField("get mqtt.client_version", "clientVersionValue", null, quiet),
           () => loadField("clock", "clockUtc", null, quiet),
           () => loadField("clock", "clockUtc", null, quiet),
           () => loadField("get public.key", "publicKey", "uppercase", quiet)
           () => loadField("get public.key", "publicKey", "uppercase", quiet)
         ]);
         ]);

+ 8 - 0
variants/lilygo_tbeam_1w/TBeam1WBoard.cpp

@@ -66,6 +66,14 @@ uint16_t TBeam1WBoard::getBattMilliVolts() {
   return static_cast<uint16_t>((raw * 3300 * ADC_MULTIPLIER) / 4095);
   return static_cast<uint16_t>((raw * 3300 * ADC_MULTIPLIER) / 4095);
 }
 }
 
 
+uint16_t TBeam1WBoard::getBatteryMinMilliVolts() const {
+  return 6000;
+}
+
+uint16_t TBeam1WBoard::getBatteryMaxMilliVolts() const {
+  return 8400;
+}
+
 const char* TBeam1WBoard::getManufacturerName() const {
 const char* TBeam1WBoard::getManufacturerName() const {
   return "LilyGo T-Beam 1W";
   return "LilyGo T-Beam 1W";
 }
 }

+ 2 - 0
variants/lilygo_tbeam_1w/TBeam1WBoard.h

@@ -50,6 +50,8 @@ public:
   void onBeforeTransmit() override;
   void onBeforeTransmit() override;
   void onAfterTransmit() override;
   void onAfterTransmit() override;
   uint16_t getBattMilliVolts() override;
   uint16_t getBattMilliVolts() override;
+  uint16_t getBatteryMinMilliVolts() const override;
+  uint16_t getBatteryMaxMilliVolts() const override;
   const char* getManufacturerName() const override;
   const char* getManufacturerName() const override;
   void powerOff() override;
   void powerOff() override;