diff --git a/src/main.cpp b/src/main.cpp index 56614de..841af45 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -185,6 +185,10 @@ extern "C" void set_boot_phase(int phase) { g_boot_phase = phase; } // "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 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 @@ -332,12 +336,21 @@ bool meshcomodMigrateSpiffsToSd(bool force) { 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 @@ -691,6 +704,7 @@ void setup() { if (mig_busy) { if (mp_ok) _mp.end(); 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"); } else if (needs_migration) { // Do not start without a durable rollback breadcrumb. If NVS is @@ -707,6 +721,7 @@ void setup() { 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 breadcrumb unavailable -> staying on SPIFFS this boot"); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 29d9997..58f5291 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -10873,13 +10873,15 @@ static void useSdStorageToggleCb(lv_event_t* e) { : TR("Data -> internal on reboot"), 1800); } -#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) +#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) // "Copy internal data to SD": recovery for the beta_36 upgrades where the live // profile was orphaned on internal flash while the honored SD toggle adopted an // empty card (which then minted a fresh identity). Overwrites the card's copies // with EVERYTHING from internal flash, forces the SD pref on, reboots. extern bool meshcomodMigrateSpiffsToSd(bool force); // main.cpp +extern bool meshcomodArmSdMigLatch(); // durable in-progress guard extern void meshcomodClearSdMigLatch(); // main.cpp (GH #142/#148 boot safe-mode) +extern bool g_sd_migration_blocked; static void sdRestoreApply() { if (!g_lv.task) return; if (!fmSdTryMount()) { g_lv.task->showAlert(TR("No SD card"), 1600); return; } @@ -10888,12 +10890,21 @@ static void sdRestoreApply() { // Land everything in RAM first — this path used to reboot WITHOUT persisting, // silently dropping every message since the last lazy flush. g_lv.task->persistHistoryNow(); + if (!meshcomodArmSdMigLatch()) { + g_sd_migration_blocked = true; + g_lv.task->showAlert(TR("Copy blocked: migration guard unavailable"), 2600); + return; + } bool ok = false; { LoopWdtGuard loop_wdt_guard; ok = meshcomodMigrateSpiffsToSd(true); } - if (!ok) { g_lv.task->showAlert(TR("Copy failed (no internal identity)"), 2200); return; } + if (!ok) { + g_sd_migration_blocked = true; + g_lv.task->showAlert(TR("Copy incomplete: SD data may be mixed. Retry before reboot."), 4200); + return; + } meshcomodClearSdMigLatch(); // manual copy succeeded -> re-arm boot auto-adoption (GH #142/#148) touchPrefsSetUseSdStorage(true); // make sure the reboot adopts the card delay(400); // let the alert render before the restart @@ -12150,6 +12161,7 @@ static void buildDeviceSettings(int sec) { even with it ON. This line shows the truth and flags that mismatch. */ { extern bool g_contacts_on_sd; + extern bool g_sd_migration_blocked; const bool want_sd = touchPrefsGetUseSdStorage(); lv_obj_t* st = lv_label_create(body); lv_label_set_long_mode(st, LV_LABEL_LONG_WRAP); @@ -12161,6 +12173,9 @@ static void buildDeviceSettings(int sec) { ? TR("SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted.") : TR("SD data is unavailable - contacts and channels cannot be saved until the card is reinserted.")); lv_obj_set_style_text_color(st, lv_color_hex(0xE34B4B), LV_PART_MAIN); + } else if (want_sd && g_sd_migration_blocked) { + lv_label_set_text(st, TR("SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.")); + lv_obj_set_style_text_color(st, lv_color_hex(0xE3A127), LV_PART_MAIN); } else if (g_contacts_on_sd) { lv_label_set_text(st, TR("Contacts are saved to the SD card.")); lv_obj_set_style_text_color(st, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); @@ -12179,8 +12194,8 @@ static void buildDeviceSettings(int sec) { orphaned on internal flash when the honored SD toggle adopted an empty (or fresh-identity) card. This copies EVERYTHING from internal flash over the card's copies and reboots into the restored profile. This is a SPIFFS->SD - recovery on T-Deck and Pager; Tanmatsu uses SD_MMC with no SPIFFS. */ -#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) + recovery on T-Deck, V4-R8 and Pager; Tanmatsu uses SD_MMC with no SPIFFS. */ +#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) { lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(96), SC(30)); @@ -12194,7 +12209,7 @@ static void buildDeviceSettings(int sec) { lv_obj_center(lbl); y += SC(40); } -#endif // HAS_TDECK_GT911 || TLORA_PAGER (SPIFFS->SD recovery copy) +#endif // HAS_TDECK_GT911 || HELTEC_LORA_V4_R8 || TLORA_PAGER (SPIFFS->SD recovery copy) #endif } @@ -14447,7 +14462,7 @@ static bool s_telem_manual_pending = false; // a manual request is awaiting static uint32_t s_telem_deadline_ms = 0; // manual-request timeout (separate from ping) static int s_telem_win_now_h = 0; // display window: newer bound (hours ago) static int s_telem_win_past_h = 24; // display window: older bound (hours ago) -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) static void openTelemetryWindow(const uint8_t* key6, const char* name, int state); // fwd #endif static constexpr unsigned long UI_PING_TIMEOUT_MS = 30000; // 30 s for flood paths @@ -14522,7 +14537,7 @@ static void actionSheetTelemetryCb(lv_event_t* e) { bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // Open the telemetry window on its history; it does NOT auto-request. The user // taps the Request button (refresh glyph, left of the gear) to poll. memcpy(s_telem_node, c.id.pub_key, 6); @@ -17073,7 +17088,7 @@ static inline void sdNoteIoFailure() { // the 24h chart still works. SD path lives under /meshcomod with the other files; // SPIFFS (flat) uses a top-level path. static fs::FS& battLogFs() { -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (SD.cardType() != CARD_NONE) { if (SD.exists("/meshcomod")) return SD; // cardType() says present (cached) but real I/O failed — wedge/removal tell. @@ -17084,7 +17099,7 @@ static fs::FS& battLogFs() { return SPIFFS; } static bool battLogOnSd() { -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) return SD.cardType() != CARD_NONE && SD.exists("/meshcomod"); #else return false; @@ -17443,7 +17458,7 @@ static void batteryTapCb(lv_event_t* e) { openBatteryChartWindow(); } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // ----- Per-node telemetry log (/meshcomod/telemetry/.log) ----- // One file per node (6-byte pubkey prefix, hex). Columns (tab-separated): // epoch \t YYYY-MM-DD HH:MM \t battery_mv \t tempC*10 \t humidity% @@ -21178,7 +21193,7 @@ static uint32_t s_disc_log_count = 0; // sightings written to SD this sessi static lv_obj_t* s_disc_footer = nullptr; // bottom status line: GPS / wardrive state static void discoverLogSighting(int32_t lat_e6, int32_t lon_e6, const MyMesh::DiscoverHit& h) { -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (SD.cardType() == CARD_NONE) return; markSdIo(); SD.mkdir("/meshcomod"); @@ -25489,7 +25504,7 @@ static void queueTileForFetch(uint8_t z, int32_t x, int32_t y) { // elsewhere s_tiles_from_sd is always false, so the simple guard is equivalent. // The SD-pack offline mode is OSM-only; topo has no SD packs, so it always // fetches from the proxy regardless of the microSD-tiles setting. -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (s_map_style == 0 && s_tiles_from_sd && s_tile_fs != &SD) return; #else if (s_map_style == 0 && s_tiles_from_sd) return; @@ -25629,7 +25644,7 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, char path[48]; snprintf(path, sizeof(path), "%s/%u/%ld/%ld.jpg", mapTileRoot(), (unsigned)z, (long)x, (long)y); -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (s_tiles_from_sd && s_map_style == 0) { // SD packs are OSM-only; topo uses the online /tiles/topo cache // Tile source = microSD: read straight off the card (fully offline, no server fetch). if (s_sd_fail_note_ms) return false; // card suspected dead — don't stack per-tile SPI timeouts (sdHealthTick arbitrates) @@ -25771,7 +25786,7 @@ static void mapNoteStorageChanged() { #if defined(ESP32) // Is *some* tile source available to probe at all? (SD pack or LittleFS /tiles.) static bool mapTileSourceReady() { -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (s_tiles_from_sd) return true; #endif return s_tiles_fs_ready; @@ -25782,7 +25797,7 @@ static bool mapTileSourceReady() { // online cache), so an SD /maps/osm offline pack was invisible to zoom — the loader // drew its tiles but the buttons reported "Max/Min zoom for this pack" offline. static bool tileExistsAt(uint8_t z, long x, long y) { -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (s_tiles_from_sd && s_map_style == 0) { // topo isn't on SD packs — probe the online cache below if (s_sd_fail_note_ms) return false; // card suspected dead — skip the five per-tile probes (sdHealthTick arbitrates) if (!s_sd_mounted) return false; // never ladder from the zoom guard (see loadTileJpeg) @@ -26108,7 +26123,7 @@ static void renderMapTiles() { } #if defined(ESP32) -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (s_tiles_from_sd) { lv_label_set_text(s_map_status_lbl, TR("Map tiles: microSD\n\n" @@ -26897,12 +26912,22 @@ static void mapOptZoomButtonsCb(lv_event_t* e) { mapZoomControlsApply(); } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // Map tile source toggle (in the map options popup): ON = tiles live on the microSD // card — read the user's SD library AND cache Wi-Fi-fetched gaps there (#20), so the // library grows and downloads survive; OFF = tile server + internal LittleFS cache. static void mapOptTilesSdCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + // The worker dereferences the shared cache backend throughout a request. Do + // not repoint it while a queued/in-flight fetch may still own a File on the + // old filesystem; on Pager that would also hide the live SD handle from the + // VFS lifecycle gate and permit SD.end() underneath it. + if (s_tile_fetch_pending > 0) { + if (s_tiles_from_sd) lv_obj_add_state(lv_event_get_target(e), LV_STATE_CHECKED); + else lv_obj_clear_state(lv_event_get_target(e), LV_STATE_CHECKED); + if (g_lv.task) g_lv.task->showAlert(TR("Tile download in progress - retry"), 1800); + return; + } const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetTilesFromSd(on); s_tiles_from_sd = on; @@ -27086,7 +27111,7 @@ static void openMapOptions() { lv_obj_set_pos(title, 0, 0); int y = 26; -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // Row: tile source — microSD (offline) vs the tile server. The important one, // so it sits at the very top. { @@ -28955,7 +28980,7 @@ static void crashDumpCheck() { // so the whole crash-report UI stays off (everything downstream gates on s_crash_dump_size, // which then stays 0). The panic coredump still sits in its flash partition for a USB/esptool // pull if we ever need it. -#if CAP_FILESYSTEM +#if CAP_FILESYSTEM || defined(TLORA_PAGER) crashWdtCaptureRead(); // pull the Task-WDT culprit (if any) before checking for a dump size_t addr = 0, size = 0; if (esp_core_dump_image_check() == ESP_OK && @@ -28980,7 +29005,7 @@ static bool crashDumpExport(char* out_path, size_t out_cap) { bool used_sd = false; fs::FS* dst = nullptr; const char* path = "/wadamesh-crash.elf"; -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (fmSdTryMount()) { dst = &SD; used_sd = true; } #elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) if (tanSdTryMount()) { dst = &SD_MMC; used_sd = true; } // microSD on SDMMC slot 0 @@ -34176,7 +34201,7 @@ static uint8_t* lockscreenDecodeWallpaper(int* out_w, int* out_h) { // this path only runs for user-selected wallpapers.) fs::FS* fsp = &SPIFFS; const char* fp = path; -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (!strncmp(path, "sd:", 3)) { fsp = &SD; fp = path + 3; fmSdTryMount(); } // mount on demand so the chosen SD wallpaper decodes (T-Deck SD only) #endif @@ -34826,7 +34851,7 @@ static void backupScan() { } root.close(); } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // SD card: scan the root AND the /meshcomod data folder. SD-storage builds keep // their data under /meshcomod, so users naturally drop a backup next to it — // previously only the root was scanned, so a json in /meshcomod never showed up @@ -34876,7 +34901,7 @@ static void doBackupImportChosen() { fs::FS* fsp = nullptr; const char* path = s_backup_chosen; if (!strncmp(path, "int:", 4)) { fsp = backupInternalFs(); path += 4; } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) else if (!strncmp(path, "sd:", 3)) { fsp = &SD; path += 3; } #endif if (!fsp) { g_lv.task->showAlert(TR("Import: storage unavailable"), 2000); return; } @@ -35033,7 +35058,7 @@ static void doExportBackupFile(const char* fname) { char path[96]; snprintf(path, sizeof path, "/%s", fname); File f; const char* where = "internal"; -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // Actually mount the card (SD.cardType alone reads CARD_NONE until something // mounts it) so a backup truly lands on — and lists from — the SD card. if (fmSdTryMount()) { f = SD.open(path, FILE_WRITE); if (f) where = "SD"; } @@ -35055,7 +35080,7 @@ static void doDeleteBackup() { const char* path = s_backup_del_path; bool ok = false; if (!strncmp(path, "int:", 4)) { ok = backupInternalFs()->remove(path + 4); } // FFat on Tanmatsu/P4, SPIFFS elsewhere -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) else if (!strncmp(path, "sd:", 3)) { if (fmSdTryMount()) ok = SD.remove(path + 3); } #endif s_backup_del_path[0] = '\0'; @@ -35122,7 +35147,7 @@ static void doFactoryReset() { lv_obj_del(ov); if (g_lv.task) { g_lv.task->flushHistorySoon(); - g_lv.task->showAlert(TR("Factory reset blocked: SD is busy; retry"), 2600); + g_lv.task->showAlert(TR("Factory reset blocked: SD activity is still running; close tools and retry."), 3600); } return; } @@ -35141,7 +35166,7 @@ static void doFactoryReset() { lv_obj_del(ov); if (g_lv.task) { g_lv.task->flushHistorySoon(); - g_lv.task->showAlert(TR("Factory reset blocked: SD wipe failed; remove card and retry"), 4200); + g_lv.task->showAlert(TR("Factory reset incomplete: SD data may already be partially erased. Retry to finish, or remove the card before resetting internal data."), 6000); } return; } @@ -39069,7 +39094,7 @@ static void statusBarReaderBackCb(lv_event_t* e) { // compression) is the quickest viewable format to emit — rows are a direct copy // of the RGB565 framebuffer (LV_COLOR_16_SWAP is 0). Saved to /screenshots. static void takeScreenshotToSd() { -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) auto toast = [&](const char* m){ if (g_lv.task) g_lv.task->showAlert(m, 1800); }; if (SD.cardType() == CARD_NONE) { toast(TR("Screenshot: no SD card")); return; } const int W = lv_disp_get_hor_res(nullptr); @@ -42716,7 +42741,7 @@ void UITask::onPingReply(const ContactInfo& contact, const uint8_t* data, size_t // popup) instead of a single-slot toast, so the full multi-line reading stays on // screen until tapped away and can scroll if it's long. Tap the dimmed backdrop // to close. -#if CAP_SD // telemetry window + per-node log/poll are SD-backed (T-Deck only) +#if CAP_SD || defined(TLORA_PAGER) // telemetry window + per-node log/poll use the mounted SD store static lv_obj_t* s_telemetry_root = nullptr; static lv_obj_t* s_telem_config_root = nullptr; // settings panel spawned on top static lv_obj_t* s_telem_poll_ta = nullptr; // auto-poll interval input (config panel) @@ -43375,7 +43400,7 @@ void UITask::onTelemetryReply(const ContactInfo& contact, const uint8_t* data, s if (p > 0 && (size_t)p >= body_cap - 1) break; // body buffer full } done: { -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // Log every reply (manual or auto-poll) to the per-node telemetry file. if (any) telemetryLogAppend(contact.id.pub_key, (uint32_t)time(nullptr), tl_mv, tl_t10, tl_h); #endif @@ -43397,7 +43422,7 @@ done: { snprintf(msg, sizeof(msg), "%s: %uB %s", nm, (unsigned)len, hex); } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // Update the telemetry window ONLY for the node it's showing, and only when a // manual request is in flight — auto-poll just logs (above) without a window. if (s_telem_manual_pending && memcmp(s_telem_node, contact.id.pub_key, 6) == 0) { @@ -46122,7 +46147,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // mirrors this when the toggle flips at runtime. s_tile_fs_default = s_tile_fs; strncpy(s_tile_root_default, s_tile_root, sizeof s_tile_root_default - 1); -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) if (s_tiles_from_sd && (sdAdoptLiveMount() || fmSdTryMount())) { s_tile_fs = &SD; s_tile_root[0] = '\0'; @@ -48452,7 +48477,7 @@ void UITask::loop() { snprintf(msg, sizeof(msg), TR("No reply from %s"), s_ui_ping_target_name); showAlert(msg, 3500); } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // Manual telemetry request timed out — flip the open window to "failed" (it // still shows the history). Auto-poll has no deadline, so it never lands here. if (s_telem_manual_pending && s_telem_deadline_ms != 0 && now >= s_telem_deadline_ms) { @@ -49145,7 +49170,7 @@ void UITask::loop() { } batteryLogTick((uint32_t)now); // 5-min battery sample (SD on T-Deck, else SPIFFS) -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) telemetryPollTick((uint32_t)now); // auto-poll due nodes -> log (no window) #endif uiCp("ui:verchk"); @@ -49396,7 +49421,7 @@ static const PopupEnt k_popup_registry[] = { PF_COUNT }, #endif { P_OPEN(s_confirm_modal), []{ confirmDismiss(); }, PF_COUNT }, -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) { P_OPEN(s_telem_config_root), []{ telemetryConfigClose(); }, PF_COUNT }, // sits on the telemetry window { P_OPEN(s_telemetry_root), []{ telemetryClose(); }, PF_COUNT }, #endif diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index 0df2669..c9e0e34 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -41,8 +41,9 @@ #define CAP_LARGE_SCREEN 0 // native 480x222, no UI upscaling // Keep these structural caps at 0: they still gate several T-Deck-specific // UI/features. Pager storage is wired explicitly at its audited call sites - // (boot DataStore, history, tiles, file manager and WAV playback) using its - // shared-SPI mount helper; formatting remains deliberately unavailable. + // (boot DataStore, history, tiles, file manager/WAV, telemetry/logging, + // backups and crash export) using its shared-SPI mount helper; formatting + // remains deliberately unavailable. #define CAP_SD 0 #define CAP_FILESYSTEM 0 #define CAP_GPS 1 // u-blox MIA-M10Q