#include // needed for PlatformIO #ifdef BLE_PIN_CODE #include // for esp_bt_controller_mem_release (web reader WiFi) #endif #ifdef MECK_OTA_UPDATE #include #endif #include #include "MyMesh.h" #include "variant.h" // Board-specific defines (HAS_GPS, etc.) #include "target.h" // For sensors, board, etc. #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" #include "DiscoveryScreen.h" #include "LastHeardScreen.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 // 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 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) { Serial.println("Export: SD card not ready"); 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 // Non-fatal — text export reads from memory and doesn't need this. if (SPIFFS.exists("/contacts3")) { if (copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin")) { Serial.println("Export: binary backup OK"); } else { Serial.println("Export: binary copy to SD failed (continuing with text export)"); } } else { Serial.println("Export: /contacts3 not found on SPIFFS (skipping binary backup)"); } // 2) Human-readable listing for inspection on a computer // Reads from in-memory contact table — always works if SD is writable. int count = 0; File txt = SD.open("/meshcore/contacts_export.txt", "w", true); if (!txt) { Serial.println("Export: failed to open contacts_export.txt for writing"); digitalWrite(SDCARD_CS, HIGH); return -1; } 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 // ============================================================================= // Touch Input — unified across T5S3 (GT911) and T-Deck Pro (CST328) // ============================================================================= // Define MECK_TOUCH_ENABLED for any platform with touch support #if defined(LilyGo_T5S3_EPaper_Pro) || (defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN)) #define MECK_TOUCH_ENABLED 1 #endif // --- T5S3: GT911 capacitive touch driver --- #if defined(LilyGo_T5S3_EPaper_Pro) #include "TouchDrvGT911.hpp" #include #include "TextReaderScreen.h" #include "NotesScreen.h" #include "ContactsScreen.h" #include "ChannelScreen.h" #include "SettingsScreen.h" #include "RepeaterAdminScreen.h" #include "DiscoveryScreen.h" #include "LastHeardScreen.h" static TouchDrvGT911 gt911Touch; static bool gt911Ready = false; static bool sdCardReady = false; // T5S3 SD card state // --------------------------------------------------------------------------- // SD Settings Backup / Restore (T5S3) // --------------------------------------------------------------------------- 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; } 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"); } if (SPIFFS.exists("/channels2")) { copyFile(SPIFFS, "/channels2", SD, "/meshcore/channels.bin"); } 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"); } if (SPIFFS.exists("/contacts3")) { copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin"); } digitalWrite(SDCARD_CS, HIGH); Serial.println("Settings backed up to SD"); } bool restoreSettingsFromSD() { if (!sdCardReady) return false; bool restored = false; 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; } } 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; } #ifdef MECK_CARDKB #include "CardKBKeyboard.h" static CardKBKeyboard cardkb; static unsigned long lastCardKBProbe = 0; #define CARDKB_PROBE_INTERVAL_MS 5000 // Re-probe for hot-plug every 5s #endif // Read GT911 in landscape orientation (960×540) static bool readTouchLandscape(int16_t* outX, int16_t* outY) { if (!gt911Ready) return false; int16_t raw_x, raw_y; if (gt911Touch.getPoint(&raw_x, &raw_y)) { *outX = raw_y; *outY = EPD_HEIGHT - 1 - raw_x; return true; } return false; } // Read GT911 in portrait orientation (540×960, canvas rotation 3) static bool readTouchPortrait(int16_t* outX, int16_t* outY) { if (!gt911Ready) return false; int16_t raw_x, raw_y; if (gt911Touch.getPoint(&raw_x, &raw_y)) { *outX = raw_x; *outY = raw_y; return true; } return false; } #endif // --- Shared touch state machine variables --- #ifdef MECK_TOUCH_ENABLED static bool touchDown = false; static unsigned long touchDownTime = 0; static int16_t touchDownX = 0; static int16_t touchDownY = 0; static int16_t touchLastX = 0; static int16_t touchLastY = 0; static unsigned long lastTouchSeenMs = 0; #define TOUCH_LONG_PRESS_MS 750 #if defined(LilyGo_T5S3_EPaper_Pro) #define TOUCH_SWIPE_THRESHOLD 60 // T5S3: 960×540 — 60px ≈ 6% of width #else #define TOUCH_SWIPE_THRESHOLD 30 // T-Deck Pro: 240×320 — 30px ≈ 12.5% of width #endif #define TOUCH_LIFT_DEBOUNCE_MS 150 #define TOUCH_MIN_INTERVAL_MS 300 static bool longPressHandled = false; static bool swipeHandled = false; static bool touchCooldown = false; static unsigned long lastTouchEventMs = 0; // Unified touch reader — returns physical screen coordinates static bool readTouch(int16_t* outX, int16_t* outY) { #if defined(LilyGo_T5S3_EPaper_Pro) if (display.isPortraitMode()) { return readTouchPortrait(outX, outY); } return readTouchLandscape(outX, outY); #elif defined(LilyGo_TDeck_Pro) return touchInput.getPoint(*outX, *outY); #else return false; #endif } // Convert physical touch coords to virtual 128×128 coordinate space static void touchToVirtual(int16_t px, int16_t py, int& vx, int& vy) { #if defined(LilyGo_T5S3_EPaper_Pro) float sx = display.isPortraitMode() ? ((float)EPD_HEIGHT / 128.0f) : ((float)EPD_WIDTH / 128.0f); float sy = display.isPortraitMode() ? ((float)EPD_WIDTH / 128.0f) : ((float)EPD_HEIGHT / 128.0f); #elif defined(LilyGo_TDeck_Pro) float sx = (float)EINK_WIDTH / 128.0f; // 240/128 = 1.875 float sy = (float)EINK_HEIGHT / 128.0f; // 320/128 = 2.5 #endif vx = (int)(px / sx); #if defined(LilyGo_TDeck_Pro) // Subtract EINK_Y_OFFSET to align touch coords with virtual render coords. // GxEPD setCursor/fillRect add offset_y (5) internally, so raw physical→virtual // is off by that amount. vy = (int)(py / sy) - EINK_Y_OFFSET; #else vy = (int)(py / sy); #endif } #endif // Board-agnostic: CPU frequency scaling and AGC reset CPUPowerManager cpuPower; #define AGC_RESET_INTERVAL_MS 500 static unsigned long lastAGCReset = 0; // 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(MECK_WIFI_COMPANION) #include #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" #if HAS_GPS #include "MapScreen.h" // After BLE — PNGdec headers conflict with BLE if included earlier #endif 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 */ // Last Heard: add/remove contact for selected entry. // Called from both touch double-tap (mapTouchTap) and keyboard Enter handler. #ifdef DISPLAY_CLASS static void lastHeardToggleContact() { LastHeardScreen* lh = (LastHeardScreen*)ui_task.getLastHeardScreen(); if (!lh) return; const AdvertPath* entry = lh->getSelectedEntry(); if (!entry) return; ContactInfo* existing = the_mesh.lookupContactByPubKey(entry->pubkey_prefix, 8); if (existing) { // Double-confirm for favourites (bit 0 of flags) static unsigned long lastRemoveAttempt = 0; static uint8_t lastRemovePrefix[8] = {0}; bool isFav = (existing->flags & 0x01) != 0; if (isFav) { if (millis() - lastRemoveAttempt < 3000 && memcmp(lastRemovePrefix, entry->pubkey_prefix, 8) == 0) { // Second press within 3s — confirmed } else { // First press on favourite — warn and wait lastRemoveAttempt = millis(); memcpy(lastRemovePrefix, entry->pubkey_prefix, 8); ui_task.showAlert("Favourite! Press again", 2500); return; } } the_mesh.removeContact(*existing); the_mesh.scheduleLazyContactSave(); char alertBuf[40]; snprintf(alertBuf, sizeof(alertBuf), "Removed: %s", entry->name); ui_task.showAlert(alertBuf, 1500); Serial.printf("[LastHeard] Removed: %s\n", entry->name); } else { uint8_t blob[256]; int blobLen = the_mesh.getContactBlob(entry->pubkey_prefix, 8, blob); if (blobLen > 0) { the_mesh.importContact(blob, blobLen); the_mesh.scheduleLazyContactSave(); char alertBuf[40]; snprintf(alertBuf, sizeof(alertBuf), "Added: %s", entry->name); ui_task.showAlert(alertBuf, 1500); Serial.printf("[LastHeard] Added: %s\n", entry->name); } else { // Blob store is limited to 100 entries — with many contacts, blobs // from non-contact nodes get evicted quickly. User needs to wait // for the node to re-broadcast its advert. ui_task.showAlert("Advert expired, try later", 2000); Serial.printf("[LastHeard] Blob evicted for %s (store full)\n", entry->name); } } ui_task.forceRefresh(); } #endif // Touch mapping — must be after ui_task declaration #ifdef MECK_TOUCH_ENABLED // Map a single tap based on current screen context static char mapTouchTap(int16_t x, int16_t y) { // Convert physical screen coords to virtual (128×128) int vx, vy; touchToVirtual(x, y, vx, vy); // Dismiss boot navigation hint on any tap if (ui_task.isHintActive()) { ui_task.dismissBootHint(); return 0; } // --- Status bar tap (top ~18 virtual units) → go home from any non-home screen --- // Exception: text reader reading mode uses full screen for content (no header) if (vy < 18 && !ui_task.isOnHomeScreen()) { if (ui_task.isOnTextReader()) { TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); if (reader && reader->isReading()) { return 'd'; // reading mode: treat as next page } } ui_task.gotoHomeScreen(); return 0; } // Home screen FIRST page: tile taps (virtual coordinate hit test) if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) { const int tileW = 40, tileH = 28, gapX = 1, gapY = 1; const int gridW = tileW * 3 + gapX * 2; const int gridX = (128 - gridW) / 2; // =3 int gridY = ui_task.getTileGridVY(); // Check if tap is within the tile grid area if (vx >= gridX && vx < gridX + gridW && vy >= gridY && vy < gridY + 2 * (tileH + gapY)) { int col = (vx - gridX) / (tileW + gapX); if (col > 2) col = 2; int row = (vy - gridY) / (tileH + gapY); if (row > 1) row = 1; if (row == 0 && col == 0) { ui_task.gotoChannelScreen(); return 0; } if (row == 0 && col == 1) { ui_task.gotoContactsScreen(); return 0; } if (row == 0 && col == 2) { ui_task.gotoSettingsScreen(); return 0; } if (row == 1 && col == 0) { ui_task.gotoTextReader(); return 0; } if (row == 1 && col == 1) { ui_task.gotoNotesScreen(); return 0; } #ifdef MECK_WEB_READER if (row == 1 && col == 2) { ui_task.gotoWebReader(); return 0; } #else if (row == 1 && col == 2) { ui_task.gotoDiscoveryScreen(); return 0; } #endif } // Tap outside tiles — left half backward, right half forward return (vx < 64) ? (char)KEY_PREV : (char)KEY_NEXT; } // Home screen (non-tile pages): left half taps backward, right half forward // Exception: on Recent Adverts page, bottom area tap opens Last Heard if (ui_task.isOnHomeScreen()) { if (ui_task.isHomeOnRecentPage() && vy >= 100) { ui_task.gotoLastHeardScreen(); return 0; } return (vx < 64) ? (char)KEY_PREV : (char)KEY_NEXT; } // Reader: tap in reading mode; in file list = select row if (ui_task.isOnTextReader()) { TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); if (reader && reader->isReading()) { #if defined(LilyGo_T5S3_EPaper_Pro) // Footer zone tap → go to page via VKB if (vy >= 113) { char label[24]; snprintf(label, sizeof(label), "Page (1-%d)", reader->getTotalPages()); ui_task.showVirtualKeyboard(VKB_TEXT_PAGE, label, "", 5); return 0; } #endif return 'd'; // Tap anywhere else = next page } // File list: tap-to-select, double-tap to open if (reader && reader->isInFileList()) { int result = reader->selectRowAtVY(vy); if (result == 1) { ui_task.forceRefresh(); return 0; // Moved selection } if (result == 2) return KEY_ENTER; // Same row — open } return 0; } // Notes editing: tap → open keyboard for typing if (ui_task.isOnNotesScreen()) { NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen(); if (notes && notes->isEditing()) { #if defined(LilyGo_T5S3_EPaper_Pro) ui_task.showVirtualKeyboard(VKB_NOTES, "Edit Note", "", 137); #endif return 0; // T-Deck Pro: keyboard handles typing directly } } #ifdef MECK_WEB_READER // Web reader: context-dependent taps if (ui_task.isOnWebReader()) { WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen(); if (wr) { if (wr->isReading()) { #if defined(LilyGo_T5S3_EPaper_Pro) // Footer zone tap → open link VKB (if links exist) if (vy >= 113 && wr->getLinkCount() > 0) { ui_task.showVirtualKeyboard(VKB_WEB_LINK, "Link #", "", 3); return 0; } #endif return 'd'; // Tap reading area → next page } if (wr->isHome()) { #if defined(LilyGo_T5S3_EPaper_Pro) int sel = wr->getHomeSelected(); if (sel == 1) { ui_task.showVirtualKeyboard(VKB_WEB_URL, "Enter URL", wr->getUrlText(), WEB_MAX_URL_LEN - 1); return 0; } if (sel == 2) { ui_task.showVirtualKeyboard(VKB_WEB_SEARCH, "Search DuckDuckGo", "", 127); return 0; } #endif return KEY_ENTER; // Select current item (keyboard handles text on T-Deck Pro) } if (wr->isWifiSetup()) { #if defined(LilyGo_T5S3_EPaper_Pro) if (wr->isPasswordEntry()) { ui_task.showVirtualKeyboard(VKB_WEB_WIFI_PASS, "WiFi Password", "", 63); return 0; } #endif return KEY_ENTER; // SSID list: select, failed: retry } } return KEY_ENTER; } #endif // Channel screen: tap footer area → hop path, tap elsewhere → no action if (ui_task.isOnChannelScreen()) { ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); if (chScr && chScr->isShowingPathOverlay()) { return 'q'; // Dismiss overlay on any tap } // Footer zone: bottom ~15 virtual units (≈63 physical pixels) if (vy >= 113) { return 'v'; // Show path overlay } return 0; // Tap on message area — consumed, no action } // Contacts screen: tap to select row, tap same row to activate if (ui_task.isOnContactsScreen()) { ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); if (cs) { int result = cs->selectRowAtVY(vy); if (result == 1) { ui_task.forceRefresh(); return 0; // Moved cursor } if (result == 2) return KEY_ENTER; // Same row — activate } return 0; } // Discovery screen: tap to select, tap same to add if (ui_task.isOnDiscoveryScreen()) { DiscoveryScreen* ds = (DiscoveryScreen*)ui_task.getDiscoveryScreen(); if (ds) { int result = ds->selectRowAtVY(vy); if (result == 1) { ui_task.forceRefresh(); return 0; } if (result == 2) return KEY_ENTER; // Same row — add to contacts } return 0; } // Last Heard screen: tap to select, tap same to add/remove if (ui_task.isOnLastHeardScreen()) { LastHeardScreen* lh = (LastHeardScreen*)ui_task.getLastHeardScreen(); if (lh) { int result = lh->selectRowAtVY(vy); if (result == 1) { ui_task.forceRefresh(); return 0; } if (result == 2) { lastHeardToggleContact(); return 0; } } return 0; } // Settings screen: tap to select row, tap same row to activate if (ui_task.isOnSettingsScreen()) { SettingsScreen* ss = (SettingsScreen*)ui_task.getSettingsScreen(); if (ss && !ss->isEditing()) { int result = ss->selectRowAtVY(vy); if (result == 1) { ui_task.forceRefresh(); return 0; // Moved cursor — just refresh, don't activate } if (result == 2) return KEY_ENTER; // Tapped same row — activate } return KEY_ENTER; // Editing mode or header/footer tap } // SMS screen: dedicated dialer/touch handler runs separately (HAS_4G_MODEM block) // Return 0 so the general handler doesn't inject spurious keys #ifdef HAS_4G_MODEM if (ui_task.isOnSMSScreen()) return 0; #endif // All other screens: tap = select return KEY_ENTER; } // Map a swipe direction to a key static char mapTouchSwipe(int16_t dx, int16_t dy) { bool horizontal = abs(dx) > abs(dy); // SMS screen: dedicated touch handler covers all interaction #ifdef HAS_4G_MODEM if (ui_task.isOnSMSScreen()) return 0; #endif // Reader (reading mode): swipe left/right for page turn if (ui_task.isOnTextReader()) { TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); if (reader && reader->isReading()) { if (horizontal) { return (dx < 0) ? 'd' : 'a'; // swipe left=next, right=prev } // Vertical swipe in reader: also page turn (natural scroll) return (dy > 0) ? 'd' : 'a'; // swipe down=next, up=prev } } #ifdef MECK_WEB_READER // Web reader: page turn in reading mode, list scroll elsewhere if (ui_task.isOnWebReader()) { WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen(); if (wr && wr->isReading()) { if (horizontal) { return (dx < 0) ? 'd' : 'a'; // swipe left=next page, right=prev } return (dy > 0) ? 'd' : 'a'; // swipe down=next, up=prev } // HOME / WIFI_SETUP / other: vertical swipe = scroll list if (!horizontal) { return (dy > 0) ? 's' : 'w'; } return 0; // Ignore horizontal swipes on non-reading modes } #endif // Home screen: swipe left = next page, swipe right = previous page if (ui_task.isOnHomeScreen()) { if (horizontal) { return (dx < 0) ? (char)KEY_NEXT : (char)KEY_PREV; } return (char)KEY_NEXT; // vertical swipe = next (default) } // Settings: horizontal swipe → a/d for picker/number editing if (ui_task.isOnSettingsScreen() && horizontal) { return (dx < 0) ? 'd' : 'a'; // swipe left=next option, right=prev } // Channel screen: horizontal swipe → a/d to switch channels if (ui_task.isOnChannelScreen() && horizontal) { return (dx < 0) ? 'd' : 'a'; // swipe left=next channel, right=prev } // Contacts screen: horizontal swipe → a/d to change filter if (ui_task.isOnContactsScreen() && horizontal) { return (dx < 0) ? 'd' : 'a'; // swipe left=next filter, right=prev } // All other screens: vertical swipe scrolls if (!horizontal) { return (dy > 0) ? 's' : 'w'; // swipe down=scroll down, up=scroll up } return 0; // ignore horizontal swipes on non-applicable screens } // Map a long press to a key static char mapTouchLongPress(int16_t x, int16_t y) { // SMS screen: dedicated touch handler covers all interaction #ifdef HAS_4G_MODEM if (ui_task.isOnSMSScreen()) return 0; #endif // Home screen: long press = activate current page action // (BLE toggle, send advert, hibernate, GPS toggle, etc.) if (ui_task.isOnHomeScreen()) { return (char)KEY_ENTER; } // Reader reading: long press = close book if (ui_task.isOnTextReader()) { TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); if (reader && reader->isReading()) { return 'q'; } return KEY_ENTER; // file list: open } #ifdef MECK_WEB_READER // Web reader: long press for back navigation if (ui_task.isOnWebReader()) { WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen(); if (wr) { if (wr->isReading()) { return 'b'; // Back to previous page, or HOME if no history } if (wr->isHome()) { int sel = wr->getHomeSelected(); int bmEnd = 3 + wr->getBookmarkCount(); if (sel >= 3 && sel < bmEnd) { return '\b'; // Long press on bookmark → delete } return 'q'; // All others: exit web reader } if (wr->isWifiSetup()) { return 'q'; // Back from WiFi setup } if (wr->isIRCMode()) { return 'q'; // Back from IRC } } return 'q'; // Default: back out } #endif // Channel screen: long press → compose to current channel (or DM actions on DM tab) if (ui_task.isOnChannelScreen()) { uint8_t chIdx = ui_task.getChannelScreenViewIdx(); if (chIdx == 0xFF) { ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); if (chScr->isDMInboxMode()) { // Inbox mode: long press = open selected conversation (same as Enter) return '\r'; } // Conversation mode: long press = compose reply #if defined(LilyGo_T5S3_EPaper_Pro) const char* dmName = chScr->getDMFilterName(); if (dmName && dmName[0]) { uint32_t numC = the_mesh.getNumContacts(); ContactInfo ci; for (uint32_t j = 0; j < numC; j++) { if (the_mesh.getContactByIdx(j, ci) && strcmp(ci.name, dmName) == 0) { char label[40]; snprintf(label, sizeof(label), "DM: %s", dmName); ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, j); ui_task.clearDMUnread(j); return 0; } } } ui_task.showAlert("Contact not found", 1000); return 0; #else return KEY_ENTER; #endif } #if defined(LilyGo_T5S3_EPaper_Pro) ChannelDetails ch; if (the_mesh.getChannel(chIdx, ch)) { char label[40]; snprintf(label, sizeof(label), "To: %s", ch.name); ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx); } return 0; #else return KEY_ENTER; // T-Deck Pro: keyboard handles compose mode #endif } // Contacts screen: long press → DM for chat contacts, admin for repeaters if (ui_task.isOnContactsScreen()) { ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); if (cs) { int idx = cs->getSelectedContactIdx(); uint8_t ctype = cs->getSelectedContactType(); #if defined(LilyGo_T5S3_EPaper_Pro) if (idx >= 0 && ctype == ADV_TYPE_CHAT) { if (ui_task.hasDMUnread(idx)) { char cname[32]; cs->getSelectedContactName(cname, sizeof(cname)); ui_task.clearDMUnread(idx); ui_task.gotoDMConversation(cname); return 0; } char dname[32]; cs->getSelectedContactName(dname, sizeof(dname)); char label[40]; snprintf(label, sizeof(label), "DM: %s", dname); ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx); return 0; } else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) { ui_task.gotoRepeaterAdmin(idx); return 0; } else if (idx >= 0 && ctype == ADV_TYPE_ROOM) { // Room server: open login (after login, auto-redirects to conversation) ui_task.gotoRepeaterAdmin(idx); return 0; } else if (idx >= 0 && ui_task.hasDMUnread(idx)) { char cname[32]; cs->getSelectedContactName(cname, sizeof(cname)); ui_task.clearDMUnread(idx); ui_task.gotoDMConversation(cname); return 0; } #else // T-Deck Pro: repeater admin works directly, DM via keyboard compose if (idx >= 0 && ctype == ADV_TYPE_REPEATER) { ui_task.gotoRepeaterAdmin(idx); return 0; } return KEY_ENTER; #endif } return KEY_ENTER; } // Discovery screen: long press = rescan if (ui_task.isOnDiscoveryScreen()) { return 'f'; } // Repeater admin: long press → open keyboard for password or CLI if (ui_task.isOnRepeaterAdmin()) { RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen(); if (admin) { RepeaterAdminScreen::AdminState astate = admin->getState(); if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) { #if defined(LilyGo_T5S3_EPaper_Pro) ui_task.showVirtualKeyboard(VKB_ADMIN_PASSWORD, "Admin Password", "", 32); return 0; #else return KEY_ENTER; // T-Deck Pro: keyboard handles password entry #endif } } } // Notes screen: long press in editor → save and exit if (ui_task.isOnNotesScreen()) { NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen(); if (notes && notes->isEditing()) { notes->triggerSaveAndExit(); return 0; } } // Settings screen: context-dependent long press if (ui_task.isOnSettingsScreen()) { SettingsScreen* ss = (SettingsScreen*)ui_task.getSettingsScreen(); if (ss) { if (ss->isEditing()) { return 0; // Consume — don't interfere with active edit mode } if (ss->isOnDeletableChannel()) { return 'x'; // Long press on channel row → delete } } return KEY_ENTER; // Not editing: toggle/edit selected row } // Default: enter/select return KEY_ENTER; } #endif 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"); } } #elif defined(LilyGo_T5S3_EPaper_Pro) && defined(HAS_SDCARD) { // T5S3: SD card shares LoRa SPI bus (SCK=14, MOSI=13, MISO=21) // LoRa SPI already initialized by target.cpp. Create a local HSPI // reference for SD init (same hardware peripheral, different CS). static SPIClass sdSpi(HSPI); sdSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, SDCARD_CS); pinMode(SDCARD_CS, OUTPUT); digitalWrite(SDCARD_CS, HIGH); pinMode(P_LORA_NSS, OUTPUT); digitalWrite(P_LORA_NSS, HIGH); delay(100); bool mounted = false; for (int attempt = 0; attempt < 3 && !mounted; attempt++) { if (attempt > 0) { digitalWrite(SDCARD_CS, HIGH); delay(250); Serial.printf("setup() - SD card retry %d/3\n", attempt + 1); } mounted = SD.begin(SDCARD_CS, sdSpi, 4000000); } if (mounted) { sdCardReady = true; Serial.println("setup() - SD card initialized"); // If SPIFFS was wiped (fresh flash), restore settings from SD backup if (restoreSettingsFromSD()) { Serial.println("setup() - T5S3: Settings restored from SD backup"); } } else { Serial.println("setup() - SD card not available"); } } #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 (compile-time credentials)"); WiFi.begin(WIFI_SSID, WIFI_PWD); serial_interface.begin(TCP_PORT); #elif defined(MECK_WIFI_COMPANION) { // WiFi companion: load credentials from SD at runtime. // TCP server starts regardless — companion connects when WiFi comes up. MESH_DEBUG_PRINTLN("setup() - WiFi companion mode (runtime credentials)"); WiFi.mode(WIFI_STA); if (sdCardReady) { File f = SD.open("/web/wifi.cfg", FILE_READ); if (f) { String ssid = f.readStringUntil('\n'); ssid.trim(); String pass = f.readStringUntil('\n'); pass.trim(); f.close(); digitalWrite(SDCARD_CS, HIGH); if (ssid.length() > 0) { MESH_DEBUG_PRINTLN("setup() - WiFi: connecting to '%s'", ssid.c_str()); WiFi.begin(ssid.c_str(), pass.c_str()); unsigned long timeout = millis() + 3000; // 3s max — non-critical, Settings can retry while (WiFi.status() != WL_CONNECTED && millis() < timeout) { yield(); // Feed WDT during wait delay(50); } if (WiFi.status() == WL_CONNECTED) { Serial.printf("WiFi companion: connected to %s, IP: %s\n", ssid.c_str(), WiFi.localIP().toString().c_str()); } else { Serial.println("WiFi companion: auto-connect failed (configure in Settings)"); } } } else { digitalWrite(SDCARD_CS, HIGH); Serial.println("WiFi companion: no /web/wifi.cfg found (configure in Settings)"); } } serial_interface.begin(TCP_PORT); MESH_DEBUG_PRINTLN("setup() - WiFi TCP server started on port %d", 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 must end Serial1 first, then reclaim the pins for Serial2 (which feeds gpsStream). #if HAS_GPS Serial1.end(); // Release GPS pins from Serial1's UART + ISR Serial2.end(); // Close any existing Serial2 { uint32_t gps_baud = the_mesh.getNodePrefs()->gps_baudrate; Serial.printf("GPS: prefs gps_baudrate=%lu (0=use default)\n", (unsigned long)gps_baud); if (gps_baud == 0) gps_baud = GPS_BAUDRATE; Serial2.begin(gps_baud, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); Serial.printf("GPS: Serial2 started at %lu baud (RX=%d TX=%d)\n", (unsigned long)gps_baud, GPS_RX_PIN, GPS_TX_PIN); } #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 // --------------------------------------------------------------------------- // OTA boot validation — confirm new firmware is working after an OTA update. // If we reach this point, display + radio + SD + mesh all initialised OK. // Without this call (when CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE is set), // the bootloader will roll back to the previous partition on next reboot. // --------------------------------------------------------------------------- #ifdef MECK_OTA_UPDATE { const esp_partition_t* running = esp_ota_get_running_partition(); esp_ota_img_states_t ota_state; if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) { if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) { if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) { Serial.println("OTA: New firmware validated, rollback cancelled"); } else { Serial.println("OTA: WARNING - failed to cancel rollback"); } } } } #endif // Initialize T-Deck Pro keyboard #if defined(LilyGo_TDeck_Pro) initKeyboard(); #endif // Initialize touch input (CST328 on T-Deck Pro) #if defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN) if (touchInput.begin(CST328_PIN_INT)) { MESH_DEBUG_PRINTLN("setup() - Touch input initialized"); } else { MESH_DEBUG_PRINTLN("setup() - Touch input FAILED"); } #endif // Initialize GT911 touch (T5S3 E-Paper Pro) // Wire is already initialized by T5S3Board::begin(). The 4-arg begin() re-calls // Wire.begin() which logs "bus already initialized" — cosmetic only, not harmful. #if defined(LilyGo_T5S3_EPaper_Pro) gt911Touch.setPins(GT911_PIN_RST, GT911_PIN_INT); if (gt911Touch.begin(Wire, GT911_SLAVE_ADDRESS_L, GT911_PIN_SDA, GT911_PIN_SCL)) { gt911Ready = true; Serial.println("setup() - GT911 touch initialized"); } else { Serial.println("setup() - GT911 touch FAILED"); } #endif // Initialize CardKB external keyboard (if connected via QWIIC) #if defined(LilyGo_T5S3_EPaper_Pro) && defined(MECK_CARDKB) if (cardkb.begin()) { ui_task.setCardKBDetected(true); Serial.println("setup() - CardKB detected at 0x5F"); } else { Serial.println("setup() - CardKB not detected (will re-probe)"); } #endif // RTC diagnostic + boot-time serial clock sync (T5S3 has no GPS) #if defined(LilyGo_T5S3_EPaper_Pro) { uint32_t rtcTime = rtc_clock.getCurrentTime(); Serial.printf("setup() - RTC time: %lu (valid=%s)\n", rtcTime, rtcTime > 1700000000 ? "YES" : "NO"); if (rtcTime < 1700000000) { // No valid time. If a USB host has the serial port open (Serial // evaluates true on ESP32-S3 native CDC), request an automatic // clock sync. The PlatformIO monitor filter "clock_sync" watches // for MECK_CLOCK_REQ and responds immediately with the host time. // Manual sync is also accepted: type "clock sync " in any // serial terminal. if (Serial) { Serial.println("MECK_CLOCK_REQ"); Serial.println(" (Waiting 3s for clock sync from host...)"); char syncBuf[64]; int syncPos = 0; unsigned long syncDeadline = millis() + 3000; bool synced = false; while (millis() < syncDeadline && !synced) { while (Serial.available() && syncPos < (int)sizeof(syncBuf) - 1) { char c = Serial.read(); if (c == '\r' || c == '\n') { if (syncPos > 0) { syncBuf[syncPos] = '\0'; if (memcmp(syncBuf, "clock sync ", 11) == 0) { uint32_t epoch = (uint32_t)strtoul(&syncBuf[11], nullptr, 10); if (epoch > 1704067200UL && epoch < 2082758400UL) { rtc_clock.setCurrentTime(epoch); Serial.printf(" > Clock synced to %lu\n", (unsigned long)epoch); synced = true; } } syncPos = 0; } break; } syncBuf[syncPos++] = c; } if (!synced) delay(10); } if (!synced) { Serial.println(" > No clock sync received, continuing boot"); Serial.println(" > Use 'clock sync ' any time to sync later"); } } else { Serial.println("setup() - RTC not set, no serial host detected (skipping sync window)"); } } } #endif // 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 // T5S3 SD-dependent features #if defined(LilyGo_T5S3_EPaper_Pro) && defined(HAS_SDCARD) if (sdCardReady) { // Channel message history ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen(); if (chanScr) { chanScr->setSDReady(true); if (chanScr->loadFromSD()) { Serial.println("setup() - Message history loaded from SD"); } } // Text reader — set SD ready and pre-index books TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); if (reader) { reader->setSDReady(true); if (disp) { cpuPower.setBoost(); reader->bootIndex(*disp); } } // Notes screen NotesScreen* notesScr = (NotesScreen*)ui_task.getNotesScreen(); if (notesScr) { notesScr->setSDReady(true); } Serial.println("setup() - SD features initialized"); } #endif // 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(); // Show hint immediately overlaid on the onboarding screen if (!prefs->hint_shown) ui_task.showBootHint(true); } } #endif // GPS power — honour saved pref, default to enabled on first boot. // GPS is critical for timesync on standalone variants without 4G. #if HAS_GPS { bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled; Serial.printf("GPS: pref gps_enabled=%d\n", (int)gps_wanted); if (gps_wanted) { #ifdef PIN_GPS_EN digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE); #endif sensors.setSettingValue("gps", "1"); } else { #ifdef PIN_GPS_EN digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE); #endif sensors.setSettingValue("gps", "0"); } Serial.printf("GPS: power %s, PIN_GPS_EN=%d\n", gps_wanted ? "ON" : "OFF", PIN_GPS_EN); } #endif // BLE starts disabled for standalone-first operation // User can toggle it from the Bluetooth home page (Enter or long-press) #if (defined(LilyGo_TDeck_Pro) || defined(LilyGo_T5S3_EPaper_Pro)) && defined(BLE_PIN_CODE) serial_interface.disable(); MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)"); #endif // Alarm clock: create at boot so config is loaded, background alarm check // works from first loop(), and the bell indicator is visible immediately. // Audio object is NOT created here — lazy-init when alarm fires or user opens player. #ifdef MECK_AUDIO_VARIANT { AlarmScreen* alarmScr = new AlarmScreen(&ui_task); alarmScr->setSDReady(sdCardReady); // Audio pointer set later when needed (fireAlarm or 'k'/'p' key) ui_task.setAlarmScreen(alarmScr); Serial.printf("ALARM: Boot init, %d alarms enabled\n", alarmScr->enabledCount()); } #endif Serial.printf("setup() complete — free heap: %d, largest block: %d\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ==="); } // --------------------------------------------------------------------------- // OTA radio control — pause LoRa during firmware updates to prevent SPI // bus contention (SD and LoRa share the same SPI bus on both platforms). // Also pauses the mesh loop to prevent radio state confusion while standby. // --------------------------------------------------------------------------- #ifdef MECK_OTA_UPDATE extern RADIO_CLASS radio; // Defined in target.cpp static bool otaRadioPaused = false; void otaPauseRadio() { otaRadioPaused = true; radio.standby(); Serial.println("OTA: Radio standby, mesh loop paused"); } void otaResumeRadio() { radio.startReceive(); otaRadioPaused = false; Serial.println("OTA: Radio receive resumed, mesh loop active"); } #endif void loop() { #ifdef MECK_OTA_UPDATE if (!otaRadioPaused) { #endif the_mesh.loop(); #ifdef MECK_OTA_UPDATE } else { // OTA/File Manager active — poll the web server from the main loop for fast response. // The render cycle on T5S3 (960×540 FastEPD) can block for 500ms+ during // e-ink refresh, causing the browser to timeout before handleClient() runs. // Polling here gives us ~1-5ms response time instead. if (ui_task.isOnSettingsScreen()) { SettingsScreen* ss = (SettingsScreen*)ui_task.getSettingsScreen(); if (ss) { ss->pollOTAServer(); // Detect upload completion and trigger verify → flash → reboot. // Must happen here (not in render) because T5S3 e-ink refresh blocks // for 500ms+ and the render-based check never fires reliably. ss->checkOTAComplete(display); } } } #endif sensors.loop(); // GPS diagnostic — disabled to reduce serial noise (uncomment for debugging) // #if HAS_GPS // { // static unsigned long lastGpsDiag = 0; // if (millis() - lastGpsDiag >= 30000) { // lastGpsDiag = millis(); // uint32_t sentences = gpsStream.getSentenceCount(); // uint16_t perSec = gpsStream.getSentencesPerSec(); // Serial.printf("GPS diag: %lu sentences total, %u/sec, Serial2.available=%d, lat=%.6f lon=%.6f\n", // (unsigned long)sentences, perSec, Serial2.available(), // sensors.node_lat, sensors.node_lon); // } // } // #endif // Map screen: periodically update own GPS position and contact markers #if HAS_GPS if (ui_task.isOnMapScreen()) { static unsigned long lastMapUpdate = 0; if (millis() - lastMapUpdate > 30000) { // Every 30 seconds lastMapUpdate = millis(); MapScreen* ms = (MapScreen*)ui_task.getMapScreen(); if (ms) { // Update own GPS position when GPS is enabled ms->updateGPSPosition(sensors.node_lat, sensors.node_lon); // Always refresh contact markers (new contacts arrive via radio) ms->clearMarkers(); ContactsIterator it = the_mesh.startContactsIterator(); ContactInfo ci; while (it.hasNext(&the_mesh, ci)) { if (ci.gps_lat != 0 || ci.gps_lon != 0) { double lat = ((double)ci.gps_lat) / 1000000.0; double lon = ((double)ci.gps_lon) / 1000000.0; ms->addMarker(lat, lon, ci.name, ci.type); } } } } } #endif // CPU frequency auto-timeout back to idle cpuPower.loop(); // Low-power mode — drop CPU to 40 MHz and throttle loop when lock screen // is active. The mesh radio has its own FIFO so packets are buffered; // 50 ms yield means the loop still runs 20×/sec which is more than enough // to drain the radio FIFO before overflow. #if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) { static bool wasLocked = false; bool nowLocked = ui_task.isLocked(); if (nowLocked && !wasLocked) { cpuPower.setLowPower(); Serial.printf("[Power] Low-power mode: CPU %d MHz, loop throttled\n", cpuPower.getFrequencyMHz()); } else if (!nowLocked && wasLocked) { cpuPower.clearLowPower(); Serial.printf("[Power] Normal mode: CPU %d MHz\n", cpuPower.getFrequencyMHz()); } wasLocked = nowLocked; } #endif // Audiobook: service audio decode regardless of which screen is active #if defined(LilyGo_TDeck_Pro) && !defined(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 // Alarm clock: background alarm check + audio tick #if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT) { AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen(); if (alarmScr) { // Service alarm audio decode (like audiobook audioTick) alarmScr->alarmAudioTick(); if (alarmScr->isAlarmAudioActive()) { cpuPower.setBoost(); } // Periodic alarm check (~every 10 seconds) static unsigned long lastAlarmCheck = 0; if (millis() - lastAlarmCheck > ALARM_CHECK_INTERVAL_MS) { lastAlarmCheck = millis(); uint32_t rtcNow = the_mesh.getRTCClock()->getCurrentTime(); int fireSlot = alarmScr->checkAlarms(rtcNow, the_mesh.getNodePrefs()->utc_offset_hours); if (fireSlot >= 0 && !alarmScr->isRinging()) { // If audiobook is playing, the alarm will take over the shared Audio* // object. The audiobook auto-saves bookmarks every 30s, so at most // 30s of position is lost. User can resume from audiobook player after. AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)ui_task.getAudiobookScreen(); if (abPlayer && abPlayer->isAudioActive()) { Serial.println("ALARM: Audiobook active — alarm taking over Audio"); } // Ensure Audio object is shared if (!audio) audio = new Audio(); alarmScr->setAudio(audio); // Fire the alarm alarmScr->fireAlarm(fireSlot); alarmScr->setLastFiredEpoch(fireSlot, rtcNow); // Let audio buffer fill before e-ink refresh blocks SPI for (int i = 0; i < 50; i++) { alarmScr->alarmAudioTick(); delay(2); } // Switch UI to alarm screen (ringing mode) ui_task.gotoAlarmScreen(); // Wake display if asleep ui_task.keepAlive(); ui_task.forceRefresh(); Serial.printf("ALARM: Fired slot %d, switched to ringing screen\n", fireSlot); } } } } #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 #ifdef MECK_OTA_UPDATE if (!otaRadioPaused) #endif 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 // --------------------------------------------------------------------------- // Touch Input — tap/swipe/long-press state machine (T5S3 + T-Deck Pro) // Gestures: // Tap = finger down + up with minimal movement → select/open // Swipe = finger drag > threshold → scroll/page turn // Long press = finger held > 750ms without moving → edit/enter // After processing an event, cooldown waits for finger lift before next event. // Touch is disabled while lock screen is active. // When virtual keyboard is active (T5S3), taps route to keyboard. // --------------------------------------------------------------------------- #ifdef MECK_TOUCH_ENABLED { // Guard: skip touch when locked or VKB active bool touchBlocked = ui_task.isLocked(); #if defined(LilyGo_T5S3_EPaper_Pro) touchBlocked = touchBlocked || ui_task.isVKBActive(); #endif #ifdef HAS_4G_MODEM // SMS dialer has its own dedicated touch handler — don't consume touch data here if (smsMode) { SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen(); if (smsScr && smsScr->getSubView() == SMSScreen::PHONE_DIALER) { touchBlocked = true; } } #endif if (!touchBlocked) { int16_t tx, ty; bool gotPoint = readTouch(&tx, &ty); unsigned long now = millis(); if (gotPoint) { lastTouchSeenMs = now; } bool fingerPresent = (now - lastTouchSeenMs) < TOUCH_LIFT_DEBOUNCE_MS; if (touchCooldown) { if (!fingerPresent && (now - lastTouchEventMs) >= TOUCH_MIN_INTERVAL_MS) { touchCooldown = false; touchDown = false; } } else if (gotPoint && !touchDown) { touchDown = true; touchDownTime = now; touchDownX = tx; touchDownY = ty; touchLastX = tx; touchLastY = ty; longPressHandled = false; swipeHandled = false; } else if (touchDown && fingerPresent) { if (gotPoint) { touchLastX = tx; touchLastY = ty; } int16_t dx = touchLastX - touchDownX; int16_t dy = touchLastY - touchDownY; int16_t dist = abs(dx) > abs(dy) ? abs(dx) : abs(dy); if (!swipeHandled && !longPressHandled && dist >= TOUCH_SWIPE_THRESHOLD) { swipeHandled = true; char c = mapTouchSwipe(dx, dy); if (c) { ui_task.injectKey(c); cpuPower.setBoost(); } lastTouchEventMs = now; touchCooldown = true; } else if (!longPressHandled && !swipeHandled && dist < TOUCH_SWIPE_THRESHOLD && (now - touchDownTime) >= TOUCH_LONG_PRESS_MS) { longPressHandled = true; char c = mapTouchLongPress(touchDownX, touchDownY); if (c) { ui_task.injectKey(c); cpuPower.setBoost(); } lastTouchEventMs = now; touchCooldown = true; } } else if (touchDown && !fingerPresent) { touchDown = false; if (!longPressHandled && !swipeHandled) { char c = mapTouchTap(touchDownX, touchDownY); if (c) { ui_task.injectKey(c); } cpuPower.setBoost(); lastTouchEventMs = now; touchCooldown = true; } } } } // Virtual keyboard touch routing (T5S3 only — T-Deck Pro uses physical keyboard) #if defined(LilyGo_T5S3_EPaper_Pro) { static bool vkbNeedLift = true; if (ui_task.isVKBActive()) { int16_t tx, ty; bool gotPt = readTouch(&tx, &ty); if (!gotPt) { vkbNeedLift = false; } bool cooldownOk = (millis() - ui_task.vkbOpenedAt()) > 2000; if (gotPt && !vkbNeedLift && cooldownOk) { int vx, vy; touchToVirtual(tx, ty, vx, vy); if (ui_task.getVKB().handleTap(vx, vy)) { ui_task.forceRefresh(); } vkbNeedLift = true; } } else { vkbNeedLift = true; } } #endif #endif // MECK_TOUCH_ENABLED // --------------------------------------------------------------------------- // CardKB external keyboard polling (T5S3 only, via QWIIC) // When VKB is active: typed characters feed into the VKB text buffer. // When VKB is not active: navigation keys route through injectKey(). // ESC key maps to 'q' (back) when no VKB is active. // --------------------------------------------------------------------------- #if defined(LilyGo_T5S3_EPaper_Pro) && defined(MECK_CARDKB) { // Hot-plug detection: re-probe periodically if (millis() - lastCardKBProbe >= CARDKB_PROBE_INTERVAL_MS) { lastCardKBProbe = millis(); bool wasDetected = cardkb.isDetected(); bool nowDetected = cardkb.probe(); if (nowDetected != wasDetected) { ui_task.setCardKBDetected(nowDetected); Serial.printf("[CardKB] %s\n", nowDetected ? "Connected" : "Disconnected"); } } // Poll for keypress char ckb = cardkb.readKey(); if (ckb != 0) { // Block input while locked (same as touch) if (!ui_task.isLocked()) { cpuPower.setBoost(); ui_task.keepAlive(); if (ui_task.isVKBActive()) { // VKB is open — feed character into VKB text buffer ui_task.feedCardKBChar(ckb); } else if (ui_task.isOnHomeScreen()) { // Home screen: ESC does nothing special, letter shortcuts open tiles if (ckb == 0x1B) { // ESC on home — no-op (already home) } else { switch (ckb) { case 'm': ui_task.gotoChannelScreen(); break; case 'c': ui_task.gotoContactsScreen(); break; case 'e': ui_task.gotoTextReader(); break; case 'n': ui_task.gotoNotesScreen(); break; case 's': ui_task.gotoSettingsScreen(); break; case 'f': ui_task.gotoDiscoveryScreen(); break; case 'h': ui_task.gotoLastHeardScreen(); break; #ifdef MECK_WEB_READER case 'b': ui_task.gotoWebReader(); break; #endif #if HAS_GPS case 'g': ui_task.gotoMapScreen(); break; #endif default: ui_task.injectKey(ckb); break; } } } else { // Non-home screens: context-specific routing bool handled = false; // Notes editing/renaming: route ALL keys directly (no VKB). // This gives: Enter=newline, arrows=cursor, printable=insert, ESC=save&exit if (ui_task.isOnNotesScreen()) { NotesScreen* notesScr = (NotesScreen*)ui_task.getNotesScreen(); if (notesScr && (notesScr->isEditing() || notesScr->isRenaming())) { handled = true; if (ckb == 0x1B) { // ESC: save & exit editing, or cancel rename if (notesScr->isEditing()) { notesScr->triggerSaveAndExit(); } else { ui_task.injectKey('q'); } } else if (notesScr->isEditing()) { // Editing mode: arrows move cursor, everything else types directly switch (ckb) { case (char)0xF2: notesScr->moveCursorUp(); break; case (char)0xF1: notesScr->moveCursorDown(); break; case (char)0xF3: notesScr->moveCursorLeft(); break; case (char)0xF4: notesScr->moveCursorRight(); break; default: ui_task.injectKey(ckb); break; } } else { // Renaming mode: all keys go directly to rename handler ui_task.injectKey(ckb); } ui_task.forceRefresh(); } } if (!handled) { // ESC → back (same as 'q' on T-Deck Pro) for all non-notes screens if (ckb == 0x1B) { ui_task.injectKey('q'); } else if (ckb == '\r') { // Enter key — screen-specific compose or select if (ui_task.isOnChannelScreen()) { uint8_t chIdx = ui_task.getChannelScreenViewIdx(); if (chIdx == 0xFF) { ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); if (chScr->isDMInboxMode()) { // Inbox mode: inject Enter to open conversation ui_task.injectKey('\r'); } else { // Conversation mode: open VKB DM compose const char* dmName = chScr->getDMFilterName(); if (dmName && dmName[0]) { uint32_t numC = the_mesh.getNumContacts(); ContactInfo ci; for (uint32_t j = 0; j < numC; j++) { if (the_mesh.getContactByIdx(j, ci) && strcmp(ci.name, dmName) == 0) { char label[40]; snprintf(label, sizeof(label), "DM: %s", dmName); ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, j); ui_task.clearDMUnread(j); break; } } } } } else { // Open VKB for channel message compose ChannelDetails ch; if (the_mesh.getChannel(chIdx, ch)) { char label[40]; snprintf(label, sizeof(label), "To: %s", ch.name); ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx); } } } else if (ui_task.isOnContactsScreen()) { // DM compose for chat contacts, admin for repeaters ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); if (cs) { int idx = cs->getSelectedContactIdx(); uint8_t ctype = cs->getSelectedContactType(); if (idx >= 0 && ctype == ADV_TYPE_CHAT) { if (ui_task.hasDMUnread(idx)) { char cname[32]; cs->getSelectedContactName(cname, sizeof(cname)); ui_task.clearDMUnread(idx); ui_task.gotoDMConversation(cname); } else { char dname[32]; cs->getSelectedContactName(dname, sizeof(dname)); char label[40]; snprintf(label, sizeof(label), "DM: %s", dname); ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx); } } else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) { ui_task.gotoRepeaterAdmin(idx); } else if (idx >= 0 && ctype == ADV_TYPE_ROOM) { // Room server: open login (auto-redirects to conversation) ui_task.gotoRepeaterAdmin(idx); } else if (idx >= 0 && ui_task.hasDMUnread(idx)) { char cname[32]; cs->getSelectedContactName(cname, sizeof(cname)); ui_task.clearDMUnread(idx); ui_task.gotoDMConversation(cname); } } } else if (ui_task.isOnRepeaterAdmin()) { // Open VKB for password or CLI entry RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen(); if (admin) { RepeaterAdminScreen::AdminState astate = admin->getState(); if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) { ui_task.showVirtualKeyboard(VKB_ADMIN_PASSWORD, "Admin Password", "", 32); } else { ui_task.showVirtualKeyboard(VKB_ADMIN_CLI, "Admin Command", "", 137); } } } else { // All other screens: pass Enter through for native handling // (settings toggle, discovery add-contact, last heard, text reader, notes file list, etc.) ui_task.injectKey('\r'); } } else { // Non-Enter keys: remap arrows to WASD, pass others through switch (ckb) { case (char)0xF2: ui_task.injectKey('w'); break; // Up → scroll up case (char)0xF1: ui_task.injectKey('s'); break; // Down → scroll down case (char)0xF3: ui_task.injectKey('a'); break; // Left → prev channel/category case (char)0xF4: ui_task.injectKey('d'); break; // Right → next channel/category default: ui_task.injectKey(ckb); break; } } } } } } } #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 // Low-power loop throttle — yield CPU when lock screen is active. // The RTOS idle task executes WFI (wait-for-interrupt) during delay(), // dramatically reducing CPU power draw. 50 ms gives 20 loop cycles/sec // which is ample for LoRa packet reception (radio has hardware FIFO). #if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) if (ui_task.isLocked()) { delay(50); } #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; // Block all keyboard input while lock screen is active. // Still read the key above to clear the TCA8418 buffer. if (ui_task.isLocked()) return; // Dismiss boot navigation hint on any keypress if (ui_task.isHintActive()) { ui_task.dismissBootHint(); return; // Consume the keypress (don't act on it) } Serial.printf("handleKeyboardInput: key='%c' (0x%02X) composeMode=%d\n", key >= 32 ? key : '?', key, composeMode); // Alarm ringing: ANY key dismisses (highest priority after lock screen) #ifdef MECK_AUDIO_VARIANT { AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen(); if (alarmScr && alarmScr->isRinging()) { if (key == 'z') { alarmScr->handleInput('z'); // Snooze } else { alarmScr->dismiss(); // Any other key = dismiss } ui_task.gotoHomeScreen(); ui_task.forceRefresh(); return; // Consume the key } } #endif 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..."); bool composeWasSent = false; if (composePos > 0) { sendComposedMessage(); composeWasSent = true; // sendComposedMessage shows its own alert } bool wasDM = composeDM; int savedDMIdx = composeDMContactIdx; char savedDMName[32]; if (wasDM) strncpy(savedDMName, composeDMName, sizeof(savedDMName)); composeMode = false; emojiPickerMode = false; composeDM = false; composeDMContactIdx = -1; composeBuffer[0] = '\0'; composePos = 0; if (wasDM && savedDMIdx >= 0) { // Return to DM conversation to see sent message ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); uint8_t savedPerms = (chScr && chScr->isDMConversation()) ? chScr->getDMContactPerms() : 0; ui_task.gotoDMConversation(savedDMName, savedDMIdx, savedPerms); // Re-show alert after navigation (setCurrScreen clears prior alerts) if (composeWasSent) ui_task.showAlert("DM sent!", 1500); } else if (wasDM) { ui_task.gotoContactsScreen(); } else { ui_task.gotoChannelScreen(); } 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; int savedDMIdx = composeDMContactIdx; char savedDMName[32]; if (wasDM) strncpy(savedDMName, composeDMName, sizeof(savedDMName)); composeMode = false; emojiPickerMode = false; composeDM = false; composeDMContactIdx = -1; composeBuffer[0] = '\0'; composePos = 0; if (wasDM && savedDMIdx >= 0) { ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); uint8_t savedPerms = (chScr && chScr->isDMConversation()) ? chScr->getDMContactPerms() : 0; ui_task.gotoDMConversation(savedDMName, savedDMIdx, savedPerms); } else if (wasDM) { ui_task.gotoContactsScreen(); } else { ui_task.gotoChannelScreen(); } 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 — skip gaps if (composeChannelIdx > 0) { bool found = false; for (uint8_t prev = composeChannelIdx - 1; ; prev--) { ChannelDetails ch; if (the_mesh.getChannel(prev, ch) && ch.name[0] != '\0') { composeChannelIdx = prev; found = true; break; } if (prev == 0) break; } if (!found) { // 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; } } } } 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 — skip gaps bool found = false; for (uint8_t next = composeChannelIdx + 1; next < MAX_GROUP_CHANNELS; next++) { ChannelDetails ch; if (the_mesh.getChannel(next, ch) && ch.name[0] != '\0') { composeChannelIdx = next; found = true; break; } } if (!found) { 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()); // Helper: exit admin — room servers go to DM conversation if logged in, otherwise contacts auto exitAdmin = [&]() { int cidx = admin->getContactIdx(); uint8_t perms = admin->getPermissions() & 0x03; ContactInfo ci; if (cidx >= 0 && perms > 0 && the_mesh.getContactByIdx(cidx, ci) && ci.type == ADV_TYPE_ROOM) { ui_task.gotoDMConversation(ci.name, cidx, perms); Serial.printf("Nav: Admin -> conversation for %s\n", ci.name); } else { ui_task.gotoContactsScreen(); Serial.println("Nav: Admin -> contacts"); } }; // In password entry: Shift+Del exits, all other keys pass through normally if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) { if (shiftDel) { exitAdmin(); } else { ui_task.injectKey(key); } return; } // In category menu (top level): Shift+Del exits, C opens compose if (astate == RepeaterAdminScreen::STATE_CATEGORY_MENU) { if (shiftDel) { exitAdmin(); 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; } // All other states (command menu, param entry, confirm, waiting, // response, error): convert Shift+Del to exit signal and let the // screen handle back-navigation internally 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, the_mesh.getNodePrefs()); abScreen->setSDReady(sdCardReady); ui_task.setAudiobookScreen(abScreen); Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap()); } ui_task.gotoAudiobookPlayer(); break; #endif #ifdef MECK_AUDIO_VARIANT case 'k': // Open alarm clock (screen created at boot; just ensure Audio* is available) Serial.println("Opening alarm clock"); if (!audio) { Serial.printf("Alarm: lazy init Audio - free heap: %d, largest block: %d\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); audio = new Audio(); } { AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen(); if (alarmScr) alarmScr->setAudio(audio); } ui_task.gotoAlarmScreen(); 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; #ifdef MECK_WIFI_COMPANION // WiFi companion: WiFi is already up from boot, no BLE to tear down webReaderWifiReady = true; #endif if (!webReaderWifiReady) { #ifdef BLE_PIN_CODE // 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()); #endif // 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"); 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 'g': // Open map screen, or re-center on GPS if already on map if (ui_task.isOnMapScreen()) { ui_task.injectKey('g'); // Re-center on GPS } else { Serial.println("Opening map"); cpuPower.setBoost(); // Map render is CPU-intensive (PNG decode + SD reads) { MapScreen* ms = (MapScreen*)ui_task.getMapScreen(); if (ms) { ms->setSDReady(sdCardReady); ms->setGPSPosition(sensors.node_lat, sensors.node_lon); // Populate contact markers via iterator ms->clearMarkers(); ContactsIterator it = the_mesh.startContactsIterator(); ContactInfo ci; int markerCount = 0; while (it.hasNext(&the_mesh, ci)) { if (ci.gps_lat != 0 || ci.gps_lon != 0) { double lat = ((double)ci.gps_lat) / 1000000.0; double lon = ((double)ci.gps_lon) / 1000000.0; ms->addMarker(lat, lon, ci.name, ci.type); markerCount++; } } Serial.printf("MapScreen: %d contacts with GPS position\n", markerCount); } } ui_task.gotoMapScreen(); } break; case 'n': // Open notes Serial.println("Opening notes"); ui_task.gotoNotesScreen(); break; case 's': // Open settings (from home), or navigate down on channel/contacts/admin/web/map/discovery/lastheard if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif || ui_task.isOnMapScreen() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #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() || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif || ui_task.isOnMapScreen() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #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.isOnMapScreen() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #endif ) { 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.isOnMapScreen() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #endif ) { 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) { // If unread DMs exist, go to conversation view to read first if (ui_task.hasDMUnread(idx)) { char cname[32]; cs->getSelectedContactName(cname, sizeof(cname)); ui_task.clearDMUnread(idx); ui_task.gotoDMConversation(cname); Serial.printf("Unread DMs from %s — opening conversation\n", cname); } else { 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 && ctype == ADV_TYPE_ROOM) { // Room server: open login screen (after login, auto-redirects to conversation) char rname[32]; cs->getSelectedContactName(rname, sizeof(rname)); Serial.printf("Room %s — opening login\n", rname); ui_task.gotoRepeaterAdmin(idx); } else if (idx >= 0) { // Other contacts with unreads if (ui_task.hasDMUnread(idx)) { char cname[32]; cs->getSelectedContactName(cname, sizeof(cname)); ui_task.clearDMUnread(idx); ui_task.gotoDMConversation(cname); } else { 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; } // DM inbox mode: pass Enter to ChannelScreen to open the selected conversation if (chScr2 && chScr2->isDMInboxMode()) { ui_task.injectKey('\r'); break; } // DM conversation mode: Enter opens DM compose to the contact being viewed // (DM inbox mode Enter is handled by ChannelScreen::handleInput internally) if (chScr2 && chScr2->isDMConversation()) { const char* dmName = chScr2->getDMFilterName(); if (dmName && dmName[0]) { uint32_t numC = the_mesh.getNumContacts(); ContactInfo ci; for (uint32_t j = 0; j < numC; j++) { if (the_mesh.getContactByIdx(j, ci) && strcmp(ci.name, dmName) == 0) { composeDM = true; composeDMContactIdx = (int)j; strncpy(composeDMName, dmName, sizeof(composeDMName) - 1); composeDMName[sizeof(composeDMName) - 1] = '\0'; composeMode = true; composeBuffer[0] = '\0'; composePos = 0; ui_task.clearDMUnread(j); Serial.printf("DM conversation compose to %s (idx %d)\n", dmName, j); drawComposeScreen(); lastComposeRefresh = millis(); break; } } } else { ui_task.showAlert("No contact selected", 1000); } 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 if (ui_task.isOnDiscoveryScreen()) { // Discovery screen: Enter adds selected node to contacts DiscoveryScreen* ds = (DiscoveryScreen*)ui_task.getDiscoveryScreen(); int didx = ds->getSelectedIdx(); if (didx >= 0 && didx < the_mesh.getDiscoveredCount()) { const DiscoveredNode& node = the_mesh.getDiscovered(didx); if (node.already_in_contacts) { ui_task.showAlert("Already in contacts", 800); } else if (the_mesh.addDiscoveredToContacts(didx)) { char alertBuf[48]; snprintf(alertBuf, sizeof(alertBuf), "Added: %s", node.contact.name); ui_task.showAlert(alertBuf, 1500); ui_task.notify(UIEventType::ack); } else { ui_task.showAlert("Add failed", 1000); } } } else if (ui_task.isOnLastHeardScreen()) { lastHeardToggleContact(); } else { // Other screens: pass Enter as generic select ui_task.injectKey(13); } break; case 'z': // Zoom in on map screen if (ui_task.isOnMapScreen()) { ui_task.injectKey('z'); } break; case 'x': // Zoom out on map screen, or export contacts on contacts screen if (ui_task.isOnMapScreen()) { ui_task.injectKey('x'); } else 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 (check serial)", 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 'f': // Start discovery scan from home/contacts screen, or rescan on discovery screen if (ui_task.isOnContactsScreen() || ui_task.isOnHomeScreen()) { Serial.println("Starting discovery scan..."); the_mesh.startDiscovery(); ui_task.gotoDiscoveryScreen(); } else if (ui_task.isOnDiscoveryScreen()) { ui_task.injectKey('f'); // pass through for rescan } break; case 'h': // Open Last Heard screen (passive advert list) if (!ui_task.isOnLastHeardScreen()) { Serial.println("Opening last heard"); ui_task.gotoLastHeardScreen(); } break; case 'l': // L = Login/Admin — from DM conversation, open repeater admin with auto-login if (ui_task.isOnChannelScreen()) { ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); if (chScr && chScr->isDMConversation() && chScr->getDMContactPerms() > 0) { int cidx = chScr->getDMContactIdx(); if (cidx >= 0) { ui_task.gotoRepeaterAdminDirect(cidx); Serial.printf("DM conversation: auto-login admin for idx %d\n", cidx); } } } 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 // Discovery screen: Q goes back to contacts (not home) if (ui_task.isOnDiscoveryScreen()) { the_mesh.stopDiscovery(); Serial.println("Nav: Discovery -> Contacts"); ui_task.gotoContactsScreen(); break; } // Alarm screen: Q/backspace routing depends on sub-mode #ifdef MECK_AUDIO_VARIANT if (ui_task.isOnAlarmScreen()) { AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen(); if (alarmScr && alarmScr->isRinging()) { alarmScr->dismiss(); ui_task.gotoHomeScreen(); } else if (alarmScr && alarmScr->getMode() != AlarmScreen::ALARM_LIST) { // In edit/picker/digit mode — pass to screen (Q = back to list, backspace = delete) ui_task.injectKey(key); } else { // On alarm list — go home Serial.println("Nav: Alarm -> Home"); ui_task.gotoHomeScreen(); } break; } #endif // Last Heard: Q goes back to home if (ui_task.isOnLastHeardScreen()) { Serial.println("Nav: Last Heard -> Home"); ui_task.gotoHomeScreen(); break; } // 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 // Pass unhandled keys to map screen (+, -, i, o for zoom) if (ui_task.isOnMapScreen()) { ui_task.injectKey(key); break; } #ifdef MECK_AUDIO_VARIANT // Pass unhandled keys to alarm screen (digits for time entry, o for toggle) if (ui_task.isOnAlarmScreen()) { 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)) { // Add to channel screen so sent DM appears in conversation view ui_task.addSentDM(composeDMName, the_mesh.getNodePrefs()->node_name, 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