Frame rate: full-width PSRAM draw band (6 bands a frame instead of 14 on the T-Deck), the still aurora drawn once and copied, LVGL at -O2 with its blend and label loops in IRAM (scripts/build/perf_lvgl.py, 1 KB more loop stack), circle and gradient caches, newlib memcpy. Rolled out to the M9, Attaky, RAK, Wio, CrowPanel and V4-R8; the Pager gets the band only, the V4 the caches only. Frame benchmark tour (DOC_PERF_BENCH) for measuring it. Fixes: hop counts read from the packed path length (#621); Arabic shaping rows patched in LVGL (patch_lvgl_arabic.py), Arabic 101 shift layer, kaf, symbols, text sent as standard letters (#582); info popup colour runs that never wrap (#602); keypad nav reaches the chat header (#623); the browser opens the changelog (#622, gen-changelog.py at every site deploy); the Theme background colour picker no longer edits the accent; flasher entries for P4 units with factory ESP-AT Wi-Fi chips (#612, #619). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2265 lines
106 KiB
C++
2265 lines
106 KiB
C++
#include <Arduino.h> // needed for PlatformIO
|
|
#include <Mesh.h>
|
|
#include "MyMesh.h"
|
|
#if defined(ESP32_PLATFORM)
|
|
#include <new> // placement-new for the PSRAM-resident the_mesh
|
|
#include "esp_heap_caps.h" // heap_caps_malloc(MALLOC_CAP_SPIRAM)
|
|
#include <esp_sntp.h> // completion status for normal + cold-boot time sync
|
|
#endif
|
|
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
|
|
#include <Preferences.h>
|
|
#include <esp_system.h>
|
|
#include <esp_ota_ops.h> // recovery-first boot: running slot + reset the boot pointer to factory
|
|
#include <esp_partition.h> // find/erase otadata so the bootloader returns to the recovery
|
|
#include <helpers/TouchDiagTrace.h>
|
|
#include <helpers/MeshTouchTxTrace.h>
|
|
#include "helpers/esp32/TouchPrefsStore.h" // QUOTED: get wadamesh's copy (touchPrefsReload), not the lib's stale one
|
|
#include "helpers/esp32/SdNvsPrefs.h" // route prefs to file storage (SD/SPIFFS), off NVS
|
|
// (quoted: use wadamesh's src/ copy, not the lib's stale one)
|
|
#include "helpers/esp32/BootTimeSync.h" // opt-in cold-boot clock sync over saved Wi-Fi (#383)
|
|
#include "ui-touch/i18n.h" // translated Pager transport-state alerts
|
|
#include "wadamesh_mark_rgb.h" // anti-aliased mesh-mark (RGB565) for the pre-LVGL boot screen
|
|
#include "ui-touch/TouchSleep.h" // idle light-sleep controller (loopEnd called at end of loop())
|
|
#include "ui-touch/device_caps.h" // CAP_BLE_KEYBOARD: keyboard mode keeps the phone link paused from boot
|
|
#endif
|
|
|
|
#if defined(WADA_LVGL_O2)
|
|
// LVGL compiled at -O2 (scripts/build/perf_lvgl.py) goes 160 bytes deeper into the
|
|
// loop task's stack at its deepest (measured on the T-Deck: 2108 -> 1948 bytes left of
|
|
// the framework's 8 KB). 1 KB more leaves more room than before; the draw band that
|
|
// moved to PSRAM freed ten times that much internal RAM.
|
|
SET_LOOP_TASK_STACK_SIZE(9 * 1024);
|
|
#endif
|
|
|
|
// Believe it or not, this std C function is busted on some platforms!
|
|
static uint32_t _atoi(const char* sp) {
|
|
uint32_t n = 0;
|
|
while (*sp && *sp >= '0' && *sp <= '9') {
|
|
n *= 10;
|
|
n += (*sp++ - '0');
|
|
}
|
|
return n;
|
|
}
|
|
|
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
|
#include <InternalFileSystem.h>
|
|
#if defined(QSPIFLASH)
|
|
#include <CustomLFS_QSPIFlash.h>
|
|
DataStore store(InternalFS, QSPIFlash, rtc_clock);
|
|
#else
|
|
#if defined(EXTRAFS)
|
|
#include <CustomLFS.h>
|
|
CustomLFS ExtraFS(0xD4000, 0x19000, 128);
|
|
DataStore store(InternalFS, ExtraFS, rtc_clock);
|
|
#else
|
|
DataStore store(InternalFS, rtc_clock);
|
|
#endif
|
|
#endif
|
|
#elif defined(RP2040_PLATFORM)
|
|
#include <LittleFS.h>
|
|
DataStore store(LittleFS, rtc_clock);
|
|
#elif defined(ESP32)
|
|
#include <SPIFFS.h>
|
|
#if defined(HAS_WIO_TRACKER_L2)
|
|
#include <SD_MMC.h>
|
|
#include <WioTrackerL2Io.h>
|
|
#endif
|
|
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HAS_CROWPANEL_35)
|
|
#if defined(HAS_CROWPANEL_35)
|
|
#include <CrowPanel35SD.h>
|
|
#else
|
|
#include <SD.h>
|
|
#include "SdFastClock.h" // post-mount operating-clock raise (SD_SPI_FAST_HZ boards)
|
|
#endif
|
|
#include <Preferences.h>
|
|
#if defined(TLORA_PAGER)
|
|
#include <mbedtls/sha256.h>
|
|
#endif
|
|
#ifndef PIN_SD_CS
|
|
#if defined(TLORA_PAGER)
|
|
#define PIN_SD_CS PAGER_PIN_SD_CS
|
|
#else
|
|
#define PIN_SD_CS 39 // T-Deck microSD chip-select (V4-R8 sets 3 in the env)
|
|
#endif
|
|
#endif
|
|
#endif
|
|
extern "C" void set_boot_phase(int phase);
|
|
namespace { struct MainBootTrace { MainBootTrace() { set_boot_phase(2); } } _main_boot_trace; }
|
|
DataStore store(SPIFFS, rtc_clock);
|
|
#if defined(WIFI_SSID) || defined(MULTI_TRANSPORT_COMPANION)
|
|
#include "WiFiConfig.h"
|
|
#endif
|
|
#endif
|
|
|
|
#ifdef ESP32
|
|
#ifdef MULTI_TRANSPORT_COMPANION
|
|
// This class is extended locally (web mirror/P4 routing/BLE state). Include
|
|
// the matching project header explicitly: the MeshCore dependency ships an
|
|
// older class layout, and mixing that header with our local .cpp makes the
|
|
// placement allocation undersized and shifts every member after _ws_started.
|
|
#include "helpers/esp32/MultiTransportCompanionInterface.h"
|
|
#include "helpers/esp32/MqttBridge.h"
|
|
#include <esp_heap_caps.h>
|
|
#include <new>
|
|
// ~9.2 KB of TCP/WS/USB framing buffers. Internal DRAM is the scarce pool on
|
|
// the touch boards (Wi-Fi + BLE coexistence needs ~50 KB free), and none of
|
|
// these buffers are touched from ISR context, so build the whole object in
|
|
// PSRAM (heap is up before C++ static init on ESP32; falls back to internal
|
|
// RAM if PSRAM is absent). In-TU init order runs this before
|
|
// ui_task(&serial_interface) further down.
|
|
static void* s_si_mem = [] {
|
|
void* p = heap_caps_malloc(sizeof(MultiTransportCompanionInterface),
|
|
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
return p ? p : malloc(sizeof(MultiTransportCompanionInterface));
|
|
}();
|
|
MultiTransportCompanionInterface& serial_interface =
|
|
*new (s_si_mem) MultiTransportCompanionInterface();
|
|
#ifndef TCP_PORT
|
|
#define TCP_PORT 5000
|
|
#endif
|
|
#ifndef WS_PORT
|
|
#define WS_PORT 8765
|
|
#endif
|
|
|
|
#if defined(TLORA_PAGER)
|
|
static void pagerLogInternalHeap(const char* phase) {
|
|
const uint32_t caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
|
|
Serial.printf("[heap] %s: internal free=%u largest=%u low=%u dma=%u psram=%u\n",
|
|
phase,
|
|
(unsigned)heap_caps_get_free_size(caps),
|
|
(unsigned)heap_caps_get_largest_free_block(caps),
|
|
(unsigned)heap_caps_get_minimum_free_size(caps),
|
|
(unsigned)heap_caps_get_free_size(MALLOC_CAP_DMA),
|
|
(unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
|
|
}
|
|
// Claim Wi-Fi's coexistence resources first. A cold STA allocation or real
|
|
// WPA association owns a bounded ordering window; idle/scannable Wi-Fi and
|
|
// a failed association both allow BLE to run.
|
|
static bool s_pager_ble_after_wifi = false;
|
|
static uint32_t s_pager_wifi_ble_deadline_ms = 0;
|
|
static const uint32_t PAGER_WIFI_BLE_HANDOFF_MS = 6000;
|
|
|
|
static void pagerWifiEnterBleFallback(const char* reason) {
|
|
WiFi.setAutoReconnect(false);
|
|
// Stop WPA without deinitializing esp_wifi. Keeping the STA allocation
|
|
// resident lets BLE come back immediately and avoids repeating the
|
|
// order-sensitive cold allocation for a later scan or explicit retry.
|
|
if ((WiFi.getMode() & WIFI_MODE_STA) != 0) {
|
|
WiFi.disconnect(false, false);
|
|
}
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::BleFallback);
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
Serial.printf("[wifi] BLE fallback: %s\n", reason ? reason : "association stopped");
|
|
}
|
|
#endif
|
|
#elif defined(WIFI_SSID)
|
|
#include <helpers/esp32/SerialWifiInterface.h>
|
|
SerialWifiInterface serial_interface;
|
|
#ifndef TCP_PORT
|
|
#define TCP_PORT 5000
|
|
#endif
|
|
#elif defined(BLE_PIN_CODE)
|
|
#include <helpers/esp32/SerialBLEInterface.h>
|
|
SerialBLEInterface serial_interface;
|
|
#elif defined(SERIAL_RX)
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
HardwareSerial companion_serial(1);
|
|
#else
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
#endif
|
|
#elif defined(RP2040_PLATFORM)
|
|
//#ifdef WIFI_SSID
|
|
// #include <helpers/rp2040/SerialWifiInterface.h>
|
|
// SerialWifiInterface serial_interface;
|
|
// #ifndef TCP_PORT
|
|
// #define TCP_PORT 5000
|
|
// #endif
|
|
// #elif defined(BLE_PIN_CODE)
|
|
// #include <helpers/rp2040/SerialBLEInterface.h>
|
|
// SerialBLEInterface serial_interface;
|
|
#if defined(SERIAL_RX)
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
HardwareSerial companion_serial(1);
|
|
#else
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
#endif
|
|
#elif defined(NRF52_PLATFORM)
|
|
#ifdef BLE_PIN_CODE
|
|
#include <helpers/nrf52/SerialBLEInterface.h>
|
|
SerialBLEInterface serial_interface;
|
|
#else
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
#endif
|
|
#elif defined(STM32_PLATFORM)
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
#else
|
|
#error "need to define a serial interface"
|
|
#endif
|
|
|
|
/* GLOBAL OBJECTS */
|
|
#ifdef DISPLAY_CLASS
|
|
#include "UITask.h"
|
|
UITask ui_task(&board, &serial_interface);
|
|
#endif
|
|
|
|
StdRNG fast_rng;
|
|
SimpleMeshTables tables;
|
|
#if defined(ESP32_PLATFORM)
|
|
// the_mesh is ~42 KB (dominated by the MAX_CONTACTS ContactInfo array) and was the
|
|
// single biggest static internal-DRAM consumer. Place the whole object in PSRAM —
|
|
// the contacts array rides along inside it — and bind a reference so every
|
|
// `the_mesh.foo()` call site is unchanged. The constructor still runs HERE at
|
|
// static-init (PSRAM is already up; the UITask psAlloc statics rely on the same),
|
|
// so timing/behaviour are identical to the old direct global — only the address
|
|
// moves off internal DRAM. heap_caps falls back to internal RAM if PSRAM is absent.
|
|
static MyMesh& makeTheMesh() {
|
|
void* mem = heap_caps_malloc(sizeof(MyMesh), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
if (!mem) mem = malloc(sizeof(MyMesh)); // no-PSRAM fallback: behaves as before
|
|
return *new (mem) MyMesh(radio_driver, fast_rng, rtc_clock, tables, store
|
|
#ifdef DISPLAY_CLASS
|
|
, &ui_task
|
|
#endif
|
|
);
|
|
}
|
|
MyMesh& the_mesh = makeTheMesh();
|
|
#else
|
|
MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
|
|
#ifdef DISPLAY_CLASS
|
|
, &ui_task
|
|
#endif
|
|
);
|
|
#endif
|
|
|
|
/* END GLOBAL OBJECTS */
|
|
|
|
#if defined(ESP32)
|
|
volatile int g_boot_phase = 0;
|
|
extern "C" void set_boot_phase(int phase) { g_boot_phase = phase; }
|
|
// True once contacts/channels are actually routed to the SD card at boot (SD mounted). The
|
|
// "Store data on SD" toggle is only a stored intent — if the card didn't mount at boot, contacts
|
|
// silently stay on internal flash. The Storage settings page reads this to show the REAL location.
|
|
bool g_contacts_on_sd = false;
|
|
// True only when DataStore adopted SD as the primary identity/preferences
|
|
// backend for this boot. The UI history selector uses the same decision so an
|
|
// established card identity can never be paired with another profile's
|
|
// internal chat history merely because an old card lacks a migration marker.
|
|
bool g_full_data_on_sd = false;
|
|
// True when full SD adoption was requested but the guarded SPIFFS -> SD copy
|
|
// could not be proven complete. Settings surfaces the recovery action; contacts
|
|
// may still use SD as the secondary store while identity/prefs remain internal.
|
|
bool g_sd_migration_blocked = false;
|
|
#endif
|
|
|
|
|
|
void halt() {
|
|
while (1) ;
|
|
}
|
|
|
|
/* WIFI RECONNECT TRACKERS */
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
bool wifi_needs_reconnect = false;
|
|
unsigned long last_wifi_reconnect_attempt = 0;
|
|
#endif
|
|
#if defined(ESP32)
|
|
// Last STA disconnect reason (esp_wifi wifi_err_reason_t). The UI's coarse
|
|
// "auth failed" (WL_CONNECT_FAILED) covers wrong password, WPA3-SAE handshake
|
|
// failure and AP-side rejection alike — the reason number tells them apart
|
|
// (15 = 4-way handshake timeout ≈ wrong password; 2/202 = auth expired/failed
|
|
// ≈ WPA3-only or auth mismatch; 201 = AP not found).
|
|
//
|
|
// DEFINED in ui-touch/UITask.cpp, not here. The ESP32-P4 targets (Tanmatsu,
|
|
// T-Display P4) are IDF builds with their own main.cpp and never compile this
|
|
// file, so a definition here links on the S3 boards and leaves both P4 targets
|
|
// with an undefined reference from the UI that reads it.
|
|
extern volatile uint8_t g_wifi_last_disc_reason;
|
|
#endif
|
|
|
|
#include "esp_task_wdt.h" // task-watchdog reconfigure — see setup() (GH #56)
|
|
|
|
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HAS_CROWPANEL_35)
|
|
// ---- SPIFFS -> SD migration (fixes the beta_36 "lost my profile" upgrades) ----
|
|
// Users who flipped "Store data on SD" before beta_36 ran with the toggle IGNORED
|
|
// (the flag never survived a reboot), so their identity/prefs/contacts kept living
|
|
// on SPIFFS. When beta_36 made the flag finally take effect, useSdStorage() pointed
|
|
// the store at an EMPTY card and the identity store generated a brand-new node:
|
|
// "lost profile settings". The data was never gone — it was orphaned on SPIFFS.
|
|
// Copies every SPIFFS file into SD:/meshcomod ("/prefs/<ns>.kv" flattens to
|
|
// "/meshcomod/<ns>.kv", matching SdNvsPrefs's SD layout). Returns true only when
|
|
// every file landed on the card and a last-written completion marker commits
|
|
// that attempt. The caller must NOT adopt the card otherwise. force=true
|
|
// (the Settings "Copy internal data to SD" recovery button) overwrites whatever
|
|
// the card holds on legacy targets. Pager recovery is deliberately non-clobbering;
|
|
// its boot and manual paths only fill missing files and commit the marker last.
|
|
// Both filesystems must be mounted by the caller.
|
|
static constexpr const char* kInternalIdentity = "/identity/_main.id";
|
|
static constexpr const char* kSdIdentity = "/meshcomod/identity/_main.id";
|
|
static constexpr const char* kSdMigrationComplete = "/meshcomod/.spiffs-migration-v1";
|
|
static constexpr const char* kSdMigrationCompleteTmp = "/meshcomod/.spiffs-migration-v1.tmp";
|
|
|
|
// Byte-compare the card's identity against the internal one without logging
|
|
// either. Any read failure counts as a mismatch. True means the card carries
|
|
// THIS device's profile — migration never deletes its SPIFFS sources, so a
|
|
// device that already adopted the card still has a byte-identical copy here.
|
|
static bool sdIdentityMatchesInternal() {
|
|
File internal = SPIFFS.open(kInternalIdentity, FILE_READ);
|
|
if (!internal) return false;
|
|
File card = SD.open(kSdIdentity, FILE_READ);
|
|
if (!card || internal.size() == 0 || internal.size() != card.size()) {
|
|
internal.close();
|
|
if (card) card.close();
|
|
return false;
|
|
}
|
|
uint8_t internal_buf[64];
|
|
uint8_t card_buf[64];
|
|
bool matches = true;
|
|
while (matches && internal.available()) {
|
|
const size_t n = internal.read(internal_buf, sizeof internal_buf);
|
|
if (n == 0 || card.read(card_buf, n) != n || memcmp(internal_buf, card_buf, n) != 0)
|
|
matches = false;
|
|
}
|
|
if (card.available()) matches = false;
|
|
internal.close();
|
|
card.close();
|
|
return matches;
|
|
}
|
|
|
|
#if defined(TLORA_PAGER)
|
|
static constexpr const char* kSdMigrationOwner = "/meshcomod/.spiffs-migration-v1.owner";
|
|
static constexpr const char* kSdMigrationOwnerTmp = "/meshcomod/.spiffs-migration-v1.owner.tmp";
|
|
|
|
static bool sdProfileTreeContainsFile(const char* path, uint8_t depth = 0) {
|
|
// Only the root may be judged absent. A recursive child came straight from
|
|
// openNextFile(), so it provably exists and re-stat'ing it can only produce a
|
|
// false negative — which would be the one verdict that lets a foreign
|
|
// residual subtree read as empty and get claimed. Below the root, let the
|
|
// open() fail-closed path govern instead.
|
|
if (depth == 0 && !SD.exists(path)) return false;
|
|
if (depth >= 8) return true; // unexpected/deep content is not safe to adopt
|
|
File dir = SD.open(path, FILE_READ);
|
|
if (!dir) return true; // unreadable content is not an empty profile
|
|
if (!dir.isDirectory()) {
|
|
dir.close();
|
|
return true;
|
|
}
|
|
for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
|
|
const bool is_dir = entry.isDirectory();
|
|
if (!is_dir) {
|
|
entry.close();
|
|
dir.close();
|
|
return true;
|
|
}
|
|
char child[160];
|
|
const char* entry_path = entry.path();
|
|
const bool path_ok = entry_path && strlcpy(child, entry_path, sizeof child) < sizeof child;
|
|
entry.close();
|
|
// A truncated/unreadable directory name must fail closed. Treating the
|
|
// truncated path as absent could make a foreign residual subtree look empty.
|
|
if (!path_ok || child[0] == '\0' || sdProfileTreeContainsFile(child, depth + 1)) {
|
|
dir.close();
|
|
return true;
|
|
}
|
|
}
|
|
dir.close();
|
|
return false;
|
|
}
|
|
|
|
static bool internalIdentityDigest(uint8_t out[32]) {
|
|
File identity = SPIFFS.open(kInternalIdentity, FILE_READ);
|
|
if (!identity) return false;
|
|
mbedtls_sha256_context ctx;
|
|
mbedtls_sha256_init(&ctx);
|
|
int rc = mbedtls_sha256_starts_ret(&ctx, 0);
|
|
uint8_t chunk[128];
|
|
while (rc == 0 && identity.available()) {
|
|
const size_t n = identity.read(chunk, sizeof chunk);
|
|
if (n == 0) { rc = -1; break; }
|
|
rc = mbedtls_sha256_update_ret(&ctx, chunk, n);
|
|
}
|
|
if (rc == 0) rc = mbedtls_sha256_finish_ret(&ctx, out);
|
|
mbedtls_sha256_free(&ctx);
|
|
identity.close();
|
|
return rc == 0;
|
|
}
|
|
|
|
static bool sdDigestFileMatches(const char* path, const uint8_t expected[32]) {
|
|
File marker = SD.open(path, FILE_READ);
|
|
if (!marker || marker.size() != 32) {
|
|
if (marker) marker.close();
|
|
return false;
|
|
}
|
|
uint8_t actual[32];
|
|
const bool matches = marker.read(actual, sizeof actual) == sizeof actual &&
|
|
memcmp(actual, expected, sizeof actual) == 0;
|
|
marker.close();
|
|
return matches;
|
|
}
|
|
|
|
static bool ensureSdMigrationOwner() {
|
|
uint8_t digest[32];
|
|
if (!internalIdentityDigest(digest)) return false;
|
|
if (SD.exists(kSdMigrationOwner))
|
|
return sdDigestFileMatches(kSdMigrationOwner, digest);
|
|
if (SD.exists(kSdMigrationOwnerTmp)) {
|
|
if (sdDigestFileMatches(kSdMigrationOwnerTmp, digest))
|
|
return SD.rename(kSdMigrationOwnerTmp, kSdMigrationOwner);
|
|
// Someone else's half-written claim. Clearing it is safe because the tree
|
|
// check below still refuses any card that holds real payload; a stranded
|
|
// tmp marker on an otherwise empty card is not an established profile.
|
|
if (!SD.remove(kSdMigrationOwnerTmp)) return false;
|
|
}
|
|
// Without a matching owner marker, only a tree containing no file payload is
|
|
// safe to claim. Empty directories from an interrupted mkdir sequence are OK.
|
|
if (sdProfileTreeContainsFile("/meshcomod")) return false;
|
|
if (!SD.exists("/meshcomod") && !SD.mkdir("/meshcomod")) return false;
|
|
File marker = SD.open(kSdMigrationOwnerTmp, FILE_WRITE);
|
|
const bool wrote = marker && marker.write(digest, sizeof digest) == sizeof digest;
|
|
if (marker) marker.close();
|
|
return wrote && SD.rename(kSdMigrationOwnerTmp, kSdMigrationOwner);
|
|
}
|
|
|
|
// Manual recovery may resume onto an empty card or the same profile, but it
|
|
// must never fill one identity's missing files from another profile. Compare
|
|
// the small identity files without logging their contents before arming the
|
|
// durable migration latch. A read failure is treated as a mismatch.
|
|
bool meshcomodSdProfileMatchesInternal() {
|
|
if (!SPIFFS.exists(kInternalIdentity)) return false;
|
|
// An empty card is claimable; a populated one must prove it is ours.
|
|
if (!SD.exists(kSdIdentity)) return ensureSdMigrationOwner();
|
|
return sdIdentityMatchesInternal();
|
|
}
|
|
#endif
|
|
|
|
bool meshcomodPrepareSdMigration() {
|
|
if (SD.exists(kSdMigrationCompleteTmp) && !SD.remove(kSdMigrationCompleteTmp))
|
|
return false;
|
|
if (SD.exists(kSdMigrationComplete) && !SD.remove(kSdMigrationComplete))
|
|
return false;
|
|
#if defined(TLORA_PAGER)
|
|
if (!SD.exists(kSdIdentity) && !ensureSdMigrationOwner()) return false;
|
|
#endif
|
|
return true;
|
|
}
|
|
|
|
bool meshcomodMigrateSpiffsToSd(bool force) {
|
|
if (!SPIFFS.exists("/identity/_main.id")) return false; // nothing worth adopting
|
|
#if defined(TLORA_PAGER)
|
|
// Pager recovery is always resumable/non-clobbering. Keep that invariant in
|
|
// the migration primitive itself so a future caller cannot accidentally turn
|
|
// a profile-reconciliation action into an overwrite.
|
|
force = false;
|
|
#endif
|
|
SD.mkdir("/meshcomod");
|
|
SD.mkdir("/meshcomod/identity");
|
|
SD.mkdir("/meshcomod/bl");
|
|
SD.mkdir("/meshcomod/lock");
|
|
SD.mkdir("/meshcomod/msgs"); // chat segments: SPIFFS names them flat ("/msgs/seg_*.bin"),
|
|
// but the FAT card needs the real parent dir or every copy fails
|
|
SD.mkdir("/meshcomod/apps"); // Lua apps installed to internal storage before a card existed
|
|
bool identity_ok = false;
|
|
bool identity_deferred = false;
|
|
int copied = 0, failed = 0;
|
|
static uint8_t buf[4096];
|
|
// This routine runs only on loopTask. Keep the working paths off its stack:
|
|
// the manual recovery used to be called from a deep LVGL event callback and
|
|
// field evidence showed this buffer being corrupted after dozens of files.
|
|
static char src[96];
|
|
static char dst[112];
|
|
static char tmp[120];
|
|
const UBaseType_t low_water = uxTaskGetStackHighWaterMark(nullptr);
|
|
Serial.printf("[BOOT] SD migration start, loop stack low-water: %u bytes\n",
|
|
(unsigned)(low_water * sizeof(StackType_t)));
|
|
auto ensureSdParents = [](const char* path) -> bool {
|
|
char parent[112];
|
|
strlcpy(parent, path, sizeof(parent));
|
|
for (char* slash = strchr(parent + 1, '/'); slash; slash = strchr(slash + 1, '/')) {
|
|
*slash = '\0';
|
|
const bool ok = SD.exists(parent) || SD.mkdir(parent);
|
|
*slash = '/';
|
|
if (!ok) return false;
|
|
}
|
|
return true;
|
|
};
|
|
// Copy identity last. Its presence is the boot-time adoption key, so landing
|
|
// it before a later history/settings failure could make a partial card look
|
|
// complete if the NVS in-progress breadcrumb were ever lost. Existing card
|
|
// identities are never replaced by the non-clobbering Pager path.
|
|
auto copyFile = [&](File& source, const char* destination,
|
|
const char*& fail_stage) -> bool {
|
|
snprintf(tmp, sizeof tmp, "%s.mig", destination);
|
|
fail_stage = "parents";
|
|
bool ok = ensureSdParents(destination);
|
|
if (ok && SD.exists(tmp)) {
|
|
fail_stage = "stale temp";
|
|
ok = SD.remove(tmp); // only our own prior migration residue
|
|
}
|
|
File d;
|
|
if (ok) {
|
|
fail_stage = "open temp";
|
|
d = SD.open(tmp, FILE_WRITE);
|
|
ok = (bool)d;
|
|
}
|
|
size_t since_feed = 0;
|
|
const size_t source_size = source.size();
|
|
size_t source_read = 0;
|
|
while (ok && source_read < source_size) {
|
|
fail_stage = "read source";
|
|
const size_t remain = source_size - source_read;
|
|
const size_t n = source.read(buf, remain < sizeof(buf) ? remain : sizeof(buf));
|
|
if (n == 0) { ok = false; break; }
|
|
fail_stage = "write temp";
|
|
if (d.write(buf, n) != n) { ok = false; break; }
|
|
source_read += n;
|
|
// A large history file on a slow card can exceed the task-WDT window.
|
|
since_feed += n;
|
|
if (since_feed >= 32768) { esp_task_wdt_reset(); since_feed = 0; }
|
|
}
|
|
source.close();
|
|
if (d) d.close();
|
|
// Commit only a complete temporary file, preserving the old destination
|
|
// until its replacement is ready.
|
|
if (ok) {
|
|
fail_stage = "replace destination";
|
|
if (force && SD.exists(destination) && !SD.remove(destination)) ok = false;
|
|
if (ok) {
|
|
fail_stage = "commit rename";
|
|
if (!SD.rename(tmp, destination)) ok = false;
|
|
}
|
|
}
|
|
return ok;
|
|
};
|
|
File root = SPIFFS.open("/");
|
|
if (!root || !root.isDirectory()) {
|
|
if (root) root.close();
|
|
Serial.println("[BOOT] SD migrate FAILED: open SPIFFS root");
|
|
return false;
|
|
}
|
|
for (File f = root.openNextFile(); f; f = root.openNextFile()) {
|
|
if (f.isDirectory()) { f.close(); continue; }
|
|
// Arduino-ESP32 File::name() is only the basename (pathToFileName()). Use
|
|
// path() so nested SPIFFS names retain identity/, prefs/, msgs/, etc. The
|
|
// old name() assumption made us reopen "/_main.id" instead of
|
|
// "/identity/_main.id", causing the first full-SD boot migration to fail.
|
|
const char* sp = f.path();
|
|
snprintf(src, sizeof src, "%s%s", sp[0] == '/' ? "" : "/", sp);
|
|
if (strncmp(src, "/prefs/", 7) == 0)
|
|
snprintf(dst, sizeof dst, "/meshcomod/%s", src + 7); // kv files sit flat on the card
|
|
else
|
|
snprintf(dst, sizeof dst, "/meshcomod%s", src);
|
|
if (!force && SD.exists(dst)) {
|
|
if (strcmp(src, "/identity/_main.id") == 0) identity_ok = true;
|
|
f.close();
|
|
continue; // boot path: never clobber
|
|
}
|
|
if (strcmp(src, "/identity/_main.id") == 0) {
|
|
identity_deferred = true;
|
|
f.close();
|
|
continue;
|
|
}
|
|
const char* fail_stage = nullptr;
|
|
const bool ok = copyFile(f, dst, fail_stage);
|
|
if (ok) {
|
|
++copied;
|
|
} else {
|
|
++failed;
|
|
Serial.printf("[BOOT] SD migrate FAILED (%s): %s\n", fail_stage, src);
|
|
#if defined(TLORA_PAGER)
|
|
// A failed FAT operation can mean the card/volume is wedged. Do not issue
|
|
// remove(), exists(), or another copy against that same volume: the old
|
|
// cleanup call was exactly where the first requested SD boot could hang.
|
|
// The .mig name is never adopted and a later explicit retry removes it.
|
|
break;
|
|
#else
|
|
SD.remove(tmp);
|
|
#endif
|
|
}
|
|
esp_task_wdt_reset();
|
|
yield();
|
|
}
|
|
root.close();
|
|
if (failed != 0) {
|
|
Serial.printf("[BOOT] SPIFFS -> SD migration stopped after %d copied, %d failed\n",
|
|
copied, failed);
|
|
return false; // breadcrumb remains armed; no more SD calls on this boot
|
|
}
|
|
if (identity_deferred) {
|
|
strlcpy(src, "/identity/_main.id", sizeof(src));
|
|
strlcpy(dst, "/meshcomod/identity/_main.id", sizeof(dst));
|
|
File identity = SPIFFS.open(src, FILE_READ);
|
|
const char* fail_stage = "open source";
|
|
const bool identity_opened = (bool)identity;
|
|
bool ok = identity_opened;
|
|
if (ok) ok = copyFile(identity, dst, fail_stage);
|
|
if (!ok) {
|
|
if (identity) identity.close();
|
|
++failed;
|
|
Serial.printf("[BOOT] SD migrate FAILED (%s): %s\n", fail_stage, src);
|
|
#if !defined(TLORA_PAGER)
|
|
if (identity_opened) SD.remove(tmp);
|
|
#endif
|
|
Serial.printf("[BOOT] SPIFFS -> SD migration stopped after %d copied, %d failed\n",
|
|
copied, failed);
|
|
return false;
|
|
}
|
|
++copied;
|
|
identity_ok = true;
|
|
esp_task_wdt_reset();
|
|
yield();
|
|
}
|
|
// The boot path skips files the card already has — an identity already on the
|
|
// card counts as "landed" (nothing needed migrating). Identity alone is not
|
|
// enough, though: adopting after a larger history/prefs copy failed hides the
|
|
// complete SPIFFS store behind a partial SD tree.
|
|
Serial.printf("[BOOT] SPIFFS -> SD migration: %d copied, %d failed, identity %s\n",
|
|
copied, failed, identity_ok ? "ok" : "MISSING");
|
|
bool complete = identity_ok && failed == 0;
|
|
if (complete) {
|
|
if (SD.exists(kSdMigrationCompleteTmp) &&
|
|
!SD.remove(kSdMigrationCompleteTmp)) {
|
|
Serial.println("[BOOT] SD migrate FAILED: stale completion temp");
|
|
return false;
|
|
}
|
|
File marker = SD.open(kSdMigrationCompleteTmp, FILE_WRITE);
|
|
bool marker_ok = marker && marker.print("complete v1\n") == 12;
|
|
if (marker) marker.close();
|
|
if (marker_ok) {
|
|
if (SD.exists(kSdMigrationComplete) &&
|
|
!SD.remove(kSdMigrationComplete)) {
|
|
marker_ok = false;
|
|
} else {
|
|
marker_ok = SD.rename(kSdMigrationCompleteTmp, kSdMigrationComplete);
|
|
}
|
|
}
|
|
if (!marker_ok) {
|
|
Serial.println("[BOOT] SD migrate FAILED: completion marker");
|
|
// Treat marker failure like any other volume failure. Leaving the
|
|
// marker temp is harmless; touching the failed card again can hang.
|
|
return false;
|
|
}
|
|
}
|
|
return complete;
|
|
}
|
|
|
|
bool meshcomodArmSdMigLatch() {
|
|
Preferences prefs;
|
|
if (!prefs.begin("touch", false)) return false;
|
|
const bool armed = prefs.putBool("sd_mig_busy", true) == 1;
|
|
prefs.end();
|
|
return armed;
|
|
}
|
|
|
|
// Clear the boot safe-mode latch (see the SPIFFS->SD migration above): called after a
|
|
// successful manual "Copy internal data to SD" so a deliberate retry re-arms boot-time
|
|
// auto-adoption. The boot path re-latches on its own if a later migration wedges. GH #142/#148.
|
|
void meshcomodClearSdMigLatch() {
|
|
Preferences _mp;
|
|
if (_mp.begin("touch", false)) { _mp.remove("sd_mig_busy"); _mp.end(); }
|
|
g_sd_migration_blocked = false;
|
|
}
|
|
#endif
|
|
|
|
#if defined(ENV_INCLUDE_GPS) && (ENV_INCLUDE_GPS == 1)
|
|
// Whether Serial1 got the 4 KB RX ring (the boot-time gate in setup() below
|
|
// only installs it when gps_enabled was already persisted).
|
|
static bool s_gps_big_rx_ring = false;
|
|
// Called by the "gps on" paths (UITask::toggleGPS, the companion
|
|
// CMD_SET_CUSTOM_VAR("gps") handler) right after a SUCCESSFUL mid-session
|
|
// start. Such an enable used to run the whole session on the stock 256 B
|
|
// ring — setRxBufferSize is a no-op on a running UART, and initBasicGPS()
|
|
// opened Serial1 at boot — with only ~22 ms of slack at the M9's 115200
|
|
// baud, so any loop stall past that (a long LVGL frame; every 50 ms
|
|
// battery-saver park) clipped NMEA bursts until the next reboot. Cycle it:
|
|
// end -> resize -> begin, with the same pins/baud initBasicGPS() uses. Safe
|
|
// even though the line may already carry NMEA (boards without a GPS EN pin —
|
|
// T-Deck, pagers, RAK TAP — have always-powered modules that stream even
|
|
// while "stopped"): every Serial1 reader runs on this same loopTask, so the
|
|
// worst case is one torn, checksum-rejected sentence plus dropping the stale
|
|
// 256 B backlog.
|
|
void gpsEnsureBigRxRing() {
|
|
if (s_gps_big_rx_ring) return;
|
|
Serial1.end();
|
|
Serial1.setRxBufferSize(4096);
|
|
Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); // same (module-perspective) order as initBasicGPS
|
|
#ifdef GPS_BAUD_RATE
|
|
const uint32_t k_gps_baud_default = GPS_BAUD_RATE;
|
|
#else
|
|
const uint32_t k_gps_baud_default = 9600;
|
|
#endif
|
|
#if defined(HAS_TOUCH_UI) && defined(ESP32)
|
|
Serial1.begin(touchPrefsGetGpsBaud(k_gps_baud_default));
|
|
#else
|
|
Serial1.begin(k_gps_baud_default);
|
|
#endif
|
|
s_gps_big_rx_ring = true;
|
|
}
|
|
#endif
|
|
|
|
#if defined(HAS_WIO_TRACKER_L2) && defined(WIO_TRACKER_L2_GPS_PROBE)
|
|
// ============================================================================
|
|
// TEMP DIAGNOSTIC — remove this block and the -D WIO_TRACKER_L2_GPS_PROBE=1 line in
|
|
// platformio.ini once the Wio Tracker L2's GPS wiring is confirmed.
|
|
//
|
|
// the L2's PIN_GPS_RX=18 / PIN_GPS_TX=17 were added wholesale in the bring-up
|
|
// commit and are byte-identical to the Attaky env, so neither the pin pair nor
|
|
// GPS_BAUD_RATE=9600 has ever been checked against this board. ENV_SKIP_GPS_DETECT
|
|
// hard-codes gps_detected = true, so the UI reports a module present whether or
|
|
// not a byte ever arrives — this probe is the missing evidence.
|
|
//
|
|
// Runs after board.begin() (WioTrackerL2Io::begin has already powered the GNSS and
|
|
// released its reset) and before sensors.begin() (which is what normally opens
|
|
// Serial1), then leaves the UART closed for initBasicGPS() to reopen.
|
|
//
|
|
// RX only: txPin is -1 so the probe never drives a pin whose real function on
|
|
// this board is unconfirmed. Costs ~6 s of boot time while it runs.
|
|
// ============================================================================
|
|
static void wioTrackerL2GpsProbe() {
|
|
static const int8_t kPins[] = {17, 18}; // 17 = today's ESP RX, 18 = the swap
|
|
static const uint32_t kBauds[] = {9600, 38400, 115200};
|
|
|
|
Serial.println(F("[GPSPROBE] ---- start: 2 pins x 3 bauds, 1s each ----"));
|
|
Serial1.setRxBufferSize(1024); // must precede the first begin() to take effect
|
|
|
|
int8_t best_pin = -1;
|
|
uint32_t best_baud = 0;
|
|
uint32_t best_score = 0;
|
|
|
|
for (size_t pi = 0; pi < sizeof(kPins) / sizeof(kPins[0]); ++pi) {
|
|
for (size_t bi = 0; bi < sizeof(kBauds) / sizeof(kBauds[0]); ++bi) {
|
|
const int8_t pin = kPins[pi];
|
|
const uint32_t baud = kBauds[bi];
|
|
Serial1.begin(baud, SERIAL_8N1, pin, -1 /*no TX*/);
|
|
|
|
uint32_t total = 0, printable = 0, dollars = 0;
|
|
char head[40];
|
|
uint8_t head_n = 0;
|
|
const uint32_t deadline = millis() + 1000;
|
|
while ((int32_t)(millis() - deadline) < 0) {
|
|
while (Serial1.available() > 0) {
|
|
const int c = Serial1.read();
|
|
if (c < 0) break;
|
|
++total;
|
|
if (c == '$') ++dollars;
|
|
if (c >= 0x20 && c < 0x7F) ++printable;
|
|
if (head_n < sizeof(head) - 1)
|
|
head[head_n++] = (c >= 0x20 && c < 0x7F) ? (char)c : '.';
|
|
}
|
|
delay(1); // yield to IDLE so the task watchdog stays fed
|
|
}
|
|
Serial1.end();
|
|
head[head_n] = '\0';
|
|
|
|
// NMEA is all-printable and sentence-framed. Garbage at the wrong baud
|
|
// still yields bytes, so require both a high printable ratio AND '$'.
|
|
const bool looks_nmea = (total > 20) && (dollars > 0) && (printable * 10 >= total * 9);
|
|
Serial.printf("[GPSPROBE] rx=%-2d baud=%-6lu bytes=%-5lu printable=%-5lu '$'=%-3lu %s%s%s\n",
|
|
(int)pin, (unsigned long)baud, (unsigned long)total,
|
|
(unsigned long)printable, (unsigned long)dollars,
|
|
looks_nmea ? "<== NMEA " : "", head_n ? "head=" : "", head);
|
|
if (looks_nmea && total > best_score) { best_score = total; best_pin = pin; best_baud = baud; }
|
|
}
|
|
}
|
|
|
|
if (best_pin < 0) {
|
|
Serial.println(F("[GPSPROBE] no NMEA on 17 or 18 at any baud."));
|
|
Serial.println(F("[GPSPROBE] -> module is on another pin, unpowered, or silent. Next: confirm"));
|
|
Serial.println(F("[GPSPROBE] the schematic, or scope the module's TX for activity."));
|
|
} else {
|
|
Serial.printf("[GPSPROBE] NMEA found: ESP RX=%d at %lu baud.\n",
|
|
(int)best_pin, (unsigned long)best_baud);
|
|
// NAMING TRAP (see the comments at platformio.ini:273/935/1182):
|
|
// EnvironmentSensorManager calls Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX) and
|
|
// Arduino's setPins() takes (rxPin, txPin) -- so the pin we RECEIVE on is
|
|
// PIN_GPS_TX, and PIN_GPS_RX is the pin we transmit on.
|
|
Serial.printf("[GPSPROBE] -> platformio.ini wio_tracker_l2 env should read: PIN_GPS_TX=%d, PIN_GPS_RX=%d, GPS_BAUD_RATE=%lu\n",
|
|
(int)best_pin, (int)(best_pin == 17 ? 18 : 17), (unsigned long)best_baud);
|
|
}
|
|
Serial.println(F("[GPSPROBE] ---- end ----"));
|
|
}
|
|
#endif // HAS_WIO_TRACKER_L2 && WIO_TRACKER_L2_GPS_PROBE
|
|
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
#if defined(HAS_RAK_TAP_V2)
|
|
delay(1500); // USB-CDC enumeration before boot logs
|
|
#else
|
|
delay(200);
|
|
#endif
|
|
Serial.println("[BOOT] setup start");
|
|
// The SDK ships CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096: every allocation
|
|
// under 4 KB is forced into internal DRAM even when PSRAM is free. On the V4
|
|
// that is why internal sat at ~99% while 800+ KB of PSRAM went unused. Lower
|
|
// the threshold at runtime so ordinary mallocs land in PSRAM; anything that
|
|
// genuinely needs internal/DMA memory asks for it by capability and is
|
|
// unaffected. Tunable — raise it if something misbehaves.
|
|
heap_caps_malloc_extmem_enable(256);
|
|
|
|
// Widen the task-watchdog grace period. The ~5 s default trips during a legitimate-but-slow flash
|
|
// burst — a SPIFFS garbage-collect, or a bulk save (DataStore issues ~12 flash ops per contact,
|
|
// thousands for a full address book). A flash op parks BOTH cores with the cache disabled, so both
|
|
// IDLE tasks miss their reset and the watchdog panics (decoded coredumps: task_wdt CPU0=IDLE0
|
|
// CPU1=IDLE1, core 0 in spi_flash_op_block_func). The burst can't be chunked under the limit easily
|
|
// and the per-core WdtHeavyGuard only covers core 0, so give the dog enough headroom to ride the
|
|
// burst out while still catching a genuine multi-second hang. (Fixes the random WDT reboots, GH #56.)
|
|
esp_task_wdt_init(20, true); // 20 s grace (was ~5 s), keep panic. Re-init reconfigures the
|
|
// already-running TWDT + keeps the idle-task subscriptions.
|
|
|
|
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
|
|
// Record which slot we booted from so the recovery's "Boot firmware" can return
|
|
// here. Recovery-first itself is enforced by the CUSTOM bootloader (it boots
|
|
// factory by default and an ota slot only on its one-shot flag), so we must NOT
|
|
// touch otadata here — otadata just tracks which A/B slot is current, and the
|
|
// bootloader's default-to-factory is what makes recovery survive ANY app
|
|
// (Meshtastic included). Skipped where there's no factory partition (V4 /
|
|
// standalone dual-OTA T-Deck).
|
|
{
|
|
const esp_partition_t* fac =
|
|
esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, NULL);
|
|
if (fac) {
|
|
const esp_partition_t* run = esp_ota_get_running_partition();
|
|
Preferences pslot;
|
|
if (pslot.begin("mcboot", false)) {
|
|
pslot.putString("slot", (run && run->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_1) ? "ota_1" : "ota_0");
|
|
pslot.end();
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
{
|
|
bool aes_ok = mesh::Utils::selfTestAES();
|
|
Serial.printf("[BOOT] AES self-test: %s\n", aes_ok ? "PASS" : "FAIL");
|
|
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
|
|
mesh_touch_tx_tracef("AES_SELFTEST: %s", aes_ok ? "PASS" : "FAIL");
|
|
#endif
|
|
}
|
|
|
|
board.begin();
|
|
Serial.println("[BOOT] board ok");
|
|
|
|
#if defined(HAS_RAK_TAP_V2)
|
|
// Quick PSRAM sanity check — silent crash before SPIFFS could be bad PSRAM config
|
|
{
|
|
void* p = heap_caps_malloc(64, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
bool psram_ok = (p != NULL);
|
|
if (p) {
|
|
memset(p, 0x55, 64);
|
|
bool match = true;
|
|
for (int i = 0; i < 64; ++i) match &= (((uint8_t*)p)[i] == 0x55);
|
|
free(p);
|
|
psram_ok = match;
|
|
}
|
|
Serial.printf("[BOOT] psram probe: %s\n", psram_ok ? "OK" : "FAIL"); Serial.flush();
|
|
if (!psram_ok) { Serial.println("[BOOT] FATAL: PSRAM readback mismatch — halting"); halt(); }
|
|
}
|
|
#endif
|
|
|
|
#ifdef DISPLAY_CLASS
|
|
DisplayDriver* disp = NULL;
|
|
if (display.begin()) {
|
|
disp = &display;
|
|
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
|
|
// Rotate the panel to the saved UI orientation BEFORE painting the boot
|
|
// wordmark, so it's upright in landscape too (UITask applies the same
|
|
// hardware rotation later for the LVGL UI). ROT_90->1, ROT_270->3. Settings
|
|
// live in SPIFFS/SD, which aren't mounted yet, so read the NVS boot copy.
|
|
{
|
|
uint8_t r = touchPrefsGetBootUiRotation();
|
|
#if defined(ATTAKY_MESH_SERIES)
|
|
if (r != 0) r = 1; // UI init coerces any landscape to ROT_90
|
|
#endif
|
|
if (r == 1) display.setDisplayRotation(1);
|
|
else if (r == 3) display.setDisplayRotation(3);
|
|
#if defined(HAS_CROWPANEL_35)
|
|
else display.setDisplayRotation(0); // restore saved portrait
|
|
#endif
|
|
}
|
|
#endif
|
|
#if defined(HAS_TDECK_PRO)
|
|
// Never ink the retained e-paper black during the pre-LVGL boot. Console
|
|
// Mode keeps this polarity, and the graphical day UI replaces it later.
|
|
// This cannot depend on the mode preference: file-backed prefs are reloaded
|
|
// only after storage mounts, well after this first frame is committed.
|
|
display.startFrame((ColorVal)0xFFFF);
|
|
display.setColor((ColorVal)0x0000);
|
|
display.setTextSize(2);
|
|
display.drawTextCentered(display.width() / 2, display.height() / 2 - 8, "WADAMESH");
|
|
display.endFrame();
|
|
#else
|
|
// Paint the WADAMESH mesh mark the instant the panel is up, so the logo is on
|
|
// screen from power-on — before LVGL is ready. Blitted as an anti-aliased
|
|
// RGB565 bitmap via the full-res LVGL path (writePixelsRGB565), so the
|
|
// diagonals are smooth, not 1-bit jagged. White-on-black here; the teal dots
|
|
// arrive with the LVGL splash. Centred exactly: the artwork is centred within
|
|
// the bitmap, and the colour splash mark is centred to the same point, so the
|
|
// hand-off stays in place.
|
|
// Explicit black: startFrame()'s default is UIColor::window_bkg, and on boards
|
|
// whose DISPLAY_CLASS is a core driver the 1.17 core's palette makes that WHITE
|
|
// (the boot logo grew a white border). Graphical pre-LVGL screens stay dark.
|
|
display.startFrame((ColorVal)0x0000);
|
|
display.writePixelsRGB565((display.width() - WADAMESH_MARK_W) / 2,
|
|
(display.height() - WADAMESH_MARK_H) / 2,
|
|
WADAMESH_MARK_W, WADAMESH_MARK_H, WADAMESH_MARK_RGB565);
|
|
display.endFrame();
|
|
#endif
|
|
}
|
|
#endif
|
|
|
|
bool radio_ok = radio_init();
|
|
if (!radio_ok) { delay(150); radio_ok = radio_init(); } // one retry: transient SPI/reset flakes
|
|
#if defined(PIN_PERF_POWERON)
|
|
// Last resort: do what the error screen would otherwise ask the USER to do —
|
|
// power-cycle the module — by bouncing the rail that feeds it.
|
|
//
|
|
// On boards with a peripheral power gate (T-Deck, GPIO10) the LoRa module is only
|
|
// powered once that pin is driven high in Board::begin(), a few milliseconds before
|
|
// it gets probed. A cold start survives that because the rail was already settled;
|
|
// a SOFTWARE reset does not. An OTA update ends in ESP.restart(), which releases the
|
|
// pin, collapses the rail, and re-drives it immediately — so the radio is probed
|
|
// while it is still coming up, and the boot dead-ends on "LoRa radio not detected"
|
|
// until the user power-cycles by hand (reported on a T-Deck straight after an
|
|
// update; a reboot cleared it). A brief, deliberate LOW gives the module a clean
|
|
// power-on reset regardless of how this boot was reached.
|
|
//
|
|
// Only reached when the alternative is halting with the fatal screen, so it cannot
|
|
// regress a healthy boot: a board that probes fine never runs any of this.
|
|
if (!radio_ok) {
|
|
Serial.println("[BOOT] radio not detected — power-cycling the peripheral rail and retrying");
|
|
digitalWrite(PIN_PERF_POWERON, LOW);
|
|
delay(120); // long enough for the rail to actually drain
|
|
digitalWrite(PIN_PERF_POWERON, HIGH);
|
|
delay(250); // POR + crystal startup before the module answers SPI
|
|
radio_ok = radio_init();
|
|
if (radio_ok) Serial.println("[BOOT] radio came up after the power-cycle");
|
|
}
|
|
#endif
|
|
if (!radio_ok) {
|
|
// A dead or absent LoRa module used to halt behind the frozen boot logo,
|
|
// with the only clue on serial (#244). Say it on the panel instead.
|
|
Serial.println("[BOOT] FATAL: LoRa radio init failed — halting with on-screen notice");
|
|
#ifdef DISPLAY_CLASS
|
|
if (disp) {
|
|
display.startFrame((ColorVal)0x0000); // explicit dark (core palettes vary — see boot splash)
|
|
display.setTextSize(1);
|
|
display.setColor((ColorVal)0xF800); // red
|
|
display.drawTextCentered(display.width() / 2, display.height() / 2 - 20, "LoRa radio not detected");
|
|
display.setColor((ColorVal)0xFFFF); // white
|
|
display.drawTextCentered(display.width() / 2, display.height() / 2 + 2, "wadamesh needs the LoRa module.");
|
|
display.drawTextCentered(display.width() / 2, display.height() / 2 + 16, "Check it is fitted, then power-cycle.");
|
|
display.endFrame();
|
|
}
|
|
#endif
|
|
halt();
|
|
}
|
|
Serial.println("[BOOT] radio ok");
|
|
|
|
fast_rng.begin(radio_driver.getRngSeed());
|
|
|
|
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
|
|
{
|
|
Preferences prefs;
|
|
if (prefs.begin("mcboot", false)) {
|
|
uint32_t bn = prefs.getUInt("n", 0);
|
|
++bn;
|
|
prefs.putUInt("n", bn);
|
|
size_t nvs_free = prefs.freeEntries(); // NVS partition headroom; near 0 = full (the boot-loop trigger)
|
|
prefs.end();
|
|
meshcomod_touch_set_boot_stats(bn, static_cast<uint8_t>(esp_reset_reason()));
|
|
Serial.printf("[BOOT] touch_boot_n=%lu reason=%u nvs_free_entries=%u\n",
|
|
static_cast<unsigned long>(bn),
|
|
static_cast<unsigned>(esp_reset_reason()),
|
|
static_cast<unsigned>(nvs_free));
|
|
#if defined(HAS_RAK_TAP_V2)
|
|
Serial.flush();
|
|
}
|
|
Serial.println("[BOOT] about to call initTxtTxUniquenessFromRng..."); Serial.flush();
|
|
the_mesh.initTxtTxUniquenessFromRng();
|
|
Serial.println("[BOOT] initTxtTxUniqueness done"); Serial.flush();
|
|
#else
|
|
}
|
|
the_mesh.initTxtTxUniquenessFromRng();
|
|
#endif
|
|
}
|
|
#endif
|
|
|
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
|
InternalFS.begin();
|
|
#if defined(QSPIFLASH)
|
|
if (!QSPIFlash.begin()) {
|
|
// debug output might not be available at this point, might be too early. maybe should fall back to InternalFS here?
|
|
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: failed to initialize");
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: initialized successfully");
|
|
}
|
|
#else
|
|
#if defined(EXTRAFS)
|
|
ExtraFS.begin();
|
|
#endif
|
|
#endif
|
|
store.begin();
|
|
the_mesh.begin(
|
|
#ifdef DISPLAY_CLASS
|
|
disp != NULL
|
|
#else
|
|
false
|
|
#endif
|
|
);
|
|
|
|
#ifdef BLE_PIN_CODE
|
|
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
|
#else
|
|
serial_interface.begin(Serial);
|
|
#endif
|
|
the_mesh.startInterface(serial_interface);
|
|
#elif defined(RP2040_PLATFORM)
|
|
LittleFS.begin();
|
|
store.begin();
|
|
the_mesh.begin(
|
|
#ifdef DISPLAY_CLASS
|
|
disp != NULL
|
|
#else
|
|
false
|
|
#endif
|
|
);
|
|
|
|
//#ifdef WIFI_SSID
|
|
// WiFi.begin(WIFI_SSID, WIFI_PWD);
|
|
// serial_interface.begin(TCP_PORT);
|
|
// #elif defined(BLE_PIN_CODE)
|
|
// char dev_name[32+16];
|
|
// sprintf(dev_name, "%s%s", BLE_NAME_PREFIX, the_mesh.getNodeName());
|
|
// serial_interface.begin(dev_name, the_mesh.getBLEPin());
|
|
#if defined(SERIAL_RX)
|
|
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
|
companion_serial.begin(115200);
|
|
serial_interface.begin(companion_serial);
|
|
#else
|
|
serial_interface.begin(Serial);
|
|
#endif
|
|
the_mesh.startInterface(serial_interface);
|
|
#elif defined(ESP32)
|
|
// Storage selection. SPIFFS by default; use the SD card under /meshcomod when
|
|
// SPIFFS is unavailable (e.g. installed under Launcher) OR the user opted in
|
|
// ("Store data on SD"). The SD shares the LoRa SPI bus, already brought up by
|
|
// radio_init() above, so SD.begin's spi.begin is a no-op. Graceful: any SD
|
|
// failure falls back to SPIFFS so the device always boots.
|
|
bool spiffs_ok = SPIFFS.begin(false); // try first WITHOUT auto-format
|
|
bool sd_storage = false;
|
|
#if defined(HAS_WIO_TRACKER_L2)
|
|
// This target uses a dedicated one-bit SD_MMC bus rather than the LoRa SPI
|
|
// bus. Power the card first, set the non-default pins, and adopt it as the
|
|
// complete store when available; SPIFFS remains the graceful fallback.
|
|
//
|
|
// 20 MHz (SDMMC_FREQ_DEFAULT), not Arduino's 40 MHz HIGHSPEED default: CLK/CMD/D0
|
|
// are ordinary GPIOs (2/3/1) routed through the GPIO matrix, not an IOMUX SD pad
|
|
// group, and this is the card the *whole* data store lives on. At 40 MHz a marginal
|
|
// edge shows up as a mid-session "sdmmc_host_wait_for_event returned 0x107"
|
|
// (ESP_ERR_TIMEOUT) that takes prefs, contacts and chat history down with it. The
|
|
// T-Display P4 hot-insert path already drops to this rate for the same reason.
|
|
sdMountDiagBegin();
|
|
const bool wio_l2_sd_bus_ready = WioTrackerL2Io::ready() &&
|
|
WioTrackerL2Io::setSdPower(true) &&
|
|
SD_MMC.setPins(2, 3, 1);
|
|
bool wio_l2_sd_ready = false;
|
|
if (wio_l2_sd_bus_ready) {
|
|
const auto try_wio_l2_sd_mount = []() {
|
|
const bool begin_ok = SD_MMC.begin("/sdcard", true, false, SDMMC_FREQ_DEFAULT);
|
|
const bool card_ready = begin_ok && SD_MMC.cardType() != CARD_NONE;
|
|
sdMountDiagAttempt((uint32_t)SDMMC_FREQ_DEFAULT * 1000u, begin_ok, card_ready);
|
|
return card_ready;
|
|
};
|
|
|
|
// The expander-controlled rail has only just risen. Some cards are not yet
|
|
// responsive when SD_MMC starts its handshake, especially after flashing.
|
|
delay(150);
|
|
wio_l2_sd_ready = try_wio_l2_sd_mount();
|
|
if (!wio_l2_sd_ready) {
|
|
SD_MMC.end();
|
|
(void)WioTrackerL2Io::setSdPower(false);
|
|
delay(100);
|
|
if (WioTrackerL2Io::setSdPower(true)) {
|
|
delay(250);
|
|
Serial.println("[BOOT] wio-l2 SD_MMC: retrying after rail power cycle");
|
|
wio_l2_sd_ready = try_wio_l2_sd_mount();
|
|
}
|
|
}
|
|
}
|
|
sdMountDiagSetMounted(wio_l2_sd_ready,
|
|
wio_l2_sd_ready ? (uint32_t)SDMMC_FREQ_DEFAULT * 1000u : 0);
|
|
if (wio_l2_sd_ready) {
|
|
sd_storage = store.useSdMmcStorage();
|
|
g_contacts_on_sd = sd_storage;
|
|
g_full_data_on_sd = sd_storage;
|
|
Serial.printf("[BOOT] wio-l2 SD_MMC: %s\n", sd_storage ? "adopted" : "mount only");
|
|
} else {
|
|
SD_MMC.end();
|
|
(void)WioTrackerL2Io::setSdPower(false);
|
|
Serial.println("[BOOT] wio-l2 SD_MMC unavailable; using SPIFFS");
|
|
}
|
|
#endif
|
|
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HAS_CROWPANEL_35)
|
|
{
|
|
#if !defined(HAS_CROWPANEL_35)
|
|
#if defined(TLORA_PAGER)
|
|
extern SPIClass* tloraPagerSharedSPI(); // display/radio/SD shared bus
|
|
#elif defined(HELTEC_LORA_V4_R8)
|
|
extern SPIClass* heltecV4R8SharedSPI(); // FSPI, shared with the TFT (CS=3)
|
|
#elif defined(HAS_THINKNODE_M9)
|
|
extern SPIClass* m9SharedSPI(); // radio/display/SD shared bus
|
|
#else
|
|
extern SPIClass* tdeckSharedSPI(); // LoRa SPI bus
|
|
#endif
|
|
#endif
|
|
bool setup_done = false;
|
|
{ Preferences _p; if (_p.begin("touch", true)) {
|
|
setup_done = _p.getBool("setup_ok", false); // finished first-run setup
|
|
_p.end();
|
|
} }
|
|
const bool use_sd_pref = touchPrefsReadUseSdAtBoot(); // NVS + /prefs/touch.kv
|
|
|
|
// First-run SD default: the very first time meshcomod boots on a brand-new
|
|
// device — the user hasn't finished setup yet AND nothing is stored on
|
|
// SPIFFS — prefer the SD card when one is present. Keeps internal flash free
|
|
// and is Launcher-friendly. The "no SPIFFS data" guard is what makes this
|
|
// safe: a device that already holds data on internal flash (e.g. one updated
|
|
// from an earlier build) is never silently migrated onto an empty card.
|
|
bool spiffs_has_data = spiffs_ok &&
|
|
(SPIFFS.exists("/new_prefs") || SPIFFS.exists("/node_prefs") ||
|
|
SPIFFS.exists("/identity/_main.id"));
|
|
bool fresh_install = !use_sd_pref && !setup_done && !spiffs_has_data;
|
|
|
|
// Move the WHOLE store (identity/prefs/contacts) to SD:/meshcomod when the
|
|
// device has no usable SPIFFS, the user opted in, or it's a brand-new device.
|
|
bool want_full_sd = !spiffs_ok || use_sd_pref || fresh_install;
|
|
|
|
#if !defined(HAS_CROWPANEL_35)
|
|
#if defined(TLORA_PAGER)
|
|
SPIClass* _spi = tloraPagerSharedSPI();
|
|
#elif defined(HELTEC_LORA_V4_R8)
|
|
SPIClass* _spi = heltecV4R8SharedSPI();
|
|
#elif defined(HAS_THINKNODE_M9)
|
|
SPIClass* _spi = m9SharedSPI();
|
|
#else
|
|
SPIClass* _spi = tdeckSharedSPI();
|
|
#endif
|
|
#endif
|
|
bool sd_mounted = false;
|
|
#if defined(HAS_CROWPANEL_35)
|
|
sdMountDiagBegin();
|
|
const bool begin_ok = SD.begin();
|
|
sd_mounted = begin_ok && SD.cardType() != CARD_NONE;
|
|
sdMountDiagAttempt(0, begin_ok, sd_mounted);
|
|
sdMountDiagSetMounted(sd_mounted, 0);
|
|
if (!sd_mounted) Serial.println("[BOOT] CrowPanel SD unavailable; using SPIFFS");
|
|
#elif defined(TLORA_PAGER)
|
|
sdMountDiagBegin();
|
|
if (!_spi) {
|
|
Serial.println("[BOOT] SD: shared SPI unavailable");
|
|
} else if (!board.sdCardPresent()) {
|
|
Serial.println("[BOOT] SD: no card detected");
|
|
} else {
|
|
// Match LilyGo's pager bring-up: the card shares the already-running
|
|
// display/radio SPIClass and mounts at a conservative 4 MHz. A failure
|
|
// gets one dedicated SD-rail reset and retry; the shared bus itself is
|
|
// never torn down or hidden behind a broad retry ladder.
|
|
Serial.println("[BOOT] SD: card detected; mounting at 4 MHz");
|
|
bool sd_begin_ok = SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 6);
|
|
sd_mounted = sd_begin_ok && SD.cardType() != CARD_NONE;
|
|
sdMountDiagAttempt(4000000, sd_begin_ok, sd_mounted);
|
|
if (!sd_mounted) {
|
|
if (sd_begin_ok) {
|
|
// Release only the unusable mount created above. SD.end() unregisters
|
|
// this SD VFS; it does not stop the shared SPIClass used by TFT/radio.
|
|
SD.end();
|
|
}
|
|
// Keep the board bring-up invariant explicit even if the SD library
|
|
// changed this pin while unwinding a failed mount.
|
|
pinMode(PIN_SD_CS, OUTPUT);
|
|
digitalWrite(PIN_SD_CS, HIGH);
|
|
// SD_EN survives software power-off because the XL9555 remains powered
|
|
// during deep sleep. Give a host-used/reinserted card a real controller
|
|
// reset, then retry once without disturbing the shared SPIClass.
|
|
if (board.resetSdCardPower()) {
|
|
Serial.println("[BOOT] SD: retrying after rail power cycle");
|
|
sd_begin_ok = SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 6);
|
|
sd_mounted = sd_begin_ok && SD.cardType() != CARD_NONE;
|
|
sdMountDiagAttempt(4000000, sd_begin_ok, sd_mounted);
|
|
}
|
|
}
|
|
if (!sd_mounted) {
|
|
if (sd_begin_ok) SD.end();
|
|
pinMode(PIN_SD_CS, OUTPUT);
|
|
digitalWrite(PIN_SD_CS, HIGH);
|
|
}
|
|
Serial.printf("[BOOT] SD mount: %s\n", sd_mounted ? "ok" : "failed");
|
|
}
|
|
sdMountDiagSetMounted(sd_mounted, sd_mounted ? 4000000 : 0);
|
|
#else
|
|
sdMountDiagBegin();
|
|
uint32_t mounted_hz = 0;
|
|
if (_spi) {
|
|
// Try to mount the card on EVERY boot: even a device that keeps identity on SPIFFS
|
|
// wants its churn-heavy contacts/channels on the card.
|
|
//
|
|
// Use the SAME forgiving ladder the map-tile mount (fmSdTryMount) uses — dropping to
|
|
// 1 MHz then 400 kHz — not a fast 4 MHz-only ladder. A cold / cheap / slow-to-wake card
|
|
// fails a 4 MHz-only mount here but later succeeds the slow tile mount, so contacts got
|
|
// silently pushed back to SPIFFS *even though the toggle said SD and tiles worked* — then
|
|
// SPIFFS churn eventually lost them (gubbinsgalore's "100 repeaters gone overnight").
|
|
// Matching the tile ladder means contacts land on the card wherever tiles do. A card-less
|
|
// device still bails fast (3 quick tries at 4 MHz, ~480 ms); only a present-but-cold card
|
|
// walks down to the slow clocks. delay() feeds the task WDT, so the ~2.8 s worst case
|
|
// (present cold card only) doesn't trip it.
|
|
static const struct { uint16_t settle_ms; uint32_t hz; } kBootMount[] = {
|
|
{ 40, 4000000 }, { 220, 4000000 }, { 220, 4000000 },
|
|
{ 300, 1000000 }, { 450, 1000000 }, { 650, 400000 }, { 900, 400000 },
|
|
};
|
|
int tries = want_full_sd ? 7 : 3;
|
|
for (int a = 0; a < tries && !sd_mounted; ++a) {
|
|
SD.end();
|
|
delay(kBootMount[a].settle_ms);
|
|
const bool begin_ok = SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 6);
|
|
const bool card_ready = begin_ok && SD.cardType() != CARD_NONE;
|
|
sdMountDiagAttempt(kBootMount[a].hz, begin_ok, card_ready);
|
|
if (card_ready) {
|
|
sd_mounted = true;
|
|
mounted_hz = kBootMount[a].hz;
|
|
}
|
|
}
|
|
// RENEGOTIATE UPWARD after a slow-rung success (GH #194). Standard SD bring-up is
|
|
// "initialise at a conservative clock, then raise it" — but SD.begin's clock is the
|
|
// operating clock for the whole session, so a card that only WOKE at 400 kHz then ran
|
|
// its entire life at 400 kHz (~25 KB/s). With the card as the primary store that was a
|
|
// ~3-minute boot (contacts + history at modem speed), runtime f_getfree timeouts (the
|
|
// file manager showing an inserted card as 0 KB/empty), and wedged backups. An
|
|
// initialised card almost always sustains 4 MHz even when its power-up handshake needed
|
|
// 400 kHz; if the fast re-begin fails, fall back to the clock that just worked.
|
|
if (sd_mounted && mounted_hz < 4000000) {
|
|
SD.end();
|
|
delay(60);
|
|
const bool fast_begin_ok = SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 6);
|
|
const bool fast_ready = fast_begin_ok && SD.cardType() != CARD_NONE;
|
|
sdMountDiagAttempt(4000000, fast_begin_ok, fast_ready);
|
|
if (fast_ready) {
|
|
Serial.printf("[BOOT] SD renegotiated %lu -> 4000000 Hz\n", (unsigned long)mounted_hz);
|
|
mounted_hz = 4000000;
|
|
} else {
|
|
SD.end();
|
|
delay(120);
|
|
const bool fallback_begin_ok = SD.begin(PIN_SD_CS, *_spi, mounted_hz, "/sd", 6);
|
|
sd_mounted = fallback_begin_ok && SD.cardType() != CARD_NONE;
|
|
sdMountDiagAttempt(mounted_hz, fallback_begin_ok, sd_mounted);
|
|
if (sd_mounted) Serial.printf("[BOOT] SD stays at %lu Hz (4 MHz renegotiation failed)\n", (unsigned long)mounted_hz);
|
|
}
|
|
}
|
|
// Operating-clock raise with read-verify (SD_SPI_FAST_HZ boards only; no-op elsewhere).
|
|
if (sd_mounted) { mounted_hz = sdTryFastClock(PIN_SD_CS, *_spi, mounted_hz, "BOOT"); sd_mounted = mounted_hz != 0; }
|
|
if (sd_mounted) { extern uint32_t g_sd_operating_hz; g_sd_operating_hz = mounted_hz; } // About-page readout (UITask.cpp)
|
|
}
|
|
sdMountDiagSetMounted(sd_mounted, mounted_hz);
|
|
#endif
|
|
if (sd_mounted) {
|
|
g_contacts_on_sd = true; // every branch below routes contacts/channels to the card
|
|
if (want_full_sd) {
|
|
// beta_36 upgrade heal: adopting the card while the LIVE data still sits
|
|
// on SPIFFS (the pre-beta_36 "toggle ignored" state) must migrate FIRST,
|
|
// or the identity store mints a fresh node on the empty card and the user
|
|
// "loses" their profile. Only fires when SPIFFS holds an identity the
|
|
// card lacks; a failed migration keeps the device on SPIFFS this boot
|
|
// rather than adopting a card without the identity on it.
|
|
bool adopt = true;
|
|
if (SPIFFS.exists("/identity/_main.id")) {
|
|
// Boot safe-mode (GH #142/#148): a wedged or corrupt SD card can hang / WDT-reboot the
|
|
// device mid-migration, stranding it on the boot screen EVERY boot (reset can't escape,
|
|
// only a downgrade could). Drop an NVS breadcrumb before migrating and clear it only if
|
|
// the copy fully completes. If it's still set at the next boot, the last migration failed
|
|
// -> skip it and boot from SPIFFS (the data is safe there); Settings > "Copy internal data
|
|
// to SD" re-arms a deliberate retry. A merely-slow (healthy) card completes thanks to the
|
|
// in-loop WDT feed, so it never latches here.
|
|
Preferences _mp;
|
|
const bool mp_ok = _mp.begin("touch", false);
|
|
const bool mig_busy = mp_ok && _mp.getBool("sd_mig_busy", false);
|
|
// An identity makes this an existing SD profile, even if it predates
|
|
// the Pager migration-complete marker. Never auto-overlay missing
|
|
// internal files onto an established card profile.
|
|
const bool needs_migration =
|
|
!SD.exists("/meshcomod/identity/_main.id");
|
|
if (mig_busy) {
|
|
// A completion marker written after identity proves the interrupted
|
|
// state was only the tiny crash window before the NVS latch clear.
|
|
// Otherwise fail closed even if an older identity still exists.
|
|
const bool committed = !needs_migration && SD.exists(kSdMigrationComplete);
|
|
// Upgrade amnesty. Pre-#206 firmware had no completion marker and
|
|
// cleared the latch whenever the copy RETURNED, not when it
|
|
// succeeded. A device whose migration died after the identity landed
|
|
// therefore adopted the card anyway and has been running full-SD
|
|
// ever since, with sd_mig_busy stuck at 1 and no marker that could
|
|
// ever exist. Demanding the marker on upgrade would demote that live
|
|
// card to a months-stale SPIFFS snapshot. If the card's identity is
|
|
// byte-identical to ours the card demonstrably holds THIS profile
|
|
// and is the newer store, so adopt it -- but keep the recovery
|
|
// banner up so a reconciling Copy-to-SD can fill whatever the
|
|
// interrupted copy missed.
|
|
const bool legacy_adopted =
|
|
!committed && !needs_migration && sdIdentityMatchesInternal();
|
|
if (committed || legacy_adopted) {
|
|
if (mp_ok) _mp.remove("sd_mig_busy");
|
|
if (legacy_adopted) {
|
|
g_sd_migration_blocked = true; // surface the reconcile action
|
|
Serial.println("[BOOT] pre-marker SD profile matches internal identity -> adopting; reconcile via Settings > Copy internal data to SD");
|
|
} else {
|
|
Serial.println("[BOOT] SD migration committed before reset -> adopting completed profile");
|
|
}
|
|
} else {
|
|
adopt = false;
|
|
g_sd_migration_blocked = true;
|
|
Serial.println("[BOOT] prior SD migration did not complete -> skipping (staying on SPIFFS); retry via Settings > Copy internal data to SD");
|
|
}
|
|
if (mp_ok) _mp.end();
|
|
} else if (needs_migration) {
|
|
// Do not start without a durable rollback breadcrumb. If NVS is
|
|
// unavailable, a reset after identity lands would otherwise leave
|
|
// no evidence that the SD tree is only partially migrated.
|
|
const bool prepared = mp_ok && meshcomodPrepareSdMigration();
|
|
const bool armed = prepared && _mp.putBool("sd_mig_busy", true) == 1;
|
|
if (mp_ok) _mp.end();
|
|
adopt = armed && meshcomodMigrateSpiffsToSd(false);
|
|
// Keep the breadcrumb latched after a returned-but-incomplete copy.
|
|
// That matters when identity landed before a larger history file
|
|
// failed: the next boot must not adopt the partial SD tree merely
|
|
// because the identity now exists. Manual Copy-to-SD clears it only
|
|
// after a fully successful retry.
|
|
if (adopt) {
|
|
Preferences _mp2; if (_mp2.begin("touch", false)) { _mp2.remove("sd_mig_busy"); _mp2.end(); }
|
|
}
|
|
g_sd_migration_blocked = !adopt;
|
|
if (!adopt) Serial.println(armed
|
|
? "[BOOT] SD migration incomplete -> staying on SPIFFS this boot"
|
|
: "[BOOT] SD migration preparation/breadcrumb unavailable -> staying on SPIFFS this boot");
|
|
} else {
|
|
if (mp_ok) _mp.end();
|
|
}
|
|
}
|
|
if (adopt) {
|
|
sd_storage = store.useSdStorage();
|
|
g_full_data_on_sd = sd_storage;
|
|
if (!sd_storage) {
|
|
g_contacts_on_sd = false;
|
|
Serial.println("[BOOT] SD data directory setup failed; using SPIFFS");
|
|
}
|
|
// On a genuine first run, persist the auto-pick so the "Store data on SD"
|
|
// toggle reflects it and the choice sticks on every later boot.
|
|
if (fresh_install && sd_storage && !use_sd_pref) {
|
|
Preferences _p; if (_p.begin("touch", false)) { _p.putBool("use_sd", true); _p.end(); }
|
|
Serial.println("[BOOT] first run + SD card present -> data defaults to SD");
|
|
}
|
|
} else {
|
|
store.setSecondaryFS(&SD);
|
|
Serial.println("[BOOT] contacts/channels -> SD card (identity/prefs stay on SPIFFS)");
|
|
}
|
|
} else {
|
|
// Upgraded device: identity + prefs stay on SPIFFS (no node-identity
|
|
// change, safe if the card is later pulled), but route the frequently
|
|
// rewritten contacts + channels to the card. On a near-full 3.375 MB
|
|
// SPIFFS every 5-second saveContacts triggers a multi-second GC pass
|
|
// that starves the loop task and trips the task watchdog (the beta_25
|
|
// reboot loop). DataStore::begin() migrates the existing SPIFFS copies
|
|
// to the card once, so the contact list is preserved.
|
|
store.setSecondaryFS(&SD);
|
|
Serial.println("[BOOT] contacts/channels -> SD card (identity/prefs stay on SPIFFS)");
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
// Repair an unusable SPIFFS whether or not the profile lives on SD.
|
|
//
|
|
// This used to be `if (!sd_storage && !spiffs_ok)`, which is a catch-22: an
|
|
// invalid SPIFFS forces want_full_sd above ("Move the WHOLE store to SD when
|
|
// the device has no usable SPIFFS"), and being on SD then skipped the repair —
|
|
// so the partition stayed unformatted forever. Every boot logged
|
|
// `E SPIFFS: mount failed, -10025` and internal storage read as 0 bytes.
|
|
//
|
|
// The consequence is not cosmetic. The migration path documents that it
|
|
// "never deletes its SPIFFS sources, so a device that already adopted the card
|
|
// still has a byte-identical copy here" — i.e. the design assumes internal
|
|
// flash holds a second copy of the identity. A device that went SD-native
|
|
// because SPIFFS was broken never had that copy, so /meshcomod/identity/_main.id
|
|
// on the card is the ONLY thing making it its node. On the ThinkNode M9 the
|
|
// card is soldered, so it cannot even be moved to another device to recover.
|
|
//
|
|
// Formatting here is safe for the profile: sd_storage was already decided
|
|
// above and is not revisited, so this only makes the internal filesystem
|
|
// usable again — it does not move anyone's data. A brand-new device is
|
|
// unaffected (a virgin partition is formatted on the same call it always was).
|
|
// KNOWN BUG, deliberately NOT fixed here yet — read before touching this.
|
|
//
|
|
// The condition is a catch-22. An invalid SPIFFS forces want_full_sd above
|
|
// ("Move the WHOLE store to SD when the device has no usable SPIFFS"), and
|
|
// being on SD then SKIPS this repair — so the partition stays unformatted
|
|
// forever. Every boot logs `E SPIFFS: mount failed, -10025` and internal
|
|
// storage reads 0 bytes. It is not cosmetic: the migration path documents that
|
|
// it "never deletes its SPIFFS sources, so a device that already adopted the
|
|
// card still has a byte-identical copy here", i.e. the design assumes internal
|
|
// flash holds a SECOND copy of the identity. A device that went SD-native
|
|
// *because* SPIFFS was broken never had that copy, so
|
|
// /meshcomod/identity/_main.id on the card is the only thing making it its
|
|
// node — and on the ThinkNode M9 that card is soldered.
|
|
//
|
|
// Reproduced on M9 hardware 2026-08-28. Changing this to
|
|
// `if (!spiffs_ok) spiffs_ok = SPIFFS.begin(true);` DOES repair the partition
|
|
// (the boot then reports the internal fs as ok, and the profile correctly
|
|
// stays on SD), but that boot then panicked and looped:
|
|
// Guru Meditation Error: Core 1 panic'ed (InstrFetchProhibited)
|
|
// Backtrace: WiFiEventCbList::WiFiEventCbList(const&)
|
|
// -> WiFiGenericClass::_eventCallback -> _arduino_event_task
|
|
// The follow-up matters: once the partition was formatted, the SAME build with
|
|
// this line back as-is — SPIFFS now mounting AND the profile on SD, i.e. the
|
|
// exact state that crashed — boots cleanly and repeatedly. So the fault is not
|
|
// "SPIFFS is mounted"; it is almost certainly the 3.375 MB format ITSELF
|
|
// running at this point in setup(), blocking long enough to race the Arduino
|
|
// Wi-Fi event task that is starting up alongside it.
|
|
//
|
|
// So the fix is not this one-liner: it is to repair the partition somewhere
|
|
// that can afford a multi-second blocking format (before the Wi-Fi/event tasks
|
|
// exist, or deferred to a UI-driven action with a progress notice), and to
|
|
// confirm on hardware that a cold first-format boot survives.
|
|
if (!sd_storage && !spiffs_ok) SPIFFS.begin(true); // last resort: format SPIFFS
|
|
Serial.printf("[BOOT] storage: %s\n", sd_storage ? "SD /meshcomod" : "SPIFFS");
|
|
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
|
|
// Route touch settings + Wi-Fi creds to the active filesystem (SD when that's
|
|
// the data store, else SPIFFS) instead of NVS. Old NVS values still load and
|
|
// migrate on their next save, so this is a transparent in-place upgrade.
|
|
#if defined(HAS_WIO_TRACKER_L2)
|
|
SdNvsPrefs::useFile(sd_storage ? (fs::FS*)&SD_MMC : (fs::FS*)&SPIFFS,
|
|
sd_storage ? "/meshcomod" : "/prefs");
|
|
#elif defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HAS_CROWPANEL_35)
|
|
SdNvsPrefs::useFile(sd_storage ? (fs::FS*)&SD : (fs::FS*)&SPIFFS,
|
|
sd_storage ? "/meshcomod" : "/prefs");
|
|
#else
|
|
SdNvsPrefs::useFile((fs::FS*)&SPIFFS, "/prefs"); // no SD on this board
|
|
#endif
|
|
// The boot wordmark already read a pref (UI rotation) BEFORE useFile switched
|
|
// the backend, caching the settings blob from legacy NVS. Re-read it now so
|
|
// file-saved values (theme accent, brightness, language, …) take effect this
|
|
// boot — otherwise a theme change "reverts" on every restart.
|
|
touchPrefsReload();
|
|
// Keep the boot logo's NVS copy of the rotation in step with the real one.
|
|
touchPrefsSyncBootUiRotation();
|
|
// Restore replay protection before mesh startup or optional cold-boot NTP.
|
|
// A trusted correction can then pull a poisoned future floor back through
|
|
// ClockFloorRTC's guarded set path without a later restore undoing it.
|
|
rtc_clock.seedFloor(touchPrefsGetClockFloor());
|
|
rtc_clock.seedSystemClock();
|
|
#if defined(HAS_RAK_TAP_V2)
|
|
Serial.println("[BOOT] prefs_backend ok"); Serial.flush();
|
|
Serial.println("[BOOT] touchPrefsReload ok"); Serial.flush();
|
|
#endif
|
|
#endif
|
|
store.begin();
|
|
#if defined(HAS_RAK_TAP_V2)
|
|
Serial.println("[BOOT] store ok"); Serial.flush();
|
|
Serial.println("[BOOT] calling mesh.begin..."); Serial.flush();
|
|
#endif
|
|
the_mesh.begin(
|
|
#ifdef DISPLAY_CLASS
|
|
disp != NULL
|
|
#else
|
|
false
|
|
#endif
|
|
);
|
|
Serial.println("[BOOT] mesh ok");
|
|
#if defined(HAS_RAK_TAP_V2)
|
|
Serial.flush();
|
|
#endif
|
|
|
|
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
|
|
{
|
|
char nodeHex[13] = {};
|
|
for (int i = 0; i < 6; ++i) snprintf(nodeHex + i * 2, 3, "%02x", the_mesh.self_id.pub_key[i]);
|
|
mqtt_bridge.begin(nodeHex);
|
|
}
|
|
#endif
|
|
|
|
#if defined(WIFI_SSID) || defined(MULTI_TRANSPORT_COMPANION)
|
|
wifiConfigBegin();
|
|
Serial.println("[BOOT] wifiConfig ok");
|
|
|
|
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
|
|
/* Cold-boot clock acquisition over SAVED Wi-Fi (#383, BootTimeSync.h).
|
|
*
|
|
* HERE, and nowhere later, on purpose: after wifiConfigBegin() (so the saved
|
|
* credentials are readable) but BEFORE serial_interface.begin(), the TCP/WS
|
|
* listeners and any cold BLE allocation. Running before the transports exist
|
|
* means the temporary session has nothing live to suspend and rebuild — the
|
|
* normal boot path a few lines down reads the persisted Wi-Fi/BLE intent
|
|
* flags, which this never touches, and proceeds exactly as it always did.
|
|
*
|
|
* Opt-in, power-on-reset only, and bounded (~12 s worst case); it returns
|
|
* Skipped without spending anything when the board already knows the time,
|
|
* which is every boot on a T-Pager or an M9 whose RTC is healthy. */
|
|
{
|
|
uint32_t synced_epoch = 0;
|
|
const BootTimeSyncResult r = bootTimeSyncRun(rtc_clock.timeIsCurrent(), synced_epoch);
|
|
if (r == BootTimeSyncResult::Ok) {
|
|
// Through the normal guarded path, so the clock floor, the system clock
|
|
// and the board's RTC chip are all updated the same way an NTP or GPS
|
|
// sync would update them.
|
|
rtc_clock.setCurrentTime(synced_epoch);
|
|
Serial.printf("[BOOT] cold-boot time sync ok: %lu\n", (unsigned long)synced_epoch);
|
|
} else if (r != BootTimeSyncResult::Skipped) {
|
|
Serial.printf("[BOOT] cold-boot time sync: %s\n", bootTimeSyncResultName(r));
|
|
}
|
|
}
|
|
#endif
|
|
#endif
|
|
|
|
#ifdef MULTI_TRANSPORT_COMPANION
|
|
board.setInhibitSleep(true);
|
|
serial_interface.begin(Serial, TCP_PORT, WS_PORT);
|
|
Serial.println("[BOOT] serial_interface ok");
|
|
serial_interface.setBroadcastResponses(true); // RX log, channel messages, etc. go to all clients (USB + TCP + WS [+ BLE]), not only last sender
|
|
/* Wi-Fi and NimBLE coexist, but their first allocations are order-sensitive
|
|
* on the Pager. Claim Wi-Fi's DMA/coexistence resources before starting BLE;
|
|
* T-Deck does not reproduce this sequencing constraint. */
|
|
bool want_wifi = wifiConfigWantsWifi();
|
|
#if defined(TLORA_PAGER)
|
|
bool pager_wifi_ready_for_ble = false;
|
|
const bool pager_want_ble = wifiConfigGetBleEnabled();
|
|
const bool pager_has_wifi_credentials = wifiConfigHasRuntime();
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Idle);
|
|
#endif
|
|
/* Wi-Fi + BLE now COEXIST (NimBLE host is light enough — the old Bluedroid
|
|
* heap clash is gone). Bring Wi-Fi up FIRST: esp_wifi_init grabs a big
|
|
* contiguous DMA block, so let it claim memory before BLE. (Association
|
|
* happens later in loop(); this just inits the stack.) */
|
|
if (want_wifi) {
|
|
// Register every Wi-Fi event handler BEFORE the stack is started, never
|
|
// after. WiFi.onEvent() push_backs into a std::vector that the Arduino core
|
|
// iterates from its own event task, with no lock on either side: a
|
|
// registration that reallocates the vector while an event is being
|
|
// dispatched frees the buffer the event task is walking, and it then calls
|
|
// through a dangling function pointer. That is a real crash we have a dump
|
|
// of, on a Heltec V4-R8 on beta_77: the event task was inside
|
|
// _eventCallback copying a WiFiEventCbList entry while setup() was inside
|
|
// _M_realloc_insert growing that same vector, and it jumped to a garbage PC.
|
|
//
|
|
// The bug is in the Arduino core rather than here, and we cannot lock its
|
|
// vector, but we can make the race impossible: with nothing registered
|
|
// after the stack can emit events, the vector is never mutated while it is
|
|
// being read. Both handlers used to be added afterwards, one of them after
|
|
// WiFi.begin(), which is where the window came from.
|
|
//
|
|
// Records WHY every STA disconnect happened, surfaced by the UI's Wi-Fi
|
|
// status string as "auth failed (rNN)", so failures are diagnosable on a
|
|
// device with no serial console.
|
|
WiFi.onEvent([](WiFiEvent_t, WiFiEventInfo_t info){
|
|
g_wifi_last_disc_reason = info.wifi_sta_disconnected.reason;
|
|
}, ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
|
|
// Without the WIFI_DEBUG_PRINTLN tracing the old copy had: that macro comes
|
|
// from SerialWifiInterface.h, which is not in scope this early in setup().
|
|
// The prints were debug-only and compile to nothing in a release build; the
|
|
// flag is the handler's actual job and is unchanged.
|
|
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t){
|
|
if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) wifi_needs_reconnect = true;
|
|
else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) wifi_needs_reconnect = false;
|
|
});
|
|
const bool wifi_mode_ready = WiFi.mode(WIFI_STA);
|
|
#if defined(TLORA_PAGER)
|
|
if (!wifi_mode_ready) {
|
|
pagerWifiEnterBleFallback("boot STA initialization failed");
|
|
}
|
|
// Pager reconnects are owned by the loop below. Background WPA retries are
|
|
// invisible to PagerWifiBlePhase and could overlap a cold NimBLE start.
|
|
WiFi.setAutoReconnect(false);
|
|
#else
|
|
WiFi.setAutoReconnect(true); // safe while NimBLE is not resident; disabled below once BLE
|
|
// exists so a later WPA re-auth cannot bypass Wi-Fi-first ordering
|
|
#endif
|
|
WiFi.persistent(false);
|
|
// NOTE: do NOT enable modem-sleep here. On a fresh, *unassociated* STA (the
|
|
// setup wizard, no creds yet) DTIM modem-sleep naps the radio through the
|
|
// scan dwell, so WiFi.scanNetworks() comes back empty ("no networks found").
|
|
// It's enabled once we actually associate — see the GOT_IP handler below.
|
|
#if defined(TLORA_PAGER)
|
|
/* Arduino-ESP32 2.0.17 can watchdog in WPA3 SAE on the Pager when NimBLE
|
|
* already owns the coexistence path. Start the saved Wi-Fi association
|
|
* first and wait on the state transition (not a fixed delay), while
|
|
* internal heap is still plentiful; BLE starts below only after the link
|
|
* is established. T-Deck does not reproduce this ordering constraint. */
|
|
if (wifi_mode_ready && pager_has_wifi_credentials) {
|
|
char ssid[WIFI_CONFIG_SSID_MAX];
|
|
char pwd[WIFI_CONFIG_PWD_MAX];
|
|
wifiConfigGetSsid(ssid, sizeof(ssid));
|
|
wifiConfigGetPwd(pwd, sizeof(pwd));
|
|
if (ssid[0]) {
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Associating);
|
|
const uint32_t assoc_start_ms = millis();
|
|
WiFi.begin(ssid, pwd[0] ? pwd : nullptr);
|
|
while (WiFi.status() != WL_CONNECTED &&
|
|
(uint32_t)(millis() - assoc_start_ms) < 6000UL) {
|
|
delay(20);
|
|
}
|
|
pager_wifi_ready_for_ble = WiFi.status() == WL_CONNECTED;
|
|
if (pager_wifi_ready_for_ble) {
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Connected);
|
|
} else if (pager_want_ble) {
|
|
// Do not strand BLE behind an unavailable AP. Stop authentication so
|
|
// NimBLE cannot race a background WPA retry, then leave Wi-Fi retry
|
|
// paused until an explicit Apply/toggle requests another handoff.
|
|
pagerWifiEnterBleFallback("boot association timed out");
|
|
}
|
|
Serial.printf("[boot] Pager Wi-Fi pre-BLE association: %s (%lums)\n",
|
|
pager_wifi_ready_for_ble ? "ready" :
|
|
(pager_want_ble ? "BLE fallback" : "still associating"),
|
|
(unsigned long)(millis() - assoc_start_ms));
|
|
pagerLogInternalHeap("after Wi-Fi association");
|
|
}
|
|
} else if (wifi_mode_ready) {
|
|
// Keep STA available for the setup wizard's scan, but with no credentials
|
|
// there is no association to order ahead of BLE.
|
|
WiFi.setAutoReconnect(false);
|
|
}
|
|
#endif
|
|
}
|
|
#if defined(BLE_PIN_CODE)
|
|
/* Always stash the BLE params so the toggle can bring BLE up live later, even
|
|
* if we defer it now. Then co-init BLE if the user has it enabled AND there's
|
|
* comfortable internal heap left after Wi-Fi — otherwise defer to Wi-Fi-only
|
|
* this boot rather than risk an OOM at NimBLE init (recoverable via the live
|
|
* toggle once memory frees). */
|
|
// Defensive: force node_name NUL-terminated before it builds the BLE device
|
|
// name. Under Launcher (degraded storage) it can load non-terminated, which
|
|
// is what overran the BLE name buffer; the snprintf there now bounds the write,
|
|
// and this bounds the read so the name is the first <=31 chars, not garbage.
|
|
{ NodePrefs* _np = the_mesh.getNodePrefs();
|
|
_np->node_name[sizeof(_np->node_name) - 1] = '\0'; }
|
|
serial_interface.prepareBle(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
|
#if CAP_BLE_KEYBOARD
|
|
// Bluetooth serves a keyboard: never advertise the phone link, not even
|
|
// between BLE start-up and the UI taking over.
|
|
serial_interface.setBlePhoneLinkPaused(touchPrefsGetBleKbdMode());
|
|
#endif
|
|
#if defined(TLORA_PAGER)
|
|
{
|
|
const bool want_ble = pager_want_ble;
|
|
/* Serialise the Pager's first association before BLE init. Once associated,
|
|
* the two radios coexist normally. Do not create a disabled NimBLE stack
|
|
* for Wi-Fi-only users: keeping Wi-Fi's normal auto-reconnect path avoids a
|
|
* needless device reboot when no Bluetooth controller is resident. */
|
|
if (!wifiConfigPagerWifiBlocksBle()) {
|
|
if (want_ble) {
|
|
// Use the same contiguous-heap guard as every other cold NimBLE start.
|
|
// Boot normally has the most headroom, but a future driver allocation
|
|
// must degrade to BLE-pending instead of OOM-panicking setup().
|
|
serial_interface.enableBle();
|
|
if (serial_interface.isBleEnabled()) {
|
|
Serial.printf("[boot] BLE stack ready (enabled=1 wifi=%d)\n", (int)want_wifi);
|
|
pagerLogInternalHeap("after BLE init");
|
|
} else {
|
|
// A future association edge or explicit Bluetooth retry can consume
|
|
// the request. This is a heap refusal, not Wi-Fi ownership.
|
|
s_pager_ble_after_wifi = true;
|
|
Serial.println("[boot] BLE cold start deferred: insufficient contiguous heap");
|
|
}
|
|
}
|
|
} else {
|
|
// Only an active association reaches here. Both success and the bounded
|
|
// fallback consume this pending request.
|
|
s_pager_ble_after_wifi = want_ble;
|
|
Serial.printf("[boot] BLE init deferred until ordered Wi-Fi association (enable=%d)\n",
|
|
(int)want_ble);
|
|
}
|
|
}
|
|
#else
|
|
if (wifiConfigGetBleEnabled()) {
|
|
const size_t BLE_COEXIST_MIN_FREE = 50 * 1024; // free heap after Wi-Fi to also start BLE
|
|
const size_t BLE_COEXIST_MIN_BLOCK = 20 * 1024; // largest contiguous block (NimBLE controller/host)
|
|
const size_t freeh = ESP.getFreeHeap();
|
|
const size_t maxblk = ESP.getMaxAllocHeap();
|
|
if (!want_wifi || (freeh >= BLE_COEXIST_MIN_FREE && maxblk >= BLE_COEXIST_MIN_BLOCK)) {
|
|
serial_interface.beginBle(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
|
Serial.printf("[boot] BLE co-init OK (wifi=%d free=%u maxblk=%u)\n", (int)want_wifi, (unsigned)freeh, (unsigned)maxblk);
|
|
} else {
|
|
Serial.printf("[boot] BLE deferred: low heap (free=%u maxblk=%u) — Wi-Fi only\n", (unsigned)freeh, (unsigned)maxblk);
|
|
}
|
|
}
|
|
#endif
|
|
#endif
|
|
#elif defined(WIFI_SSID)
|
|
board.setInhibitSleep(true); // prevent sleep when WiFi is active
|
|
WiFi.setAutoReconnect(true);
|
|
|
|
if (wifiConfigHasRuntime()) {
|
|
char ssid[WIFI_CONFIG_SSID_MAX];
|
|
char pwd[WIFI_CONFIG_PWD_MAX];
|
|
wifiConfigGetSsid(ssid, sizeof(ssid));
|
|
wifiConfigGetPwd(pwd, sizeof(pwd));
|
|
WiFi.begin(ssid, pwd[0] ? pwd : nullptr);
|
|
} else {
|
|
WiFi.begin(WIFI_SSID, WIFI_PWD);
|
|
}
|
|
serial_interface.begin(TCP_PORT);
|
|
#elif defined(BLE_PIN_CODE)
|
|
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
|
#elif defined(SERIAL_RX)
|
|
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
|
companion_serial.begin(115200);
|
|
serial_interface.begin(companion_serial);
|
|
#else
|
|
serial_interface.begin(Serial);
|
|
#endif
|
|
the_mesh.startInterface(serial_interface);
|
|
#else
|
|
#error "need to define filesystem"
|
|
#endif
|
|
|
|
#if defined(ENV_INCLUDE_GPS) && (ENV_INCLUDE_GPS == 1)
|
|
// GPS UART resilience (slow / never-acquires TTFF fix): the core opens Serial1 for the
|
|
// NMEA GPS with Arduino's default 256-byte RX ring — only ~66 ms of slack at 38400 baud
|
|
// (~270 ms at 9600). A single long LVGL/map frame stalls this loop past that, dropping
|
|
// UART bytes and corrupting NMEA ephemeris subframes. Each corrupted subframe costs the
|
|
// receiver ~30 s of re-acquisition, so a busy UI turns a ~1-minute fix into several
|
|
// minutes — or, with frequent stalls, never. A larger ring absorbs the stalls. MUST
|
|
// precede the core's Serial1.begin() inside sensors.begin(); setRxBufferSize is a no-op
|
|
// once the UART is already running.
|
|
//
|
|
// Gate on gps_enabled: sensors.begin()'s GPS-detect opens Serial1 on EVERY boot —
|
|
// including the many V4s with no GPS module — and never calls Serial1.end(), so an
|
|
// unconditional 4 KB ring permanently costs ~3.8 KB of scarce internal DRAM for nothing
|
|
// on the GPS-off majority (the "RAM is higher now" reports). GPS-on users (who actually
|
|
// hit the overflow) still get the big ring; default GPS-off keeps the stock 256 B. A user
|
|
// who enables GPS mid-session gets it immediately via gpsEnsureBigRxRing() above (it
|
|
// used to wait for the next reboot).
|
|
{
|
|
#if defined(ATTAKY_MESH_SERIES)
|
|
// This fixed stack always carries the GPS, so take the larger RX ring
|
|
// unconditionally; the default 256 B ring gives the slowest first fix.
|
|
Serial1.setRxBufferSize(4096);
|
|
s_gps_big_rx_ring = true;
|
|
#else
|
|
auto* np = the_mesh.getNodePrefs();
|
|
if (np && np->gps_enabled) { Serial1.setRxBufferSize(4096); s_gps_big_rx_ring = true; }
|
|
#endif
|
|
}
|
|
#endif
|
|
#if defined(HAS_WIO_TRACKER_L2) && defined(WIO_TRACKER_L2_GPS_PROBE)
|
|
wioTrackerL2GpsProbe(); // TEMP DIAGNOSTIC — must precede sensors.begin()'s Serial1.begin()
|
|
#endif
|
|
sensors.begin();
|
|
|
|
#ifdef DISPLAY_CLASS
|
|
ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved
|
|
Serial.println("[BOOT] ui ready");
|
|
#if defined(TLORA_PAGER) && defined(MULTI_TRANSPORT_COMPANION)
|
|
pagerLogInternalHeap("after UI init");
|
|
#endif
|
|
#endif
|
|
|
|
board.onBootComplete();
|
|
}
|
|
|
|
void loop() {
|
|
#ifdef R8_DIAG_BATT
|
|
// TEMP diagnostic (build with -DR8_DIAG_BATT, remove after): raw battery mV
|
|
// every 5 s to Serial AND as an on-screen toast every 8 s — the R8's
|
|
// USB-Serial-JTAG resets into the ROM bootloader whenever a host opens the
|
|
// port, so the screen is the only reliable live readout on this board.
|
|
{ static uint32_t s_batt_next = 0;
|
|
if (millis() > s_batt_next) { s_batt_next = millis() + 5000;
|
|
Serial.printf("[BATT] %u mV\n", (unsigned)board.getBattMilliVolts()); } }
|
|
{ static uint32_t s_batt_ui_next = 0;
|
|
if (millis() > s_batt_ui_next && millis() > 15000) { s_batt_ui_next = millis() + 8000;
|
|
char b[28]; snprintf(b, sizeof b, "BATT %u mV", (unsigned)board.getBattMilliVolts());
|
|
ui_task.showAlert(b, 2500); } }
|
|
#endif
|
|
// Run UI first every iteration so splash can dismiss at 3s even if mesh/serial blocks later (was stuck on version screen when the_mesh.loop() ran before ui_task.loop()).
|
|
#ifdef DISPLAY_CLASS
|
|
// ---- beta_31 field-freeze tracer (see UITask.cpp): time each loop section ----
|
|
extern void stallLog(const char* tag, uint32_t dur_ms);
|
|
extern const char* g_ui_stall_tag;
|
|
extern uint16_t g_ui_stall_max;
|
|
#define STALL_SCOPE(tag, call) { uint32_t _st0 = millis(); call; uint32_t _sdt = millis() - _st0; if (_sdt >= 200) stallLog(tag, _sdt); }
|
|
{ uint32_t _ui0 = millis();
|
|
ui_task.loop();
|
|
uint32_t _uidt = millis() - _ui0;
|
|
if (_uidt >= 200) stallLog((g_ui_stall_max >= 150 && g_ui_stall_tag[0]) ? g_ui_stall_tag : "ui:other", _uidt);
|
|
}
|
|
#endif
|
|
#ifdef MULTI_TRANSPORT_COMPANION
|
|
#ifdef DISPLAY_CLASS
|
|
uint32_t _wf0 = millis(); // beta_31 tracer: time the whole Wi-Fi state machine + SNTP span
|
|
#endif
|
|
static bool wifi_started = false;
|
|
static uint32_t last_wifi_retry_ms = 0;
|
|
static const uint32_t WIFI_RETRY_INTERVAL_MS = 10000;
|
|
static bool wifi_radio_prev = true;
|
|
static bool wifi_radio_inited = false;
|
|
/* Run the saved Wi-Fi state machine whenever its radio preference is on.
|
|
* Pager setup separately guarantees that Wi-Fi claims its coexistence
|
|
* resources before a cold BLE start. */
|
|
bool wifi_radio_en = wifiConfigWantsWifi();
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
// BLE fallback pauses automatic association so a missing AP cannot make the
|
|
// advertised GATT service disappear every retry interval. Turning Bluetooth
|
|
// off resumes retries only when the STA allocation itself is healthy; an
|
|
// allocation failure stays latched until an explicit Apply/toggle.
|
|
if (wifiConfigPagerBleFallbackActive() && !wifiConfigGetBleEnabled() &&
|
|
(WiFi.getMode() & WIFI_MODE_STA) != 0) {
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Idle);
|
|
}
|
|
// An association may have started while Bluetooth was disabled. If the user
|
|
// requests BLE during that attempt, join the already-running ordered handoff
|
|
// immediately instead of waiting for the next 10-second Wi-Fi retry to arm it.
|
|
if (wifiConfigGetPagerWifiBlePhase() == PagerWifiBlePhase::Associating &&
|
|
wifiConfigGetBleEnabled() && !s_pager_ble_after_wifi) {
|
|
s_pager_ble_after_wifi = true;
|
|
s_pager_wifi_ble_deadline_ms = millis() + PAGER_WIFI_BLE_HANDOFF_MS;
|
|
Serial.println("[wifi] Bluetooth requested during association; handoff armed");
|
|
}
|
|
#endif
|
|
if (!wifi_radio_inited) {
|
|
wifi_radio_inited = true;
|
|
wifi_radio_prev = wifi_radio_en;
|
|
} else if (wifi_radio_en != wifi_radio_prev) {
|
|
wifi_radio_prev = wifi_radio_en;
|
|
if (!wifi_radio_en) {
|
|
WiFi.disconnect(true);
|
|
delay(50);
|
|
WiFi.mode(WIFI_OFF);
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Idle);
|
|
const bool ble_waiting = s_pager_ble_after_wifi ||
|
|
(wifiConfigGetBleEnabled() &&
|
|
!serial_interface.isBleStackBegun());
|
|
s_pager_ble_after_wifi = false;
|
|
if (ble_waiting) {
|
|
if (wifiConfigGetBleEnabled() && !serial_interface.isBleEnabled()) {
|
|
serial_interface.enableBle();
|
|
if (serial_interface.isBleEnabled()) {
|
|
Serial.println("[boot] deferred BLE enabled after T-Pager Wi-Fi was disabled");
|
|
} else {
|
|
Serial.println("[wifi] deferred BLE unavailable after Wi-Fi was disabled");
|
|
ui_task.showAlert(TR("Bluetooth unavailable; retry from Settings"), 2600);
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
wifi_started = false;
|
|
}
|
|
/* UI may have changed SSID/PWD and asked for a re-apply. Trigger re-begin
|
|
* by forcing wifi_started=false; on next iter the block below will WiFi.begin
|
|
* with the freshly-saved credentials. Also handles toggling radio_en off
|
|
* from the UI (the transition above already covered the on case). */
|
|
bool wifi_apply_ready = true;
|
|
#if defined(ESP32)
|
|
// Keep a queued credential/radio re-apply pending until the worker releases
|
|
// its scan. Consuming it here would disconnect or reconfigure the driver
|
|
// underneath esp_wifi_scan_start on the other core.
|
|
wifi_apply_ready = !wifiScanIsActive();
|
|
#endif
|
|
if (wifi_apply_ready && wifiConfigConsumeApplyRequest()) {
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
// Cold Wi-Fi allocation and credential changes use the same live handoff:
|
|
// preserve BLE intent and bond state, quiesce BLE traffic, let the main task
|
|
// own esp_wifi_init and WPA, then resume BLE once Wi-Fi is stable (or has
|
|
// timed out).
|
|
if (wifi_radio_en) {
|
|
const bool cold_sta = (WiFi.getMode() & WIFI_MODE_STA) == 0;
|
|
const bool ordered_handoff = cold_sta || wifiConfigHasRuntime();
|
|
s_pager_ble_after_wifi = wifiConfigGetBleEnabled() &&
|
|
(ordered_handoff || !serial_interface.isBleStackBegun());
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
wifiConfigSetPagerWifiBlePhase(ordered_handoff
|
|
? PagerWifiBlePhase::Associating : PagerWifiBlePhase::Idle);
|
|
if (ordered_handoff && serial_interface.isBleStackBegun()) {
|
|
if (!serial_interface.suspendBleForWifiReconnect()) {
|
|
s_pager_ble_after_wifi = false;
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
pagerWifiEnterBleFallback("BLE disconnect timed out; Wi-Fi start cancelled");
|
|
if (wifiConfigGetBleEnabled()) serial_interface.enableBle();
|
|
ui_task.showAlert(TR("Wi-Fi paused; Bluetooth disconnect timed out"), 2600);
|
|
return;
|
|
}
|
|
Serial.println("[wifi] BLE paused for ordered T-Pager Wi-Fi start");
|
|
}
|
|
// Give WPA its full window; the bounded asynchronous BLE disconnect above
|
|
// is part of quiescing the old transport, not part of association time.
|
|
s_pager_wifi_ble_deadline_ms =
|
|
(wifiConfigHasRuntime() && s_pager_ble_after_wifi)
|
|
? millis() + PAGER_WIFI_BLE_HANDOFF_MS : 0;
|
|
} else {
|
|
s_pager_ble_after_wifi = false;
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Idle);
|
|
}
|
|
// Keep Pager WPA retries explicit even when BLE is currently absent. The
|
|
// loop below records Associating before each begin(), so a simultaneous
|
|
// Bluetooth request can never cold-start NimBLE over a hidden retry.
|
|
if (wifi_radio_en) WiFi.setAutoReconnect(false);
|
|
#endif
|
|
/* Only touch WiFi state if the driver is actually up. When BLE is the active
|
|
* transport (no creds saved), WiFi was never inited and calling
|
|
* WiFi.disconnect()/mode(WIFI_OFF) would trigger esp_wifi_init under low
|
|
* heap → crash. Setting wifi_started=false here is harmless.
|
|
*
|
|
* wifi_started alone under-approximates that: a scan or the version check can
|
|
* bring STA up without going through this state machine, and then disabling
|
|
* Wi-Fi skipped the power-down entirely and left the modem running (#450 — the
|
|
* idle power-saver then blocks on "Wi-Fi on" forever). getMode() != WIFI_OFF
|
|
* PROVES the driver is inited, which is precisely what the guard wants to know,
|
|
* so it is safe to add and it closes that leak. */
|
|
if (wifi_started || WiFi.getMode() != WIFI_OFF) {
|
|
if (!wifi_radio_en) {
|
|
WiFi.disconnect(true);
|
|
delay(50);
|
|
WiFi.mode(WIFI_OFF);
|
|
} else {
|
|
WiFi.disconnect(false, false);
|
|
delay(50);
|
|
}
|
|
}
|
|
wifi_started = false;
|
|
last_wifi_retry_ms = 0;
|
|
}
|
|
bool wifi_state_machine_active = wifi_radio_en;
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
wifi_state_machine_active = wifi_state_machine_active && !wifiConfigPagerBleFallbackActive();
|
|
#endif
|
|
if (wifi_state_machine_active) {
|
|
if (!wifi_started) {
|
|
const bool wifi_mode_ready = WiFi.mode(WIFI_STA);
|
|
if (!wifi_mode_ready) {
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
const bool ble_waiting = s_pager_ble_after_wifi ||
|
|
(wifiConfigGetBleEnabled() &&
|
|
!serial_interface.isBleStackBegun());
|
|
s_pager_ble_after_wifi = false;
|
|
last_wifi_retry_ms = millis();
|
|
pagerWifiEnterBleFallback("STA initialization failed; Bluetooth restored");
|
|
if (ble_waiting && wifiConfigGetBleEnabled() &&
|
|
!serial_interface.isBleEnabled()) {
|
|
serial_interface.enableBle();
|
|
}
|
|
if (wifiConfigGetBleEnabled() && serial_interface.isBleEnabled()) {
|
|
ui_task.showAlert(TR("Wi-Fi unavailable; Bluetooth restored"), 2200);
|
|
} else if (wifiConfigGetBleEnabled()) {
|
|
ui_task.showAlert(TR("Wi-Fi and Bluetooth unavailable; retry from Settings"), 2800);
|
|
} else {
|
|
ui_task.showAlert(TR("Wi-Fi unavailable; retry from Settings"), 2200);
|
|
}
|
|
#else
|
|
last_wifi_retry_ms = millis();
|
|
#endif
|
|
} else {
|
|
wifi_started = true;
|
|
}
|
|
if (wifi_mode_ready && wifiConfigHasRuntime() && !wifiScanIsActive()) {
|
|
char ssid[WIFI_CONFIG_SSID_MAX];
|
|
char pwd[WIFI_CONFIG_PWD_MAX];
|
|
wifiConfigGetSsid(ssid, sizeof(ssid));
|
|
wifiConfigGetPwd(pwd, sizeof(pwd));
|
|
if (strlen(ssid) > 0 && WiFi.status() != WL_CONNECTED) {
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Associating);
|
|
#endif
|
|
WiFi.begin(ssid, pwd[0] ? pwd : nullptr);
|
|
}
|
|
last_wifi_retry_ms = millis();
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
} else if (wifi_mode_ready) {
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Idle);
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
const bool ble_waiting = s_pager_ble_after_wifi ||
|
|
(wifiConfigGetBleEnabled() &&
|
|
!serial_interface.isBleStackBegun());
|
|
s_pager_ble_after_wifi = false;
|
|
if (ble_waiting && wifiConfigGetBleEnabled() &&
|
|
!serial_interface.isBleEnabled()) {
|
|
serial_interface.enableBle();
|
|
if (serial_interface.isBleEnabled()) {
|
|
Serial.println("[wifi] BLE restored after idle STA initialization");
|
|
} else {
|
|
Serial.println("[wifi] BLE unavailable after idle STA initialization");
|
|
ui_task.showAlert(TR("Bluetooth unavailable; retry from Settings"), 2600);
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
// Automatic WiFi recovery for TCP mode: retry connection periodically if link drops.
|
|
// Suppressed while a scan runs on the worker: WiFi.disconnect()+begin() here would
|
|
// abort the in-flight sweep (the scan-while-connected "0 networks" bug).
|
|
if (wifiConfigHasRuntime() && WiFi.status() != WL_CONNECTED && !wifiScanIsActive()) {
|
|
uint32_t now = millis();
|
|
if ((uint32_t)(now - last_wifi_retry_ms) >= WIFI_RETRY_INTERVAL_MS) {
|
|
last_wifi_retry_ms = now;
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
s_pager_ble_after_wifi = wifiConfigGetBleEnabled();
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Associating);
|
|
if (serial_interface.isBleStackBegun()) {
|
|
// Auto-reconnect is disabled once NimBLE exists. Pause advertising and
|
|
// disconnect its peer first, then use the ordinary retry below; GOT_IP
|
|
// resumes BLE once Wi-Fi owns the coexistence path again. This preserves
|
|
// the bond and bounds an AP outage to transport reconnects.
|
|
if (!serial_interface.suspendBleForWifiReconnect()) {
|
|
s_pager_ble_after_wifi = false;
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
pagerWifiEnterBleFallback("BLE disconnect timed out; Wi-Fi retry cancelled");
|
|
if (wifiConfigGetBleEnabled()) serial_interface.enableBle();
|
|
ui_task.showAlert(TR("Wi-Fi paused; Bluetooth disconnect timed out"), 2600);
|
|
return;
|
|
}
|
|
Serial.println("[wifi] BLE paused after link loss; re-associating");
|
|
}
|
|
s_pager_wifi_ble_deadline_ms = s_pager_ble_after_wifi
|
|
? millis() + PAGER_WIFI_BLE_HANDOFF_MS : 0;
|
|
#endif
|
|
char ssid[WIFI_CONFIG_SSID_MAX];
|
|
char pwd[WIFI_CONFIG_PWD_MAX];
|
|
wifiConfigGetSsid(ssid, sizeof(ssid));
|
|
wifiConfigGetPwd(pwd, sizeof(pwd));
|
|
if (strlen(ssid) > 0) {
|
|
// A bare begin() on a supplicant wedged after a silent drop (AP reboot /
|
|
// beacon loss) can be a no-op — clear its state first so this forces a
|
|
// fresh association. Backs up setAutoReconnect(true) for the stuck case.
|
|
WiFi.disconnect(false, true);
|
|
WiFi.begin(ssid, pwd[0] ? pwd : nullptr);
|
|
}
|
|
}
|
|
}
|
|
/* SNTP: kick off when Wi-Fi associates; once system time syncs, push it
|
|
* into the mesh RTC so timestamps on messages are accurate. */
|
|
static bool sntp_kicked = false;
|
|
static bool sntp_pushed = false;
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Connected);
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
// Consume only the explicit handoff token. If a cold NimBLE start is
|
|
// refused, do not hammer the allocator and alert on every loop while
|
|
// Wi-Fi remains connected; a resident stack simply resumes advertising.
|
|
const bool ble_waiting = s_pager_ble_after_wifi;
|
|
if (ble_waiting) {
|
|
s_pager_ble_after_wifi = false;
|
|
if (wifiConfigGetBleEnabled() && !serial_interface.isBleEnabled()) {
|
|
// Wi-Fi is now stable, so a cold BLE allocation cannot enter WPA in
|
|
// the unsafe order. A resident stack resumes allocation-free; the
|
|
// transport guards heap only if it genuinely needs a cold start.
|
|
serial_interface.enableBle();
|
|
if (serial_interface.isBleEnabled()) {
|
|
WiFi.setAutoReconnect(false);
|
|
Serial.println("[boot] deferred BLE enabled after T-Pager Wi-Fi association");
|
|
} else {
|
|
Serial.println("[boot] deferred BLE unavailable; retry from Bluetooth settings");
|
|
ui_task.showAlert(TR("Bluetooth deferred; retry from Settings"), 2600);
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
// Now that we're associated, enable DTIM modem-sleep (saves power + gives
|
|
// BLE coexistence airtime). Deferred to here on purpose: enabling it on the
|
|
// unassociated STA naps the radio through a scan dwell and breaks the setup
|
|
// wizard's WiFi.scanNetworks() ("no networks found"). One-shot.
|
|
//
|
|
// Unconditional, and deliberately NOT a user preference. This was briefly
|
|
// configurable (default off on the V4-R8, for a lower-latency app link), but
|
|
// WIFI_PS_NONE is not a legal sleep mode while the BT controller is running --
|
|
// modem sleep is exactly what yields airtime to BLE, and the Wi-Fi PM blob
|
|
// aborts rather than refusing. An R8 with saved credentials and Bluetooth on
|
|
// crashed on association every time: task "wifi", abort() in pm_set_sleep_type.
|
|
// The latency it was buying did not exist either: profiling the companion link
|
|
// showed sync cost is round-trip COUNT (~40 CMD_GET_CHANNEL), with the firmware
|
|
// accounting for 0.3% of it. So there is nothing to trade away here.
|
|
static bool modem_sleep_set = false;
|
|
if (!modem_sleep_set) {
|
|
WiFi.setSleep(true);
|
|
modem_sleep_set = true;
|
|
}
|
|
if (!sntp_kicked) {
|
|
/* Brussels timezone with DST rules baked in (POSIX "CET-1CEST,...").
|
|
* On touch builds the base is shifted by the user's manual hour offset
|
|
* (Settings -> Device -> Time offset) so localtime() matches what they
|
|
* set. configTzTime only affects localtime() display; the mesh RTC
|
|
* still stores UTC seconds (protocol-facing). */
|
|
char _tz[48];
|
|
#if defined(HAS_TOUCH_UI)
|
|
touchPrefsBuildLocalTz(_tz, sizeof _tz);
|
|
#else
|
|
strncpy(_tz, "CET-1CEST,M3.5.0,M10.5.0/3", sizeof _tz);
|
|
_tz[sizeof _tz - 1] = '\0';
|
|
#endif
|
|
esp_sntp_set_sync_status(SNTP_SYNC_STATUS_RESET);
|
|
configTzTime(_tz, "pool.ntp.org", "time.google.com");
|
|
sntp_kicked = true;
|
|
} else if (!sntp_pushed &&
|
|
esp_sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
|
|
time_t t = time(nullptr);
|
|
if (t > (time_t)ClockFloorRTC::MIN_VALID_EPOCH &&
|
|
t <= (time_t)ClockFloorRTC::MAX_PLAUSIBLE_EPOCH) {
|
|
/* Mesh RTC stores UTC seconds (protocol-facing); display layer
|
|
* converts to local via localtime_r() using the TZ from configTzTime. */
|
|
rtc_clock.setCurrentTime((uint32_t)t);
|
|
sntp_pushed = true;
|
|
}
|
|
}
|
|
} else {
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
if (wifiConfigGetPagerWifiBlePhase() == PagerWifiBlePhase::Connected)
|
|
wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase::Idle);
|
|
#endif
|
|
// Link dropped: allow re-sync on next reconnect.
|
|
if (sntp_kicked && !sntp_pushed) sntp_kicked = false;
|
|
}
|
|
#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE)
|
|
if (s_pager_wifi_ble_deadline_ms != 0 &&
|
|
(int32_t)(millis() - s_pager_wifi_ble_deadline_ms) >= 0) {
|
|
s_pager_wifi_ble_deadline_ms = 0;
|
|
if (s_pager_ble_after_wifi && wifiConfigGetBleEnabled() &&
|
|
WiFi.status() != WL_CONNECTED) {
|
|
s_pager_ble_after_wifi = false;
|
|
wifi_started = false;
|
|
pagerWifiEnterBleFallback("association timed out; Bluetooth restored");
|
|
serial_interface.enableBle();
|
|
if (serial_interface.isBleEnabled()) {
|
|
Serial.println("[wifi] BLE restored after failed T-Pager association");
|
|
ui_task.showAlert(TR("Wi-Fi unavailable; Bluetooth restored"), 2200);
|
|
} else {
|
|
Serial.println("[wifi] BLE restore failed after T-Pager association timeout");
|
|
ui_task.showAlert(TR("Bluetooth unavailable; retry from Settings"), 2600);
|
|
}
|
|
} else if (!wifiConfigGetBleEnabled()) {
|
|
s_pager_ble_after_wifi = false;
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
#ifdef DISPLAY_CLASS
|
|
{ uint32_t _wfdt = millis() - _wf0; if (_wfdt >= 200) stallLog("wifi-sm", _wfdt); }
|
|
#endif
|
|
// Defer TCP and WebSocket until after splash dismisses so the_mesh.loop() never blocks on accept() before ui_task.loop() runs.
|
|
static const uint32_t TCP_DEFER_MS = 5000; // 5 s: don't start TCP/WS until version screen has dismissed
|
|
/* Only start TCP / WS when WiFi was actually brought up. In BLE-only mode
|
|
* (no saved creds) the lwIP stack is never initialized — calling
|
|
* WiFiServer::begin() crashes with a tcpip_adapter assert. */
|
|
if (millis() > TCP_DEFER_MS && wifi_started) {
|
|
#ifdef DISPLAY_CLASS
|
|
STALL_SCOPE("tcp-server", serial_interface.startTcpServer(WiFi.status() == WL_CONNECTED));
|
|
STALL_SCOPE("ws-tick", serial_interface.tickWebSocketHandshake());
|
|
#else
|
|
serial_interface.startTcpServer(WiFi.status() == WL_CONNECTED);
|
|
serial_interface.tickWebSocketHandshake();
|
|
#endif
|
|
}
|
|
#endif
|
|
#if defined(HAS_TOUCH_UI)
|
|
// The touch-UI "Spectrum" RF analyzer borrows the radio while open: it sweeps
|
|
// the modem across the band, so the mesh must NOT touch the radio (re-tune /
|
|
// re-arm RX on the home channel) meanwhile. spectrumOwnsRadio() is true only
|
|
// while that app is up; the moment it closes it restores the mesh radio params
|
|
// and clears the flag, so the next the_mesh.loop() re-arms RX correctly.
|
|
if (!spectrumOwnsRadio())
|
|
#endif
|
|
#ifdef DISPLAY_CLASS
|
|
STALL_SCOPE("mesh", the_mesh.loop());
|
|
#else
|
|
the_mesh.loop();
|
|
#endif
|
|
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
|
|
#ifdef DISPLAY_CLASS
|
|
STALL_SCOPE("mqtt", mqtt_bridge.loop());
|
|
#else
|
|
mqtt_bridge.loop();
|
|
#endif
|
|
#endif
|
|
#if defined(GPS_BUF_DEBUG)
|
|
// Bench diagnostic (build with -DGPS_BUF_DEBUG only; absent in releases): peak GPS UART
|
|
// backlog accumulated between sensors.loop() drains. A peak above the old 256-byte default
|
|
// proves loop stalls were overflowing the default ring — i.e. NMEA was being lost, which
|
|
// is the slow/never-acquires TTFF mechanism. With the 4096 ring above it can climb past
|
|
// 256 without loss, so a >256 reading is direct proof the fix matters on this unit.
|
|
{ static size_t s_gps_peak = 0; static uint32_t s_gps_log = 0;
|
|
size_t bl = Serial1.available();
|
|
if (bl > s_gps_peak) s_gps_peak = bl;
|
|
if (millis() - s_gps_log > 5000) {
|
|
s_gps_log = millis();
|
|
Serial.printf("[GPSBUF] peak=%u B / 5s (old cap 256, now 4096)\n", (unsigned)s_gps_peak);
|
|
s_gps_peak = 0;
|
|
}
|
|
}
|
|
#endif
|
|
#ifdef DISPLAY_CLASS
|
|
STALL_SCOPE("sensors", sensors.loop());
|
|
#else
|
|
sensors.loop();
|
|
#endif
|
|
#if defined(ESP32)
|
|
// GPS time guard (Ricky Leong's "stuck at 1902"): MicroNMEALocationProvider
|
|
// sets the mesh RTC from a GPS *position* fix even before the date fields are
|
|
// valid (getYear()==0 -> ~1902-10-11), and re-syncs every 30 min — so it
|
|
// periodically clobbers a good time. A 1902 clock stamps our adverts as
|
|
// decades old and every other node rejects them as stale.
|
|
//
|
|
// On the T-Deck the mesh RTC, NTP and GPS ALL share the one ESP32 system clock
|
|
// (ESP32RTCClock == settimeofday/gettimeofday), so we can't recover by
|
|
// "re-reading NTP" — it was already overwritten. Instead keep a millis()-
|
|
// anchored copy of the last good time and rebuild from it whenever the clock
|
|
// reads garbage, undoing the clobber before the next the_mesh.loop() sends an
|
|
// advert. The anchor refreshes every loop while the clock is sane, so the
|
|
// rebuilt time is accurate to the second.
|
|
{
|
|
static uint32_t good_epoch = 0, good_millis = 0;
|
|
const uint32_t live = rtc_clock.getCurrentTime();
|
|
if (live > 1700000000UL) { // clock sane -> remember it (anchor)
|
|
good_epoch = live;
|
|
good_millis = millis();
|
|
} else if (good_epoch != 0) { // clock went bad -> rebuild from anchor
|
|
const uint32_t rebuilt = good_epoch + (uint32_t)((millis() - good_millis) / 1000UL);
|
|
rtc_clock.setCurrentTime(rebuilt);
|
|
}
|
|
}
|
|
#endif
|
|
rtc_clock.tick();
|
|
|
|
// (1.16) sleep when there's no pending work — nRF power saving
|
|
if (!the_mesh.hasPendingWork()) {
|
|
#if defined(NRF52_PLATFORM)
|
|
board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
|
|
#endif
|
|
}
|
|
|
|
// (1.16) non-blocking WiFi auto-reconnect (event-flagged in setup). Touch /
|
|
// multi-transport builds run their own WiFi reconnect state machine above and
|
|
// don't include SerialWifiInterface's WIFI_DEBUG_PRINTLN, so skip it there.
|
|
#if defined(ESP32) && defined(WIFI_SSID) && !defined(MULTI_TRANSPORT_COMPANION)
|
|
if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > 10000)) {
|
|
WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect...");
|
|
WiFi.disconnect();
|
|
WiFi.reconnect();
|
|
last_wifi_reconnect_attempt = millis();
|
|
}
|
|
#endif
|
|
|
|
// (fork) drive the in-firmware OTA staged-reboot
|
|
#if defined(ESP32_PLATFORM)
|
|
board.pollHttpOtaReboot();
|
|
#endif
|
|
|
|
#if defined(HAS_TOUCH_UI)
|
|
// Idle light-sleep gate: evaluated every loop tick. g_enabled is false by
|
|
// default (Task 1 is inert); Task 2 sets it from the NVS pref and wires
|
|
// the real predicates so the gate can actually pass and arm light sleep.
|
|
#ifdef DISPLAY_CLASS
|
|
STALL_SCOPE("sleep-gate", touchSleep::loopEnd(millis()));
|
|
#else
|
|
touchSleep::loopEnd(millis());
|
|
#endif
|
|
#endif
|
|
}
|