diff --git a/docs/api.md b/docs/api.md index f9313785..81811b0f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -102,6 +102,21 @@ Example: curl -sk -X POST https:///login --data '' ``` +### `GET /api/session` + +Check whether a stored session token is still valid. + +Headers: + +```text +X-Auth-Token: +``` + +Response: + +- JSON `{"authenticated":true}` on success +- `401 Unauthorized` if the token is missing or stale after lock, restart, or reflash + ### `POST /api/command` Run a repeater CLI command remotely. diff --git a/docs/custom-cli.md b/docs/custom-cli.md index 4469651c..0da69ef0 100644 --- a/docs/custom-cli.md +++ b/docs/custom-cli.md @@ -70,7 +70,7 @@ 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. When enabled, the history capture now covers supported environment telemetry too, not just the original battery/radio series. GPS-enabled boards also record per-minute satellites samples for the `/stats` history view. +- `get web.stats.status`: shows whether the dedicated stats page and history subsystem are enabled, whether recent history is active, whether PSRAM-backed history is available, and whether the SD-backed archive is mounted. When enabled, the history capture now covers supported environment telemetry too, not just the original battery/radio series. GPS-active boards also record per-minute satellites samples for the `/stats` history view. - `set web on|off` - `set.web on|off`: enables or disables the local HTTPS panel. - `set web.stats on|off` diff --git a/docs/web-panel.md b/docs/web-panel.md index 3eb2d7ba..dea1ac2d 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -72,6 +72,8 @@ Example: - the connection is HTTPS, but the certificate is self-signed - browsers will warn the first time you connect - the panel exposes the repeater CLI after login +- the browser keeps the session token while changing between `/app` and `/stats`; the page switches views in place until logout, idle lock, or server restart +- after a device restart or reflash, the app checks the stored token before loading settings and returns to login if the session is stale This is intended for local admin use on a trusted network, not for open internet exposure. @@ -229,13 +231,13 @@ The `/stats` page currently shows: - `Services`: MQTT, web, archive, neighbour count, and, when mounted, card and archive capacity - optional full-width `Environment` summary card on boards that report GPS or environmental telemetry -- `Trends`: battery, heap free, packet activity, signal, noise floor, and, when GPS is enabled, satellites +- `Trends`: battery, heap free, packet activity, signal, noise floor, and, when GPS is active, satellites - `Neighbours`: current neighbour table with ID, SNR, heard age, and advert age - `Events`: current boot/session events Planned MQTT JWT rotations are shown as `mqtt_token_refreshed` events instead of a short `mqtt_disconnected` / `mqtt_connected` pair. -For boards that expose extra telemetry, the optional `Environment` summary card can show current values such as GPS fix state, latitude, longitude, GPS altitude, voltage, sensor temperature, humidity, barometer, pressure-derived altitude, and MCU temperature. +For boards that expose extra telemetry, the optional `Environment` summary card can show current values such as GPS active/fix state, latitude, longitude, GPS altitude, voltage, sensor temperature, humidity, barometer, pressure-derived altitude, and MCU temperature. Metrics with no current value are hidden rather than showing placeholder rows, so the cards vary by board and by current sensor state. @@ -248,7 +250,7 @@ The trend graphs load sequentially rather than as one large payload: 3. memory 4. packet activity 5. signal -6. satellites when GPS is enabled +6. satellites when GPS is active This keeps browser-side and device-side memory use lower than the previous in-page stats view. @@ -267,13 +269,13 @@ Current in-memory history caps are: | `4 MB` to less than `8 MB` PSRAM | `480` | `192` | About `8` hours | | `8 MB` PSRAM or more | `720` | `288` | About `12` hours | -On boards with roughly `2 MB` PSRAM or more, stats history starts capturing from boot when `web.stats` is enabled, even if `/stats` has not been opened yet. +When `web.stats` is enabled, stats history starts capturing from boot or from the time it is enabled, even if `/stats` has not been opened yet. No-PSRAM boards use the smaller live-only buffer shown above. Archive-backed restore requires `web.stats` enabled plus a mounted SD card on boards that support the EastMesh archive path. The main purpose of the SD card is to let the repeater retain and restore stats history for `/stats`. The archive keeps fast `.latest` snapshot files for quick restore and UTC-dated daily `.log` files for longer-term history. As a secondary option, those files can also be removed and inspected on a computer for deeper manual review. -On no-PSRAM boards, `/stats` can still show recent graphs while the stats view is active, but the history is smaller and does not provide the same archive-backed behaviour as PSRAM-capable boards. +On no-PSRAM boards, `/stats` can still show recent graphs, but the history is smaller and does not provide the same archive-backed behaviour as PSRAM-capable boards. Useful CLI commands: diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index fb6a6586..4b2a0714 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -55,10 +55,13 @@ WebSensorSnapshot collectWebSensorSnapshot(mesh::MainBoard& board, SensorManager LocationProvider* location = sensors.getLocationProvider(); if (location != nullptr) { snapshot.has_gps = true; - snapshot.gps_enabled = location->isEnabled(); - snapshot.gps_fix = location->isValid(); + const bool provider_enabled = location->isEnabled(); + const char* gps_setting = sensors.getSettingByKey("gps"); + const bool setting_active = (gps_setting == nullptr) || (strcmp(gps_setting, "1") == 0); + snapshot.gps_enabled = provider_enabled && setting_active; + snapshot.gps_fix = snapshot.gps_enabled && location->isValid(); const long satellites = location->satellitesCount(); - if (snapshot.gps_enabled || satellites > 0) { + if (snapshot.gps_enabled) { snapshot.has_satellites = true; snapshot.satellites = max(satellites, 0); } diff --git a/release-notes.yml b/release-notes.yml index 64ad8772..854b0504 100644 --- a/release-notes.yml +++ b/release-notes.yml @@ -527,3 +527,33 @@ releases: area: readme text: "Updated the README to describe EastMesh as an Eastern Australia community mesh network, introduce CoreScope telemetry, and warn that MQTT repeater builds are intended for Eastern Australia repeaters only." breaking_changes: [] + + - track: repeater-mqtt + version: "1.3.14" + tag: "repeater-mqtt-eastmesh-v1.3.14" + date: "2026-04-30" + previous_version: "1.3.13" + summary: "Fixes `/stats` GPS visibility and no-PSRAM trend capture, improves web-panel session recovery and App/Stats navigation on constrained boards, quiets absent I2C sensor probes, and prevents truncated MQTT offline status payloads." + changes: + - type: fixed + area: stats + text: "Updated `/stats` GPS gauges and satellite trends to respect the active GPS setting so boards that report GPS hardware as on but deactivated after `gps off` no longer keep satellite metrics visible." + - type: fixed + area: stats + text: "Allowed no-PSRAM boards to collect the small live `/stats` history buffer whenever `web.stats` is enabled, so Heltec v3 targets can show recent trend samples without archive storage." + - type: fixed + area: web-panel + text: "Improved App/Stats navigation and session recovery by preserving the login token across panel views, validating stale browser tokens before loading settings, and clearing expired sessions quickly after a reflash." + - type: fixed + area: web-panel + text: "Delayed the red MQTT IATA warning until the panel has loaded the configured value, avoiding a misleading unset banner while `/app` is still fetching settings." + - type: fixed + area: sensors + text: "Probed optional I2C environmental sensor addresses before driver initialization so boards without those sensors no longer emit repeated Wire read errors during boot." + - type: fixed + area: mqtt + text: "Increased the MQTT offline last-will payload buffer so the retained offline status JSON is not truncated when release metadata and identity fields are near their configured limits." + - type: docs + area: web-panel + text: "Documented no-PSRAM `/stats` history behavior, stale browser-token recovery, and the lightweight `/api/session` endpoint used by the web panel." + breaking_changes: [] diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index 538d2dbe..a39a8bc4 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -537,7 +537,7 @@ bool StatsHistory::supportsPersistence() const { } bool StatsHistory::shouldAutoActivateFromBoot() const { - return _enabled && !_live_only && hasBootAutoCapturePsram(); + return _enabled && (_live_only || hasBootAutoCapturePsram()); } bool StatsHistory::isBootAutoCaptureExpected() const { @@ -556,7 +556,7 @@ bool StatsHistory::isAccessActive(uint32_t now_ms) const { if (!_live_only) { return true; } - return _last_access_ms != 0 && (now_ms - _last_access_ms) < kLiveOnlyIdleTimeoutMs; + return _activated || (_last_access_ms != 0 && (now_ms - _last_access_ms) < kLiveOnlyIdleTimeoutMs); } void StatsHistory::noteAccess(uint32_t now_ms) { diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index 619f7907..33a1ff56 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -94,7 +94,7 @@ private: char* token; char client_id[48]; char status_topic[128]; - char offline_payload[256]; + char offline_payload[512]; }; #endif diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 19472406..da63837d 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -1,5 +1,7 @@ #include "EnvironmentSensorManager.h" +#include + #if ENV_PIN_SDA && ENV_PIN_SCL #define TELEM_WIRE &Wire1 // Use Wire1 as the I2C bus for Environment Sensors #else @@ -161,6 +163,18 @@ public: static RAK12500LocationProvider RAK12500_provider; #endif +namespace { + +bool i2cDevicePresent(TwoWire* wire, uint8_t address) { + if (wire == nullptr) { + return false; + } + wire->beginTransmission(address); + return wire->endTransmission() == 0; +} + +} // namespace + bool EnvironmentSensorManager::begin() { #if ENV_INCLUDE_GPS #ifdef RAK_WISBLOCK_GPS @@ -182,7 +196,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_AHTX0 - if (AHTX0.begin(TELEM_WIRE, 0, TELEM_AHTX_ADDRESS)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_AHTX_ADDRESS) && AHTX0.begin(TELEM_WIRE, 0, TELEM_AHTX_ADDRESS)) { MESH_DEBUG_PRINTLN("Found AHT10/AHT20 at address: %02X", TELEM_AHTX_ADDRESS); AHTX0_initialized = true; } else { @@ -192,7 +206,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_BME680 - if (BME680.begin(TELEM_BME680_ADDRESS)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_BME680_ADDRESS) && BME680.begin(TELEM_BME680_ADDRESS)) { MESH_DEBUG_PRINTLN("Found BME680 at address: %02X", TELEM_BME680_ADDRESS); BME680_initialized = true; } else { @@ -202,7 +216,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_BME280 - if (BME280.begin(TELEM_BME280_ADDRESS, TELEM_WIRE)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_BME280_ADDRESS) && BME280.begin(TELEM_BME280_ADDRESS, TELEM_WIRE)) { MESH_DEBUG_PRINTLN("Found BME280 at address: %02X", TELEM_BME280_ADDRESS); MESH_DEBUG_PRINTLN("BME sensor ID: %02X", BME280.sensorID()); // Reduce self-heating: single-shot conversions, light oversampling, long standby. @@ -220,7 +234,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_BMP280 - if (BMP280.begin(TELEM_BMP280_ADDRESS)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_BMP280_ADDRESS) && BMP280.begin(TELEM_BMP280_ADDRESS)) { MESH_DEBUG_PRINTLN("Found BMP280 at address: %02X", TELEM_BMP280_ADDRESS); MESH_DEBUG_PRINTLN("BMP sensor ID: %02X", BMP280.sensorID()); BMP280_initialized = true; @@ -231,7 +245,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_SHTC3 - if (SHTC3.begin(TELEM_WIRE)) { + if (i2cDevicePresent(TELEM_WIRE, 0x70) && SHTC3.begin(TELEM_WIRE)) { MESH_DEBUG_PRINTLN("Found sensor: SHTC3"); SHTC3_initialized = true; } else { @@ -242,10 +256,14 @@ bool EnvironmentSensorManager::begin() { #if ENV_INCLUDE_SHT4X - SHT4X.begin(*TELEM_WIRE, TELEM_SHT4X_ADDRESS); uint32_t serialNumber = 0; int16_t sht4x_error; - sht4x_error = SHT4X.serialNumber(serialNumber); + if (i2cDevicePresent(TELEM_WIRE, TELEM_SHT4X_ADDRESS)) { + SHT4X.begin(*TELEM_WIRE, TELEM_SHT4X_ADDRESS); + sht4x_error = SHT4X.serialNumber(serialNumber); + } else { + sht4x_error = -1; + } if (sht4x_error == 0) { MESH_DEBUG_PRINTLN("Found SHT4X at address: %02X", TELEM_SHT4X_ADDRESS); SHT4X_initialized = true; @@ -256,7 +274,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_LPS22HB - if (LPS22HB.begin()) { + if (i2cDevicePresent(TELEM_WIRE, 0x5C) && LPS22HB.begin()) { MESH_DEBUG_PRINTLN("Found sensor: LPS22HB"); LPS22HB_initialized = true; } else { @@ -266,7 +284,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_INA3221 - if (INA3221.begin(TELEM_INA3221_ADDRESS, TELEM_WIRE)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_INA3221_ADDRESS) && INA3221.begin(TELEM_INA3221_ADDRESS, TELEM_WIRE)) { MESH_DEBUG_PRINTLN("Found INA3221 at address: %02X", TELEM_INA3221_ADDRESS); MESH_DEBUG_PRINTLN("%04X %04X", INA3221.getDieID(), INA3221.getManufacturerID()); @@ -281,7 +299,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_INA219 - if (INA219.begin(TELEM_WIRE)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_INA219_ADDRESS) && INA219.begin(TELEM_WIRE)) { MESH_DEBUG_PRINTLN("Found INA219 at address: %02X", TELEM_INA219_ADDRESS); INA219_initialized = true; } else { @@ -291,7 +309,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_INA260 - if (INA260.begin(TELEM_INA260_ADDRESS, TELEM_WIRE)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_INA260_ADDRESS) && INA260.begin(TELEM_INA260_ADDRESS, TELEM_WIRE)) { MESH_DEBUG_PRINTLN("Found INA260 at address: %02X", TELEM_INA260_ADDRESS); INA260_initialized = true; } else { @@ -301,7 +319,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_INA226 - if (INA226.begin()) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_INA226_ADDRESS) && INA226.begin()) { MESH_DEBUG_PRINTLN("Found INA226 at address: %02X", TELEM_INA226_ADDRESS); INA226.setMaxCurrentShunt(TELEM_INA226_MAX_AMP, TELEM_INA226_SHUNT_VALUE); INA226_initialized = true; @@ -312,7 +330,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_MLX90614 - if (MLX90614.begin(TELEM_MLX90614_ADDRESS, TELEM_WIRE)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_MLX90614_ADDRESS) && MLX90614.begin(TELEM_MLX90614_ADDRESS, TELEM_WIRE)) { MESH_DEBUG_PRINTLN("Found MLX90614 at address: %02X", TELEM_MLX90614_ADDRESS); MLX90614_initialized = true; } else { @@ -322,7 +340,7 @@ bool EnvironmentSensorManager::begin() { #endif #if ENV_INCLUDE_VL53L0X - if (VL53L0X.begin(TELEM_VL53L0X_ADDRESS, false, TELEM_WIRE)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_VL53L0X_ADDRESS) && VL53L0X.begin(TELEM_VL53L0X_ADDRESS, false, TELEM_WIRE)) { MESH_DEBUG_PRINTLN("Found VL53L0X at address: %02X", TELEM_VL53L0X_ADDRESS); VL53L0X_initialized = true; } else { @@ -334,7 +352,7 @@ bool EnvironmentSensorManager::begin() { #if ENV_INCLUDE_BMP085 // First argument is MODE (aka oversampling) // choose ULTRALOWPOWER - if (BMP085.begin(0, TELEM_WIRE)) { + if (i2cDevicePresent(TELEM_WIRE, 0x77) && BMP085.begin(0, TELEM_WIRE)) { MESH_DEBUG_PRINTLN("Found sensor BMP085"); BMP085_initialized = true; } else { @@ -345,7 +363,7 @@ bool EnvironmentSensorManager::begin() { #if ENV_INCLUDE_RAK12035 RAK12035.setup(*TELEM_WIRE); - if (RAK12035.begin(TELEM_RAK12035_ADDRESS)) { + if (i2cDevicePresent(TELEM_WIRE, TELEM_RAK12035_ADDRESS) && RAK12035.begin(TELEM_RAK12035_ADDRESS)) { MESH_DEBUG_PRINTLN("Found sensor RAK12035 at address: %02X", TELEM_RAK12035_ADDRESS); RAK12035_initialized = true; } else { diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 39e74c5e..21881eec 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -255,7 +255,12 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML( @@ -2737,12 +2816,14 @@ bool WebPanelServer::start() { httpd_uri_t app_uri = {.uri = "/app", .method = HTTP_GET, .handler = &WebPanelServer::handleApp, .user_ctx = &_route_context}; httpd_uri_t stats_page_uri = {.uri = "/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStatsPage, .user_ctx = &_route_context}; httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &WebPanelServer::handleLogin, .user_ctx = &_route_context}; + httpd_uri_t session_uri = {.uri = "/api/session", .method = HTTP_GET, .handler = &WebPanelServer::handleSession, .user_ctx = &_route_context}; httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &WebPanelServer::handleCommand, .user_ctx = &_route_context}; httpd_uri_t stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStats, .user_ctx = &_route_context}; httpd_register_uri_handler(_server, &index_uri); httpd_register_uri_handler(_server, &app_uri); httpd_register_uri_handler(_server, &stats_page_uri); httpd_register_uri_handler(_server, &login_uri); + httpd_register_uri_handler(_server, &session_uri); httpd_register_uri_handler(_server, &command_uri); httpd_register_uri_handler(_server, &stats_uri); @@ -2894,6 +2975,21 @@ esp_err_t WebPanelServer::handleLogin(httpd_req_t* req) { return httpd_resp_sendstr(req, ctx->self->_token); } +esp_err_t WebPanelServer::handleSession(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr) { + return httpd_resp_send_500(req); + } + if (!ctx->self->isAuthorized(req)) { + return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); + } + + ctx->self->noteActivity(); + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_sendstr(req, "{\"authenticated\":true}"); +} + esp_err_t WebPanelServer::handleCommand(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) { diff --git a/src/helpers/web/WebPanelServer.h b/src/helpers/web/WebPanelServer.h index 7c76ed08..e1d0ce58 100644 --- a/src/helpers/web/WebPanelServer.h +++ b/src/helpers/web/WebPanelServer.h @@ -65,6 +65,7 @@ private: 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 handleSession(httpd_req_t* req); static esp_err_t handleCommand(httpd_req_t* req); static esp_err_t handleStats(httpd_req_t* req);