Merge pull request #42 from xJARiD/develop
fix: add environment stats and suspend web panel for OTA
This commit is contained in:
@@ -2,7 +2,7 @@ name: PR Build Check
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [ main ]
|
||||||
paths:
|
paths:
|
||||||
- "src/**"
|
- "src/**"
|
||||||
- "examples/**"
|
- "examples/**"
|
||||||
@@ -23,11 +23,10 @@ jobs:
|
|||||||
# ESP32-S3 / core EastMesh targets
|
# ESP32-S3 / core EastMesh targets
|
||||||
- heltec_v4_companion_radio_wifi
|
- heltec_v4_companion_radio_wifi
|
||||||
- heltec_v4_repeater_mqtt
|
- 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_companion_radio_wifi
|
||||||
- T_Beam_S3_Supreme_SX1262_repeater_mqtt
|
- T_Beam_S3_Supreme_SX1262_repeater_mqtt
|
||||||
# ESP32-C6
|
|
||||||
- LilyGo_Tlora_C6_repeater_mqtt
|
|
||||||
- Xiao_C6_repeater_mqtt
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Repo
|
- name: Clone Repo
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: PUSH Build Check
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [develop]
|
branches: [ develop ]
|
||||||
paths:
|
paths:
|
||||||
- "src/**"
|
- "src/**"
|
||||||
- "examples/**"
|
- "examples/**"
|
||||||
@@ -22,6 +22,7 @@ jobs:
|
|||||||
environment:
|
environment:
|
||||||
# ESP32-S3 / core EastMesh targets
|
# ESP32-S3 / core EastMesh targets
|
||||||
- heltec_v4_repeater_mqtt
|
- heltec_v4_repeater_mqtt
|
||||||
|
- LilyGo_TBeam_1W_repeater_mqtt
|
||||||
- T_Beam_S3_Supreme_SX1262_repeater_mqtt
|
- T_Beam_S3_Supreme_SX1262_repeater_mqtt
|
||||||
# ESP32-C6
|
# ESP32-C6
|
||||||
- LilyGo_Tlora_C6_repeater_mqtt
|
- LilyGo_Tlora_C6_repeater_mqtt
|
||||||
|
|||||||
@@ -152,6 +152,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
|
||||||
|
|
||||||
### `GET /api/stats?series=<name>`
|
### `GET /api/stats?series=<name>`
|
||||||
|
|
||||||
@@ -164,6 +165,14 @@ Supported series:
|
|||||||
- `signal`
|
- `signal`
|
||||||
- `noise_floor`
|
- `noise_floor`
|
||||||
- `packets`
|
- `packets`
|
||||||
|
- `voltage`
|
||||||
|
- `sensor_temp`
|
||||||
|
- `humidity`
|
||||||
|
- `pressure`
|
||||||
|
- `pressure_altitude`
|
||||||
|
- `mcu_temp`
|
||||||
|
- `gps_altitude`
|
||||||
|
- `gps_satellites`
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -176,6 +185,7 @@ Notes:
|
|||||||
|
|
||||||
- use `?series=battery`, not just `?series`
|
- 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
|
- 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`
|
### `GET /api/stats?view=legacy`
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ 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.
|
- `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.
|
- `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`
|
||||||
- `set.web on|off`: enables or disables the local HTTPS panel.
|
- `set.web on|off`: enables or disables the local HTTPS panel.
|
||||||
- `set web.stats on|off`
|
- `set web.stats on|off`
|
||||||
@@ -123,6 +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`
|
||||||
|
|
||||||
## Companion WiFi Rescue Commands
|
## Companion WiFi Rescue Commands
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -191,10 +191,15 @@ The stats page is loaded separately from `/app` and is intended to keep the main
|
|||||||
The `/stats` page currently shows:
|
The `/stats` page currently shows:
|
||||||
|
|
||||||
- `Services`: MQTT, web, archive, neighbour count, and, when mounted, card and archive capacity
|
- `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
|
- `Neighbours`: current neighbour table with ID, SNR, heard age, and advert age
|
||||||
- `Events`: current boot/session events
|
- `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:
|
The trend graphs load sequentially rather than as one large payload:
|
||||||
|
|
||||||
1. summary/status
|
1. summary/status
|
||||||
@@ -202,10 +207,11 @@ The trend graphs load sequentially rather than as one large payload:
|
|||||||
3. memory
|
3. memory
|
||||||
4. packet activity
|
4. packet activity
|
||||||
5. signal
|
5. signal
|
||||||
|
6. remaining supported environment series
|
||||||
|
|
||||||
This keeps browser-side and device-side memory use lower than the previous in-page stats view.
|
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
|
### Stats History Capacity
|
||||||
|
|
||||||
@@ -271,7 +277,8 @@ On mobile:
|
|||||||
|
|
||||||
1. Press `Start OTA`.
|
1. Press `Start OTA`.
|
||||||
2. Confirm the action.
|
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
|
### Use Historical Stats
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#include "MyMesh.h"
|
#include "MyMesh.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <helpers/sensors/LPPDataHelpers.h>
|
||||||
|
|
||||||
#if defined(TBEAM_1W)
|
#if defined(TBEAM_1W)
|
||||||
#include <TBeam1WBoard.h>
|
#include <TBeam1WBoard.h>
|
||||||
@@ -26,6 +28,183 @@
|
|||||||
|
|
||||||
namespace {
|
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 unsigned long kArchiveNeighboursFlushIntervalMs = 60UL * 1000UL;
|
||||||
constexpr const char* kArchiveNeighboursSnapshotPath = "/stats/neighbours.snapshot";
|
constexpr const char* kArchiveNeighboursSnapshotPath = "/stats/neighbours.snapshot";
|
||||||
|
|
||||||
@@ -1602,6 +1781,7 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) {
|
|||||||
#endif
|
#endif
|
||||||
if (next_history_sample_ms == 0 || millisHasNowPassed(next_history_sample_ms)) {
|
if (next_history_sample_ms == 0 || millisHasNowPassed(next_history_sample_ms)) {
|
||||||
if (!live_stats_headroom_low) {
|
if (!live_stats_headroom_low) {
|
||||||
|
WebSensorSnapshot sensor_snapshot = collectWebSensorSnapshot(board, sensors);
|
||||||
HistorySample sample{};
|
HistorySample sample{};
|
||||||
sample.epoch_secs = getRTCClock()->getCurrentTime();
|
sample.epoch_secs = getRTCClock()->getCurrentTime();
|
||||||
sample.uptime_secs = static_cast<uint32_t>(uptime_millis / 1000);
|
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.last_snr_x4 = static_cast<int16_t>(radio_driver.getLastSNR() * 4.0f);
|
||||||
sample.noise_floor = static_cast<int16_t>(_radio->getNoiseFloor());
|
sample.noise_floor = static_cast<int16_t>(_radio->getNoiseFloor());
|
||||||
sample.battery_pct = static_cast<int8_t>(board.getBatteryPercent());
|
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)
|
#if defined(ESP32)
|
||||||
sample.heap_free = free_heap;
|
sample.heap_free = free_heap;
|
||||||
sample.heap_min = ESP.getMinFreeHeap();
|
sample.heap_min = ESP.getMinFreeHeap();
|
||||||
@@ -1743,6 +1973,12 @@ void MyMesh::clearStats() {
|
|||||||
((SimpleMeshTables *)getTables())->resetStats();
|
((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) {
|
void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) {
|
||||||
if (region_load_active) {
|
if (region_load_active) {
|
||||||
if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offset += snprintf(&reply[offset], reply_size - offset, ",");
|
||||||
|
if (!appendJsonSensors(reply, reply_size, offset)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
offset += snprintf(&reply[offset], reply_size - offset, ",");
|
offset += snprintf(&reply[offset], reply_size - offset, ",");
|
||||||
if (!appendJsonEvents(reply, reply_size, offset)) {
|
if (!appendJsonEvents(reply, reply_size, offset)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2312,6 +2552,90 @@ bool MyMesh::formatWebStatsSeriesJson(const char* series, char* reply, size_t re
|
|||||||
#endif
|
#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() {
|
void MyMesh::loop() {
|
||||||
#ifdef WITH_BRIDGE
|
#ifdef WITH_BRIDGE
|
||||||
bridge.loop();
|
bridge.loop();
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public WebPanelComm
|
|||||||
void recordStatsEvent(uint8_t type, int16_t value = 0);
|
void recordStatsEvent(uint8_t type, int16_t value = 0);
|
||||||
bool appendJsonEvents(char* reply, size_t reply_size, size_t& offset) const;
|
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 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 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 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);
|
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 formatRadioStatsReply(char *reply, size_t reply_size) override;
|
||||||
void formatPacketStatsReply(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 formatMemoryReply(char *reply, size_t reply_size) override;
|
||||||
|
void prepareForOTAStart() override;
|
||||||
void startRegionsLoad() override;
|
void startRegionsLoad() override;
|
||||||
bool saveRegions() override;
|
bool saveRegions() override;
|
||||||
void onDefaultRegionChanged(const RegionEntry* r) override;
|
void onDefaultRegionChanged(const RegionEntry* r) override;
|
||||||
|
|||||||
+14
-2
@@ -371,7 +371,7 @@ releases:
|
|||||||
tag: "repeater-mqtt-eastmesh-v1.3.8"
|
tag: "repeater-mqtt-eastmesh-v1.3.8"
|
||||||
date: "2026-04-21"
|
date: "2026-04-21"
|
||||||
previous_version: "1.3.7"
|
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:
|
changes:
|
||||||
- type: fixed
|
- type: fixed
|
||||||
area: web-panel
|
area: web-panel
|
||||||
@@ -385,7 +385,19 @@ releases:
|
|||||||
- type: fixed
|
- type: fixed
|
||||||
area: stats
|
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."
|
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
|
- type: docs
|
||||||
area: 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: []
|
breaking_changes: []
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const char* cardTypeName(uint8_t type) {
|
|||||||
|
|
||||||
#if defined(ESP32)
|
#if defined(ESP32)
|
||||||
SPIClass* getBoardSharedArchiveSPI() __attribute__((weak));
|
SPIClass* getBoardSharedArchiveSPI() __attribute__((weak));
|
||||||
|
SPIClass* getBoardSharedArchiveSPI() {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
ArchiveStorage::ArchiveStorage()
|
ArchiveStorage::ArchiveStorage()
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
|||||||
strcpy(reply, "ERR: clock cannot go backwards");
|
strcpy(reply, "ERR: clock cannot go backwards");
|
||||||
}
|
}
|
||||||
} else if (memcmp(command, "start ota", 9) == 0) {
|
} else if (memcmp(command, "start ota", 9) == 0) {
|
||||||
|
_callbacks->prepareForOTAStart();
|
||||||
if (!_board->startOTAUpdate(_prefs->node_name, reply)) {
|
if (!_board->startOTAUpdate(_prefs->node_name, reply)) {
|
||||||
strcpy(reply, "Error");
|
strcpy(reply, "Error");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ public:
|
|||||||
virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0;
|
virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0;
|
||||||
virtual void clearStats() = 0;
|
virtual void clearStats() = 0;
|
||||||
virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) = 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() {
|
virtual void startRegionsLoad() {
|
||||||
// no op by default
|
// no op by default
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ namespace {
|
|||||||
constexpr uint32_t kSummaryFlushIntervalMs = 5UL * 60UL * 1000UL;
|
constexpr uint32_t kSummaryFlushIntervalMs = 5UL * 60UL * 1000UL;
|
||||||
constexpr uint32_t kEventFlushIntervalMs = 60UL * 1000UL;
|
constexpr uint32_t kEventFlushIntervalMs = 60UL * 1000UL;
|
||||||
constexpr size_t kMaxSeriesPoints = 64;
|
constexpr size_t kMaxSeriesPoints = 64;
|
||||||
constexpr size_t kSummaryRestoreWindowBytes = 16384;
|
constexpr size_t kSummaryRestoreWindowBytes = 32768;
|
||||||
constexpr size_t kEventsRestoreWindowBytes = 4096;
|
constexpr size_t kEventsRestoreWindowBytes = 4096;
|
||||||
constexpr uint32_t kBootAutoCapturePsramMinBytes = 2000000UL;
|
constexpr uint32_t kBootAutoCapturePsramMinBytes = 2000000UL;
|
||||||
constexpr size_t kLiveOnlySampleCapacity = 24;
|
constexpr size_t kLiveOnlySampleCapacity = 24;
|
||||||
@@ -187,28 +187,90 @@ File openArchiveWriteWithRecovery(ArchiveStorage* archive, const char* filename)
|
|||||||
return fs != nullptr ? openArchiveWrite(fs, filename) : File();
|
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) {
|
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) {
|
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) {
|
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) {
|
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 (strcmp(series, "packets") == 0) {
|
||||||
if (previous == nullptr) {
|
if (previous == nullptr) {
|
||||||
return 0;
|
value = 0;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
const uint32_t curr_total = sample.packets_sent + sample.packets_recv;
|
const uint32_t curr_total = sample.packets_sent + sample.packets_recv;
|
||||||
const uint32_t prev_total = previous->packets_sent + previous->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) {
|
const char* seriesTitle(const char* series) {
|
||||||
@@ -227,6 +289,30 @@ const char* seriesTitle(const char* series) {
|
|||||||
if (strcmp(series, "noise_floor") == 0) {
|
if (strcmp(series, "noise_floor") == 0) {
|
||||||
return "Noise Floor";
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +332,24 @@ const char* seriesUnit(const char* series) {
|
|||||||
if (strcmp(series, "noise_floor") == 0) {
|
if (strcmp(series, "noise_floor") == 0) {
|
||||||
return "noise_floor_x4";
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,8 +590,85 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
|
|||||||
unsigned direct_dups = 0;
|
unsigned direct_dups = 0;
|
||||||
unsigned flood_dups = 0;
|
unsigned flood_dups = 0;
|
||||||
unsigned flags = 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,
|
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",
|
"%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u",
|
||||||
&epoch_secs,
|
&epoch_secs,
|
||||||
&uptime_secs,
|
&uptime_secs,
|
||||||
@@ -589,7 +770,7 @@ bool StatsHistory::restoreSummaryLog() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
char line[192];
|
char line[384];
|
||||||
size_t line_len = 0;
|
size_t line_len = 0;
|
||||||
bool skip_partial = (start > 0);
|
bool skip_partial = (start > 0);
|
||||||
while (file.available()) {
|
while (file.available()) {
|
||||||
@@ -819,9 +1000,9 @@ void StatsHistory::flushSummaryLog() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
char line[256];
|
char line[384];
|
||||||
snprintf(line, sizeof(line),
|
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.epoch_secs),
|
||||||
static_cast<unsigned long>(latest.uptime_secs),
|
static_cast<unsigned long>(latest.uptime_secs),
|
||||||
static_cast<unsigned>(latest.battery_mv),
|
static_cast<unsigned>(latest.battery_mv),
|
||||||
@@ -838,7 +1019,18 @@ void StatsHistory::flushSummaryLog() {
|
|||||||
static_cast<unsigned>(latest.neighbour_count),
|
static_cast<unsigned>(latest.neighbour_count),
|
||||||
static_cast<unsigned>(latest.direct_dups),
|
static_cast<unsigned>(latest.direct_dups),
|
||||||
static_cast<unsigned>(latest.flood_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);
|
const size_t written = file.print(line);
|
||||||
file.flush();
|
file.flush();
|
||||||
file.close();
|
file.close();
|
||||||
@@ -943,7 +1135,7 @@ bool StatsHistory::buildSeriesJson(const char* series, char* buffer, size_t buff
|
|||||||
|
|
||||||
if (_sample_count == 0) {
|
if (_sample_count == 0) {
|
||||||
snprintf(buffer, buffer_size,
|
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,
|
series,
|
||||||
seriesTitle(series),
|
seriesTitle(series),
|
||||||
seriesUnit(series),
|
seriesUnit(series),
|
||||||
@@ -959,55 +1151,83 @@ bool StatsHistory::buildSeriesJson(const char* series, char* buffer, size_t buff
|
|||||||
HistorySample previous{};
|
HistorySample previous{};
|
||||||
bool have_previous = false;
|
bool have_previous = false;
|
||||||
int current_value = 0;
|
int current_value = 0;
|
||||||
|
bool have_current_value = false;
|
||||||
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));
|
|
||||||
|
|
||||||
getSampleFromOldest(_sample_count - 1, sample);
|
getSampleFromOldest(_sample_count - 1, sample);
|
||||||
if (strcmp(series, "packets") == 0 && _sample_count >= 2) {
|
if (strcmp(series, "packets") == 0 && _sample_count >= 2) {
|
||||||
getSampleFromOldest(_sample_count - 2, previous);
|
getSampleFromOldest(_sample_count - 2, previous);
|
||||||
current_value = buildPointValue(sample, &previous, series);
|
have_current_value = buildPointValue(sample, &previous, series, current_value);
|
||||||
} else {
|
} 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 oldest_sample{};
|
||||||
HistorySample latest_emitted_sample{};
|
HistorySample latest_emitted_sample{};
|
||||||
const bool have_oldest_sample = getSampleFromOldest(0, oldest_sample);
|
bool have_oldest_sample = false;
|
||||||
const bool have_latest_emitted_sample = getSampleFromOldest(last_index, latest_emitted_sample);
|
bool have_latest_emitted_sample = false;
|
||||||
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));
|
|
||||||
|
|
||||||
size_t emitted = 0;
|
size_t emitted = 0;
|
||||||
for (size_t i = 0; i < _sample_count && emitted < points; i += step, ++emitted) {
|
for (size_t i = 0; i < _sample_count && emitted < points; i += step, ++emitted) {
|
||||||
if (!getSampleFromOldest(i, sample)) {
|
if (!getSampleFromOldest(i, sample)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
int value = buildPointValue(sample, have_previous ? &previous : nullptr, series);
|
int value = 0;
|
||||||
offset += snprintf(&buffer[offset], buffer_size - offset,
|
const bool have_value = buildPointValue(sample, have_previous ? &previous : nullptr, series, value);
|
||||||
"%s[%lu,%d]",
|
if (have_value) {
|
||||||
emitted == 0 ? "" : ",",
|
if (!have_oldest_sample) {
|
||||||
static_cast<unsigned long>(sample.uptime_secs),
|
oldest_sample = sample;
|
||||||
value);
|
have_oldest_sample = true;
|
||||||
|
}
|
||||||
|
latest_emitted_sample = sample;
|
||||||
|
have_latest_emitted_sample = true;
|
||||||
|
}
|
||||||
previous = sample;
|
previous = sample;
|
||||||
have_previous = true;
|
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;
|
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, "]}");
|
snprintf(&buffer[offset], buffer_size - offset, "]}");
|
||||||
|
|||||||
@@ -24,9 +24,20 @@ struct HistorySample {
|
|||||||
int16_t last_rssi_x4;
|
int16_t last_rssi_x4;
|
||||||
int16_t last_snr_x4;
|
int16_t last_snr_x4;
|
||||||
int16_t noise_floor;
|
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;
|
uint8_t flags;
|
||||||
int8_t battery_pct;
|
int8_t battery_pct;
|
||||||
uint16_t reserved;
|
uint8_t reserved;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct HistoryEvent {
|
struct HistoryEvent {
|
||||||
@@ -48,6 +59,22 @@ enum HistorySampleFlags : uint8_t {
|
|||||||
HISTORY_FLAG_ARCHIVE_MOUNTED = 1 << 7,
|
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 {
|
enum HistoryEventType : uint8_t {
|
||||||
HISTORY_EVENT_BOOT = 1,
|
HISTORY_EVENT_BOOT = 1,
|
||||||
HISTORY_EVENT_WEB_STARTED = 2,
|
HISTORY_EVENT_WEB_STARTED = 2,
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
.meter-fill.warn { background:linear-gradient(90deg,#d7a531,#e9bf52); }
|
.meter-fill.warn { background:linear-gradient(90deg,#d7a531,#e9bf52); }
|
||||||
.meter-fill.bad { background:linear-gradient(90deg,#bf4b4b,#dd6a6a); }
|
.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 { 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 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
|
||||||
.core-grid .hud-row { align-content:start; }
|
.core-grid .hud-row { align-content:start; }
|
||||||
.core-metrics { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
.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 class="metric-value">${escapeHtml(value)}</div>
|
||||||
</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) {
|
function renderMissingCard(title, message) {
|
||||||
return `<section class="hud-card">
|
return `<section class="hud-card">
|
||||||
<h3>${escapeHtml(title)}</h3>
|
<h3>${escapeHtml(title)}</h3>
|
||||||
@@ -1575,20 +1588,44 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
const archiveUsed = archive.used_bytes || 0;
|
const archiveUsed = archive.used_bytes || 0;
|
||||||
const archiveFree = Math.max(0, archiveTotal - archiveUsed);
|
const archiveFree = Math.max(0, archiveTotal - archiveUsed);
|
||||||
const archiveFreePct = archiveTotal > 0 ? Math.round((archiveFree / archiveTotal) * 100) : 0;
|
const archiveFreePct = archiveTotal > 0 ? Math.round((archiveFree / archiveTotal) * 100) : 0;
|
||||||
const archiveMetrics = archive.available ? `
|
const metrics = [
|
||||||
${renderMetric("Card", archive.type || "--")}
|
{ label:"MQTT", value:services.mqtt_state || (services.mqtt_connected ? "up" : "down") },
|
||||||
${renderMetric("Archive Total", formatArchiveGigabytes(archiveTotal))}
|
{ label:"Web", value:services.web_panel_up ? services.web_auth || "up" : "down" },
|
||||||
${renderMetric("Archive Used", formatArchiveUsed(archiveUsed))}
|
{ label:"Archive", value:archive.available ? archive.logical || "archive" : "unavailable" },
|
||||||
${renderMetric("Archive Free", archiveTotal > 0 ? (formatArchiveGigabytes(archiveFree) + " (" + archiveFreePct + "%)") : "--")}` : "";
|
{ 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">
|
return `<section class="hud-card">
|
||||||
<h3>Services</h3>
|
<h3>Services</h3>
|
||||||
<div class="metric-grid">
|
${renderMetricList(metrics, "metric-grid-4")}
|
||||||
${renderMetric("MQTT", services.mqtt_state || (services.mqtt_connected ? "up" : "down"))}
|
</section>`;
|
||||||
${renderMetric("Web", services.web_panel_up ? services.web_auth || "up" : "down")}
|
}
|
||||||
${renderMetric("Archive", archive.available ? archive.logical || "archive" : "unavailable")}
|
function renderSensorsCard(sensorSummary) {
|
||||||
${renderMetric("Neighbours", packets.neighbors || 0)}
|
const sensors = sensorSummary && typeof sensorSummary === "object" ? sensorSummary : {};
|
||||||
${archiveMetrics}
|
const metrics = [
|
||||||
</div>
|
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>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
function renderEventsSection(events) {
|
function renderEventsSection(events) {
|
||||||
@@ -1668,6 +1705,14 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
renderStatsDashboard(results, [], "statsSummary");
|
renderStatsDashboard(results, [], "statsSummary");
|
||||||
const summaryEl = document.getElementById("statsSummary");
|
const summaryEl = document.getElementById("statsSummary");
|
||||||
if (!summaryEl) return;
|
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>`;
|
summaryEl.innerHTML += `<div class="hud-grid-1">${renderServicesCard(payload)}</div>`;
|
||||||
const introEl = document.getElementById("statsTrendIntro");
|
const introEl = document.getElementById("statsTrendIntro");
|
||||||
if (introEl) {
|
if (introEl) {
|
||||||
@@ -1705,10 +1750,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
function formatTrendValue(key, value) {
|
function formatTrendValue(key, value) {
|
||||||
if (!Number.isFinite(value)) return "--";
|
if (!Number.isFinite(value)) return "--";
|
||||||
if (key === "battery") return Math.round(value) + " mV";
|
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 === "memory") return formatBytes(value);
|
||||||
if (key === "packets") return Math.round(value) + " pkts";
|
if (key === "packets") return Math.round(value) + " pkts";
|
||||||
if (key === "signal") return (value / 4).toFixed(1) + " dBm";
|
if (key === "signal") return (value / 4).toFixed(1) + " dBm";
|
||||||
if (key === "noise_floor") 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);
|
return String(value);
|
||||||
}
|
}
|
||||||
function formatArchiveGigabytes(value) {
|
function formatArchiveGigabytes(value) {
|
||||||
@@ -1935,10 +1986,20 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
axisRightEl.style.color = "var(--status-red)";
|
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");
|
const trendsEl = document.getElementById("statsTrends");
|
||||||
if (!trendsEl) return;
|
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}">
|
`<section class="trend-card" id="trend-${key}">
|
||||||
<div class="trend-title">${escapeHtml(key)}</div>
|
<div class="trend-title">${escapeHtml(key)}</div>
|
||||||
<div class="spark-axis"><span></span><span>Loading...</span></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");
|
const summaryEl = document.getElementById("statsSummary");
|
||||||
if (!summaryEl) return;
|
if (!summaryEl) return;
|
||||||
summaryEl.innerHTML = '<div class="stats-empty">Loading summary...</div>';
|
summaryEl.innerHTML = '<div class="stats-empty">Loading summary...</div>';
|
||||||
initTrendCards();
|
let summaryPayload = null;
|
||||||
try {
|
try {
|
||||||
const payload = await fetchJson("/api/stats?view=summary");
|
summaryPayload = await fetchJson("/api/stats?view=summary");
|
||||||
if (!payload) throw new Error("no summary payload");
|
if (!summaryPayload) throw new Error("no summary payload");
|
||||||
renderStatsSummary(payload);
|
renderStatsSummary(summaryPayload);
|
||||||
|
initTrendCards(getTrendSeriesOrder(summaryPayload));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
summaryEl.innerHTML = `<div class="stats-error">${escapeHtml(error && error.message ? error.message : "summary unavailable")}</div>`;
|
summaryEl.innerHTML = `<div class="stats-error">${escapeHtml(error && error.message ? error.message : "summary unavailable")}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesOrder = ["battery", "memory", "signal", "noise_floor", "packets"];
|
const seriesOrder = getTrendSeriesOrder(summaryPayload);
|
||||||
for (const key of seriesOrder) {
|
for (const key of seriesOrder) {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchJson("/api/stats?series=" + encodeURIComponent(key));
|
const payload = await fetchJson("/api/stats?series=" + encodeURIComponent(key));
|
||||||
@@ -2577,10 +2639,7 @@ bool WebPanelServer::start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WebPanelServer::stop() {
|
void WebPanelServer::stop() {
|
||||||
if (_redirect_server != nullptr) {
|
stopRedirectServer();
|
||||||
httpd_stop(_redirect_server);
|
|
||||||
_redirect_server = nullptr;
|
|
||||||
}
|
|
||||||
if (_server != nullptr) {
|
if (_server != nullptr) {
|
||||||
WEB_PANEL_LOG("server stopped");
|
WEB_PANEL_LOG("server stopped");
|
||||||
httpd_ssl_stop(_server);
|
httpd_ssl_stop(_server);
|
||||||
@@ -2598,6 +2657,14 @@ bool WebPanelServer::hasSessionToken() const {
|
|||||||
return _token[0] != 0;
|
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 {
|
bool WebPanelServer::shouldAutoLock(unsigned long now_ms) const {
|
||||||
if (_server == nullptr || _token[0] == 0 || kWebIdleTimeoutMs == 0 || _last_activity_ms == 0) {
|
if (_server == nullptr || _token[0] == 0 || kWebIdleTimeoutMs == 0 || _last_activity_ms == 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2717,6 +2784,11 @@ esp_err_t WebPanelServer::handleCommand(httpd_req_t* req) {
|
|||||||
|
|
||||||
ctx->self->noteActivity();
|
ctx->self->noteActivity();
|
||||||
memset(reply, 0, kWebReplyBufferSize);
|
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);
|
ctx->self->_runner->runWebCommand(command, reply, kWebReplyBufferSize);
|
||||||
httpd_resp_set_type(req, "text/plain; charset=utf-8");
|
httpd_resp_set_type(req, "text/plain; charset=utf-8");
|
||||||
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ private:
|
|||||||
void refreshToken();
|
void refreshToken();
|
||||||
bool isAuthorized(httpd_req_t* req) const;
|
bool isAuthorized(httpd_req_t* req) const;
|
||||||
void noteActivity();
|
void noteActivity();
|
||||||
|
void stopRedirectServer();
|
||||||
#else
|
#else
|
||||||
WebPanelCommandRunner* _runner;
|
WebPanelCommandRunner* _runner;
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
#endif
|
#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);
|
WebPrefsStore::setDefaults(_prefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +19,13 @@ void WebService::end() {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void WebService::suspendForOTA() {
|
||||||
|
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
|
||||||
|
_suspended_for_ota = true;
|
||||||
|
_panel.stop();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
void WebService::loop() {
|
void WebService::loop() {
|
||||||
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
|
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
|
||||||
ensureWebServer();
|
ensureWebServer();
|
||||||
@@ -39,9 +46,12 @@ bool WebService::savePrefs() {
|
|||||||
|
|
||||||
bool WebService::setWebEnabled(bool enabled) {
|
bool WebService::setWebEnabled(bool enabled) {
|
||||||
_prefs.web_enabled = enabled ? 1 : 0;
|
_prefs.web_enabled = enabled ? 1 : 0;
|
||||||
|
if (!enabled) {
|
||||||
|
_suspended_for_ota = false;
|
||||||
|
}
|
||||||
bool ok = savePrefs();
|
bool ok = savePrefs();
|
||||||
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
|
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
|
||||||
if (_prefs.web_enabled != 0) {
|
if (_prefs.web_enabled != 0 && !_suspended_for_ota) {
|
||||||
ensureWebServer();
|
ensureWebServer();
|
||||||
} else {
|
} else {
|
||||||
_panel.stop();
|
_panel.stop();
|
||||||
@@ -62,6 +72,11 @@ void WebService::formatWebStatusReply(char* reply, size_t reply_size) const {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_suspended_for_ota) {
|
||||||
|
snprintf(reply, reply_size, "> web:suspended ota");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_panel.isRunning() || _network == nullptr || !_network->isWifiConnected()) {
|
if (!_panel.isRunning() || _network == nullptr || !_network->isWifiConnected()) {
|
||||||
snprintf(reply, reply_size, "> web:down");
|
snprintf(reply, reply_size, "> web:down");
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +91,7 @@ void WebService::formatWebStatusReply(char* reply, size_t reply_size) const {
|
|||||||
|
|
||||||
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
|
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
|
||||||
void WebService::ensureWebServer() {
|
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();
|
_panel.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public:
|
|||||||
void begin(FILESYSTEM* fs);
|
void begin(FILESYSTEM* fs);
|
||||||
void end();
|
void end();
|
||||||
void loop();
|
void loop();
|
||||||
|
void suspendForOTA();
|
||||||
|
|
||||||
void setCommandRunner(WebPanelCommandRunner* runner);
|
void setCommandRunner(WebPanelCommandRunner* runner);
|
||||||
void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; }
|
void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; }
|
||||||
@@ -38,4 +39,5 @@ private:
|
|||||||
WebPanelCommandRunner* _runner;
|
WebPanelCommandRunner* _runner;
|
||||||
NetworkStateProvider* _network;
|
NetworkStateProvider* _network;
|
||||||
WebPanelServer _panel;
|
WebPanelServer _panel;
|
||||||
|
bool _suspended_for_ota;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,4 +50,3 @@ mesh::LocalIdentity radio_new_identity() {
|
|||||||
RadioNoiseListener rng(radio);
|
RadioNoiseListener rng(radio);
|
||||||
return mesh::LocalIdentity(&rng); // create new random identity
|
return mesh::LocalIdentity(&rng); // create new random identity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user