diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 7283a11c..dc86b2e3 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -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 diff --git a/.github/workflows/push-build-check.yml b/.github/workflows/push-build-check.yml index 8983fd2e..ed575fcf 100644 --- a/.github/workflows/push-build-check.yml +++ b/.github/workflows/push-build-check.yml @@ -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 diff --git a/docs/api.md b/docs/api.md index cd2c995d..e802504e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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=` @@ -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` diff --git a/docs/custom-cli.md b/docs/custom-cli.md index 69e9a7f5..1cac81bb 100644 --- a/docs/custom-cli.md +++ b/docs/custom-cli.md @@ -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 diff --git a/docs/web-panel.md b/docs/web-panel.md index 8730d8c7..2e59cd58 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -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 diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index f8de42aa..90e1b6ac 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,6 +1,8 @@ #include "MyMesh.h" #include +#include #include +#include #if defined(TBEAM_1W) #include @@ -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(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(location->getLatitude()) / 1000000.0f; + snapshot.has_gps_lon = true; + snapshot.gps_lon = static_cast(location->getLongitude()) / 1000000.0f; + snapshot.has_gps_altitude = true; + snapshot.gps_altitude_m = static_cast(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(written) >= (reply_size - offset)) { + return false; + } + offset += static_cast(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(written) >= (reply_size - offset)) { + return false; + } + offset += static_cast(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(written) >= (reply_size - offset)) { + return false; + } + offset += static_cast(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(uptime_millis / 1000); @@ -1620,6 +1800,56 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) { sample.last_snr_x4 = static_cast(radio_driver.getLastSNR() * 4.0f); sample.noise_floor = static_cast(_radio->getNoiseFloor()); sample.battery_pct = static_cast(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(min(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(max(-32768L, min(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(max(-32768L, min(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(min(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(min(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(max(-32768L, min(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(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(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(max(-32768L, min(32767L, lroundf(sensor_snapshot.gps_altitude_m)))); + } + if (sensor_snapshot.has_satellites) { + sample.sensor_flags |= HISTORY_SENSOR_GPS_SATELLITES; + sample.gps_satellites = static_cast(min(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(open_written) >= (reply_size - offset)) { + return false; + } + offset += static_cast(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(close_written) >= (reply_size - offset)) { + return false; + } + offset += static_cast(close_written); + return true; +#else + (void)reply; + (void)reply_size; + (void)offset; + return false; +#endif +} + void MyMesh::loop() { #ifdef WITH_BRIDGE bridge.loop(); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 57f58aa6..18912e95 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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; diff --git a/release-notes.yml b/release-notes.yml index 0dd26e54..6fc267c3 100644 --- a/release-notes.yml +++ b/release-notes.yml @@ -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: [] diff --git a/src/helpers/ArchiveStorage.cpp b/src/helpers/ArchiveStorage.cpp index 72d77e5a..c6189642 100644 --- a/src/helpers/ArchiveStorage.cpp +++ b/src/helpers/ArchiveStorage.cpp @@ -37,6 +37,9 @@ const char* cardTypeName(uint8_t type) { #if defined(ESP32) SPIClass* getBoardSharedArchiveSPI() __attribute__((weak)); +SPIClass* getBoardSharedArchiveSPI() { + return nullptr; +} #endif ArchiveStorage::ArchiveStorage() diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 7197a78e..9e2306e1 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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"); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index d5123dc6..a3aaf541 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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 diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index 888fa4e6..d97f5378 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -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(sample.battery_mv); + value = static_cast(sample.battery_mv); + return true; } if (strcmp(series, "memory") == 0) { - return static_cast(sample.heap_free); + value = static_cast(sample.heap_free); + return true; } if (strcmp(series, "signal") == 0) { - return static_cast(sample.last_rssi_x4); + value = static_cast(sample.last_rssi_x4); + return true; } if (strcmp(series, "noise_floor") == 0) { - return static_cast(sample.noise_floor * 4); + value = static_cast(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(curr_total >= prev_total ? (curr_total - prev_total) : 0); + value = static_cast(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(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(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(sample.humidity_deci_pct); + return true; + } + if (strcmp(series, "pressure") == 0) { + if ((sample.sensor_flags & HISTORY_SENSOR_PRESSURE) == 0) { + return false; + } + value = static_cast(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(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(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(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(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(epoch_secs); + sample.uptime_secs = static_cast(uptime_secs); + sample.packets_sent = static_cast(packets_sent); + sample.packets_recv = static_cast(packets_recv); + sample.heap_free = static_cast(heap_free); + sample.heap_min = static_cast(heap_free); + sample.psram_free = static_cast(psram_free); + sample.psram_min = static_cast(psram_free); + sample.battery_mv = static_cast(battery_mv); + sample.queue_len = static_cast(queue_len); + sample.error_flags = static_cast(error_flags); + sample.recv_errors = static_cast(recv_errors); + sample.neighbour_count = static_cast(neighbour_count); + sample.direct_dups = static_cast(direct_dups); + sample.flood_dups = static_cast(flood_dups); + sample.last_rssi_x4 = static_cast(last_rssi_x4); + sample.last_snr_x4 = static_cast(last_snr_x4); + sample.noise_floor = static_cast(noise_floor); + sample.supply_voltage_centi_v = static_cast(supply_voltage_centi_v); + sample.sensor_temp_deci_c = static_cast(sensor_temp_deci_c); + sample.mcu_temp_deci_c = static_cast(mcu_temp_deci_c); + sample.humidity_deci_pct = static_cast(humidity_deci_pct); + sample.pressure_deci_hpa = static_cast(pressure_deci_hpa); + sample.pressure_altitude_m = static_cast(pressure_altitude_m); + sample.gps_altitude_m = static_cast(gps_altitude_m); + sample.gps_lat_e6 = static_cast(gps_lat_e6); + sample.gps_lon_e6 = static_cast(gps_lon_e6); + sample.sensor_flags = static_cast(sensor_flags); + sample.gps_satellites = static_cast(gps_satellites); + sample.flags = static_cast(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(latest.epoch_secs), static_cast(latest.uptime_secs), static_cast(latest.battery_mv), @@ -838,7 +1019,18 @@ void StatsHistory::flushSummaryLog() { static_cast(latest.neighbour_count), static_cast(latest.direct_dups), static_cast(latest.flood_dups), - static_cast(latest.flags)); + static_cast(latest.flags), + static_cast(latest.supply_voltage_centi_v), + static_cast(latest.sensor_temp_deci_c), + static_cast(latest.mcu_temp_deci_c), + static_cast(latest.humidity_deci_pct), + static_cast(latest.pressure_deci_hpa), + static_cast(latest.pressure_altitude_m), + static_cast(latest.gps_altitude_m), + static_cast(latest.gps_lat_e6), + static_cast(latest.gps_lon_e6), + static_cast(latest.sensor_flags), + static_cast(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(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(oldest_age_secs), - static_cast(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(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(kSampleIntervalSecs), + current_json, + static_cast(oldest_age_secs), + static_cast(latest_age_secs)); + if (header_written < 0 || static_cast(header_written) >= buffer_size) { + return false; + } + offset = static_cast(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(sample.uptime_secs), + value); + valid_emitted++; + if (offset + 24 >= buffer_size) { + break; + } + } + previous = sample; + have_previous = true; } snprintf(&buffer[offset], buffer_size - offset, "]}"); diff --git a/src/helpers/StatsHistory.h b/src/helpers/StatsHistory.h index baa6a20a..9a10b23e 100644 --- a/src/helpers/StatsHistory.h +++ b/src/helpers/StatsHistory.h @@ -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, diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 0e55e58e..c5617160 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -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(
${escapeHtml(value)}
`; } + 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 `
${items.map((item) => renderMetric(item.label, item.value)).join("")}
`; + } function renderMissingCard(title, message) { return `

