Merge pull request #42 from xJARiD/develop

fix: add environment stats and suspend web panel for OTA
This commit is contained in:
xJARiD
2026-04-21 18:23:14 +10:00
committato da GitHub
18 ha cambiato i file con 783 aggiunte e 84 eliminazioni
+3 -4
Vedi File
@@ -2,7 +2,7 @@ name: PR Build Check
on:
pull_request:
branches: [main]
branches: [ main ]
paths:
- "src/**"
- "examples/**"
@@ -23,11 +23,10 @@ jobs:
# ESP32-S3 / core EastMesh targets
- heltec_v4_companion_radio_wifi
- heltec_v4_repeater_mqtt
- LilyGo_TBeam_1W_companion_radio_wifi
- LilyGo_TBeam_1W_repeater_mqtt
- T_Beam_S3_Supreme_SX1262_companion_radio_wifi
- T_Beam_S3_Supreme_SX1262_repeater_mqtt
# ESP32-C6
- LilyGo_Tlora_C6_repeater_mqtt
- Xiao_C6_repeater_mqtt
steps:
- name: Clone Repo
+2 -1
Vedi File
@@ -2,7 +2,7 @@ name: PUSH Build Check
on:
push:
branches: [develop]
branches: [ develop ]
paths:
- "src/**"
- "examples/**"
@@ -22,6 +22,7 @@ jobs:
environment:
# ESP32-S3 / core EastMesh targets
- heltec_v4_repeater_mqtt
- LilyGo_TBeam_1W_repeater_mqtt
- T_Beam_S3_Supreme_SX1262_repeater_mqtt
# ESP32-C6
- LilyGo_Tlora_C6_repeater_mqtt
+10
Vedi File
@@ -152,6 +152,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
### `GET /api/stats?series=<name>`
@@ -164,6 +165,14 @@ Supported series:
- `signal`
- `noise_floor`
- `packets`
- `voltage`
- `sensor_temp`
- `humidity`
- `pressure`
- `pressure_altitude`
- `mcu_temp`
- `gps_altitude`
- `gps_satellites`
Example:
@@ -176,6 +185,7 @@ Notes:
- use `?series=battery`, not just `?series`
- the built-in web panel loads these series sequentially rather than all at once to keep board memory pressure lower
- environment series are included only when the board reports those readings; if a series has no captured points yet, it returns an empty `points` array and `current:null`
### `GET /api/stats?view=legacy`
+3 -2
Vedi File
@@ -68,8 +68,8 @@ Legacy dotted aliases are also accepted:
### Web Panel Controls
- `get web`
- `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.
- `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.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.
- `set web on|off`
- `set.web on|off`: enables or disables the local HTTPS panel.
- `set web.stats on|off`
@@ -123,6 +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`
## Companion WiFi Rescue Commands
+10 -3
Vedi File
@@ -191,10 +191,15 @@ The stats page is loaded separately from `/app` and is intended to keep the main
The `/stats` page currently shows:
- `Services`: MQTT, web, archive, neighbour count, and, when mounted, card and archive capacity
- `Trends`: battery, heap free, packet activity, signal, and noise floor
- optional full-width `Environment` summary card on boards that report GPS or environmental telemetry
- `Trends`: battery, heap free, packet activity, signal, noise floor, and supported environment history such as voltage, temperatures, humidity, barometer, altitude, and, when GPS is enabled, satellites
- `Neighbours`: current neighbour table with ID, SNR, heard age, and advert age
- `Events`: current boot/session events
For boards that expose extra telemetry, the optional `Environment` summary card can show current values such as GPS fix state, latitude, longitude, GPS altitude, voltage, sensor temperature, humidity, barometer, pressure-derived altitude, and MCU temperature.
Metrics with no current value are hidden rather than showing placeholder rows, so the cards vary by board and by current sensor state.
The trend graphs load sequentially rather than as one large payload:
1. summary/status
@@ -202,10 +207,11 @@ The trend graphs load sequentially rather than as one large payload:
3. memory
4. packet activity
5. signal
6. remaining supported environment series
This keeps browser-side and device-side memory use lower than the previous in-page stats view.
If `web.stats` is enabled and an SD archive is mounted, trends can restore archived summary points after reboot. Recent live points are still added from in-memory history.
If `web.stats` is enabled and an SD archive is mounted, trends can restore archived summary points after reboot. Recent live points are still added from in-memory history. This now includes the supported environment series as well as the original core/radio stats.
### Stats History Capacity
@@ -271,7 +277,8 @@ On mobile:
1. Press `Start OTA`.
2. Confirm the action.
3. Continue with your normal OTA workflow.
3. The local repeater web panel is suspended until reboot so OTA can take over HTTP on port `80`.
4. Continue with your normal OTA workflow.
### Use Historical Stats
+324
Vedi File
@@ -1,6 +1,8 @@
#include "MyMesh.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <helpers/sensors/LPPDataHelpers.h>
#if defined(TBEAM_1W)
#include <TBeam1WBoard.h>
@@ -26,6 +28,183 @@
namespace {
struct WebSensorSnapshot {
bool has_supply_voltage = false;
float supply_voltage_v = NAN;
bool has_sensor_temp = false;
float sensor_temp_c = NAN;
bool has_mcu_temp = false;
float mcu_temp_c = NAN;
bool has_humidity = false;
float humidity_pct = NAN;
bool has_pressure = false;
float pressure_hpa = NAN;
bool has_pressure_altitude = false;
float pressure_altitude_m = NAN;
bool has_gps = false;
bool gps_enabled = false;
bool gps_fix = false;
bool has_gps_lat = false;
float gps_lat = NAN;
bool has_gps_lon = false;
float gps_lon = NAN;
bool has_gps_altitude = false;
float gps_altitude_m = NAN;
bool has_satellites = false;
long satellites = 0;
};
WebSensorSnapshot collectWebSensorSnapshot(mesh::MainBoard& board, SensorManager& sensors) {
WebSensorSnapshot snapshot;
CayenneLPP sensor_telemetry(200);
sensor_telemetry.reset();
sensor_telemetry.addVoltage(TELEM_CHANNEL_SELF, static_cast<float>(board.getBattMilliVolts()) / 1000.0f);
sensors.querySensors(0xFF, sensor_telemetry);
const float board_temp_c = board.getMCUTemperature();
if (!isnan(board_temp_c)) {
sensor_telemetry.addTemperature(TELEM_CHANNEL_SELF, board_temp_c);
}
LocationProvider* location = sensors.getLocationProvider();
if (location != nullptr) {
snapshot.has_gps = true;
snapshot.gps_enabled = location->isEnabled();
snapshot.gps_fix = location->isValid();
const long satellites = location->satellitesCount();
if (satellites > 0) {
snapshot.has_satellites = true;
snapshot.satellites = satellites;
}
if (snapshot.gps_fix) {
snapshot.has_gps_lat = true;
snapshot.gps_lat = static_cast<float>(location->getLatitude()) / 1000000.0f;
snapshot.has_gps_lon = true;
snapshot.gps_lon = static_cast<float>(location->getLongitude()) / 1000000.0f;
snapshot.has_gps_altitude = true;
snapshot.gps_altitude_m = static_cast<float>(location->getAltitude()) / 1000.0f;
}
}
float temperatures[4] = {NAN, NAN, NAN, NAN};
size_t temperature_count = 0;
LPPReader reader(sensor_telemetry.getBuffer(), sensor_telemetry.getSize());
uint8_t channel = 0;
uint8_t type = 0;
while (reader.readHeader(channel, type)) {
float value = NAN;
switch (type) {
case LPP_GPS: {
float lat = NAN;
float lon = NAN;
float alt = NAN;
if (reader.readGPS(lat, lon, alt) && !snapshot.has_gps && std::isfinite(lat) && std::isfinite(lon) && std::isfinite(alt)) {
snapshot.has_gps = true;
snapshot.gps_enabled = true;
snapshot.gps_fix = true;
snapshot.has_gps_lat = true;
snapshot.gps_lat = lat;
snapshot.has_gps_lon = true;
snapshot.gps_lon = lon;
snapshot.has_gps_altitude = true;
snapshot.gps_altitude_m = alt;
}
break;
}
case LPP_VOLTAGE:
if (reader.readVoltage(value) && channel == TELEM_CHANNEL_SELF && !snapshot.has_supply_voltage) {
snapshot.has_supply_voltage = std::isfinite(value);
snapshot.supply_voltage_v = value;
}
break;
case LPP_TEMPERATURE:
if (reader.readTemperature(value) && temperature_count < 4 && std::isfinite(value)) {
temperatures[temperature_count++] = value;
}
break;
case LPP_RELATIVE_HUMIDITY:
if (reader.readRelativeHumidity(value) && !snapshot.has_humidity) {
snapshot.has_humidity = std::isfinite(value);
snapshot.humidity_pct = value;
}
break;
case LPP_BAROMETRIC_PRESSURE:
if (reader.readPressure(value) && !snapshot.has_pressure) {
snapshot.has_pressure = std::isfinite(value);
snapshot.pressure_hpa = value;
}
break;
case LPP_ALTITUDE:
if (reader.readAltitude(value) && !snapshot.has_pressure_altitude) {
snapshot.has_pressure_altitude = std::isfinite(value);
snapshot.pressure_altitude_m = value;
}
break;
default:
reader.skipData(type);
break;
}
}
if (temperature_count >= 2) {
snapshot.has_sensor_temp = true;
snapshot.sensor_temp_c = temperatures[0];
snapshot.has_mcu_temp = true;
snapshot.mcu_temp_c = temperatures[temperature_count - 1];
} else if (temperature_count == 1) {
if (snapshot.has_humidity || snapshot.has_pressure || snapshot.has_pressure_altitude) {
snapshot.has_sensor_temp = true;
snapshot.sensor_temp_c = temperatures[0];
} else {
snapshot.has_mcu_temp = true;
snapshot.mcu_temp_c = temperatures[0];
}
}
return snapshot;
}
bool appendJsonBoolField(char* reply, size_t reply_size, size_t& offset, bool& needs_comma, const char* key, bool value) {
const int written = snprintf(&reply[offset], reply_size - offset, "%s\"%s\":%s", needs_comma ? "," : "", key, value ? "true" : "false");
if (written < 0 || static_cast<size_t>(written) >= (reply_size - offset)) {
return false;
}
offset += static_cast<size_t>(written);
needs_comma = true;
return true;
}
bool appendJsonLongField(char* reply, size_t reply_size, size_t& offset, bool& needs_comma, const char* key, long value) {
const int written = snprintf(&reply[offset], reply_size - offset, "%s\"%s\":%ld", needs_comma ? "," : "", key, value);
if (written < 0 || static_cast<size_t>(written) >= (reply_size - offset)) {
return false;
}
offset += static_cast<size_t>(written);
needs_comma = true;
return true;
}
bool appendJsonFloatField(char* reply, size_t reply_size, size_t& offset, bool& needs_comma, const char* key, float value, int precision) {
if (!std::isfinite(value)) {
return true;
}
const int written = snprintf(&reply[offset], reply_size - offset, "%s\"%s\":%.*f", needs_comma ? "," : "", key, precision, value);
if (written < 0 || static_cast<size_t>(written) >= (reply_size - offset)) {
return false;
}
offset += static_cast<size_t>(written);
needs_comma = true;
return true;
}
constexpr unsigned long kArchiveNeighboursFlushIntervalMs = 60UL * 1000UL;
constexpr const char* kArchiveNeighboursSnapshotPath = "/stats/neighbours.snapshot";
@@ -1602,6 +1781,7 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) {
#endif
if (next_history_sample_ms == 0 || millisHasNowPassed(next_history_sample_ms)) {
if (!live_stats_headroom_low) {
WebSensorSnapshot sensor_snapshot = collectWebSensorSnapshot(board, sensors);
HistorySample sample{};
sample.epoch_secs = getRTCClock()->getCurrentTime();
sample.uptime_secs = static_cast<uint32_t>(uptime_millis / 1000);
@@ -1620,6 +1800,56 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) {
sample.last_snr_x4 = static_cast<int16_t>(radio_driver.getLastSNR() * 4.0f);
sample.noise_floor = static_cast<int16_t>(_radio->getNoiseFloor());
sample.battery_pct = static_cast<int8_t>(board.getBatteryPercent());
if (sensor_snapshot.has_supply_voltage && std::isfinite(sensor_snapshot.supply_voltage_v)) {
sample.sensor_flags |= HISTORY_SENSOR_SUPPLY_VOLTAGE;
sample.supply_voltage_centi_v =
static_cast<uint16_t>(min<int>(lroundf(sensor_snapshot.supply_voltage_v * 100.0f), 0xFFFF));
}
if (sensor_snapshot.has_sensor_temp && std::isfinite(sensor_snapshot.sensor_temp_c)) {
sample.sensor_flags |= HISTORY_SENSOR_TEMP;
sample.sensor_temp_deci_c =
static_cast<int16_t>(max<long>(-32768L, min<long>(32767L, lroundf(sensor_snapshot.sensor_temp_c * 10.0f))));
}
if (sensor_snapshot.has_mcu_temp && std::isfinite(sensor_snapshot.mcu_temp_c)) {
sample.sensor_flags |= HISTORY_SENSOR_MCU_TEMP;
sample.mcu_temp_deci_c =
static_cast<int16_t>(max<long>(-32768L, min<long>(32767L, lroundf(sensor_snapshot.mcu_temp_c * 10.0f))));
}
if (sensor_snapshot.has_humidity && std::isfinite(sensor_snapshot.humidity_pct)) {
sample.sensor_flags |= HISTORY_SENSOR_HUMIDITY;
sample.humidity_deci_pct =
static_cast<uint16_t>(min<int>(lroundf(sensor_snapshot.humidity_pct * 10.0f), 0xFFFF));
}
if (sensor_snapshot.has_pressure && std::isfinite(sensor_snapshot.pressure_hpa)) {
sample.sensor_flags |= HISTORY_SENSOR_PRESSURE;
sample.pressure_deci_hpa =
static_cast<uint16_t>(min<int>(lroundf(sensor_snapshot.pressure_hpa * 10.0f), 0xFFFF));
}
if (sensor_snapshot.has_pressure_altitude && std::isfinite(sensor_snapshot.pressure_altitude_m)) {
sample.sensor_flags |= HISTORY_SENSOR_PRESSURE_ALTITUDE;
sample.pressure_altitude_m =
static_cast<int16_t>(max<long>(-32768L, min<long>(32767L, lroundf(sensor_snapshot.pressure_altitude_m))));
}
if (sensor_snapshot.has_gps) sample.sensor_flags |= HISTORY_SENSOR_GPS_PRESENT;
if (sensor_snapshot.gps_enabled) sample.sensor_flags |= HISTORY_SENSOR_GPS_ENABLED;
if (sensor_snapshot.gps_fix) sample.sensor_flags |= HISTORY_SENSOR_GPS_FIX;
if (sensor_snapshot.has_gps_lat && std::isfinite(sensor_snapshot.gps_lat)) {
sample.sensor_flags |= HISTORY_SENSOR_GPS_LAT;
sample.gps_lat_e6 = static_cast<int32_t>(lroundf(sensor_snapshot.gps_lat * 1000000.0f));
}
if (sensor_snapshot.has_gps_lon && std::isfinite(sensor_snapshot.gps_lon)) {
sample.sensor_flags |= HISTORY_SENSOR_GPS_LON;
sample.gps_lon_e6 = static_cast<int32_t>(lroundf(sensor_snapshot.gps_lon * 1000000.0f));
}
if (sensor_snapshot.has_gps_altitude && std::isfinite(sensor_snapshot.gps_altitude_m)) {
sample.sensor_flags |= HISTORY_SENSOR_GPS_ALTITUDE;
sample.gps_altitude_m =
static_cast<int16_t>(max<long>(-32768L, min<long>(32767L, lroundf(sensor_snapshot.gps_altitude_m))));
}
if (sensor_snapshot.has_satellites) {
sample.sensor_flags |= HISTORY_SENSOR_GPS_SATELLITES;
sample.gps_satellites = static_cast<uint8_t>(min<long>(sensor_snapshot.satellites, 255));
}
#if defined(ESP32)
sample.heap_free = free_heap;
sample.heap_min = ESP.getMinFreeHeap();
@@ -1743,6 +1973,12 @@ void MyMesh::clearStats() {
((SimpleMeshTables *)getTables())->resetStats();
}
void MyMesh::prepareForOTAStart() {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
web.suspendForOTA();
#endif
}
void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) {
if (region_load_active) {
if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation
@@ -2275,6 +2511,10 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
return false;
}
offset += snprintf(&reply[offset], reply_size - offset, ",");
if (!appendJsonSensors(reply, reply_size, offset)) {
return false;
}
offset += snprintf(&reply[offset], reply_size - offset, ",");
if (!appendJsonEvents(reply, reply_size, offset)) {
return false;
@@ -2312,6 +2552,90 @@ bool MyMesh::formatWebStatsSeriesJson(const char* series, char* reply, size_t re
#endif
}
bool MyMesh::appendJsonSensors(char* reply, size_t reply_size, size_t& offset) const {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
WebSensorSnapshot snapshot = collectWebSensorSnapshot(board, sensors);
bool needs_comma = false;
const int open_written = snprintf(&reply[offset], reply_size - offset, "\"sensors\":{");
if (open_written < 0 || static_cast<size_t>(open_written) >= (reply_size - offset)) {
return false;
}
offset += static_cast<size_t>(open_written);
if (snapshot.has_gps) {
if (!appendJsonBoolField(reply, reply_size, offset, needs_comma, "gps_enabled", snapshot.gps_enabled)) {
return false;
}
if (!appendJsonBoolField(reply, reply_size, offset, needs_comma, "gps_fix", snapshot.gps_fix)) {
return false;
}
}
if (snapshot.has_satellites) {
if (!appendJsonLongField(reply, reply_size, offset, needs_comma, "satellites", snapshot.satellites)) {
return false;
}
}
if (snapshot.has_gps_lat) {
if (!appendJsonFloatField(reply, reply_size, offset, needs_comma, "gps_lat", snapshot.gps_lat, 6)) {
return false;
}
}
if (snapshot.has_gps_lon) {
if (!appendJsonFloatField(reply, reply_size, offset, needs_comma, "gps_lon", snapshot.gps_lon, 6)) {
return false;
}
}
if (snapshot.has_gps_altitude) {
if (!appendJsonFloatField(reply, reply_size, offset, needs_comma, "gps_altitude_m", snapshot.gps_altitude_m, 0)) {
return false;
}
}
if (snapshot.has_supply_voltage) {
if (!appendJsonFloatField(reply, reply_size, offset, needs_comma, "supply_voltage_v", snapshot.supply_voltage_v, 2)) {
return false;
}
}
if (snapshot.has_sensor_temp) {
if (!appendJsonFloatField(reply, reply_size, offset, needs_comma, "sensor_temp_c", snapshot.sensor_temp_c, 1)) {
return false;
}
}
if (snapshot.has_humidity) {
if (!appendJsonFloatField(reply, reply_size, offset, needs_comma, "humidity_pct", snapshot.humidity_pct, 0)) {
return false;
}
}
if (snapshot.has_pressure) {
if (!appendJsonFloatField(reply, reply_size, offset, needs_comma, "pressure_hpa", snapshot.pressure_hpa, 1)) {
return false;
}
}
if (snapshot.has_pressure_altitude) {
if (!appendJsonFloatField(reply, reply_size, offset, needs_comma, "pressure_altitude_m", snapshot.pressure_altitude_m, 0)) {
return false;
}
}
if (snapshot.has_mcu_temp) {
if (!appendJsonFloatField(reply, reply_size, offset, needs_comma, "mcu_temp_c", snapshot.mcu_temp_c, 1)) {
return false;
}
}
const int close_written = snprintf(&reply[offset], reply_size - offset, "}");
if (close_written < 0 || static_cast<size_t>(close_written) >= (reply_size - offset)) {
return false;
}
offset += static_cast<size_t>(close_written);
return true;
#else
(void)reply;
(void)reply_size;
(void)offset;
return false;
#endif
}
void MyMesh::loop() {
#ifdef WITH_BRIDGE
bridge.loop();
+2
Vedi File
@@ -159,6 +159,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public WebPanelComm
void recordStatsEvent(uint8_t type, int16_t value = 0);
bool appendJsonEvents(char* reply, size_t reply_size, size_t& offset) const;
bool appendJsonNeighbours(char* reply, size_t reply_size, size_t& offset) const;
bool appendJsonSensors(char* reply, size_t reply_size, size_t& offset) const;
uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood);
uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data);
uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data);
@@ -254,6 +255,7 @@ public:
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;
void prepareForOTAStart() override;
void startRegionsLoad() override;
bool saveRegions() override;
void onDefaultRegionChanged(const RegionEntry* r) override;
+14 -2
Vedi File
@@ -371,7 +371,7 @@ releases:
tag: "repeater-mqtt-eastmesh-v1.3.8"
date: "2026-04-21"
previous_version: "1.3.7"
summary: "Fixed repeater web radio preset application, added T-Beam 1W fan controls, and stabilised T-Beam 1W web stats by reusing the radio SPI bus for SD archive access."
summary: "Expanded repeater `/stats` with environment telemetry and persisted trends, and hardened archive startup on shared-SPI boards."
changes:
- type: fixed
area: web-panel
@@ -385,7 +385,19 @@ releases:
- type: fixed
area: stats
text: "Stabilised T-Beam 1W web stats with SD archive enabled by making archive access reuse the same SPI object as the LoRa radio on the board's shared SPI bus."
- type: added
area: web-panel
text: "Added an optional `Environment` section on `/stats` for boards that expose GPS or environmental telemetry, and hid metrics that have no current reading."
- type: added
area: stats
text: "Extended `/stats` history persistence in PSRAM and on the SD-backed archive to capture supported environment telemetry, including voltage, temperatures, humidity, barometer, altitude, and GPS satellites."
- type: added
area: stats
text: "Added environment trend series to `/stats`, including a GPS satellites graph that only appears when GPS is enabled."
- type: fixed
area: board-support
text: "Hardened archive startup on the T-Beam S3 Supreme by providing a safe default shared-archive SPI hook so boards without an override do not crash during early boot."
- type: docs
area: docs
text: "Updated the custom CLI docs for the T-Beam 1W fan controls and their persisted repeater behaviour."
text: "Updated the web panel, API, and custom CLI docs to cover the environment summary/trends, stats persistence behaviour, and existing T-Beam 1W fan controls."
breaking_changes: []
+3
Vedi File
@@ -37,6 +37,9 @@ const char* cardTypeName(uint8_t type) {
#if defined(ESP32)
SPIClass* getBoardSharedArchiveSPI() __attribute__((weak));
SPIClass* getBoardSharedArchiveSPI() {
return nullptr;
}
#endif
ArchiveStorage::ArchiveStorage()
+1
Vedi File
@@ -252,6 +252,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "ERR: clock cannot go backwards");
}
} else if (memcmp(command, "start ota", 9) == 0) {
_callbacks->prepareForOTAStart();
if (!_board->startOTAUpdate(_prefs->node_name, reply)) {
strcpy(reply, "Error");
}
+3
Vedi File
@@ -92,6 +92,9 @@ public:
virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0;
virtual void clearStats() = 0;
virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) = 0;
virtual void prepareForOTAStart() {
// no op by default
}
virtual void startRegionsLoad() {
// no op by default
+264 -44
Vedi File
@@ -26,7 +26,7 @@ namespace {
constexpr uint32_t kSummaryFlushIntervalMs = 5UL * 60UL * 1000UL;
constexpr uint32_t kEventFlushIntervalMs = 60UL * 1000UL;
constexpr size_t kMaxSeriesPoints = 64;
constexpr size_t kSummaryRestoreWindowBytes = 16384;
constexpr size_t kSummaryRestoreWindowBytes = 32768;
constexpr size_t kEventsRestoreWindowBytes = 4096;
constexpr uint32_t kBootAutoCapturePsramMinBytes = 2000000UL;
constexpr size_t kLiveOnlySampleCapacity = 24;
@@ -187,28 +187,90 @@ File openArchiveWriteWithRecovery(ArchiveStorage* archive, const char* filename)
return fs != nullptr ? openArchiveWrite(fs, filename) : File();
}
int buildPointValue(const HistorySample& sample, const HistorySample* previous, const char* series) {
bool buildPointValue(const HistorySample& sample, const HistorySample* previous, const char* series, int& value) {
if (strcmp(series, "battery") == 0) {
return static_cast<int>(sample.battery_mv);
value = static_cast<int>(sample.battery_mv);
return true;
}
if (strcmp(series, "memory") == 0) {
return static_cast<int>(sample.heap_free);
value = static_cast<int>(sample.heap_free);
return true;
}
if (strcmp(series, "signal") == 0) {
return static_cast<int>(sample.last_rssi_x4);
value = static_cast<int>(sample.last_rssi_x4);
return true;
}
if (strcmp(series, "noise_floor") == 0) {
return static_cast<int>(sample.noise_floor * 4);
value = static_cast<int>(sample.noise_floor * 4);
return true;
}
if (strcmp(series, "packets") == 0) {
if (previous == nullptr) {
return 0;
value = 0;
return true;
}
const uint32_t curr_total = sample.packets_sent + sample.packets_recv;
const uint32_t prev_total = previous->packets_sent + previous->packets_recv;
return static_cast<int>(curr_total >= prev_total ? (curr_total - prev_total) : 0);
value = static_cast<int>(curr_total >= prev_total ? (curr_total - prev_total) : 0);
return true;
}
return 0;
if (strcmp(series, "voltage") == 0) {
if ((sample.sensor_flags & HISTORY_SENSOR_SUPPLY_VOLTAGE) == 0) {
return false;
}
value = static_cast<int>(sample.supply_voltage_centi_v);
return true;
}
if (strcmp(series, "sensor_temp") == 0) {
if ((sample.sensor_flags & HISTORY_SENSOR_TEMP) == 0) {
return false;
}
value = static_cast<int>(sample.sensor_temp_deci_c);
return true;
}
if (strcmp(series, "humidity") == 0) {
if ((sample.sensor_flags & HISTORY_SENSOR_HUMIDITY) == 0) {
return false;
}
value = static_cast<int>(sample.humidity_deci_pct);
return true;
}
if (strcmp(series, "pressure") == 0) {
if ((sample.sensor_flags & HISTORY_SENSOR_PRESSURE) == 0) {
return false;
}
value = static_cast<int>(sample.pressure_deci_hpa);
return true;
}
if (strcmp(series, "pressure_altitude") == 0) {
if ((sample.sensor_flags & HISTORY_SENSOR_PRESSURE_ALTITUDE) == 0) {
return false;
}
value = static_cast<int>(sample.pressure_altitude_m);
return true;
}
if (strcmp(series, "mcu_temp") == 0) {
if ((sample.sensor_flags & HISTORY_SENSOR_MCU_TEMP) == 0) {
return false;
}
value = static_cast<int>(sample.mcu_temp_deci_c);
return true;
}
if (strcmp(series, "gps_altitude") == 0) {
if ((sample.sensor_flags & HISTORY_SENSOR_GPS_ALTITUDE) == 0) {
return false;
}
value = static_cast<int>(sample.gps_altitude_m);
return true;
}
if (strcmp(series, "gps_satellites") == 0) {
if ((sample.sensor_flags & HISTORY_SENSOR_GPS_SATELLITES) == 0) {
return false;
}
value = static_cast<int>(sample.gps_satellites);
return true;
}
return false;
}
const char* seriesTitle(const char* series) {
@@ -227,6 +289,30 @@ const char* seriesTitle(const char* series) {
if (strcmp(series, "noise_floor") == 0) {
return "Noise Floor";
}
if (strcmp(series, "voltage") == 0) {
return "Voltage";
}
if (strcmp(series, "sensor_temp") == 0) {
return "Sensor Temp";
}
if (strcmp(series, "humidity") == 0) {
return "Humidity";
}
if (strcmp(series, "pressure") == 0) {
return "Barometer";
}
if (strcmp(series, "pressure_altitude") == 0) {
return "Pressure Altitude";
}
if (strcmp(series, "mcu_temp") == 0) {
return "MCU Temp";
}
if (strcmp(series, "gps_altitude") == 0) {
return "GPS Altitude";
}
if (strcmp(series, "gps_satellites") == 0) {
return "Satellites";
}
return "";
}
@@ -246,6 +332,24 @@ const char* seriesUnit(const char* series) {
if (strcmp(series, "noise_floor") == 0) {
return "noise_floor_x4";
}
if (strcmp(series, "voltage") == 0) {
return "centi_v";
}
if (strcmp(series, "sensor_temp") == 0 || strcmp(series, "mcu_temp") == 0) {
return "deci_c";
}
if (strcmp(series, "humidity") == 0) {
return "deci_pct";
}
if (strcmp(series, "pressure") == 0) {
return "deci_hpa";
}
if (strcmp(series, "pressure_altitude") == 0 || strcmp(series, "gps_altitude") == 0) {
return "m";
}
if (strcmp(series, "gps_satellites") == 0) {
return "count";
}
return "";
}
@@ -486,8 +590,85 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
unsigned direct_dups = 0;
unsigned flood_dups = 0;
unsigned flags = 0;
unsigned supply_voltage_centi_v = 0;
int sensor_temp_deci_c = 0;
int mcu_temp_deci_c = 0;
unsigned humidity_deci_pct = 0;
unsigned pressure_deci_hpa = 0;
int pressure_altitude_m = 0;
int gps_altitude_m = 0;
long gps_lat_e6 = 0;
long gps_lon_e6 = 0;
unsigned sensor_flags = 0;
unsigned gps_satellites = 0;
int parsed = sscanf(line,
"%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u,%u,%d,%d,%u,%u,%d,%d,%ld,%ld,%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,
&supply_voltage_centi_v,
&sensor_temp_deci_c,
&mcu_temp_deci_c,
&humidity_deci_pct,
&pressure_deci_hpa,
&pressure_altitude_m,
&gps_altitude_m,
&gps_lat_e6,
&gps_lon_e6,
&sensor_flags,
&gps_satellites);
if (parsed == 28) {
memset(&sample, 0, sizeof(sample));
sample.epoch_secs = static_cast<uint32_t>(epoch_secs);
sample.uptime_secs = static_cast<uint32_t>(uptime_secs);
sample.packets_sent = static_cast<uint32_t>(packets_sent);
sample.packets_recv = static_cast<uint32_t>(packets_recv);
sample.heap_free = static_cast<uint32_t>(heap_free);
sample.heap_min = static_cast<uint32_t>(heap_free);
sample.psram_free = static_cast<uint32_t>(psram_free);
sample.psram_min = static_cast<uint32_t>(psram_free);
sample.battery_mv = static_cast<uint16_t>(battery_mv);
sample.queue_len = static_cast<uint16_t>(queue_len);
sample.error_flags = static_cast<uint16_t>(error_flags);
sample.recv_errors = static_cast<uint16_t>(recv_errors);
sample.neighbour_count = static_cast<uint16_t>(neighbour_count);
sample.direct_dups = static_cast<uint16_t>(direct_dups);
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.supply_voltage_centi_v = static_cast<uint16_t>(supply_voltage_centi_v);
sample.sensor_temp_deci_c = static_cast<int16_t>(sensor_temp_deci_c);
sample.mcu_temp_deci_c = static_cast<int16_t>(mcu_temp_deci_c);
sample.humidity_deci_pct = static_cast<uint16_t>(humidity_deci_pct);
sample.pressure_deci_hpa = static_cast<uint16_t>(pressure_deci_hpa);
sample.pressure_altitude_m = static_cast<int16_t>(pressure_altitude_m);
sample.gps_altitude_m = static_cast<int16_t>(gps_altitude_m);
sample.gps_lat_e6 = static_cast<int32_t>(gps_lat_e6);
sample.gps_lon_e6 = static_cast<int32_t>(gps_lon_e6);
sample.sensor_flags = static_cast<uint16_t>(sensor_flags);
sample.gps_satellites = static_cast<uint8_t>(gps_satellites);
sample.flags = static_cast<uint8_t>(flags);
sample.battery_pct = -1;
return true;
}
parsed = sscanf(line,
"%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u",
&epoch_secs,
&uptime_secs,
@@ -589,7 +770,7 @@ bool StatsHistory::restoreSummaryLog() {
return false;
}
char line[192];
char line[384];
size_t line_len = 0;
bool skip_partial = (start > 0);
while (file.available()) {
@@ -819,9 +1000,9 @@ void StatsHistory::flushSummaryLog() {
return;
}
char line[256];
char line[384];
snprintf(line, sizeof(line),
"%lu,%lu,%u,%u,%d,%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,%u,%d,%d,%u,%u,%d,%d,%ld,%ld,%u,%u\n",
static_cast<unsigned long>(latest.epoch_secs),
static_cast<unsigned long>(latest.uptime_secs),
static_cast<unsigned>(latest.battery_mv),
@@ -838,7 +1019,18 @@ void StatsHistory::flushSummaryLog() {
static_cast<unsigned>(latest.neighbour_count),
static_cast<unsigned>(latest.direct_dups),
static_cast<unsigned>(latest.flood_dups),
static_cast<unsigned>(latest.flags));
static_cast<unsigned>(latest.flags),
static_cast<unsigned>(latest.supply_voltage_centi_v),
static_cast<int>(latest.sensor_temp_deci_c),
static_cast<int>(latest.mcu_temp_deci_c),
static_cast<unsigned>(latest.humidity_deci_pct),
static_cast<unsigned>(latest.pressure_deci_hpa),
static_cast<int>(latest.pressure_altitude_m),
static_cast<int>(latest.gps_altitude_m),
static_cast<long>(latest.gps_lat_e6),
static_cast<long>(latest.gps_lon_e6),
static_cast<unsigned>(latest.sensor_flags),
static_cast<unsigned>(latest.gps_satellites));
const size_t written = file.print(line);
file.flush();
file.close();
@@ -943,7 +1135,7 @@ bool StatsHistory::buildSeriesJson(const char* series, char* buffer, size_t buff
if (_sample_count == 0) {
snprintf(buffer, buffer_size,
"{\"series\":\"%s\",\"title\":\"%s\",\"unit\":\"%s\",\"interval_secs\":%lu,\"current\":0,\"oldest_age_secs\":0,\"latest_age_secs\":0,\"points\":[]}",
"{\"series\":\"%s\",\"title\":\"%s\",\"unit\":\"%s\",\"interval_secs\":%lu,\"current\":null,\"oldest_age_secs\":0,\"latest_age_secs\":0,\"points\":[]}",
series,
seriesTitle(series),
seriesUnit(series),
@@ -959,55 +1151,83 @@ bool StatsHistory::buildSeriesJson(const char* series, char* buffer, size_t buff
HistorySample previous{};
bool have_previous = false;
int current_value = 0;
offset += snprintf(&buffer[offset], buffer_size - offset,
"{\"series\":\"%s\",\"title\":\"%s\",\"unit\":\"%s\",\"interval_secs\":%lu,\"current\":",
series,
seriesTitle(series),
seriesUnit(series),
static_cast<unsigned long>(kSampleIntervalSecs));
bool have_current_value = false;
getSampleFromOldest(_sample_count - 1, sample);
if (strcmp(series, "packets") == 0 && _sample_count >= 2) {
getSampleFromOldest(_sample_count - 2, previous);
current_value = buildPointValue(sample, &previous, series);
have_current_value = buildPointValue(sample, &previous, series, current_value);
} else {
current_value = buildPointValue(sample, nullptr, series);
have_current_value = buildPointValue(sample, nullptr, series, current_value);
}
size_t last_index = 0;
for (size_t i = 0, emitted = 0; i < _sample_count && emitted < points; i += step, ++emitted) {
last_index = i;
}
HistorySample oldest_sample{};
HistorySample latest_emitted_sample{};
const bool have_oldest_sample = getSampleFromOldest(0, oldest_sample);
const bool have_latest_emitted_sample = getSampleFromOldest(last_index, latest_emitted_sample);
const uint32_t oldest_age_secs = have_oldest_sample ? sampleAgeSecs(oldest_sample, now_epoch_secs, now_uptime_secs) : 0;
const uint32_t latest_age_secs = have_latest_emitted_sample ? sampleAgeSecs(latest_emitted_sample, now_epoch_secs, now_uptime_secs) : 0;
offset += snprintf(&buffer[offset], buffer_size - offset,
"%d,\"oldest_age_secs\":%lu,\"latest_age_secs\":%lu,\"points\":[",
current_value,
static_cast<unsigned long>(oldest_age_secs),
static_cast<unsigned long>(latest_age_secs));
bool have_oldest_sample = false;
bool have_latest_emitted_sample = false;
size_t emitted = 0;
for (size_t i = 0; i < _sample_count && emitted < points; i += step, ++emitted) {
if (!getSampleFromOldest(i, sample)) {
break;
}
int value = buildPointValue(sample, have_previous ? &previous : nullptr, series);
offset += snprintf(&buffer[offset], buffer_size - offset,
"%s[%lu,%d]",
emitted == 0 ? "" : ",",
static_cast<unsigned long>(sample.uptime_secs),
value);
int value = 0;
const bool have_value = buildPointValue(sample, have_previous ? &previous : nullptr, series, value);
if (have_value) {
if (!have_oldest_sample) {
oldest_sample = sample;
have_oldest_sample = true;
}
latest_emitted_sample = sample;
have_latest_emitted_sample = true;
}
previous = sample;
have_previous = true;
if (offset + 24 >= buffer_size) {
}
const uint32_t oldest_age_secs = have_oldest_sample ? sampleAgeSecs(oldest_sample, now_epoch_secs, now_uptime_secs) : 0;
const uint32_t latest_age_secs = have_latest_emitted_sample ? sampleAgeSecs(latest_emitted_sample, now_epoch_secs, now_uptime_secs) : 0;
char current_value_buf[24];
const char* current_json = "null";
if (have_current_value) {
snprintf(current_value_buf, sizeof(current_value_buf), "%d", current_value);
current_json = current_value_buf;
}
const int header_written = snprintf(buffer, buffer_size,
"{\"series\":\"%s\",\"title\":\"%s\",\"unit\":\"%s\",\"interval_secs\":%lu,\"current\":%s,\"oldest_age_secs\":%lu,\"latest_age_secs\":%lu,\"points\":[",
series,
seriesTitle(series),
seriesUnit(series),
static_cast<unsigned long>(kSampleIntervalSecs),
current_json,
static_cast<unsigned long>(oldest_age_secs),
static_cast<unsigned long>(latest_age_secs));
if (header_written < 0 || static_cast<size_t>(header_written) >= buffer_size) {
return false;
}
offset = static_cast<size_t>(header_written);
emitted = 0;
size_t valid_emitted = 0;
have_previous = false;
for (size_t i = 0; i < _sample_count && emitted < points; i += step, ++emitted) {
if (!getSampleFromOldest(i, sample)) {
break;
}
int value = 0;
const bool have_value = buildPointValue(sample, have_previous ? &previous : nullptr, series, value);
if (have_value) {
offset += snprintf(&buffer[offset], buffer_size - offset,
"%s[%lu,%d]",
valid_emitted == 0 ? "" : ",",
static_cast<unsigned long>(sample.uptime_secs),
value);
valid_emitted++;
if (offset + 24 >= buffer_size) {
break;
}
}
previous = sample;
have_previous = true;
}
snprintf(&buffer[offset], buffer_size - offset, "]}");
+28 -1
Vedi File
@@ -24,9 +24,20 @@ struct HistorySample {
int16_t last_rssi_x4;
int16_t last_snr_x4;
int16_t noise_floor;
uint16_t supply_voltage_centi_v;
int16_t sensor_temp_deci_c;
int16_t mcu_temp_deci_c;
uint16_t humidity_deci_pct;
uint16_t pressure_deci_hpa;
int16_t pressure_altitude_m;
int16_t gps_altitude_m;
int32_t gps_lat_e6;
int32_t gps_lon_e6;
uint16_t sensor_flags;
uint8_t gps_satellites;
uint8_t flags;
int8_t battery_pct;
uint16_t reserved;
uint8_t reserved;
};
struct HistoryEvent {
@@ -48,6 +59,22 @@ enum HistorySampleFlags : uint8_t {
HISTORY_FLAG_ARCHIVE_MOUNTED = 1 << 7,
};
enum HistorySampleSensorFlags : uint16_t {
HISTORY_SENSOR_SUPPLY_VOLTAGE = 1 << 0,
HISTORY_SENSOR_TEMP = 1 << 1,
HISTORY_SENSOR_MCU_TEMP = 1 << 2,
HISTORY_SENSOR_HUMIDITY = 1 << 3,
HISTORY_SENSOR_PRESSURE = 1 << 4,
HISTORY_SENSOR_PRESSURE_ALTITUDE = 1 << 5,
HISTORY_SENSOR_GPS_PRESENT = 1 << 6,
HISTORY_SENSOR_GPS_ENABLED = 1 << 7,
HISTORY_SENSOR_GPS_FIX = 1 << 8,
HISTORY_SENSOR_GPS_LAT = 1 << 9,
HISTORY_SENSOR_GPS_LON = 1 << 10,
HISTORY_SENSOR_GPS_ALTITUDE = 1 << 11,
HISTORY_SENSOR_GPS_SATELLITES = 1 << 12,
};
enum HistoryEventType : uint8_t {
HISTORY_EVENT_BOOT = 1,
HISTORY_EVENT_WEB_STARTED = 2,
+95 -23
Vedi File
@@ -484,6 +484,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
.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; }
.metric-grid-4 { grid-template-columns:repeat(4,minmax(0,1fr)); }
.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)); }
@@ -1389,6 +1390,18 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
<div class="metric-value">${escapeHtml(value)}</div>
</div>`;
}
function hasMetricValue(value) {
if (value == null) return false;
if (typeof value === "number") return Number.isFinite(value);
return String(value).trim() !== "";
}
function renderMetricList(metrics, gridClass = "") {
const items = (Array.isArray(metrics) ? metrics : []).filter((item) => item && hasMetricValue(item.value));
if (!items.length) return "";
const classes = ["metric-grid"];
if (gridClass) classes.push(gridClass);
return `<div class="${classes.join(" ")}">${items.map((item) => renderMetric(item.label, item.value)).join("")}</div>`;
}
function renderMissingCard(title, message) {
return `<section class="hud-card">
<h3>${escapeHtml(title)}</h3>
@@ -1575,20 +1588,44 @@ 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 + "%)") : "--")}` : "";
const metrics = [
{ label:"MQTT", value:services.mqtt_state || (services.mqtt_connected ? "up" : "down") },
{ label:"Web", value:services.web_panel_up ? services.web_auth || "up" : "down" },
{ label:"Archive", value:archive.available ? archive.logical || "archive" : "unavailable" },
{ label:"Neighbours", value:packets.neighbors || 0 }
];
if (archive.available) {
metrics.push(
{ label:"Card", value:archive.type || "--" },
{ label:"Archive Total", value:formatArchiveGigabytes(archiveTotal) },
{ label:"Archive Used", value:formatArchiveUsed(archiveUsed) },
{ label:"Archive Free", value:archiveTotal > 0 ? (formatArchiveGigabytes(archiveFree) + " (" + archiveFreePct + "%)") : "--" }
);
}
return `<section class="hud-card">
<h3>Services</h3>
<div class="metric-grid">
${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)}
${archiveMetrics}
</div>
${renderMetricList(metrics, "metric-grid-4")}
</section>`;
}
function renderSensorsCard(sensorSummary) {
const sensors = sensorSummary && typeof sensorSummary === "object" ? sensorSummary : {};
const metrics = [
Number.isFinite(sensors.supply_voltage_v) ? { label:"Voltage", value:sensors.supply_voltage_v.toFixed(2) + " V" } : null,
Number.isFinite(sensors.sensor_temp_c) ? { label:"Sensor Temp", value:sensors.sensor_temp_c.toFixed(1) + " C" } : null,
Number.isFinite(sensors.humidity_pct) ? { label:"Humidity", value:Math.round(sensors.humidity_pct) + " %" } : null,
Number.isFinite(sensors.pressure_hpa) ? { label:"Barometer", value:sensors.pressure_hpa.toFixed(1) + " hPa" } : null,
Number.isFinite(sensors.pressure_altitude_m) ? { label:"Pressure Altitude", value:Math.round(sensors.pressure_altitude_m) + " m" } : null,
Number.isFinite(sensors.mcu_temp_c) ? { label:"MCU Temp", value:sensors.mcu_temp_c.toFixed(1) + " C" } : null,
typeof sensors.gps_enabled === "boolean" ? { label:"GPS", value:sensors.gps_fix ? "Fix" : (sensors.gps_enabled ? "Searching" : "Off") } : null,
Number.isFinite(sensors.satellites) ? { label:"Satellites", value:String(Math.round(sensors.satellites)) } : null,
Number.isFinite(sensors.gps_lat) ? { label:"Latitude", value:sensors.gps_lat.toFixed(6) } : null,
Number.isFinite(sensors.gps_lon) ? { label:"Longitude", value:sensors.gps_lon.toFixed(6) } : null,
Number.isFinite(sensors.gps_altitude_m) ? { label:"GPS Altitude", value:Math.round(sensors.gps_altitude_m) + " m" } : null
];
if (!renderMetricList(metrics)) return "";
return `<section class="hud-card">
<h3>Environment</h3>
${renderMetricList(metrics, "metric-grid-4")}
</section>`;
}
function renderEventsSection(events) {
@@ -1668,6 +1705,14 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
renderStatsDashboard(results, [], "statsSummary");
const summaryEl = document.getElementById("statsSummary");
if (!summaryEl) return;
const sensorCards = [
renderSensorsCard(payload && payload.sensors ? payload.sensors : null)
].filter((card) => card);
if (sensorCards.length > 0) {
sensorCards.forEach((card) => {
summaryEl.innerHTML += `<div class="hud-grid-1">${card}</div>`;
});
}
summaryEl.innerHTML += `<div class="hud-grid-1">${renderServicesCard(payload)}</div>`;
const introEl = document.getElementById("statsTrendIntro");
if (introEl) {
@@ -1705,10 +1750,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
function formatTrendValue(key, value) {
if (!Number.isFinite(value)) return "--";
if (key === "battery") return Math.round(value) + " mV";
if (key === "voltage") return (value / 100).toFixed(2) + " V";
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";
if (key === "sensor_temp" || key === "mcu_temp") return (value / 10).toFixed(1) + " C";
if (key === "humidity") return (value / 10).toFixed(1) + " %";
if (key === "pressure") return (value / 10).toFixed(1) + " hPa";
if (key === "pressure_altitude" || key === "gps_altitude") return Math.round(value) + " m";
if (key === "gps_satellites") return Math.round(value) + " sats";
return String(value);
}
function formatArchiveGigabytes(value) {
@@ -1935,10 +1986,20 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
axisRightEl.style.color = "var(--status-red)";
}
}
function initTrendCards() {
function getTrendSeriesOrder(summaryPayload) {
const sensors = summaryPayload && summaryPayload.sensors ? summaryPayload.sensors : null;
const gpsEnabled = !!(sensors && sensors.gps_enabled === true);
const order = ["battery", "memory", "signal", "noise_floor", "packets"];
if (gpsEnabled) {
order.push("gps_satellites");
}
return order.concat(["voltage", "sensor_temp", "humidity", "pressure", "pressure_altitude", "mcu_temp", "gps_altitude"]);
}
function initTrendCards(seriesOrder) {
const trendsEl = document.getElementById("statsTrends");
if (!trendsEl) return;
trendsEl.innerHTML = ["battery", "memory", "signal", "noise_floor", "packets"].map((key) =>
const order = Array.isArray(seriesOrder) && seriesOrder.length ? seriesOrder : getTrendSeriesOrder(null);
trendsEl.innerHTML = order.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>
@@ -2248,17 +2309,18 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
const summaryEl = document.getElementById("statsSummary");
if (!summaryEl) return;
summaryEl.innerHTML = '<div class="stats-empty">Loading summary...</div>';
initTrendCards();
let summaryPayload = null;
try {
const payload = await fetchJson("/api/stats?view=summary");
if (!payload) throw new Error("no summary payload");
renderStatsSummary(payload);
summaryPayload = await fetchJson("/api/stats?view=summary");
if (!summaryPayload) throw new Error("no summary payload");
renderStatsSummary(summaryPayload);
initTrendCards(getTrendSeriesOrder(summaryPayload));
} catch (error) {
summaryEl.innerHTML = `<div class="stats-error">${escapeHtml(error && error.message ? error.message : "summary unavailable")}</div>`;
return;
}
const seriesOrder = ["battery", "memory", "signal", "noise_floor", "packets"];
const seriesOrder = getTrendSeriesOrder(summaryPayload);
for (const key of seriesOrder) {
try {
const payload = await fetchJson("/api/stats?series=" + encodeURIComponent(key));
@@ -2577,10 +2639,7 @@ bool WebPanelServer::start() {
}
void WebPanelServer::stop() {
if (_redirect_server != nullptr) {
httpd_stop(_redirect_server);
_redirect_server = nullptr;
}
stopRedirectServer();
if (_server != nullptr) {
WEB_PANEL_LOG("server stopped");
httpd_ssl_stop(_server);
@@ -2598,6 +2657,14 @@ bool WebPanelServer::hasSessionToken() const {
return _token[0] != 0;
}
void WebPanelServer::stopRedirectServer() {
if (_redirect_server != nullptr) {
WEB_PANEL_LOG("redirect server stopped");
httpd_stop(_redirect_server);
_redirect_server = nullptr;
}
}
bool WebPanelServer::shouldAutoLock(unsigned long now_ms) const {
if (_server == nullptr || _token[0] == 0 || kWebIdleTimeoutMs == 0 || _last_activity_ms == 0) {
return false;
@@ -2717,6 +2784,11 @@ esp_err_t WebPanelServer::handleCommand(httpd_req_t* req) {
ctx->self->noteActivity();
memset(reply, 0, kWebReplyBufferSize);
if (strcmp(command, "start ota") == 0) {
// OTA serves its own HTTP listener on port 80, so release the
// web-panel redirect listener first or it will keep owning that port.
ctx->self->stopRedirectServer();
}
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");
+1
Vedi File
@@ -71,6 +71,7 @@ private:
void refreshToken();
bool isAuthorized(httpd_req_t* req) const;
void noteActivity();
void stopRedirectServer();
#else
WebPanelCommandRunner* _runner;
#endif
+18 -3
Vedi File
@@ -4,7 +4,7 @@
#include <WiFi.h>
#endif
WebService::WebService() : _fs(nullptr), _prefs{}, _runner(nullptr), _network(nullptr) {
WebService::WebService() : _fs(nullptr), _prefs{}, _runner(nullptr), _network(nullptr), _suspended_for_ota(false) {
WebPrefsStore::setDefaults(_prefs);
}
@@ -19,6 +19,13 @@ void WebService::end() {
#endif
}
void WebService::suspendForOTA() {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
_suspended_for_ota = true;
_panel.stop();
#endif
}
void WebService::loop() {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
ensureWebServer();
@@ -39,9 +46,12 @@ bool WebService::savePrefs() {
bool WebService::setWebEnabled(bool enabled) {
_prefs.web_enabled = enabled ? 1 : 0;
if (!enabled) {
_suspended_for_ota = false;
}
bool ok = savePrefs();
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
if (_prefs.web_enabled != 0) {
if (_prefs.web_enabled != 0 && !_suspended_for_ota) {
ensureWebServer();
} else {
_panel.stop();
@@ -62,6 +72,11 @@ void WebService::formatWebStatusReply(char* reply, size_t reply_size) const {
return;
}
if (_suspended_for_ota) {
snprintf(reply, reply_size, "> web:suspended ota");
return;
}
if (!_panel.isRunning() || _network == nullptr || !_network->isWifiConnected()) {
snprintf(reply, reply_size, "> web:down");
return;
@@ -76,7 +91,7 @@ void WebService::formatWebStatusReply(char* reply, size_t reply_size) const {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
void WebService::ensureWebServer() {
if (_runner == nullptr || _prefs.web_enabled == 0 || _network == nullptr || !_network->isWifiConnected()) {
if (_suspended_for_ota || _runner == nullptr || _prefs.web_enabled == 0 || _network == nullptr || !_network->isWifiConnected()) {
_panel.stop();
return;
}
+2
Vedi File
@@ -14,6 +14,7 @@ public:
void begin(FILESYSTEM* fs);
void end();
void loop();
void suspendForOTA();
void setCommandRunner(WebPanelCommandRunner* runner);
void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; }
@@ -38,4 +39,5 @@ private:
WebPanelCommandRunner* _runner;
NetworkStateProvider* _network;
WebPanelServer _panel;
bool _suspended_for_ota;
};
@@ -50,4 +50,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}