Files
Meck/examples/companion_radio/main.cpp

3763 lines
131 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <Arduino.h> // needed for PlatformIO
#ifdef BLE_PIN_CODE
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
#endif
#ifdef MECK_OTA_UPDATE
#include <esp_ota_ops.h>
#endif
#include <Mesh.h>
#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 <SD.h>
#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 <SD.h>
#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 500
#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 <InternalFileSystem.h>
#if defined(QSPIFLASH)
#include <CustomLFS_QSPIFlash.h>
DataStore store(InternalFS, QSPIFlash, rtc_clock);
#else
#if defined(EXTRAFS)
#include <CustomLFS.h>
CustomLFS ExtraFS(0xD4000, 0x19000, 128);
DataStore store(InternalFS, ExtraFS, rtc_clock);
#else
DataStore store(InternalFS, rtc_clock);
#endif
#endif
#elif defined(RP2040_PLATFORM)
#include <LittleFS.h>
DataStore store(LittleFS, rtc_clock);
#elif defined(ESP32)
#include <SPIFFS.h>
DataStore store(SPIFFS, rtc_clock);
#endif
#ifdef ESP32
#ifdef WIFI_SSID
#include <helpers/esp32/SerialWifiInterface.h>
SerialWifiInterface serial_interface;
#ifndef TCP_PORT
#define TCP_PORT 5000
#endif
#elif defined(MECK_WIFI_COMPANION)
#include <WiFi.h>
#include <helpers/esp32/SerialWifiInterface.h>
SerialWifiInterface serial_interface;
#ifndef TCP_PORT
#define TCP_PORT 5000
#endif
#elif defined(BLE_PIN_CODE)
#include <helpers/esp32/SerialBLEInterface.h>
SerialBLEInterface serial_interface;
#elif defined(SERIAL_RX)
#include <helpers/ArduinoSerialInterface.h>
ArduinoSerialInterface serial_interface;
HardwareSerial companion_serial(1);
#else
#include <helpers/ArduinoSerialInterface.h>
ArduinoSerialInterface serial_interface;
#endif
#elif defined(RP2040_PLATFORM)
//#ifdef WIFI_SSID
// #include <helpers/rp2040/SerialWifiInterface.h>
// SerialWifiInterface serial_interface;
// #ifndef TCP_PORT
// #define TCP_PORT 5000
// #endif
// #elif defined(BLE_PIN_CODE)
// #include <helpers/rp2040/SerialBLEInterface.h>
// SerialBLEInterface serial_interface;
#if defined(SERIAL_RX)
#include <helpers/ArduinoSerialInterface.h>
ArduinoSerialInterface serial_interface;
HardwareSerial companion_serial(1);
#else
#include <helpers/ArduinoSerialInterface.h>
ArduinoSerialInterface serial_interface;
#endif
#elif defined(NRF52_PLATFORM)
#ifdef BLE_PIN_CODE
#include <helpers/nrf52/SerialBLEInterface.h>
SerialBLEInterface serial_interface;
#else
#include <helpers/ArduinoSerialInterface.h>
ArduinoSerialInterface serial_interface;
#endif
#elif defined(STM32_PLATFORM)
#include <helpers/ArduinoSerialInterface.h>
ArduinoSerialInterface serial_interface;
#else
#error "need to define a serial interface"
#endif
/* GLOBAL OBJECTS */
#ifdef DISPLAY_CLASS
#include "UITask.h"
#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);
// --- 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
}
// 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);
// 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) {
// 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 <epoch>" 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 <epoch>' 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();
}
}
#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
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 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
// 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 > 500ms 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
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;
Serial.printf("handleKeyboardInput: key='%c' (0x%02X) composeMode=%d\n",
key >= 32 ? key : '?', key, composeMode);
if (composeMode) {
// Emoji picker sub-mode
if (emojiPickerMode) {
uint8_t result = emojiPicker.handleInput(key);
if (result == 0xFF) {
// Cancelled - immediate draw to return to compose
emojiPickerMode = false;
drawComposeScreen();
lastComposeRefresh = millis();
composeNeedsRefresh = false;
} else if (result >= EMOJI_ESCAPE_START && result <= EMOJI_ESCAPE_END) {
// Emoji selected - insert escape byte + padding to match UTF-8 wire cost
int cost = emojiUtf8Cost(result);
if (composePos + cost <= 137) {
composeBuffer[composePos++] = (char)result;
for (int p = 1; p < cost; p++) {
composeBuffer[composePos++] = (char)EMOJI_PAD_BYTE;
}
composeBuffer[composePos] = '\0';
Serial.printf("Compose: Inserted emoji 0x%02X cost=%d, pos=%d\n", result, cost, composePos);
}
emojiPickerMode = false;
drawComposeScreen();
lastComposeRefresh = millis();
composeNeedsRefresh = false;
} else {
// Navigation - debounce (don't draw immediately, let loop handle it)
composeNeedsRefresh = true;
}
return;
}
// In compose mode - handle text input
if (key == '\r') {
// Enter - send the message
Serial.println("Compose: Enter pressed, sending...");
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
if (composeChannelIdx > 0) {
composeChannelIdx--;
} else {
// Wrap to last valid channel
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
composeChannelIdx = i;
break;
}
}
}
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
composeNeedsRefresh = true; // Debounced refresh
return;
}
if ((key == 'd') && composePos == 0 && !composeDM) {
// Next channel
ChannelDetails ch;
uint8_t nextIdx = composeChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
composeChannelIdx = nextIdx;
} else {
composeChannelIdx = 0; // Wrap to first channel
}
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
composeNeedsRefresh = true; // Debounced refresh
return;
}
// '$' key (without Sym) opens emoji picker
if (key == KB_KEY_EMOJI) {
emojiPicker.reset();
emojiPickerMode = true;
drawEmojiPicker();
lastComposeRefresh = millis();
composeNeedsRefresh = false;
return;
}
// Regular character input
if (key >= 32 && key < 127 && composePos < 137) {
composeBuffer[composePos++] = key;
composeBuffer[composePos] = '\0';
Serial.printf("Compose: Added '%c', pos=%d\n", key, composePos);
composeNeedsRefresh = true; // Use debounced refresh
}
return;
}
// *** AUDIOBOOK MODE ***
#ifndef HAS_4G_MODEM
if (audiobookMode) {
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
// Q key: behavior depends on playback state
// - Playing: navigate home, audio continues in background
// - Paused/stopped: close book, return to file list
// - File list: exit player entirely
if (key == 'q') {
if (abPlayer->isBookOpen()) {
if (abPlayer->isAudioActive()) {
// Audio is playing — leave screen, audio continues via audioTick()
Serial.println("Leaving audiobook player (audio continues in background)");
ui_task.gotoHomeScreen();
} else {
// Paused or stopped — close book, show file list
abPlayer->closeCurrentBook();
Serial.println("Closed audiobook (was paused/stopped)");
// Stay on audiobook screen showing file list
}
} else {
abPlayer->exitPlayer();
Serial.println("Exiting audiobook player");
ui_task.gotoHomeScreen();
}
return;
}
// All other keys pass through to the player screen
ui_task.injectKey(key);
return;
}
#endif // !HAS_4G_MODEM
// *** TEXT READER MODE ***
if (readerMode) {
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
// Q key: if reading, reader handles it (close book -> file list)
// if on file list, exit reader entirely
if (key == 'q') {
if (reader->isReading()) {
// Let the reader handle Q (close book, go to file list)
ui_task.injectKey('q');
} else {
// On file list - exit reader, go home
reader->exitReader();
Serial.println("Exiting text reader");
ui_task.gotoHomeScreen();
}
return;
}
// All other keys pass through to the reader screen
ui_task.injectKey(key);
return;
}
// *** NOTES MODE ***
if (notesMode) {
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
// ---- EDITING MODE ----
if (notes->isEditing()) {
// Shift+Backspace = save and exit
if (key == '\b') {
if (keyboard.wasShiftConsumed()) {
Serial.println("Notes: Shift+Backspace, saving...");
notes->saveAndExit();
ui_task.forceRefresh();
return;
}
// Regular backspace - delete before cursor
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
// Cursor navigation via Shift+WASD (produces uppercase)
if (key == 'W') { notes->moveCursorUp(); composeNeedsRefresh = true; return; }
if (key == 'A') { notes->moveCursorLeft(); composeNeedsRefresh = true; return; }
if (key == 'S') { notes->moveCursorDown(); composeNeedsRefresh = true; return; }
if (key == 'D') { notes->moveCursorRight(); composeNeedsRefresh = true; return; }
// Q when buffer is empty or unchanged = exit (nothing to lose)
if (key == 'q' && (notes->isEmpty() || !notes->isDirty())) {
Serial.println("Notes: Q exit (nothing to save)");
notes->discardAndExit();
ui_task.forceRefresh();
return;
}
// Enter = newline (pass through with debounce)
if (key == '\r') {
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
// All other printable chars (lowercase only - uppercase consumed by cursor nav)
if (key >= 32 && key < 127) {
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
return;
}
// ---- RENAMING MODE ----
if (notes->isRenaming()) {
// All input goes to rename handler (debounced like editing)
ui_task.injectKey(key);
composeNeedsRefresh = true;
if (!notes->isRenaming()) {
// Exited rename mode (confirmed or cancelled)
ui_task.forceRefresh();
}
return;
}
// ---- DELETE CONFIRMATION MODE ----
if (notes->isConfirmingDelete()) {
ui_task.injectKey(key);
if (!notes->isConfirmingDelete()) {
// Exited confirm mode
ui_task.forceRefresh();
}
return;
}
// ---- FILE LIST MODE ----
if (notes->isInFileList()) {
if (key == 'q') {
notes->exitNotes();
Serial.println("Exiting notes");
ui_task.gotoHomeScreen();
return;
}
// Shift+Backspace on a file = delete with confirmation
if (key == '\b' && keyboard.wasShiftConsumed()) {
if (notes->startDeleteFromList()) {
ui_task.forceRefresh();
}
return;
}
// R on a file = rename
if (key == 'r') {
if (notes->startRename()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis() - COMPOSE_REFRESH_INTERVAL; // Trigger on next loop iteration
}
return;
}
// Normal keys pass through
ui_task.injectKey(key);
// Check if we just entered editing mode (new note via Enter)
if (notes->isEditing()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
}
return;
}
// ---- READING MODE ----
if (notes->isReading()) {
if (key == 'q') {
ui_task.injectKey('q');
return;
}
// Shift+Backspace = delete note
if (key == '\b' && keyboard.wasShiftConsumed()) {
Serial.println("Notes: Deleting current note");
notes->deleteCurrentNote();
ui_task.forceRefresh();
return;
}
// All other keys (Enter for edit, W/S for page nav)
ui_task.injectKey(key);
if (notes->isEditing()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
}
return;
}
return;
}
// *** SETTINGS MODE ***
if (ui_task.isOnSettingsScreen()) {
SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen();
// Q key: exit settings (when not editing)
if (!settings->isEditing() && (key == 'q')) {
if (settings->hasRadioChanges()) {
// Let settings show "apply changes?" confirm dialog
ui_task.injectKey(key);
} else {
Serial.println("Exiting settings");
ui_task.gotoHomeScreen();
}
return;
}
// All other keys → settings screen via injectKey
ui_task.injectKey(key);
return;
}
// *** REPEATER ADMIN MODE ***
if (ui_task.isOnRepeaterAdmin()) {
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
RepeaterAdminScreen::AdminState astate = admin->getState();
bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed());
// 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);
abScreen->setSDReady(sdCardReady);
ui_task.setAudiobookScreen(abScreen);
Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap());
}
ui_task.gotoAudiobookPlayer();
break;
#endif
#ifdef HAS_4G_MODEM
case 't':
// Open SMS (4G variant only)
Serial.println("Opening SMS");
ui_task.gotoSMSScreen();
break;
#endif
#ifdef MECK_WEB_READER
case 'b':
// Open web reader (browser)
Serial.println("Opening web reader");
{
static bool webReaderWifiReady = false;
#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()
) {
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()
) {
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()) {
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()) {
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;
}
// 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;
}
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