diff --git a/eastmesh-docs/custom-cli.md b/eastmesh-docs/custom-cli.md index b7bb0616..ca777c08 100644 --- a/eastmesh-docs/custom-cli.md +++ b/eastmesh-docs/custom-cli.md @@ -142,10 +142,12 @@ OK - `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-active boards also record per-minute satellites samples for the `/stats` history view. If archive access drops while stats remain enabled, the repeater retries the SD mount periodically. +- At boot, archive-backed stats restore uses bounded reads of the latest summary, events, and neighbour snapshots so malformed or unexpectedly large archive files do not delay MQTT or web startup. - `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. +- `purge sd`: deletes files and directories from the mounted SD card archive, then recreates the empty `/stats` archive directory so runtime stats capture can continue. It does not erase the internal repeater filesystem or stored settings. ### Runtime Diagnostics @@ -204,6 +206,8 @@ Notes: - commands run with the same care as if you typed them into the repeater CLI directly - this is intended for local admin use on a trusted network - `start ota` releases the local HTTP redirect listener on port `80` so the OTA HTTP listener can take over without stopping the rest of the repeater services, regardless of whether the command is run from the web panel, serial CLI, or a remote companion/app CLI session +- if the old redirect listener has not fully released port `80`, the OTA listener retries for up to about 30 seconds before giving up +- the web `Purge SD` button runs `purge sd` after browser confirmation - `start ota` uses the repeater's existing Wi-Fi address when already connected, or starts the `MeshCore-OTA` access point when Wi-Fi is not connected - the `/app` Regions shortcut runs the existing MeshCore region commands in sequence: `region put au`, `region put au-STATE`, `region allowf au`, `region allowf au-STATE`, then `region save` diff --git a/eastmesh-docs/web-panel.md b/eastmesh-docs/web-panel.md index a90dced8..32dec770 100644 --- a/eastmesh-docs/web-panel.md +++ b/eastmesh-docs/web-panel.md @@ -348,14 +348,21 @@ On mobile: 1. Press `Start OTA`. 2. Confirm the action. -3. The panel starts OTA, waits briefly for the OTA HTTP listener to take over port `80`, then opens the returned `http://.../update` URL. +3. The panel starts OTA, waits for the OTA HTTP listener to take over port `80`, then opens the returned `http://.../update` URL. 4. The local HTTP redirect listener on port `80` is released so OTA can take over that port. 5. Continue with your normal OTA workflow. If the repeater is already connected to Wi-Fi, OTA uses the existing LAN address. If Wi-Fi is not connected, the repeater starts the `MeshCore-OTA` access point and returns that OTA address instead. +If the old redirect listener has not fully released port `80`, the OTA listener retries for up to about 30 seconds before giving up. If an older build sends you through a strange redirect after `start ota`, use the web `Start OTA` button to begin the upgrade. This redirect issue is fixed in `observer-eastmesh-v1.3.11`. +### Purge SD + +`Purge SD` appears as red inline text next to `Archive Used` in the `/stats` Services card. It asks for confirmation, then runs the authenticated `purge sd` command. + +This deletes files and directories from the mounted SD card archive, then recreates the empty `/stats` archive directory for continued runtime capture. It does not erase the internal repeater filesystem, identity, prefs, regions, radio settings, or MQTT settings. + ### Use Historical Stats 1. Enable stats if needed with `set web.stats on`. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1c227cf5..b2f4a2a7 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1332,6 +1332,9 @@ void MyMesh::begin(FILESYSTEM *fs, ArchiveStorage* archive) { web.setCommandRunner(this); web.setNetworkStateProvider(&network); web.begin(_fs); + if (web.isWebEnabled()) { + network.loop(true); + } _stats_history.begin(web.isWebStatsEnabled(), _archive); if (web.isWebStatsEnabled() && !_stats_history.isLiveOnly() && _archive != nullptr && _archive->isMounted()) { restoreArchiveNeighbours(); @@ -1353,6 +1356,11 @@ void MyMesh::begin(FILESYSTEM *fs, ArchiveStorage* archive) { mqtt.setNetworkStateProvider(&network); #endif mqtt.begin(_fs); +#if defined(ESP_PLATFORM) + if (mqtt.isActive()) { + network.loop(true); + } +#endif #endif radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); @@ -1578,6 +1586,41 @@ void MyMesh::formatMemoryReply(char *reply, size_t reply_size) { StatsFormatHelper::formatMemoryStats(reply, reply_size); } +bool MyMesh::purgeSdCard(char* reply, size_t reply_size) { + if (reply != nullptr && reply_size > 0) { + reply[0] = 0; + } + if (_archive == nullptr || !_archive->isSupported()) { + if (reply != nullptr && reply_size > 0) { + snprintf(reply, reply_size, "Err - SD archive unsupported"); + } + return false; + } + if (!_archive->isMounted() && !_archive->recover()) { + if (reply != nullptr && reply_size > 0) { + snprintf(reply, reply_size, "Err - SD archive unavailable"); + } + return false; + } + + uint32_t files_removed = 0; + uint32_t dirs_removed = 0; + if (!_archive->purge(&files_removed, &dirs_removed)) { + if (reply != nullptr && reply_size > 0) { + snprintf(reply, reply_size, "Err - SD purge failed"); + } + return false; + } + + _archive_neighbours_dirty = true; + if (reply != nullptr && reply_size > 0) { + snprintf(reply, reply_size, "OK - SD purged files:%lu dirs:%lu", + static_cast(files_removed), + static_cast(dirs_removed)); + } + return true; +} + size_t MyMesh::getNeighbourCount() const { #if MAX_NEIGHBOURS size_t count = 0; @@ -1618,10 +1661,21 @@ bool MyMesh::restoreArchiveNeighbours() { return false; } + const size_t max_restore_bytes = static_cast(MAX_NEIGHBOURS) * 128U; + const size_t file_size = static_cast(file.size()); + if (file_size > max_restore_bytes) { + const size_t start = file_size - max_restore_bytes; + if (!file.seek(start)) { + file.close(); + return false; + } + } + memset(neighbours, 0, sizeof(neighbours)); char line[128]; size_t line_len = 0; size_t restored = 0; + bool skip_partial = file_size > max_restore_bytes; while (file.available()) { const int raw = file.read(); if (raw < 0) { @@ -1629,6 +1683,12 @@ bool MyMesh::restoreArchiveNeighbours() { } const char ch = static_cast(raw); + if (skip_partial) { + if (ch == '\n') { + skip_partial = false; + } + continue; + } if (ch == '\r') { continue; } @@ -2211,6 +2271,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply static_cast(_stats_history.getEventCount()), static_cast(_stats_history.getEventCapacity()), (_archive != nullptr && _archive->isMounted()) ? "mounted" : "unavailable"); + } else if (strcmp(command, "purge sd") == 0) { + purgeSdCard(reply, 160); #endif #if defined(TBEAM_1W) } else if (strcmp(command, "get fan") == 0) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 1b89222e..2842ff5c 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -297,6 +297,7 @@ public: void formatRadioStatsReply(char *reply, size_t reply_size) override; void formatPacketStatsReply(char *reply, size_t reply_size) override; void formatMemoryReply(char *reply, size_t reply_size) override; + bool purgeSdCard(char* reply, size_t reply_size); void prepareForOTAStart() override; void startRegionsLoad() override; bool saveRegions() override; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index d5bef9da..35b7d8ac 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -34,6 +34,9 @@ void setup() { delay(1000); board.begin(); + + sensors.begin(); + archive.begin(); #if defined(MESH_DEBUG) && defined(NRF52_PLATFORM) @@ -90,8 +93,6 @@ void setup() { command[0] = 0; - sensors.begin(); - the_mesh.begin(fs, &archive); #ifdef DISPLAY_CLASS diff --git a/release-notes.yml b/release-notes.yml index 5b832257..d548cdea 100644 --- a/release-notes.yml +++ b/release-notes.yml @@ -152,6 +152,33 @@ releases: text: "Updated EastMesh custom CLI docs for the new `flood.max.advert` command." breaking_changes: [] + - track: observer-eastmesh + version: "2026.6.2" + tag: "observer-eastmesh-v2026.6.2" + date: "2026-06-07" + previous_version: "2026.6.1" + summary: "Improves Observer boot reliability, T-Beam S3 Supreme telemetry, Wi-Fi reconnect speed, and web-panel archive maintenance." + changes: + - type: fixed + area: board + text: "Fixed LilyGo T-Beam S3 Supreme BME280 detection and telemetry reads by using the board-detected address and a stop-based I2C read path." + - type: fixed + area: wifi + text: "Improved boot-time Wi-Fi startup by starting Wi-Fi earlier and remembering the last connected AP channel, with automatic fallback to full scan if that channel is stale." + - type: fixed + area: cli + text: "Fixed `set wifi.powersaving none` returning an error when Wi-Fi power saving was already disabled." + - type: fixed + area: archive + text: "Bounded archive neighbour restore and history reads so large SD-card archive files no longer slow boot unnecessarily." + - type: added + area: web + text: "Added a web-panel and CLI SD purge action for clearing archive contents from supported SD-card targets." + - type: fixed + area: web + text: "Hardened web-panel OTA startup when the OTA listener port is still being released." + breaking_changes: [] + - track: observer-eastmesh-bridge-espnow version: "2026.5.1" tag: "observer-eastmesh-bridge-espnow-v2026.5.1" @@ -215,3 +242,30 @@ releases: area: docs text: "Updated EastMesh custom CLI docs for the new `flood.max.advert` command." breaking_changes: [] + + - track: observer-eastmesh-bridge-espnow + version: "2026.6.2" + tag: "observer-eastmesh-bridge-espnow-v2026.6.2" + date: "2026-06-07" + previous_version: "2026.6.1" + summary: "Applies the Observer boot, Wi-Fi, telemetry, archive, and web-panel maintenance fixes to ESP-NOW bridge builds." + changes: + - type: fixed + area: board + text: "Fixed LilyGo T-Beam S3 Supreme BME280 detection and telemetry reads by using the board-detected address and a stop-based I2C read path." + - type: fixed + area: wifi + text: "Improved boot-time Wi-Fi startup by starting Wi-Fi earlier and remembering the last connected AP channel, with automatic fallback to full scan if that channel is stale." + - type: fixed + area: cli + text: "Fixed `set wifi.powersaving none` returning an error when Wi-Fi power saving was already disabled." + - type: fixed + area: archive + text: "Bounded archive neighbour restore and history reads so large SD-card archive files no longer slow boot unnecessarily." + - type: added + area: web + text: "Added a web-panel and CLI SD purge action for clearing archive contents from supported SD-card targets." + - type: fixed + area: web + text: "Hardened web-panel OTA startup when the OTA listener port is still being released." + breaking_changes: [] diff --git a/src/helpers/ArchiveStorage.cpp b/src/helpers/ArchiveStorage.cpp index 978c2237..9e455385 100644 --- a/src/helpers/ArchiveStorage.cpp +++ b/src/helpers/ArchiveStorage.cpp @@ -48,6 +48,52 @@ void deassertChipSelect(int pin, uint8_t archive_cs_pin) { pinMode(static_cast(pin), OUTPUT); digitalWrite(static_cast(pin), HIGH); } + +bool removeArchivePath(FILESYSTEM* fs, const char* path, uint8_t depth, uint32_t& files_removed, uint32_t& dirs_removed) { + if (fs == nullptr || path == nullptr || path[0] == 0 || depth > 16) { + return false; + } + + File entry = fs->open(path, FILE_READ); + if (!entry) { + return false; + } + + if (!entry.isDirectory()) { + entry.close(); + if (fs->remove(path)) { + files_removed++; + return true; + } + return false; + } + + bool ok = true; + while (true) { + File child = entry.openNextFile(FILE_READ); + if (!child) { + break; + } + + char child_path[160]; + const int written = snprintf(child_path, sizeof(child_path), "%s", child.path()); + child.close(); + if (written <= 0 || static_cast(written) >= sizeof(child_path) || + !removeArchivePath(fs, child_path, depth + 1, files_removed, dirs_removed)) { + ok = false; + } + } + entry.close(); + + if (strcmp(path, "/") != 0) { + if (fs->rmdir(path)) { + dirs_removed++; + } else { + ok = false; + } + } + return ok; +} #endif } // namespace @@ -214,6 +260,42 @@ FILESYSTEM* ArchiveStorage::getFS() { #endif } +bool ArchiveStorage::purge(uint32_t* files_removed, uint32_t* dirs_removed) { +#if defined(ESP32) + if (!_mounted) { + return false; + } + + prepareBusForArchive(); + uint32_t removed_files = 0; + uint32_t removed_dirs = 0; + const bool ok = removeArchivePath(&SD, "/", 0, removed_files, removed_dirs); + if (!SD.exists(getFsStatsPath())) { + SD.mkdir(getFsStatsPath()); + } + refreshCardInfo(); + if (files_removed != nullptr) { + *files_removed = removed_files; + } + if (dirs_removed != nullptr) { + *dirs_removed = removed_dirs; + } + ARCHIVE_LOG("purged files=%lu dirs=%lu ok=%s", + static_cast(removed_files), + static_cast(removed_dirs), + ok ? "yes" : "no"); + return ok; +#else + if (files_removed != nullptr) { + *files_removed = 0; + } + if (dirs_removed != nullptr) { + *dirs_removed = 0; + } + return false; +#endif +} + const char* ArchiveStorage::getCardTypeName() const { #if defined(ESP32) if (!_mounted) { diff --git a/src/helpers/ArchiveStorage.h b/src/helpers/ArchiveStorage.h index 1ccbd712..0b3a5c6b 100644 --- a/src/helpers/ArchiveStorage.h +++ b/src/helpers/ArchiveStorage.h @@ -20,6 +20,7 @@ public: bool isAttempted() const { return _attempted; } bool isMounted() const { return _mounted; } bool hadMountFailure() const { return _mount_failed; } + bool purge(uint32_t* files_removed = nullptr, uint32_t* dirs_removed = nullptr); const char* getLogicalName() const { return "archive"; } const char* getLogicalStatsPath() const { return "archive:/stats"; } diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 9b5ccafc..4212786b 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -15,9 +15,12 @@ AsyncWebServer* ota_server = nullptr; char ota_id_buf[60]; char ota_home_buf[90]; volatile bool ota_start_pending = false; +constexpr uint8_t kOtaStartAttempts = 60; +constexpr uint32_t kOtaStartRetryDelayMs = 500; -void startOTAServerNow() { +bool startOTAServerNow() { if (ota_server != nullptr) { + ota_server->end(); delete ota_server; ota_server = nullptr; } @@ -34,11 +37,28 @@ void startOTAServerNow() { AsyncElegantOTA.setID(ota_id_buf); AsyncElegantOTA.begin(ota_server); // Start ElegantOTA ota_server->begin(); + if (ota_server->state() != LISTEN) { + ota_server->end(); + delete ota_server; + ota_server = nullptr; + return false; + } + return true; } void startOTAServerTask(void*) { - vTaskDelay(pdMS_TO_TICKS(750)); - startOTAServerNow(); + vTaskDelay(pdMS_TO_TICKS(1500)); + for (uint8_t attempt = 1; attempt <= kOtaStartAttempts; ++attempt) { + if (startOTAServerNow()) { + MESH_DEBUG_PRINTLN("OTA server listening on port 80"); + ota_start_pending = false; + vTaskDelete(nullptr); + return; + } + MESH_DEBUG_PRINTLN("OTA server port 80 busy, retry %u/%u", attempt, kOtaStartAttempts); + vTaskDelay(pdMS_TO_TICKS(kOtaStartRetryDelayMs)); + } + MESH_DEBUG_PRINTLN("OTA server failed to bind port 80"); ota_start_pending = false; vTaskDelete(nullptr); } @@ -68,8 +88,12 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { if (!ota_start_pending) { ota_start_pending = true; if (xTaskCreate(startOTAServerTask, "ota-start", 4096, nullptr, 1, nullptr) != pdPASS) { - startOTAServerNow(); + const bool started = startOTAServerNow(); ota_start_pending = false; + if (!started) { + strcpy(reply, "Error - OTA listener start failed"); + return false; + } } } diff --git a/src/helpers/NetworkPrefs.cpp b/src/helpers/NetworkPrefs.cpp index 9e4f0b3a..009eb14c 100644 --- a/src/helpers/NetworkPrefs.cpp +++ b/src/helpers/NetworkPrefs.cpp @@ -53,6 +53,7 @@ void NetworkPrefsStore::setDefaults(NetworkPrefs& prefs) { memset(&prefs, 0, sizeof(prefs)); prefs.magic = kMagic; prefs.wifi_powersave = 0; + prefs.wifi_channel = 0; } bool NetworkPrefsStore::load(FILESYSTEM* fs, NetworkPrefs& prefs, @@ -102,6 +103,9 @@ bool NetworkPrefsStore::load(FILESYSTEM* fs, NetworkPrefs& prefs, if (prefs.wifi_powersave > 2) { prefs.wifi_powersave = 0; } + if (prefs.wifi_channel < 1 || prefs.wifi_channel > 14) { + prefs.wifi_channel = 0; + } return true; } diff --git a/src/helpers/NetworkPrefs.h b/src/helpers/NetworkPrefs.h index 2e2da522..3f041392 100644 --- a/src/helpers/NetworkPrefs.h +++ b/src/helpers/NetworkPrefs.h @@ -6,7 +6,8 @@ struct NetworkPrefs { uint32_t magic; uint8_t wifi_powersave; - uint8_t reserved[3]; + uint8_t wifi_channel; + uint8_t reserved[2]; char wifi_ssid[33]; char wifi_pwd[65]; }; diff --git a/src/helpers/NetworkService.cpp b/src/helpers/NetworkService.cpp index d383881b..65ed6103 100644 --- a/src/helpers/NetworkService.cpp +++ b/src/helpers/NetworkService.cpp @@ -14,8 +14,13 @@ namespace { #if defined(ESP_PLATFORM) constexpr unsigned long kWifiRetryMillis = 15000; constexpr unsigned long kWifiConnectTimeoutMillis = 45000; +constexpr unsigned long kWifiChannelHintTimeoutMillis = 7000; constexpr time_t kMinSaneEpoch = 1735689600; // 2025-01-01T00:00:00Z +bool isValidWifiChannel(uint8_t channel) { + return channel >= 1 && channel <= 14; +} + int getWifiQualityPercent(int rssi_dbm) { if (rssi_dbm <= -100) { return 0; @@ -43,7 +48,7 @@ const char* getWifiQualityLabel(int rssi_dbm) { } // namespace NetworkService::NetworkService() - : _fs(nullptr), _prefs{}, _wifi_started(false), _sntp_started(false), _have_time_sync(false), _last_wifi_attempt(0) { + : _fs(nullptr), _prefs{}, _wifi_started(false), _sntp_started(false), _have_time_sync(false), _last_wifi_status(-1), _last_wifi_attempt(0) { NetworkPrefsStore::setDefaults(_prefs); } @@ -53,6 +58,12 @@ void NetworkService::begin(FILESYSTEM* fs, const char* legacy_wifi_pwd) { _fs = fs; NetworkPrefsStore::load(_fs, _prefs, legacy_wifi_powersave, legacy_wifi_ssid, legacy_wifi_pwd); +#if defined(ESP_PLATFORM) + Serial.printf("[BOOT] wifi prefs powersave=%s channel=%u ssid=%s\n", + getPowerSaveLabel(_prefs.wifi_powersave), + _prefs.wifi_channel, + _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-"); +#endif } void NetworkService::end() { @@ -65,6 +76,7 @@ void NetworkService::end() { _wifi_started = false; _sntp_started = false; _have_time_sync = false; + _last_wifi_status = -1; _last_wifi_attempt = 0; } @@ -86,6 +98,9 @@ bool NetworkService::setWifiSSID(const char* ssid) { return false; } StrHelper::strncpy(_prefs.wifi_ssid, ssid, sizeof(_prefs.wifi_ssid)); +#if defined(ESP_PLATFORM) + _prefs.wifi_channel = 0; +#endif bool ok = savePrefs(); reconnectWifi(); return ok; @@ -96,6 +111,9 @@ bool NetworkService::setWifiPassword(const char* pwd) { return false; } StrHelper::strncpy(_prefs.wifi_pwd, pwd, sizeof(_prefs.wifi_pwd)); +#if defined(ESP_PLATFORM) + _prefs.wifi_channel = 0; +#endif bool ok = savePrefs(); reconnectWifi(); return ok; @@ -106,22 +124,41 @@ bool NetworkService::setWifiPowerSave(const char* mode) { return false; } + char normalized[8]; + size_t len = 0; + while (*mode == ' ' || *mode == '\t') { + mode++; + } + while (len < sizeof(normalized) - 1) { + char c = mode[len]; + if (c == 0 || c == '\r' || c == '\n' || c == ' ' || c == '\t') { + break; + } + normalized[len] = c; + len++; + } + normalized[len] = 0; + uint8_t next_mode; - if (strcmp(mode, "none") == 0) { + if (strcmp(normalized, "none") == 0) { next_mode = 0; - } else if (strcmp(mode, "min") == 0) { + } else if (strcmp(normalized, "min") == 0) { next_mode = 1; - } else if (strcmp(mode, "max") == 0) { + } else if (strcmp(normalized, "max") == 0) { next_mode = 2; } else { return false; } + if (_prefs.wifi_powersave == next_mode) { + return true; + } + _prefs.wifi_powersave = next_mode; bool ok = savePrefs(); #if defined(ESP_PLATFORM) if (_wifi_started) { - ok = WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)) && ok; + WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)); } #endif return ok; @@ -244,6 +281,7 @@ void NetworkService::ensureWifi(bool network_required) { _wifi_started = false; _sntp_started = false; _have_time_sync = false; + _last_wifi_status = -1; } return; } @@ -253,13 +291,42 @@ void NetworkService::ensureWifi(bool network_required) { return; } - if (WiFi.status() == WL_CONNECTED) { + wl_status_t status = WiFi.status(); + if (static_cast(status) != _last_wifi_status) { + _last_wifi_status = static_cast(status); + if (status == WL_CONNECTED) { + const int connected_channel = WiFi.channel(); + Serial.printf("[BOOT] wifi connected t=%lu ip=%s rssi=%d channel=%d\n", + static_cast(millis()), + WiFi.localIP().toString().c_str(), + WiFi.RSSI(), + connected_channel); + if (connected_channel > 0 && connected_channel <= 14 && _prefs.wifi_channel != connected_channel) { + _prefs.wifi_channel = static_cast(connected_channel); + Serial.printf("[BOOT] wifi learned channel=%u save=%s\n", + _prefs.wifi_channel, + savePrefs() ? "ok" : "failed"); + } + } + } + + if (status == WL_CONNECTED) { return; } unsigned long now_ms = millis(); - wl_status_t status = WiFi.status(); if (_wifi_started) { + if (isValidWifiChannel(_prefs.wifi_channel) && + _last_wifi_attempt != 0 && + now_ms - _last_wifi_attempt >= kWifiChannelHintTimeoutMillis) { + Serial.printf("[BOOT] wifi channel hint timeout t=%lu channel=%u\n", + static_cast(millis()), + _prefs.wifi_channel); + _prefs.wifi_channel = 0; + savePrefs(); + WiFi.disconnect(false, false); + _last_wifi_attempt = 0; + } if (_last_wifi_attempt != 0 && status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) { return; } @@ -272,10 +339,17 @@ void NetworkService::ensureWifi(bool network_required) { WiFi.mode(WIFI_STA); WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)); _wifi_started = true; + Serial.printf("[BOOT] wifi start t=%lu\n", static_cast(millis())); } _last_wifi_attempt = now_ms; - WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd); + if (isValidWifiChannel(_prefs.wifi_channel)) { + WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd, _prefs.wifi_channel); + Serial.printf("[BOOT] wifi begin t=%lu channel=%u\n", static_cast(millis()), _prefs.wifi_channel); + } else { + WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd); + Serial.printf("[BOOT] wifi begin t=%lu channel=scan\n", static_cast(millis())); + } } void NetworkService::updateTimeSync() { diff --git a/src/helpers/NetworkService.h b/src/helpers/NetworkService.h index 350bd20e..470e8b76 100644 --- a/src/helpers/NetworkService.h +++ b/src/helpers/NetworkService.h @@ -46,5 +46,6 @@ private: bool _wifi_started; bool _sntp_started; bool _have_time_sync; + int _last_wifi_status; unsigned long _last_wifi_attempt; }; diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index a39a8bc4..8740ec96 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -783,6 +783,7 @@ bool StatsHistory::restoreSummaryLog() { if (latest_file) { char line[384]; size_t line_len = 0; + bool line_too_long = false; while (latest_file.available()) { const int raw = latest_file.read(); if (raw < 0) { @@ -797,10 +798,17 @@ bool StatsHistory::restoreSummaryLog() { } if (line_len + 1 < sizeof(line)) { line[line_len++] = ch; + } else { + line_too_long = true; + break; } } line[line_len] = 0; latest_file.close(); + if (line_too_long) { + ARCHIVE_LOG("summary latest ignored: line too long path=%s", kSummaryLatestPath); + return false; + } HistorySample latest_sample{}; if (line_len > 0 && parseSummaryLine(line, latest_sample)) { storeSample(latest_sample, false); @@ -932,9 +940,10 @@ bool StatsHistory::restoreEventsLog() { } const size_t size = static_cast(file.size()); - const size_t start = (strcmp(events_restore_path, kEventsLatestPath) == 0) - ? 0 - : ((size > kEventsRestoreWindowBytes) ? (size - kEventsRestoreWindowBytes) : 0); + const bool is_latest_events = strcmp(events_restore_path, kEventsLatestPath) == 0; + const size_t latest_events_window = _event_capacity * 160U; + const size_t restore_window = is_latest_events ? latest_events_window : kEventsRestoreWindowBytes; + const size_t start = (size > restore_window) ? (size - restore_window) : 0; if (start > 0 && !file.seek(start)) { file.close(); return false; diff --git a/src/helpers/esp32/TBeamBoard.cpp b/src/helpers/esp32/TBeamBoard.cpp index 5f708d71..97196469 100644 --- a/src/helpers/esp32/TBeamBoard.cpp +++ b/src/helpers/esp32/TBeamBoard.cpp @@ -5,6 +5,17 @@ //#include uint32_t deviceOnline = 0x00; +static uint8_t detectedBME280Address = 0; + +uint8_t boardDetectedI2CSensorAddress(const char* name) { +#ifdef MESH_DEBUG + if (name != nullptr && strcmp(name, "BME280") == 0 && (deviceOnline & (1UL << 7))) { + return detectedBME280Address; + } +#endif + (void)name; + return 0; +} bool pmuInterrupt; static void setPmuFlag() @@ -40,6 +51,19 @@ void TBeamBoard::begin() { } #ifdef MESH_DEBUG +static uint8_t readI2CRegister8(TwoWire* w, uint8_t addr, uint8_t reg) +{ + w->beginTransmission(addr); + w->write(reg); + if (w->endTransmission(false) != 0) { + return 0; + } + if (w->requestFrom((uint8_t)addr, (uint8_t)1) != 1) { + return 0; + } + return w->read(); +} + void TBeamBoard::scanDevices(TwoWire *w) { uint8_t err, addr; @@ -56,8 +80,15 @@ void TBeamBoard::scanDevices(TwoWire *w) switch (addr) { case 0x77: case 0x76: - Serial.println("\tFound BME280 Sensor"); - deviceOnline |= BME280_ONLINE; + { + const uint8_t chipId = readI2CRegister8(w, addr, 0xD0); + if (chipId == 0x60) { + Serial.print("\tFound BME280 Sensor at address 0x"); + Serial.println(addr, HEX); + detectedBME280Address = addr; + deviceOnline |= BME280_ONLINE; + } + } break; case 0x34: Serial.println("\tFound AXP192/AXP2101 PMU"); diff --git a/src/helpers/esp32/TBeamBoard.h b/src/helpers/esp32/TBeamBoard.h index 6e29ee76..79f9f93f 100644 --- a/src/helpers/esp32/TBeamBoard.h +++ b/src/helpers/esp32/TBeamBoard.h @@ -33,7 +33,7 @@ #define P_BOARD_RTC_INT 14 //RTC Int pin //I2C Wire addresses - #define I2C_BME280_ADD 0x76 //BME280 sensor I2C address on Wire + #define I2C_BME280_ADD 0x77 //BME280 sensor I2C address on Wire #define I2C_OLED_ADD 0x3C //SH1106 OLED I2C address on Wire #define I2C_QMC6310U_ADD 0x1C //QMC6310U mag sensor I2C address on Wire diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 73842d9e..a2ec73f8 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -65,9 +65,11 @@ static Adafruit_AHTX0 AHTX0; #define TELEM_BME280_ADDRESS 0x76 // BME280 environmental sensor I2C address #endif #define TELEM_BME280_SEALEVELPRESSURE_HPA (1013.25) // Atmospheric pressure at sea level +#ifndef TBEAM_SUPREME_SX1262 #include static Adafruit_BME280 BME280; #endif +#endif #if ENV_INCLUDE_BMP280 #ifndef TELEM_BMP280_ADDRESS @@ -211,17 +213,71 @@ static RAK12500LocationProvider RAK12500_provider; #endif // ============================================================ -// I2C bus scanner -// Probes every valid address and records which ones ACK. +// I2C sensor probe +// Probes only the compiled-in sensor addresses. // This runs before any sensor library is touched, so a missing // or misbehaving device cannot stall or crash the boot sequence. // ============================================================ -static void scanI2CBus(TwoWire* wire, bool found[128]) { - for (uint8_t addr = 0x08; addr < 0x78; addr++) { - wire->beginTransmission(addr); - found[addr] = (wire->endTransmission() == 0); +static bool probeI2CAddress(TwoWire* wire, uint8_t addr) { + if (wire == nullptr) { + return false; } +#if defined(ESP32) + wire->setTimeOut(20); +#endif + wire->beginTransmission(addr); + return wire->endTransmission() == 0; +} + +static bool readI2CRegister8(TwoWire* wire, uint8_t addr, uint8_t reg, uint8_t* value) { + if (wire == nullptr || value == nullptr) { + return false; + } + wire->beginTransmission(addr); + wire->write(reg); + if (wire->endTransmission(true) != 0) { + return false; + } + if (wire->requestFrom((uint8_t)addr, (uint8_t)1) != 1) { + return false; + } + *value = wire->read(); + return true; +} + +static bool readI2CBuffer(TwoWire* wire, uint8_t addr, uint8_t reg, uint8_t* buffer, size_t len) { + if (wire == nullptr || buffer == nullptr || len == 0) { + return false; + } + wire->beginTransmission(addr); + wire->write(reg); + if (wire->endTransmission(true) != 0) { + return false; + } + if (wire->requestFrom((uint8_t)addr, (uint8_t)len) != len) { + return false; + } + for (size_t i = 0; i < len; i++) { + buffer[i] = wire->read(); + } + return true; +} + +static bool writeI2CRegister8(TwoWire* wire, uint8_t addr, uint8_t reg, uint8_t value) { + if (wire == nullptr) { + return false; + } + wire->beginTransmission(addr); + wire->write(reg); + wire->write(value); + return wire->endTransmission(true) == 0; +} + +uint8_t boardDetectedI2CSensorAddress(const char* name) __attribute__((weak)); +uint8_t boardDetectedI2CSensorAddress(const char* name) { + (void)name; + return 0; } // ============================================================ @@ -269,8 +325,151 @@ static void query_bme680(uint8_t ch, uint8_t, CayenneLPP& lpp) { #endif #if ENV_INCLUDE_BME280 +#ifdef TBEAM_SUPREME_SX1262 +struct BME280Calibration { + uint16_t dig_T1; + int16_t dig_T2; + int16_t dig_T3; + uint16_t dig_P1; + int16_t dig_P2; + int16_t dig_P3; + int16_t dig_P4; + int16_t dig_P5; + int16_t dig_P6; + int16_t dig_P7; + int16_t dig_P8; + int16_t dig_P9; + uint8_t dig_H1; + int16_t dig_H2; + uint8_t dig_H3; + int16_t dig_H4; + int16_t dig_H5; + int8_t dig_H6; +}; + +static BME280Calibration BME280Cal; +static TwoWire* BME280Wire = nullptr; +static uint8_t BME280Address = 0; +static int32_t BME280TFine = 0; + +static uint16_t u16le(const uint8_t* p) { + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); +} + +static int16_t s16le(const uint8_t* p) { + return (int16_t)u16le(p); +} + +static bool read_bme280_calibration(TwoWire* wire, uint8_t addr) { + uint8_t calib1[26] = {}; + uint8_t calib2[7] = {}; + if (!readI2CBuffer(wire, addr, 0x88, calib1, sizeof(calib1)) || + !readI2CBuffer(wire, addr, 0xE1, calib2, sizeof(calib2))) { + return false; + } + + BME280Cal.dig_T1 = u16le(&calib1[0]); + BME280Cal.dig_T2 = s16le(&calib1[2]); + BME280Cal.dig_T3 = s16le(&calib1[4]); + BME280Cal.dig_P1 = u16le(&calib1[6]); + BME280Cal.dig_P2 = s16le(&calib1[8]); + BME280Cal.dig_P3 = s16le(&calib1[10]); + BME280Cal.dig_P4 = s16le(&calib1[12]); + BME280Cal.dig_P5 = s16le(&calib1[14]); + BME280Cal.dig_P6 = s16le(&calib1[16]); + BME280Cal.dig_P7 = s16le(&calib1[18]); + BME280Cal.dig_P8 = s16le(&calib1[20]); + BME280Cal.dig_P9 = s16le(&calib1[22]); + BME280Cal.dig_H1 = calib1[25]; + BME280Cal.dig_H2 = s16le(&calib2[0]); + BME280Cal.dig_H3 = calib2[2]; + BME280Cal.dig_H4 = ((int16_t)((int8_t)calib2[3]) << 4) | (calib2[4] & 0x0F); + BME280Cal.dig_H5 = ((int16_t)((int8_t)calib2[5]) << 4) | (calib2[4] >> 4); + BME280Cal.dig_H6 = (int8_t)calib2[6]; + return BME280Cal.dig_T1 != 0 && BME280Cal.dig_P1 != 0; +} + +static float compensate_bme280_temperature(int32_t adc_T) { + int32_t var1 = ((((adc_T >> 3) - ((int32_t)BME280Cal.dig_T1 << 1))) * ((int32_t)BME280Cal.dig_T2)) >> 11; + int32_t var2 = (((((adc_T >> 4) - ((int32_t)BME280Cal.dig_T1)) * ((adc_T >> 4) - ((int32_t)BME280Cal.dig_T1))) >> 12) * + ((int32_t)BME280Cal.dig_T3)) >> 14; + BME280TFine = var1 + var2; + return ((BME280TFine * 5 + 128) >> 8) / 100.0f; +} + +static float compensate_bme280_pressure(int32_t adc_P) { + int64_t var1 = ((int64_t)BME280TFine) - 128000; + int64_t var2 = var1 * var1 * (int64_t)BME280Cal.dig_P6; + var2 = var2 + ((var1 * (int64_t)BME280Cal.dig_P5) << 17); + var2 = var2 + (((int64_t)BME280Cal.dig_P4) << 35); + var1 = ((var1 * var1 * (int64_t)BME280Cal.dig_P3) >> 8) + ((var1 * (int64_t)BME280Cal.dig_P2) << 12); + var1 = (((((int64_t)1) << 47) + var1)) * ((int64_t)BME280Cal.dig_P1) >> 33; + if (var1 == 0) { + return NAN; + } + int64_t p = 1048576 - adc_P; + p = (((p << 31) - var2) * 3125) / var1; + var1 = (((int64_t)BME280Cal.dig_P9) * (p >> 13) * (p >> 13)) >> 25; + var2 = (((int64_t)BME280Cal.dig_P8) * p) >> 19; + p = ((p + var1 + var2) >> 8) + (((int64_t)BME280Cal.dig_P7) << 4); + return (float)p / 256.0f; +} + +static float compensate_bme280_humidity(int32_t adc_H) { + int32_t v_x1_u32r = BME280TFine - 76800; + v_x1_u32r = (((((adc_H << 14) - (((int32_t)BME280Cal.dig_H4) << 20) - + (((int32_t)BME280Cal.dig_H5) * v_x1_u32r)) + 16384) >> 15) * + (((((((v_x1_u32r * ((int32_t)BME280Cal.dig_H6)) >> 10) * + (((v_x1_u32r * ((int32_t)BME280Cal.dig_H3)) >> 11) + 32768)) >> 10) + 2097152) * + ((int32_t)BME280Cal.dig_H2) + 8192) >> 14)); + v_x1_u32r = v_x1_u32r - (((((v_x1_u32r >> 15) * (v_x1_u32r >> 15)) >> 7) * ((int32_t)BME280Cal.dig_H1)) >> 4); + v_x1_u32r = v_x1_u32r < 0 ? 0 : v_x1_u32r; + v_x1_u32r = v_x1_u32r > 419430400 ? 419430400 : v_x1_u32r; + return (v_x1_u32r >> 12) / 1024.0f; +} + static uint8_t init_bme280(TwoWire* wire, uint8_t addr) { - if (!BME280.begin(addr, wire)) return 0; + uint8_t chip_id = 0; + const bool chip_id_ok = readI2CRegister8(wire, addr, 0xD0, &chip_id); + if (!chip_id_ok || chip_id != 0x60 || !read_bme280_calibration(wire, addr)) { + return 0; + } + BME280Wire = wire; + BME280Address = addr; + writeI2CRegister8(wire, addr, 0xF2, 0x01); // humidity oversampling x1 + writeI2CRegister8(wire, addr, 0xF5, 0x00); // filter off, standby 0.5 ms + return 1; +} + +static void query_bme280(uint8_t ch, uint8_t, CayenneLPP& lpp) { + if (BME280Wire == nullptr || BME280Address == 0) { + return; + } + if (!writeI2CRegister8(BME280Wire, BME280Address, 0xF2, 0x01) || + !writeI2CRegister8(BME280Wire, BME280Address, 0xF4, 0x25)) { // temp/pressure x1, forced mode + return; + } + delay(10); + uint8_t data[8] = {}; + if (!readI2CBuffer(BME280Wire, BME280Address, 0xF7, data, sizeof(data))) { + return; + } + const int32_t adc_P = ((int32_t)data[0] << 12) | ((int32_t)data[1] << 4) | (data[2] >> 4); + const int32_t adc_T = ((int32_t)data[3] << 12) | ((int32_t)data[4] << 4) | (data[5] >> 4); + const int32_t adc_H = ((int32_t)data[6] << 8) | data[7]; + const float temperature = compensate_bme280_temperature(adc_T); + const float pressure_pa = compensate_bme280_pressure(adc_P); + const float humidity = compensate_bme280_humidity(adc_H); + lpp.addTemperature(ch, temperature); + lpp.addRelativeHumidity(ch, humidity); + lpp.addBarometricPressure(ch, pressure_pa / 100.0f); + lpp.addAltitude(ch, 44330.0f * (1.0f - powf((pressure_pa / 100.0f) / (float)TELEM_BME280_SEALEVELPRESSURE_HPA, 0.1903f))); +} +#else +static uint8_t init_bme280(TwoWire* wire, uint8_t addr) { + if (!BME280.begin(addr, wire)) { + return 0; + } BME280.setSampling(Adafruit_BME280::MODE_FORCED, Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::SAMPLING_X1, @@ -288,6 +487,7 @@ static void query_bme280(uint8_t ch, uint8_t, CayenneLPP& lpp) { } } #endif +#endif #if ENV_INCLUDE_BMP280 static uint8_t init_bmp280(TwoWire*, uint8_t addr) { @@ -630,26 +830,36 @@ bool EnvironmentSensorManager::begin() { Wire1.begin(ENV_PIN_SDA, ENV_PIN_SCL, 100000); #endif MESH_DEBUG_PRINTLN("Second I2C initialized on pins SDA: %d SCL: %d", ENV_PIN_SDA, ENV_PIN_SCL); + #elif defined(ESP32) && defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + Wire.setClock(100000); + MESH_DEBUG_PRINTLN("I2C using board pins SDA: %d SCL: %d", PIN_BOARD_SDA, PIN_BOARD_SCL); #endif - // Scan the I2C bus before touching any sensor library. - bool detected[128] = {}; - scanI2CBus(TELEM_WIRE, detected); - // Walk the sensor table and initialize only detected devices. _active_sensor_count = 0; for (size_t i = 0; i < SENSOR_TABLE_SIZE && _active_sensor_count < MAX_ACTIVE_SENSORS; i++) { const SensorDef& def = SENSOR_TABLE[i]; - if (!detected[def.address]) { - MESH_DEBUG_PRINTLN("%s not detected at I2C address %02X", def.name, def.address); + uint8_t address = def.address; + const uint8_t board_detected_address = boardDetectedI2CSensorAddress(def.name); + const bool board_detected = board_detected_address != 0; + if (board_detected) { + address = board_detected_address; + if (address == def.address) { + MESH_DEBUG_PRINTLN("%s already detected by board scan at I2C address %02X", def.name, address); + } else { + MESH_DEBUG_PRINTLN("%s already detected by board scan at I2C address %02X (configured %02X)", def.name, address, def.address); + } + } + if (!board_detected && !probeI2CAddress(TELEM_WIRE, address)) { + MESH_DEBUG_PRINTLN("%s not detected at I2C address %02X", def.name, address); continue; } - uint8_t n = def.init(TELEM_WIRE, def.address); + uint8_t n = def.init(TELEM_WIRE, address); if (n == 0) { - MESH_DEBUG_PRINTLN("%s found at %02X but failed to initialize", def.name, def.address); + MESH_DEBUG_PRINTLN("%s found at %02X but failed to initialize", def.name, address); continue; } - MESH_DEBUG_PRINTLN("Found %s at address: %02X", def.name, def.address); + MESH_DEBUG_PRINTLN("Found %s at address: %02X", def.name, address); for (uint8_t sub = 0; sub < n && _active_sensor_count < MAX_ACTIVE_SENSORS; sub++) { _active_sensors[_active_sensor_count++] = { def.query, sub }; } diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 0e0a2097..1cf652cc 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -514,6 +514,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( :root[data-theme="dark"] .metric { background:rgba(0,0,0,.16); } .metric-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; } .metric-value { margin-top:4px; font-size:16px; font-weight:700; color:var(--text); } + .metric-value.with-action { display:flex; align-items:baseline; gap:8px; flex-wrap:wrap; } + .metric-text-action { width:auto; min-height:0; padding:0; border:none; background:transparent; color:var(--status-red); font:inherit; font-size:12px; font-weight:700; line-height:1.2; text-decoration:underline; } + .metric-text-action:hover { background:transparent; color:var(--status-red); text-decoration:none; } .events-table-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:12px; } .events-table { width:100%; border-collapse:collapse; } .events-table th, .events-table td { padding:10px 12px; text-align:left; font-size:13px; } @@ -1858,10 +1861,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
`; } - function renderMetric(label, value) { + function renderMetric(label, value, actionHtml = "") { + const valueClass = actionHtml ? "metric-value with-action" : "metric-value"; return `
${escapeHtml(label)}
-
${escapeHtml(value)}
+
${escapeHtml(value)}${actionHtml}
`; } function hasMetricValue(value) { @@ -1874,7 +1878,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (!items.length) return ""; const classes = ["metric-grid"]; if (gridClass) classes.push(gridClass); - return `
${items.map((item) => renderMetric(item.label, item.value)).join("")}
`; + return `
${items.map((item) => renderMetric(item.label, item.value, item.actionHtml || "")).join("")}
`; } function renderMissingCard(title, message) { return `
@@ -2080,7 +2084,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( metrics.push( { label:"Card", value:archive.type || "--" }, { label:"Archive Total", value:formatArchiveGigabytes(archiveTotal) }, - { label:"Archive Used", value:formatArchiveUsed(archiveUsed) }, + { label:"Archive Used", value:formatArchiveUsed(archiveUsed), actionHtml:'' }, { label:"Archive Free", value:archiveTotal > 0 ? (formatArchiveGigabytes(archiveFree) + " (" + archiveFreePct + "%)") : "--" } ); } @@ -3139,6 +3143,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( } } }; + document.addEventListener("click", async (event) => { + const target = event.target; + if (!target || !target.matches || !target.matches('[data-action="purge-sd"]')) return; + if (confirm("Purge the SD card archive now? This deletes all files on the mounted SD card.")) { + await runCommand("purge sd"); + if (isStatsPage) { + await loadStatsPage(nextPageLoadGeneration()); + } + } + }); document.getElementById("refreshPageBtn").onclick = async () => { const generation = nextPageLoadGeneration(); if (isStatsPage) { @@ -3317,7 +3331,7 @@ bool WebPanelServer::start() { WEB_PANEL_LOG("redirect server start failed rc=0x%x", static_cast(rc)); } - WEB_PANEL_LOG("server started on https://%s/", WiFi.localIP().toString().c_str()); + WEB_PANEL_LOG("server started t=%lu on https://%s/", static_cast(millis()), WiFi.localIP().toString().c_str()); return true; }