Merge pull request #67 from xJARiD/develop

fix: harden observer boot and T-Beam S3 telemetry
This commit is contained in:
xJARiD
2026-06-07 13:57:44 +10:00
committed by GitHub
19 changed files with 916 additions and 65 deletions
+4
View File
@@ -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`
+8 -1
View File
@@ -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`.
+76 -1
View File
@@ -1216,6 +1216,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_archive_neighbours_dirty = false;
_battery_sample_valid = false;
_battery_mv_cache = 0;
_web_sensor_snapshot_valid = false;
region_load_active = false;
memset(&_stats_state, 0, sizeof(_stats_state));
@@ -1332,6 +1333,11 @@ void MyMesh::begin(FILESYSTEM *fs, ArchiveStorage* archive) {
web.setCommandRunner(this);
web.setNetworkStateProvider(&network);
web.begin(_fs);
#if defined(TBEAM_SUPREME_SX1262)
if (web.isWebEnabled()) {
network.loop(true);
}
#endif
_stats_history.begin(web.isWebStatsEnabled(), _archive);
if (web.isWebStatsEnabled() && !_stats_history.isLiveOnly() && _archive != nullptr && _archive->isMounted()) {
restoreArchiveNeighbours();
@@ -1353,6 +1359,11 @@ void MyMesh::begin(FILESYSTEM *fs, ArchiveStorage* archive) {
mqtt.setNetworkStateProvider(&network);
#endif
mqtt.begin(_fs);
#if defined(ESP_PLATFORM) && defined(TBEAM_SUPREME_SX1262)
if (mqtt.isActive()) {
network.loop(true);
}
#endif
#endif
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
@@ -1578,6 +1589,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<unsigned long>(files_removed),
static_cast<unsigned long>(dirs_removed));
}
return true;
}
size_t MyMesh::getNeighbourCount() const {
#if MAX_NEIGHBOURS
size_t count = 0;
@@ -1618,10 +1664,21 @@ bool MyMesh::restoreArchiveNeighbours() {
return false;
}
const size_t max_restore_bytes = static_cast<size_t>(MAX_NEIGHBOURS) * 128U;
const size_t file_size = static_cast<size_t>(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 +1686,12 @@ bool MyMesh::restoreArchiveNeighbours() {
}
const char ch = static_cast<char>(raw);
if (skip_partial) {
if (ch == '\n') {
skip_partial = false;
}
continue;
}
if (ch == '\r') {
continue;
}
@@ -1891,6 +1954,8 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) {
if (!live_stats_headroom_low) {
const uint16_t battery_mv = getBatteryMilliVolts();
WebSensorSnapshot sensor_snapshot = collectWebSensorSnapshot(board, sensors, battery_mv);
_web_sensor_snapshot = sensor_snapshot;
_web_sensor_snapshot_valid = true;
HistorySample sample{};
sample.epoch_secs = getRTCClock()->getCurrentTime();
sample.uptime_secs = static_cast<uint32_t>(uptime_millis / 1000);
@@ -2211,6 +2276,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
static_cast<unsigned>(_stats_history.getEventCount()),
static_cast<unsigned>(_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) {
@@ -2586,7 +2653,15 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
const int battery_display_pct =
(battery_pct >= 0) ? std::max(0, std::min(100, battery_pct))
: clampBatteryPercentFromRange(battery_mv, battery_min_mv, battery_max_mv);
const WebSensorSnapshot sensor_snapshot = collectWebSensorSnapshot(board, sensors, battery_mv);
WebSensorSnapshot sensor_snapshot;
if (_web_sensor_snapshot_valid) {
sensor_snapshot = _web_sensor_snapshot;
sensor_snapshot.has_battery = true;
sensor_snapshot.battery_mv = battery_mv;
} else {
sensor_snapshot.has_battery = true;
sensor_snapshot.battery_mv = battery_mv;
}
const bool archive_available = (_archive != nullptr) && _archive->isMounted();
#ifdef WITH_MQTT_UPLINK
const bool mqtt_connected = mqtt.isAnyBrokerConnected();
+3
View File
@@ -137,6 +137,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public WebPanelComm
bool _archive_neighbours_dirty;
bool _battery_sample_valid;
uint16_t _battery_mv_cache;
bool _web_sensor_snapshot_valid;
WebSensorSnapshot _web_sensor_snapshot;
NodePrefs _prefs;
ClientACL acl;
CommonCLI _cli;
@@ -297,6 +299,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;
+7
View File
@@ -34,6 +34,11 @@ void setup() {
delay(1000);
board.begin();
#if defined(TBEAM_SUPREME_SX1262)
sensors.begin();
#endif
archive.begin();
#if defined(MESH_DEBUG) && defined(NRF52_PLATFORM)
@@ -90,7 +95,9 @@ void setup() {
command[0] = 0;
#if !defined(TBEAM_SUPREME_SX1262)
sensors.begin();
#endif
the_mesh.begin(fs, &archive);
+72
View File
@@ -152,6 +152,42 @@ 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/OTA persistence, and web-panel archive maintenance."
changes:
- type: fixed
area: board
text: "Fixed LilyGo T-Beam S3 Supreme BME280 detection and live telemetry by using the board-detected address, stop-based I2C reads, and board-specific I2C bus recovery before samples."
- type: fixed
area: web
text: "Changed live `/stats` sensor values to use the main-loop sensor snapshot instead of reading I2C sensors from the web request handler."
- type: fixed
area: wifi
text: "Improved boot-time Wi-Fi startup by starting Wi-Fi earlier on T-Beam S3 Supreme, remembering the last connected AP channel, and falling back to full scan if that channel is stale."
- type: fixed
area: wifi
text: "Added NVS-backed Wi-Fi preference recovery so SSID and password survive OTA updates even if the filesystem preference file is lost."
- type: fixed
area: wifi
text: "Hardened Wi-Fi retry handling so scan-mode connects are not interrupted early and failed attempts reset the ESP32 station state before retrying."
- 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 OTA startup by binding before reporting success and falling back to alternate ports when port 80 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 +251,39 @@ 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/OTA persistence, 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 live telemetry by using the board-detected address, stop-based I2C reads, and board-specific I2C bus recovery before samples."
- type: fixed
area: web
text: "Changed live `/stats` sensor values to use the main-loop sensor snapshot instead of reading I2C sensors from the web request handler."
- type: fixed
area: wifi
text: "Improved boot-time Wi-Fi startup by starting Wi-Fi earlier on T-Beam S3 Supreme, remembering the last connected AP channel, and falling back to full scan if that channel is stale."
- type: fixed
area: wifi
text: "Added NVS-backed Wi-Fi preference recovery so SSID and password survive OTA updates even if the filesystem preference file is lost."
- type: fixed
area: wifi
text: "Hardened Wi-Fi retry handling so scan-mode connects are not interrupted early and failed attempts reset the ESP32 station state before retrying."
- 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 OTA startup by binding before reporting success and falling back to alternate ports when port 80 is still being released."
breaking_changes: []
+82
View File
@@ -48,6 +48,52 @@ void deassertChipSelect(int pin, uint8_t archive_cs_pin) {
pinMode(static_cast<uint8_t>(pin), OUTPUT);
digitalWrite(static_cast<uint8_t>(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<size_t>(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<unsigned long>(removed_files),
static_cast<unsigned long>(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) {
+1
View File
@@ -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"; }
+47 -17
View File
@@ -14,15 +14,19 @@ namespace {
AsyncWebServer* ota_server = nullptr;
char ota_id_buf[60];
char ota_home_buf[90];
volatile bool ota_start_pending = false;
uint16_t ota_server_port = 0;
constexpr uint8_t kOtaPort80Attempts = 8;
constexpr uint32_t kOtaStartRetryDelayMs = 500;
void startOTAServerNow() {
bool startOTAServerNow(uint16_t port) {
if (ota_server != nullptr) {
ota_server->end();
delete ota_server;
ota_server = nullptr;
ota_server_port = 0;
}
ota_server = new AsyncWebServer(80);
ota_server = new AsyncWebServer(port);
ota_server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(200, "text/html", ota_home_buf);
@@ -34,13 +38,39 @@ 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;
ota_server_port = 0;
return false;
}
ota_server_port = port;
return true;
}
void startOTAServerTask(void*) {
vTaskDelay(pdMS_TO_TICKS(750));
startOTAServerNow();
ota_start_pending = false;
vTaskDelete(nullptr);
bool startOTAServerWithFallback() {
for (uint8_t attempt = 1; attempt <= kOtaPort80Attempts; ++attempt) {
if (startOTAServerNow(80)) {
MESH_DEBUG_PRINTLN("OTA server listening on port 80");
return true;
}
MESH_DEBUG_PRINTLN("OTA server port 80 busy, retry %u/%u", attempt, kOtaPort80Attempts);
delay(kOtaStartRetryDelayMs);
}
static const uint16_t fallback_ports[] = {8080, 8081};
for (uint8_t i = 0; i < sizeof(fallback_ports) / sizeof(fallback_ports[0]); ++i) {
const uint16_t port = fallback_ports[i];
if (startOTAServerNow(port)) {
MESH_DEBUG_PRINTLN("OTA server listening on fallback port %u", port);
return true;
}
MESH_DEBUG_PRINTLN("OTA server fallback port %u busy", port);
}
MESH_DEBUG_PRINTLN("OTA server failed to bind any port");
return false;
}
}
@@ -59,20 +89,20 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
ota_ip = WiFi.softAPIP();
}
sprintf(reply, "Started: http://%s/update", ota_ip.toString().c_str());
MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
snprintf(ota_id_buf, sizeof(ota_id_buf), "%s (%s)", id, getManufacturerName());
snprintf(ota_home_buf, sizeof(ota_home_buf), "<H2>Hi! I am a MeshCore Repeater. ID: %s</H2>", id);
if (!ota_start_pending) {
ota_start_pending = true;
if (xTaskCreate(startOTAServerTask, "ota-start", 4096, nullptr, 1, nullptr) != pdPASS) {
startOTAServerNow();
ota_start_pending = false;
}
if (!startOTAServerWithFallback()) {
strcpy(reply, "Error - OTA listener start failed");
return false;
}
if (ota_server_port == 80) {
sprintf(reply, "Started: http://%s/update", ota_ip.toString().c_str());
} else {
sprintf(reply, "Started: http://%s:%u/update", ota_ip.toString().c_str(), ota_server_port);
}
MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
return true;
}
+73 -4
View File
@@ -3,6 +3,10 @@
#include <helpers/TxtDataHelpers.h>
#include <string.h>
#if defined(ESP_PLATFORM)
#include <Preferences.h>
#endif
namespace {
struct LegacyWebPrefsV1 {
@@ -47,12 +51,48 @@ bool loadLegacyWebWifiPrefs(FILESYSTEM* fs, NetworkPrefs& prefs) {
return true;
}
#if defined(ESP_PLATFORM)
bool loadNvsNetworkPrefs(NetworkPrefs& prefs) {
Preferences nvs;
if (!nvs.begin("eastmesh-net", true)) {
return false;
}
NetworkPrefs stored{};
const size_t read = nvs.getBytes("prefs", &stored, sizeof(stored));
nvs.end();
if (read != sizeof(stored) || stored.magic != NetworkPrefsStore::magicValue()) {
return false;
}
prefs = stored;
return true;
}
bool saveNvsNetworkPrefs(const NetworkPrefs& prefs) {
Preferences nvs;
if (!nvs.begin("eastmesh-net", false)) {
return false;
}
const size_t written = nvs.putBytes("prefs", &prefs, sizeof(prefs));
nvs.end();
return written == sizeof(prefs);
}
#else
bool loadNvsNetworkPrefs(NetworkPrefs&) {
return false;
}
bool saveNvsNetworkPrefs(const NetworkPrefs&) {
return true;
}
#endif
} // namespace
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,
@@ -61,10 +101,15 @@ bool NetworkPrefsStore::load(FILESYSTEM* fs, NetworkPrefs& prefs,
const char* legacy_wifi_pwd) {
setDefaults(prefs);
if (fs == nullptr) {
loadNvsNetworkPrefs(prefs);
return false;
}
if (!fs->exists(kFilename)) {
if (loadNvsNetworkPrefs(prefs)) {
save(fs, prefs);
return true;
}
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));
@@ -94,6 +139,10 @@ bool NetworkPrefsStore::load(FILESYSTEM* fs, NetworkPrefs& prefs,
if (!ok || persisted.magic != kMagic) {
fs->remove(kFilename);
if (loadNvsNetworkPrefs(prefs)) {
save(fs, prefs);
return true;
}
save(fs, prefs);
return false;
}
@@ -102,15 +151,35 @@ 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;
}
bool repaired = false;
if (prefs.wifi_ssid[0] == 0 && legacy_wifi_ssid != nullptr && legacy_wifi_ssid[0] != 0) {
StrHelper::strncpy(prefs.wifi_ssid, legacy_wifi_ssid, sizeof(prefs.wifi_ssid));
prefs.wifi_channel = 0;
repaired = true;
}
if (prefs.wifi_pwd[0] == 0 && legacy_wifi_pwd != nullptr && legacy_wifi_pwd[0] != 0) {
StrHelper::strncpy(prefs.wifi_pwd, legacy_wifi_pwd, sizeof(prefs.wifi_pwd));
prefs.wifi_channel = 0;
repaired = true;
}
if (repaired) {
save(fs, prefs);
} else {
saveNvsNetworkPrefs(prefs);
}
return true;
}
bool NetworkPrefsStore::save(FILESYSTEM* fs, const NetworkPrefs& prefs) {
const bool nvs_ok = saveNvsNetworkPrefs(prefs);
if (fs == nullptr) {
return false;
return nvs_ok;
}
if (fs->exists(kFilename) && !fs->remove(kFilename)) {
return false;
return nvs_ok;
}
#if defined(RP2040_PLATFORM)
File file = fs->open(kFilename, "w");
@@ -118,9 +187,9 @@ bool NetworkPrefsStore::save(FILESYSTEM* fs, const NetworkPrefs& prefs) {
File file = fs->open(kFilename, "w", true);
#endif
if (!file) {
return false;
return nvs_ok;
}
bool ok = file.write(reinterpret_cast<const uint8_t*>(&prefs), sizeof(prefs)) == sizeof(prefs);
file.close();
return ok;
return ok && nvs_ok;
}
+3 -1
View File
@@ -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];
};
@@ -19,6 +20,7 @@ public:
const char* legacy_wifi_ssid = nullptr,
const char* legacy_wifi_pwd = nullptr);
static bool save(FILESYSTEM* fs, const NetworkPrefs& prefs);
static constexpr uint32_t magicValue() { return kMagic; }
private:
static constexpr uint32_t kMagic = 0x4E455450;
+95 -9
View File
@@ -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,16 +291,57 @@ void NetworkService::ensureWifi(bool network_required) {
return;
}
if (WiFi.status() == WL_CONNECTED) {
wl_status_t status = WiFi.status();
if (static_cast<int>(status) != _last_wifi_status) {
_last_wifi_status = static_cast<int>(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<unsigned long>(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<uint8_t>(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 (_last_wifi_attempt != 0 && status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) {
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<unsigned long>(millis()),
_prefs.wifi_channel);
_prefs.wifi_channel = 0;
savePrefs();
WiFi.disconnect(false, false);
_last_wifi_attempt = 0;
}
if (_last_wifi_attempt != 0 && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) {
return;
}
if (_last_wifi_attempt != 0) {
Serial.printf("[BOOT] wifi timeout t=%lu code=%d retry\n",
static_cast<unsigned long>(millis()),
static_cast<int>(status));
WiFi.disconnect(false, false);
WiFi.mode(WIFI_OFF);
delay(100);
_wifi_started = false;
_sntp_started = false;
_have_time_sync = false;
_last_wifi_status = -1;
}
if (now_ms - _last_wifi_attempt < kWifiRetryMillis) {
return;
}
@@ -272,10 +351,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<unsigned long>(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<unsigned long>(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<unsigned long>(millis()));
}
}
void NetworkService::updateTimeSync() {
+1
View File
@@ -46,5 +46,6 @@ private:
bool _wifi_started;
bool _sntp_started;
bool _have_time_sync;
int _last_wifi_status;
unsigned long _last_wifi_attempt;
};
+12 -3
View File
@@ -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<size_t>(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;
+33 -2
View File
@@ -5,6 +5,17 @@
//#include <RadioLib.h>
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");
+1 -1
View File
@@ -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
+335 -16
View File
@@ -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 <Adafruit_BME280.h>
static Adafruit_BME280 BME280;
#endif
#endif
#if ENV_INCLUDE_BMP280
#ifndef TELEM_BMP280_ADDRESS
@@ -211,17 +213,138 @@ 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;
}
#if defined(TBEAM_SUPREME_SX1262) && ENV_INCLUDE_BME280
static void recoverBME280I2CBus(TwoWire* wire) {
if (wire == nullptr) {
return;
}
#if defined(ESP32) && defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL)
if (wire == &Wire) {
Wire.end();
delay(5);
Wire.begin(PIN_BOARD_SDA, PIN_BOARD_SCL);
Wire.setClock(100000);
delay(5);
}
#endif
}
static bool readI2CRegister8WithRetry(TwoWire* wire, uint8_t addr, uint8_t reg, uint8_t* value) {
for (uint8_t attempt = 0; attempt < 20; attempt++) {
if (readI2CRegister8(wire, addr, reg, value)) {
return true;
}
if (attempt == 4) {
recoverBME280I2CBus(wire);
}
delay(5);
}
MESH_DEBUG_PRINTLN("BME280 read reg 0x%02X failed", reg);
return false;
}
static bool readI2CBufferWithRetry(TwoWire* wire, uint8_t addr, uint8_t reg, uint8_t* buffer, size_t len) {
for (uint8_t attempt = 0; attempt < 20; attempt++) {
if (readI2CBuffer(wire, addr, reg, buffer, len)) {
return true;
}
if (attempt == 4) {
recoverBME280I2CBus(wire);
}
delay(5);
}
MESH_DEBUG_PRINTLN("BME280 read buffer reg 0x%02X len=%u failed", reg, (unsigned)len);
return false;
}
static bool writeI2CRegister8WithRetry(TwoWire* wire, uint8_t addr, uint8_t reg, uint8_t value) {
if (wire == nullptr) {
return false;
}
uint8_t last_error = 0xFF;
for (uint8_t attempt = 0; attempt < 20; attempt++) {
wire->beginTransmission(addr);
wire->write(reg);
wire->write(value);
last_error = wire->endTransmission(true);
if (last_error == 0) {
return true;
}
if (attempt == 4) {
recoverBME280I2CBus(wire);
}
delay(5);
}
MESH_DEBUG_PRINTLN("BME280 write reg 0x%02X failed err=%u", reg, last_error);
return false;
}
#endif
uint8_t boardDetectedI2CSensorAddress(const char* name) __attribute__((weak));
uint8_t boardDetectedI2CSensorAddress(const char* name) {
(void)name;
return 0;
}
// ============================================================
@@ -269,8 +392,192 @@ 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 (!readI2CBufferWithRetry(wire, addr, 0x88, calib1, sizeof(calib1)) ||
!readI2CBufferWithRetry(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 = readI2CRegister8WithRetry(wire, addr, 0xD0, &chip_id);
if (!chip_id_ok || chip_id != 0x60) {
MESH_DEBUG_PRINTLN("BME280 init failed chip ID 0x%02X", chip_id);
return 0;
}
if (!read_bme280_calibration(wire, addr)) {
MESH_DEBUG_PRINTLN("BME280 init failed calibration read");
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;
}
recoverBME280I2CBus(BME280Wire);
if (!writeI2CRegister8WithRetry(BME280Wire, BME280Address, 0xF2, 0x01) ||
!writeI2CRegister8WithRetry(BME280Wire, BME280Address, 0xF4, 0x25)) { // temp/pressure x1, forced mode
MESH_DEBUG_PRINTLN("BME280 query failed to start forced measurement");
return;
}
uint8_t status = 0;
bool measurement_done = false;
for (uint8_t attempt = 0; attempt < 20; attempt++) {
if (!readI2CRegister8WithRetry(BME280Wire, BME280Address, 0xF3, &status)) {
MESH_DEBUG_PRINTLN("BME280 query failed status read");
return;
}
if ((status & 0x08) == 0) {
measurement_done = true;
break;
}
delay(2);
}
if (!measurement_done) {
MESH_DEBUG_PRINTLN("BME280 query timed out waiting for measurement");
return;
}
uint8_t data[8] = {};
if (!readI2CBufferWithRetry(BME280Wire, BME280Address, 0xF7, data, sizeof(data))) {
MESH_DEBUG_PRINTLN("BME280 query failed data read");
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];
if (adc_P == 0 || adc_T == 0) {
MESH_DEBUG_PRINTLN("BME280 query returned invalid raw sample p=%ld t=%ld h=%ld",
(long)adc_P,
(long)adc_T,
(long)adc_H);
return;
}
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);
if (pressure_pa <= 0.0f) {
MESH_DEBUG_PRINTLN("BME280 query returned invalid pressure %.2f", pressure_pa);
return;
}
MESH_DEBUG_PRINTLN("BME280 sample temp=%.2f humidity=%.2f pressure=%.2f",
temperature,
humidity,
pressure_pa / 100.0f);
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 +595,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,31 +938,42 @@ 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 };
}
}
MESH_DEBUG_PRINTLN("Environment sensors active: %d", _active_sensor_count);
return true;
}
+6 -1
View File
@@ -11,7 +11,12 @@ bool SH1106Display::i2c_probe(TwoWire &wire, uint8_t addr)
bool SH1106Display::begin()
{
return display.begin(DISPLAY_ADDRESS, true) && i2c_probe(Wire, DISPLAY_ADDRESS);
const bool started = display.begin(DISPLAY_ADDRESS, true);
#if defined(ESP32) && defined(TBEAM_SUPREME_SX1262) && defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL)
Wire.begin(PIN_BOARD_SDA, PIN_BOARD_SCL);
Wire.setClock(100000);
#endif
return started && i2c_probe(Wire, DISPLAY_ADDRESS);
}
void SH1106Display::turnOn()
+57 -9
View File
@@ -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(
<div class="meter"><div class="meter-fill${tone ? " " + tone : ""}" style="width:${pct}%;${invertFill ? "margin-left:auto;" : ""}"></div></div>
</div>`;
}
function renderMetric(label, value) {
function renderMetric(label, value, actionHtml = "") {
const valueClass = actionHtml ? "metric-value with-action" : "metric-value";
return `<div class="metric">
<div class="metric-label">${escapeHtml(label)}</div>
<div class="metric-value">${escapeHtml(value)}</div>
<div class="${valueClass}"><span>${escapeHtml(value)}</span>${actionHtml}</div>
</div>`;
}
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 `<div class="${classes.join(" ")}">${items.map((item) => renderMetric(item.label, item.value)).join("")}</div>`;
return `<div class="${classes.join(" ")}">${items.map((item) => renderMetric(item.label, item.value, item.actionHtml || "")).join("")}</div>`;
}
function renderMissingCard(title, message) {
return `<section class="hud-card">
@@ -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:'<button type="button" class="metric-text-action" data-action="purge-sd">Purge SD</button>' },
{ label:"Archive Free", value:archiveTotal > 0 ? (formatArchiveGigabytes(archiveFree) + " (" + archiveFreePct + "%)") : "--" }
);
}
@@ -2493,9 +2497,30 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
const sensors = summaryPayload && summaryPayload.sensors ? summaryPayload.sensors : null;
const gpsEnabled = !!(sensors && sensors.gps_enabled === true);
const order = ["battery", "memory", "signal", "noise_floor", "packets"];
if (sensors && Number.isFinite(sensors.supply_voltage_v)) {
order.push("voltage");
}
if (sensors && Number.isFinite(sensors.sensor_temp_c)) {
order.push("sensor_temp");
}
if (sensors && Number.isFinite(sensors.humidity_pct)) {
order.push("humidity");
}
if (sensors && Number.isFinite(sensors.pressure_hpa)) {
order.push("pressure");
}
if (sensors && Number.isFinite(sensors.pressure_altitude_m)) {
order.push("pressure_altitude");
}
if (sensors && Number.isFinite(sensors.mcu_temp_c)) {
order.push("mcu_temp");
}
if (gpsEnabled) {
order.push("gps_satellites");
}
if (sensors && Number.isFinite(sensors.gps_altitude_m)) {
order.push("gps_altitude");
}
return order;
}
function initTrendCards(seriesOrder) {
@@ -2702,10 +2727,19 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
return false;
}
try {
await fetchJson("/api/session");
const res = await fetch("/api/session", { headers:{ "X-Auth-Token": token } });
if (res.status === 401) {
redirectToLogin();
return false;
}
if (!res.ok) {
statusEl.textContent = "Web panel reconnecting...";
return null;
}
return true;
} catch (_) {
return false;
} catch (err) {
statusEl.textContent = "Web panel reconnecting...";
return null;
}
}
function getLetsmeshMode() {
@@ -3139,6 +3173,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) {
@@ -3157,10 +3201,14 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
}
mqttIataLoaded = false;
refreshMqttIataWarning();
if (!await validateSession()) {
const sessionOk = await validateSession();
if (sessionOk === false) {
return;
}
showAuthedUi(true);
if (sessionOk === null) {
return;
}
if (isStatsPage) {
try {
await loadStatsPage(generation);
@@ -3317,7 +3365,7 @@ bool WebPanelServer::start() {
WEB_PANEL_LOG("redirect server start failed rc=0x%x", static_cast<unsigned>(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<unsigned long>(millis()), WiFi.localIP().toString().c_str());
return true;
}