fix: harden observer boot and T-Beam S3 telemetry

This commit is contained in:
Jared Dohrman committed 2026-06-07 12:32:42 +10:00
1 parent 7232afc115
commit cbfe1cec66
18 files changed
+623 -43

No files matched your search

+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`.
+62
View File
@@ -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<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 +1661,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 +1683,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;
}
@@ -2211,6 +2271,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) {
+1
View File
@@ -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;
+3 -2
View File
@@ -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
+54
View File
@@ -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: []
+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"; }
+28 -4
View File
@@ -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;
}
}
}
+4
View File
@@ -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;
}
+2 -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];
};
+82 -8
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,13 +291,42 @@ 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 (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 && 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<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
+226 -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,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 };
}
+19 -5
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 + "%)") : "--" }
);
}
@@ -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<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;
}