diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 79619c53..7283a11c 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -25,9 +25,6 @@ jobs: - heltec_v4_repeater_mqtt - T_Beam_S3_Supreme_SX1262_companion_radio_wifi - T_Beam_S3_Supreme_SX1262_repeater_mqtt - # ESP32-C3 - - Xiao_C3_companion_radio_wifi - - Xiao_C3_repeater_mqtt # ESP32-C6 - LilyGo_Tlora_C6_repeater_mqtt - Xiao_C6_repeater_mqtt diff --git a/.github/workflows/push-build-check.yml b/.github/workflows/push-build-check.yml index 1c96248c..3d8a821c 100644 --- a/.github/workflows/push-build-check.yml +++ b/.github/workflows/push-build-check.yml @@ -23,8 +23,6 @@ jobs: # ESP32-S3 / core EastMesh targets - heltec_v4_repeater_mqtt - T_Beam_S3_Supreme_SX1262_repeater_mqtt - # ESP32-C3 - - Xiao_C3_repeater_mqtt # ESP32-C6 - LilyGo_Tlora_C6_repeater_mqtt - Xiao_C6_repeater_mqtt diff --git a/README.md b/README.md index a56171aa..eadb474c 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,6 @@ Features include: Recommended use is initial setup and occasional troubleshooting. On MQTT repeater deployments, disable the panel again when finished if you want maximum MQTT heap headroom. -Some constrained targets disable the panel to stay within flash limits. For example, `Xiao_C3_repeater_mqtt` builds with `WITH_WEB_PANEL=0`. - ### Companion WiFi Additions `*_companion_radio_wifi` targets now support persisted WiFi rescue commands: diff --git a/docs/_assets/repeater_web_panel_app.png b/docs/_assets/repeater_web_panel_app.png new file mode 100644 index 00000000..c5edae05 Binary files /dev/null and b/docs/_assets/repeater_web_panel_app.png differ diff --git a/docs/_assets/repeater_web_panel_overview.png b/docs/_assets/repeater_web_panel_overview.png deleted file mode 100644 index a06a5849..00000000 Binary files a/docs/_assets/repeater_web_panel_overview.png and /dev/null differ diff --git a/docs/_assets/repeater_web_panel_stats.png b/docs/_assets/repeater_web_panel_stats.png new file mode 100644 index 00000000..441df8a6 Binary files /dev/null and b/docs/_assets/repeater_web_panel_stats.png differ diff --git a/docs/boards.md b/docs/boards.md index e4ba6e96..45872975 100644 --- a/docs/boards.md +++ b/docs/boards.md @@ -16,14 +16,15 @@ The tables below are built from the repo's PlatformIO board metadata and variant - `RAM` is the MCU's built-in RAM. - `PSRAM` is extra memory on some boards. More PSRAM usually means more headroom for UI, MQTT, and future features. - `GPS` uses `โœ…` when present and is blank when absent. +- `SD` uses `โœ…` when the board is currently known to support the SD-backed archive path in EastMesh, `๐Ÿงช` when the hardware likely supports TF/microSD but the board-specific integration still needs validation, and is blank when there is no current SD/archive support note. ## Start Here - Pick an `ESP32-S3` board with `16MB` flash and PSRAM if you want strong overall headroom for MQTT plus UI: `heltec_v4_tft`, `heltec_v4`, `Station_G2`, `LilyGo_TBeam_1W`. - Pick a TFT board if this will be used as a human-facing companion or field node: `heltec_v4_tft`, `heltec_tracker_v2`, `LilyGo_TDeck`, `Heltec_T190`. - Pick e-paper if you want a status screen with lower idle draw and less frequent refresh: `Heltec_E213`, `Heltec_E290`, `Heltec_Wireless_Paper`, `ThinkNode_M5`. -- Pick a headless board if this is mainly a fixed MQTT gateway and screen space is not useful: `Xiao_C3`, `RAK_3112`, `Tenstar_C3`, `Heltec_ct62`, `Generic_E22`. -- Pick a headless Wi-Fi companion if the phone app will be the primary UI anyway: `Xiao_C3_companion_radio_wifi`, `RAK_3112_companion_radio_wifi`, `Xiao_S3_WIO_companion_radio_wifi`, `Station_G2_companion_radio_wifi`. +- Pick a headless board if this is mainly a fixed MQTT gateway and screen space is not useful: `RAK_3112`, `Generic_E22`, `Meshimi`, `Xiao_C6`. +- Pick a headless Wi-Fi companion if the phone app will be the primary UI anyway: `RAK_3112_companion_radio_wifi`, `Xiao_S3_WIO_companion_radio_wifi`, `Station_G2_companion_radio_wifi`. - Pick a GPS-capable board if location-aware/mobile use matters: the T-Beam family, `heltec_tracker_v2`, `Heltec_v3`, `heltec_v4`, `Station_G2`, `ThinkNode_M5`. - If you want the most conservative, older radio family choices, the `SX1276` boards are `LilyGo_TLora_V2_1_1_6`, `Tbeam_SX1276`, and `Heltec_v2`. @@ -41,7 +42,6 @@ If you are deciding between otherwise similar boards, these target-level setting | Ebyte_EoRa-S3 | default | 22 dBm cfg | ADC batt | | | Generic_E22_sx1262 | default | 22 dBm cfg | ADC batt | RF switch | | Generic_E22_sx1268 | default | 22 dBm cfg | ADC batt | RF switch | -| Heltec_ct62 | 80 MHz | 22 dBm cfg | ADC batt | | | Heltec_E213 | default | 22 dBm cfg | ADC batt | | | Heltec_E290 | default | 22 dBm cfg | ADC batt | | | Heltec_T190 | default | 22 dBm cfg | ADC batt | | @@ -70,12 +70,9 @@ If you are deciding between otherwise similar boards, these target-level setting | T_Beam_S3_Supreme_SX1262 | default | 22 dBm cfg | PMU | BME280 | | Tbeam_SX1262 | default | 22 dBm cfg | PMU | | | Tbeam_SX1276 | default | 20 dBm cfg | PMU | | -| Tenstar_C3_sx1262 | 80 MHz | 22 dBm cfg | ADC batt | | -| Tenstar_C3_sx1268 | 80 MHz | 22 dBm cfg | ADC batt | | | ThinkNode_M2 | default | 22 dBm cfg | ADC batt | | | ThinkNode_M5 | default | 22 dBm cfg | ADC batt | | | WHY2025_badge | default | 22 dBm cfg | none | | -| Xiao_C3 | 80 MHz | 22 dBm cfg | ADC batt | | | Xiao_C6 | default | 22 dBm cfg | none | RF switch | | Xiao_S3_WIO | default | 22 dBm cfg | none | RF switch | @@ -85,50 +82,50 @@ If you are deciding between otherwise similar boards, these target-level setting This table includes all repeater MQTT targets currently defined in `variants/eastmesh_mqtt/platformio.ini`. -| Target | MCU | RAM | PSRAM | Flash | LoRa | Display | GPS | -| ------------------------ | ------- | ------ | ----- | ----- | ------ | --------------- | --- | -| Ebyte_EoRa-S3 | ESP32S3 | 512 KB | 2 MB | 4 MB | SX1262 | OLED (SSD1306) | | -| Generic_E22_sx1262 | ESP32 | 520 KB | No | 4 MB | SX1262 | None | | -| Generic_E22_sx1268 | ESP32 | 520 KB | No | 4 MB | SX1268 | None | | -| Heltec_ct62 | ESP32C3 | 400 KB | No | 4 MB | SX1262 | None | | -| Heltec_E213 | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | E-paper (2.13") | | -| Heltec_E290 | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | E-paper (2.9") | | -| Heltec_T190 | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | TFT (ST7789) | | -| heltec_tracker_v2 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | TFT (ST7735) | โœ… | -| Heltec_v2 | ESP32 | 520 KB | No | 8 MB | SX1276 | OLED (SSD1306) | | -| Heltec_v3 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | OLED (SSD1306) | โœ… | -| heltec_v4 | ESP32S3 | 512 KB | 2 MB | 16 MB | SX1262 | OLED (SSD1306) | โœ… | -| heltec_v4_tft | ESP32S3 | 512 KB | 2 MB | 16 MB | SX1262 | TFT (ST7789) | โœ… | -| Heltec_Wireless_Paper | ESP32S3 | 512 KB | No | 8 MB | SX1262 | E-paper (2.13") | | -| Heltec_Wireless_Tracker | ESP32S3 | 512 KB | No | 8 MB | SX1262 | TFT (ST7735) | โœ… | -| Heltec_WSL3 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | None | โœ… | -| LilyGo_T3S3_sx1262 | ESP32S3 | 512 KB | 2 MB | 4 MB | SX1262 | OLED (SSD1306) | | -| LilyGo_T3S3_sx1276 | ESP32S3 | 512 KB | 2 MB | 4 MB | SX1276 | OLED (SSD1306) | | -| LilyGo_TBeam_1W | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | OLED (SH1106) | โœ… | -| LilyGo_TDeck | ESP32S3 | 512 KB | No | 16 MB | SX1262 | TFT (ST7789) | โœ… | -| LilyGo_Tlora_C6 | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | | -| LilyGo_TLora_V2_1_1_6 | ESP32 | 520 KB | No | 4 MB | SX1276 | OLED (SSD1306) | โœ… | -| M5Stack_Unit_C6L | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | โœ… | -| Meshadventurer_sx1262 | ESP32 | 520 KB | No | 4 MB | SX1262 | OLED (SSD1306) | โœ… | -| Meshadventurer_sx1268 | ESP32 | 520 KB | No | 4 MB | SX1268 | OLED (SSD1306) | โœ… | -| Meshimi | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | | -| nibble_screen_connect | ESP32S3 | 512 KB | No | 4 MB | SX1262 | OLED (SSD1306) | | -| RAK_3112 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | None | โœ… | -| Station_G2 | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | OLED (SH1106) | โœ… | -| Station_G2_logging | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | OLED (SH1106) | โœ… | -| T_Beam_S3_Supreme_SX1262 | ESP32S3 | 512 KB | 8 MB | 8 MB | SX1262 | OLED (SH1106) | โœ… | -| Tbeam_SX1262 | ESP32 | 520 KB | No | 4 MB | SX1262 | OLED (SSD1306) | โœ… | -| Tbeam_SX1276 | ESP32 | 520 KB | No | 4 MB | SX1276 | OLED (SSD1306) | โœ… | -| Tenstar_C3_sx1262 | ESP32C3 | 400 KB | No | 4 MB | SX1262 | None | | -| Tenstar_C3_sx1268 | ESP32C3 | 400 KB | No | 4 MB | SX1268 | None | | -| ThinkNode_M2 | ESP32S3 | 512 KB | No | 4 MB | SX1262 | OLED (SH1106) | | -| ThinkNode_M5 | ESP32S3 | 512 KB | No | 4 MB | SX1262 | E-paper (GxEPD) | โœ… | -| WHY2025_badge | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | | -| Xiao_C3 | ESP32C3 | 400 KB | No | 4 MB | SX1262 | None | โœ… | -| Xiao_C6 | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | | -| Xiao_S3_WIO | ESP32S3 | 512 KB | No | 8 MB | SX1262 | None | โœ… | +| Target | MCU | RAM | PSRAM | Flash | LoRa | Display | GPS | SD | +| ------------------------ | ------- | ------ | ----- | ----- | ------ | --------------- | --- | --- | +| Ebyte_EoRa-S3 | ESP32S3 | 512 KB | 2 MB | 4 MB | SX1262 | OLED (SSD1306) | | | +| Generic_E22_sx1262 | ESP32 | 520 KB | No | 4 MB | SX1262 | None | | | +| Generic_E22_sx1268 | ESP32 | 520 KB | No | 4 MB | SX1268 | None | | | +| Heltec_E213 | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | E-paper (2.13") | | | +| Heltec_E290 | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | E-paper (2.9") | | | +| Heltec_T190 | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | TFT (ST7789) | | | +| heltec_tracker_v2 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | TFT (ST7735) | โœ… | | +| Heltec_v2 | ESP32 | 520 KB | No | 8 MB | SX1276 | OLED (SSD1306) | | | +| Heltec_v3 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | OLED (SSD1306) | โœ… | | +| heltec_v4 | ESP32S3 | 512 KB | 2 MB | 16 MB | SX1262 | OLED (SSD1306) | โœ… | | +| heltec_v4_tft | ESP32S3 | 512 KB | 2 MB | 16 MB | SX1262 | TFT (ST7789) | โœ… | | +| Heltec_Wireless_Paper | ESP32S3 | 512 KB | No | 8 MB | SX1262 | E-paper (2.13") | | | +| Heltec_Wireless_Tracker | ESP32S3 | 512 KB | No | 8 MB | SX1262 | TFT (ST7735) | โœ… | | +| Heltec_WSL3 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | None | โœ… | | +| LilyGo_T3S3_sx1262 | ESP32S3 | 512 KB | 2 MB | 4 MB | SX1262 | OLED (SSD1306) | | ๐Ÿงช | +| LilyGo_T3S3_sx1276 | ESP32S3 | 512 KB | 2 MB | 4 MB | SX1276 | OLED (SSD1306) | | ๐Ÿงช | +| LilyGo_TBeam_1W | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | OLED (SH1106) | โœ… | โœ… | +| LilyGo_TDeck | ESP32S3 | 512 KB | No | 16 MB | SX1262 | TFT (ST7789) | โœ… | ๐Ÿงช | +| LilyGo_Tlora_C6 | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | | | +| LilyGo_TLora_V2_1_1_6 | ESP32 | 520 KB | No | 4 MB | SX1276 | OLED (SSD1306) | โœ… | | +| M5Stack_Unit_C6L | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | โœ… | | +| Meshadventurer_sx1262 | ESP32 | 520 KB | No | 4 MB | SX1262 | OLED (SSD1306) | โœ… | | +| Meshadventurer_sx1268 | ESP32 | 520 KB | No | 4 MB | SX1268 | OLED (SSD1306) | โœ… | | +| Meshimi | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | | | +| nibble_screen_connect | ESP32S3 | 512 KB | No | 4 MB | SX1262 | OLED (SSD1306) | | | +| RAK_3112 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | None | โœ… | | +| Station_G2 | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | OLED (SH1106) | โœ… | | +| Station_G2_logging | ESP32S3 | 512 KB | 8 MB | 16 MB | SX1262 | OLED (SH1106) | โœ… | | +| T_Beam_S3_Supreme_SX1262 | ESP32S3 | 512 KB | 8 MB | 8 MB | SX1262 | OLED (SH1106) | โœ… | โœ… | +| Tbeam_SX1262 | ESP32 | 520 KB | No | 4 MB | SX1262 | OLED (SSD1306) | โœ… | | +| Tbeam_SX1276 | ESP32 | 520 KB | No | 4 MB | SX1276 | OLED (SSD1306) | โœ… | | +| ThinkNode_M2 | ESP32S3 | 512 KB | No | 4 MB | SX1262 | OLED (SH1106) | | | +| ThinkNode_M5 | ESP32S3 | 512 KB | No | 4 MB | SX1262 | E-paper (GxEPD) | โœ… | | +| WHY2025_badge | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | | | +| Xiao_C6 | ESP32C6 | 512 KB | No | 4 MB | SX1262 | None | | | +| Xiao_S3_WIO | ESP32S3 | 512 KB | No | 8 MB | SX1262 | None | โœ… | | -Note: all current `repeater_mqtt` boards support the local web panel except `Xiao_C3`, where it is disabled to preserve limited board resources. +SD notes: + +- `โœ…` means the board is currently known to work with the SD-backed archive path in EastMesh. +- `๐Ÿงช` means the board hardware likely supports TF/microSD, but the EastMesh board-specific integration still needs validation before it should be treated as supported. +- For the current stats archive, a `4 GB` `FAT32` microSD card is more than sufficient. Larger cards are also supported. ## `companion_radio_wifi` Boards With A Display @@ -157,15 +154,14 @@ These are the Wi-Fi companion targets that rely on the companion app as the prim | ----------- | ------- | ------ | ----- | ----- | ------ | --- | | Heltec_WSL3 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | โœ… | | RAK_3112 | ESP32S3 | 512 KB | No | 8 MB | SX1262 | โœ… | -| Xiao_C3 | ESP32C3 | 400 KB | No | 4 MB | SX1262 | โœ… | | Xiao_S3_WIO | ESP32S3 | 512 KB | No | 8 MB | SX1262 | โœ… | ## Practical Picks - Best all-round MQTT repeater with screen and overall headroom: `heltec_v4_repeater_mqtt`, `heltec_v4_tft_repeater_mqtt`, `Station_G2_repeater_mqtt`, `LilyGo_TBeam_1W_repeater_mqtt`. -- Best MQTT repeater if you want a simple headless install: `RAK_3112_repeater_mqtt`, `Tenstar_C3_sx1262_repeater_mqtt`, `Heltec_ct62_repeater_mqtt`, `Generic_E22_sx1262_repeater_mqtt`. +- Best MQTT repeater if you want a simple headless install: `RAK_3112_repeater_mqtt`, `Generic_E22_sx1262_repeater_mqtt`, `Meshimi_repeater_mqtt`, `Xiao_C6_repeater_mqtt`. - Best low-power display MQTT builds: `Heltec_E213_repeater_mqtt`, `Heltec_E290_repeater_mqtt`, `Heltec_Wireless_Paper_repeater_mqtt`, `ThinkNode_M5_Repeater_mqtt`. - Best companion choices if you want the richest local UI: `heltec_v4_tft_companion_radio_wifi` and `heltec_tracker_v2_companion_radio_wifi`. - Best companion choices if you want maximum memory headroom: `T_Beam_S3_Supreme_SX1262_companion_radio_wifi`, `Station_G2_companion_radio_wifi`, `LilyGo_TBeam_1W_companion_radio_wifi`. -- Best companion choices if you are happy with an app-first, mostly headless setup: `Xiao_C3_companion_radio_wifi`, `RAK_3112_companion_radio_wifi`, `Xiao_S3_WIO_companion_radio_wifi`, `Station_G2_companion_radio_wifi`. +- Best companion choices if you are happy with an app-first, mostly headless setup: `RAK_3112_companion_radio_wifi`, `Xiao_S3_WIO_companion_radio_wifi`, `Station_G2_companion_radio_wifi`. - Best companion choices if you prefer OLED over TFT: `T_Beam_S3_Supreme_SX1262_companion_radio_wifi`, `heltec_v4_companion_radio_wifi`, `LilyGo_TBeam_1W_companion_radio_wifi`. diff --git a/docs/custom-cli.md b/docs/custom-cli.md index 617117e2..56381cbd 100644 --- a/docs/custom-cli.md +++ b/docs/custom-cli.md @@ -62,8 +62,11 @@ Legacy dotted aliases are also accepted: - `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. - `set web on|off` - `set.web on|off`: enables or disables the local HTTPS panel. +- `set web.stats on|off` +- `set.web.stats on|off`: enables or disables the dedicated `/stats` page and historical stats collection. ### Runtime Diagnostics @@ -87,9 +90,11 @@ That allowlist currently includes: - `get mqtt.status` - `get web` - `get web.status` +- `get web.stats.status` - `advert` - `reboot` - `start ota` +- `set repeat on|off` - `memory` - `stats-core` - `stats-radio` @@ -117,6 +122,7 @@ That allowlist currently includes: - `get mqtt.letsmesh-us` - `set mqtt.letsmesh-us on|off` - `set web on|off` +- `set web.stats on|off` - `get name` - `set name ` - `get lat` diff --git a/docs/local-builds.md b/docs/local-builds.md index 193425a0..a96585f9 100644 --- a/docs/local-builds.md +++ b/docs/local-builds.md @@ -67,7 +67,6 @@ These are the full PlatformIO env names used for local source builds and release ```text Generic_E22_sx1262_repeater_mqtt Generic_E22_sx1268_repeater_mqtt -Heltec_ct62_repeater_mqtt Heltec_E213_repeater_mqtt Heltec_E290_repeater_mqtt Heltec_T190_repeater_mqtt @@ -96,18 +95,11 @@ Station_G2_repeater_mqtt T_Beam_S3_Supreme_SX1262_repeater_mqtt Tbeam_SX1262_repeater_mqtt Tbeam_SX1276_repeater_mqtt -Tenstar_C3_sx1262_repeater_mqtt -Tenstar_C3_sx1268_repeater_mqtt WHY2025_badge_repeater_mqtt -Xiao_C3_repeater_mqtt Xiao_C6_repeater_mqtt Xiao_S3_WIO_repeater_mqtt ``` -Notes: - -- `Xiao_C3_repeater_mqtt` disables the web panel to stay within flash limits - ## Supported `companion_radio_wifi` Boards These are the full PlatformIO env names used for local source builds and release artifact naming. @@ -127,7 +119,6 @@ Station_G2_companion_radio_wifi T_Beam_S3_Supreme_SX1262_companion_radio_wifi ThinkNode_M2_companion_radio_wifi ThinkNode_M5_companion_radio_wifi -Xiao_C3_companion_radio_wifi Xiao_S3_WIO_companion_radio_wifi ``` diff --git a/docs/web-panel.md b/docs/web-panel.md index 7d73405f..e8659229 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -10,12 +10,13 @@ The repeater web panel is a local HTTPS configuration page served directly by th It gives you: -- a password-gated local admin page +- a password-gated local admin page at `/app` +- a dedicated stats and trends page at `/stats` - quick `get` commands for common repeater and MQTT checks - a terminal-style CLI panel for allowlisted commands - editable repeater settings - editable MQTT settings -- a stats dashboard with Wi-Fi, core, radio, memory, and packet views +- a historical stats view with trends, neighbours, and recent events Operational guidance: @@ -25,9 +26,15 @@ Operational guidance: ## Screenshot Overview -The layout below reflects the current panel structure for the repeater web UI. +The screenshots below show the current split between the lighter `/app` admin page and the dedicated `/stats` status page. -![Repeater web panel overview](./_assets/repeater_web_panel_overview.png) +### `/app` + +![Repeater web panel `/app` overview](./_assets/repeater_web_panel_app.png) + +### `/stats` + +![Repeater web panel `/stats` overview](./_assets/repeater_web_panel_stats.png) ## Requirements @@ -78,18 +85,42 @@ Recommended practice for repeater deployments: - use it again for occasional checks or troubleshooting - disable it with `set web off` when finished so MQTT has the most headroom available -## Actions +## Navigation And Actions -The Actions panel gives you the most common operational controls: +The web console now has two main pages: +- `/app`: lighter-weight control and configuration view +- `/stats`: current status, trends, neighbours, and recent events + +Both pages share the same top navigation and utility actions. + +### `/app` + +The `/app` page is the main admin and configuration surface. + +It includes: + +- navigation to `App` and `Stats` - `Advert` - `Start OTA` - `Reboot` -- `Logout` - theme toggle +- `Logout` Use `Start OTA` only when you intend to update firmware. +### `/stats` + +The `/stats` page is the home for current status and historical visibility. + +It includes: + +- navigation to `App` and `Stats` +- `Refresh` +- `Reboot` +- theme toggle +- `Logout` + ## Quick "get" Commands This section runs common read-only commands for: @@ -148,19 +179,34 @@ Notes: - you can toggle each MQTT server on or off from this panel - if all three servers are enabled at once, the panel shows a warning recommending two at most -## Stats +## `/stats` Overview -Press `Get Stats` to load the dashboard. +The stats page is loaded separately from `/app` and is intended to keep the main admin page lighter. -The stats page currently shows: +The `/stats` page currently shows: -- Core -- Radio -- Memory -- Wi-Fi -- Packets +- `Services`: MQTT, web, archive, card, neighbour count, and archive capacity +- `Trends`: battery, heap free, packet activity, and signal +- `Neighbours`: current neighbour table +- `Events`: current boot/session events -The dashboard is designed to work well on phones as well as desktop browsers. +The trend graphs load sequentially rather than as one large payload: + +1. summary/status +2. battery +3. memory +4. packet activity +5. signal + +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. + +Useful CLI commands: + +- `set web.stats on` +- `set web.stats off` +- `get web.stats.status` ## Mobile Use @@ -169,9 +215,9 @@ The page is responsive and should work cleanly on a phone. On mobile: - quick command buttons collapse into a two-column layout -- action buttons stack more cleanly +- top navigation and action groups stay compact and touch-friendly - input rows stay usable for touch interaction -- stats cards reorganize into single-column sections where needed +- trend cards reorganize into single-column sections where needed ## Common Tasks @@ -180,7 +226,7 @@ On mobile: 1. Open the panel. 2. Press `wifi.status` in Quick `get` Commands. 3. Press `mqtt.status` in Quick `get` Commands. -4. Press `Get Stats` for the dashboard view. +4. Open `/stats` from the top navigation for the historical stats view. ### Change Device Name @@ -201,6 +247,14 @@ On mobile: 2. Confirm the action. 3. Continue with your normal OTA workflow. +### Use Historical Stats + +1. Enable stats if needed with `set web.stats on`. +2. Open `/stats` from the top navigation. +3. Review `Services` for archive and runtime state. +4. Review `Trends` for recent graph history. +5. Use `Refresh` to reload the stats page. + ## Troubleshooting ### The browser warns about the certificate @@ -241,9 +295,20 @@ The panel intentionally limits what can be run from the browser. Use the serial Try: - refreshing the browser tab +- using `Refresh` on `/stats` - logging out and back in - checking WiFi stability with `get wifi.status` +### `/stats` is unavailable + +Check: + +- `get web.status` +- `get web.stats.status` +- whether `set web.stats on` has been applied + +If `web.stats` is off, `/stats` will stay disabled and the historical graph requests will not run. + ## Related Docs - [Custom CLI Commands](./custom-cli.md) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4e4ef4e7..8becb028 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,6 +1,129 @@ #include "MyMesh.h" #include +#if defined(ESP32) && WITH_WEB_PANEL + #include +#endif + +#ifndef ARCHIVE_DEBUG + #if defined(MQTT_DEBUG) && MQTT_DEBUG + #define ARCHIVE_DEBUG 1 + #else + #define ARCHIVE_DEBUG 0 + #endif +#endif + +#if ARCHIVE_DEBUG + #define ARCHIVE_LOG(fmt, ...) Serial.printf("[ARCHIVE] " fmt "\n", ##__VA_ARGS__) +#else + #define ARCHIVE_LOG(...) do { } while (0) +#endif + +namespace { + +constexpr unsigned long kArchiveNeighboursFlushIntervalMs = 60UL * 1000UL; +constexpr const char* kArchiveNeighboursSnapshotPath = "/stats/neighbours.snapshot"; + +File openArchiveWrite(FILESYSTEM* fs, const char* filename) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs->remove(filename); + return fs->open(filename, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + return fs->open(filename, "w"); +#else + fs->remove(filename); + return fs->open(filename, FILE_WRITE); +#endif +} + +File openArchiveRead(FILESYSTEM* fs, const char* filename) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(RP2040_PLATFORM) + return fs->open(filename, "r"); +#else + return fs->open(filename, FILE_READ); +#endif +} + +File openArchiveWriteWithRecovery(ArchiveStorage* archive, const char* filename) { + if (archive == nullptr) { + return File(); + } + FILESYSTEM* fs = archive->getFS(); + if (fs == nullptr) { + return File(); + } + File file = openArchiveWrite(fs, filename); + if (file) { + return file; + } + if (!archive->recover()) { + return File(); + } + fs = archive->getFS(); + return fs != nullptr ? openArchiveWrite(fs, filename) : File(); +} + +File openArchiveReadWithRecovery(ArchiveStorage* archive, const char* filename) { + if (archive == nullptr) { + return File(); + } + FILESYSTEM* fs = archive->getFS(); + if (fs == nullptr) { + return File(); + } + File file = openArchiveRead(fs, filename); + if (file) { + return file; + } + if (!archive->recover()) { + return File(); + } + fs = archive->getFS(); + return fs != nullptr ? openArchiveRead(fs, filename) : File(); +} + +void escapeJsonString(const char* input, char* output, size_t output_size) { + if (output == nullptr || output_size == 0) { + return; + } + + size_t oi = 0; + for (size_t i = 0; input != nullptr && input[i] != 0 && oi + 1 < output_size; ++i) { + const char c = input[i]; + const char* escape = nullptr; + switch (c) { + case '\\': + escape = "\\\\"; + break; + case '"': + escape = "\\\""; + break; + case '\n': + escape = "\\n"; + break; + case '\r': + escape = "\\r"; + break; + case '\t': + escape = "\\t"; + break; + default: + break; + } + + if (escape != nullptr) { + while (*escape != 0 && oi + 1 < output_size) { + output[oi++] = *escape++; + } + } else { + output[oi++] = c; + } + } + output[oi] = 0; +} + +} // namespace + /* ------------------------------ Config -------------------------------- */ #ifndef LORA_FREQ @@ -84,6 +207,7 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn neighbour->advert_timestamp = timestamp; neighbour->heard_timestamp = getRTCClock()->getCurrentTime(); neighbour->snr = (int8_t)(snr * 4); + _archive_neighbours_dirty = true; #endif } @@ -852,12 +976,17 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #endif { last_millis = 0; + _archive = nullptr; uptime_millis = 0; + next_archive_neighbours_flush_ms = 0; + next_history_sample_ms = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; set_radio_at = revert_radio_at = 0; _logging = false; + _archive_neighbours_dirty = false; region_load_active = false; + memset(&_stats_state, 0, sizeof(_stats_state)); #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); @@ -912,9 +1041,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc pending_discover_until = 0; } -void MyMesh::begin(FILESYSTEM *fs) { +void MyMesh::begin(FILESYSTEM *fs, ArchiveStorage* archive) { mesh::Mesh::begin(); _fs = fs; + _archive = archive; + last_millis = millis(); // load persisted prefs _cli.loadPrefs(_fs); acl.load(_fs, self_id); @@ -926,10 +1057,45 @@ void MyMesh::begin(FILESYSTEM *fs) { bridge.begin(); } #endif -#if defined(WITH_MQTT_UPLINK) +#if defined(ESP_PLATFORM) + uint8_t legacy_wifi_powersave = 0; + const char* legacy_wifi_ssid = nullptr; + const char* legacy_wifi_pwd = nullptr; +#ifdef WITH_MQTT_UPLINK + MQTTPrefs legacy_mqtt_prefs{}; + MQTTPrefsStore::setDefaults(legacy_mqtt_prefs); + MQTTPrefsStore::load(_fs, legacy_mqtt_prefs); + legacy_wifi_powersave = legacy_mqtt_prefs.legacy_wifi_powersave; + legacy_wifi_ssid = legacy_mqtt_prefs.legacy_wifi_ssid; + legacy_wifi_pwd = legacy_mqtt_prefs.legacy_wifi_pwd; +#endif + network.begin(_fs, legacy_wifi_powersave, legacy_wifi_ssid, legacy_wifi_pwd); +#endif +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL board.setInhibitSleep(true); + web.setCommandRunner(this); + web.setNetworkStateProvider(&network); + web.begin(_fs); + _stats_history.begin(web.isWebStatsEnabled(), _archive); + if (web.isWebStatsEnabled() && _archive != nullptr && _archive->isMounted()) { + restoreArchiveNeighbours(); + next_archive_neighbours_flush_ms = millis() + kArchiveNeighboursFlushIntervalMs; + } + if (web.isWebStatsEnabled()) { + recordStatsEvent(HISTORY_EVENT_BOOT); + if (_archive != nullptr) { + recordStatsEvent(_archive->isMounted() ? HISTORY_EVENT_ARCHIVE_MOUNTED : HISTORY_EVENT_ARCHIVE_UNAVAILABLE); + } + } +#endif +#if defined(WITH_MQTT_UPLINK) && !(defined(ESP_PLATFORM) && WITH_WEB_PANEL) + board.setInhibitSleep(true); +#endif +#ifdef WITH_MQTT_UPLINK mqtt.setNodeNameSource(_prefs.node_name); - mqtt.setWebCommandRunner(this); +#if defined(ESP_PLATFORM) + mqtt.setNetworkStateProvider(&network); +#endif mqtt.begin(_fs); #endif @@ -949,6 +1115,8 @@ void MyMesh::begin(FILESYSTEM *fs) { #if ENV_INCLUDE_GPS == 1 applyGpsPrefs(); #endif + + next_history_sample_ms = futureMillis(1000); } void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { @@ -1079,6 +1247,7 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) { NeighbourInfo *neighbour = &neighbours[i]; if (memcmp(neighbour->id.pub_key, pubkey, key_len) == 0) { neighbours[i] = NeighbourInfo(); // clear neighbour entry + _archive_neighbours_dirty = true; } } #endif @@ -1101,6 +1270,370 @@ void MyMesh::formatMemoryReply(char *reply, size_t reply_size) { StatsFormatHelper::formatMemoryStats(reply, reply_size); } +size_t MyMesh::getNeighbourCount() const { +#if MAX_NEIGHBOURS + size_t count = 0; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0) { + count++; + } + } + return count; +#else + return 0; +#endif +} + +bool MyMesh::restoreArchiveNeighbours() { +#if MAX_NEIGHBOURS + if (_archive == nullptr || !_archive->isMounted()) { + return false; + } + + FILESYSTEM* fs = _archive->getFS(); + if (fs == nullptr || !fs->exists(kArchiveNeighboursSnapshotPath)) { + return false; + } + + File file = openArchiveReadWithRecovery(_archive, kArchiveNeighboursSnapshotPath); + if (!file) { + ARCHIVE_LOG("neighbours restore open failed path=%s", kArchiveNeighboursSnapshotPath); + return false; + } + + memset(neighbours, 0, sizeof(neighbours)); + char line[128]; + size_t line_len = 0; + size_t restored = 0; + while (file.available()) { + const int raw = file.read(); + if (raw < 0) { + break; + } + + const char ch = static_cast(raw); + if (ch == '\r') { + continue; + } + if (ch == '\n') { + line[line_len] = 0; + if (line_len > 0 && restored < MAX_NEIGHBOURS) { + char full_hex[65]; + unsigned long advert_timestamp = 0; + unsigned long heard_timestamp = 0; + int snr = 0; + memset(full_hex, 0, sizeof(full_hex)); + if (sscanf(line, "%64[^,],%lu,%lu,%d", full_hex, &advert_timestamp, &heard_timestamp, &snr) == 4) { + uint8_t pub_key[PUB_KEY_SIZE]; + if (mesh::Utils::fromHex(pub_key, PUB_KEY_SIZE, full_hex)) { + neighbours[restored].id = mesh::Identity(pub_key); + neighbours[restored].advert_timestamp = static_cast(advert_timestamp); + neighbours[restored].heard_timestamp = static_cast(heard_timestamp); + neighbours[restored].snr = static_cast(constrain(snr, -128, 127)); + restored++; + } + } + } + line_len = 0; + continue; + } + + if (line_len + 1 < sizeof(line)) { + line[line_len++] = ch; + } + } + + if (line_len > 0 && restored < MAX_NEIGHBOURS) { + line[line_len] = 0; + char full_hex[65]; + unsigned long advert_timestamp = 0; + unsigned long heard_timestamp = 0; + int snr = 0; + memset(full_hex, 0, sizeof(full_hex)); + if (sscanf(line, "%64[^,],%lu,%lu,%d", full_hex, &advert_timestamp, &heard_timestamp, &snr) == 4) { + uint8_t pub_key[PUB_KEY_SIZE]; + if (mesh::Utils::fromHex(pub_key, PUB_KEY_SIZE, full_hex)) { + neighbours[restored].id = mesh::Identity(pub_key); + neighbours[restored].advert_timestamp = static_cast(advert_timestamp); + neighbours[restored].heard_timestamp = static_cast(heard_timestamp); + neighbours[restored].snr = static_cast(constrain(snr, -128, 127)); + restored++; + } + } + } + + file.close(); + _archive_neighbours_dirty = false; + return restored > 0; +#else + return false; +#endif +} + +void MyMesh::flushArchiveNeighbours() { +#if MAX_NEIGHBOURS + if (_archive == nullptr || !_archive->isMounted()) { + return; + } + + FILESYSTEM* fs = _archive->getFS(); + if (fs == nullptr) { + return; + } + + int16_t neighbours_count = 0; + NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0) { + sorted_neighbours[neighbours_count++] = &neighbours[i]; + } + } + + std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->heard_timestamp > b->heard_timestamp; + }); + + File file = openArchiveWriteWithRecovery(_archive, kArchiveNeighboursSnapshotPath); + if (!file) { + ARCHIVE_LOG("neighbours open failed path=%s", kArchiveNeighboursSnapshotPath); + return; + } + + size_t total_written = 0; + for (int i = 0; i < neighbours_count; ++i) { + char full_hex[65]; + mesh::Utils::toHex(full_hex, sorted_neighbours[i]->id.pub_key, PUB_KEY_SIZE); + total_written += file.printf("%s,%lu,%lu,%d\n", + full_hex, + static_cast(sorted_neighbours[i]->advert_timestamp), + static_cast(sorted_neighbours[i]->heard_timestamp), + static_cast(sorted_neighbours[i]->snr)); + } + file.flush(); + file.close(); + ARCHIVE_LOG("neighbours flushed path=%s bytes=%u count=%d", + kArchiveNeighboursSnapshotPath, + static_cast(total_written), + static_cast(neighbours_count)); + _archive_neighbours_dirty = false; +#endif +} + +void MyMesh::maybeFlushArchiveNeighbours(unsigned long now_ms) { +#if MAX_NEIGHBOURS + if (!_archive_neighbours_dirty || _archive == nullptr || !_archive->isMounted()) { + return; + } + if (next_archive_neighbours_flush_ms == 0 || millisHasNowPassed(next_archive_neighbours_flush_ms)) { + flushArchiveNeighbours(); + next_archive_neighbours_flush_ms = now_ms + kArchiveNeighboursFlushIntervalMs; + } +#else + (void)now_ms; +#endif +} + +void MyMesh::recordStatsEvent(uint8_t type, int16_t value) { + _stats_history.recordEvent(type, getRTCClock()->getCurrentTime(), static_cast(uptime_millis / 1000), value); +} + +void MyMesh::updateStatsHistory(unsigned long now_ms) { +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + constexpr uint32_t kLowMemoryEnterBytes = 32UL * 1024UL; + constexpr uint32_t kLowMemoryClearBytes = 48UL * 1024UL; + constexpr uint32_t kLowMemoryEventCooldownSecs = 5UL * 60UL; + _stats_history.setArchive(_archive); + _stats_history.setEnabled(web.isWebStatsEnabled()); + if (!_stats_history.isEnabled()) { + _stats_state.initialized = false; + _archive_neighbours_dirty = false; + return; + } + + const bool wifi_connected = network.isWifiConnected(); +#ifdef WITH_MQTT_UPLINK + const bool mqtt_connected = mqtt.isAnyBrokerConnected(); +#else + const bool mqtt_connected = false; +#endif + const bool web_panel_up = web.isPanelRunning(); + const bool archive_mounted = (_archive != nullptr) && _archive->isMounted(); +#if defined(ESP32) + const uint32_t free_heap = ESP.getFreeHeap(); + const uint32_t uptime_secs = static_cast(uptime_millis / 1000); + bool low_memory = _stats_state.low_memory; + if (!_stats_state.initialized) { + low_memory = free_heap <= kLowMemoryEnterBytes; + } else if (low_memory) { + low_memory = free_heap <= kLowMemoryClearBytes; + } else { + low_memory = free_heap <= kLowMemoryEnterBytes; + } +#else + const bool low_memory = false; +#endif + + if (!_stats_state.initialized) { + _stats_state.initialized = true; + _stats_state.wifi_connected = wifi_connected; + _stats_state.mqtt_connected = mqtt_connected; + _stats_state.web_panel_up = web_panel_up; + _stats_state.archive_mounted = archive_mounted; + _stats_state.low_memory = low_memory; + _stats_state.last_low_memory_event_uptime_secs = 0; + } else { + if (_stats_state.mqtt_connected != mqtt_connected) { + recordStatsEvent(mqtt_connected ? HISTORY_EVENT_MQTT_CONNECTED : HISTORY_EVENT_MQTT_DISCONNECTED); + _stats_state.mqtt_connected = mqtt_connected; + } + if (_stats_state.web_panel_up != web_panel_up) { + recordStatsEvent(web_panel_up ? HISTORY_EVENT_WEB_STARTED : HISTORY_EVENT_WEB_STOPPED); + _stats_state.web_panel_up = web_panel_up; + } + if (_stats_state.archive_mounted != archive_mounted) { + recordStatsEvent(archive_mounted ? HISTORY_EVENT_ARCHIVE_MOUNTED : HISTORY_EVENT_ARCHIVE_UNAVAILABLE); + _stats_state.archive_mounted = archive_mounted; + if (archive_mounted) { + if (getNeighbourCount() == 0) { + restoreArchiveNeighbours(); + } + next_archive_neighbours_flush_ms = now_ms + kArchiveNeighboursFlushIntervalMs; + } + } + if (!_stats_state.low_memory && low_memory) { +#if defined(ESP32) + if (_stats_state.last_low_memory_event_uptime_secs == 0 || + (uptime_secs - _stats_state.last_low_memory_event_uptime_secs) >= kLowMemoryEventCooldownSecs) { + recordStatsEvent(HISTORY_EVENT_LOW_MEMORY, static_cast(min(free_heap / 1024, 32767))); + _stats_state.last_low_memory_event_uptime_secs = uptime_secs; + } +#endif + } + _stats_state.wifi_connected = wifi_connected; + _stats_state.low_memory = low_memory; + } + + if (next_history_sample_ms == 0 || millisHasNowPassed(next_history_sample_ms)) { + HistorySample sample{}; + sample.epoch_secs = getRTCClock()->getCurrentTime(); + sample.uptime_secs = static_cast(uptime_millis / 1000); + sample.packets_sent = radio_driver.getPacketsSent(); + sample.packets_recv = radio_driver.getPacketsRecv(); + sample.battery_mv = board.getBattMilliVolts(); + sample.queue_len = static_cast(_mgr->getOutboundTotal()); + sample.error_flags = _err_flags; + sample.recv_errors = static_cast(min(radio_driver.getPacketsRecvErrors(), 0xFFFF)); + sample.neighbour_count = static_cast(min(getNeighbourCount(), 0xFFFF)); + sample.direct_dups = static_cast(min(((SimpleMeshTables *)getTables())->getNumDirectDups(), 0xFFFF)); + sample.flood_dups = static_cast(min(((SimpleMeshTables *)getTables())->getNumFloodDups(), 0xFFFF)); + sample.last_rssi_x4 = static_cast(radio_driver.getLastRSSI() * 4.0f); + 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 defined(ESP32) + sample.heap_free = ESP.getFreeHeap(); + sample.heap_min = ESP.getMinFreeHeap(); + sample.psram_free = ESP.getFreePsram(); + sample.psram_min = ESP.getMinFreePsram(); +#endif + if (board.isExternalPowered()) sample.flags |= HISTORY_FLAG_EXTERNAL_POWER; + if (board.isCharging()) sample.flags |= HISTORY_FLAG_CHARGING; + if (board.isVbusPresent()) sample.flags |= HISTORY_FLAG_VBUS; + if (wifi_connected) sample.flags |= HISTORY_FLAG_WIFI_CONNECTED; + if (mqtt_connected) sample.flags |= HISTORY_FLAG_MQTT_CONNECTED; + if (web.isWebEnabled()) sample.flags |= HISTORY_FLAG_WEB_ENABLED; + if (web_panel_up) sample.flags |= HISTORY_FLAG_WEB_PANEL_UP; + if (archive_mounted) sample.flags |= HISTORY_FLAG_ARCHIVE_MOUNTED; + _stats_history.pushSample(sample); + next_history_sample_ms = now_ms + 60000UL; + } + + _stats_history.maybeFlush(now_ms); + maybeFlushArchiveNeighbours(now_ms); +#else + (void)now_ms; +#endif +} + +bool MyMesh::appendJsonEvents(char* reply, size_t reply_size, size_t& offset) const { + offset += snprintf(&reply[offset], reply_size - offset, "\"events\":["); + const size_t max_events = min(_stats_history.getEventCount(), 6); + const uint32_t now_epoch_secs = getRTCClock()->getCurrentTime(); + const uint32_t now_uptime_secs = static_cast(uptime_millis / 1000); + for (size_t i = 0; i < max_events; ++i) { + HistoryEvent event{}; + if (!_stats_history.getRecentEvent(i, event)) { + break; + } + const uint32_t age_secs = (now_epoch_secs >= event.epoch_secs && event.epoch_secs > 0) + ? (now_epoch_secs - event.epoch_secs) + : ((now_uptime_secs >= event.uptime_secs) ? (now_uptime_secs - event.uptime_secs) : event.uptime_secs); + offset += snprintf(&reply[offset], reply_size - offset, + "%s{\"t\":%lu,\"type\":\"%s\",\"value\":%d}", + i == 0 ? "" : ",", + static_cast(age_secs), + StatsHistory::getEventTypeName(event.type), + static_cast(event.value)); + if (offset >= reply_size) { + return false; + } + } + offset += snprintf(&reply[offset], reply_size - offset, "]"); + return offset < reply_size; +} + +bool MyMesh::appendJsonNeighbours(char* reply, size_t reply_size, size_t& offset) const { + offset += snprintf(&reply[offset], reply_size - offset, "\"neighbors_detail\":["); + if (offset >= reply_size) { + return false; + } + +#if MAX_NEIGHBOURS + constexpr size_t kMaxNeighboursJson = 10; + int16_t neighbours_count = 0; + NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + auto neighbour = const_cast(&neighbours[i]); + if (neighbour->heard_timestamp > 0) { + sorted_neighbours[neighbours_count++] = neighbour; + } + } + + std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->heard_timestamp > b->heard_timestamp; + }); + + const size_t emit_count = min(neighbours_count, kMaxNeighboursJson); + const uint32_t now_secs = getRTCClock()->getCurrentTime(); + for (size_t i = 0; i < emit_count; ++i) { + const NeighbourInfo* neighbour = sorted_neighbours[i]; + char hex[7]; + char full_hex[65]; + mesh::Utils::toHex(hex, neighbour->id.pub_key, 3); + mesh::Utils::toHex(full_hex, neighbour->id.pub_key, PUB_KEY_SIZE); + const uint32_t heard_secs_ago = now_secs - neighbour->heard_timestamp; + const uint32_t advert_secs_ago = now_secs - neighbour->advert_timestamp; + ClientInfo* client = const_cast(acl).getClient(neighbour->id.pub_key, PUB_KEY_SIZE); + const bool route_known = (client != nullptr && client->out_path_len != OUT_PATH_UNKNOWN); + offset += snprintf(&reply[offset], reply_size - offset, + "%s{\"id\":\"%s\",\"full_id\":\"%s\",\"heard_secs_ago\":%lu,\"advert_secs_ago\":%lu,\"snr_db\":%.2f,\"route\":\"%s\"}", + i == 0 ? "" : ",", + hex, + full_hex, + static_cast(heard_secs_ago), + static_cast(advert_secs_ago), + static_cast(neighbour->snr) / 4.0, + route_known ? "known" : "unknown"); + if (offset >= reply_size) { + return false; + } + } +#endif + + offset += snprintf(&reply[offset], reply_size - offset, "]"); + return offset < reply_size; +} + void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); @@ -1307,6 +1840,75 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + } else if (strcmp(command, "get web.status") == 0 || strcmp(command, "get web") == 0) { + web.formatWebStatusReply(reply, 160); + } else if (strcmp(command, "get web.stats.status") == 0) { + snprintf(reply, 160, + "> enabled:%s history:%s psram:%s degraded:%s samples:%u/%u events:%u/%u archive:%s logical:%s path:%s", + web.isWebStatsEnabled() ? "on" : "off", + (_stats_history.isEnabled() && _stats_history.isRecentHistoryAvailable()) ? "active" : "inactive", + _stats_history.isPsramBacked() ? "yes" : "no", + _stats_history.isDegraded() ? "yes" : "no", + static_cast(_stats_history.getSampleCount()), + static_cast(_stats_history.getSampleCapacity()), + static_cast(_stats_history.getEventCount()), + static_cast(_stats_history.getEventCapacity()), + (_archive != nullptr && _archive->isMounted()) ? "mounted" : "unavailable", + (_archive != nullptr) ? _archive->getLogicalName() : "archive", + (_archive != nullptr) ? _archive->getLogicalStatsPath() : "archive:/stats"); +#endif +#if defined(ESP_PLATFORM) + } else if (memcmp(command, "get wifi.status", 15) == 0) { + network.formatWifiStatusReply(reply, 160); + } else if (memcmp(command, "get wifi.ssid", 13) == 0) { + sprintf(reply, "> %s", network.getWifiSSID()[0] ? network.getWifiSSID() : "-"); + } else if (memcmp(command, "get wifi.powersaving", 20) == 0) { + sprintf(reply, "> %s", network.getWifiPowerSave()); +#endif +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + } else if (memcmp(command, "set web ", 8) == 0) { + web.setWebEnabled(memcmp(&command[8], "on", 2) == 0); + strcpy(reply, "OK"); + } else if (memcmp(command, "set.web ", 8) == 0) { + web.setWebEnabled(memcmp(&command[8], "on", 2) == 0); + strcpy(reply, "OK"); + } else if (memcmp(command, "set web.stats ", 14) == 0 || memcmp(command, "set.web.stats ", 15) == 0) { + const char* value = (memcmp(command, "set web.stats ", 14) == 0) ? &command[14] : &command[15]; + const bool enabled = memcmp(value, "on", 2) == 0; + if (web.setWebStatsEnabled(enabled)) { + _stats_history.setEnabled(enabled); + recordStatsEvent(enabled ? HISTORY_EVENT_STATS_ENABLED : HISTORY_EVENT_STATS_DISABLED); + if (enabled) { + next_history_sample_ms = millis(); + } else { + _stats_state.initialized = false; + } + strcpy(reply, enabled ? "OK - web.stats on" : "OK - web.stats off"); + } else { + strcpy(reply, "Err - unable to update web.stats"); + } +#endif +#if defined(ESP_PLATFORM) + } else if (memcmp(command, "set wifi.ssid ", 14) == 0) { + if (network.setWifiSSID(&command[14])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - bad wifi.ssid"); + } + } else if (memcmp(command, "set wifi.pwd ", 13) == 0) { + if (network.setWifiPassword(&command[13])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - bad wifi.pwd"); + } + } else if (memcmp(command, "set wifi.powersaving ", 21) == 0) { + if (network.setWifiPowerSave(&command[21])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - use none|min|max"); + } +#endif #ifdef WITH_MQTT_UPLINK } else if (memcmp(command, "mqtt.owner ", 11) == 0) { if (mqtt.setOwnerPublicKey(&command[11])) { @@ -1330,14 +1932,6 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sprintf(reply, "> %s", mqtt.isStatusEnabled() ? "on" : "off"); } else if (strcmp(command, "get mqtt.status") == 0) { mqtt.formatStatusReply(reply, 160); - } else if (strcmp(command, "get web.status") == 0 || strcmp(command, "get web") == 0) { - mqtt.formatWebStatusReply(reply, 160); - } else if (memcmp(command, "get wifi.status", 15) == 0) { - mqtt.formatWifiStatusReply(reply, 160); - } else if (memcmp(command, "get wifi.ssid", 13) == 0) { - sprintf(reply, "> %s", mqtt.getWifiSSID()[0] ? mqtt.getWifiSSID() : "-"); - } else if (memcmp(command, "get wifi.powersaving", 20) == 0) { - sprintf(reply, "> %s", mqtt.getWifiPowerSave()); } else if (memcmp(command, "get mqtt.iata", 13) == 0) { sprintf(reply, "> %s", mqtt.getIata()); } else if (memcmp(command, "get mqtt.owner", 14) == 0) { @@ -1359,30 +1953,6 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } else if (memcmp(command, "set mqtt.tx ", 12) == 0) { mqtt.setTxEnabled(memcmp(&command[12], "on", 2) == 0); strcpy(reply, "OK"); - } else if (memcmp(command, "set web ", 8) == 0) { - mqtt.setWebEnabled(memcmp(&command[8], "on", 2) == 0); - strcpy(reply, "OK"); - } else if (memcmp(command, "set.web ", 8) == 0) { - mqtt.setWebEnabled(memcmp(&command[8], "on", 2) == 0); - strcpy(reply, "OK"); - } else if (memcmp(command, "set wifi.ssid ", 14) == 0) { - if (mqtt.setWifiSSID(&command[14])) { - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - bad wifi.ssid"); - } - } else if (memcmp(command, "set wifi.pwd ", 13) == 0) { - if (mqtt.setWifiPassword(&command[13])) { - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - bad wifi.pwd"); - } - } else if (memcmp(command, "set wifi.powersaving ", 21) == 0) { - if (mqtt.setWifiPowerSave(&command[21])) { - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - use none|min|max"); - } } else if (memcmp(command, "set mqtt.iata ", 14) == 0) { if (mqtt.setIata(&command[14])) { strcpy(reply, "OK"); @@ -1457,6 +2027,7 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size) matches_exact("clock") || matches_exact("get mqtt.status") || matches_exact("get web.status") || + matches_exact("get web.stats.status") || matches_exact("get web") || matches_exact("advert") || matches_exact("reboot") || @@ -1489,6 +2060,7 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size) matches_exact("get public.key") || matches_exact("get advert.interval") || matches_exact("get flood.advert.interval") || + matches_exact("get repeat") || matches_exact("get flood.max") || matches_exact("get path.hash.mode") || matches_exact("get owner.info") || @@ -1505,6 +2077,8 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size) matches_prefix("set mqtt.tx ") || matches_prefix("set web ") || matches_prefix("set.web ") || + matches_prefix("set web.stats ") || + matches_prefix("set.web.stats ") || matches_prefix("set mqtt.eastmesh-au ") || matches_prefix("set mqtt.eastmesh.au ") || matches_prefix("set mqtt.letsmesh-eu ") || @@ -1520,6 +2094,7 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size) matches_prefix("set prv.key ") || matches_prefix("set advert.interval ") || matches_prefix("set flood.advert.interval ") || + matches_prefix("set repeat ") || matches_prefix("set flood.max ") || matches_prefix("set path.hash.mode ") || matches_prefix("time ") || @@ -1538,11 +2113,256 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size) reply[reply_size - 1] = 0; } +bool MyMesh::isWebStatsEnabled() const { +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + return web.isWebStatsEnabled(); +#else + return false; +#endif +} + +bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { + if (reply == nullptr || reply_size == 0) { + return false; + } + reply[0] = 0; + +#if !defined(ESP_PLATFORM) || !WITH_WEB_PANEL + return false; +#else + char wifi_ssid[48]; + char wifi_status[20]; + char wifi_state[24]; + char wifi_ip[20]; + char wifi_signal[16]; + char wifi_powersave[12]; + escapeJsonString(network.getWifiSSID()[0] ? network.getWifiSSID() : "-", wifi_ssid, sizeof(wifi_ssid)); + escapeJsonString(network.getWifiPowerSave(), wifi_powersave, sizeof(wifi_powersave)); + int wifi_rssi = 0; + int wifi_quality = 0; + int wifi_code = 0; + +#if defined(ESP32) + if (network.getWifiSSID()[0] == 0) { + strncpy(wifi_status, "unconfigured", sizeof(wifi_status) - 1); + wifi_status[sizeof(wifi_status) - 1] = 0; + strncpy(wifi_state, "unconfigured", sizeof(wifi_state) - 1); + wifi_state[sizeof(wifi_state) - 1] = 0; + strncpy(wifi_ip, "--", sizeof(wifi_ip) - 1); + wifi_ip[sizeof(wifi_ip) - 1] = 0; + strncpy(wifi_signal, "--", sizeof(wifi_signal) - 1); + wifi_signal[sizeof(wifi_signal) - 1] = 0; + } else if (network.isWifiConnected()) { + strncpy(wifi_status, "connected", sizeof(wifi_status) - 1); + wifi_status[sizeof(wifi_status) - 1] = 0; + strncpy(wifi_state, "connected", sizeof(wifi_state) - 1); + wifi_state[sizeof(wifi_state) - 1] = 0; + String ip = WiFi.localIP().toString(); + escapeJsonString(ip.c_str(), wifi_ip, sizeof(wifi_ip)); + wifi_rssi = WiFi.RSSI(); + wifi_code = static_cast(WiFi.status()); + if (wifi_rssi <= -100) { + wifi_quality = 0; + strncpy(wifi_signal, "poor", sizeof(wifi_signal) - 1); + } else if (wifi_rssi >= -50) { + wifi_quality = 100; + strncpy(wifi_signal, "excellent", sizeof(wifi_signal) - 1); + } else { + wifi_quality = 2 * (wifi_rssi + 100); + if (wifi_rssi >= -60) { + strncpy(wifi_signal, "excellent", sizeof(wifi_signal) - 1); + } else if (wifi_rssi >= -67) { + strncpy(wifi_signal, "good", sizeof(wifi_signal) - 1); + } else if (wifi_rssi >= -75) { + strncpy(wifi_signal, "fair", sizeof(wifi_signal) - 1); + } else { + strncpy(wifi_signal, "poor", sizeof(wifi_signal) - 1); + } + } + wifi_signal[sizeof(wifi_signal) - 1] = 0; + } else { + strncpy(wifi_status, "connecting", sizeof(wifi_status) - 1); + wifi_status[sizeof(wifi_status) - 1] = 0; + wifi_code = static_cast(WiFi.status()); + switch (WiFi.status()) { + case WL_IDLE_STATUS: + strncpy(wifi_state, "idle", sizeof(wifi_state) - 1); + break; + case WL_NO_SSID_AVAIL: + strncpy(wifi_state, "no_ssid", sizeof(wifi_state) - 1); + break; + case WL_SCAN_COMPLETED: + strncpy(wifi_state, "scan_completed", sizeof(wifi_state) - 1); + break; + case WL_CONNECT_FAILED: + strncpy(wifi_state, "connect_failed", sizeof(wifi_state) - 1); + break; + case WL_CONNECTION_LOST: + strncpy(wifi_state, "connection_lost", sizeof(wifi_state) - 1); + break; + case WL_DISCONNECTED: + strncpy(wifi_state, "disconnected", sizeof(wifi_state) - 1); + break; + default: + strncpy(wifi_state, "unknown", sizeof(wifi_state) - 1); + break; + } + wifi_state[sizeof(wifi_state) - 1] = 0; + strncpy(wifi_ip, "--", sizeof(wifi_ip) - 1); + wifi_ip[sizeof(wifi_ip) - 1] = 0; + strncpy(wifi_signal, "--", sizeof(wifi_signal) - 1); + wifi_signal[sizeof(wifi_signal) - 1] = 0; + } +#else + strncpy(wifi_status, "unsupported", sizeof(wifi_status) - 1); + wifi_status[sizeof(wifi_status) - 1] = 0; + strncpy(wifi_state, "unsupported", sizeof(wifi_state) - 1); + wifi_state[sizeof(wifi_state) - 1] = 0; + strncpy(wifi_ip, "--", sizeof(wifi_ip) - 1); + wifi_ip[sizeof(wifi_ip) - 1] = 0; + strncpy(wifi_signal, "--", sizeof(wifi_signal) - 1); + wifi_signal[sizeof(wifi_signal) - 1] = 0; +#endif + + const int battery_pct = board.getBatteryPercent(); + const bool archive_available = (_archive != nullptr) && _archive->isMounted(); +#ifdef WITH_MQTT_UPLINK + const bool mqtt_connected = mqtt.isAnyBrokerConnected(); +#else + const bool mqtt_connected = false; +#endif + const bool web_panel_up = web.isPanelRunning(); + const char* archive_name = (_archive != nullptr) ? _archive->getLogicalName() : "archive"; + const char* archive_path = (_archive != nullptr) ? _archive->getLogicalStatsPath() : "archive:/stats"; + const char* archive_type = (_archive != nullptr) ? _archive->getCardTypeName() : "unavailable"; + + size_t offset = 0; + offset += snprintf(&reply[offset], reply_size - offset, + "{\"enabled\":true," + "\"history\":{\"active\":%s,\"psram\":%s,\"degraded\":%s,\"samples\":%u,\"sample_capacity\":%u,\"sample_interval_secs\":%lu," + "\"archive_restored\":%s,\"archive_restored_samples\":%u,\"archive_summary_interval_secs\":%lu," + "\"events\":%u,\"event_capacity\":%u}," + "\"archive\":{\"logical\":\"%s\",\"available\":%s,\"path\":\"%s\",\"type\":\"%s\"," + "\"total_bytes\":%llu,\"used_bytes\":%llu}," + "\"core\":{\"battery_mv\":%u,\"battery_pct\":%d,\"uptime_secs\":%lu,\"errors\":%u,\"queue_len\":%u," + "\"external_power\":%s,\"charging\":%s,\"vbus\":%s}," + "\"radio\":{\"noise_floor\":%d,\"last_rssi\":%.2f,\"last_snr\":%.2f,\"tx_air_secs\":%lu,\"rx_air_secs\":%lu}," + "\"packets\":{\"recv\":%u,\"sent\":%u,\"flood_tx\":%u,\"direct_tx\":%u,\"flood_rx\":%u,\"direct_rx\":%u," + "\"recv_errors\":%u,\"direct_dups\":%u,\"flood_dups\":%u,\"neighbors\":%u}," + "\"memory\":{\"heap_free\":%u,\"heap_min\":%u,\"heap_max\":%u,\"psram_free\":%u,\"psram_min\":%u,\"psram_max\":%u}," + "\"wifi\":{\"ssid\":\"%s\",\"status\":\"%s\",\"connected\":%s,\"state\":\"%s\",\"code\":%d,\"ip\":\"%s\",\"rssi\":%d,\"quality\":%d,\"signal\":\"%s\",\"powersave\":\"%s\"}," + "\"services\":{\"mqtt_connected\":%s,\"web_enabled\":%s,\"web_panel_up\":%s,\"web_auth\":\"%s\"," + "\"archive_available\":%s}", + (_stats_history.isEnabled() && _stats_history.isRecentHistoryAvailable()) ? "true" : "false", + _stats_history.isPsramBacked() ? "true" : "false", + _stats_history.isDegraded() ? "true" : "false", + static_cast(_stats_history.getSampleCount()), + static_cast(_stats_history.getSampleCapacity()), + static_cast(StatsHistory::getSampleIntervalSecs()), + _stats_history.hasArchiveRestore() ? "true" : "false", + static_cast(_stats_history.getRestoredSampleCount()), + static_cast(StatsHistory::getArchiveSummaryIntervalSecs()), + static_cast(_stats_history.getEventCount()), + static_cast(_stats_history.getEventCapacity()), + archive_name, + archive_available ? "true" : "false", + archive_path, + archive_type, + static_cast(_archive != nullptr ? _archive->getTotalBytes() : 0), + static_cast(_archive != nullptr ? _archive->getUsedBytes() : 0), + board.getBattMilliVolts(), + battery_pct, + static_cast(uptime_millis / 1000), + _err_flags, + static_cast(_mgr->getOutboundTotal()), + board.isExternalPowered() ? "true" : "false", + board.isCharging() ? "true" : "false", + board.isVbusPresent() ? "true" : "false", + static_cast(_radio->getNoiseFloor()), + radio_driver.getLastRSSI(), + radio_driver.getLastSNR(), + static_cast(getTotalAirTime() / 1000), + static_cast(getReceiveAirTime() / 1000), + static_cast(radio_driver.getPacketsRecv()), + static_cast(radio_driver.getPacketsSent()), + static_cast(getNumSentFlood()), + static_cast(getNumSentDirect()), + static_cast(getNumRecvFlood()), + static_cast(getNumRecvDirect()), + static_cast(radio_driver.getPacketsRecvErrors()), + static_cast(((SimpleMeshTables *)getTables())->getNumDirectDups()), + static_cast(((SimpleMeshTables *)getTables())->getNumFloodDups()), + static_cast(getNeighbourCount()), + ESP.getFreeHeap(), + ESP.getMinFreeHeap(), + ESP.getMaxAllocHeap(), + ESP.getFreePsram(), + ESP.getMinFreePsram(), + ESP.getMaxAllocPsram(), + wifi_ssid, + wifi_status, + network.isWifiConnected() ? "true" : "false", + wifi_state, + wifi_code, + wifi_ip, + wifi_rssi, + wifi_quality, + wifi_signal, + wifi_powersave, + mqtt_connected ? "true" : "false", + web.isWebEnabled() ? "true" : "false", + web_panel_up ? "true" : "false", + web.isPanelUnlocked() ? "unlocked" : "locked", + archive_available ? "true" : "false"); + if (offset >= reply_size) { + return false; + } + + offset += snprintf(&reply[offset], reply_size - offset, ","); + if (!appendJsonEvents(reply, reply_size, offset)) { + return false; + } + offset += snprintf(&reply[offset], reply_size - offset, ","); + if (!appendJsonNeighbours(reply, reply_size, offset)) { + return false; + } + offset += snprintf(&reply[offset], reply_size - offset, "}"); + return offset < reply_size; +#endif +} + +bool MyMesh::formatWebStatsSeriesJson(const char* series, char* reply, size_t reply_size) { +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + if (!web.isWebStatsEnabled()) { + if (reply != nullptr && reply_size > 0) { + reply[0] = 0; + } + return false; + } + return _stats_history.buildSeriesJson( + series, + reply, + reply_size, + getRTCClock()->getCurrentTime(), + static_cast(uptime_millis / 1000)); +#else + (void)series; + if (reply != nullptr && reply_size > 0) { + reply[0] = 0; + } + return false; +#endif +} + void MyMesh::loop() { #ifdef WITH_BRIDGE bridge.loop(); #endif + const uint32_t now = millis(); + uptime_millis += now - last_millis; + last_millis = now; + mesh::Mesh::loop(); if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { @@ -1577,6 +2397,19 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } +#if defined(ESP_PLATFORM) + bool network_required = false; +#if WITH_WEB_PANEL + network_required = web.isWebEnabled(); +#endif +#ifdef WITH_MQTT_UPLINK + network_required = network_required || mqtt.isActive(); +#endif + network.loop(network_required); +#if WITH_WEB_PANEL + web.loop(); +#endif +#endif #ifdef WITH_MQTT_UPLINK MQTTStatusSnapshot mqtt_status{}; mqtt_status.battery_mv = static_cast(board.getBattMilliVolts()); @@ -1593,11 +2426,9 @@ void MyMesh::loop() { mqtt_status.radio_cr = _prefs.cr; mqtt.loop(mqtt_status); #endif - - // update uptime - uint32_t now = millis(); - uptime_millis += now - last_millis; - last_millis = now; +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + updateStatsHistory(now); +#endif } // To check if there is pending work @@ -1607,6 +2438,9 @@ bool MyMesh::hasPendingWork() const { #endif #if defined(WITH_MQTT_UPLINK) if (mqtt.isActive()) return true; +#endif +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + if (web.isWebEnabled()) return true; #endif return _mgr->getOutboundTotal() > 0; } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 3e88af25..37f61d9c 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -26,13 +26,17 @@ #ifdef WITH_MQTT_UPLINK #include #endif +#include +#include #include +#include #include #include #include #include #include +#include #include #include #include @@ -84,12 +88,16 @@ struct NeighbourInfo { #define PACKET_LOG_FILE "/packet_log" -class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public MQTTWebCommandRunner { +class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public WebPanelCommandRunner { FILESYSTEM* _fs; + ArchiveStorage* _archive; uint32_t last_millis; uint64_t uptime_millis; + unsigned long next_archive_neighbours_flush_ms; + unsigned long next_history_sample_ms; unsigned long next_local_advert, next_flood_advert; bool _logging; + bool _archive_neighbours_dirty; NodePrefs _prefs; ClientACL acl; CommonCLI _cli; @@ -124,8 +132,32 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public MQTTWebComma #ifdef WITH_MQTT_UPLINK MQTTUplink mqtt; #endif +#if defined(ESP_PLATFORM) + NetworkService network; +#endif +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + WebService web; +#endif + StatsHistory _stats_history; + struct { + bool initialized; + bool wifi_connected; + bool mqtt_connected; + bool web_panel_up; + bool archive_mounted; + bool low_memory; + uint32_t last_low_memory_event_uptime_secs; + } _stats_state; void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); + size_t getNeighbourCount() const; + void updateStatsHistory(unsigned long now_ms); + bool restoreArchiveNeighbours(); + void flushArchiveNeighbours(); + void maybeFlushArchiveNeighbours(unsigned long now_ms); + 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; 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); @@ -182,7 +214,7 @@ protected: public: MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); - void begin(FILESYSTEM* fs); + void begin(FILESYSTEM* fs, ArchiveStorage* archive = nullptr); void sendNodeDiscoverReq(); const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } @@ -224,6 +256,9 @@ public: void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void runWebCommand(const char* command, char* reply, size_t reply_size) override; const char* getWebAdminPassword() const override { return _prefs.password; } + bool isWebStatsEnabled() const override; + bool formatWebStatsSummaryJson(char* reply, size_t reply_size) override; + bool formatWebStatsSeriesJson(const char* series, char* reply, size_t reply_size) override; void loop(); #if defined(WITH_BRIDGE) diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index e37078ce..157aa894 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -2,6 +2,7 @@ #include #include "MyMesh.h" +#include #ifdef DISPLAY_CLASS #include "UITask.h" @@ -10,6 +11,7 @@ StdRNG fast_rng; SimpleMeshTables tables; +ArchiveStorage archive; MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); @@ -33,6 +35,7 @@ void setup() { delay(1000); board.begin(); + archive.begin(); #if defined(MESH_DEBUG) && defined(NRF52_PLATFORM) // give some extra time for serial to settle so @@ -93,7 +96,7 @@ void setup() { sensors.begin(); - the_mesh.begin(fs); + the_mesh.begin(fs, &archive); #ifdef DISPLAY_CLASS ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); diff --git a/platformio.ini b/platformio.ini index def24659..fcad6303 100644 --- a/platformio.ini +++ b/platformio.ini @@ -48,7 +48,6 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 build_src_filter = +<*.cpp> + - + + + + diff --git a/src/MeshCore.h b/src/MeshCore.h index bbde30a9..30bfa23d 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -47,6 +47,9 @@ public: virtual bool supportsBatteryReporting() const { return false; } virtual bool setBatteryReporting(bool enabled) { (void)enabled; return false; } virtual bool isBatteryReportingEnabled() const { return true; } + virtual int getBatteryPercent() { return -1; } + virtual bool isCharging() { return false; } + virtual bool isVbusPresent() { return false; } virtual float getMCUTemperature() { return NAN; } virtual bool setAdcMultiplier(float multiplier) { return false; }; virtual float getAdcMultiplier() const { return 0.0f; } diff --git a/src/helpers/ArchiveStorage.cpp b/src/helpers/ArchiveStorage.cpp new file mode 100644 index 00000000..d46dc5e8 --- /dev/null +++ b/src/helpers/ArchiveStorage.cpp @@ -0,0 +1,243 @@ +#include "ArchiveStorage.h" + +#include + +#ifndef ARCHIVE_DEBUG + #if defined(MQTT_DEBUG) && MQTT_DEBUG + #define ARCHIVE_DEBUG 1 + #else + #define ARCHIVE_DEBUG 0 + #endif +#endif + +#if ARCHIVE_DEBUG + #define ARCHIVE_LOG(fmt, ...) Serial.printf("[ARCHIVE] " fmt "\n", ##__VA_ARGS__) +#else + #define ARCHIVE_LOG(...) do { } while (0) +#endif + +namespace { + +#if defined(ESP32) +const char* cardTypeName(uint8_t type) { + switch (type) { + case CARD_MMC: + return "mmc"; + case CARD_SD: + return "sdsc"; + case CARD_SDHC: + return "sdhc"; + default: + return "unknown"; + } +} +#endif + +} // namespace + +ArchiveStorage::ArchiveStorage() + : _attempted(false), _mounted(false), _mount_failed(false), _supported(false), _card_type(0), _spi_bus(HSPI), + _cs_pin(0xFF), _sck_pin(0xFF), + _miso_pin(0xFF), _mosi_pin(0xFF), _card_size_bytes(0), _total_bytes(0), _used_bytes(0) +#if defined(ESP32) + , _spi(nullptr) +#endif +{ +} + +namespace { + +bool mountArchiveSd(SPIClass* spi, + uint8_t spi_bus, + uint8_t cs_pin, + uint8_t sck_pin, + uint8_t miso_pin, + uint8_t mosi_pin) { + if (spi == nullptr) { + return false; + } +#if defined(P_BOARD_IMU_CS) + pinMode(P_BOARD_IMU_CS, OUTPUT); + digitalWrite(P_BOARD_IMU_CS, HIGH); +#endif + pinMode(cs_pin, OUTPUT); + digitalWrite(cs_pin, HIGH); + spi->begin(sck_pin, miso_pin, mosi_pin, cs_pin); + return SD.begin(cs_pin, *spi, 10000000U); +} + +} // namespace + +void ArchiveStorage::begin() { + if (_attempted) { + return; + } + _attempted = true; + +#if defined(ESP32) + _supported = resolvePins(); + if (!_supported) { + ARCHIVE_LOG("unsupported: no SD pin mapping"); + return; + } + + ARCHIVE_LOG("init bus=%u cs=%u sck=%u miso=%u mosi=%u", + static_cast(_spi_bus), + static_cast(_cs_pin), + static_cast(_sck_pin), + static_cast(_miso_pin), + static_cast(_mosi_pin)); + + _spi = new SPIClass(_spi_bus); + if (_spi == nullptr) { + _mount_failed = true; + ARCHIVE_LOG("mount failed: SPI alloc failed"); + return; + } + + if (!mountArchiveSd(_spi, _spi_bus, _cs_pin, _sck_pin, _miso_pin, _mosi_pin)) { + _mount_failed = true; + ARCHIVE_LOG("mount failed bus=%u cs=%u sck=%u miso=%u mosi=%u", + static_cast(_spi_bus), + static_cast(_cs_pin), + static_cast(_sck_pin), + static_cast(_miso_pin), + static_cast(_mosi_pin)); + return; + } + + _mounted = true; + if (!SD.exists(getFsStatsPath())) { + SD.mkdir(getFsStatsPath()); + } + refreshCardInfo(); + ARCHIVE_LOG("mounted type=%s card=%llu total=%llu used=%llu path=%s", + getCardTypeName(), + static_cast(_card_size_bytes), + static_cast(_total_bytes), + static_cast(_used_bytes), + getLogicalStatsPath()); +#else + _supported = false; +#endif +} + +bool ArchiveStorage::recover() { +#if defined(ESP32) + if (!_supported || _spi == nullptr) { + return false; + } + + ARCHIVE_LOG("recover begin bus=%u cs=%u", static_cast(_spi_bus), static_cast(_cs_pin)); + SD.end(); + _spi->end(); + _mounted = false; + _mount_failed = false; + _card_size_bytes = 0; + _total_bytes = 0; + _used_bytes = 0; + + if (!mountArchiveSd(_spi, _spi_bus, _cs_pin, _sck_pin, _miso_pin, _mosi_pin)) { + _mount_failed = true; + ARCHIVE_LOG("recover failed bus=%u cs=%u", static_cast(_spi_bus), static_cast(_cs_pin)); + return false; + } + + _mounted = true; + if (!SD.exists(getFsStatsPath())) { + SD.mkdir(getFsStatsPath()); + } + refreshCardInfo(); + ARCHIVE_LOG("recover mounted type=%s card=%llu total=%llu used=%llu path=%s", + getCardTypeName(), + static_cast(_card_size_bytes), + static_cast(_total_bytes), + static_cast(_used_bytes), + getLogicalStatsPath()); + return true; +#else + return false; +#endif +} + +bool ArchiveStorage::isSupported() const { + return _supported; +} + +FILESYSTEM* ArchiveStorage::getFS() { +#if defined(ESP32) + return _mounted ? &SD : nullptr; +#else + return nullptr; +#endif +} + +const char* ArchiveStorage::getCardTypeName() const { +#if defined(ESP32) + if (!_mounted) { + return "unavailable"; + } + return cardTypeName(_card_type); +#else + return "unsupported"; +#endif +} + +#if defined(ESP32) +bool ArchiveStorage::resolvePins() { +#if defined(P_BOARD_SPI_SCK) && defined(P_BOARD_SPI_MISO) && defined(P_BOARD_SPI_MOSI) && defined(P_BOARD_SPI_CS) + _sck_pin = static_cast(P_BOARD_SPI_SCK); + _miso_pin = static_cast(P_BOARD_SPI_MISO); + _mosi_pin = static_cast(P_BOARD_SPI_MOSI); + _cs_pin = static_cast(P_BOARD_SPI_CS); +#if defined(TBEAM_SUPREME_SX1262) && defined(FSPI) + _spi_bus = FSPI; +#endif + return true; +#elif defined(TBEAM_SUPREME_SX1262) + // T-Beam S3 Supreme SD/IMU shared SPI bus pins. + _sck_pin = 36; + _miso_pin = 37; + _mosi_pin = 35; + _cs_pin = 47; +#if defined(FSPI) + _spi_bus = FSPI; +#endif + return true; +#elif defined(HAS_SDCARD) && defined(SDCARD_CS) + _cs_pin = static_cast(SDCARD_CS); + #if defined(SPI_SCK) && defined(SPI_MISO) && defined(SPI_MOSI) + _sck_pin = static_cast(SPI_SCK); + _miso_pin = static_cast(SPI_MISO); + _mosi_pin = static_cast(SPI_MOSI); + return true; + #elif defined(P_LORA_SCLK) && defined(P_LORA_MISO) && defined(P_LORA_MOSI) + _sck_pin = static_cast(P_LORA_SCLK); + _miso_pin = static_cast(P_LORA_MISO); + _mosi_pin = static_cast(P_LORA_MOSI); + return true; + #endif +#endif + return false; +} + +void ArchiveStorage::refreshCardInfo() { + if (!_mounted) { + _card_type = 0; + _card_size_bytes = 0; + _total_bytes = 0; + _used_bytes = 0; + return; + } + + if (_card_type == 0) { + _card_type = SD.cardType(); + } + _card_size_bytes = SD.cardSize(); + _total_bytes = SD.totalBytes(); + _used_bytes = SD.usedBytes(); + if (_total_bytes == 0) { + _total_bytes = _card_size_bytes; + } +} +#endif diff --git a/src/helpers/ArchiveStorage.h b/src/helpers/ArchiveStorage.h new file mode 100644 index 00000000..ccf4e7b3 --- /dev/null +++ b/src/helpers/ArchiveStorage.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +#if defined(ESP32) + #include + #include + #include +#endif + +class ArchiveStorage { +public: + ArchiveStorage(); + + void begin(); + bool recover(); + + bool isSupported() const; + bool isAttempted() const { return _attempted; } + bool isMounted() const { return _mounted; } + bool hadMountFailure() const { return _mount_failed; } + + const char* getLogicalName() const { return "archive"; } + const char* getLogicalStatsPath() const { return "archive:/stats"; } + const char* getFsStatsPath() const { return "/stats"; } + + FILESYSTEM* getFS(); + + uint64_t getCardSizeBytes() const { return _card_size_bytes; } + uint64_t getTotalBytes() const { return _total_bytes; } + uint64_t getUsedBytes() const { return _used_bytes; } + const char* getCardTypeName() const; + uint8_t getChipSelectPin() const { return _cs_pin; } + +private: +#if defined(ESP32) + bool resolvePins(); + void refreshCardInfo(); +#endif + + bool _attempted; + bool _mounted; + bool _mount_failed; + bool _supported; + uint8_t _card_type; + uint8_t _spi_bus; + uint8_t _cs_pin; + uint8_t _sck_pin; + uint8_t _miso_pin; + uint8_t _mosi_pin; + uint64_t _card_size_bytes; + uint64_t _total_bytes; + uint64_t _used_bytes; + +#if defined(ESP32) + SPIClass* _spi; +#endif +}; diff --git a/src/helpers/NetworkPrefs.cpp b/src/helpers/NetworkPrefs.cpp new file mode 100644 index 00000000..9e4f0b3a --- /dev/null +++ b/src/helpers/NetworkPrefs.cpp @@ -0,0 +1,126 @@ +#include "NetworkPrefs.h" + +#include +#include + +namespace { + +struct LegacyWebPrefsV1 { + uint32_t magic; + uint8_t web_enabled; + uint8_t web_stats_enabled; + uint8_t wifi_powersave; + uint8_t reserved; + char wifi_ssid[33]; + char wifi_pwd[65]; +}; + +bool loadLegacyWebWifiPrefs(FILESYSTEM* fs, NetworkPrefs& prefs) { + if (fs == nullptr || !fs->exists("/web_prefs")) { + return false; + } + +#if defined(RP2040_PLATFORM) + File file = fs->open("/web_prefs", "r"); +#else + File file = fs->open("/web_prefs"); +#endif + if (!file) { + return false; + } + + if (static_cast(file.size()) < sizeof(LegacyWebPrefsV1)) { + file.close(); + return false; + } + + LegacyWebPrefsV1 legacy{}; + bool ok = file.read(reinterpret_cast(&legacy), sizeof(legacy)) == sizeof(legacy); + file.close(); + if (!ok || legacy.magic != 0x57454250) { + return false; + } + + prefs.wifi_powersave = legacy.wifi_powersave <= 2 ? legacy.wifi_powersave : 0; + StrHelper::strncpy(prefs.wifi_ssid, legacy.wifi_ssid, sizeof(prefs.wifi_ssid)); + StrHelper::strncpy(prefs.wifi_pwd, legacy.wifi_pwd, sizeof(prefs.wifi_pwd)); + return true; +} + +} // namespace + +void NetworkPrefsStore::setDefaults(NetworkPrefs& prefs) { + memset(&prefs, 0, sizeof(prefs)); + prefs.magic = kMagic; + prefs.wifi_powersave = 0; +} + +bool NetworkPrefsStore::load(FILESYSTEM* fs, NetworkPrefs& prefs, + uint8_t legacy_wifi_powersave, + const char* legacy_wifi_ssid, + const char* legacy_wifi_pwd) { + setDefaults(prefs); + if (fs == nullptr) { + return false; + } + + if (!fs->exists(kFilename)) { + prefs.wifi_powersave = legacy_wifi_powersave <= 2 ? legacy_wifi_powersave : 0; + if (legacy_wifi_ssid != nullptr) { + StrHelper::strncpy(prefs.wifi_ssid, legacy_wifi_ssid, sizeof(prefs.wifi_ssid)); + } + if (legacy_wifi_pwd != nullptr) { + StrHelper::strncpy(prefs.wifi_pwd, legacy_wifi_pwd, sizeof(prefs.wifi_pwd)); + } + loadLegacyWebWifiPrefs(fs, prefs); + save(fs, prefs); + return false; + } + +#if defined(RP2040_PLATFORM) + File file = fs->open(kFilename, "r"); +#else + File file = fs->open(kFilename); +#endif + if (!file) { + return false; + } + + NetworkPrefs persisted{}; + size_t bytes_to_read = min(static_cast(file.size()), sizeof(persisted)); + bool ok = bytes_to_read >= sizeof(persisted.magic) && + file.read(reinterpret_cast(&persisted), bytes_to_read) == bytes_to_read; + file.close(); + + if (!ok || persisted.magic != kMagic) { + fs->remove(kFilename); + save(fs, prefs); + return false; + } + + prefs = persisted; + if (prefs.wifi_powersave > 2) { + prefs.wifi_powersave = 0; + } + return true; +} + +bool NetworkPrefsStore::save(FILESYSTEM* fs, const NetworkPrefs& prefs) { + if (fs == nullptr) { + return false; + } + if (fs->exists(kFilename) && !fs->remove(kFilename)) { + return false; + } +#if defined(RP2040_PLATFORM) + File file = fs->open(kFilename, "w"); +#else + File file = fs->open(kFilename, "w", true); +#endif + if (!file) { + return false; + } + bool ok = file.write(reinterpret_cast(&prefs), sizeof(prefs)) == sizeof(prefs); + file.close(); + return ok; +} diff --git a/src/helpers/NetworkPrefs.h b/src/helpers/NetworkPrefs.h new file mode 100644 index 00000000..2e2da522 --- /dev/null +++ b/src/helpers/NetworkPrefs.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +struct NetworkPrefs { + uint32_t magic; + uint8_t wifi_powersave; + uint8_t reserved[3]; + char wifi_ssid[33]; + char wifi_pwd[65]; +}; + +class NetworkPrefsStore { +public: + static void setDefaults(NetworkPrefs& prefs); + static bool load(FILESYSTEM* fs, NetworkPrefs& prefs, + uint8_t legacy_wifi_powersave = 0, + const char* legacy_wifi_ssid = nullptr, + const char* legacy_wifi_pwd = nullptr); + static bool save(FILESYSTEM* fs, const NetworkPrefs& prefs); + +private: + static constexpr uint32_t kMagic = 0x4E455450; + static constexpr const char* kFilename = "/network_prefs"; +}; diff --git a/src/helpers/NetworkService.cpp b/src/helpers/NetworkService.cpp new file mode 100644 index 00000000..a3483c0b --- /dev/null +++ b/src/helpers/NetworkService.cpp @@ -0,0 +1,299 @@ +#include "NetworkService.h" + +#include +#include +#include + +#if defined(ESP_PLATFORM) + #include + #include +#endif + +namespace { + +#if defined(ESP_PLATFORM) +constexpr unsigned long kWifiRetryMillis = 15000; +constexpr unsigned long kWifiConnectTimeoutMillis = 45000; +constexpr time_t kMinSaneEpoch = 1735689600; // 2025-01-01T00:00:00Z + +int getWifiQualityPercent(int rssi_dbm) { + if (rssi_dbm <= -100) { + return 0; + } + if (rssi_dbm >= -50) { + return 100; + } + return 2 * (rssi_dbm + 100); +} + +const char* getWifiQualityLabel(int rssi_dbm) { + if (rssi_dbm >= -60) { + return "excellent"; + } + if (rssi_dbm >= -67) { + return "good"; + } + if (rssi_dbm >= -75) { + return "fair"; + } + return "poor"; +} +#endif + +} // namespace + +NetworkService::NetworkService() + : _fs(nullptr), _prefs{}, _wifi_started(false), _sntp_started(false), _have_time_sync(false), _last_wifi_attempt(0) { + NetworkPrefsStore::setDefaults(_prefs); +} + +void NetworkService::begin(FILESYSTEM* fs, + uint8_t legacy_wifi_powersave, + const char* legacy_wifi_ssid, + const char* legacy_wifi_pwd) { + _fs = fs; + NetworkPrefsStore::load(_fs, _prefs, legacy_wifi_powersave, legacy_wifi_ssid, legacy_wifi_pwd); +} + +void NetworkService::end() { +#if defined(ESP_PLATFORM) + if (_wifi_started) { + WiFi.disconnect(true, true); + WiFi.mode(WIFI_OFF); + } +#endif + _wifi_started = false; + _sntp_started = false; + _have_time_sync = false; + _last_wifi_attempt = 0; +} + +void NetworkService::loop(bool network_required) { +#if defined(ESP_PLATFORM) + ensureWifi(network_required); + updateTimeSync(); +#else + (void)network_required; +#endif +} + +bool NetworkService::savePrefs() { + return NetworkPrefsStore::save(_fs, _prefs); +} + +bool NetworkService::setWifiSSID(const char* ssid) { + if (ssid == nullptr) { + return false; + } + StrHelper::strncpy(_prefs.wifi_ssid, ssid, sizeof(_prefs.wifi_ssid)); + bool ok = savePrefs(); + reconnectWifi(); + return ok; +} + +bool NetworkService::setWifiPassword(const char* pwd) { + if (pwd == nullptr) { + return false; + } + StrHelper::strncpy(_prefs.wifi_pwd, pwd, sizeof(_prefs.wifi_pwd)); + bool ok = savePrefs(); + reconnectWifi(); + return ok; +} + +bool NetworkService::setWifiPowerSave(const char* mode) { + if (mode == nullptr) { + return false; + } + + uint8_t next_mode; + if (strcmp(mode, "none") == 0) { + next_mode = 0; + } else if (strcmp(mode, "min") == 0) { + next_mode = 1; + } else if (strcmp(mode, "max") == 0) { + next_mode = 2; + } else { + return false; + } + + _prefs.wifi_powersave = next_mode; + bool ok = savePrefs(); +#if defined(ESP_PLATFORM) + if (_wifi_started) { + ok = WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)) && ok; + } +#endif + return ok; +} + +const char* NetworkService::getWifiPowerSave() const { +#if defined(ESP_PLATFORM) + return getPowerSaveLabel(_prefs.wifi_powersave); +#else + return "unsupported"; +#endif +} + +void NetworkService::formatWifiStatusReply(char* reply, size_t reply_size) const { +#if defined(ESP_PLATFORM) + const char* status = "disconnected"; + const char* state = "disconnected"; + wl_status_t wifi_status = WiFi.status(); + if (_prefs.wifi_ssid[0] == 0) { + status = "unconfigured"; + state = "unconfigured"; + } else if (wifi_status == WL_CONNECTED) { + status = "connected"; + state = "connected"; + } else if (_wifi_started) { + status = "connecting"; + } + + switch (wifi_status) { + case WL_IDLE_STATUS: + state = "idle"; + break; + case WL_NO_SSID_AVAIL: + state = "no_ssid"; + break; + case WL_SCAN_COMPLETED: + state = "scan_completed"; + break; + case WL_CONNECTED: + state = "connected"; + break; + case WL_CONNECT_FAILED: + state = "connect_failed"; + break; + case WL_CONNECTION_LOST: + state = "connection_lost"; + break; + case WL_DISCONNECTED: + state = "disconnected"; + break; + default: + state = "unknown"; + break; + } + + if (wifi_status == WL_CONNECTED) { + const int rssi_dbm = WiFi.RSSI(); + snprintf(reply, reply_size, + "> ssid:%s status:%s code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s", + _prefs.wifi_ssid, status, static_cast(wifi_status), state, WiFi.localIP().toString().c_str(), + rssi_dbm, getWifiQualityPercent(rssi_dbm), getWifiQualityLabel(rssi_dbm)); + } else { + snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-", + status, static_cast(wifi_status), state); + } +#else + snprintf(reply, reply_size, "> wifi:unsupported"); +#endif +} + +void NetworkService::reconnectWifi() { +#if defined(ESP_PLATFORM) + if (_wifi_started) { + WiFi.disconnect(true, true); + WiFi.mode(WIFI_OFF); + } +#endif + _wifi_started = false; + _sntp_started = false; + _have_time_sync = false; + _last_wifi_attempt = 0; +} + +bool NetworkService::isWifiConnected() const { +#if defined(ESP_PLATFORM) + return _wifi_started && WiFi.status() == WL_CONNECTED; +#else + return false; +#endif +} + +#if defined(ESP_PLATFORM) +wifi_ps_type_t NetworkService::toEspPowerSave(uint8_t mode) { + switch (mode) { + case 1: + return WIFI_PS_MIN_MODEM; + case 2: + return WIFI_PS_MAX_MODEM; + default: + return WIFI_PS_NONE; + } +} + +const char* NetworkService::getPowerSaveLabel(uint8_t mode) { + switch (mode) { + case 1: + return "min"; + case 2: + return "max"; + default: + return "none"; + } +} + +void NetworkService::ensureWifi(bool network_required) { + if (!network_required) { + if (_wifi_started) { + WiFi.disconnect(true, true); + WiFi.mode(WIFI_OFF); + _wifi_started = false; + _sntp_started = false; + _have_time_sync = false; + } + return; + } + + if (_prefs.wifi_ssid[0] == 0) { + reconnectWifi(); + return; + } + + if (WiFi.status() == WL_CONNECTED) { + return; + } + + unsigned long now_ms = millis(); + wl_status_t status = WiFi.status(); + if (_wifi_started) { + if (_last_wifi_attempt != 0 && status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) { + return; + } + if (now_ms - _last_wifi_attempt < kWifiRetryMillis) { + return; + } + } + + if (!_wifi_started) { + WiFi.mode(WIFI_STA); + WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)); + _wifi_started = true; + } + + _last_wifi_attempt = now_ms; + WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd); +} + +void NetworkService::updateTimeSync() { + bool prev_have_time_sync = _have_time_sync; + if (!_wifi_started || WiFi.status() != WL_CONNECTED) { + _have_time_sync = false; + return; + } + + if (!_sntp_started) { + configTzTime("UTC0", "au.pool.ntp.org", "time.google.com", "time.cloudflare.com"); + _sntp_started = true; + } + + sntp_sync_status_t sync_status = sntp_get_sync_status(); + time_t now = time(nullptr); + bool sane_time = now >= kMinSaneEpoch; + bool sync_ready = sync_status == SNTP_SYNC_STATUS_COMPLETED || sync_status == SNTP_SYNC_STATUS_IN_PROGRESS; + _have_time_sync = sane_time && (sync_ready || prev_have_time_sync); +} +#endif diff --git a/src/helpers/NetworkService.h b/src/helpers/NetworkService.h new file mode 100644 index 00000000..350bd20e --- /dev/null +++ b/src/helpers/NetworkService.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +#if defined(ESP_PLATFORM) + #include +#endif + +#include "NetworkPrefs.h" + +class NetworkService : public NetworkStateProvider { +public: + NetworkService(); + + void begin(FILESYSTEM* fs, + uint8_t legacy_wifi_powersave = 0, + const char* legacy_wifi_ssid = nullptr, + const char* legacy_wifi_pwd = nullptr); + void end(); + void loop(bool network_required); + + bool setWifiSSID(const char* ssid); + bool setWifiPassword(const char* pwd); + const char* getWifiSSID() const { return _prefs.wifi_ssid; } + bool setWifiPowerSave(const char* mode); + const char* getWifiPowerSave() const; + void formatWifiStatusReply(char* reply, size_t reply_size) const; + void reconnectWifi(); + + bool isWifiConnected() const override; + bool hasTimeSync() const override { return _have_time_sync; } + +private: +#if defined(ESP_PLATFORM) + static wifi_ps_type_t toEspPowerSave(uint8_t mode); + static const char* getPowerSaveLabel(uint8_t mode); + void ensureWifi(bool network_required); + void updateTimeSync(); +#endif + bool savePrefs(); + + FILESYSTEM* _fs; + NetworkPrefs _prefs; + bool _wifi_started; + bool _sntp_started; + bool _have_time_sync; + unsigned long _last_wifi_attempt; +}; diff --git a/src/helpers/NetworkStateProvider.h b/src/helpers/NetworkStateProvider.h new file mode 100644 index 00000000..695a79c8 --- /dev/null +++ b/src/helpers/NetworkStateProvider.h @@ -0,0 +1,8 @@ +#pragma once + +class NetworkStateProvider { +public: + virtual ~NetworkStateProvider() = default; + virtual bool isWifiConnected() const = 0; + virtual bool hasTimeSync() const = 0; +}; diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp new file mode 100644 index 00000000..35d872d0 --- /dev/null +++ b/src/helpers/StatsHistory.cpp @@ -0,0 +1,889 @@ +#include "StatsHistory.h" + +#include +#include + +#if defined(ESP32) + #include +#endif + +#ifndef ARCHIVE_DEBUG + #if defined(MQTT_DEBUG) && MQTT_DEBUG + #define ARCHIVE_DEBUG 1 + #else + #define ARCHIVE_DEBUG 0 + #endif +#endif + +#if ARCHIVE_DEBUG + #define ARCHIVE_LOG(fmt, ...) Serial.printf("[ARCHIVE] " fmt "\n", ##__VA_ARGS__) +#else + #define ARCHIVE_LOG(...) do { } while (0) +#endif + +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 kEventsRestoreWindowBytes = 4096; + +struct HistoryCapacityBucket { + size_t sample_capacity; + size_t event_capacity; +}; + +HistoryCapacityBucket getHistoryCapacityBucket(bool want_psram) { +#if defined(ESP32) + if (!want_psram) { + return {96, 32}; + } + + const uint32_t psram_size = ESP.getPsramSize(); + if (psram_size >= (8UL * 1024UL * 1024UL)) { + return {720, 288}; + } + if (psram_size >= (4UL * 1024UL * 1024UL)) { + return {480, 192}; + } + return {240, 96}; +#else + (void)want_psram; + return {96, 32}; +#endif +} + +template +T* allocHistoryBuffer(size_t count) { +#if defined(ESP32) + if (psramFound()) { + void* ptr = heap_caps_calloc(count, sizeof(T), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr != nullptr) { + return static_cast(ptr); + } + } + return static_cast(calloc(count, sizeof(T))); +#else + return static_cast(calloc(count, sizeof(T))); +#endif +} + +void freeHistoryBuffer(void* ptr) { +#if defined(ESP32) + if (ptr != nullptr) { + free(ptr); + } +#else + free(ptr); +#endif +} + +File openArchiveRead(FILESYSTEM* fs, const char* filename) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(RP2040_PLATFORM) + return fs->open(filename, "r"); +#else + return fs->open(filename, FILE_READ); +#endif +} + +File openArchiveAppend(FILESYSTEM* fs, const char* filename) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(RP2040_PLATFORM) + return fs->open(filename, "a"); +#else + return fs->open(filename, FILE_APPEND, true); +#endif +} + +File openArchiveWrite(FILESYSTEM* fs, const char* filename) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + if (fs->exists(filename)) { + fs->remove(filename); + } + return fs->open(filename, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + return fs->open(filename, "w"); +#else + if (fs->exists(filename)) { + fs->remove(filename); + } + return fs->open(filename, FILE_WRITE); +#endif +} + +File openArchiveReadWithRecovery(ArchiveStorage* archive, const char* filename) { + if (archive == nullptr) { + return File(); + } + FILESYSTEM* fs = archive->getFS(); + if (fs == nullptr) { + return File(); + } + File file = openArchiveRead(fs, filename); + if (file) { + return file; + } + if (!archive->recover()) { + return File(); + } + fs = archive->getFS(); + return fs != nullptr ? openArchiveRead(fs, filename) : File(); +} + +File openArchiveAppendWithRecovery(ArchiveStorage* archive, const char* filename) { + if (archive == nullptr) { + return File(); + } + FILESYSTEM* fs = archive->getFS(); + if (fs == nullptr) { + return File(); + } + File file = openArchiveAppend(fs, filename); + if (file) { + return file; + } + if (!archive->recover()) { + return File(); + } + fs = archive->getFS(); + return fs != nullptr ? openArchiveAppend(fs, filename) : File(); +} + +File openArchiveWriteWithRecovery(ArchiveStorage* archive, const char* filename) { + if (archive == nullptr) { + return File(); + } + FILESYSTEM* fs = archive->getFS(); + if (fs == nullptr) { + return File(); + } + File file = openArchiveWrite(fs, filename); + if (file) { + return file; + } + if (!archive->recover()) { + return File(); + } + fs = archive->getFS(); + return fs != nullptr ? openArchiveWrite(fs, filename) : File(); +} + +int buildPointValue(const HistorySample& sample, const HistorySample* previous, const char* series) { + if (strcmp(series, "battery") == 0) { + return static_cast(sample.battery_mv); + } + if (strcmp(series, "memory") == 0) { + return static_cast(sample.heap_free); + } + if (strcmp(series, "signal") == 0) { + return static_cast(sample.last_rssi_x4); + } + if (strcmp(series, "packets") == 0) { + if (previous == nullptr) { + return 0; + } + 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); + } + return 0; +} + +const char* seriesTitle(const char* series) { + if (strcmp(series, "battery") == 0) { + return "Battery"; + } + if (strcmp(series, "memory") == 0) { + return "Heap Free"; + } + if (strcmp(series, "packets") == 0) { + return "Packet Activity"; + } + if (strcmp(series, "signal") == 0) { + return "Signal"; + } + return ""; +} + +const char* seriesUnit(const char* series) { + if (strcmp(series, "battery") == 0) { + return "mV"; + } + if (strcmp(series, "memory") == 0) { + return "bytes"; + } + if (strcmp(series, "packets") == 0) { + return "pkts"; + } + if (strcmp(series, "signal") == 0) { + return "rssi_x4"; + } + return ""; +} + +uint32_t sampleAgeSecs(const HistorySample& sample, uint32_t now_epoch_secs, uint32_t now_uptime_secs) { + if (sample.epoch_secs > 0 && now_epoch_secs >= sample.epoch_secs) { + return now_epoch_secs - sample.epoch_secs; + } + if (now_uptime_secs >= sample.uptime_secs) { + return now_uptime_secs - sample.uptime_secs; + } + return sample.uptime_secs; +} + +uint8_t eventTypeFromName(const char* name) { + if (name == nullptr || name[0] == 0) { + return 0; + } + if (strcmp(name, "boot") == 0) return HISTORY_EVENT_BOOT; + if (strcmp(name, "web_started") == 0) return HISTORY_EVENT_WEB_STARTED; + if (strcmp(name, "web_stopped") == 0) return HISTORY_EVENT_WEB_STOPPED; + if (strcmp(name, "mqtt_connected") == 0) return HISTORY_EVENT_MQTT_CONNECTED; + if (strcmp(name, "mqtt_disconnected") == 0) return HISTORY_EVENT_MQTT_DISCONNECTED; + if (strcmp(name, "archive_mounted") == 0) return HISTORY_EVENT_ARCHIVE_MOUNTED; + if (strcmp(name, "archive_unavailable") == 0) return HISTORY_EVENT_ARCHIVE_UNAVAILABLE; + if (strcmp(name, "low_memory") == 0) return HISTORY_EVENT_LOW_MEMORY; + if (strcmp(name, "stats_enabled") == 0) return HISTORY_EVENT_STATS_ENABLED; + if (strcmp(name, "stats_disabled") == 0) return HISTORY_EVENT_STATS_DISABLED; + return 0; +} + +} // namespace + +StatsHistory::StatsHistory() + : _enabled(false), _psram_backed(false), _degraded(false), _summary_dirty(false), _next_summary_flush_ms(0), + _next_event_flush_ms(0), _sample_capacity(0), _sample_head(0), _sample_count(0), _event_capacity(0), + _event_head(0), _event_count(0), _samples(nullptr), _events(nullptr), _archive(nullptr), _restored_sample_count(0), + _pending_event_count(0) { +} + +StatsHistory::~StatsHistory() { + freeHistoryBuffer(_samples); + freeHistoryBuffer(_events); +} + +void StatsHistory::begin(bool enabled, ArchiveStorage* archive) { + _archive = archive; + _enabled = enabled; + ensureBuffers(); + if (_enabled && _sample_count == 0 && isArchiveAvailable()) { + restoreSummaryLog(); + } + _next_summary_flush_ms = millis() + kSummaryFlushIntervalMs; + _next_event_flush_ms = millis() + kEventFlushIntervalMs; +} + +void StatsHistory::setArchive(ArchiveStorage* archive) { + _archive = archive; +} + +void StatsHistory::setEnabled(bool enabled) { + if (enabled && !ensureBuffers()) { + _enabled = false; + return; + } + _enabled = enabled; + if (_enabled && _sample_count == 0 && isArchiveAvailable()) { + restoreSummaryLog(); + } +} + +bool StatsHistory::isArchiveAvailable() const { + return _archive != nullptr && _archive->isMounted(); +} + +bool StatsHistory::ensureBuffers() { + if (_samples != nullptr && _events != nullptr) { + return true; + } + +#if defined(ESP32) + const bool want_psram = psramFound(); +#else + const bool want_psram = false; +#endif + + const HistoryCapacityBucket bucket = getHistoryCapacityBucket(want_psram); + _sample_capacity = bucket.sample_capacity; + _event_capacity = bucket.event_capacity; + _samples = allocHistoryBuffer(_sample_capacity); + _events = allocHistoryBuffer(_event_capacity); + _psram_backed = want_psram; + _degraded = !want_psram; + + if (_samples == nullptr || _events == nullptr) { + freeHistoryBuffer(_samples); + freeHistoryBuffer(_events); + _samples = allocHistoryBuffer(64); + _events = allocHistoryBuffer(24); + _sample_capacity = (_samples != nullptr) ? 64 : 0; + _event_capacity = (_events != nullptr) ? 24 : 0; + _psram_backed = false; + _degraded = true; + } + + return _samples != nullptr && _events != nullptr; +} + +void StatsHistory::storeSample(const HistorySample& sample, bool mark_dirty) { + if (!ensureBuffers()) { + return; + } + + _samples[_sample_head] = sample; + _sample_head = (_sample_head + 1) % _sample_capacity; + if (_sample_count < _sample_capacity) { + _sample_count++; + } + if (mark_dirty) { + _summary_dirty = true; + } +} + +bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) const { + if (line == nullptr || line[0] == 0) { + return false; + } + + unsigned long epoch_secs = 0; + unsigned long uptime_secs = 0; + unsigned battery_mv = 0; + unsigned queue_len = 0; + int last_rssi_x4 = 0; + int last_snr_x4 = 0; + unsigned long packets_sent = 0; + unsigned long packets_recv = 0; + unsigned long heap_free = 0; + unsigned long psram_free = 0; + unsigned error_flags = 0; + unsigned recv_errors = 0; + unsigned neighbour_count = 0; + unsigned direct_dups = 0; + unsigned flood_dups = 0; + unsigned flags = 0; + + const int parsed = sscanf(line, + "%lu,%lu,%u,%u,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u", + &epoch_secs, + &uptime_secs, + &battery_mv, + &queue_len, + &last_rssi_x4, + &last_snr_x4, + &packets_sent, + &packets_recv, + &heap_free, + &psram_free, + &error_flags, + &recv_errors, + &neighbour_count, + &direct_dups, + &flood_dups, + &flags); + if (parsed != 16) { + return false; + } + + 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.flags = static_cast(flags); + sample.battery_pct = -1; + return true; +} + +bool StatsHistory::restoreSummaryLog() { + if (!isArchiveAvailable() || _sample_capacity == 0) { + return false; + } + + FILESYSTEM* fs = _archive->getFS(); + if (fs == nullptr || !fs->exists("/stats/summary.log")) { + return false; + } + + File file = openArchiveReadWithRecovery(_archive, "/stats/summary.log"); + if (!file) { + ARCHIVE_LOG("summary restore open failed path=%s", "/stats/summary.log"); + return false; + } + + _restored_sample_count = 0; + const size_t size = static_cast(file.size()); + const size_t start = (size > kSummaryRestoreWindowBytes) ? (size - kSummaryRestoreWindowBytes) : 0; + if (start > 0 && !file.seek(start)) { + file.close(); + return false; + } + + char line[192]; + size_t line_len = 0; + bool skip_partial = (start > 0); + while (file.available()) { + const int raw = file.read(); + if (raw < 0) { + break; + } + const char ch = static_cast(raw); + if (skip_partial) { + if (ch == '\n') { + skip_partial = false; + } + continue; + } + if (ch == '\r') { + continue; + } + if (ch == '\n') { + line[line_len] = 0; + HistorySample sample{}; + if (parseSummaryLine(line, sample)) { + storeSample(sample, false); + _restored_sample_count++; + } + line_len = 0; + continue; + } + if (line_len + 1 < sizeof(line)) { + line[line_len++] = ch; + } + } + + if (!skip_partial && line_len > 0) { + line[line_len] = 0; + HistorySample sample{}; + if (parseSummaryLine(line, sample)) { + storeSample(sample, false); + _restored_sample_count++; + } + } + + file.close(); + return _restored_sample_count > 0; +} + +bool StatsHistory::parseEventLine(const char* line, HistoryEvent& event) const { + if (line == nullptr || line[0] == 0) { + return false; + } + + unsigned long epoch_secs = 0; + unsigned long uptime_secs = 0; + char type_name[32]; + int value = 0; + memset(type_name, 0, sizeof(type_name)); + + const int parsed = sscanf(line, "%lu,%lu,%31[^,],%d", &epoch_secs, &uptime_secs, type_name, &value); + if (parsed != 4) { + return false; + } + + const uint8_t type = eventTypeFromName(type_name); + if (type == 0) { + return false; + } + + memset(&event, 0, sizeof(event)); + event.type = type; + event.epoch_secs = static_cast(epoch_secs); + event.uptime_secs = static_cast(uptime_secs); + event.value = static_cast(value); + return true; +} + +bool StatsHistory::restoreEventsLog() { + if (!isArchiveAvailable() || _event_capacity == 0) { + return false; + } + + FILESYSTEM* fs = _archive->getFS(); + if (fs == nullptr || !fs->exists("/stats/events.log")) { + return false; + } + + File file = openArchiveRead(fs, "/stats/events.log"); + if (!file) { + return false; + } + + const size_t size = static_cast(file.size()); + const size_t start = (size > kEventsRestoreWindowBytes) ? (size - kEventsRestoreWindowBytes) : 0; + if (start > 0 && !file.seek(start)) { + file.close(); + return false; + } + + char line[160]; + size_t line_len = 0; + bool skip_partial = (start > 0); + while (file.available()) { + const int raw = file.read(); + if (raw < 0) { + break; + } + const char ch = static_cast(raw); + if (skip_partial) { + if (ch == '\n') { + skip_partial = false; + } + continue; + } + if (ch == '\r') { + continue; + } + if (ch == '\n') { + line[line_len] = 0; + HistoryEvent event{}; + if (parseEventLine(line, event)) { + storeEvent(event, false); + } + line_len = 0; + continue; + } + if (line_len + 1 < sizeof(line)) { + line[line_len++] = ch; + } + } + + if (!skip_partial && line_len > 0) { + line[line_len] = 0; + HistoryEvent event{}; + if (parseEventLine(line, event)) { + storeEvent(event, false); + } + } + + file.close(); + return _event_count > 0; +} + +void StatsHistory::pushSample(const HistorySample& sample) { + if (!_enabled || !ensureBuffers()) { + return; + } + storeSample(sample, true); +} + +void StatsHistory::appendPendingEvent(const HistoryEvent& event) { + if (_pending_event_count < (sizeof(_pending_events) / sizeof(_pending_events[0]))) { + _pending_events[_pending_event_count++] = event; + return; + } + + memmove(&_pending_events[0], &_pending_events[1], sizeof(_pending_events) - sizeof(_pending_events[0])); + _pending_events[(sizeof(_pending_events) / sizeof(_pending_events[0])) - 1] = event; +} + +void StatsHistory::storeEvent(const HistoryEvent& event, bool queue_pending) { + if (!ensureBuffers()) { + return; + } + + _events[_event_head] = event; + _event_head = (_event_head + 1) % _event_capacity; + if (_event_count < _event_capacity) { + _event_count++; + } + + if (queue_pending) { + appendPendingEvent(event); + } +} + +void StatsHistory::recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptime_secs, int16_t value) { + if (!ensureBuffers()) { + return; + } + + HistoryEvent event{}; + event.type = type; + event.epoch_secs = epoch_secs; + event.uptime_secs = uptime_secs; + event.value = value; + storeEvent(event, true); +} + +void StatsHistory::maybeFlush(uint32_t now_ms) { + if (!_enabled || !isArchiveAvailable()) { + return; + } + + if (_summary_dirty && (now_ms >= _next_summary_flush_ms || _pending_event_count >= 8)) { + flushSummaryLog(); + _summary_dirty = false; + _next_summary_flush_ms = now_ms + kSummaryFlushIntervalMs; + } + + if (_pending_event_count > 0 && now_ms >= _next_event_flush_ms) { + flushEventsLog(); + _next_event_flush_ms = now_ms + kEventFlushIntervalMs; + } +} + +void StatsHistory::flushSummaryLog() { + if (_archive == nullptr || _sample_count == 0) { + return; + } + + FILESYSTEM* fs = _archive->getFS(); + if (fs == nullptr) { + return; + } + + HistorySample latest{}; + if (!getSampleFromOldest(_sample_count - 1, latest)) { + return; + } + + File file = openArchiveAppendWithRecovery(_archive, "/stats/summary.log"); + if (!file) { + ARCHIVE_LOG("summary open failed path=%s", "/stats/summary.log"); + return; + } + + char line[256]; + snprintf(line, sizeof(line), + "%lu,%lu,%u,%u,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n", + static_cast(latest.epoch_secs), + static_cast(latest.uptime_secs), + static_cast(latest.battery_mv), + static_cast(latest.queue_len), + static_cast(latest.last_rssi_x4), + static_cast(latest.last_snr_x4), + static_cast(latest.packets_sent), + static_cast(latest.packets_recv), + static_cast(latest.heap_free), + static_cast(latest.psram_free), + static_cast(latest.error_flags), + static_cast(latest.recv_errors), + static_cast(latest.neighbour_count), + static_cast(latest.direct_dups), + static_cast(latest.flood_dups), + static_cast(latest.flags)); + const size_t written = file.print(line); + file.flush(); + file.close(); + ARCHIVE_LOG("summary flushed path=%s bytes=%u sample_count=%u", + "/stats/summary.log", + static_cast(written), + static_cast(_sample_count)); + writeMetaFile(); +} + +void StatsHistory::flushEventsLog() { + if (_archive == nullptr || _pending_event_count == 0) { + return; + } + + FILESYSTEM* fs = _archive->getFS(); + if (fs == nullptr) { + return; + } + + File file = openArchiveAppendWithRecovery(_archive, "/stats/events.log"); + if (!file) { + ARCHIVE_LOG("events open failed path=%s", "/stats/events.log"); + return; + } + + size_t total_written = 0; + for (size_t i = 0; i < _pending_event_count; ++i) { + const HistoryEvent& event = _pending_events[i]; + char line[160]; + snprintf(line, sizeof(line), + "%lu,%lu,%s,%d\n", + static_cast(event.epoch_secs), + static_cast(event.uptime_secs), + getEventTypeName(event.type), + static_cast(event.value)); + total_written += file.print(line); + } + file.flush(); + file.close(); + ARCHIVE_LOG("events flushed path=%s bytes=%u count=%u", + "/stats/events.log", + static_cast(total_written), + static_cast(_pending_event_count)); + _pending_event_count = 0; + writeMetaFile(); +} + +void StatsHistory::writeMetaFile() const { + if (_archive == nullptr) { + return; + } + FILESYSTEM* fs = _archive->getFS(); + if (fs == nullptr) { + return; + } + + File file = openArchiveWriteWithRecovery(_archive, "/stats/meta.json"); + if (!file) { + ARCHIVE_LOG("meta open failed path=%s", "/stats/meta.json"); + return; + } + + char json[256]; + snprintf(json, sizeof(json), + "{\"logical\":\"%s\",\"path\":\"%s\",\"samples\":{\"count\":%u,\"capacity\":%u}," + "\"events\":{\"count\":%u,\"capacity\":%u},\"psram\":%s,\"degraded\":%s}\n", + _archive->getLogicalName(), + _archive->getLogicalStatsPath(), + static_cast(_sample_count), + static_cast(_sample_capacity), + static_cast(_event_count), + static_cast(_event_capacity), + _psram_backed ? "true" : "false", + _degraded ? "true" : "false"); + const size_t written = file.print(json); + file.flush(); + file.close(); + ARCHIVE_LOG("meta flushed path=%s bytes=%u", + "/stats/meta.json", + static_cast(written)); +} + +bool StatsHistory::getSampleFromOldest(size_t index, HistorySample& sample) const { + if (_samples == nullptr || index >= _sample_count) { + return false; + } + const size_t oldest = (_sample_head + _sample_capacity - _sample_count) % _sample_capacity; + const size_t slot = (oldest + index) % _sample_capacity; + sample = _samples[slot]; + return true; +} + +bool StatsHistory::buildSeriesJson(const char* series, char* buffer, size_t buffer_size, uint32_t now_epoch_secs, uint32_t now_uptime_secs) const { + if (buffer == nullptr || buffer_size == 0 || series == nullptr) { + return false; + } + buffer[0] = 0; + if (seriesTitle(series)[0] == 0) { + return false; + } + + 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, + seriesTitle(series), + seriesUnit(series), + static_cast(kSampleIntervalSecs)); + return true; + } + + const size_t points = min(_sample_count, kMaxSeriesPoints); + const size_t step = (_sample_count > kMaxSeriesPoints) ? ((_sample_count + kMaxSeriesPoints - 1) / kMaxSeriesPoints) : 1; + + size_t offset = 0; + HistorySample sample{}; + 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)); + + getSampleFromOldest(_sample_count - 1, sample); + if (strcmp(series, "packets") == 0 && _sample_count >= 2) { + getSampleFromOldest(_sample_count - 2, previous); + current_value = buildPointValue(sample, &previous, series); + } else { + current_value = buildPointValue(sample, nullptr, series); + } + + 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)); + + 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); + previous = sample; + have_previous = true; + if (offset + 24 >= buffer_size) { + break; + } + } + + snprintf(&buffer[offset], buffer_size - offset, "]}"); + return true; +} + +bool StatsHistory::getRecentEvent(size_t reverse_index, HistoryEvent& event) const { + if (_events == nullptr || reverse_index >= _event_count) { + return false; + } + + const size_t newest = (_event_head + _event_capacity - 1) % _event_capacity; + const size_t slot = (newest + _event_capacity - reverse_index) % _event_capacity; + event = _events[slot]; + return true; +} + +const char* StatsHistory::getEventTypeName(uint8_t type) { + switch (type) { + case HISTORY_EVENT_BOOT: + return "boot"; + case HISTORY_EVENT_WEB_STARTED: + return "web_started"; + case HISTORY_EVENT_WEB_STOPPED: + return "web_stopped"; + case HISTORY_EVENT_MQTT_CONNECTED: + return "mqtt_connected"; + case HISTORY_EVENT_MQTT_DISCONNECTED: + return "mqtt_disconnected"; + case HISTORY_EVENT_ARCHIVE_MOUNTED: + return "archive_mounted"; + case HISTORY_EVENT_ARCHIVE_UNAVAILABLE: + return "archive_unavailable"; + case HISTORY_EVENT_LOW_MEMORY: + return "low_memory"; + case HISTORY_EVENT_STATS_ENABLED: + return "stats_enabled"; + case HISTORY_EVENT_STATS_DISABLED: + return "stats_disabled"; + default: + return "unknown"; + } +} diff --git a/src/helpers/StatsHistory.h b/src/helpers/StatsHistory.h new file mode 100644 index 00000000..e0172197 --- /dev/null +++ b/src/helpers/StatsHistory.h @@ -0,0 +1,132 @@ +#pragma once + +#include +#include + +#include "ArchiveStorage.h" + +struct HistorySample { + uint32_t epoch_secs; + uint32_t uptime_secs; + uint32_t packets_sent; + uint32_t packets_recv; + uint32_t heap_free; + uint32_t heap_min; + uint32_t psram_free; + uint32_t psram_min; + uint16_t battery_mv; + uint16_t queue_len; + uint16_t error_flags; + uint16_t recv_errors; + uint16_t neighbour_count; + uint16_t direct_dups; + uint16_t flood_dups; + int16_t last_rssi_x4; + int16_t last_snr_x4; + int16_t noise_floor; + uint8_t flags; + int8_t battery_pct; + uint16_t reserved; +}; + +struct HistoryEvent { + uint32_t epoch_secs; + uint32_t uptime_secs; + uint8_t type; + int16_t value; + uint8_t reserved; +}; + +enum HistorySampleFlags : uint8_t { + HISTORY_FLAG_EXTERNAL_POWER = 1 << 0, + HISTORY_FLAG_CHARGING = 1 << 1, + HISTORY_FLAG_VBUS = 1 << 2, + HISTORY_FLAG_WIFI_CONNECTED = 1 << 3, + HISTORY_FLAG_MQTT_CONNECTED = 1 << 4, + HISTORY_FLAG_WEB_ENABLED = 1 << 5, + HISTORY_FLAG_WEB_PANEL_UP = 1 << 6, + HISTORY_FLAG_ARCHIVE_MOUNTED = 1 << 7, +}; + +enum HistoryEventType : uint8_t { + HISTORY_EVENT_BOOT = 1, + HISTORY_EVENT_WEB_STARTED = 2, + HISTORY_EVENT_WEB_STOPPED = 3, + HISTORY_EVENT_MQTT_CONNECTED = 4, + HISTORY_EVENT_MQTT_DISCONNECTED = 5, + HISTORY_EVENT_ARCHIVE_MOUNTED = 6, + HISTORY_EVENT_ARCHIVE_UNAVAILABLE = 7, + HISTORY_EVENT_LOW_MEMORY = 8, + HISTORY_EVENT_STATS_ENABLED = 9, + HISTORY_EVENT_STATS_DISABLED = 10, +}; + +class StatsHistory { +public: + static constexpr uint32_t kSampleIntervalSecs = 60; + static constexpr uint32_t kArchiveSummaryIntervalSecs = 300; + + StatsHistory(); + ~StatsHistory(); + + void begin(bool enabled, ArchiveStorage* archive); + void setArchive(ArchiveStorage* archive); + void setEnabled(bool enabled); + + bool isEnabled() const { return _enabled; } + bool isRecentHistoryAvailable() const { return _samples != nullptr; } + bool isPsramBacked() const { return _psram_backed; } + bool isDegraded() const { return _degraded; } + bool isArchiveAvailable() const; + bool hasArchiveRestore() const { return _restored_sample_count > 0; } + + size_t getSampleCapacity() const { return _sample_capacity; } + size_t getSampleCount() const { return _sample_count; } + size_t getEventCapacity() const { return _event_capacity; } + size_t getEventCount() const { return _event_count; } + size_t getRestoredSampleCount() const { return _restored_sample_count; } + + void pushSample(const HistorySample& sample); + void recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptime_secs, int16_t value = 0); + void maybeFlush(uint32_t now_ms); + + bool buildSeriesJson(const char* series, char* buffer, size_t buffer_size, uint32_t now_epoch_secs, uint32_t now_uptime_secs) const; + bool getRecentEvent(size_t reverse_index, HistoryEvent& event) const; + + static const char* getEventTypeName(uint8_t type); + static constexpr uint32_t getSampleIntervalSecs() { return kSampleIntervalSecs; } + static constexpr uint32_t getArchiveSummaryIntervalSecs() { return kArchiveSummaryIntervalSecs; } + +private: + bool ensureBuffers(); + bool restoreSummaryLog(); + bool restoreEventsLog(); + bool parseSummaryLine(const char* line, HistorySample& sample) const; + bool parseEventLine(const char* line, HistoryEvent& event) const; + void storeSample(const HistorySample& sample, bool mark_dirty); + void storeEvent(const HistoryEvent& event, bool queue_pending); + void appendPendingEvent(const HistoryEvent& event); + void flushSummaryLog(); + void flushEventsLog(); + void writeMetaFile() const; + bool getSampleFromOldest(size_t index, HistorySample& sample) const; + + bool _enabled; + bool _psram_backed; + bool _degraded; + bool _summary_dirty; + uint32_t _next_summary_flush_ms; + uint32_t _next_event_flush_ms; + size_t _sample_capacity; + size_t _sample_head; + size_t _sample_count; + size_t _event_capacity; + size_t _event_head; + size_t _event_count; + HistorySample* _samples; + HistoryEvent* _events; + ArchiveStorage* _archive; + size_t _restored_sample_count; + HistoryEvent _pending_events[16]; + size_t _pending_event_count; +}; diff --git a/src/helpers/esp32/TBeamBoard.h b/src/helpers/esp32/TBeamBoard.h index 4ff95551..ecc190ef 100644 --- a/src/helpers/esp32/TBeamBoard.h +++ b/src/helpers/esp32/TBeamBoard.h @@ -26,7 +26,7 @@ #define P_BOARD_SPI_MOSI 35 //SPI for SD Card and QMI8653 (IMU) #define P_BOARD_SPI_MISO 37 //SPI for SD Card and QMI8653 (IMU) #define P_BOARD_SPI_SCK 36 //SPI for SD Card and QMI8653 (IMU) - #define P_BPARD_SPI_CS 47 //Pin for SD Card CS + #define P_BOARD_SPI_CS 47 //Pin for SD Card CS #define P_BOARD_IMU_CS 34 //Pin for QMI8653 (IMU) CS #define P_BOARD_IMU_INT 33 //IMU Int pin @@ -154,8 +154,24 @@ public: esp_deep_sleep_start(); // CPU halts here and never returns! } - uint16_t getBattMilliVolts(){ - return PMU->getBattVoltage(); + uint16_t getBattMilliVolts() override { + return PMU != NULL ? PMU->getBattVoltage() : 0; + } + + int getBatteryPercent() override { + return (PMU != NULL && PMU->isBatteryConnect()) ? PMU->getBatteryPercent() : -1; + } + + bool isCharging() override { + return PMU != NULL && PMU->isCharging(); + } + + bool isVbusPresent() override { + return PMU != NULL && PMU->isVbusIn(); + } + + bool isExternalPowered() override { + return isVbusPresent(); } const char* getManufacturerName() const{ diff --git a/src/helpers/mqtt/MQTTPrefs.cpp b/src/helpers/mqtt/MQTTPrefs.cpp index 8076a137..5097b4ff 100644 --- a/src/helpers/mqtt/MQTTPrefs.cpp +++ b/src/helpers/mqtt/MQTTPrefs.cpp @@ -2,6 +2,7 @@ #ifdef WITH_MQTT_UPLINK +#include #include #include @@ -17,15 +18,16 @@ void MQTTPrefsStore::setDefaults(MQTTPrefs& prefs) { prefs.raw_enabled = 0; prefs.status_enabled = 1; prefs.tx_enabled = 0; - prefs.web_enabled = 1; - prefs.wifi_powersave = 0; + prefs.deprecated_web_enabled = 0; + prefs.deprecated_web_stats_enabled = 0; + prefs.legacy_wifi_powersave = 0; prefs.status_interval_ms = kFixedStatusIntervalMs; StrHelper::strncpy(prefs.iata, MQTT_DEFAULT_IATA, sizeof(prefs.iata)); #ifdef WIFI_SSID - StrHelper::strncpy(prefs.wifi_ssid, WIFI_SSID, sizeof(prefs.wifi_ssid)); + StrHelper::strncpy(prefs.legacy_wifi_ssid, WIFI_SSID, sizeof(prefs.legacy_wifi_ssid)); #endif #ifdef WIFI_PWD - StrHelper::strncpy(prefs.wifi_pwd, WIFI_PWD, sizeof(prefs.wifi_pwd)); + StrHelper::strncpy(prefs.legacy_wifi_pwd, WIFI_PWD, sizeof(prefs.legacy_wifi_pwd)); #endif } @@ -58,10 +60,9 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) { return false; } prefs = persisted; - if (prefs.wifi_powersave > 2) { - prefs.wifi_powersave = 0; + if (prefs.legacy_wifi_powersave > 2) { + prefs.legacy_wifi_powersave = 0; } - prefs.web_enabled = prefs.web_enabled ? 1 : 0; prefs.status_interval_ms = kFixedStatusIntervalMs; prefs.enabled_mask &= 0x07; return true; diff --git a/src/helpers/mqtt/MQTTPrefs.h b/src/helpers/mqtt/MQTTPrefs.h index a8308733..057135d8 100644 --- a/src/helpers/mqtt/MQTTPrefs.h +++ b/src/helpers/mqtt/MQTTPrefs.h @@ -16,14 +16,16 @@ struct MQTTPrefs { uint8_t raw_enabled; uint8_t status_enabled; uint8_t tx_enabled; - uint8_t web_enabled; - uint8_t wifi_powersave; + uint8_t deprecated_web_enabled; + uint8_t legacy_wifi_powersave; uint32_t status_interval_ms; char iata[8]; - char wifi_ssid[33]; - char wifi_pwd[65]; + char legacy_wifi_ssid[33]; + char legacy_wifi_pwd[65]; char owner_public_key[65]; char owner_email[96]; + uint8_t deprecated_web_stats_enabled; + uint8_t reserved[3]; }; class MQTTPrefsStore { diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index e9a47581..bf0e3abc 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -35,21 +34,13 @@ #if MQTT_DEBUG #define LOG_CAT(tag, fmt, ...) Serial.printf("[" tag "] " fmt "\n", ##__VA_ARGS__) - #define WIFI_LOG(fmt, ...) LOG_CAT("WIFI", fmt, ##__VA_ARGS__) - #define WEB_LOG(fmt, ...) LOG_CAT("WEB", fmt, ##__VA_ARGS__) - #define TIME_LOG(fmt, ...) LOG_CAT("TIME", fmt, ##__VA_ARGS__) #define MQTT_LOG(fmt, ...) LOG_CAT("MQTT", fmt, ##__VA_ARGS__) #else #define LOG_CAT(...) do { } while (0) - #define WIFI_LOG(...) do { } while (0) - #define WEB_LOG(...) do { } while (0) - #define TIME_LOG(...) do { } while (0) #define MQTT_LOG(...) do { } while (0) #endif namespace { -constexpr unsigned long kWifiRetryMillis = 15000; -constexpr unsigned long kWifiConnectTimeoutMillis = 45000; constexpr unsigned long kBrokerRetryBaseMillis = 10000; constexpr unsigned long kBrokerRetryMaxMillis = 300000; constexpr size_t kBrokerTokenSize = 640; @@ -57,29 +48,6 @@ constexpr time_t kTokenLifetimeSecs = 3600; constexpr time_t kTokenRefreshSlackSecs = 300; constexpr time_t kMinSaneEpoch = 1735689600; // 2025-01-01T00:00:00Z -int getWifiQualityPercent(int rssi_dbm) { - if (rssi_dbm <= -100) { - return 0; - } - if (rssi_dbm >= -50) { - return 100; - } - return 2 * (rssi_dbm + 100); -} - -const char* getWifiQualityLabel(int rssi_dbm) { - if (rssi_dbm >= -60) { - return "excellent"; - } - if (rssi_dbm >= -67) { - return "good"; - } - if (rssi_dbm >= -75) { - return "fair"; - } - return "poor"; -} - unsigned long getBrokerRetryDelayMillis(uint8_t failures) { unsigned long delay_ms = kBrokerRetryBaseMillis; if (failures > 0) { @@ -106,20 +74,6 @@ void freeScratchBuffer(void* ptr) { } } -const char* getWifiStateLabel(const MQTTPrefs& prefs, bool wifi_started) { - if (prefs.wifi_ssid[0] == 0) { - return "off"; - } - wl_status_t status = WiFi.status(); - if (status == WL_CONNECTED) { - return "up"; - } - if (wifi_started) { - return "conn"; - } - return "down"; -} - #if MQTT_DEBUG void logMqttMemorySnapshot(const char* phase, const char* broker_label = nullptr) { MQTT_LOG("mem phase=%s broker=%s uptime_ms=%lu heap_free=%u heap_min=%u heap_max=%u psram_free=%u psram_min=%u " @@ -150,10 +104,8 @@ const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[3] = { }; MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) - : _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _wifi_started(false), _sntp_started(false), - _have_time_sync(false), _last_wifi_attempt(0), _last_status_publish(0), _last_status{}, - _node_name(nullptr), - _web_runner(nullptr) + : _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _last_status_publish(0), _last_status{}, + _node_name(nullptr), _network(nullptr) { memset(_device_id, 0, sizeof(_device_id)); MQTTPrefsStore::setDefaults(_prefs); @@ -164,11 +116,6 @@ MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) MQTT_LOG("uplink init"); } -void MQTTUplink::setWebCommandRunner(MQTTWebCommandRunner* runner) { - _web_runner = runner; - _web_panel.setCommandRunner(runner); -} - bool MQTTUplink::savePrefs() { return MQTTPrefsStore::save(_fs, _prefs); } @@ -203,24 +150,8 @@ bool MQTTUplink::isActive() const { return _running && hasEnabledBroker(); } -void MQTTUplink::reconnectWifi() { - WIFI_LOG("reset"); - stopWebServer(); - for (BrokerState& broker : _brokers) { - destroyBroker(broker); - } - if (_wifi_started) { - WiFi.disconnect(true, true); - WiFi.mode(WIFI_OFF); - } - _wifi_started = false; - _sntp_started = false; - _have_time_sync = false; - _last_wifi_attempt = 0; -} - bool MQTTUplink::sendStatusNow() { - if (!_running || !_have_time_sync || WiFi.status() != WL_CONNECTED) { + if (!_running || _network == nullptr || !_network->hasTimeSync() || !_network->isWifiConnected()) { return false; } @@ -327,28 +258,6 @@ void MQTTUplink::escapeJsonString(const char* input, char* output, size_t output output[oi] = 0; } -wifi_ps_type_t MQTTUplink::toEspPowerSave(uint8_t mode) { - switch (mode) { - case 1: - return WIFI_PS_MIN_MODEM; - case 2: - return WIFI_PS_MAX_MODEM; - default: - return WIFI_PS_NONE; - } -} - -const char* MQTTUplink::getPowerSaveLabel(uint8_t mode) { - switch (mode) { - case 1: - return "min"; - case 2: - return "max"; - default: - return "none"; - } -} - void MQTTUplink::refreshIdentityStrings() { bytesToHexUpper(_identity->pub_key, PUB_KEY_SIZE, _device_id, sizeof(_device_id)); for (BrokerState& broker : _brokers) { @@ -388,7 +297,7 @@ void MQTTUplink::refreshBrokerState(BrokerState& broker) { bool MQTTUplink::refreshToken(BrokerState& broker) { time_t now = time(nullptr); if (now < kMinSaneEpoch) { - TIME_LOG("%s token skipped: clock not ready (%lu)", broker.spec->label, static_cast(now)); + MQTT_LOG("%s token skipped: clock not ready (%lu)", broker.spec->label, static_cast(now)); return false; } @@ -685,94 +594,6 @@ void MQTTUplink::handleMqttEvent(void* handler_args, esp_event_base_t, int32_t e } } -void MQTTUplink::stopWebServer() { - _web_panel.stop(); -} - -void MQTTUplink::ensureWebServer() { - if (_web_runner == nullptr || _prefs.web_enabled == 0 || !_wifi_started || WiFi.status() != WL_CONNECTED) { - stopWebServer(); - return; - } - _web_panel.start(); -} - -void MQTTUplink::ensureWifi() { - if (_prefs.wifi_ssid[0] == 0) { - WIFI_LOG("disabled: no ssid"); - stopWebServer(); - reconnectWifi(); - return; - } - - if (!hasEnabledBroker() && _web_runner == nullptr) { - WIFI_LOG("disabled: no mqtt endpoints and no web runner"); - stopWebServer(); - if (_wifi_started) { - WiFi.disconnect(true, true); - WiFi.mode(WIFI_OFF); - _wifi_started = false; - _sntp_started = false; - _have_time_sync = false; - } - return; - } - - if (WiFi.status() == WL_CONNECTED) { - return; - } - - unsigned long now_ms = millis(); - wl_status_t status = WiFi.status(); - if (_wifi_started) { - if (_last_wifi_attempt != 0 && status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) { - return; - } - if (now_ms - _last_wifi_attempt < kWifiRetryMillis) { - return; - } - } - - if (!_wifi_started) { - WiFi.mode(WIFI_STA); - WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)); - _wifi_started = true; - WIFI_LOG("sta start powersaving=%s", getPowerSaveLabel(_prefs.wifi_powersave)); - } else { - WIFI_LOG("retry status=%d", static_cast(status)); - } - _last_wifi_attempt = now_ms; - WIFI_LOG("begin ssid=%s", _prefs.wifi_ssid); - WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd); -} - -void MQTTUplink::updateTimeSync() { - bool prev_have_time_sync = _have_time_sync; - if (!_wifi_started || WiFi.status() != WL_CONNECTED) { - _have_time_sync = false; - if (prev_have_time_sync != _have_time_sync) { - TIME_LOG("sntp lost"); - } - return; - } - - if (!_sntp_started) { - configTzTime("UTC0", "au.pool.ntp.org", "time.google.com", "time.cloudflare.com"); - _sntp_started = true; - TIME_LOG("sntp start servers=au.pool.ntp.org,time.google.com,time.cloudflare.com"); - } - - sntp_sync_status_t sync_status = sntp_get_sync_status(); - time_t now = time(nullptr); - bool sane_time = now >= kMinSaneEpoch; - bool sync_ready = sync_status == SNTP_SYNC_STATUS_COMPLETED || sync_status == SNTP_SYNC_STATUS_IN_PROGRESS; - _have_time_sync = sane_time && (sync_ready || prev_have_time_sync); - if (prev_have_time_sync != _have_time_sync) { - TIME_LOG("sntp %s epoch=%lu status=%ld", _have_time_sync ? "ready" : "waiting", - static_cast(now), static_cast(sync_status)); - } -} - void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { if (broker.spec == nullptr) { return; @@ -787,7 +608,7 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { return; } - if (!_have_time_sync || WiFi.status() != WL_CONNECTED) { + if (_network == nullptr || !_network->hasTimeSync() || !_network->isWifiConnected()) { return; } @@ -899,24 +720,15 @@ void MQTTUplink::begin(FILESYSTEM* fs) { refreshIdentityStrings(); _running = true; _last_status_publish = millis(); - MQTT_LOG("begin iata=%s enabled_mask=0x%02X wifi_ssid=%s", _prefs.iata, _prefs.enabled_mask, _prefs.wifi_ssid); + MQTT_LOG("begin iata=%s enabled_mask=0x%02X", _prefs.iata, _prefs.enabled_mask); } void MQTTUplink::end() { MQTT_LOG("end"); publishStatus(false); - stopWebServer(); for (BrokerState& broker : _brokers) { destroyBroker(broker); } - if (_wifi_started) { - WiFi.disconnect(true, true); - WiFi.mode(WIFI_OFF); - } - _wifi_started = false; - _sntp_started = false; - _have_time_sync = false; - _last_wifi_attempt = 0; _running = false; } @@ -926,13 +738,6 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) { } _last_status = snapshot; - ensureWifi(); - updateTimeSync(); - ensureWebServer(); - if (_web_panel.isRunning() && _web_panel.shouldAutoLock(millis())) { - WEB_LOG("idle lock"); - _web_panel.lockSession(); - } BrokerState* active_connecting_broker = nullptr; for (BrokerState& broker : _brokers) { @@ -958,7 +763,7 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) { } } - if (_prefs.status_enabled && hasEnabledBroker() && _have_time_sync && + if (_prefs.status_enabled && hasEnabledBroker() && _network != nullptr && _network->hasTimeSync() && millis() - _last_status_publish >= _prefs.status_interval_ms) { publishStatus(true); _last_status_publish = millis(); @@ -966,7 +771,8 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) { } void MQTTUplink::publishPacket(const mesh::Packet& packet, bool is_tx, int rssi, float snr, int score, int duration) { - if (!_running || !_have_time_sync || !hasEnabledBroker() || !_prefs.packets_enabled) { + if (!_running || _network == nullptr || !_network->hasTimeSync() || !_network->isWifiConnected() || !hasEnabledBroker() || + !_prefs.packets_enabled) { return; } if (is_tx && !_prefs.tx_enabled) { @@ -1035,7 +841,7 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { if (broker->connected) { return "up"; } - if (WiFi.status() != WL_CONNECTED || !_have_time_sync) { + if (_network == nullptr || !_network->isWifiConnected() || !_network->hasTimeSync()) { return "wait"; } if (broker->client != nullptr) { @@ -1048,36 +854,13 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { }; snprintf(reply, reply_size, "> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s status:%s tx:%s", - getWifiStateLabel(_prefs, _wifi_started), _have_time_sync ? "up" : "wait", _prefs.iata, + (_network != nullptr && _network->isWifiConnected()) ? "up" : "down", + (_network != nullptr && _network->hasTimeSync()) ? "up" : "wait", + _prefs.iata, broker_state(kEastmeshBit), broker_state(kLetsmeshEuBit), broker_state(kLetsmeshUsBit), _prefs.status_enabled ? "on" : "off", _prefs.tx_enabled ? "on" : "off"); } -void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { -#if WITH_WEB_PANEL - if (_web_runner == nullptr) { - snprintf(reply, reply_size, "> web:off"); - return; - } - - if (_prefs.web_enabled == 0) { - snprintf(reply, reply_size, "> web:off"); - return; - } - - if (!_web_panel.isRunning() || !_wifi_started || WiFi.status() != WL_CONNECTED) { - snprintf(reply, reply_size, "> web:down"); - return; - } - - snprintf(reply, reply_size, "> web:up url:https://%s/ auth:%s", WiFi.localIP().toString().c_str(), - _web_panel.hasSessionToken() ? "unlocked" : "locked"); -#else - (void)reply_size; - snprintf(reply, reply_size, "> web:unsupported"); -#endif -} - bool MQTTUplink::setEndpointEnabled(uint8_t bit, bool enabled) { uint8_t next_mask = _prefs.enabled_mask & 0x07; if (enabled) { @@ -1117,20 +900,6 @@ bool MQTTUplink::setTxEnabled(bool enabled) { return savePrefs(); } -bool MQTTUplink::setWebEnabled(bool enabled) { -#if WITH_WEB_PANEL - _prefs.web_enabled = enabled ? 1 : 0; - bool ok = savePrefs(); - if (_prefs.web_enabled != 0) { - ensureWebServer(); - } - return ok; -#else - (void)enabled; - return false; -#endif -} - bool MQTTUplink::setIata(const char* iata) { if (iata == nullptr || *iata == 0) { return false; @@ -1147,54 +916,6 @@ bool MQTTUplink::setIata(const char* iata) { return savePrefs(); } -bool MQTTUplink::setWifiPowerSave(const char* mode) { - if (mode == nullptr) { - return false; - } - - uint8_t next_mode; - if (strcmp(mode, "none") == 0) { - next_mode = 0; - } else if (strcmp(mode, "min") == 0) { - next_mode = 1; - } else if (strcmp(mode, "max") == 0) { - next_mode = 2; - } else { - return false; - } - - _prefs.wifi_powersave = next_mode; - bool ok = savePrefs(); - if (_wifi_started) { - ok = WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)) && ok; - } - return ok; -} - -const char* MQTTUplink::getWifiPowerSave() const { - return getPowerSaveLabel(_prefs.wifi_powersave); -} - -bool MQTTUplink::setWifiSSID(const char* ssid) { - if (ssid == nullptr) { - return false; - } - StrHelper::strncpy(_prefs.wifi_ssid, ssid, sizeof(_prefs.wifi_ssid)); - bool ok = savePrefs(); - reconnectWifi(); - return ok; -} - -bool MQTTUplink::setWifiPassword(const char* pwd) { - if (pwd == nullptr) { - return false; - } - StrHelper::strncpy(_prefs.wifi_pwd, pwd, sizeof(_prefs.wifi_pwd)); - bool ok = savePrefs(); - reconnectWifi(); - return ok; -} - bool MQTTUplink::setOwnerPublicKey(const char* owner_public_key) { if (owner_public_key == nullptr) { return false; @@ -1227,66 +948,21 @@ bool MQTTUplink::setOwnerEmail(const char* owner_email) { return savePrefs(); } -void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { - const char* status = "disconnected"; - const char* state = "disconnected"; - wl_status_t wifi_status = WiFi.status(); - if (_prefs.wifi_ssid[0] == 0) { - status = "unconfigured"; - state = "unconfigured"; - } else if (wifi_status == WL_CONNECTED) { - status = "connected"; - state = "connected"; - } else if (_wifi_started) { - status = "connecting"; - } - - switch (wifi_status) { - case WL_IDLE_STATUS: - state = "idle"; - break; - case WL_NO_SSID_AVAIL: - state = "no_ssid"; - break; - case WL_SCAN_COMPLETED: - state = "scan_completed"; - break; - case WL_CONNECTED: - state = "connected"; - break; - case WL_CONNECT_FAILED: - state = "connect_failed"; - break; - case WL_CONNECTION_LOST: - state = "connection_lost"; - break; - case WL_DISCONNECTED: - state = "disconnected"; - break; - default: - state = "unknown"; - break; - } - - if (wifi_status == WL_CONNECTED) { - const int rssi_dbm = WiFi.RSSI(); - snprintf(reply, reply_size, - "> ssid:%s status:%s code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s", - _prefs.wifi_ssid, status, static_cast(wifi_status), state, WiFi.localIP().toString().c_str(), - rssi_dbm, getWifiQualityPercent(rssi_dbm), getWifiQualityLabel(rssi_dbm)); - } else { - snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-", - status, static_cast(wifi_status), state); +bool MQTTUplink::isAnyBrokerConnected() const { + for (const BrokerState& broker : _brokers) { + if (broker.spec != nullptr && broker.connected) { + return true; + } } + return false; } #else MQTTUplink::MQTTUplink(mesh::RTCClock&, mesh::LocalIdentity&) - : _fs(nullptr), _rtc(nullptr), _identity(nullptr), _running(false), _wifi_started(false), _sntp_started(false), - _have_time_sync(false), _last_wifi_attempt(0), _last_status_publish(0), _last_status{}, - _node_name(nullptr), - _web_runner(nullptr) { + : _fs(nullptr), _rtc(nullptr), _identity(nullptr), _running(false), _last_status_publish(0), _last_status{}, + _node_name(nullptr), _network(nullptr) { + MQTTPrefsStore::setDefaults(_prefs); } bool MQTTUplink::savePrefs() { return false; } @@ -1295,25 +971,18 @@ void MQTTUplink::end() {} void MQTTUplink::loop(const MQTTStatusSnapshot&) {} void MQTTUplink::publishPacket(const mesh::Packet&, bool, int, float, int, int) {} void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); } -void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); } bool MQTTUplink::setEndpointEnabled(uint8_t, bool) { return false; } bool MQTTUplink::isEndpointEnabled(uint8_t) const { return false; } bool MQTTUplink::setPacketsEnabled(bool) { return false; } bool MQTTUplink::setRawEnabled(bool) { return false; } bool MQTTUplink::setStatusEnabled(bool) { return false; } bool MQTTUplink::setTxEnabled(bool) { return false; } -bool MQTTUplink::setWebEnabled(bool) { return false; } bool MQTTUplink::setIata(const char*) { return false; } -bool MQTTUplink::setWifiPowerSave(const char*) { return false; } -const char* MQTTUplink::getWifiPowerSave() const { return "unsupported"; } bool MQTTUplink::isActive() const { return false; } -bool MQTTUplink::setWifiSSID(const char*) { return false; } -bool MQTTUplink::setWifiPassword(const char*) { return false; } bool MQTTUplink::setOwnerPublicKey(const char*) { return false; } bool MQTTUplink::setOwnerEmail(const char*) { return false; } -void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); } -void MQTTUplink::reconnectWifi() {} bool MQTTUplink::sendStatusNow() { return false; } +bool MQTTUplink::isAnyBrokerConnected() const { return false; } #endif diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index d5dfa896..2168914f 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -3,23 +3,16 @@ #ifdef WITH_MQTT_UPLINK #include -#include -#include +#include #include #include "JWTHelper.h" #include "MQTTPrefs.h" #if defined(ESP_PLATFORM) -#include -#if !defined(WITH_WEB_PANEL) - #define WITH_WEB_PANEL 1 -#endif #include #endif -using MQTTWebCommandRunner = WebPanelCommandRunner; - struct MQTTStatusSnapshot { int battery_mv; uint32_t uptime_secs; @@ -49,7 +42,6 @@ public: bool isActive() const; void formatStatusReply(char* reply, size_t reply_size) const; - void formatWebStatusReply(char* reply, size_t reply_size) const; bool setEndpointEnabled(uint8_t bit, bool enabled); bool isEndpointEnabled(uint8_t bit) const; @@ -61,24 +53,16 @@ public: bool isStatusEnabled() const { return _prefs.status_enabled != 0; } bool setTxEnabled(bool enabled); bool isTxEnabled() const { return _prefs.tx_enabled != 0; } - bool setWebEnabled(bool enabled); - bool isWebEnabled() const { return _prefs.web_enabled != 0; } bool setIata(const char* iata); const char* getIata() const { return _prefs.iata; } void setNodeNameSource(const char* node_name) { _node_name = node_name; } - void setWebCommandRunner(MQTTWebCommandRunner* runner); - bool setWifiSSID(const char* ssid); - bool setWifiPassword(const char* pwd); - const char* getWifiSSID() const { return _prefs.wifi_ssid; } - bool setWifiPowerSave(const char* mode); - const char* getWifiPowerSave() const; bool setOwnerPublicKey(const char* owner_public_key); const char* getOwnerPublicKey() const { return _prefs.owner_public_key; } bool setOwnerEmail(const char* owner_email); const char* getOwnerEmail() const { return _prefs.owner_email; } - void formatWifiStatusReply(char* reply, size_t reply_size) const; - void reconnectWifi(); bool sendStatusNow(); + bool isAnyBrokerConnected() const; + void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; } private: #if defined(ESP_PLATFORM) @@ -114,16 +98,11 @@ private: mesh::LocalIdentity* _identity; MQTTPrefs _prefs; bool _running; - bool _wifi_started; - bool _sntp_started; - bool _have_time_sync; - unsigned long _last_wifi_attempt; unsigned long _last_status_publish; MQTTStatusSnapshot _last_status; char _device_id[65]; const char* _node_name; - MQTTWebCommandRunner* _web_runner; - WebPanelServer _web_panel; + NetworkStateProvider* _network; #if defined(ESP_PLATFORM) static constexpr uint8_t kEastmeshBit = 0x01; @@ -135,10 +114,6 @@ private: BrokerState _brokers[3]; static void handleMqttEvent(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data); - void ensureWebServer(); - void stopWebServer(); - void ensureWifi(); - void updateTimeSync(); bool hasEnabledBroker() const; static uint8_t normalizeEnabledMask(uint8_t mask); void formatTopic(char* dst, size_t dst_size, const char* leaf) const; @@ -155,8 +130,6 @@ private: int buildPacketJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, float snr, int score, int duration) const; int buildRawJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, float snr) const; - static wifi_ps_type_t toEspPowerSave(uint8_t mode); - static const char* getPowerSaveLabel(uint8_t mode); static void escapeJsonString(const char* input, char* output, size_t output_size); static void makeSafeToken(const char* input, char* output, size_t output_size); static void bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size); diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 2206e330..4b447b7a 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -24,6 +24,8 @@ constexpr size_t kWebServerStackSize = WEB_PANEL_STACK_SIZE; constexpr size_t kWebPasswordBufferSize = 80; constexpr size_t kWebCommandBufferSize = 192; constexpr size_t kWebReplyBufferSize = 256; +constexpr size_t kWebStatsQueryBufferSize = 96; +constexpr size_t kWebStatsReplyBufferSize = 4608; constexpr size_t kWebPageChunkSize = 768; constexpr unsigned long kWebIdleTimeoutMs = WEB_PANEL_IDLE_TIMEOUT_MS; @@ -147,6 +149,58 @@ esp_err_t sendJsonFieldChunk(httpd_req_t* req, const char* key, const char* valu return sendChunk(req, "\""); } +bool getQueryValue(httpd_req_t* req, const char* key, char* value, size_t value_size) { + if (req == nullptr || key == nullptr || value == nullptr || value_size == 0) { + return false; + } + const size_t query_len = httpd_req_get_url_query_len(req); + if (query_len == 0 || query_len + 1 > kWebStatsQueryBufferSize) { + return false; + } + + char query[kWebStatsQueryBufferSize]; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) { + return false; + } + return httpd_query_key_value(query, key, value, value_size) == ESP_OK; +} + +esp_err_t sendLegacyStatsBundle(httpd_req_t* req, WebPanelCommandRunner* runner, char* reply) { + const struct { + const char* key; + const char* command; + } fields[] = { + {"wifi", "get wifi.status"}, + {"wifi_powersave", "get wifi.powersaving"}, + {"core", "stats-core"}, + {"radio", "stats-radio"}, + {"packets", "stats-packets"}, + {"memory", "memory"}, + }; + + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + if (sendChunk(req, "{") != ESP_OK) { + httpd_resp_sendstr_chunk(req, nullptr); + return ESP_FAIL; + } + for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) { + memset(reply, 0, kWebReplyBufferSize); + runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize); + if (sendJsonFieldChunk(req, fields[i].key, reply, i != 0) != ESP_OK) { + httpd_resp_sendstr_chunk(req, nullptr); + return ESP_FAIL; + } + } + esp_err_t rc = sendChunk(req, "}"); + if (rc == ESP_OK) { + rc = httpd_resp_sendstr_chunk(req, nullptr); + } else { + httpd_resp_sendstr_chunk(req, nullptr); + } + return rc; +} + const char kWebPanelLoginHtml[] PROGMEM = R"HTML( @@ -225,6 +279,44 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML( )HTML"; +const char kWebPanelStatsDisabledHtml[] PROGMEM = R"HTML( + + + + + + Stats Disabled + + + +
+
+

