diff --git a/docs/api.md b/docs/api.md index e802504e..f9313785 100644 --- a/docs/api.md +++ b/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 - 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 +- 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=` diff --git a/docs/custom-cli.md b/docs/custom-cli.md index c7237756..3b574cea 100644 --- a/docs/custom-cli.md +++ b/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.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. - `set mqtt.iata `: 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. @@ -68,7 +69,7 @@ Legacy dotted aliases are also accepted: ### Web Panel Controls - `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. - `set web on|off` - `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 - 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 -- `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 diff --git a/docs/web-panel.md b/docs/web-panel.md index 215ca16c..d8bc858c 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -130,7 +130,7 @@ This section runs common read-only commands for: - Wi-Fi - 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 @@ -149,6 +149,7 @@ This makes it easy to see exactly what the panel sent to the repeater. This section includes: - Device Name +- Clock UTC - Latitude - Longitude - Guest Password @@ -165,6 +166,14 @@ Notes: - the refresh buttons load the current value from the repeater - 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 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. +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: 1. summary/status @@ -301,7 +312,7 @@ On mobile: 1. Press `Start OTA`. 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. ### Use Historical Stats diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 465981f2..cf0ede95 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -28,6 +28,15 @@ 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(battery_mv) - static_cast(min_mv)) * 100L / + static_cast(max_mv - min_mv); + return std::max(0L, std::min(100L, scaled)); +} + WebSensorSnapshot collectWebSensorSnapshot(mesh::MainBoard& board, SensorManager& sensors, uint16_t battery_mv) { WebSensorSnapshot snapshot; snapshot.has_battery = true; @@ -2047,9 +2056,6 @@ void MyMesh::clearStats() { } void MyMesh::prepareForOTAStart() { -#if defined(ESP_PLATFORM) && WITH_WEB_PANEL - web.suspendForOTA(); -#endif } 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"); } else if (strcmp(command, "get mqtt.status") == 0) { 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) { sprintf(reply, "> %s", mqtt.getIata()); } 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 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 bool archive_available = (_archive != nullptr) && _archive->isMounted(); #ifdef WITH_MQTT_UPLINK @@ -2510,7 +2523,8 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { "\"events\":%u,\"event_capacity\":%u}," "\"archive\":{\"logical\":\"%s\",\"available\":%s,\"path\":\"%s\",\"type\":\"%s\"," "\"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}," "\"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," @@ -2539,6 +2553,9 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { static_cast(_archive != nullptr ? _archive->getUsedBytes() : 0), battery_mv, battery_pct, + battery_display_pct, + battery_min_mv, + battery_max_mv, static_cast(uptime_millis / 1000), _err_flags, static_cast(_mgr->getOutboundTotal()), diff --git a/release-notes.yml b/release-notes.yml index 7ce3af72..bea35781 100644 --- a/release-notes.yml +++ b/release-notes.yml @@ -425,3 +425,33 @@ releases: 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." 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: [] diff --git a/src/MeshCore.h b/src/MeshCore.h index 3ab58b28..14ac9514 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -45,6 +45,8 @@ class MainBoard { public: virtual uint16_t getBattMilliVolts() = 0; 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 isVbusPresent() { return false; } virtual float getMCUTemperature() { return NAN; } diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index e0ca1d0e..a34cf5e2 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -12,7 +12,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { 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()); MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply); @@ -22,7 +26,12 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { static char home_buf[90]; sprintf(home_buf, "

Hi! I am a MeshCore Repeater. ID: %s

", 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) { request->send(200, "text/html", home_buf); diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index 494a2dae..30ec85ac 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -116,6 +116,10 @@ MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) MQTT_LOG("uplink init"); } +const char* MQTTUplink::getClientVersion() const { + return CLIENT_VERSION; +} + bool MQTTUplink::savePrefs() { return MQTTPrefsStore::save(_fs, _prefs); } diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index 6cce049b..b61ad5a6 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -55,6 +55,7 @@ public: bool isTxEnabled() const { return _prefs.tx_enabled != 0; } bool setIata(const char* iata); const char* getIata() const { return _prefs.iata; } + const char* getClientVersion() const; void setNodeNameSource(const char* node_name) { _node_name = node_name; } bool setOwnerPublicKey(const char* owner_public_key); const char* getOwnerPublicKey() const { return _prefs.owner_public_key; } diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 41e73836..a9ce9aee 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -576,6 +576,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( MQTT
+ @@ -599,18 +600,15 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
- +
- - +
- -
- - - + +
+
@@ -629,12 +627,22 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(

Repeater Settings

-
- -
- - - +
+
+ +
+ + + +
+
+
+ +
+ + + +
@@ -1457,13 +1465,20 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( }; } 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 errorsPct = core.errors > 0 ? 100 : 0; + const batteryDetail = batteryMin < batteryMax + ? (core.battery_mv || 0) + " mV (" + batteryMin + "-" + batteryMax + " mV)" + : (core.battery_mv || 0) + " mV"; return `

Core

- ${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("Errors", String(core.errors ?? 0), errorsPct, "sticky error flags", true)}
@@ -1798,6 +1813,10 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( return baseColor || "#2f8f4e"; } function sparkValueRange(key, values) { + if (key === "packets" || key === "gps_satellites") { + const maxValue = Math.max(1, ...values); + return { min:0, max:maxValue }; + } if (key === "signal") { return { min:(-125 * 4), max:(-30 * 4) }; } @@ -1881,21 +1900,25 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( ctx.lineTo(plotRight, y); ctx.stroke(); }); + const slotWidth = (plotRight - plotLeft) / Math.max(points.length, 1); 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]) })); const strokeColor = sparkStrokeColor(key, points); 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 hoverColor = sparkHoverColor(key, strokeColor); 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 barHeight = Math.max(1, plotBottom - top); + const barHeight = Math.max(0, plotBottom - top); 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; } @@ -2516,7 +2539,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const quiet = { recordHistory:false, updateInput:false }; try { 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("get public.key", "publicKey", "uppercase", quiet) ]); diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index 5a7fb786..28c6c241 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -66,6 +66,14 @@ uint16_t TBeam1WBoard::getBattMilliVolts() { return static_cast((raw * 3300 * ADC_MULTIPLIER) / 4095); } +uint16_t TBeam1WBoard::getBatteryMinMilliVolts() const { + return 6000; +} + +uint16_t TBeam1WBoard::getBatteryMaxMilliVolts() const { + return 8400; +} + const char* TBeam1WBoard::getManufacturerName() const { return "LilyGo T-Beam 1W"; } diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.h b/variants/lilygo_tbeam_1w/TBeam1WBoard.h index 6378c538..e0c7028e 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -50,6 +50,8 @@ public: void onBeforeTransmit() override; void onAfterTransmit() override; uint16_t getBattMilliVolts() override; + uint16_t getBatteryMinMilliVolts() const override; + uint16_t getBatteryMaxMilliVolts() const override; const char* getManufacturerName() const override; void powerOff() override;