Merge pull request #24 from xJARiD/develop

feat: add SD-backed stats archive and dedicated /stats web console
Этот коммит содержится в:
xJARiD
2026-04-15 19:00:59 +10:00
коммит произвёл GitHub
родитель bd90f5b958 656bfdbc67
Коммит dc7521c8c7
37 изменённых файлов: 3974 добавлений и 721 удалений
-3
Просмотреть файл
@@ -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
-2
Просмотреть файл
@@ -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
-2
Просмотреть файл
@@ -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:
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 325 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 394 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 385 KiB

+48 -52
Просмотреть файл
@@ -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`.
+6
Просмотреть файл
@@ -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 <device-name>`
- `get lat`
-9
Просмотреть файл
@@ -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
```
+84 -19
Просмотреть файл
@@ -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)
Разница между файлами не показана из-за своего большого размера Загрузить разницу
+37 -2
Просмотреть файл
@@ -26,13 +26,17 @@
#ifdef WITH_MQTT_UPLINK
#include <helpers/mqtt/MQTTUplink.h>
#endif
#include <helpers/NetworkService.h>
#include <helpers/web/WebService.h>
#include <helpers/AdvertDataHelpers.h>
#include <helpers/ArchiveStorage.h>
#include <helpers/ArduinoHelpers.h>
#include <helpers/ClientACL.h>
#include <helpers/CommonCLI.h>
#include <helpers/IdentityStore.h>
#include <helpers/SimpleMeshTables.h>
#include <helpers/StatsHistory.h>
#include <helpers/StaticPoolPacketManager.h>
#include <helpers/StatsFormatHelper.h>
#include <helpers/TxtDataHelpers.h>
@@ -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)
+4 -1
Просмотреть файл
@@ -2,6 +2,7 @@
#include <Mesh.h>
#include "MyMesh.h"
#include <helpers/ArchiveStorage.h>
#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);
-1
Просмотреть файл
@@ -48,7 +48,6 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
build_src_filter =
+<*.cpp>
+<helpers/*.cpp>
+<helpers/web/*.cpp>
+<helpers/radiolib/*.cpp>
+<helpers/bridges/BridgeBase.cpp>
+<helpers/ui/MomentaryButton.cpp>
+3
Просмотреть файл
@@ -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; }
+243
Просмотреть файл
@@ -0,0 +1,243 @@
#include "ArchiveStorage.h"
#include <MeshCore.h>
#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<unsigned>(_spi_bus),
static_cast<unsigned>(_cs_pin),
static_cast<unsigned>(_sck_pin),
static_cast<unsigned>(_miso_pin),
static_cast<unsigned>(_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<unsigned>(_spi_bus),
static_cast<unsigned>(_cs_pin),
static_cast<unsigned>(_sck_pin),
static_cast<unsigned>(_miso_pin),
static_cast<unsigned>(_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<unsigned long long>(_card_size_bytes),
static_cast<unsigned long long>(_total_bytes),
static_cast<unsigned long long>(_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<unsigned>(_spi_bus), static_cast<unsigned>(_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<unsigned>(_spi_bus), static_cast<unsigned>(_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<unsigned long long>(_card_size_bytes),
static_cast<unsigned long long>(_total_bytes),
static_cast<unsigned long long>(_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<uint8_t>(P_BOARD_SPI_SCK);
_miso_pin = static_cast<uint8_t>(P_BOARD_SPI_MISO);
_mosi_pin = static_cast<uint8_t>(P_BOARD_SPI_MOSI);
_cs_pin = static_cast<uint8_t>(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<uint8_t>(SDCARD_CS);
#if defined(SPI_SCK) && defined(SPI_MISO) && defined(SPI_MOSI)
_sck_pin = static_cast<uint8_t>(SPI_SCK);
_miso_pin = static_cast<uint8_t>(SPI_MISO);
_mosi_pin = static_cast<uint8_t>(SPI_MOSI);
return true;
#elif defined(P_LORA_SCLK) && defined(P_LORA_MISO) && defined(P_LORA_MOSI)
_sck_pin = static_cast<uint8_t>(P_LORA_SCLK);
_miso_pin = static_cast<uint8_t>(P_LORA_MISO);
_mosi_pin = static_cast<uint8_t>(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
+59
Просмотреть файл
@@ -0,0 +1,59 @@
#pragma once
#include <Arduino.h>
#include <helpers/IdentityStore.h>
#if defined(ESP32)
#include <FS.h>
#include <SD.h>
#include <SPI.h>
#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
};
+126
Просмотреть файл
@@ -0,0 +1,126 @@
#include "NetworkPrefs.h"
#include <helpers/TxtDataHelpers.h>
#include <string.h>
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<size_t>(file.size()) < sizeof(LegacyWebPrefsV1)) {
file.close();
return false;
}
LegacyWebPrefsV1 legacy{};
bool ok = file.read(reinterpret_cast<uint8_t*>(&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<size_t>(file.size()), sizeof(persisted));
bool ok = bytes_to_read >= sizeof(persisted.magic) &&
file.read(reinterpret_cast<uint8_t*>(&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<const uint8_t*>(&prefs), sizeof(prefs)) == sizeof(prefs);
file.close();
return ok;
}
+26
Просмотреть файл
@@ -0,0 +1,26 @@
#pragma once
#include <helpers/IdentityStore.h>
#include <stdint.h>
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";
};
+299
Просмотреть файл
@@ -0,0 +1,299 @@
#include "NetworkService.h"
#include <helpers/TxtDataHelpers.h>
#include <string.h>
#include <time.h>
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#include <esp_sntp.h>
#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<int>(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<int>(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
+50
Просмотреть файл
@@ -0,0 +1,50 @@
#pragma once
#include <Arduino.h>
#include <helpers/IdentityStore.h>
#include <helpers/NetworkStateProvider.h>
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#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;
};
+8
Просмотреть файл
@@ -0,0 +1,8 @@
#pragma once
class NetworkStateProvider {
public:
virtual ~NetworkStateProvider() = default;
virtual bool isWifiConnected() const = 0;
virtual bool hasTimeSync() const = 0;
};
+889
Просмотреть файл
@@ -0,0 +1,889 @@
#include "StatsHistory.h"
#include <stdio.h>
#include <string.h>
#if defined(ESP32)
#include <esp_heap_caps.h>
#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 <typename T>
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<T*>(ptr);
}
}
return static_cast<T*>(calloc(count, sizeof(T)));
#else
return static_cast<T*>(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<int>(sample.battery_mv);
}
if (strcmp(series, "memory") == 0) {
return static_cast<int>(sample.heap_free);
}
if (strcmp(series, "signal") == 0) {
return static_cast<int>(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<int>(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<HistorySample>(_sample_capacity);
_events = allocHistoryBuffer<HistoryEvent>(_event_capacity);
_psram_backed = want_psram;
_degraded = !want_psram;
if (_samples == nullptr || _events == nullptr) {
freeHistoryBuffer(_samples);
freeHistoryBuffer(_events);
_samples = allocHistoryBuffer<HistorySample>(64);
_events = allocHistoryBuffer<HistoryEvent>(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<uint32_t>(epoch_secs);
sample.uptime_secs = static_cast<uint32_t>(uptime_secs);
sample.packets_sent = static_cast<uint32_t>(packets_sent);
sample.packets_recv = static_cast<uint32_t>(packets_recv);
sample.heap_free = static_cast<uint32_t>(heap_free);
sample.heap_min = static_cast<uint32_t>(heap_free);
sample.psram_free = static_cast<uint32_t>(psram_free);
sample.psram_min = static_cast<uint32_t>(psram_free);
sample.battery_mv = static_cast<uint16_t>(battery_mv);
sample.queue_len = static_cast<uint16_t>(queue_len);
sample.error_flags = static_cast<uint16_t>(error_flags);
sample.recv_errors = static_cast<uint16_t>(recv_errors);
sample.neighbour_count = static_cast<uint16_t>(neighbour_count);
sample.direct_dups = static_cast<uint16_t>(direct_dups);
sample.flood_dups = static_cast<uint16_t>(flood_dups);
sample.last_rssi_x4 = static_cast<int16_t>(last_rssi_x4);
sample.last_snr_x4 = static_cast<int16_t>(last_snr_x4);
sample.flags = static_cast<uint8_t>(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<size_t>(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<char>(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<uint32_t>(epoch_secs);
event.uptime_secs = static_cast<uint32_t>(uptime_secs);
event.value = static_cast<int16_t>(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<size_t>(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<char>(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<unsigned long>(latest.epoch_secs),
static_cast<unsigned long>(latest.uptime_secs),
static_cast<unsigned>(latest.battery_mv),
static_cast<unsigned>(latest.queue_len),
static_cast<int>(latest.last_rssi_x4),
static_cast<int>(latest.last_snr_x4),
static_cast<unsigned>(latest.packets_sent),
static_cast<unsigned>(latest.packets_recv),
static_cast<unsigned>(latest.heap_free),
static_cast<unsigned>(latest.psram_free),
static_cast<unsigned>(latest.error_flags),
static_cast<unsigned>(latest.recv_errors),
static_cast<unsigned>(latest.neighbour_count),
static_cast<unsigned>(latest.direct_dups),
static_cast<unsigned>(latest.flood_dups),
static_cast<unsigned>(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<unsigned>(written),
static_cast<unsigned>(_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<unsigned long>(event.epoch_secs),
static_cast<unsigned long>(event.uptime_secs),
getEventTypeName(event.type),
static_cast<int>(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<unsigned>(total_written),
static_cast<unsigned>(_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<unsigned>(_sample_count),
static_cast<unsigned>(_sample_capacity),
static_cast<unsigned>(_event_count),
static_cast<unsigned>(_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<unsigned>(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<unsigned long>(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<unsigned long>(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<unsigned long>(oldest_age_secs),
static_cast<unsigned long>(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<unsigned long>(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";
}
}
+132
Просмотреть файл
@@ -0,0 +1,132 @@
#pragma once
#include <Arduino.h>
#include <MeshCore.h>
#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;
};
+19 -3
Просмотреть файл
@@ -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{
+8 -7
Просмотреть файл
@@ -2,6 +2,7 @@
#ifdef WITH_MQTT_UPLINK
#include <stddef.h>
#include <helpers/TxtDataHelpers.h>
#include <string.h>
@@ -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;
+6 -4
Просмотреть файл
@@ -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 {
+23 -354
Просмотреть файл
@@ -11,7 +11,6 @@
#include <esp_idf_version.h>
#include <esp_heap_caps.h>
#include <esp_system.h>
#include <esp_sntp.h>
#include <helpers/TxtDataHelpers.h>
#include <ctype.h>
#include <string.h>
@@ -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<unsigned long>(now));
MQTT_LOG("%s token skipped: clock not ready (%lu)", broker.spec->label, static_cast<unsigned long>(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<int>(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<unsigned long>(now), static_cast<long>(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<int>(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<int>(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
+4 -31
Просмотреть файл
@@ -3,23 +3,16 @@
#ifdef WITH_MQTT_UPLINK
#include <Mesh.h>
#include <helpers/CommonCLI.h>
#include <helpers/web/WebPanelServer.h>
#include <helpers/NetworkStateProvider.h>
#include <target.h>
#include "JWTHelper.h"
#include "MQTTPrefs.h"
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#if !defined(WITH_WEB_PANEL)
#define WITH_WEB_PANEL 1
#endif
#include <mqtt_client.h>
#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);
Разница между файлами не показана из-за своего большого размера Загрузить разницу
+15
Просмотреть файл
@@ -17,6 +17,20 @@ public:
virtual ~WebPanelCommandRunner() = default;
virtual void runWebCommand(const char* command, char* reply, size_t reply_size) = 0;
virtual const char* getWebAdminPassword() const = 0;
virtual bool isWebStatsEnabled() const { return false; }
virtual bool formatWebStatsSummaryJson(char* reply, size_t reply_size) {
if (reply != nullptr && reply_size > 0) {
reply[0] = 0;
}
return false;
}
virtual bool formatWebStatsSeriesJson(const char* series, char* reply, size_t reply_size) {
(void)series;
if (reply != nullptr && reply_size > 0) {
reply[0] = 0;
}
return false;
}
};
class WebPanelServer {
@@ -45,6 +59,7 @@ private:
static esp_err_t handleIndex(httpd_req_t* req);
static esp_err_t handleApp(httpd_req_t* req);
static esp_err_t handleStatsPage(httpd_req_t* req);
static esp_err_t handleLogin(httpd_req_t* req);
static esp_err_t handleCommand(httpd_req_t* req);
static esp_err_t handleStats(httpd_req_t* req);
+68
Просмотреть файл
@@ -0,0 +1,68 @@
#include "WebPrefs.h"
#include <string.h>
void WebPrefsStore::setDefaults(WebPrefs& prefs) {
memset(&prefs, 0, sizeof(prefs));
prefs.magic = kMagic;
prefs.web_enabled = 1;
prefs.web_stats_enabled = 1;
}
bool WebPrefsStore::load(FILESYSTEM* fs, WebPrefs& prefs) {
setDefaults(prefs);
if (fs == nullptr) {
return false;
}
if (!fs->exists(kFilename)) {
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;
}
WebPrefs persisted{};
size_t bytes_to_read = min(static_cast<size_t>(file.size()), sizeof(persisted));
bool ok = bytes_to_read >= sizeof(persisted.magic) &&
file.read(reinterpret_cast<uint8_t*>(&persisted), bytes_to_read) == bytes_to_read;
file.close();
if (!ok || persisted.magic != kMagic) {
fs->remove(kFilename);
save(fs, prefs);
return false;
}
prefs = persisted;
prefs.web_enabled = prefs.web_enabled ? 1 : 0;
prefs.web_stats_enabled = prefs.web_stats_enabled ? 1 : 0;
return true;
}
bool WebPrefsStore::save(FILESYSTEM* fs, const WebPrefs& 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<const uint8_t*>(&prefs), sizeof(prefs)) == sizeof(prefs);
file.close();
return ok;
}
+22
Просмотреть файл
@@ -0,0 +1,22 @@
#pragma once
#include <helpers/IdentityStore.h>
#include <stdint.h>
struct WebPrefs {
uint32_t magic;
uint8_t web_enabled;
uint8_t web_stats_enabled;
uint8_t reserved[2];
};
class WebPrefsStore {
public:
static void setDefaults(WebPrefs& prefs);
static bool load(FILESYSTEM* fs, WebPrefs& prefs);
static bool save(FILESYSTEM* fs, const WebPrefs& prefs);
private:
static constexpr uint32_t kMagic = 0x57454250;
static constexpr const char* kFilename = "/web_prefs";
};
+85
Просмотреть файл
@@ -0,0 +1,85 @@
#include "WebService.h"
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
#include <WiFi.h>
#endif
WebService::WebService() : _fs(nullptr), _prefs{}, _runner(nullptr), _network(nullptr) {
WebPrefsStore::setDefaults(_prefs);
}
void WebService::begin(FILESYSTEM* fs) {
_fs = fs;
WebPrefsStore::load(_fs, _prefs);
}
void WebService::end() {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
_panel.stop();
#endif
}
void WebService::loop() {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
ensureWebServer();
if (_panel.isRunning() && _panel.shouldAutoLock(millis())) {
_panel.lockSession();
}
#endif
}
void WebService::setCommandRunner(WebPanelCommandRunner* runner) {
_runner = runner;
_panel.setCommandRunner(runner);
}
bool WebService::savePrefs() {
return WebPrefsStore::save(_fs, _prefs);
}
bool WebService::setWebEnabled(bool enabled) {
_prefs.web_enabled = enabled ? 1 : 0;
bool ok = savePrefs();
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
if (_prefs.web_enabled != 0) {
ensureWebServer();
} else {
_panel.stop();
}
#endif
return ok;
}
bool WebService::setWebStatsEnabled(bool enabled) {
_prefs.web_stats_enabled = enabled ? 1 : 0;
return savePrefs();
}
void WebService::formatWebStatusReply(char* reply, size_t reply_size) const {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
if (_runner == nullptr || _prefs.web_enabled == 0) {
snprintf(reply, reply_size, "> web:off");
return;
}
if (!_panel.isRunning() || _network == nullptr || !_network->isWifiConnected()) {
snprintf(reply, reply_size, "> web:down");
return;
}
snprintf(reply, reply_size, "> web:up url:https://%s/ auth:%s", WiFi.localIP().toString().c_str(),
_panel.hasSessionToken() ? "unlocked" : "locked");
#else
snprintf(reply, reply_size, "> web:unsupported");
#endif
}
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
void WebService::ensureWebServer() {
if (_runner == nullptr || _prefs.web_enabled == 0 || _network == nullptr || !_network->isWifiConnected()) {
_panel.stop();
return;
}
_panel.start();
}
#endif
+41
Просмотреть файл
@@ -0,0 +1,41 @@
#pragma once
#include <Arduino.h>
#include <helpers/IdentityStore.h>
#include <helpers/NetworkStateProvider.h>
#include "WebPanelServer.h"
#include "WebPrefs.h"
class WebService {
public:
WebService();
void begin(FILESYSTEM* fs);
void end();
void loop();
void setCommandRunner(WebPanelCommandRunner* runner);
void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; }
bool setWebEnabled(bool enabled);
bool isWebEnabled() const { return _prefs.web_enabled != 0; }
bool setWebStatsEnabled(bool enabled);
bool isWebStatsEnabled() const { return _prefs.web_stats_enabled != 0; }
void formatWebStatusReply(char* reply, size_t reply_size) const;
bool isPanelRunning() const { return _panel.isRunning(); }
bool isPanelUnlocked() const { return _panel.hasSessionToken(); }
private:
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
void ensureWebServer();
#endif
bool savePrefs();
FILESYSTEM* _fs;
WebPrefs _prefs;
WebPanelCommandRunner* _runner;
NetworkStateProvider* _network;
WebPanelServer _panel;
};
+36 -99
Просмотреть файл
@@ -18,6 +18,7 @@ build_src_filter = ${Ebyte_EoRa-S3.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Ebyte_EoRa-S3.lib_deps}
${esp32_ota.lib_deps}
@@ -27,6 +28,7 @@ extends = Generic_E22
build_src_filter = ${Generic_E22.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${Generic_E22.build_flags}
-D RADIO_CLASS=CustomSX1262
@@ -52,6 +54,7 @@ extends = Generic_E22
build_src_filter = ${Generic_E22.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${Generic_E22.build_flags}
-D RADIO_CLASS=CustomSX1268
@@ -72,30 +75,6 @@ lib_deps =
${Generic_E22.lib_deps}
${esp32_ota.lib_deps}
[env:Heltec_ct62_repeater_mqtt]
extends = Heltec_ct62
build_flags =
${Heltec_ct62.build_flags}
;-D ARDUINO_USB_MODE=1
;-D ARDUINO_USB_CDC_ON_BOOT=1
-D ADVERT_NAME='"HT-CT62 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_UPLINK=1
-D MQTT_DEBUG=1
-D WIFI_SSID='"myssid"'
-D WIFI_PWD='"mypwd"'
-D MQTT_DEFAULT_IATA='"MEL"'
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1
build_src_filter = ${Heltec_ct62.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
lib_deps =
${Heltec_ct62.lib_deps}
${esp32_ota.lib_deps}
[env:Heltec_E213_repeater_mqtt]
extends = Heltec_E213_base
build_flags =
@@ -116,6 +95,7 @@ build_src_filter = ${Heltec_E213_base.build_src_filter}
+<helpers/ui/E213Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Heltec_E213_base.lib_deps}
${esp32_ota.lib_deps}
@@ -141,6 +121,7 @@ build_src_filter = ${Heltec_E290_base.build_src_filter}
+<helpers/ui/E290Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Heltec_E290_base.lib_deps}
${esp32_ota.lib_deps}
@@ -164,6 +145,7 @@ build_flags =
build_src_filter = ${Heltec_T190_base.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Heltec_T190_base.lib_deps}
${esp32_ota.lib_deps}
@@ -188,6 +170,7 @@ build_src_filter = ${Heltec_tracker_v2.build_src_filter}
+<helpers/ui/ST7735Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Heltec_tracker_v2.lib_deps}
${esp32_ota.lib_deps}
@@ -214,6 +197,7 @@ build_src_filter = ${Heltec_lora32_v2.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Heltec_lora32_v2.lib_deps}
${esp32_ota.lib_deps}
@@ -238,6 +222,7 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Heltec_lora32_v3.lib_deps}
${esp32_ota.lib_deps}
@@ -262,6 +247,7 @@ build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
build_src_filter = ${heltec_v4_oled.build_src_filter}
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
lib_deps =
@@ -289,6 +275,7 @@ build_src_filter = ${heltec_v4_tft.build_src_filter}
+<helpers/ui/ST7789LCDDisplay.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${heltec_v4_tft.lib_deps}
${esp32_ota.lib_deps}
@@ -314,6 +301,7 @@ build_src_filter = ${Heltec_Wireless_Paper_base.build_src_filter}
+<helpers/ui/E213Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Heltec_Wireless_Paper_base.lib_deps}
${esp32_ota.lib_deps}
@@ -340,6 +328,7 @@ build_src_filter = ${Heltec_tracker_base.build_src_filter}
+<helpers/ui/ST7735Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Heltec_tracker_base.lib_deps}
${esp32_ota.lib_deps}
@@ -362,6 +351,7 @@ build_flags =
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Heltec_lora32_v3.lib_deps}
${esp32_ota.lib_deps}
@@ -387,6 +377,7 @@ build_src_filter = ${LilyGo_T3S3_sx1262.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${LilyGo_T3S3_sx1262.lib_deps}
${esp32_ota.lib_deps}
@@ -411,6 +402,7 @@ build_src_filter = ${LilyGo_T3S3_sx1276.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${LilyGo_T3S3_sx1276.lib_deps}
${esp32_ota.lib_deps}
@@ -435,6 +427,7 @@ build_flags =
build_src_filter = ${LilyGo_TBeam_1W.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${LilyGo_TBeam_1W.lib_deps}
${esp32_ota.lib_deps}
@@ -458,6 +451,7 @@ build_src_filter = ${LilyGo_TDeck.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/ST7789LCDDisplay.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
${esp32_ota.lib_deps}
@@ -467,6 +461,7 @@ extends = tlora_c6
build_src_filter = ${tlora_c6.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${tlora_c6.build_flags}
-D ADVERT_NAME='"Tlora C6 Repeater"'
@@ -488,6 +483,7 @@ extends = LilyGo_TLora_V2_1_1_6
build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${LilyGo_TLora_V2_1_1_6.build_flags}
-D ADVERT_NAME='"TLora-V2.1-1.6 Repeater"'
@@ -510,6 +506,7 @@ extends = M5Stack_Unit_C6L
build_src_filter = ${M5Stack_Unit_C6L.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${M5Stack_Unit_C6L.build_flags}
-D ADVERT_NAME='"Unit C6L Repeater"'
@@ -532,6 +529,7 @@ build_src_filter = ${Meshadventurer.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${Meshadventurer.build_flags}
-D RADIO_CLASS=CustomSX1262
@@ -558,6 +556,7 @@ build_src_filter = ${Meshadventurer.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${Meshadventurer.build_flags}
-D RADIO_CLASS=CustomSX1268
@@ -583,6 +582,7 @@ extends = Meshimi
build_src_filter = ${Meshimi.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${Meshimi.build_flags}
-D ADVERT_NAME='"Meshimi Repeater"'
@@ -619,6 +619,7 @@ build_src_filter = ${nibble_screen_connect_base.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${nibble_screen_connect_base.lib_deps}
${esp32_ota.lib_deps}
@@ -641,6 +642,7 @@ build_flags =
build_src_filter = ${rak3112.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${rak3112.lib_deps}
${esp32_ota.lib_deps}
@@ -666,6 +668,7 @@ build_flags =
build_src_filter = ${Station_G2.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Station_G2.lib_deps}
${esp32_ota.lib_deps}
@@ -688,6 +691,7 @@ build_flags =
build_src_filter = ${Station_G2.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${Station_G2.lib_deps}
${esp32_ota.lib_deps}
@@ -710,6 +714,7 @@ build_flags =
build_src_filter = ${T_Beam_S3_Supreme_SX1262.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${T_Beam_S3_Supreme_SX1262.lib_deps}
${esp32_ota.lib_deps}
@@ -732,6 +737,7 @@ build_flags =
build_src_filter = ${LilyGo_TBeam_SX1262.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${LilyGo_TBeam_SX1262.lib_deps}
${esp32_ota.lib_deps}
@@ -755,66 +761,17 @@ build_flags =
build_src_filter = ${LilyGo_TBeam_SX1276.build_src_filter}
+<../examples/simple_repeater>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
lib_deps =
${LilyGo_TBeam_SX1276.lib_deps}
${esp32_ota.lib_deps}
[env:Tenstar_C3_sx1262_repeater_mqtt]
extends = Tenstar_esp32_C3
build_src_filter = ${Tenstar_esp32_C3.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
build_flags =
${Tenstar_esp32_C3.build_flags}
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D SX126X_RX_BOOSTED_GAIN=1
-D LORA_TX_POWER=22
-D ADVERT_NAME='"Tenstar C3 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_UPLINK=1
-D MQTT_DEBUG=1
-D WIFI_SSID='"myssid"'
-D WIFI_PWD='"mypwd"'
-D MQTT_DEFAULT_IATA='"MEL"'
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1
lib_deps =
${Tenstar_esp32_C3.lib_deps}
${esp32_ota.lib_deps}
[env:Tenstar_C3_sx1268_repeater_mqtt]
extends = Tenstar_esp32_C3
build_src_filter = ${Tenstar_esp32_C3.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
build_flags =
${Tenstar_esp32_C3.build_flags}
-D RADIO_CLASS=CustomSX1268
-D WRAPPER_CLASS=CustomSX1268Wrapper
-D LORA_TX_POWER=22
-D ADVERT_NAME='"Tenstar C3 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_UPLINK=1
-D MQTT_DEBUG=1
-D WIFI_SSID='"myssid"'
-D WIFI_PWD='"mypwd"'
-D MQTT_DEFAULT_IATA='"MEL"'
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1
lib_deps =
${Tenstar_esp32_C3.lib_deps}
${esp32_ota.lib_deps}
[env:ThinkNode_M2_Repeater_mqtt]
extends = ThinkNode_M2
build_src_filter = ${ThinkNode_M2.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${ThinkNode_M2.build_flags}
-D ADVERT_NAME='"Thinknode M2 Repeater"'
@@ -837,6 +794,7 @@ extends = ThinkNode_M5
build_src_filter = ${ThinkNode_M5.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${ThinkNode_M5.build_flags}
-D ADVERT_NAME='"Thinknode M5 Repeater"'
@@ -859,6 +817,7 @@ extends = WHY2025_badge
build_src_filter = ${WHY2025_badge.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${WHY2025_badge.build_flags}
-D ADVERT_NAME='"WHY2025 Badge Repeater"'
@@ -875,35 +834,12 @@ build_flags =
lib_deps =
${WHY2025_badge.lib_deps}
[env:Xiao_C3_repeater_mqtt]
extends = Xiao_esp32_C3
build_src_filter = ${Xiao_esp32_C3.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
build_flags =
${Xiao_esp32_C3.build_flags}
-D ADVERT_NAME='"Xiao C3 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_UPLINK=1
-D WITH_WEB_PANEL=0
-D MQTT_DEBUG=1
-D WIFI_SSID='"myssid"'
-D WIFI_PWD='"mypwd"'
-D MQTT_DEFAULT_IATA='"MEL"'
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1
lib_deps =
${Xiao_esp32_C3.lib_deps}
${esp32_ota.lib_deps}
bakercp/CRC32 @ ^2.0.0
[env:Xiao_C6_repeater_mqtt]
extends = Xiao_C6
build_src_filter = ${Xiao_C6.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${Xiao_C6.build_flags}
-D ADVERT_NAME='"Xiao C6 Repeater"'
@@ -925,6 +861,7 @@ extends = Xiao_S3_WIO
build_src_filter = ${Xiao_S3_WIO.build_src_filter}
+<../examples/simple_repeater/*.cpp>
+<helpers/mqtt/*.cpp>
+<helpers/web/*.cpp>
build_flags =
${Xiao_S3_WIO.build_flags}
-D ADVERT_NAME='"XiaoS3 Repeater"'
+1 -2
Просмотреть файл
@@ -3,7 +3,7 @@
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/XiaoC3Board.h>
#include <XiaoC3Board.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <helpers/radiolib/CustomSX1268Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
@@ -19,4 +19,3 @@ uint32_t radio_get_rng_seed();
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
void radio_set_tx_power(int8_t dbm);
mesh::LocalIdentity radio_new_identity();