Stats Disabled

+

The dedicated stats page is currently disabled for this node.

+

Enable it with `set web.stats on` from the CLI or return to /app.

+
+
+ + +)HTML"; + const char kWebPanelAppHtml[] PROGMEM = R"HTML( @@ -276,7 +368,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( main { max-width:920px; margin:0 auto; padding:24px; } .card { background:var(--card-bg); border:1px solid var(--border); border-radius:14px; padding:18px; margin-bottom:18px; } h1,h2,h3 { margin:0 0 12px; font-size:18px; } - p { color:var(--text-muted); margin:8px 0 0; } + p { color:var(--text-muted); margin:8px 0 0; line-height:1.45; } input:not([type="checkbox"]), textarea, button, select { width:100%; min-height:44px; box-sizing:border-box; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text); padding:12px; font-family:inherit; font-size:inherit; line-height:inherit; } input:not([type="checkbox"]), textarea, select, button { -webkit-appearance:none; appearance:none; } textarea { min-height:100px; resize:vertical; } @@ -310,6 +402,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( .broker-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; } .broker-grid.single { grid-template-columns:1fr; } .broker-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); } + .broker-grid.one-two { grid-template-columns:minmax(0,1fr) minmax(0,2fr); } .broker-card { background:var(--surface2); border:1px solid var(--border); border-radius:12px; padding:12px; display:grid; gap:10px; min-height:124px; align-content:start; } .broker-row { display:flex; align-items:center; justify-content:space-between; gap:12px; } .broker-copy { display:grid; gap:4px; min-width:0; } @@ -332,9 +425,10 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( :root[data-theme="dark"] .mode-label.active { background:rgba(73,194,125,.24); } .mode-label.disabled { opacity:.4; } .visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } - .panel-warning { min-height:1.4em; font-size:13px; color:var(--status-red); } - .panel-note { font-size:13px; color:var(--text-muted); } - .panel-status { min-height:1.4em; font-size:13px; color:var(--text-muted); } + .panel-copy, .panel-note, .panel-status, .panel-warning, .stats-empty, .stats-error, .events-empty, .spark-status { font-size:13px; line-height:1.45; font-weight:400; } + .panel-warning { min-height:1.4em; color:var(--status-red); } + .panel-note { color:var(--text-muted); } + .panel-status { min-height:1.4em; color:var(--text-muted); } .themebtn { padding:10px 14px; } #status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; } .terminal { background:var(--terminal-bg); border:1px solid var(--terminal-border); border-radius:12px; padding:14px; min-height:180px; max-height:320px; overflow:auto; font-family:inherit; font-size:14px; line-height:1.45; } @@ -345,11 +439,28 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( .stats-shell { display:grid; gap:12px; } .stats-empty, .stats-error { background:var(--surface2); border:1px dashed var(--border); border-radius:12px; padding:16px; color:var(--text-muted); } .stats-error { color:var(--status-red); } + .trend-section-copy { display:grid; gap:6px; } + .trend-section-copy p { margin:0; color:var(--text-muted); } + .trend-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } + .trend-card { position:relative; background:linear-gradient(180deg,var(--surface2),var(--surface1)); border:1px solid var(--border); border-radius:14px; padding:14px; display:grid; gap:10px; } + .trend-head { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; } + .trend-title { font-size:13px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.08em; } + .trend-value { font-size:24px; font-weight:700; color:var(--text); } + .spark { width:100%; height:84px; border-radius:12px; background:rgba(255,255,255,.35); border:1px solid var(--border); display:block; } + :root[data-theme="dark"] .spark { background:rgba(0,0,0,.16); } + .spark-axis { display:flex; justify-content:space-between; gap:10px; color:var(--text-muted); min-height:1.3em; } + .spark-axis span { font-size:12px; line-height:1.45; } + .spark-axis span:last-child { text-align:right; } + .spark-tooltip { position:absolute; right:14px; top:50px; padding:6px 8px; border-radius:8px; font-size:12px; color:var(--text); background:rgba(255,255,255,.92); border:1px solid var(--border); box-shadow:0 6px 18px rgba(0,0,0,.12); pointer-events:none; opacity:0; transform:translateY(-4px); transition:opacity .12s ease, transform .12s ease; } + .spark-tooltip.visible { opacity:1; transform:translateY(0); } + :root[data-theme="dark"] .spark-tooltip { background:rgba(24,24,24,.92); } .actions-bar { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:12px; } .actions-group { display:flex; gap:10px; flex-wrap:wrap; } .actions-group.left { justify-self:start; } .actions-group.center { justify-self:center; } .actions-group.right { justify-self:end; } + .actions-group .active { background:var(--accent); color:var(--button-text); border-color:transparent; } + .actions-group .active:hover { background:var(--accent-hover); } .hud-grid-1 { display:grid; grid-template-columns:1fr; gap:12px; } .hud-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; } .hud-grid-2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } @@ -372,11 +483,20 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( :root[data-theme="dark"] .metric { background:rgba(0,0,0,.16); } .metric-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; } .metric-value { margin-top:4px; font-size:16px; font-weight:700; color:var(--text); } + .events-table-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:12px; } + .events-table { width:100%; border-collapse:collapse; } + .events-table th, .events-table td { padding:10px 12px; text-align:left; font-size:13px; } + .events-table th { color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; font-weight:700; background:rgba(255,255,255,.3); } + .events-table td { color:var(--text); border-top:1px solid var(--border); } + .events-table a { color:var(--accent); text-decoration:none; font-weight:700; } + .events-table a:hover { text-decoration:underline; } + :root[data-theme="dark"] .events-table th { background:rgba(0,0,0,.16); } + .events-empty { color:var(--text-muted); } @media (max-width:760px) { body { font-size:15px; } main { padding:16px; } .card { padding:16px; margin-bottom:14px; } - .row, .row3, .row-command, .metric-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics, .broker-stack, .broker-grid, .broker-grid.single, .broker-grid.two { grid-template-columns:1fr; } + .row, .row3, .row-command, .metric-grid, .trend-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics, .broker-stack, .broker-grid, .broker-grid.single, .broker-grid.two, .broker-grid.one-two { grid-template-columns:1fr; } .inline-actions { grid-template-columns:minmax(0,1fr) auto auto; } .fieldline { grid-template-columns:minmax(0,1fr) auto; align-items:center; } .row-command button { width:100%; } @@ -389,9 +509,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( .actions-bar { display:grid; grid-template-columns:1fr; gap:10px; } .actions-group { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); } .actions-group.left, .actions-group.center, .actions-group.right { justify-self:stretch; } - .actions-group.center { grid-template-columns:1fr; justify-items:center; } + .actions-group.center { grid-template-columns:repeat(3,minmax(0,1fr)); justify-items:stretch; } + .actions-group.right { grid-template-columns:repeat(3,minmax(0,1fr)); justify-items:stretch; } #actionsPanel .actions-group button { width:100%; } - #actionsPanel .actions-group.center button { width:auto; min-width:56px; } + #actionsPanel .actions-group.center button { width:100%; min-width:0; } + #actionsPanel .actions-group.right button { width:100%; min-width:0; } .broker-row { gap:10px; } .row > div + div, .row3 > div + div { margin-top:4px; } .row > div[style*="align-self:end"] { padding-top:4px; } @@ -414,14 +536,17 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(

Actions

- - + +
- + + +
+
@@ -455,7 +580,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( -

Only the allowlisted commands exposed by this panel will run here.

+

Only the allowlisted commands exposed by this panel will run here.

@@ -531,7 +656,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( -

Changing the private key requires a reboot to apply.

+

Changing the private key requires a reboot to apply.

@@ -638,6 +763,40 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
+
+

Modes

+
+
+
+
+
+
Ghost Node Mode
+
Off
+
+
+
+ + +
+ +
+
+
+
+
+
+
+
Description
+
+
+
Keeps web and MQTT running, but turns repeat off and disables both local and flood adverts. Turning it back off restores the prior repeat and advert settings if known, else falls back to repeat on, advert interval 60 minutes, and flood advert interval 12 hours.
+
+
+
+
@@ -793,16 +952,41 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( + +