#include // needed for PlatformIO #include // for esp_bt_controller_mem_release (web reader WiFi) #include #include "MyMesh.h" #include "variant.h" // Board-specific defines (HAS_GPS, etc.) #include "target.h" // For sensors, board, etc. #include "GPSDutyCycle.h" #include "CPUPowerManager.h" // T-Deck Pro Keyboard support #if defined(LilyGo_TDeck_Pro) #include "TCA8418Keyboard.h" #include #include "TextReaderScreen.h" #include "NotesScreen.h" #include "ContactsScreen.h" #include "ChannelScreen.h" #include "SettingsScreen.h" #include "RepeaterAdminScreen.h" #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); // Compose mode state static bool composeMode = false; static char composeBuffer[138]; // 137 bytes max + null terminator (matches BLE wire cost) static int composePos = 0; // Current wire-cost byte count static uint8_t composeChannelIdx = 0; static unsigned long lastComposeRefresh = 0; static bool composeNeedsRefresh = false; #define COMPOSE_REFRESH_INTERVAL 100 // ms before starting e-ink refresh after keypress (refresh itself takes ~644ms) // Phone dialer debounce — independent from compose/smsSuppressLoop to avoid // interfering with call view rendering and alert display static bool dialerNeedsRefresh = false; static unsigned long lastDialerRefresh = 0; // DM compose mode (direct message to a specific contact) static bool composeDM = false; static int composeDMContactIdx = -1; static char composeDMName[32]; #ifdef MECK_WEB_READER static unsigned long lastWebReaderRefresh = 0; static bool webReaderNeedsRefresh = false; static bool webReaderTextEntry = false; // True when URL/password entry active #endif // AGC reset - periodically re-assert RX boosted gain to prevent sensitivity drift #define AGC_RESET_INTERVAL_MS 500 static unsigned long lastAGCReset = 0; // Emoji picker state #include "EmojiPicker.h" static bool emojiPickerMode = false; static EmojiPicker emojiPicker; // Text reader mode state static bool readerMode = false; // Notes mode state static bool notesMode = false; // Audiobook player — Audio object is heap-allocated on first use to avoid // consuming ~40KB of DMA/decode buffers at boot (starves BLE stack). // Audiobook player — Audio object is heap-allocated on first use to avoid // consuming ~40KB of DMA/decode buffers at boot (starves BLE stack). // Not available on 4G variant (I2S pins conflict with modem control lines). #ifndef HAS_4G_MODEM #include "AudiobookPlayerScreen.h" #include "Audio.h" Audio* audio = nullptr; #endif static bool audiobookMode = false; #ifdef HAS_4G_MODEM #include "ModemManager.h" #include "SMSStore.h" #include "SMSContacts.h" #include "SMSScreen.h" static bool smsMode = false; #endif // Touch input (for phone dialer numpad) #ifdef HAS_TOUCHSCREEN #include "TouchInput.h" TouchInput touchInput(&Wire); #endif // Power management #if HAS_GPS GPSDutyCycle gpsDuty; #endif CPUPowerManager cpuPower; void initKeyboard(); void handleKeyboardInput(); void drawComposeScreen(); void drawEmojiPicker(); void sendComposedMessage(); // SD-backed persistence state static bool sdCardReady = false; // --------------------------------------------------------------------------- // SD Settings Backup / Restore // --------------------------------------------------------------------------- // Copies a file byte-for-byte between two filesystem objects. // Works across SPIFFS <-> SD because both use the Arduino File API. static bool copyFile(fs::FS& srcFS, const char* srcPath, fs::FS& dstFS, const char* dstPath) { File src = srcFS.open(srcPath, "r"); if (!src) return false; File dst = dstFS.open(dstPath, "w", true); if (!dst) { src.close(); return false; } uint8_t buf[128]; while (src.available()) { int n = src.read(buf, sizeof(buf)); if (n > 0) dst.write(buf, n); } src.close(); dst.close(); return true; } // Backup prefs, channels, and identity from SPIFFS to SD card. // Called after any savePrefs() to keep the SD mirror current. void backupSettingsToSD() { if (!sdCardReady) return; if (!SD.exists("/meshcore")) SD.mkdir("/meshcore"); if (SPIFFS.exists("/new_prefs")) { copyFile(SPIFFS, "/new_prefs", SD, "/meshcore/prefs.bin"); } // Channels may live on SPIFFS or ExtraFS - on ESP32 they are on SPIFFS if (SPIFFS.exists("/channels2")) { copyFile(SPIFFS, "/channels2", SD, "/meshcore/channels.bin"); } // Identity if (SPIFFS.exists("/identity/_main.id")) { if (!SD.exists("/meshcore/identity")) SD.mkdir("/meshcore/identity"); copyFile(SPIFFS, "/identity/_main.id", SD, "/meshcore/identity/_main.id"); } // Contacts if (SPIFFS.exists("/contacts3")) { copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin"); } digitalWrite(SDCARD_CS, HIGH); // Release SD CS Serial.println("Settings backed up to SD"); } // Restore prefs, channels, and identity from SD card to SPIFFS. // Called at boot if SPIFFS prefs file is missing (e.g. after a fresh flash). // Returns true if anything was restored. bool restoreSettingsFromSD() { if (!sdCardReady) return false; bool restored = false; // Only restore if SPIFFS is missing the prefs file (fresh flash) if (!SPIFFS.exists("/new_prefs") && SD.exists("/meshcore/prefs.bin")) { if (copyFile(SD, "/meshcore/prefs.bin", SPIFFS, "/new_prefs")) { Serial.println("Restored prefs from SD"); restored = true; } } if (!SPIFFS.exists("/channels2") && SD.exists("/meshcore/channels.bin")) { if (copyFile(SD, "/meshcore/channels.bin", SPIFFS, "/channels2")) { Serial.println("Restored channels from SD"); restored = true; } } // Identity - most critical; keeps the same device pub key across reflashes if (!SPIFFS.exists("/identity/_main.id") && SD.exists("/meshcore/identity/_main.id")) { SPIFFS.mkdir("/identity"); if (copyFile(SD, "/meshcore/identity/_main.id", SPIFFS, "/identity/_main.id")) { Serial.println("Restored identity from SD"); restored = true; } } if (!SPIFFS.exists("/contacts3") && SD.exists("/meshcore/contacts.bin")) { if (copyFile(SD, "/meshcore/contacts.bin", SPIFFS, "/contacts3")) { Serial.println("Restored contacts from SD"); restored = true; } } if (restored) { Serial.println("=== Settings restored from SD card backup ==="); } digitalWrite(SDCARD_CS, HIGH); return restored; } // ----------------------------------------------------------------------- // On-demand export: save current contacts to SD card. // Writes binary backup + human-readable listing. // Returns number of contacts exported, or -1 on error. // ----------------------------------------------------------------------- int exportContactsToSD() { if (!sdCardReady) return -1; // Ensure in-memory contacts are flushed to SPIFFS first the_mesh.saveContacts(); if (!SD.exists("/meshcore")) SD.mkdir("/meshcore"); // 1) Binary backup: SPIFFS /contacts3 → SD /meshcore/contacts.bin if (!SPIFFS.exists("/contacts3")) return -1; if (!copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin")) return -1; // 2) Human-readable listing for inspection on a computer int count = 0; File txt = SD.open("/meshcore/contacts_export.txt", "w", true); if (txt) { txt.printf("Meck Contacts Export (%d total)\n", (int)the_mesh.getNumContacts()); txt.printf("========================================\n"); txt.printf("%-5s %-30s %s\n", "Type", "Name", "PubKey (prefix)"); txt.printf("----------------------------------------\n"); ContactInfo c; for (uint32_t i = 0; i < (uint32_t)the_mesh.getNumContacts(); i++) { if (the_mesh.getContactByIdx(i, c)) { const char* typeStr = "???"; switch (c.type) { case ADV_TYPE_CHAT: typeStr = "Chat"; break; case ADV_TYPE_REPEATER: typeStr = "Rptr"; break; case ADV_TYPE_ROOM: typeStr = "Room"; break; } // First 8 bytes of pub key as hex identifier char hexBuf[20]; mesh::Utils::toHex(hexBuf, c.id.pub_key, 8); txt.printf("%-5s %-30s %s\n", typeStr, c.name, hexBuf); count++; } } txt.printf("========================================\n"); txt.printf("Total: %d contacts\n", count); txt.close(); } digitalWrite(SDCARD_CS, HIGH); Serial.printf("Contacts exported to SD: %d contacts\n", count); return count; } // ----------------------------------------------------------------------- // On-demand import: merge contacts from SD backup into live table. // // Reads /meshcore/contacts.bin from SD and for each contact: // - If already in memory (matching pub_key) → skip (keep current) // - If NOT in memory → addContact (append to table) // // This is a non-destructive merge: you never lose contacts already in // memory, and you gain any that were only in the backup. // // After merging, saves the combined set back to SPIFFS so it persists. // Returns number of NEW contacts added, or -1 on error. // ----------------------------------------------------------------------- int importContactsFromSD() { if (!sdCardReady) return -1; if (!SD.exists("/meshcore/contacts.bin")) return -1; File file = SD.open("/meshcore/contacts.bin", "r"); if (!file) return -1; int added = 0; int skipped = 0; while (true) { ContactInfo c; uint8_t pub_key[32]; uint8_t unused; // Parse one contact record (same binary format as DataStore::loadContacts) bool success = (file.read(pub_key, 32) == 32); success = success && (file.read((uint8_t *)&c.name, 32) == 32); success = success && (file.read(&c.type, 1) == 1); success = success && (file.read(&c.flags, 1) == 1); success = success && (file.read(&unused, 1) == 1); success = success && (file.read((uint8_t *)&c.sync_since, 4) == 4); success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1); success = success && (file.read((uint8_t *)&c.last_advert_timestamp, 4) == 4); success = success && (file.read(c.out_path, 64) == 64); success = success && (file.read((uint8_t *)&c.lastmod, 4) == 4); success = success && (file.read((uint8_t *)&c.gps_lat, 4) == 4); success = success && (file.read((uint8_t *)&c.gps_lon, 4) == 4); if (!success) break; // EOF or read error c.id = mesh::Identity(pub_key); c.shared_secret_valid = false; // Check if this contact already exists in the live table if (the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE) != NULL) { skipped++; continue; // Already have this contact, skip } // New contact — add to the live table if (the_mesh.addContact(c)) { added++; } else { // Table is full, stop importing Serial.printf("Import: table full after adding %d contacts\n", added); break; } } file.close(); digitalWrite(SDCARD_CS, HIGH); // Persist the merged set to SPIFFS if (added > 0) { the_mesh.saveContacts(); } Serial.printf("Contacts import: %d added, %d already present, %d total\n", added, skipped, (int)the_mesh.getNumContacts()); return added; } #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 #if defined(QSPIFLASH) #include DataStore store(InternalFS, QSPIFlash, rtc_clock); #else #if defined(EXTRAFS) #include CustomLFS ExtraFS(0xD4000, 0x19000, 128); DataStore store(InternalFS, ExtraFS, rtc_clock); #else DataStore store(InternalFS, rtc_clock); #endif #endif #elif defined(RP2040_PLATFORM) #include DataStore store(LittleFS, rtc_clock); #elif defined(ESP32) #include DataStore store(SPIFFS, rtc_clock); #endif #ifdef ESP32 #ifdef WIFI_SSID #include SerialWifiInterface serial_interface; #ifndef TCP_PORT #define TCP_PORT 5000 #endif #elif defined(BLE_PIN_CODE) #include SerialBLEInterface serial_interface; #elif defined(SERIAL_RX) #include ArduinoSerialInterface serial_interface; HardwareSerial companion_serial(1); #else #include ArduinoSerialInterface serial_interface; #endif #elif defined(RP2040_PLATFORM) //#ifdef WIFI_SSID // #include // SerialWifiInterface serial_interface; // #ifndef TCP_PORT // #define TCP_PORT 5000 // #endif // #elif defined(BLE_PIN_CODE) // #include // SerialBLEInterface serial_interface; #if defined(SERIAL_RX) #include ArduinoSerialInterface serial_interface; HardwareSerial companion_serial(1); #else #include ArduinoSerialInterface serial_interface; #endif #elif defined(NRF52_PLATFORM) #ifdef BLE_PIN_CODE #include SerialBLEInterface serial_interface; #else #include ArduinoSerialInterface serial_interface; #endif #elif defined(STM32_PLATFORM) #include 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; MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store #ifdef DISPLAY_CLASS , &ui_task #endif ); /* END GLOBAL OBJECTS */ void halt() { while (1) ; } void setup() { Serial.begin(115200); delay(100); // Give serial time to initialize MESH_DEBUG_PRINTLN("=== setup() - STARTING ==="); board.begin(); MESH_DEBUG_PRINTLN("setup() - board.begin() done"); #ifdef DISPLAY_CLASS DisplayDriver* disp = NULL; MESH_DEBUG_PRINTLN("setup() - about to call display.begin()"); // ========================================================================= // T-Deck Pro V1.1: Initialize E-Ink reset pin BEFORE display.begin() // This is critical - the display won't work without proper reset sequence // ========================================================================= #if defined(LilyGo_TDeck_Pro) // Initialize E-Ink reset pin (GPIO 16) pinMode(PIN_DISPLAY_RST, OUTPUT); digitalWrite(PIN_DISPLAY_RST, HIGH); MESH_DEBUG_PRINTLN("setup() - E-Ink reset pin initialized"); // Initialize Touch reset pin (GPIO 38) #ifdef CST328_PIN_RST pinMode(CST328_PIN_RST, OUTPUT); digitalWrite(CST328_PIN_RST, HIGH); delay(20); digitalWrite(CST328_PIN_RST, LOW); delay(80); digitalWrite(CST328_PIN_RST, HIGH); delay(20); MESH_DEBUG_PRINTLN("setup() - Touch reset pin initialized"); #endif #endif // ========================================================================= if (display.begin()) { MESH_DEBUG_PRINTLN("setup() - display.begin() returned true"); disp = &display; disp->startFrame(); #ifdef ST7789 disp->setTextSize(2); #endif disp->drawTextCentered(disp->width() / 2, 28, "Loading..."); disp->endFrame(); MESH_DEBUG_PRINTLN("setup() - Loading screen drawn"); } else { MESH_DEBUG_PRINTLN("setup() - display.begin() returned false!"); } #endif MESH_DEBUG_PRINTLN("setup() - about to call radio_init()"); if (!radio_init()) { MESH_DEBUG_PRINTLN("setup() - radio_init() FAILED! Halting."); halt(); } MESH_DEBUG_PRINTLN("setup() - radio_init() done"); // CPU frequency scaling — drop to 80 MHz for idle mesh listening cpuPower.begin(); MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()"); fast_rng.begin(radio_get_rng_seed()); MESH_DEBUG_PRINTLN("setup() - fast_rng.begin() done"); #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) MESH_DEBUG_PRINTLN("setup() - NRF52/STM32 filesystem init"); InternalFS.begin(); #if defined(QSPIFLASH) if (!QSPIFlash.begin()) { MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: failed to initialize"); } else { MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: initialized successfully"); } #else #if defined(EXTRAFS) ExtraFS.begin(); #endif #endif MESH_DEBUG_PRINTLN("setup() - about to call store.begin()"); store.begin(); MESH_DEBUG_PRINTLN("setup() - store.begin() done"); MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.begin()"); the_mesh.begin( #ifdef DISPLAY_CLASS disp != NULL #else false #endif ); MESH_DEBUG_PRINTLN("setup() - the_mesh.begin() done"); #ifdef BLE_PIN_CODE MESH_DEBUG_PRINTLN("setup() - about to call serial_interface.begin() with BLE"); serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); MESH_DEBUG_PRINTLN("setup() - serial_interface.begin() done"); #else serial_interface.begin(Serial); #endif MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.startInterface()"); the_mesh.startInterface(serial_interface); MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done"); #elif defined(RP2040_PLATFORM) MESH_DEBUG_PRINTLN("setup() - RP2040 filesystem init"); LittleFS.begin(); MESH_DEBUG_PRINTLN("setup() - about to call store.begin()"); store.begin(); MESH_DEBUG_PRINTLN("setup() - store.begin() done"); MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.begin()"); the_mesh.begin( #ifdef DISPLAY_CLASS disp != NULL #else false #endif ); MESH_DEBUG_PRINTLN("setup() - the_mesh.begin() done"); //#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 MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.startInterface()"); the_mesh.startInterface(serial_interface); MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done"); #elif defined(ESP32) MESH_DEBUG_PRINTLN("setup() - ESP32 filesystem init - calling SPIFFS.begin()"); SPIFFS.begin(true); MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done"); // --------------------------------------------------------------------------- // Early SD card init — needed BEFORE the_mesh.begin() so we can restore // settings from a previous firmware flash. The display SPI bus is already // up (display.begin() ran earlier), so SD can share it now. // --------------------------------------------------------------------------- #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) { // Deselect ALL SPI devices before SD init to prevent bus contention. // E-ink, LoRa, and SD share the same SPI bus (SCK=36, MOSI=33, MISO=47). // If LoRa CS is still asserted from board/radio init, it responds on the // shared MISO line and corrupts SD card replies (CMD0 fails intermittently). pinMode(SDCARD_CS, OUTPUT); digitalWrite(SDCARD_CS, HIGH); pinMode(PIN_EINK_CS, OUTPUT); digitalWrite(PIN_EINK_CS, HIGH); pinMode(LORA_CS, OUTPUT); digitalWrite(LORA_CS, HIGH); // SD cards need 74+ SPI clock cycles after power stabilization before // accepting CMD0. A brief delay avoids race conditions on cold boot // or with slow-starting cards. delay(100); // Retry loop — some SD cards are slow to initialise, especially on // cold boot or marginal USB power. Three attempts with increasing // settle time covers the vast majority of transient failures. bool mounted = false; for (int attempt = 0; attempt < 3 && !mounted; attempt++) { if (attempt > 0) { digitalWrite(SDCARD_CS, HIGH); // Ensure CS released between retries delay(250); MESH_DEBUG_PRINTLN("setup() - SD card retry %d/3", attempt + 1); } mounted = SD.begin(SDCARD_CS, displaySpi, 4000000); } if (mounted) { sdCardReady = true; MESH_DEBUG_PRINTLN("setup() - SD card initialized (early)"); // If SPIFFS was wiped (fresh flash), restore settings from SD backup if (restoreSettingsFromSD()) { MESH_DEBUG_PRINTLN("setup() - Settings restored from SD backup"); } } else { MESH_DEBUG_PRINTLN("setup() - SD card not available after 3 attempts"); } } #endif MESH_DEBUG_PRINTLN("setup() - about to call store.begin()"); store.begin(); MESH_DEBUG_PRINTLN("setup() - store.begin() done"); MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.begin()"); the_mesh.begin( #ifdef DISPLAY_CLASS disp != NULL #else false #endif ); MESH_DEBUG_PRINTLN("setup() - the_mesh.begin() done"); #ifdef WIFI_SSID MESH_DEBUG_PRINTLN("setup() - WiFi mode"); WiFi.begin(WIFI_SSID, WIFI_PWD); serial_interface.begin(TCP_PORT); #elif defined(BLE_PIN_CODE) MESH_DEBUG_PRINTLN("setup() - about to call serial_interface.begin() with BLE"); serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); MESH_DEBUG_PRINTLN("setup() - serial_interface.begin() done"); #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 MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.startInterface()"); the_mesh.startInterface(serial_interface); MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done"); #else #error "need to define filesystem" #endif MESH_DEBUG_PRINTLN("setup() - about to call sensors.begin()"); sensors.begin(); MESH_DEBUG_PRINTLN("setup() - sensors.begin() done"); // IMPORTANT: sensors.begin() calls initBasicGPS() which steals the GPS pins for Serial1 // We need to reinitialize Serial2 to reclaim them #if HAS_GPS Serial2.end(); // Close any existing Serial2 Serial2.begin(GPS_BAUDRATE, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); MESH_DEBUG_PRINTLN("setup() - Reinitialized Serial2 for GPS after sensors.begin()"); #endif #ifdef DISPLAY_CLASS MESH_DEBUG_PRINTLN("setup() - about to call ui_task.begin()"); ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); MESH_DEBUG_PRINTLN("setup() - ui_task.begin() done"); #endif // Initialize T-Deck Pro keyboard #if defined(LilyGo_TDeck_Pro) initKeyboard(); #endif // Initialize touch input (CST328) #ifdef HAS_TOUCHSCREEN if (touchInput.begin(CST328_PIN_INT)) { MESH_DEBUG_PRINTLN("setup() - Touch input initialized"); } else { MESH_DEBUG_PRINTLN("setup() - Touch input FAILED"); } #endif // --------------------------------------------------------------------------- // SD card is already initialized (early init above). // Now set up SD-dependent features: message history + text reader. // --------------------------------------------------------------------------- #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) if (sdCardReady) { // Load persisted channel messages from SD ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen(); if (chanScr) { chanScr->setSDReady(true); if (chanScr->loadFromSD()) { MESH_DEBUG_PRINTLN("setup() - Message history loaded from SD"); } } // Tell the text reader that SD is ready, then pre-index books at boot TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); if (reader) { reader->setSDReady(true); if (disp) { cpuPower.setBoost(); // Boost CPU for EPUB processing reader->bootIndex(*disp); } } // Tell notes screen that SD is ready NotesScreen* notesScr = (NotesScreen*)ui_task.getNotesScreen(); if (notesScr) { notesScr->setSDReady(true); } // Audiobook player screen creation is deferred to first use (case 'p' in // handleKeyboardInput) to avoid allocating Audio I2S/DMA buffers at boot, // which would starve BLE of heap memory. MESH_DEBUG_PRINTLN("setup() - Audiobook player deferred (lazy init on first use)"); // Do an initial settings backup to SD (captures any first-boot defaults) backupSettingsToSD(); // SMS / 4G modem init (after SD is ready) #ifdef HAS_4G_MODEM { smsStore.begin(); smsContacts.begin(); // Tell SMS screen that SD is ready SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen(); if (smsScr) { smsScr->setSDReady(true); } // Start modem if enabled in config (default = enabled) bool modemEnabled = ModemManager::loadEnabledConfig(); if (modemEnabled) { modemManager.begin(); MESH_DEBUG_PRINTLN("setup() - 4G modem manager started"); } else { // Ensure modem power is off (kills red LED too) pinMode(MODEM_POWER_EN, OUTPUT); digitalWrite(MODEM_POWER_EN, LOW); MESH_DEBUG_PRINTLN("setup() - 4G modem disabled by config"); } } #endif } #endif // --------------------------------------------------------------------------- // First-boot onboarding detection // Check if node name is still the default hex prefix (first 4 bytes of pub key) // If so, launch onboarding wizard to set name and radio preset // --------------------------------------------------------------------------- #if defined(LilyGo_TDeck_Pro) { char defaultName[10]; mesh::Utils::toHex(defaultName, the_mesh.self_id.pub_key, 4); NodePrefs* prefs = the_mesh.getNodePrefs(); if (strcmp(prefs->node_name, defaultName) == 0) { MESH_DEBUG_PRINTLN("setup() - Default node name detected, launching onboarding"); ui_task.gotoOnboarding(); } } #endif // GPS duty cycle — honour saved pref, default to enabled on first boot #if HAS_GPS { bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled; gpsDuty.setStreamCounter(&gpsStream); gpsDuty.begin(gps_wanted); if (gps_wanted) { sensors.setSettingValue("gps", "1"); } else { sensors.setSettingValue("gps", "0"); } MESH_DEBUG_PRINTLN("setup() - GPS duty cycle started (enabled=%d)", gps_wanted); } #endif // T-Deck Pro: BLE starts disabled for standalone-first operation // User can toggle it on from the Bluetooth home page (Enter or long-press) #if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE) serial_interface.disable(); MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)"); #endif Serial.printf("setup() complete — free heap: %d, largest block: %d\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ==="); } void loop() { the_mesh.loop(); // GPS duty cycle — check for fix and manage power state #if HAS_GPS { bool gps_hw_on = gpsDuty.loop(); if (gps_hw_on) { LocationProvider* lp = sensors.getLocationProvider(); if (lp != NULL && lp->isValid()) { gpsDuty.notifyFix(); } } } #endif sensors.loop(); // CPU frequency auto-timeout back to idle cpuPower.loop(); // Audiobook: service audio decode regardless of which screen is active #ifndef HAS_4G_MODEM { AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)ui_task.getAudiobookScreen(); if (abPlayer) { abPlayer->audioTick(); // Keep CPU at high freq during active audio decode if (abPlayer->isAudioActive()) { cpuPower.setBoost(); } } } #endif // SMS: poll for incoming messages from modem #ifdef HAS_4G_MODEM { SMSIncoming incoming; while (modemManager.recvSMS(incoming)) { // Save to store and notify UI SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen(); if (smsScr) { smsScr->onIncomingSMS(incoming.phone, incoming.body, incoming.timestamp); } // Alert + buzzer char alertBuf[48]; snprintf(alertBuf, sizeof(alertBuf), "SMS: %s", incoming.phone); ui_task.showAlert(alertBuf, 2000); ui_task.notify(UIEventType::contactMessage); Serial.printf("[SMS] Received from %s: %.40s...\n", incoming.phone, incoming.body); } // Poll for voice call events from modem CallEvent callEvt; while (modemManager.pollCallEvent(callEvt)) { SMSScreen* smsScr2 = (SMSScreen*)ui_task.getSMSScreen(); if (smsScr2) { smsScr2->onCallEvent(callEvt); } if (callEvt.type == CallEventType::INCOMING) { // Incoming call — auto-switch to SMS screen if not already there char alertBuf[48]; char dispName[SMS_CONTACT_NAME_LEN]; smsContacts.displayName(callEvt.phone, dispName, sizeof(dispName)); snprintf(alertBuf, sizeof(alertBuf), "Call: %s", dispName); ui_task.showAlert(alertBuf, 3000); ui_task.notify(UIEventType::contactMessage); if (!smsMode) { ui_task.gotoSMSScreen(); } ui_task.forceRefresh(); Serial.printf("[Call] Incoming from %s\n", callEvt.phone); } else if (callEvt.type == CallEventType::CONNECTED) { Serial.printf("[Call] Connected to %s\n", callEvt.phone); ui_task.forceRefresh(); } else if (callEvt.type == CallEventType::ENDED) { Serial.printf("[Call] Ended (%lus) with %s\n", (unsigned long)callEvt.duration, callEvt.phone); // Show alert with duration (supplements the immediate alert from Q hangup; // this catches remote hangups and network drops) { char alertBuf[48]; if (callEvt.duration > 0) { snprintf(alertBuf, sizeof(alertBuf), "Call Ended %lu:%02lu", (unsigned long)(callEvt.duration / 60), (unsigned long)(callEvt.duration % 60)); } else { snprintf(alertBuf, sizeof(alertBuf), "Call Ended"); } ui_task.showAlert(alertBuf, 2000); } ui_task.forceRefresh(); } else if (callEvt.type == CallEventType::MISSED) { char alertBuf[48]; char dispName[SMS_CONTACT_NAME_LEN]; smsContacts.displayName(callEvt.phone, dispName, sizeof(dispName)); snprintf(alertBuf, sizeof(alertBuf), "Missed: %s", dispName); ui_task.showAlert(alertBuf, 3000); Serial.printf("[Call] Missed from %s\n", callEvt.phone); ui_task.forceRefresh(); } else if (callEvt.type == CallEventType::BUSY) { ui_task.showAlert("Line busy", 2000); Serial.printf("[Call] Busy: %s\n", callEvt.phone); ui_task.forceRefresh(); } else if (callEvt.type == CallEventType::NO_ANSWER) { ui_task.showAlert("No answer", 2000); Serial.printf("[Call] No answer: %s\n", callEvt.phone); ui_task.forceRefresh(); } else if (callEvt.type == CallEventType::DIAL_FAILED) { ui_task.showAlert("Call failed", 2000); Serial.printf("[Call] Dial failed: %s\n", callEvt.phone); ui_task.forceRefresh(); } } } #endif #ifdef DISPLAY_CLASS // Skip UITask rendering when in compose mode to prevent flickering #if defined(LilyGo_TDeck_Pro) // Also suppress during notes editing (same debounce pattern as compose) bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing(); bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming(); bool notesSuppressLoop = notesEditing || notesRenaming; #ifdef HAS_4G_MODEM bool smsSuppressLoop = smsMode && ((SMSScreen*)ui_task.getSMSScreen())->isComposing(); #else bool smsSuppressLoop = false; #endif #ifdef MECK_WEB_READER // Safety: clear web reader text entry flag if we're no longer on the web reader if (webReaderTextEntry && !ui_task.isOnWebReader()) { webReaderTextEntry = false; webReaderNeedsRefresh = false; } #endif if (!composeMode && !notesSuppressLoop && !smsSuppressLoop && !dialerNeedsRefresh #ifdef MECK_WEB_READER && !webReaderTextEntry #endif ) { ui_task.loop(); } else { // Handle debounced screen refresh (compose, emoji picker, notes, or web reader text entry) if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) { if (composeMode) { if (emojiPickerMode) { drawEmojiPicker(); } else { drawComposeScreen(); } } else if (notesSuppressLoop) { // Notes editor/rename renders through UITask - force a refresh cycle ui_task.forceRefresh(); ui_task.loop(); } else if (smsSuppressLoop) { // SMS compose: render directly to display, same as mesh compose #if defined(DISPLAY_CLASS) && defined(HAS_4G_MODEM) display.startFrame(); ((SMSScreen*)ui_task.getSMSScreen())->render(display); display.endFrame(); #endif } lastComposeRefresh = millis(); composeNeedsRefresh = false; } // Phone dialer debounced render (separate from compose debounce) #ifdef HAS_4G_MODEM if (dialerNeedsRefresh && (millis() - lastDialerRefresh) >= COMPOSE_REFRESH_INTERVAL) { if (smsMode) { SMSScreen* dialScr = (SMSScreen*)ui_task.getSMSScreen(); if (dialScr && dialScr->getSubView() == SMSScreen::PHONE_DIALER) { display.startFrame(); dialScr->render(display); display.endFrame(); } } dialerNeedsRefresh = false; lastDialerRefresh = millis(); } #endif #ifdef MECK_WEB_READER if (webReaderNeedsRefresh && (millis() - lastWebReaderRefresh) >= COMPOSE_REFRESH_INTERVAL) { WebReaderScreen* wr2 = (WebReaderScreen*)ui_task.getWebReaderScreen(); if (wr2) { display.startFrame(); wr2->render(display); display.endFrame(); } lastWebReaderRefresh = millis(); webReaderNeedsRefresh = false; } // Password reveal expiry: re-render to mask character after 800ms if (webReaderTextEntry && !webReaderNeedsRefresh) { WebReaderScreen* wr3 = (WebReaderScreen*)ui_task.getWebReaderScreen(); if (wr3 && wr3->needsRevealRefresh() && (millis() - lastWebReaderRefresh) >= 850) { display.startFrame(); wr3->render(display); display.endFrame(); lastWebReaderRefresh = millis(); } } #endif } // Track reader/notes/audiobook mode state for key routing readerMode = ui_task.isOnTextReader(); notesMode = ui_task.isOnNotesScreen(); audiobookMode = ui_task.isOnAudiobookPlayer(); #ifdef HAS_4G_MODEM smsMode = ui_task.isOnSMSScreen(); #endif #else ui_task.loop(); #endif #endif rtc_clock.tick(); // Periodic AGC reset - re-assert boosted RX gain to prevent sensitivity drift if ((millis() - lastAGCReset) >= AGC_RESET_INTERVAL_MS) { radio_reset_agc(); lastAGCReset = millis(); } // Handle T-Deck Pro keyboard input #if defined(LilyGo_TDeck_Pro) handleKeyboardInput(); #endif // Poll touch input for phone dialer numpad // Hybrid debounce: finger-up detection + 150ms minimum between accepted taps. // The CST328 INT pin is pulse-based (not level), so getPoint() can return // false intermittently during a hold. Time guard prevents that from // causing repeat fires. #if defined(HAS_TOUCHSCREEN) && defined(HAS_4G_MODEM) { static bool touchFingerDown = false; static unsigned long lastTouchAccepted = 0; if (smsMode) { SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen(); if (smsScr && smsScr->getSubView() == SMSScreen::PHONE_DIALER) { int16_t tx, ty; if (touchInput.getPoint(tx, ty)) { unsigned long now = millis(); if (!touchFingerDown && (now - lastTouchAccepted >= 150)) { touchFingerDown = true; lastTouchAccepted = now; if (smsScr->handleTouch(tx, ty)) { dialerNeedsRefresh = true; lastDialerRefresh = millis(); } } } else { // Only allow finger-up after 100ms from last acceptance // (prevents INT pulse misses from resetting state mid-hold) if (touchFingerDown && (millis() - lastTouchAccepted >= 100)) { touchFingerDown = false; } } } else { touchFingerDown = false; } } } #endif } // ============================================================================ // T-DECK PRO KEYBOARD FUNCTIONS // ============================================================================ #if defined(LilyGo_TDeck_Pro) void initKeyboard() { // Keyboard uses the same I2C bus as other peripherals (already initialized) if (keyboard.begin()) { MESH_DEBUG_PRINTLN("setup() - Keyboard initialized"); composeBuffer[0] = '\0'; composePos = 0; composeMode = false; composeNeedsRefresh = false; lastComposeRefresh = 0; } else { MESH_DEBUG_PRINTLN("setup() - Keyboard initialization failed!"); } } void handleKeyboardInput() { if (!keyboard.isReady()) return; char key = keyboard.readKey(); if (key == 0) return; Serial.printf("handleKeyboardInput: key='%c' (0x%02X) composeMode=%d\n", key >= 32 ? key : '?', key, composeMode); if (composeMode) { // Emoji picker sub-mode if (emojiPickerMode) { uint8_t result = emojiPicker.handleInput(key); if (result == 0xFF) { // Cancelled - immediate draw to return to compose emojiPickerMode = false; drawComposeScreen(); lastComposeRefresh = millis(); composeNeedsRefresh = false; } else if (result >= EMOJI_ESCAPE_START && result <= EMOJI_ESCAPE_END) { // Emoji selected - insert escape byte + padding to match UTF-8 wire cost int cost = emojiUtf8Cost(result); if (composePos + cost <= 137) { composeBuffer[composePos++] = (char)result; for (int p = 1; p < cost; p++) { composeBuffer[composePos++] = (char)EMOJI_PAD_BYTE; } composeBuffer[composePos] = '\0'; Serial.printf("Compose: Inserted emoji 0x%02X cost=%d, pos=%d\n", result, cost, composePos); } emojiPickerMode = false; drawComposeScreen(); lastComposeRefresh = millis(); composeNeedsRefresh = false; } else { // Navigation - debounce (don't draw immediately, let loop handle it) composeNeedsRefresh = true; } return; } // In compose mode - handle text input if (key == '\r') { // Enter - send the message Serial.println("Compose: Enter pressed, sending..."); if (composePos > 0) { sendComposedMessage(); } bool wasDM = composeDM; composeMode = false; emojiPickerMode = false; composeDM = false; composeDMContactIdx = -1; composeBuffer[0] = '\0'; composePos = 0; if (wasDM) { ui_task.gotoContactsScreen(); } else { ui_task.gotoHomeScreen(); } return; } if (key == '\b') { // Backspace - check if shift was recently pressed for cancel combo if (keyboard.wasShiftRecentlyPressed(500)) { // Shift+Backspace = Cancel (works anytime) Serial.println("Compose: Shift+Backspace, cancelling..."); bool wasDM = composeDM; composeMode = false; emojiPickerMode = false; composeDM = false; composeDMContactIdx = -1; composeBuffer[0] = '\0'; composePos = 0; if (wasDM) { ui_task.gotoContactsScreen(); } else { ui_task.gotoHomeScreen(); } return; } // Regular backspace - delete last character (or entire emoji including pads) if (composePos > 0) { // Delete trailing pad bytes first, then the escape byte while (composePos > 0 && (uint8_t)composeBuffer[composePos - 1] == EMOJI_PAD_BYTE) { composePos--; } // Now delete the actual character (escape byte or regular char) if (composePos > 0) { composePos--; } composeBuffer[composePos] = '\0'; Serial.printf("Compose: Backspace, pos=%d\n", composePos); composeNeedsRefresh = true; // Use debounced refresh } return; } // A/D keys switch channels (only when buffer is empty, not in DM mode) if ((key == 'a') && composePos == 0 && !composeDM) { // Previous channel if (composeChannelIdx > 0) { composeChannelIdx--; } else { // Wrap to last valid channel for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) { ChannelDetails ch; if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { composeChannelIdx = i; break; } } } Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx); composeNeedsRefresh = true; // Debounced refresh return; } if ((key == 'd') && composePos == 0 && !composeDM) { // Next channel ChannelDetails ch; uint8_t nextIdx = composeChannelIdx + 1; if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') { composeChannelIdx = nextIdx; } else { composeChannelIdx = 0; // Wrap to first channel } Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx); composeNeedsRefresh = true; // Debounced refresh return; } // '$' key (without Sym) opens emoji picker if (key == KB_KEY_EMOJI) { emojiPicker.reset(); emojiPickerMode = true; drawEmojiPicker(); lastComposeRefresh = millis(); composeNeedsRefresh = false; return; } // Regular character input if (key >= 32 && key < 127 && composePos < 137) { composeBuffer[composePos++] = key; composeBuffer[composePos] = '\0'; Serial.printf("Compose: Added '%c', pos=%d\n", key, composePos); composeNeedsRefresh = true; // Use debounced refresh } return; } // *** AUDIOBOOK MODE *** #ifndef HAS_4G_MODEM if (audiobookMode) { AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)ui_task.getAudiobookScreen(); // Q key: behavior depends on playback state // - Playing: navigate home, audio continues in background // - Paused/stopped: close book, return to file list // - File list: exit player entirely if (key == 'q') { if (abPlayer->isBookOpen()) { if (abPlayer->isAudioActive()) { // Audio is playing — leave screen, audio continues via audioTick() Serial.println("Leaving audiobook player (audio continues in background)"); ui_task.gotoHomeScreen(); } else { // Paused or stopped — close book, show file list abPlayer->closeCurrentBook(); Serial.println("Closed audiobook (was paused/stopped)"); // Stay on audiobook screen showing file list } } else { abPlayer->exitPlayer(); Serial.println("Exiting audiobook player"); ui_task.gotoHomeScreen(); } return; } // All other keys pass through to the player screen ui_task.injectKey(key); return; } #endif // !HAS_4G_MODEM // *** TEXT READER MODE *** if (readerMode) { TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); // Q key: if reading, reader handles it (close book -> file list) // if on file list, exit reader entirely if (key == 'q') { if (reader->isReading()) { // Let the reader handle Q (close book, go to file list) ui_task.injectKey('q'); } else { // On file list - exit reader, go home reader->exitReader(); Serial.println("Exiting text reader"); ui_task.gotoHomeScreen(); } return; } // All other keys pass through to the reader screen ui_task.injectKey(key); return; } // *** NOTES MODE *** if (notesMode) { NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen(); // ---- EDITING MODE ---- if (notes->isEditing()) { // Shift+Backspace = save and exit if (key == '\b') { if (keyboard.wasShiftConsumed()) { Serial.println("Notes: Shift+Backspace, saving..."); notes->saveAndExit(); ui_task.forceRefresh(); return; } // Regular backspace - delete before cursor ui_task.injectKey(key); composeNeedsRefresh = true; return; } // Cursor navigation via Shift+WASD (produces uppercase) if (key == 'W') { notes->moveCursorUp(); composeNeedsRefresh = true; return; } if (key == 'A') { notes->moveCursorLeft(); composeNeedsRefresh = true; return; } if (key == 'S') { notes->moveCursorDown(); composeNeedsRefresh = true; return; } if (key == 'D') { notes->moveCursorRight(); composeNeedsRefresh = true; return; } // Q when buffer is empty or unchanged = exit (nothing to lose) if (key == 'q' && (notes->isEmpty() || !notes->isDirty())) { Serial.println("Notes: Q exit (nothing to save)"); notes->discardAndExit(); ui_task.forceRefresh(); return; } // Enter = newline (pass through with debounce) if (key == '\r') { ui_task.injectKey(key); composeNeedsRefresh = true; return; } // All other printable chars (lowercase only - uppercase consumed by cursor nav) if (key >= 32 && key < 127) { ui_task.injectKey(key); composeNeedsRefresh = true; return; } return; } // ---- RENAMING MODE ---- if (notes->isRenaming()) { // All input goes to rename handler (debounced like editing) ui_task.injectKey(key); composeNeedsRefresh = true; if (!notes->isRenaming()) { // Exited rename mode (confirmed or cancelled) ui_task.forceRefresh(); } return; } // ---- DELETE CONFIRMATION MODE ---- if (notes->isConfirmingDelete()) { ui_task.injectKey(key); if (!notes->isConfirmingDelete()) { // Exited confirm mode ui_task.forceRefresh(); } return; } // ---- FILE LIST MODE ---- if (notes->isInFileList()) { if (key == 'q') { notes->exitNotes(); Serial.println("Exiting notes"); ui_task.gotoHomeScreen(); return; } // Shift+Backspace on a file = delete with confirmation if (key == '\b' && keyboard.wasShiftConsumed()) { if (notes->startDeleteFromList()) { ui_task.forceRefresh(); } return; } // R on a file = rename if (key == 'r') { if (notes->startRename()) { composeNeedsRefresh = true; lastComposeRefresh = millis() - COMPOSE_REFRESH_INTERVAL; // Trigger on next loop iteration } return; } // Normal keys pass through ui_task.injectKey(key); // Check if we just entered editing mode (new note via Enter) if (notes->isEditing()) { composeNeedsRefresh = true; lastComposeRefresh = millis(); // Draw after debounce interval, not immediately } return; } // ---- READING MODE ---- if (notes->isReading()) { if (key == 'q') { ui_task.injectKey('q'); return; } // Shift+Backspace = delete note if (key == '\b' && keyboard.wasShiftConsumed()) { Serial.println("Notes: Deleting current note"); notes->deleteCurrentNote(); ui_task.forceRefresh(); return; } // All other keys (Enter for edit, W/S for page nav) ui_task.injectKey(key); if (notes->isEditing()) { composeNeedsRefresh = true; lastComposeRefresh = millis(); // Draw after debounce interval, not immediately } return; } return; } // *** SETTINGS MODE *** if (ui_task.isOnSettingsScreen()) { SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen(); // Q key: exit settings (when not editing) if (!settings->isEditing() && (key == 'q')) { if (settings->hasRadioChanges()) { // Let settings show "apply changes?" confirm dialog ui_task.injectKey(key); } else { Serial.println("Exiting settings"); ui_task.gotoHomeScreen(); } return; } // All other keys → settings screen via injectKey ui_task.injectKey(key); return; } // *** REPEATER ADMIN MODE *** if (ui_task.isOnRepeaterAdmin()) { RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen(); RepeaterAdminScreen::AdminState astate = admin->getState(); bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed()); // In password entry: Shift+Del exits, all other keys pass through normally if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) { if (shiftDel) { Serial.println("Nav: Back to contacts from admin login"); ui_task.gotoContactsScreen(); } else { ui_task.injectKey(key); } return; } // In menu state: Shift+Del exits to contacts, C opens compose if (astate == RepeaterAdminScreen::STATE_MENU) { if (shiftDel) { Serial.println("Nav: Back to contacts from admin menu"); ui_task.gotoContactsScreen(); return; } // C key: allow entering compose mode from admin menu if (key == 'c' || key == 'C') { composeDM = false; composeDMContactIdx = -1; composeMode = true; composeBuffer[0] = '\0'; composePos = 0; drawComposeScreen(); lastComposeRefresh = millis(); return; } // All other keys pass to admin screen ui_task.injectKey(key); return; } // In waiting/response/error states: convert Shift+Del to exit signal, // pass all other keys through if (shiftDel) { ui_task.injectKey(KEY_ADMIN_EXIT); } else { ui_task.injectKey(key); } return; } // SMS mode key routing (when on SMS screen) #ifdef HAS_4G_MODEM if (smsMode) { SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen(); if (smsScr) { // Keep display alive — SMS routes many keys via handleInput() directly, // bypassing injectKey() which normally extends the auto-off timer. ui_task.keepAlive(); if (smsScr->isInCallView()) { smsScr->handleInput(key); if (!smsScr->isInCallView()) { // Hangup just happened — show "Call Ended" alert immediately ui_task.showAlert("Call Ended", 2000); } // Force immediate render (call screen updates or return-to-dialer) ui_task.forceRefresh(); ui_task.loop(); return; } // Q from app menu → go home; Q from inner views is handled by SMSScreen if ((key == 'q' || key == '\b') && smsScr->getSubView() == SMSScreen::APP_MENU) { Serial.println("Nav: SMS -> Home"); ui_task.gotoHomeScreen(); return; } // Phone dialer: debounced refresh for digit entry, immediate render for // view transitions (Enter=call, Q=back). This avoids the 686ms e-ink // block per keypress while ensuring call/back screens render instantly. if (smsScr->getSubView() == SMSScreen::PHONE_DIALER) { smsScr->handleInput(key); if (smsScr->getSubView() == SMSScreen::PHONE_DIALER) { // Still on dialer (digit/backspace) — debounced refresh dialerNeedsRefresh = true; lastDialerRefresh = millis(); } else { // View changed (startCall or Q back) — render immediately dialerNeedsRefresh = false; ui_task.forceRefresh(); ui_task.loop(); } return; } if (smsScr->isComposing()) { // Composing/text input: route directly to screen, bypass injectKey() // to avoid UITask scheduling its own competing refresh smsScr->handleInput(key); if (smsScr->isComposing()) { // Still composing — debounced refresh composeNeedsRefresh = true; lastComposeRefresh = millis(); } else { // View changed (sent/cancelled) — immediate UITask refresh composeNeedsRefresh = false; ui_task.forceRefresh(); } } else { // Non-compose views (inbox, conversation, contacts): use normal inject ui_task.injectKey(key); } return; } } #endif // *** WEB READER TEXT INPUT MODE *** // Match compose mode pattern: key handler sets a flag and returns instantly. // Main loop renders with 100ms debounce (same as COMPOSE_REFRESH_INTERVAL). // This way the key handler never blocks for 648ms during a render. #ifdef MECK_WEB_READER if (ui_task.isOnWebReader()) { WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen(); bool urlEdit = wr ? wr->isUrlEditing() : false; bool passEdit = wr ? wr->isPasswordEntry() : false; bool formEdit = wr ? wr->isFormFilling() : false; bool searchEdit = wr ? wr->isSearchEditing() : false; if (wr && (urlEdit || passEdit || formEdit || searchEdit)) { webReaderTextEntry = true; // Suppress ui_task.loop() in main loop wr->handleInput(key); // Updates buffer instantly, no render // Check if text entry ended (submitted, cancelled, etc.) if (!wr->isUrlEditing() && !wr->isPasswordEntry() && !wr->isFormFilling() && !wr->isSearchEditing()) { // Text entry ended webReaderTextEntry = false; webReaderNeedsRefresh = false; // fetchPage()/submitForm() handle their own rendering, or mode changed — // let ui_task.loop() resume on next iteration } else { // Still typing — request debounced refresh webReaderNeedsRefresh = true; lastWebReaderRefresh = millis(); } return; } else { // Not in text entry — clear flag so ui_task.loop() resumes webReaderTextEntry = false; // Q from HOME mode exits the web reader entirely (like text reader) if ((key == 'q' || key == 'Q') && wr && wr->isHome() && !wr->isUrlEditing() && !wr->isSearchEditing()) { Serial.println("Exiting web reader"); ui_task.gotoHomeScreen(); return; } // Route keys through normal UITask for navigation/scrolling ui_task.injectKey(key); return; } } #endif // Normal mode - not composing switch (key) { case 'c': // Open contacts list Serial.println("Opening contacts"); ui_task.gotoContactsScreen(); break; case 'm': // Go to channel message screen Serial.println("Opening channel messages"); ui_task.gotoChannelScreen(); break; case 'e': // Open text reader (ebooks) Serial.println("Opening text reader"); ui_task.gotoTextReader(); break; #ifndef HAS_4G_MODEM case 'p': // Open audiobook player - lazy-init Audio + screen on first use Serial.println("Opening audiobook player"); if (!ui_task.getAudiobookScreen()) { Serial.printf("Audiobook: lazy init - free heap: %d, largest block: %d\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); audio = new Audio(); AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio); abScreen->setSDReady(sdCardReady); ui_task.setAudiobookScreen(abScreen); Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap()); } ui_task.gotoAudiobookPlayer(); break; #endif #ifdef HAS_4G_MODEM case 't': // Open SMS (4G variant only) Serial.println("Opening SMS"); ui_task.gotoSMSScreen(); break; #endif #ifdef MECK_WEB_READER case 'b': // Open web reader (browser) Serial.println("Opening web reader"); { static bool webReaderWifiReady = false; if (!webReaderWifiReady) { // WiFi needs ~40KB contiguous heap. The BLE controller holds ~30KB, // leaving only ~30KB largest block. We MUST release BLE memory first. // // This disables BLE for the duration of the session. // BLE comes back on reboot. Serial.printf("WebReader: heap BEFORE BT release: free=%d, largest=%d\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); // 1) Stop BLE controller (disable + deinit) btStop(); delay(50); // 2) Release the BT controller's reserved memory region back to heap esp_bt_controller_mem_release(ESP_BT_MODE_BTDM); delay(50); Serial.printf("WebReader: heap AFTER BT release: free=%d, largest=%d\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); // 3) Now init WiFi while we have maximum contiguous heap if (WiFi.mode(WIFI_STA)) { Serial.println("WebReader: WiFi STA init OK"); webReaderWifiReady = true; } else { Serial.println("WebReader: WiFi STA init FAILED even after BT release"); // Clean up partial WiFi init to avoid memory leak WiFi.mode(WIFI_OFF); } Serial.printf("WebReader: heap after WiFi init: free=%d, largest=%d\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); } } ui_task.gotoWebReader(); break; #endif case 'n': // Open notes Serial.println("Opening notes"); { NotesScreen* notesScr2 = (NotesScreen*)ui_task.getNotesScreen(); if (notesScr2) { uint32_t ts = rtc_clock.getCurrentTime(); int8_t utcOff = the_mesh.getNodePrefs()->utc_offset_hours; notesScr2->setTimestamp(ts, utcOff); } } ui_task.gotoNotesScreen(); break; case 's': // Open settings (from home), or navigate down on channel/contacts/admin/web if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif ) { ui_task.injectKey('s'); // Pass directly for scrolling } else { Serial.println("Opening settings"); ui_task.gotoSettingsScreen(); } break; case 'w': // Navigate up/previous (scroll on channel screen) if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif ) { ui_task.injectKey('w'); // Pass directly for scrolling } else { Serial.println("Nav: Previous"); ui_task.injectKey(0xF2); // KEY_PREV } break; case 'a': // Navigate left or switch channel (on channel screen) if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { ui_task.injectKey('a'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Previous"); ui_task.injectKey(0xF2); // KEY_PREV } break; case 'd': // Navigate right or switch channel (on channel screen) if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { ui_task.injectKey('d'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Next"); ui_task.injectKey(0xF1); // KEY_NEXT } break; case '\r': // Select/Enter - if on contacts screen, enter DM compose for chat contacts // or repeater admin for repeater contacts if (ui_task.isOnContactsScreen()) { ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); int idx = cs->getSelectedContactIdx(); uint8_t ctype = cs->getSelectedContactType(); if (idx >= 0 && ctype == ADV_TYPE_CHAT) { composeDM = true; composeDMContactIdx = idx; cs->getSelectedContactName(composeDMName, sizeof(composeDMName)); composeMode = true; composeBuffer[0] = '\0'; composePos = 0; Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx); drawComposeScreen(); lastComposeRefresh = millis(); } else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) { // Open repeater admin screen char rname[32]; cs->getSelectedContactName(rname, sizeof(rname)); Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx); ui_task.gotoRepeaterAdmin(idx); } else if (idx >= 0) { // Non-chat, non-repeater contact (room, sensor, etc.) - future use Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx); } } else if (ui_task.isOnChannelScreen()) { // If path overlay is showing, Enter copies path text to compose buffer ChannelScreen* chScr2 = (ChannelScreen*)ui_task.getChannelScreen(); if (chScr2 && chScr2->isShowingPathOverlay()) { char pathText[138]; int pathLen = chScr2->formatPathAsText(pathText, sizeof(pathText)); if (pathLen > 0) { int copyLen = pathLen < 137 ? pathLen : 137; memcpy(composeBuffer, pathText, copyLen); composeBuffer[copyLen] = '\0'; composePos = copyLen; } else { composeBuffer[0] = '\0'; composePos = 0; } composeDM = false; composeDMContactIdx = -1; composeChannelIdx = ui_task.getChannelScreenViewIdx(); composeMode = true; chScr2->dismissPathOverlay(); Serial.printf("Compose with path, channel %d, prefill %d chars\n", composeChannelIdx, composePos); drawComposeScreen(); lastComposeRefresh = millis(); break; } composeDM = false; composeDMContactIdx = -1; composeChannelIdx = ui_task.getChannelScreenViewIdx(); composeMode = true; // If reply select mode is active, pre-fill @SenderName char replySender[32]; if (chScr2 && chScr2->isReplySelectMode() && chScr2->getReplySelectSender(replySender, sizeof(replySender))) { int prefixLen = snprintf(composeBuffer, sizeof(composeBuffer), "@%s ", replySender); composePos = prefixLen; chScr2->exitReplySelect(); Serial.printf("Reply compose to @%s, channel %d\n", replySender, composeChannelIdx); } else { composeBuffer[0] = '\0'; composePos = 0; if (chScr2) chScr2->exitReplySelect(); // Clean up if somehow active Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); } drawComposeScreen(); lastComposeRefresh = millis(); } else { // Other screens: pass Enter as generic select ui_task.injectKey(13); } break; case 'x': // Export contacts to SD card (contacts screen only) if (ui_task.isOnContactsScreen()) { Serial.println("Contacts: Exporting to SD..."); int exported = exportContactsToSD(); if (exported >= 0) { char alertBuf[48]; snprintf(alertBuf, sizeof(alertBuf), "Exported %d to SD", exported); ui_task.showAlert(alertBuf, 2000); } else { ui_task.showAlert("Export failed (no SD?)", 2000); } } break; case 'r': // Reply select mode (channel screen) or import contacts (contacts screen) if (ui_task.isOnChannelScreen()) { ui_task.injectKey('r'); } else if (ui_task.isOnContactsScreen()) { Serial.println("Contacts: Importing from SD..."); int added = importContactsFromSD(); if (added > 0) { // Invalidate the contacts screen cache so it rebuilds ContactsScreen* cs2 = (ContactsScreen*)ui_task.getContactsScreen(); if (cs2) cs2->invalidateCache(); char alertBuf[48]; snprintf(alertBuf, sizeof(alertBuf), "+%d imported (%d total)", added, (int)the_mesh.getNumContacts()); ui_task.showAlert(alertBuf, 2500); } else if (added == 0) { ui_task.showAlert("No new contacts to add", 2000); } else { ui_task.showAlert("Import failed (no backup?)", 2000); } } break; case 'q': case '\b': // If channel screen reply select or path overlay is showing, dismiss it if (ui_task.isOnChannelScreen()) { ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); if (chScr && chScr->isReplySelectMode()) { ui_task.injectKey('q'); break; } if (chScr && chScr->isShowingPathOverlay()) { ui_task.injectKey('q'); break; } } #ifdef MECK_WEB_READER // If web reader is in reading/link/wifi mode, inject q for internal navigation // (reading→home, wifi→home). Only exit to firmware home if already on web home. if (ui_task.isOnWebReader()) { WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen(); if (wr && !wr->isHome()) { ui_task.injectKey('q'); break; } } #endif // Go back to home screen (admin mode handled above) Serial.println("Nav: Back to home"); ui_task.gotoHomeScreen(); break; case ' ': // Space - also acts as next/select Serial.println("Nav: Space (Next)"); ui_task.injectKey(0xF1); // KEY_NEXT break; case 'u': // UTC offset edit (home screen GPS page handles this) ui_task.injectKey('u'); break; case 'v': // View path overlay (channel screen only) if (ui_task.isOnChannelScreen()) { ui_task.injectKey('v'); } break; default: #ifdef MECK_WEB_READER // Pass unhandled keys to web reader (l=link, g=go, k=bookmark, 0-9=link#) if (ui_task.isOnWebReader()) { ui_task.injectKey(key); break; } #endif Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key); break; } } void drawComposeScreen() { #ifdef DISPLAY_CLASS display.startFrame(); display.setTextSize(1); display.setColor(DisplayDriver::GREEN); display.setCursor(0, 0); // Get the channel name for display char headerBuf[40]; if (composeDM) { snprintf(headerBuf, sizeof(headerBuf), "DM: %s", composeDMName); } else { ChannelDetails channel; if (the_mesh.getChannel(composeChannelIdx, channel)) { snprintf(headerBuf, sizeof(headerBuf), "To: %s", channel.name); } else { snprintf(headerBuf, sizeof(headerBuf), "To: Channel %d", composeChannelIdx); } } display.print(headerBuf); display.setColor(DisplayDriver::LIGHT); display.drawRect(0, 11, display.width(), 1); display.setCursor(0, 14); display.setColor(DisplayDriver::LIGHT); // Word wrap the compose buffer with word-boundary awareness // Uses advance width (cursor movement) not bounding box width for px tracking. // Advance = getTextWidth("cc") - getTextWidth("c") to get true cursor step. int y = 14; char charStr[2] = {0, 0}; char dblStr[3] = {0, 0, 0}; int px = 0; int lineW = display.width(); bool atWordBoundary = true; for (int i = 0; i < composePos; i++) { uint8_t b = (uint8_t)composeBuffer[i]; if (b == EMOJI_PAD_BYTE) continue; // Word wrap: when starting a new text word, check if it fits on this line if (atWordBoundary && b != ' ' && !isEmojiEscape(b) && px > 0) { int wordW = 0; for (int j = i; j < composePos; j++) { uint8_t wb = (uint8_t)composeBuffer[j]; if (wb == EMOJI_PAD_BYTE) continue; if (wb == ' ' || isEmojiEscape(wb)) break; dblStr[0] = dblStr[1] = (char)wb; charStr[0] = (char)wb; wordW += display.getTextWidth(dblStr) - display.getTextWidth(charStr); } if (px + wordW > lineW) { px = 0; y += 12; } } if (isEmojiEscape(b)) { if (px + EMOJI_LG_W > lineW) { px = 0; y += 12; } const uint8_t* sprite = getEmojiSpriteLg(b); if (sprite) { display.drawXbm(px, y, sprite, EMOJI_LG_W, EMOJI_LG_H); } px += EMOJI_LG_W + 1; display.setCursor(px, y); atWordBoundary = true; } else if (b == ' ') { charStr[0] = ' '; dblStr[0] = dblStr[1] = ' '; int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr); if (px + adv > lineW) { px = 0; y += 12; } else { display.setCursor(px, y); display.print(charStr); px += adv; } atWordBoundary = true; } else { charStr[0] = (char)b; dblStr[0] = dblStr[1] = (char)b; int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr); if (px + adv > lineW) { px = 0; y += 12; } display.setCursor(px, y); display.print(charStr); px += adv; atWordBoundary = false; } } // Show cursor display.setCursor(px, y); display.print("_"); // Status bar int statusY = display.height() - 12; display.setColor(DisplayDriver::LIGHT); display.drawRect(0, statusY - 2, display.width(), 1); display.setCursor(0, statusY); display.setColor(DisplayDriver::YELLOW); char status[40]; if (composePos == 0) { // Empty buffer - show channel switching hint display.print("A/D:Ch $:Emoji"); sprintf(status, "Sh+Del:X"); display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY); display.print(status); } else { // Has text - show send/cancel hint sprintf(status, "%d/137 $:Emj", composePos); display.print(status); sprintf(status, "Sh+Del:X"); display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY); display.print(status); } display.endFrame(); #endif } void drawEmojiPicker() { #ifdef DISPLAY_CLASS display.startFrame(); emojiPicker.draw(display); display.endFrame(); #endif } void sendComposedMessage() { if (composePos == 0) return; cpuPower.setBoost(); // Boost CPU for crypto + radio TX // Convert escape bytes back to UTF-8 for mesh transmission and BLE app char utf8Buf[512]; emojiUnescape(composeBuffer, utf8Buf, sizeof(utf8Buf)); if (composeDM) { // Direct message to a specific contact if (composeDMContactIdx >= 0) { if (the_mesh.uiSendDirectMessage((uint32_t)composeDMContactIdx, utf8Buf)) { ui_task.showAlert("DM sent!", 1500); } else { ui_task.showAlert("DM failed!", 1500); } } else { ui_task.showAlert("No contact!", 1500); } return; } // Channel (group) message ChannelDetails channel; if (the_mesh.getChannel(composeChannelIdx, channel)) { uint32_t timestamp = rtc_clock.getCurrentTime(); int utf8Len = strlen(utf8Buf); if (the_mesh.sendGroupMessage(timestamp, channel.channel, the_mesh.getNodePrefs()->node_name, utf8Buf, utf8Len)) { ui_task.addSentChannelMessage(composeChannelIdx, the_mesh.getNodePrefs()->node_name, utf8Buf); the_mesh.queueSentChannelMessage(composeChannelIdx, timestamp, the_mesh.getNodePrefs()->node_name, utf8Buf); ui_task.showAlert("Sent!", 1500); } else { ui_task.showAlert("Send failed!", 1500); } } else { ui_task.showAlert("No channel!", 1500); } } // ============================================================================ // ESP32-audioI2S CALLBACKS // ============================================================================ // The audio library calls these global functions - must be defined at file scope. // Not available on 4G variant (no audio hardware). #ifndef HAS_4G_MODEM void audio_info(const char *info) { Serial.printf("Audio: %s\n", info); } void audio_eof_mp3(const char *info) { Serial.printf("Audio: End of file - %s\n", info); // Signal the player screen for auto-advance to next track AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)ui_task.getAudiobookScreen(); if (abPlayer) { abPlayer->onEOF(); } } #endif // !HAS_4G_MODEM #endif // LilyGo_TDeck_Pro