// wadamesh on the LilyGo T-Display P4 — the app entry point. // // Standalone ESP-IDF app (NOT an AppFS/launcher app like the Tanmatsu): it owns the USB console, // drives the XL9535 expander to power the board, brings up the RM69A10 AMOLED + the C6 (esp-hosted) // + the raw SX1262 or LR2021, then runs the shared UITask. The board/radio/display globals + radio_init() live // in variants/tdisplay_p4/target.cpp (via target.h). Modeled on tanmatsu/main/main.cpp; the WiFi + // companion loop is ported faithfully. Bring-up TODOs (touch, brightness, DSI/power tuning) in // variants/tdisplay_p4/TDISPLAY_P4_PORT.md. #include // adcAttachPin() shim — BEFORE target.h pulls ESP32Board.h #include #include #include "MyMesh.h" #include // placement-new for the PSRAM-resident the_mesh #include "esp_heap_caps.h" #include "UITask.h" #include "target.h" // board, radio_driver, rtc_clock, display, sensors, radio_init() #include // internal 'storage' partition, mounted as LittleFS (label lookup is // subtype-agnostic, so the ex-FAT partition converts in place -- no // partition-table change, OTA-safe). The P4's FAT-on-flash layer has // documented broken metadata (exists/size/f_getfree lie) and v3 of the // #167 migration proved even its WRITES cannot be trusted; LittleFS is // the same battle-proven backend every S3 board uses internally. #include // microSD on SDMMC slot 0 (primary store) #include "esp_partition.h" #include #include "../../src/helpers/esp32/MultiTransportCompanionInterface.h" #include // wifiConfig* (runtime Wi-Fi state the loop drives) #include // touchPrefsBuildLocalTz + WIFI_CONFIG_* sizes #include "../../src/helpers/esp32/BootTimeSync.h" // opt-in cold-boot clock sync over saved Wi-Fi (#383) #include "esp_hosted.h" #include "esp_netif.h" #include "esp_event.h" #include "lvgl.h" #if !TDP4_C6_HOSTED #include "c6_at.h" // legacy AT-over-SDIO backend #include #endif #include "esp_vfs_fat.h" // native SD probe (esp_vfs_fat_sdmmc_mount) #include "driver/sdmmc_host.h" // SDMMC_HOST_DEFAULT / sdmmc_slot_config_t for the probe #include "sdmmc_cmd.h" #include "sd_pwr_ctrl_by_on_chip_ldo.h" // SD IO rail = P4 on-chip LDO channel 4 // Legacy builds rebind WiFi.* to the ESP-AT facade. Current units ship hosted // C6 firmware and use Arduino's standard remote Wi-Fi implementation. #if !TDP4_C6_HOSTED #include #endif #ifndef TCP_PORT #define TCP_PORT 5000 #endif #ifndef WS_PORT #define WS_PORT 8765 #endif // --- C6 / esp-hosted bring-up gate ----------------------------------------------------------------- // TWO HARDWARE GENERATIONS, and the image has to match the one in front of it. Current T-Display P4 // V1 units ship the C6 with esp-hosted firmware (2.12.3) and are what TDP4_C6_HOSTED=1 (the default) // is for. OLDER units still carry the factory ESP-AT firmware and need WADA_P4_LEGACY_AT=1. Flash a // hosted image onto an ESP-AT unit and the SDIO stream never frames — the log fills with // "H_SDIO_DRV: len[...]>max[1524] ... Drop" and Wi-Fi/BLE simply never arrive. // // That mismatch must cost Wi-Fi and BLE and NOTHING ELSE. All C6-dependent bring-up therefore runs // at the END of setup, after ui_task.begin() — see the block there. LoRa is a raw SX1262/LR2021 on // P4 GPIOs, independent of the C6, so the mesh still works over USB/LoRa on a mismatched unit. // // esp-hosted RESETS THE HOST on a transport failure, which is what turns a mismatch into a boot // loop rather than a missing feature. There are two reset paths and they need separate switches: // CONFIG_ESP_HOSTED_HOST_RESTART_NO_COMMUNICATION_WITH_SLAVE=n covers one, and // CONFIG_ESP_HOSTED_TRANSPORT_RESTART_ON_FAILURE=n covers the one that actually fires here, about // 10 s after the connect attempt begins (sdio_drv.c, H_TRANSPORT_RESTART_ON_FAILURE). Both are off // in sdkconfigs/tdisplay_p4. Measured on an ESP-AT unit before the second one was set: five resets // in a 45 s capture, each rst:0x9 HP_SYS_LP_WDT_RESET. Do not re-enable either. #define TDP4_C6_READY TDP4_C6_HOSTED // BLE over the C6 uses arduino-esp32's hostedInitBLE(), which only exists in arduino-esp32 >=3.3.10. // We pin 3.3.0 (so esp_hosted can be the C6-compatible 2.0.17), which predates hostedInitBLE — so BLE // needs its own bring-up. Keep BLE gated OFF until that's provided; Wi-Fi (esp_wifi_remote) works now. #define TDP4_BLE_READY TDP4_C6_HOSTED #if TDP4_C6_HOSTED && defined(CONFIG_BT_NIMBLE_TRANSPORT_UART) #error "T-Display P4 hosted C6 requires NimBLE hosted HCI; disable CONFIG_BT_NIMBLE_TRANSPORT_UART" #endif extern "C" bool hostedInitBLE(); // arduino-esp32 BLE controller bring-up over esp-hosted // Boot-trace hooks the shared code defines on the S3 boards (src/main.cpp, excluded here). volatile int g_boot_phase = 0; extern "C" void set_boot_phase(int phase) { g_boot_phase = phase; } // App globals — src/main.cpp declares these; we own them here (that file is excluded from the build). DataStore store(LittleFS, rtc_clock); bool g_fs_ok = false; // internal 'storage' (LittleFS) mounted (extern — UITask file browser) bool g_sd_ok = false; // microSD mounted (extern) MultiTransportCompanionInterface serial_interface; StdRNG fast_rng; SimpleMeshTables tables; UITask ui_task(&board, &serial_interface); // the_mesh (~42 KB, MAX_CONTACTS-dominated) lives in the 32 MB PSRAM, not scarce internal DRAM. static MyMesh& makeTheMesh() { void* mem = heap_caps_malloc(sizeof(MyMesh), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!mem) mem = malloc(sizeof(MyMesh)); return *new (mem) MyMesh(radio_driver, fast_rng, rtc_clock, tables, store, &ui_task); } MyMesh& the_mesh = makeTheMesh(); static constexpr bool s_hosted_c6_up = true; // hostedInit() connects lazily on first Wi-Fi/BLE use // Recursive FS→FS copy for the one-time SD adoption migration (FFat store → SD /meshcomod). // Skips files that already exist at the destination, so a partial earlier run just completes. static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const char *dp, bool overwrite = false) { if (!overwrite) { File probe = dst.open(dp, FILE_READ); if (probe) { probe.close(); return; } } File in = src.open(sp, FILE_READ); if (!in) return; File out = dst.open(dp, FILE_WRITE); if (!out) { in.close(); return; } static uint8_t buf[1024]; int n; while ((n = in.read(buf, sizeof buf)) > 0) out.write(buf, (size_t)n); out.close(); in.close(); printf("[storage] migrated %s\n", sp); } static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char *ddir, bool overwrite = false) { File d = src.open(sdir); if (!d || !d.isDirectory()) { if (d) d.close(); return; } dst.mkdir(ddir); File f; while ((f = d.openNextFile())) { const char *name = f.name(); const char *leaf = strrchr(name, '/'); leaf = leaf ? leaf + 1 : name; char sp[128], dp[128]; snprintf(sp, sizeof sp, "%s/%s", strcmp(sdir, "/") == 0 ? "" : sdir, leaf); snprintf(dp, sizeof dp, "%s/%s", ddir, leaf); bool is_dir = f.isDirectory(); f.close(); if (is_dir) migrateFsDir(src, sp, dst, dp, overwrite); else migrateFsCopyFile(src, sp, dst, dp, overwrite); } d.close(); } static void wadameshSetup() { Serial.begin(115200); delay(150); printf("[BOOT] wadamesh / T-Display P4\n"); // 1. XL9535 expander FIRST — it powers the rails and enables the C6, SD, screen + touch. if (!xl9535.begin(7, 8)) printf("[BOOT] XL9535 init FAILED (board will not power up)\n"); xl9535.powerOnSequence(); // 2. AMOLED (RM69A10 MIPI-DSI) — up before LVGL flushes to it. if (!display.begin()) printf("[BOOT] RM69A10 display init FAILED\n"); // 3. lwIP/tcpip + default event loop before any WiFi/netif call (esp_wifi_remote needs it). esp_netif_init(); esp_event_loop_create_default(); // 4. C6: which firmware it runs depends on the unit's age — see the generation note at the top. // Either way nothing happens HERE: both backends are started at the end of setup, after the // UI. On the ESP-AT build the c6_at AT-over-SDIO worker carries Wi-Fi and the BLE companion is // unavailable (advertising commands stubbed), so the phone pairs over Wi-Fi (TCP:5000) or USB. #if TDP4_C6_HOSTED printf("[BOOT] C6 = esp-hosted (lazy Wi-Fi/BLE initialization)\n"); #else printf("[BOOT] C6 = factory ESP-AT (Wi-Fi via c6_at worker; BLE companion unavailable on this build)\n"); #endif // 5. Radio (raw SX1262/LR2021; reset via the XL9535 inside radio_init). if (!radio_init()) printf("[BOOT] radio_init FAILED\n"); DisplayDriver* disp = &display; // 6. Storage — microSD (SDMMC slot 0) primary, internal FFat 'storage' as the no-card fallback. // formatOnFail=true: the first boot of this build finds FAT data in the partition, fails the // littlefs mount, and formats it — the deliberate one-way conversion (store contents come back // from the SD card via the v4 migration below). g_fs_ok = LittleFS.begin(true, "/lfs", 10, "storage"); printf("[storage] LittleFS(storage) = %s\n", g_fs_ok ? "OK" : "FAILED"); if (g_fs_ok) { LittleFS.mkdir("/identity"); LittleFS.mkdir("/bl"); } // The slot's VDD is gated by the XL9535's SD_EN (IO15), ACTIVE-LOW — powerOnSequence() drives it // low. (Root-caused 2026-07-15 with a GPIO pad sweep: SD_EN high = all six SD pads clamped LOW // through the unpowered card's ESD diodes -> 0x107/0x109 on every mount; SD_EN low = pads high, // card powered. Our Xl9535::begin() had parked it output-HIGH.) Card supply also wants the P4's // on-chip LDO4 attached like Meck does — Arduino's SD_MMC defaults to exactly that on this chip. // Pins are the board's SDMMC slot 0 IOMUX set (CLK43/CMD44/D0-3=39..42), 4-bit like Meck. SD_MMC.setPins(43, 44, 39, 40, 41, 42); // Meck's working init also sets SDMMC_SLOT_FLAG_INTERNAL_PULLUP (build.sh patches Arduino's slot-0 // struct literal to add it). The powered board turns out to have external pull-ups on all six // lines too — belt and braces. for (int p : {44, 39, 40, 41, 42}) gpio_pullup_en((gpio_num_t)p); // Mount ladder: 4-bit@20 MHz normally succeeds first try now that the slot is powered; the // fallbacks stay for marginal cards. (1-bit skips D1..D3; 5 MHz derates signal margin.) // // #167: SD activity IS the whole-screen flash. Proven by elimination on-device: identical // firmware with the card removed never flashes, while every reported trigger (message arrives, // opening an unread chat, contact add/delete, boot pre-splash) ends in an SD write burst or the // mount probe. The display stack itself measured clean throughout (framebuffer content, bridge // underrun, host protocol errors, DCS traffic, expander writes -- all silent during visible // flashes; DSI config + panel init identical to the flash-free Meck-P4). Remaining coupling is // electrical: the 4-bit 20 MHz SDMMC bus and/or the card's write-current spikes on the shared // rail. TDP4_SD_KHZ / TDP4_SD_1BIT (build-time) tune the first mount rung to test/derate. { #ifndef TDP4_SD_KHZ #define TDP4_SD_KHZ SDMMC_FREQ_DEFAULT // 20 MHz #endif #ifndef TDP4_SD_1BIT #define TDP4_SD_1BIT false #endif struct { bool onebit; int khz; const char *tag; } tries[] = { { TDP4_SD_1BIT, TDP4_SD_KHZ, "primary (TDP4_SD_KHZ)" }, { false, 5000, "4-bit 5MHz" }, { true, SDMMC_FREQ_DEFAULT, "1-bit 20MHz" }, { true, 5000, "1-bit 5MHz" }, }; sdMountDiagBegin(); uint32_t mounted_hz = 0; for (auto &t : tries) { const bool begin_ok = SD_MMC.begin("/sdcard", t.onebit, false, t.khz); g_sd_ok = begin_ok && SD_MMC.cardType() != CARD_NONE; sdMountDiagAttempt((uint32_t)t.khz * 1000u, begin_ok, g_sd_ok); printf("[storage] SD_MMC try %s -> %s\n", t.tag, g_sd_ok ? "OK" : "fail"); if (g_sd_ok) { mounted_hz = (uint32_t)t.khz * 1000u; break; } SD_MMC.end(); delay(120); } sdMountDiagSetMounted(g_sd_ok, mounted_hz); } printf("[storage] SD_MMC = %s\n", g_sd_ok ? "OK" : "no card"); if (g_sd_ok) { // #167 RESOLUTION -- the hot store lives on INTERNAL FFat; the SD carries only bulk, gentle // writers (map tiles, backups). Root cause, established by on-device elimination: SD-card // WRITE bursts electrically disturb the AMOLED (whole-screen blue flash). The display stack // measured clean throughout -- framebuffer content, DSI bridge underrun, DSI host protocol // errors, DCS traffic, expander writes: all silent during visible flashes -- and the flash // survived every digital derate (4->1-bit bus, 20->10 MHz SD clock, 1000->750 Mbps DSI lane // rate) on EVERY card tried, at any brightness, yet vanished the moment the card was removed // or left unwritten. The card's supply rail is shared with the panel (XL9535 SD_EN switches // it off the same 3V3 domain), so its program-current spikes reach the panel and firmware // cannot filter them -- it can only stop hammering the card. Streaming tile writes never // flashed, so the SD keeps those. This is also why the flash-free Meck build proves nothing // about the board: it keeps its store internal and never writes the SD at all. // // One-time REVERSE migration: earlier P4 builds kept the live store on SD /meshcomod, so if // the card carries an identity and this build hasn't migrated yet, copy the store back to // FFat, OVERWRITING FFat's stale pre-adoption copies (the SD is the current truth -- booting // cardless on the old FFat set showed long-deleted contacts). SD data is left in place, // untouched, as a rollback. // v4 migration -- one lesson per predecessor: v1 trusted whichever card was in and copied a // blank store; v2 copied the whole SD tree and filled the partition before contacts3 landed; // v3 did format+whitelist correctly and STILL read back empty -- because the P4's FAT-on-flash // driver itself is broken (its stat layer lying was already documented; v3 proved writes are // no better). v4 = the same clean-slate whitelist copy, onto LittleFS. Card is never written. File mk = LittleFS.open("/.hot_store_on_lfs_v4", FILE_READ); bool migrated = (bool)mk; if (mk) mk.close(); if (!migrated) { File sp = SD_MMC.open("/meshcomod/identity/_main.id", FILE_READ); bool sd_has_id = (bool)sp; if (sp) sp.close(); auto fsize = [](fs::FS &f, const char *path) -> size_t { File h = f.open(path, FILE_READ); size_t n = h ? h.size() : 0; if (h) h.close(); return n; }; size_t sd_meshcomod = fsize(SD_MMC, "/meshcomod/contacts3"); size_t sd_root = fsize(SD_MMC, "/contacts3"); size_t lf_contacts = fsize(LittleFS, "/contacts3"); size_t sd_contacts = sd_meshcomod > sd_root ? sd_meshcomod : sd_root; if (sd_has_id && sd_contacts > lf_contacts) { printf("[storage] #167 v4: store SD -> LittleFS (contacts3 /meshcomod=%uB root=%uB, lfs had %uB)\n", (unsigned)sd_meshcomod, (unsigned)sd_root, (unsigned)lf_contacts); LittleFS.mkdir("/identity"); LittleFS.mkdir("/bl"); const char *sub = sd_meshcomod >= sd_root ? "/meshcomod" : ""; // live layout root on the card char sp2[64]; migrateFsDir(SD_MMC, "/meshcomod/identity", LittleFS, "/identity", true); migrateFsDir(SD_MMC, "/meshcomod/bl", LittleFS, "/bl", true); static const char *k_files[] = { "/contacts3", "/channels2", "/adv_blobs", "/new_prefs", "/new_prefs.tmp", "/regions2", "/ui_chat_history_v1.bin" }; for (const char *nm : k_files) { snprintf(sp2, sizeof sp2, "%s%s", sub, nm); File probe = SD_MMC.open(sp2, FILE_READ); bool have = (bool)probe; if (probe) probe.close(); if (!have && sub[0]) { snprintf(sp2, sizeof sp2, "%s", nm); } else if (!have) { snprintf(sp2, sizeof sp2, "/meshcomod%s", nm); } migrateFsCopyFile(SD_MMC, sp2, LittleFS, nm, true); } printf("[storage] #167 v4: done -- lfs used %u / %u KB, contacts3 readback %uB\n", (unsigned)(LittleFS.usedBytes() / 1024), (unsigned)(LittleFS.totalBytes() / 1024), (unsigned)fsize(LittleFS, "/contacts3")); File w = LittleFS.open("/.hot_store_on_lfs_v4", FILE_WRITE); if (w) { w.write((const uint8_t*)"1", 1); w.close(); } } else { printf("[storage] #167 v4: no migration (sd_id=%d sd=%uB lfs=%uB) -- LittleFS is the store\n", (int)sd_has_id, (unsigned)sd_contacts, (unsigned)lf_contacts); File w = LittleFS.open("/.hot_store_on_lfs_v4", FILE_WRITE); if (w) { w.write((const uint8_t*)"1", 1); w.close(); } } } #if defined(TDP4_SD_STORE_TEST) // #167 EXPERIMENT: store back on the SD card with all hot writes hopped to core 0 (the tile // task's core, which has never flashed). One variable vs the known-flashing config: the // executing core. Uses the card's existing (slightly stale) store; migration skipped. printf("[storage] TDP4_SD_STORE_TEST: store on SD, writes on core 0\n"); store.useSdMmcStorage(); #else // Deliberately NOT calling store.useSdMmcStorage(): the internal store stays the DataStore root. #endif } store.begin(); the_mesh.begin(disp != NULL); #if TDP4_C6_HOSTED wifiConfigBegin(); // NVS only, no radio — safe this early, and the getters assume it #endif serial_interface.begin(Serial, TCP_PORT, WS_PORT); serial_interface.setBroadcastResponses(true); the_mesh.startInterface(serial_interface); // GPS UART resilience (same fix as the S3 boards): the core opens Serial1 with Arduino's // 256-byte RX ring; one long LVGL frame overflows it and corrupts NMEA, stretching TTFF from // ~1 min to many. Must precede sensors.begin() (a no-op once the UART runs). Unconditional // here — the P4 has RAM to spare, and GPS detection itself needs an intact first second. Serial1.setRxBufferSize(4096); sensors.begin(); { // ---- GPS reality check ------------------------------------------------------------- // The line that used to be here reported whether the "gps" SETTING got registered, which on // this board is FORCED true by ENV_SKIP_GPS_DETECT (we set that because the P4's heavy boot // misses the core's 1 s detect window, and a false negative hides the GPS toggle entirely). // So it printed DETECTED unconditionally and proved nothing, including across several rounds // of "GPS still isn't working". Read the UART directly instead and report what arrives. bool has_setting = false; for (int i = 0; i < sensors.getNumSettings(); i++) if (strcmp(sensors.getSettingName(i), "gps") == 0) has_setting = true; // Drain a SHORT window of NMEA. Bytes at all => pins, rails, wake and baud are ALL correct and // a stuck "acquiring" is a FIX problem (antenna / sky view). Zero bytes => the link is dead. // 1.2 s, not longer: this runs on every boot and it CONSUMES bytes the NMEA parser would // otherwise see, so it is deliberately just long enough to catch one of each sentence type // (the P4's factory-configured L76K emits its enabled set at 5 Hz). uint32_t bytes = 0, lines = 0; char first[96]; first[0] = '\0'; char cur[96]; size_t cl = 0; // Keep one example of each distinct GSV/GGA/RMC talker so every constellation is represented // without dumping the whole stream (the same 5 sentence types repeat every second). char kept[10][96]; int kept_n = 0; const uint32_t until = millis() + 1200; while ((int32_t)(millis() - until) < 0) { while (Serial1.available() > 0) { const int c = Serial1.read(); if (c < 0) break; ++bytes; if (c == '\n' || c == '\r') { if (cl) { cur[cl] = '\0'; ++lines; if (!first[0] && cur[0] == '$') { strncpy(first, cur, sizeof first - 1); first[sizeof first - 1] = '\0'; } // Keep the first of each sentence TYPE (talker + type, e.g. "$GPGSV", "$GNGGA"). if (cur[0] == '$' && cl >= 6 && kept_n < 10) { bool seen = false; for (int k = 0; k < kept_n; ++k) if (strncmp(kept[k], cur, 6) == 0) { seen = true; break; } if (!seen) { strncpy(kept[kept_n], cur, sizeof kept[0] - 1); kept[kept_n][sizeof kept[0] - 1] = '\0'; ++kept_n; } } cl = 0; } } else if (cl < sizeof(cur) - 1) { cur[cl++] = (char)c; } } delay(5); } printf("[GPS] setting_registered=%d (forced by ENV_SKIP_GPS_DETECT, proves nothing)\n", (int)has_setting); const uint32_t active_baud = touchPrefsGetGpsBaud(GPS_BAUD_RATE); printf("[GPS] UART probe: %lu bytes, %lu lines in 1.2s @ %lu baud, module TX -> GPIO%d\n", (unsigned long)bytes, (unsigned long)lines, (unsigned long)active_baud, (int)PIN_GPS_TX); if (first[0]) printf("[GPS] first sentence: %s\n", first); else if (bytes) printf("[GPS] bytes but no complete '$' sentence -> wrong baud or line noise\n"); else printf("[GPS] NOTHING on the UART -> check wake (XL9535 IO11 HIGH), rails, RX pin\n"); // Satellite visibility across ALL constellations, plus the fix line. One GxGSV is not enough: // GLGSV is GLONASS only, so "00" there says nothing about GPS/Galileo/BeiDou. GSV field 3 is // sats-in-view for that constellation; GGA field 6 is fix quality (0 = no fix) and field 7 the // sats used. Non-zero in-view with quality 0 means it IS hearing satellites and just needs // time or better sky. All-zero in-view points at the antenna or shielding. if (kept_n) { printf("[GPS] --- satellite view (%d sentences) ---\n", kept_n); for (int i = 0; i < kept_n; ++i) printf("[GPS] %s\n", kept[i]); } } ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); board.onBootComplete(); printf("[BOOT] setup done\n"); #ifdef TDP4_C6_FLASH_HELPER // C6-FLASH HELPER BUILD: the P4 must NOT touch the C6 so it can be held in ROM download mode and // reflashed over its own USB. powerOnSequence() already skips the C6_EN reset pulse under this flag // (leaving C6_EN HIGH = powered), and here we skip the AT worker entirely. Boots the normal UI so // the board is alive, but the C6 is left completely free. Temporary — remove after the C6 reflash. printf("[BOOT] C6-FLASH HELPER: AT worker NOT started, C6 left free for download-mode reflash\n"); board.onBootComplete(); printf("[BOOT] setup done (helper)\n"); return; #endif // ---- C6 bring-up: AFTER the UI, for BOTH backends -------------------------------------------- // Nothing below here may run before ui_task.begin(). The C6 is a separate chip that can be // unreachable — an older unit still carries the factory ESP-AT firmware while this build speaks // esp-hosted, and then the SDIO stream never frames (H_SDIO_DRV drop storm). The legacy backend // always started its worker here, after the UI, so a dead C6 cost you Wi-Fi and nothing else. // The hosted backend was wired in ahead of the UI instead, so the same dead C6 held the AMOLED // dark through a handshake that could not succeed, and the board read as bricked. Both now start // here. A co-processor must never be able to take the display, touch and LoRa down with it. #if !TDP4_C6_HOSTED // Legacy factory ESP-AT units use the asynchronous AT worker. c6at_worker_start(); #else #if defined(BLE_PIN_CODE) && TDP4_C6_READY && TDP4_BLE_READY // Only bring the C6 up for BLE when BLE is actually wanted. hostedInitBLE() // is a full esp-hosted handshake, and it ran unconditionally here even though // the boot banner says "lazy Wi-Fi/BLE initialization" -- so every boot paid // for it, and on a unit whose C6 speaks ESP-AT it started a transport that // can never come up for a radio the user had switched off. Honour the stored // intent the way beginBle() below already did. if (wifiConfigGetBleEnabled()) { if (hostedInitBLE()) { char* nm = the_mesh.getNodePrefs()->node_name; serial_interface.prepareBle("wadamesh-", nm, the_mesh.getBLEPin()); serial_interface.beginBle("wadamesh-", nm, the_mesh.getBLEPin()); } else { printf("[BOOT] hostedInitBLE FAILED\n"); } } else { printf("[BOOT] BLE off in settings: C6 not started for it\n"); } #endif // Cold-boot clock sync over SAVED Wi-Fi (#383, BootTimeSync.h) -- the same opt-in // Settings > Clock switch the T-Deck/M9 have. Returns Skipped at no cost unless this was a // true power-on, the user turned it on, and the PCF8563 did not already give a trustworthy // time. It used to sit before the transports so its temporary Wi-Fi session had nothing live // to disturb; from here the transports exist but none of them is associated yet, and the UI // being up first is worth more than that ordering nicety. The clock simply corrects itself a // moment after the screen appears instead of the screen waiting on the network. { uint32_t synced_epoch = 0; const BootTimeSyncResult r = bootTimeSyncRun(rtc_clock.timeIsCurrent(), synced_epoch); if (r == BootTimeSyncResult::Ok) { rtc_clock.setCurrentTime(synced_epoch); // floor + system clock + RTC chip, like an NTP sync printf("[BOOT] cold-boot time sync ok: %lu\n", (unsigned long)synced_epoch); } else if (r != BootTimeSyncResult::Skipped) { printf("[BOOT] cold-boot time sync: %s\n", bootTimeSyncResultName(r)); } } #endif } // Time-sync fallback: pull UTC from the Date header of a plain-HTTP HEAD to the firmware // host. C6-SNTP needs UDP/123 egress, which some networks (guest/IoT VLANs) block — while // port-80 HTTP is proven open on this device (tiles + version checks ride it). Returns a // UTC epoch, or 0. Runs on the loop thread only while the clock is still unsynced. static uint32_t httpDateProbe(void) { #if TDP4_C6_HOSTED WiFiClient c; #else C6Client c; #endif if (!c.connect("firmware.wadamesh.com", 80, 8000)) return 0; static const char req[] = "HEAD / HTTP/1.1\r\nHost: firmware.wadamesh.com\r\nConnection: close\r\n\r\n"; if (c.write((const uint8_t*)req, sizeof(req) - 1) != sizeof(req) - 1) { c.stop(); return 0; } char buf[600]; size_t got = 0; uint32_t t0 = millis(); while (got < sizeof(buf) - 1 && millis() - t0 < 6000) { int n = c.read((uint8_t*)buf + got, sizeof(buf) - 1 - got); if (n > 0) { got += (size_t)n; buf[got] = '\0'; if (strstr(buf, "\r\n\r\n")) break; } else if (!c.connected()) break; else vTaskDelay(pdMS_TO_TICKS(20)); } c.stop(); buf[got] = '\0'; const char* p = strstr(buf, "\r\nDate: "); // "Date: Tue, 15 Jul 2026 16:33:47 GMT" if (!p) return 0; char mon[4] = {0}; int day, year, hh, mm, ss; if (sscanf(p + 8, "%*3s, %d %3s %d %d:%d:%d", &day, mon, &year, &hh, &mm, &ss) != 6) return 0; if (year < 2024) return 0; static const char* M = "JanFebMarAprMayJunJulAugSepOctNovDec"; const char* mp = strstr(M, mon); if (!mp) return 0; int m = (int)(mp - M) / 3 + 1; int y = year - (m <= 2); // days-from-civil (Howard Hinnant) int era = y / 400, yoe = y - era * 400; int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + day - 1; int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; long days = (long)era * 146097 + doe - 719468; return (uint32_t)(days * 86400L + hh * 3600 + mm * 60 + ss); } extern "C" void app_main(void) { // Before anything else registers a shutdown handler: a software restart must be a full system // reset or the panel comes back dark (see target.cpp). tdisplayP4InstallFullRestart(); initArduino(); wadameshSetup(); // WiFi state machine + SNTP + TCP/WS companion server — ported from src/main.cpp's loop() // (excluded from this build), identical to the Tanmatsu. bool wifi_started = false, wifi_radio_prev = true, wifi_radio_inited = false; bool sntp_kicked = false, sntp_pushed = false, modem_sleep_set = false; uint32_t last_wifi_retry_ms = 0, sntp_kick_ms = 0; const uint32_t WIFI_RETRY_INTERVAL_MS = 10000; for (;;) { ui_task.loop(); // WiFi.* here is the C6WifiShim facade (AT-over-SDIO), so this state machine drives REAL joins: // start once the c6_at worker has the AT link up + the user wants Wi-Fi. The 10 s retry branch // doubles as auto-reconnect. (TDP4_C6_READY still gates the unused esp-hosted path elsewhere.) #if TDP4_C6_HOSTED const bool c6_up = s_hosted_c6_up; #else const bool c6_up = c6at_is_up(); #endif bool wifi_radio_en = c6_up && wifiConfigWantsWifi(); // The C6 runs its own AT firmware with auto-connect enabled, so after a power // cycle it rejoins the last AP by itself, before this loop ever asks it to. // So latch only once the AT link is actually up, and APPLY the user's choice // at that moment rather than merely recording it: with the radio pref off // there is no on->off transition for the branch below to catch, the join the // C6 made on its own is never torn down, and the device comes up connected // while every switch in the UI reads off (#373). if (!wifi_radio_inited) { if (c6_up) { wifi_radio_inited = true; wifi_radio_prev = wifi_radio_en; if (!wifi_radio_en) { WiFi.disconnect(true); delay(50); WiFi.mode(WIFI_OFF); } } } 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); } wifi_started = false; } if (wifiConfigConsumeApplyRequest()) { if (wifi_started) { 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; } if (wifi_radio_en) { if (!wifi_started) { wifi_started = true; WiFi.mode(WIFI_STA); if (wifiConfigHasRuntime()) { char ssid[WIFI_CONFIG_SSID_MAX], pwd[WIFI_CONFIG_PWD_MAX]; wifiConfigGetSsid(ssid, sizeof(ssid)); wifiConfigGetPwd(pwd, sizeof(pwd)); if (strlen(ssid) > 0) { WiFi.begin(ssid, pwd[0] ? pwd : nullptr); last_wifi_retry_ms = millis(); } } } if (wifiConfigHasRuntime() && WiFi.status() != WL_CONNECTED) { uint32_t now = millis(); if ((uint32_t)(now - last_wifi_retry_ms) >= WIFI_RETRY_INTERVAL_MS) { last_wifi_retry_ms = now; char ssid[WIFI_CONFIG_SSID_MAX], pwd[WIFI_CONFIG_PWD_MAX]; wifiConfigGetSsid(ssid, sizeof(ssid)); wifiConfigGetPwd(pwd, sizeof(pwd)); if (strlen(ssid) > 0) { WiFi.disconnect(false, true); WiFi.begin(ssid, pwd[0] ? pwd : nullptr); } } } if (WiFi.status() == WL_CONNECTED) { if (!modem_sleep_set) { WiFi.setSleep(true); modem_sleep_set = true; } serial_interface.startTcpServer(true); // NTP runs ON the C6 (AT+CIPSNTP*, configured by the c6_at worker after a join) — lwIP has no // netif on this board, so esp_sntp/configTzTime can't sync. Pull the UTC epoch when ready. if (!sntp_pushed) { if (sntp_kick_ms == 0) sntp_kick_ms = millis(); // connected-since stamp for the fallback below #if TDP4_C6_HOSTED if (!sntp_kicked) { configTime(0, 0, "pool.ntp.org", "time.nist.gov"); sntp_kicked = true; } uint32_t e = (uint32_t)time(nullptr); #else uint32_t e = c6at_sntp_epoch(); #endif if (e > 1700000000) { rtc_clock.setCurrentTime(e); sntp_pushed = true; printf("[C6-AT] SNTP -> rtc %lu\n", (unsigned long)e); } // Bench-verified 2026-07-15: on some networks SNTP never completes (UDP/123 // blocked) — the clock then stays wrong forever, since BLE is parked and the // phone app only sets time forward. After 45 s connected without a sync, fall // back to HTTP Date (sets the RTC through the same guarded ClockFloorRTC path, // so it also *repairs* a garbage-future hardware clock via the backward hatch). else if (millis() - sntp_kick_ms > 45000) { static uint32_t http_date_next = 0; if ((int32_t)(millis() - http_date_next) >= 0) { http_date_next = millis() + 120000; uint32_t he = httpDateProbe(); if (he > 1700000000) { rtc_clock.setCurrentTime(he); sntp_pushed = true; printf("[TIME] HTTP-Date -> rtc %lu\n", (unsigned long)he); } } } } (void)sntp_kicked; } } serial_interface.tickWebSocketHandshake(); // Service the sensor manager. This was MISSING on this board: sensors.begin() ran at boot and // then nothing ever pumped it again, while every S3 board calls this each iteration (see // src/main.cpp, which is excluded from this build — that is how the call got lost in the port). // // sensors.loop() is what drains the GPS UART into MicroNMEA. Without it the parser never sees a // byte, so isValid() stays false forever and the UI sits on "searching" no matter how good the // sky is — while the module itself is perfectly happy and does acquire a fix (proven with a raw // UART probe: a valid 3D fix at HDOP 2.2 that the firmware never knew about). It also starves // everything else the manager provides: telemetry sensors and the GPS->RTC time sync. sensors.loop(); the_mesh.loop(); vTaskDelay(1); } }