${escapeHtml(title)}

@@ -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 `

Services

-
- ${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} -
+ ${renderMetricList(metrics, "metric-grid-4")} +
`; + } + 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 `
+

Environment

+ ${renderMetricList(metrics, "metric-grid-4")}
`; } 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 += `
${card}
`; + }); + } summaryEl.innerHTML += `
${renderServicesCard(payload)}
`; 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) => `
${escapeHtml(key)}
Loading...
@@ -2248,17 +2309,18 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const summaryEl = document.getElementById("statsSummary"); if (!summaryEl) return; summaryEl.innerHTML = '
Loading summary...
'; - 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 = `
${escapeHtml(error && error.message ? error.message : "summary unavailable")}
`; 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"); diff --git a/src/helpers/web/WebPanelServer.h b/src/helpers/web/WebPanelServer.h index 8d00bf0a..ba63808e 100644 --- a/src/helpers/web/WebPanelServer.h +++ b/src/helpers/web/WebPanelServer.h @@ -71,6 +71,7 @@ private: void refreshToken(); bool isAuthorized(httpd_req_t* req) const; void noteActivity(); + void stopRedirectServer(); #else WebPanelCommandRunner* _runner; #endif diff --git a/src/helpers/web/WebService.cpp b/src/helpers/web/WebService.cpp index afc5ef5c..1420526d 100644 --- a/src/helpers/web/WebService.cpp +++ b/src/helpers/web/WebService.cpp @@ -4,7 +4,7 @@ #include #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; } diff --git a/src/helpers/web/WebService.h b/src/helpers/web/WebService.h index 0cdaebf1..4275b890 100644 --- a/src/helpers/web/WebService.h +++ b/src/helpers/web/WebService.h @@ -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; }; diff --git a/variants/lilygo_tbeam_supreme_SX1262/target.cpp b/variants/lilygo_tbeam_supreme_SX1262/target.cpp index c2aa5087..23983bd3 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/target.cpp +++ b/variants/lilygo_tbeam_supreme_SX1262/target.cpp @@ -50,4 +50,3 @@ mesh::LocalIdentity radio_new_identity() { RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng); // create new random identity } -