mirror of
https://github.com/pelgraine/Meck.git
synced 2026-10-31 04:02:01 +01:00
Release note
Combined Bluetooth + WiFi build (meck_max_ble_wifi): switching between Bluetooth and WiFi now restarts the device each time, because Bluetooth only frees its memory on a restart and WiFi cannot start without it. Whichever you turn on first after a restart works straight away; turning on the other one shows a restart hint, asks you to press Enter again, restarts, and turns the chosen connection on by itself. From the companion app, set wifi.enabled now takes effect at the app's reboot.
Why
Bluetooth off keeps the Bluetooth stack's memory until a restart; WiFi off frees WiFi's driver. On device, WiFi on/off, Bluetooth on/off, then WiFi on failed ("failed to create task", largest free block 7668 bytes) and the app could not connect over Bluetooth until a restart.
Changes (combined build only, except README)
- main.cpp: tracks which connection has been on since this restart; a switch then needs a restart (also Bluetooth after the Web Reader freed it).
- main.cpp: "Switch to WiFi?" / "Switch to Bluetooth?" popup with "Enter again to restart"; a second press within 5 s shows "Restarting..." and restarts, saving pending contacts first (new MyMesh::saveContactsIfDirty(), as CMD_REBOOT does).
- main.cpp: the choice survives the restart in RTC memory and the connection turns on once the home screen shows. Normal restart or power-on: both off.
- main.cpp: app "set wifi.enabled" keeps the choice for the app's reboot instead of switching after 1 s. Reply text unchanged.
- UITask.cpp: restart hints ("restart into Bluetooth:" / "Press Enter to Restart into Wifi") only when a restart is needed; Enter or long press uses the confirmation.
- SettingsScreen.h: WiFi Radio and WiFi setup rows use the same confirmation.
- French added for all new text.
- README.md: new "Combined Bluetooth + WiFi build (T-Deck Max)" section.
Testing: PC harness on the real new logic: both directions, late and timely confirm, start-up pickup after the splash, app wifi.enabled 1/0, Web Reader case, normal restart and power-on leftovers. Full firmware build not run.
5961 lines
222 KiB
C++
5961 lines
222 KiB
C++
#include <Arduino.h> // needed for PlatformIO
|
||
#if defined(ESP32) && defined(BLE_PIN_CODE)
|
||
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
|
||
#endif
|
||
#if defined(ESP32) && defined(MECK_OTA_UPDATE)
|
||
#include <esp_ota_ops.h>
|
||
#endif
|
||
#ifdef ESP32
|
||
#include <esp_partition.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 "ChannelPickerScreen.h"
|
||
#include "MeckExport.h"
|
||
#include "MeckImport.h"
|
||
#include "SettingsScreen.h"
|
||
#include "RepeaterAdminScreen.h"
|
||
#include "DiscoveryScreen.h"
|
||
#include "LastHeardScreen.h"
|
||
#include "PathEditorScreen.h"
|
||
#include "Tracescreen.h"
|
||
#include "GamesMenuScreen.h"
|
||
#include "SnakeScreen.h"
|
||
#include "MinesweeperScreen.h"
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
#include "GBCEmulatorScreen.h"
|
||
#endif
|
||
#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 unsigned long lastComposeKeystroke = 0;
|
||
static bool composeNeedsRefresh = false;
|
||
#define COMPOSE_REFRESH_INTERVAL 0 // ms -- lastComposeRefresh is stamped AFTER drawComposeScreen()
|
||
// returns, i.e. after the ~650 ms e-ink refresh has already
|
||
// completed, so this interval only ever added dead time on top
|
||
// of the refresh (was 700, on the belief it had to cover it)
|
||
#define COMPOSE_TYPING_PAUSE 250 // ms — wait this long after last keystroke before refreshing
|
||
|
||
// 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).
|
||
#if !defined(HAS_4G_MODEM) || defined(MECK_AUDIO_VARIANT)
|
||
#include "AudiobookPlayerScreen.h"
|
||
#include "Audio.h"
|
||
Audio* audio = nullptr;
|
||
#endif
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
#include "VoiceMessageScreen.h"
|
||
#include "BundledSounds.h"
|
||
#ifdef HAS_ES8311_AUDIO
|
||
#include "ES8311.h" // MAX: native ES8311 codec init (Arduino Wire)
|
||
#endif
|
||
#endif
|
||
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
|
||
#include "NotifSounds.h"
|
||
NotifSounds notifSounds; // Global singleton for per-channel notification tones
|
||
#endif
|
||
static bool audiobookMode = false;
|
||
static bool voiceMode = 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);
|
||
#if defined(LilyGo_TDeck_Pro_Max) || defined(MECK_PRO_HYN_TOUCH)
|
||
// T-Deck Pro MAX uses the vendored Hynitron driver (HynTouch) for the
|
||
// CST328 instead of TouchInput. Its reset line is XL9555 P07; route the
|
||
// driver's virtual-GPIO writes/reads through the board's XL9555 access,
|
||
// mirroring the LilyGo factory driver (driver self-resets during init).
|
||
// MECK_PRO_HYN_TOUCH (T-Deck Pro v1.1 with the CST3530): same driver,
|
||
// but reset is native GPIO 38 -- no XL9555, so no virtual-GPIO callbacks.
|
||
#include "HynTouch.h"
|
||
#include "HynTouchBoard.h"
|
||
#if defined(LilyGo_TDeck_Pro_Max)
|
||
static bool meckHynXl9555Write(uint32_t gpio_id, bool value, void* /*user_data*/) {
|
||
if (!XL9555_GPIO_IS((int)gpio_id)) return false;
|
||
board.xl9555_digitalWrite(XL9555_GPIO_TO_PIN(gpio_id), value);
|
||
return true;
|
||
}
|
||
static bool meckHynXl9555Read(uint32_t gpio_id, int* out_value, void* /*user_data*/) {
|
||
if (!out_value || !XL9555_GPIO_IS((int)gpio_id)) return false;
|
||
*out_value = board.xl9555_digitalRead(XL9555_GPIO_TO_PIN(gpio_id)) ? 1 : 0;
|
||
return true;
|
||
}
|
||
#endif
|
||
#endif
|
||
#endif
|
||
|
||
void initKeyboard();
|
||
void handleKeyboardInput();
|
||
void drawComposeScreen();
|
||
void drawEmojiPicker();
|
||
void sendComposedMessage();
|
||
|
||
// SD-backed persistence state
|
||
static bool sdCardReady = false;
|
||
|
||
// Contacts screen: deferred Enter for long-press detection
|
||
static bool contactsEnterPending = false;
|
||
static unsigned long contactsEnterTime = 0;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 are on SPIFFS on ESP32
|
||
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;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// JSON export: write selected contacts (by raw index) to SD card.
|
||
// Compatible with MeshCore companion app's meshcore_contacts.json format.
|
||
// If indices is NULL, exports ALL contacts.
|
||
// Returns number of contacts exported, or -1 on error.
|
||
// -----------------------------------------------------------------------
|
||
int exportContactsJSON(const uint16_t* indices, int numIndices) {
|
||
if (!sdCardReady) {
|
||
Serial.println("JSON Export: SD card not ready");
|
||
return -1;
|
||
}
|
||
|
||
if (!SD.exists("/meshcore")) SD.mkdir("/meshcore");
|
||
|
||
// Build timestamped filename: meshcore_contacts_YYYYMMDD_HHMM.json
|
||
char jsonPath[64];
|
||
uint32_t epoch = rtc_clock.getCurrentTime();
|
||
int8_t utcOff = the_mesh.getNodePrefs()->utc_offset_hours;
|
||
time_t localEpoch = (time_t)epoch + (utcOff * 3600);
|
||
struct tm tmBuf;
|
||
gmtime_r(&localEpoch, &tmBuf);
|
||
snprintf(jsonPath, sizeof(jsonPath),
|
||
"/meshcore/meshcore_contacts_%04d%02d%02d_%02d%02d.json",
|
||
tmBuf.tm_year + 1900, tmBuf.tm_mon + 1, tmBuf.tm_mday,
|
||
tmBuf.tm_hour, tmBuf.tm_min);
|
||
|
||
File f = SD.open(jsonPath, "w", true);
|
||
if (!f) {
|
||
Serial.printf("JSON Export: failed to open %s\n", jsonPath);
|
||
digitalWrite(SDCARD_CS, HIGH);
|
||
return -1;
|
||
}
|
||
|
||
f.print("{\n \"contacts\": [\n");
|
||
|
||
int written = 0;
|
||
uint32_t total = the_mesh.getNumContacts();
|
||
|
||
// When indices is NULL, export all contacts (scan = rawIdx).
|
||
// When indices is provided, scan iterates over the indices array.
|
||
int loopCount = (indices != NULL) ? numIndices : (int)total;
|
||
|
||
for (int scan = 0; scan < loopCount; scan++) {
|
||
int rawIdx = (indices != NULL) ? indices[scan] : scan;
|
||
|
||
ContactInfo c;
|
||
if (!the_mesh.getContactByIdx(rawIdx, c)) continue;
|
||
|
||
if (written > 0) f.print(",\n");
|
||
|
||
// Public key -> 64-char hex string
|
||
char hexKey[65];
|
||
mesh::Utils::toHex(hexKey, c.id.pub_key, PUB_KEY_SIZE);
|
||
|
||
// GPS: int32 -> decimal string (6 decimal places)
|
||
char latStr[16], lonStr[16];
|
||
snprintf(latStr, sizeof(latStr), "%.6f", (double)c.gps_lat / 1000000.0);
|
||
snprintf(lonStr, sizeof(lonStr), "%.6f", (double)c.gps_lon / 1000000.0);
|
||
|
||
// Escape name for JSON (backslash and double-quote)
|
||
char safeName[80];
|
||
int si = 0;
|
||
for (int ni = 0; c.name[ni] && si < (int)sizeof(safeName) - 2; ni++) {
|
||
if (c.name[ni] == '"' || c.name[ni] == '\\') safeName[si++] = '\\';
|
||
safeName[si++] = c.name[ni];
|
||
}
|
||
safeName[si] = '\0';
|
||
|
||
f.printf(" {\n");
|
||
f.printf(" \"type\": %d,\n", c.type);
|
||
f.printf(" \"name\": \"%s\",\n", safeName);
|
||
f.printf(" \"custom_name\": null,\n");
|
||
f.printf(" \"public_key\": \"%s\",\n", hexKey);
|
||
f.printf(" \"flags\": %d,\n", c.flags);
|
||
f.printf(" \"latitude\": \"%s\",\n", latStr);
|
||
f.printf(" \"longitude\": \"%s\",\n", lonStr);
|
||
f.printf(" \"last_advert\": %lu,\n", (unsigned long)c.last_advert_timestamp);
|
||
f.printf(" \"last_modified\": %lu,\n", (unsigned long)c.lastmod);
|
||
f.printf(" \"out_path_list\": null\n");
|
||
f.printf(" }");
|
||
|
||
written++;
|
||
}
|
||
|
||
f.print("\n ]\n}\n");
|
||
f.close();
|
||
digitalWrite(SDCARD_CS, HIGH);
|
||
|
||
Serial.printf("JSON Export: %d contacts to %s\n", written, jsonPath);
|
||
return written;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// JSON import: merge contacts from newest meshcore_contacts*.json on SD.
|
||
// Scans /meshcore/ for the most recent file matching the pattern.
|
||
// Compatible with MeshCore companion app export format.
|
||
// Skips contacts already present (by pub_key match).
|
||
// Returns number of NEW contacts added, or -1 on error.
|
||
// -----------------------------------------------------------------------
|
||
int importContactsJSON() {
|
||
if (!sdCardReady) return -1;
|
||
if (!SD.exists("/meshcore")) return -1;
|
||
|
||
// Scan /meshcore/ for the newest meshcore_contacts*.json file.
|
||
// Timestamped filenames (YYYYMMDD_HHMM) sort lexicographically by recency.
|
||
// Also matches the plain "meshcore_contacts.json" from the MeshCore app.
|
||
char bestPath[64] = {0};
|
||
char bestName[48] = {0};
|
||
|
||
File dir = SD.open("/meshcore");
|
||
if (!dir || !dir.isDirectory()) return -1;
|
||
|
||
File entry = dir.openNextFile();
|
||
while (entry) {
|
||
if (!entry.isDirectory()) {
|
||
const char* name = entry.name();
|
||
// SD library may return full path or bare filename — strip to bare name
|
||
const char* slash = strrchr(name, '/');
|
||
if (slash) name = slash + 1;
|
||
// Match: starts with "meshcore_contacts" and ends with ".json"
|
||
if (strncmp(name, "meshcore_contacts", 17) == 0) {
|
||
int nlen = strlen(name);
|
||
if (nlen > 5 && strcmp(name + nlen - 5, ".json") == 0) {
|
||
// Keep the lexicographically greatest filename (= newest timestamp)
|
||
if (bestName[0] == '\0' || strcmp(name, bestName) > 0) {
|
||
strncpy(bestName, name, sizeof(bestName) - 1);
|
||
snprintf(bestPath, sizeof(bestPath), "/meshcore/%s", name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
entry = dir.openNextFile();
|
||
}
|
||
dir.close();
|
||
|
||
if (bestPath[0] == '\0') {
|
||
Serial.println("JSON Import: no meshcore_contacts*.json found");
|
||
return -1;
|
||
}
|
||
|
||
Serial.printf("JSON Import: opening %s\n", bestPath);
|
||
File f = SD.open(bestPath, "r");
|
||
if (!f) return -1;
|
||
|
||
int added = 0, skipped = 0;
|
||
|
||
// Simple line-by-line parser — no ArduinoJson dependency.
|
||
ContactInfo c;
|
||
bool inContact = false;
|
||
uint8_t pubkey[PUB_KEY_SIZE];
|
||
bool gotPubkey = false;
|
||
bool gotType = false;
|
||
char lineBuf[256];
|
||
|
||
while (f.available()) {
|
||
int len = 0;
|
||
while (f.available() && len < (int)sizeof(lineBuf) - 1) {
|
||
char ch = f.read();
|
||
if (ch == '\n') break;
|
||
lineBuf[len++] = ch;
|
||
}
|
||
lineBuf[len] = '\0';
|
||
|
||
// Trim leading whitespace
|
||
char* line = lineBuf;
|
||
while (*line == ' ' || *line == '\t') line++;
|
||
|
||
// Detect contact block boundaries
|
||
if (strstr(line, "{") && !strstr(line, "\"contacts\"")) {
|
||
memset(&c, 0, sizeof(c));
|
||
gotPubkey = false;
|
||
gotType = false;
|
||
c.out_path_len = OUT_PATH_UNKNOWN;
|
||
c.shared_secret_valid = false;
|
||
inContact = true;
|
||
continue;
|
||
}
|
||
|
||
if (inContact && line[0] == '}') {
|
||
// End of contact object — try to import
|
||
if (gotPubkey && gotType) {
|
||
c.id = mesh::Identity(pubkey);
|
||
if (the_mesh.lookupContactByPubKey(pubkey, PUB_KEY_SIZE) != NULL) {
|
||
skipped++;
|
||
} else if (the_mesh.addContact(c)) {
|
||
added++;
|
||
} else {
|
||
Serial.printf("JSON Import: table full after %d added\n", added);
|
||
break;
|
||
}
|
||
}
|
||
inContact = false;
|
||
continue;
|
||
}
|
||
|
||
if (!inContact) continue;
|
||
|
||
// Parse key-value pairs
|
||
char* q1 = strchr(line, '"');
|
||
if (!q1) continue;
|
||
char* q2 = strchr(q1 + 1, '"');
|
||
if (!q2) continue;
|
||
*q2 = '\0';
|
||
const char* key = q1 + 1;
|
||
|
||
// Find the value after ": "
|
||
char* valStart = q2 + 1;
|
||
while (*valStart == ':' || *valStart == ' ' || *valStart == '\t') valStart++;
|
||
|
||
// Strip trailing comma and whitespace
|
||
int vlen = strlen(valStart);
|
||
while (vlen > 0 && (valStart[vlen-1] == ',' || valStart[vlen-1] == '\n' ||
|
||
valStart[vlen-1] == '\r' || valStart[vlen-1] == ' ')) {
|
||
valStart[--vlen] = '\0';
|
||
}
|
||
|
||
if (strcmp(key, "type") == 0) {
|
||
c.type = (uint8_t)atoi(valStart);
|
||
gotType = true;
|
||
}
|
||
else if (strcmp(key, "name") == 0) {
|
||
if (valStart[0] == '"') valStart++;
|
||
int slen = strlen(valStart);
|
||
if (slen > 0 && valStart[slen-1] == '"') valStart[slen-1] = '\0';
|
||
// Unescape \" and \\ in-place
|
||
int wi = 0;
|
||
for (int ri = 0; valStart[ri] && wi < 31; ri++) {
|
||
if (valStart[ri] == '\\' && valStart[ri+1]) { ri++; }
|
||
c.name[wi++] = valStart[ri];
|
||
}
|
||
c.name[wi] = '\0';
|
||
}
|
||
else if (strcmp(key, "public_key") == 0) {
|
||
if (valStart[0] == '"') valStart++;
|
||
int slen = strlen(valStart);
|
||
if (slen > 0 && valStart[slen-1] == '"') valStart[slen-1] = '\0';
|
||
if (mesh::Utils::fromHex(pubkey, PUB_KEY_SIZE, valStart)) {
|
||
gotPubkey = true;
|
||
}
|
||
}
|
||
else if (strcmp(key, "flags") == 0) {
|
||
c.flags = (uint8_t)atoi(valStart);
|
||
}
|
||
else if (strcmp(key, "latitude") == 0) {
|
||
if (valStart[0] == '"') valStart++;
|
||
int slen = strlen(valStart);
|
||
if (slen > 0 && valStart[slen-1] == '"') valStart[slen-1] = '\0';
|
||
c.gps_lat = (int32_t)(atof(valStart) * 1000000.0);
|
||
}
|
||
else if (strcmp(key, "longitude") == 0) {
|
||
if (valStart[0] == '"') valStart++;
|
||
int slen = strlen(valStart);
|
||
if (slen > 0 && valStart[slen-1] == '"') valStart[slen-1] = '\0';
|
||
c.gps_lon = (int32_t)(atof(valStart) * 1000000.0);
|
||
}
|
||
else if (strcmp(key, "last_advert") == 0) {
|
||
c.last_advert_timestamp = (uint32_t)strtoul(valStart, NULL, 10);
|
||
}
|
||
else if (strcmp(key, "last_modified") == 0) {
|
||
c.lastmod = (uint32_t)strtoul(valStart, NULL, 10);
|
||
}
|
||
// custom_name, out_path_list — ignored
|
||
}
|
||
|
||
f.close();
|
||
digitalWrite(SDCARD_CS, HIGH);
|
||
|
||
if (added > 0) {
|
||
the_mesh.saveContacts();
|
||
}
|
||
|
||
Serial.printf("JSON Import: %d added, %d skipped, %d total\n",
|
||
added, skipped, (int)the_mesh.getNumContacts());
|
||
return added;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Select mode actions — delete selected contacts, toggle favourite
|
||
// -----------------------------------------------------------------------
|
||
int deleteSelectedContacts(const uint16_t* indices, int count) {
|
||
// Delete in reverse order so indices remain valid
|
||
int deleted = 0;
|
||
for (int i = count - 1; i >= 0; i--) {
|
||
ContactInfo c;
|
||
if (the_mesh.getContactByIdx(indices[i], c)) {
|
||
if (the_mesh.removeContact(c)) {
|
||
deleted++;
|
||
}
|
||
}
|
||
}
|
||
if (deleted > 0) {
|
||
the_mesh.saveContacts();
|
||
}
|
||
Serial.printf("Deleted %d/%d selected contacts\n", deleted, count);
|
||
return deleted;
|
||
}
|
||
|
||
int toggleFavouriteSelected(const uint16_t* indices, int count) {
|
||
int toggled = 0;
|
||
for (int i = 0; i < count; i++) {
|
||
ContactInfo tmp;
|
||
if (the_mesh.getContactByIdx(indices[i], tmp)) {
|
||
ContactInfo* cp = the_mesh.lookupContactByPubKey(tmp.id.pub_key, PUB_KEY_SIZE);
|
||
if (cp) {
|
||
cp->flags ^= 0x01; // Toggle favourite bit
|
||
toggled++;
|
||
}
|
||
}
|
||
}
|
||
if (toggled > 0) {
|
||
the_mesh.saveContacts();
|
||
}
|
||
Serial.printf("Toggled favourite on %d contacts\n", toggled);
|
||
return toggled;
|
||
}
|
||
#endif
|
||
|
||
// =============================================================================
|
||
// Touch Input -- T-Deck Pro (CST328)
|
||
// =============================================================================
|
||
|
||
// Define MECK_TOUCH_ENABLED for any platform with touch support
|
||
#if defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN)
|
||
#define MECK_TOUCH_ENABLED 1
|
||
#endif
|
||
|
||
// --- T-Watch S3 Plus: screen headers for the touch UI ---
|
||
// The watch needs the same concrete screen types as the gesture machine casts
|
||
// to, but none of the T-Deck hardware baggage (SD, TCA8418 keyboard).
|
||
|
||
// --- Shared touch state machine variables ---
|
||
#ifdef MECK_TOUCH_ENABLED
|
||
static bool touchDown = false;
|
||
static unsigned long touchDownTime = 0;
|
||
static int16_t touchDownX = 0;
|
||
static int16_t touchDownY = 0;
|
||
static int16_t touchLastX = 0;
|
||
static int16_t touchLastY = 0;
|
||
static unsigned long lastTouchSeenMs = 0;
|
||
#define TOUCH_LONG_PRESS_MS 750
|
||
#define TOUCH_SWIPE_THRESHOLD 30 // T-Deck Pro: 240×320 — 30px ≈ 12.5% of width
|
||
#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_TDeck_Pro_Max) || defined(MECK_PRO_HYN_TOUCH)
|
||
{
|
||
int16_t hx[1], hy[1];
|
||
if (hyn_touch_get_point(hx, hy, 1) > 0) {
|
||
*outX = hx[0];
|
||
*outY = hy[0];
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
#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_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(STM32_PLATFORM)
|
||
#include <InternalFileSystem.h>
|
||
DataStore store(InternalFS, rtc_clock);
|
||
#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(BLE_PIN_CODE) && defined(MECK_WIFI_COMPANION)
|
||
// Combined BLE + WiFi companion (meck_max_ble_wifi test build): both
|
||
// connections built in, both off at boot, one on at a time.
|
||
#include <WiFi.h>
|
||
#include <helpers/esp32/SerialBLEInterface.h>
|
||
#undef FRAME_QUEUE_SIZE // each connection header defines its own queue size
|
||
#include <helpers/esp32/SerialWifiInterface.h>
|
||
#include "DualCompanionInterface.h"
|
||
SerialBLEInterface ble_companion;
|
||
SerialWifiInterface wifi_companion;
|
||
DualCompanionInterface serial_interface(&ble_companion, &wifi_companion);
|
||
#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(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);
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
// Lets the alarm ringing screen (which only forward-declares UITask) query
|
||
// the lock state without pulling in the full UITask header.
|
||
bool meck_alarm_is_locked() { return ui_task.isLocked(); }
|
||
#endif
|
||
#ifdef MECK_WIFI_COMPANION
|
||
// Lets the Settings WiFi setup (which only forward-declares UITask) show
|
||
// its Connecting / IP / Could not connect popups.
|
||
void meckShowAlert(const char* text, int duration_millis) { ui_task.showAlert(text, duration_millis); }
|
||
|
||
// Why the last WiFi connection attempt failed. The WiFi driver reports a
|
||
// reason code each time a connection attempt drops; it is recorded here so
|
||
// the "Could not connect" popups and the serial log can say why.
|
||
static volatile uint8_t meckWifiLastReason = 0;
|
||
static bool meckWifiReasonHooked = false;
|
||
static void meckWifiOnDisconnect(arduino_event_id_t event, arduino_event_info_t info) {
|
||
meckWifiLastReason = info.wifi_sta_disconnected.reason;
|
||
}
|
||
// Call just before WiFi.begin(): clears the last reason (and starts
|
||
// recording reasons the first time).
|
||
void meckWifiResetReason() {
|
||
if (!meckWifiReasonHooked) {
|
||
WiFi.onEvent(meckWifiOnDisconnect, ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
|
||
meckWifiReasonHooked = true;
|
||
}
|
||
meckWifiLastReason = 0;
|
||
}
|
||
// Plain-language reason for the second line of a "Could not connect" popup.
|
||
const char* meckWifiFailReason() {
|
||
static char buf[28];
|
||
uint8_t r = meckWifiLastReason;
|
||
switch (r) {
|
||
case 0:
|
||
return MECK_TR("Timed out", "D\xC3\xA9lai d\xC3\xA9pass\xC3\xA9");
|
||
case WIFI_REASON_MIC_FAILURE:
|
||
case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT:
|
||
case WIFI_REASON_802_1X_AUTH_FAILED:
|
||
case WIFI_REASON_AUTH_FAIL:
|
||
case WIFI_REASON_HANDSHAKE_TIMEOUT:
|
||
return MECK_TR("Wrong password?", "Mot de passe erron\xC3\xA9 ?");
|
||
case WIFI_REASON_NO_AP_FOUND:
|
||
return MECK_TR("Network not found", "R\xC3\xA9seau introuvable");
|
||
case WIFI_REASON_BEACON_TIMEOUT:
|
||
return MECK_TR("Signal lost", "Signal perdu");
|
||
case WIFI_REASON_ASSOC_TOOMANY:
|
||
case WIFI_REASON_ASSOC_FAIL:
|
||
case WIFI_REASON_CONNECTION_FAIL:
|
||
snprintf(buf, sizeof(buf), MECK_TR("Network refused (%u)", "Refus du r\xC3\xA9seau (%u)"), (unsigned)r);
|
||
return buf;
|
||
default:
|
||
snprintf(buf, sizeof(buf), MECK_TR("WiFi error %u", "Erreur WiFi %u"), (unsigned)r);
|
||
return buf;
|
||
}
|
||
}
|
||
// Serial line for a failed attempt, with the driver's reason code and name.
|
||
void meckWifiLogFail(const char* who) {
|
||
uint8_t r = meckWifiLastReason;
|
||
Serial.printf("%s: WiFi connection failed, reason %u (%s)\n", who, (unsigned)r,
|
||
r ? WiFi.disconnectReasonName((wifi_err_reason_t)r) : "none reported");
|
||
}
|
||
|
||
// Join the saved network without freezing the screen: a "Connecting to
|
||
// Saved Wifi:" popup now, then meckWifiConnectPoll() (main loop) shows
|
||
// "Connected" and the IP, or "Could not connect" and why, after at most
|
||
// SETTINGS_WIFI_CONNECT_MS.
|
||
static bool meckWifiConnectPending = false;
|
||
static unsigned long meckWifiConnectAt = 0;
|
||
void meckWifiConnectSaved(const char* ssid, const char* pass) {
|
||
meckWifiResetReason();
|
||
WiFi.begin(ssid, pass);
|
||
meckWifiConnectPending = true;
|
||
meckWifiConnectAt = millis();
|
||
char msg[80];
|
||
snprintf(msg, sizeof(msg), "%s\n%s...", MECK_TR("Connecting to Saved Wifi:", "Connexion au WiFi :"), ssid);
|
||
ui_task.showAlert(msg, SETTINGS_WIFI_CONNECT_MS + 2000);
|
||
}
|
||
// WiFi turned off, or WiFi setup started: no result popup for the attempt.
|
||
void meckWifiConnectCancel() { meckWifiConnectPending = false; }
|
||
void meckWifiConnectPoll() {
|
||
if (!meckWifiConnectPending) return;
|
||
if (WiFi.status() == WL_CONNECTED) {
|
||
meckWifiConnectPending = false;
|
||
IPAddress ip = WiFi.localIP();
|
||
Serial.printf("WiFi: connected to %s, IP: %s\n", WiFi.SSID().c_str(), ip.toString().c_str());
|
||
char msg[48];
|
||
snprintf(msg, sizeof(msg), MECK_TR("Connected\nIP: %d.%d.%d.%d", "Connect\xC3\xA9\nIP : %d.%d.%d.%d"),
|
||
ip[0], ip[1], ip[2], ip[3]);
|
||
ui_task.showAlert(msg, SETTINGS_WIFI_ALERT_MS);
|
||
} else if ((long)(millis() - meckWifiConnectAt) >= SETTINGS_WIFI_CONNECT_MS) {
|
||
meckWifiConnectPending = false;
|
||
meckWifiLogFail("Saved network");
|
||
char msg[64];
|
||
snprintf(msg, sizeof(msg), "%s\n%s", MECK_TR("Could not connect", "Connexion impossible"), meckWifiFailReason());
|
||
ui_task.showAlert(msg, SETTINGS_WIFI_ALERT_MS);
|
||
}
|
||
}
|
||
#endif
|
||
#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 */
|
||
|
||
// UI language for MECK_TR() (ui-new/MeckLang.h)
|
||
uint8_t meckUiLang() { return the_mesh.getNodePrefs()->ui_lang; }
|
||
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
// ---------------------------------------------------------------------------
|
||
// Settings > Experimental Features > Delete all contacts (ported from
|
||
// Meck-P4 v0.7.4). Empties the contact table and saves the empty set, and
|
||
// removes the direct-message history from the SD message store (channel
|
||
// messages are kept). SettingsScreen restarts the device afterwards.
|
||
// ---------------------------------------------------------------------------
|
||
void meckPurgeAllContacts(int* contactsRemoved, bool* contactsOk, int* dmsRemoved) {
|
||
*contactsRemoved = the_mesh.getNumContacts();
|
||
the_mesh.purgeAllContacts();
|
||
File f = SPIFFS.open("/contacts3", "r");
|
||
*contactsOk = f && f.size() == 0;
|
||
if (f) f.close();
|
||
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||
*dmsRemoved = chScr ? chScr->purgeDMs() : -1;
|
||
Serial.printf("Purge: %d contacts removed, contacts file %s, DMs removed %d\n",
|
||
*contactsRemoved, *contactsOk ? "empty" : "NOT EMPTY", *dmsRemoved);
|
||
}
|
||
#endif
|
||
|
||
#if defined(BLE_PIN_CODE) && defined(MECK_WIFI_COMPANION)
|
||
// ---------------------------------------------------------------------------
|
||
// Combined BLE + WiFi companion (meck_max_ble_wifi test build)
|
||
// Both connections are off at boot; turning one on turns the other off.
|
||
// Free memory is logged on serial ("MEM ...") at boot, after each switch and
|
||
// when the Web Reader starts, for the feasibility measurements.
|
||
// ---------------------------------------------------------------------------
|
||
static bool meckBtReleased = false; // Web Reader has freed Bluetooth's memory
|
||
static bool meckWifiServerStarted = false; // app server (TCP_PORT) started once
|
||
|
||
// Switching between Bluetooth and WiFi needs a restart: Bluetooth only gives
|
||
// its memory back on restart, and WiFi cannot start without it. Whichever is
|
||
// turned on first after a restart works directly; turning on the other one
|
||
// asks to restart, and the chosen connection then starts by itself. The
|
||
// choice survives the restart in RTC memory (kept across a restart, lost at
|
||
// power-off), so a normal start-up still has both off.
|
||
static bool meckBleUsed = false; // Bluetooth has been on since this restart
|
||
static bool meckWifiUsed = false; // WiFi companion has been on since this restart
|
||
#define MECK_SWITCH_MAGIC 0x4D45434BUL // "MECK": meckSwitchTarget is valid
|
||
#define MECK_SWITCH_BLE 1
|
||
#define MECK_SWITCH_WIFI 2
|
||
RTC_NOINIT_ATTR uint32_t meckSwitchMagic;
|
||
RTC_NOINIT_ATTR uint8_t meckSwitchTarget; // MECK_SWITCH_BLE / MECK_SWITCH_WIFI
|
||
static uint8_t meckBootStart = 0; // target read at start-up; started once home shows
|
||
static uint8_t meckConfirmTarget = 0; // first press of a switch, awaiting the second
|
||
static unsigned long meckConfirmUntil = 0;
|
||
static unsigned long meckRestartAt = 0; // restart once the "Restarting..." popup is shown
|
||
|
||
static void meckLogMemory(const char* when) {
|
||
Serial.printf("MEM %s: heap free=%u largest=%u, PSRAM free=%u\n", when,
|
||
(unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap(),
|
||
(unsigned)ESP.getFreePsram());
|
||
}
|
||
|
||
bool meckCompanionIsBLE() {
|
||
return serial_interface.getMode() == DualCompanionInterface::MODE_BLE;
|
||
}
|
||
|
||
bool meckCompanionIsWiFi() {
|
||
return serial_interface.getMode() == DualCompanionInterface::MODE_WIFI;
|
||
}
|
||
|
||
// Read /web/wifi.cfg (line 1 network name, line 2 password), as Settings
|
||
// writes it. Values are cut to upstream's sizes (32 and 63 characters).
|
||
static bool meckWifiCfgRead(char* ssid, size_t ssidLen, char* pass, size_t passLen) {
|
||
ssid[0] = 0;
|
||
pass[0] = 0;
|
||
if (!sdCardReady) return false;
|
||
if (!SD.exists("/web/wifi.cfg")) {
|
||
digitalWrite(SDCARD_CS, HIGH);
|
||
return false;
|
||
}
|
||
File f = SD.open("/web/wifi.cfg", FILE_READ);
|
||
if (!f) {
|
||
digitalWrite(SDCARD_CS, HIGH);
|
||
return false;
|
||
}
|
||
String s = f.readStringUntil('\n'); s.trim();
|
||
String p = f.readStringUntil('\n'); p.trim();
|
||
f.close();
|
||
digitalWrite(SDCARD_CS, HIGH);
|
||
snprintf(ssid, ssidLen, "%s", s.c_str());
|
||
snprintf(pass, passLen, "%s", p.c_str());
|
||
return true;
|
||
}
|
||
|
||
// Write /web/wifi.cfg the same way the Settings WiFi setup does.
|
||
static bool meckWifiCfgWrite(const char* ssid, const char* pass) {
|
||
if (!sdCardReady) return false;
|
||
bool ok = false;
|
||
if (SD.exists("/web") || SD.mkdir("/web")) {
|
||
File f = SD.open("/web/wifi.cfg", FILE_WRITE);
|
||
if (f) {
|
||
f.println(ssid);
|
||
f.println(pass);
|
||
f.close();
|
||
ok = true;
|
||
}
|
||
}
|
||
digitalWrite(SDCARD_CS, HIGH);
|
||
return ok;
|
||
}
|
||
|
||
static void meckWifiOff() {
|
||
meckWifiConnectCancel();
|
||
wifi_companion.disable();
|
||
WiFi.disconnect(true);
|
||
WiFi.mode(WIFI_OFF);
|
||
}
|
||
|
||
void meckCompanionUseNone() {
|
||
if (meckCompanionIsBLE()) ble_companion.disable();
|
||
if (meckCompanionIsWiFi()) meckWifiOff();
|
||
serial_interface.setMode(DualCompanionInterface::MODE_NONE);
|
||
Serial.println("Companion: Bluetooth and WiFi off");
|
||
meckLogMemory("both off");
|
||
}
|
||
|
||
// Bluetooth on, WiFi off. Fails once the Web Reader has freed Bluetooth's
|
||
// memory: as in the BLE build, Bluetooth then needs a reboot.
|
||
bool meckCompanionUseBLE() {
|
||
if (meckCompanionIsBLE()) return true;
|
||
if (meckBtReleased) {
|
||
Serial.println("Companion: Bluetooth unavailable until reboot (freed for the Web Reader)");
|
||
return false;
|
||
}
|
||
if (meckCompanionIsWiFi()) meckWifiOff();
|
||
serial_interface.setMode(DualCompanionInterface::MODE_BLE);
|
||
ble_companion.enable();
|
||
meckBleUsed = true;
|
||
Serial.println("Companion: Bluetooth on");
|
||
meckLogMemory("Bluetooth on");
|
||
return true;
|
||
}
|
||
|
||
// WiFi on, Bluetooth off. connectSaved joins the network in /web/wifi.cfg
|
||
// without waiting (meckWifiConnectSaved shows the Connecting and result
|
||
// popups); WiFi setup passes false because it scans and joins by itself.
|
||
bool meckCompanionUseWiFi(bool connectSaved) {
|
||
if (meckCompanionIsWiFi()) return true;
|
||
if (meckCompanionIsBLE()) ble_companion.disable();
|
||
serial_interface.setMode(DualCompanionInterface::MODE_NONE);
|
||
meckLogMemory("before WiFi on");
|
||
if (!WiFi.mode(WIFI_STA)) {
|
||
Serial.println("Companion: WiFi STA init FAILED");
|
||
WiFi.mode(WIFI_OFF);
|
||
meckLogMemory("WiFi init failed");
|
||
return false;
|
||
}
|
||
meckWifiUsed = true;
|
||
if (!meckWifiServerStarted) {
|
||
wifi_companion.begin(TCP_PORT);
|
||
meckWifiServerStarted = true;
|
||
}
|
||
serial_interface.setMode(DualCompanionInterface::MODE_WIFI);
|
||
wifi_companion.enable();
|
||
if (connectSaved) {
|
||
char ssid[33], pass[64];
|
||
if (meckWifiCfgRead(ssid, sizeof(ssid), pass, sizeof(pass)) && ssid[0]) {
|
||
Serial.printf("Companion: WiFi on, connecting to saved network %s\n", ssid);
|
||
meckWifiConnectSaved(ssid, pass);
|
||
} else {
|
||
Serial.println("Companion: WiFi on, no saved network (set one in Settings)");
|
||
}
|
||
} else {
|
||
Serial.println("Companion: WiFi on");
|
||
}
|
||
meckLogMemory("WiFi on");
|
||
return true;
|
||
}
|
||
|
||
// App command line (message 66): upstream MeshCore's wifi.* commands, with
|
||
// the network kept in /web/wifi.cfg as Settings keeps it. Replies are worded
|
||
// as upstream's. Returns false for any other command (the caller then
|
||
// replies "Unknown command", as upstream does).
|
||
bool meckCompanionCommand(const char* command, char* reply) {
|
||
char ssid[33], pass[64]; // upstream's wifi_ssid / wifi_pwd sizes
|
||
if (memcmp(command, "set wifi.ssid ", 14) == 0) {
|
||
meckWifiCfgRead(ssid, sizeof(ssid), pass, sizeof(pass));
|
||
snprintf(ssid, sizeof(ssid), "%s", &command[14]);
|
||
if (!meckWifiCfgWrite(ssid, pass)) {
|
||
strcpy(reply, "Error, can't save to SD card");
|
||
return true;
|
||
}
|
||
sprintf(reply, "> wifi.ssid is now %s (set wifi.pwd too, then reboot)", ssid);
|
||
return true;
|
||
}
|
||
if (memcmp(command, "set wifi.pwd ", 13) == 0) {
|
||
meckWifiCfgRead(ssid, sizeof(ssid), pass, sizeof(pass));
|
||
snprintf(pass, sizeof(pass), "%s", &command[13]);
|
||
if (!meckWifiCfgWrite(ssid, pass)) {
|
||
strcpy(reply, "Error, can't save to SD card");
|
||
return true;
|
||
}
|
||
strcpy(reply, "> wifi.pwd updated (reboot to apply)");
|
||
return true;
|
||
}
|
||
if (strcmp(command, "get wifi.pwd") == 0) {
|
||
meckWifiCfgRead(ssid, sizeof(ssid), pass, sizeof(pass));
|
||
sprintf(reply, "> %s", pass);
|
||
return true;
|
||
}
|
||
if (strcmp(command, "set wifi.clear") == 0) {
|
||
if (!meckWifiCfgWrite("", "")) {
|
||
strcpy(reply, "Error, can't save to SD card");
|
||
return true;
|
||
}
|
||
strcpy(reply, "> wifi config cleared (reboot to apply)");
|
||
return true;
|
||
}
|
||
if (strcmp(command, "get wifi.ssid") == 0) {
|
||
meckWifiCfgRead(ssid, sizeof(ssid), pass, sizeof(pass));
|
||
sprintf(reply, "> %s", ssid[0] ? ssid : "(not set)");
|
||
return true;
|
||
}
|
||
if (memcmp(command, "set wifi.enabled ", 17) == 0) {
|
||
int en = atoi(&command[17]) ? 1 : 0;
|
||
// Switching connection needs a restart in this build, so the choice is
|
||
// kept for the next restart (as upstream applies it at boot): the app's
|
||
// reboot command then brings the device up on WiFi (1) or Bluetooth (0).
|
||
if (en == 1 && !meckCompanionIsWiFi()) {
|
||
meckSwitchTarget = MECK_SWITCH_WIFI;
|
||
meckSwitchMagic = MECK_SWITCH_MAGIC;
|
||
} else if (en == 0 && meckCompanionIsWiFi()) {
|
||
meckSwitchTarget = MECK_SWITCH_BLE;
|
||
meckSwitchMagic = MECK_SWITCH_MAGIC;
|
||
}
|
||
sprintf(reply, "> wifi.enabled is now %d (reboot to apply)", en);
|
||
return true;
|
||
}
|
||
if (strcmp(command, "get wifi.enabled") == 0) {
|
||
sprintf(reply, "> %d", meckCompanionIsWiFi() ? 1 : 0);
|
||
return true;
|
||
}
|
||
if (strcmp(command, "get wifi.status") == 0) {
|
||
strcpy(reply, WiFi.status() == WL_CONNECTED ? "> connected" : "> disconnected");
|
||
return true;
|
||
}
|
||
if (strcmp(command, "get wifi.ip") == 0) {
|
||
if (WiFi.status() == WL_CONNECTED) {
|
||
sprintf(reply, "> %s", WiFi.localIP().toString().c_str());
|
||
} else {
|
||
strcpy(reply, "> (not connected)");
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// True when turning this connection on needs a restart first: the other
|
||
// one has been on since the last restart (or, for Bluetooth, the Web Reader
|
||
// has freed Bluetooth's memory).
|
||
bool meckCompanionWiFiNeedsRestart() {
|
||
return !meckCompanionIsWiFi() && meckBleUsed && !meckBtReleased;
|
||
}
|
||
bool meckCompanionBLENeedsRestart() {
|
||
return !meckCompanionIsBLE() && (meckWifiUsed || meckBtReleased);
|
||
}
|
||
|
||
// Restart and turn this connection on after the restart.
|
||
static void meckCompanionRestartInto(bool toWifi) {
|
||
meckSwitchTarget = toWifi ? MECK_SWITCH_WIFI : MECK_SWITCH_BLE;
|
||
meckSwitchMagic = MECK_SWITCH_MAGIC;
|
||
Serial.printf("Companion: restarting into %s\n", toWifi ? "WiFi" : "Bluetooth");
|
||
ui_task.showAlert(MECK_TR("Restarting...", "Red\xC3\xA9marrage..."), 5000);
|
||
meckRestartAt = millis() + 1500; // let the popup reach the e-ink first
|
||
}
|
||
|
||
// Home pages and Settings rows, when a switch needs a restart: the first
|
||
// press asks, a second press within 5 s restarts into that connection.
|
||
void meckCompanionSwitchPress(bool toWifi) {
|
||
uint8_t target = toWifi ? MECK_SWITCH_WIFI : MECK_SWITCH_BLE;
|
||
if (meckConfirmTarget == target && (long)(millis() - meckConfirmUntil) < 0) {
|
||
meckConfirmTarget = 0;
|
||
meckCompanionRestartInto(toWifi);
|
||
return;
|
||
}
|
||
meckConfirmTarget = target;
|
||
meckConfirmUntil = millis() + 5000;
|
||
ui_task.showAlert(toWifi
|
||
? MECK_TR("Switch to WiFi?\nEnter again to restart", "Passer au WiFi ?\nEntr\xC3\xA9" "e encore : red\xC3\xA9marrer")
|
||
: MECK_TR("Switch to Bluetooth?\nEnter again to restart", "Passer au Bluetooth ?\nEntr\xC3\xA9" "e encore : red\xC3\xA9marrer"),
|
||
5000);
|
||
}
|
||
|
||
// Called from MyMesh::loop(): the switch restart, and after it, turning the
|
||
// chosen connection on once the home screen shows (a screen change clears
|
||
// popups, so the WiFi Connecting popup must come after the splash).
|
||
void meckCompanionPoll() {
|
||
if (meckRestartAt && (long)(millis() - meckRestartAt) >= 0) {
|
||
meckRestartAt = 0;
|
||
the_mesh.saveContactsIfDirty(); // as the app's reboot command does
|
||
board.reboot();
|
||
}
|
||
if (meckBootStart && ui_task.isOnHomeScreen()) {
|
||
uint8_t target = meckBootStart;
|
||
meckBootStart = 0;
|
||
if (target == MECK_SWITCH_WIFI) {
|
||
meckCompanionUseWiFi(true);
|
||
} else {
|
||
meckCompanionUseBLE();
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Voice-over-LoRa: incoming raw packet handler (dz0ny VE3 protocol)
|
||
// Registered with the_mesh.setVoiceHandler() when voice screen is created.
|
||
// Called from onRawDataRecv for magic 0x56 (voice data) and 0x72 (fetch req).
|
||
// ---------------------------------------------------------------------------
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
// Helper: ensure voice screen exists (lazy-init for incoming voice)
|
||
static VoiceMessageScreen* ensureVoiceScreen() {
|
||
VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)ui_task.getVoiceScreen();
|
||
if (!voiceScr) {
|
||
Serial.println("Voice: Auto-creating voice screen for incoming message");
|
||
if (!audio) audio = new Audio();
|
||
voiceScr = new VoiceMessageScreen(&ui_task, audio);
|
||
voiceScr->setSDReady(sdCardReady);
|
||
ui_task.setVoiceScreen(voiceScr);
|
||
}
|
||
return voiceScr;
|
||
}
|
||
|
||
static void voiceRawCallback(uint8_t magic, const uint8_t* payload, uint8_t len) {
|
||
VoiceMessageScreen* voiceScr = ensureVoiceScreen();
|
||
|
||
if (magic == 0x72 && len >= 6) {
|
||
// Fetch request: [0x72][sessionId:4B][flags:1B][requesterKey6:6B][missingCount:1B][indices...]
|
||
uint32_t sessionId;
|
||
memcpy(&sessionId, &payload[1], 4);
|
||
Serial.printf("Voice: Fetch request for session 0x%08X\n", sessionId);
|
||
|
||
if (voiceScr->getOutSessionId() == sessionId && voiceScr->hasValidOutSession()) {
|
||
uint8_t pktBuf[184];
|
||
int totalPkts = voiceScr->getOutSessionPacketCount();
|
||
Serial.printf("Voice: Serving %d packets for session 0x%08X\n", totalPkts, sessionId);
|
||
|
||
// Requester's 6-byte key prefix is at payload[6..11].
|
||
// Look them up to get their path for sendDirect.
|
||
if (len >= 12) {
|
||
ContactInfo* requester = the_mesh.lookupContactByPubKey(&payload[6], 6);
|
||
if (requester && requester->out_path_len != OUT_PATH_UNKNOWN) {
|
||
for (int p = 0; p < totalPkts; p++) {
|
||
int pktLen = voiceScr->buildVoicePacket(pktBuf, sizeof(pktBuf), sessionId, p);
|
||
if (pktLen > 0) {
|
||
mesh::Packet* raw = the_mesh.createRawData(pktBuf, pktLen);
|
||
if (raw) {
|
||
the_mesh.sendDirect(raw, requester->out_path, requester->out_path_len, p * 100);
|
||
}
|
||
}
|
||
}
|
||
Serial.printf("Voice: Served %d packets to %s\n", totalPkts, requester->name);
|
||
} else {
|
||
Serial.println("Voice: Fetch requester not found or no direct path");
|
||
}
|
||
}
|
||
} else {
|
||
Serial.printf("Voice: No cached session 0x%08X for fetch\n", sessionId);
|
||
}
|
||
} else if (magic == 0x56 && len > 6) {
|
||
// Incoming voice data packet — feed to incoming session accumulator
|
||
voiceScr->onVoicePacketReceived(payload, len);
|
||
}
|
||
}
|
||
|
||
// Voice envelope callback — called from MyMesh::onMessageRecv when a VE3: DM arrives
|
||
static void voiceEnvelopeCallback(const char* senderName, const char* ve3Text) {
|
||
VoiceMessageScreen* voiceScr = ensureVoiceScreen();
|
||
voiceScr->onVE3Received(senderName, ve3Text);
|
||
// Defer SD contact saves while voice packets are arriving —
|
||
// SD writes block the SPI bus shared with LoRa radio
|
||
the_mesh.setDeferSaves(true);
|
||
Serial.println("Voice: Deferring contact saves during voice receive");
|
||
}
|
||
#endif
|
||
|
||
// 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(MECK_TR("Favourite! Press again", "Favori ! Appuyez encore"), 2500);
|
||
return;
|
||
}
|
||
}
|
||
|
||
the_mesh.removeContact(*existing);
|
||
the_mesh.scheduleLazyContactSave();
|
||
char alertBuf[40];
|
||
snprintf(alertBuf, sizeof(alertBuf), MECK_TR("Removed: %s", "Retir\xC3\xA9 : %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.forceImportContact(blob, blobLen);
|
||
the_mesh.scheduleLazyContactSave();
|
||
char alertBuf[40];
|
||
snprintf(alertBuf, sizeof(alertBuf), MECK_TR("Added: %s", "Ajout\xC3\xA9 : %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(MECK_TR("Advert expired, try later", "Annonce expir\xC3\xA9" "e"), 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
|
||
#if HAS_GPS
|
||
// Open the map screen with everything it needs: the SD flag, the GPS position
|
||
// and the contact markers, then navigate. This is the setup the G key handler
|
||
// has always done. The touch tile route used to call
|
||
// ui_task.gotoMapScreen() bare, so a map opened from the tile before any G
|
||
// press that boot showed "SD card not found" (MapScreen::_sdReady starts
|
||
// false and only setSDReady() sets it). Both routes now come here.
|
||
static void openMapScreen() {
|
||
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();
|
||
}
|
||
#endif // HAS_GPS
|
||
|
||
#ifdef MECK_TOUCH_ENABLED
|
||
#if defined(LilyGo_TDeck_Pro_Max)
|
||
// T-Deck Pro MAX: the three capacitive pads below the screen are CST328 keys,
|
||
// delivered via the HynTouch key callback. The key_id -> pad mapping is read
|
||
// off the hardware (TEMP log below); reorder the cases if they come through
|
||
// in a different order.
|
||
static void meckHynKeyEvent(uint8_t key_id, bool pressed, void* /*user_data*/) {
|
||
Serial.printf("[key] id=%u pressed=%u\n", key_id, pressed ? 1u : 0u); // TEMP: confirm mapping
|
||
if (!pressed) return;
|
||
switch (key_id) {
|
||
case 0: // heart -- toggle frontlight at the user-set brightness
|
||
// Settings > Experimental Features "Change Backlight to Alt+B" turns
|
||
// the heart key off, leaving Alt+B as the backlight toggle.
|
||
if (the_mesh.getNodePrefs()->backlight_alt_b_only) break;
|
||
if (board.isBacklightOn()) {
|
||
board.backlightOff();
|
||
} else {
|
||
uint8_t _blPct = the_mesh.getNodePrefs()->backlight_brightness_pct;
|
||
board.backlightSetBrightness((uint8_t)((_blPct * 255 + 50) / 100));
|
||
}
|
||
break;
|
||
case 1: // speech bubble -- canned messages when a channel or DM
|
||
// conversation is open; otherwise channel picker (same as 'M')
|
||
if (ui_task.isOnChannelScreen()) {
|
||
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||
if (chScr && (chScr->getViewChannelIdx() != 0xFF || chScr->isDMConversation())) {
|
||
if (chScr->isCannedOpen()) {
|
||
chScr->closeCannedList();
|
||
} else if (!chScr->openCannedList()) {
|
||
ui_task.showAlert(MECK_TR("No canned messages", "Aucun message pr\xC3\xA9" "d\xC3\xA9" "fini"), 1200);
|
||
}
|
||
ui_task.forceRefresh();
|
||
break;
|
||
}
|
||
}
|
||
ui_task.gotoChannelPickerScreen();
|
||
break;
|
||
case 2: // send / paper plane -- open DM inbox
|
||
ui_task.gotoDMTab();
|
||
break;
|
||
default: break;
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// Map a single tap based on current screen context
|
||
static char mapTouchTap(int16_t x, int16_t y) {
|
||
// Convert physical screen coords to virtual (128×128)
|
||
int vx, vy;
|
||
touchToVirtual(x, y, vx, vy);
|
||
|
||
// Dismiss boot navigation hint on any tap
|
||
if (ui_task.isHintActive()) {
|
||
ui_task.dismissBootHint();
|
||
return 0;
|
||
}
|
||
|
||
// Canned messages overlay open on the channel screen: it owns every tap.
|
||
// This must run before the status-bar go-home rule below -- the overlay's
|
||
// first row starts at vy 14 and straddles the vy<18 band, so a row tap
|
||
// was going home instead of sending, leaving the overlay flag set.
|
||
if (ui_task.isOnChannelScreen()) {
|
||
ChannelScreen* cannedTapScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||
if (cannedTapScr && cannedTapScr->isCannedOpen()) {
|
||
cannedTapScr->cannedTapAt(vx, vy); // row tap queues the send; else closes
|
||
ui_task.forceRefresh();
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// --- Status bar tap (top ~18 virtual units) → go home from any non-home screen ---
|
||
// Exception: text reader reading mode uses full screen for content (no header)
|
||
if (vy < 18 && !ui_task.isOnHomeScreen()) {
|
||
if (ui_task.isOnTextReader()) {
|
||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||
if (reader && reader->isReading()) {
|
||
return 'd'; // reading mode: treat as next page
|
||
}
|
||
}
|
||
ui_task.gotoHomeScreen();
|
||
return 0;
|
||
}
|
||
|
||
// Games menu: tap to select game entry
|
||
if (ui_task.isOnGamesMenu()) {
|
||
GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen();
|
||
if (gm) {
|
||
int result = gm->selectRowAtVY(vy);
|
||
if (result == 2) return '\r'; // Tapped current selection -- launch
|
||
}
|
||
return 0; // Cursor moved or miss -- just refresh
|
||
}
|
||
|
||
// Snake screen: tap = Enter (start / restart)
|
||
if (ui_task.isOnSnakeScreen()) {
|
||
return '\r';
|
||
}
|
||
|
||
// Minesweeper screen: tap = Enter (reveal cell / start / restart)
|
||
if (ui_task.isOnMinesweeperScreen()) {
|
||
return '\r';
|
||
}
|
||
|
||
// Home screen FIRST page: tile taps (virtual coordinate hit test)
|
||
if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) {
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
// T-Deck Pro: 2-column tile grid, 6 paired rows + full-width Phone row.
|
||
// Hit-tested in RAW PHYSICAL pixels (240x320) using the untranslated
|
||
// touch coords -- geometry must stay in sync with the render block in
|
||
// UITask.cpp.
|
||
const int tileW = 111, tileH = 32, gapX = 5, gapY = 3;
|
||
const int gridX = 6;
|
||
const int gridY = 68;
|
||
const int pitch = tileH + gapY;
|
||
|
||
if (y >= gridY && y < gridY + 6 * pitch + tileH) {
|
||
int row = (y - gridY) / pitch;
|
||
if (row > 6) row = 6;
|
||
if (row == 6) {
|
||
#if defined(HAS_4G_MODEM) && !defined(MECK_40MHZ_TEST)
|
||
ui_task.gotoSMSScreen();
|
||
#endif
|
||
return 0;
|
||
}
|
||
int col = (x - gridX) / (tileW + gapX);
|
||
if (col < 0) col = 0;
|
||
if (col > 1) col = 1;
|
||
switch (row * 2 + col) {
|
||
case 0: ui_task.gotoChannelPickerScreen(); return 0;
|
||
case 1: ui_task.gotoContactsScreen(); return 0;
|
||
case 2: ui_task.gotoSettingsScreen(); return 0;
|
||
case 3: ui_task.gotoDiscoveryScreen(); return 0;
|
||
case 4: ui_task.gotoTraceScreen(); return 0;
|
||
case 5:
|
||
#if HAS_GPS && !defined(MECK_40MHZ_TEST)
|
||
openMapScreen();
|
||
#endif
|
||
return 0;
|
||
case 6: ui_task.gotoNotesScreen(); return 0;
|
||
case 7: ui_task.gotoTextReader(); return 0;
|
||
case 8:
|
||
#if (!defined(HAS_4G_MODEM) || defined(MECK_AUDIO_VARIANT)) && !defined(MECK_40MHZ_TEST)
|
||
// Audiobooks: lazy-init Audio + screen on first use (mirrors 'p' key handler)
|
||
if (!ui_task.getAudiobookScreen()) {
|
||
audio = new Audio();
|
||
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio, the_mesh.getNodePrefs());
|
||
abScreen->setSDReady(sdCardReady);
|
||
ui_task.setAudiobookScreen(abScreen);
|
||
}
|
||
ui_task.gotoAudiobookPlayer();
|
||
#endif
|
||
return 0;
|
||
case 9:
|
||
#if defined(MECK_AUDIO_VARIANT) && !defined(MECK_40MHZ_TEST)
|
||
// Alarm: ensure Audio* exists (mirrors 'k' key handler)
|
||
if (!audio) {
|
||
audio = new Audio();
|
||
}
|
||
{
|
||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||
if (alarmScr) alarmScr->setAudio(audio);
|
||
}
|
||
ui_task.gotoAlarmScreen();
|
||
#endif
|
||
return 0;
|
||
case 10:
|
||
#if defined(MECK_WEB_READER) && !defined(MECK_40MHZ_TEST)
|
||
ui_task.gotoWebReader();
|
||
#endif
|
||
return 0;
|
||
case 11: ui_task.gotoGamesMenu(); return 0;
|
||
}
|
||
return 0;
|
||
}
|
||
// Tap outside tiles -- left half backward, right half forward (physical)
|
||
return (x < 120) ? (char)KEY_PREV : (char)KEY_NEXT;
|
||
#else
|
||
const int tileW = 40, tileH = 22, 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 + 3 * (tileH + gapY)) {
|
||
int col = (vx - gridX) / (tileW + gapX);
|
||
if (col > 2) col = 2;
|
||
int row = (vy - gridY) / (tileH + gapY);
|
||
if (row > 2) row = 2;
|
||
|
||
if (row == 0 && col == 0) { ui_task.gotoChannelPickerScreen(); 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
|
||
// Third row: Trace (col 0) + Games (col 1)
|
||
if (row == 2 && col == 0) { ui_task.gotoTraceScreen(); return 0; }
|
||
if (row == 2 && col == 1) { ui_task.gotoGamesMenu(); return 0; }
|
||
}
|
||
// Tap outside tiles — left half backward, right half forward
|
||
return (vx < 64) ? (char)KEY_PREV : (char)KEY_NEXT;
|
||
#endif // LilyGo_TDeck_Pro
|
||
}
|
||
|
||
// 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()) {
|
||
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 screen: mode-dependent touch
|
||
if (ui_task.isOnNotesScreen()) {
|
||
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
|
||
if (notes) {
|
||
if (notes->isInFileList()) {
|
||
int result = notes->selectRowAtVY(vy);
|
||
if (result == 1) { ui_task.forceRefresh(); return 0; } // Moved selection
|
||
if (result == 2) return KEY_ENTER; // Same row — open
|
||
return 0;
|
||
}
|
||
if (notes->isEditing()) {
|
||
return 0; // T-Deck Pro: keyboard handles typing directly
|
||
}
|
||
// READING, RENAMING, CONFIRM_DELETE: tap = confirm/enter
|
||
return KEY_ENTER;
|
||
}
|
||
}
|
||
|
||
#ifdef MECK_WEB_READER
|
||
// Web reader: context-dependent taps
|
||
if (ui_task.isOnWebReader()) {
|
||
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||
if (wr) {
|
||
if (wr->isReading()) {
|
||
return 'd'; // Tap reading area → next page
|
||
}
|
||
|
||
if (wr->isHome()) {
|
||
return KEY_ENTER; // Select current item (keyboard handles text on T-Deck Pro)
|
||
}
|
||
|
||
if (wr->isWifiSetup()) {
|
||
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
|
||
}
|
||
|
||
// Channel picker screen: tap to select (T-Deck Pro: highlight/activate)
|
||
if (ui_task.isOnChannelPickerScreen()) {
|
||
ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen();
|
||
if (pick) {
|
||
int result = pick->selectAtVxVy(vx, vy);
|
||
if (result == 1) {
|
||
ui_task.forceRefresh();
|
||
return 0; // Moved cursor
|
||
}
|
||
if (result == 2) return KEY_ENTER; // Activate — wantsExit checked after injectKey
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Contacts screen: tap to select row, tap same row to activate
|
||
// In select mode: tap same row toggles checkbox instead
|
||
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) {
|
||
if (cs->isInSelectMode()) {
|
||
cs->toggleSelected();
|
||
ui_task.forceRefresh();
|
||
return 0;
|
||
}
|
||
return KEY_ENTER; // Normal mode — activate
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Path editor screen: tap to select row, tap same to activate
|
||
if (ui_task.isOnPathEditor()) {
|
||
PathEditorScreen* pe = (PathEditorScreen*)ui_task.getPathEditorScreen();
|
||
if (pe) {
|
||
int result = pe->selectRowAtVY(vy);
|
||
if (result == 1) {
|
||
ui_task.forceRefresh();
|
||
return 0;
|
||
}
|
||
if (result == 2) return KEY_ENTER;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
|
||
// Discovery screen: tap to select, tap same to add
|
||
if (ui_task.isOnDiscoveryScreen()) {
|
||
DiscoveryScreen* ds = (DiscoveryScreen*)ui_task.getDiscoveryScreen();
|
||
if (ds) {
|
||
int result = ds->selectRowAtVY(vy);
|
||
if (result == 1) {
|
||
ui_task.forceRefresh();
|
||
return 0;
|
||
}
|
||
if (result == 2) return KEY_ENTER; // Same row — add to contacts
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Last Heard screen: tap to select, tap same to add/remove
|
||
if (ui_task.isOnLastHeardScreen()) {
|
||
LastHeardScreen* lh = (LastHeardScreen*)ui_task.getLastHeardScreen();
|
||
if (lh) {
|
||
int result = lh->selectRowAtVY(vy);
|
||
if (result == 1) {
|
||
ui_task.forceRefresh();
|
||
return 0;
|
||
}
|
||
if (result == 2) {
|
||
lastHeardToggleContact();
|
||
return 0;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Settings screen: tap to select row, tap same row to activate
|
||
if (ui_task.isOnSettingsScreen()) {
|
||
SettingsScreen* ss = (SettingsScreen*)ui_task.getSettingsScreen();
|
||
if (ss && !ss->isEditing()) {
|
||
int result = ss->selectRowAtVY(vy);
|
||
if (result == 1) {
|
||
ui_task.forceRefresh();
|
||
return 0; // Moved cursor — just refresh, don't activate
|
||
}
|
||
if (result == 2) return KEY_ENTER; // Tapped same row — activate
|
||
}
|
||
return KEY_ENTER; // Editing mode or header/footer tap
|
||
}
|
||
|
||
// SMS screen: dedicated dialer/touch handler runs separately (HAS_4G_MODEM block)
|
||
// Return 0 so the general handler doesn't inject spurious keys
|
||
#ifdef HAS_4G_MODEM
|
||
if (ui_task.isOnSMSScreen()) return 0;
|
||
#endif
|
||
|
||
|
||
// All other screens: tap = select
|
||
return KEY_ENTER;
|
||
}
|
||
|
||
// Map a swipe direction to a key
|
||
static char mapTouchSwipe(int16_t dx, int16_t dy) {
|
||
bool horizontal = abs(dx) > abs(dy);
|
||
|
||
// SMS screen: dedicated touch handler covers all interaction
|
||
#ifdef HAS_4G_MODEM
|
||
if (ui_task.isOnSMSScreen()) return 0;
|
||
#endif
|
||
|
||
|
||
// Snake screen: swipes control direction
|
||
if (ui_task.isOnSnakeScreen()) {
|
||
if (horizontal) {
|
||
return (dx < 0) ? 'a' : 'd';
|
||
} else {
|
||
return (dy < 0) ? 'w' : 's';
|
||
}
|
||
}
|
||
|
||
// Minesweeper screen: swipes move cursor
|
||
if (ui_task.isOnMinesweeperScreen()) {
|
||
if (horizontal) {
|
||
return (dx < 0) ? 'a' : 'd';
|
||
} else {
|
||
return (dy < 0) ? 'w' : 's';
|
||
}
|
||
}
|
||
|
||
// Games menu: vertical swipe scrolls list
|
||
if (ui_task.isOnGamesMenu()) {
|
||
if (!horizontal) {
|
||
return (dy > 0) ? 's' : 'w';
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
// Shutdown page: vertical swipe toggles hibernate/power off
|
||
if (ui_task.isHomeOnShutdownPage()) {
|
||
return 'w'; // toggle (direction doesn't matter)
|
||
}
|
||
return (char)KEY_NEXT; // vertical swipe = next page (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 open channel picker
|
||
if (ui_task.isOnChannelScreen() && horizontal) {
|
||
return (dx < 0) ? 'd' : 'a'; // swipe left/right opens channel picker
|
||
}
|
||
|
||
// Contacts screen: horizontal swipe → a/d to change filter
|
||
if (ui_task.isOnContactsScreen() && horizontal) {
|
||
return (dx < 0) ? 'd' : 'a'; // swipe left=next filter, right=prev
|
||
}
|
||
|
||
// All other screens: vertical swipe scrolls
|
||
if (!horizontal) {
|
||
return (dy > 0) ? 's' : 'w'; // swipe down=scroll down, up=scroll up
|
||
}
|
||
|
||
return 0; // ignore horizontal swipes on non-applicable screens
|
||
}
|
||
|
||
// Map a long press to a key
|
||
static char mapTouchLongPress(int16_t x, int16_t y) {
|
||
// SMS screen: dedicated touch handler covers all interaction
|
||
#ifdef HAS_4G_MODEM
|
||
if (ui_task.isOnSMSScreen()) return 0;
|
||
#endif
|
||
|
||
|
||
// Snake screen: long press exits to games menu
|
||
if (ui_task.isOnSnakeScreen()) {
|
||
return 'q';
|
||
}
|
||
|
||
// Minesweeper screen: long press toggles flag on cursor cell
|
||
if (ui_task.isOnMinesweeperScreen()) {
|
||
return 'f';
|
||
}
|
||
|
||
// Games menu: long press exits to home
|
||
if (ui_task.isOnGamesMenu()) {
|
||
return 'q';
|
||
}
|
||
|
||
|
||
// Home screen: long press = activate current page action
|
||
// (BLE toggle, send advert, hibernate, GPS toggle, etc.)
|
||
if (ui_task.isOnHomeScreen()) {
|
||
// Timezones page: select the zone row under the finger first, so the
|
||
// Enter opens that row's editor. While the editor is open the row is
|
||
// left alone and the Enter saves.
|
||
if (ui_task.isHomeOnTimezonesPage()) {
|
||
int vx, vy;
|
||
touchToVirtual(x, y, vx, vy);
|
||
ui_task.selectHomeTimezoneRowAt(vy);
|
||
}
|
||
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
|
||
return KEY_ENTER;
|
||
}
|
||
return KEY_ENTER; // T-Deck Pro: keyboard handles compose mode
|
||
}
|
||
|
||
// Contacts screen: long press
|
||
// T-Deck Pro: toggle select mode (DM/admin handled by keyboard Enter)
|
||
// If in select mode, long press exits it.
|
||
if (ui_task.isOnContactsScreen()) {
|
||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||
if (cs) {
|
||
if (cs->isInSelectMode()) {
|
||
// Both platforms: long press exits select mode
|
||
cs->exitSelectMode();
|
||
ui_task.forceRefresh();
|
||
Serial.println("Contacts: exited select mode (touch long press)");
|
||
return 0;
|
||
}
|
||
// T-Deck Pro: long press enters select mode
|
||
cs->enterSelectMode();
|
||
ui_task.forceRefresh();
|
||
Serial.println("Contacts: entered select mode (touch long press)");
|
||
return 0;
|
||
}
|
||
return KEY_ENTER;
|
||
}
|
||
|
||
// Discovery screen: long press = rescan
|
||
if (ui_task.isOnDiscoveryScreen()) {
|
||
return 'f';
|
||
}
|
||
|
||
// Path editor: long press = Enter (select item)
|
||
if (ui_task.isOnPathEditor()) {
|
||
return KEY_ENTER;
|
||
}
|
||
|
||
// Trace screen: long press = Enter.
|
||
if (ui_task.isOnTraceScreen()) {
|
||
return KEY_ENTER;
|
||
}
|
||
|
||
// 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) {
|
||
return KEY_ENTER; // T-Deck Pro: keyboard handles password entry
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// Channel picker: long press = delete message history for highlighted channel
|
||
if (ui_task.isOnChannelPickerScreen()) {
|
||
return 'x';
|
||
}
|
||
|
||
// 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");
|
||
|
||
// Initialize touch input (CST328) HERE, immediately after board.begin(),
|
||
// while the I2C bus is freshly initialised and quiet -- mirroring LilyGo's
|
||
// working example, which calls touch.begin() first thing. Running it later
|
||
// (after display/radio/filesystem init) left the bus in a state where the
|
||
// CST328 firmware-info read failed.
|
||
#if defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN)
|
||
#if defined(LilyGo_TDeck_Pro_Max)
|
||
board.touchReset(); // fresh XL9555 reset immediately before driver init
|
||
// Route HynTouch's reset (XL9555 P07) through the board, then probe/init.
|
||
hyn_touch_set_virtual_gpio_callbacks(meckHynXl9555Write, meckHynXl9555Read, nullptr);
|
||
hyn_touch_set_key_callback(meckHynKeyEvent, nullptr);
|
||
{
|
||
HynTouchConfig hcfg = hyn_touch_default_config();
|
||
if (hyn_touch_init_with_config(&hcfg)) {
|
||
MESH_DEBUG_PRINTLN("setup() - Touch input initialized (HynTouch)");
|
||
} else {
|
||
MESH_DEBUG_PRINTLN("setup() - Touch input FAILED (HynTouch)");
|
||
}
|
||
}
|
||
#elif defined(MECK_PRO_HYN_TOUCH)
|
||
// T-Deck Pro v1.1 with the CST3530 (CST66xx family) at 0x1A: use the
|
||
// vendored Hynitron driver with native pins. The driver self-resets
|
||
// (cst66xx_rst) as its first init action using reset_pin, so no manual
|
||
// pulse is needed -- and the later GPIO 38 pulse in the DISPLAY_CLASS
|
||
// block is suppressed for this build (it would undo this init).
|
||
{
|
||
HynTouchConfig hcfg = hyn_touch_default_config();
|
||
hcfg.sda_pin = I2C_SDA; // 13 (shared bus, same as the Max)
|
||
hcfg.scl_pin = I2C_SCL; // 14
|
||
hcfg.reset_pin = CST328_PIN_RST; // 38: native GPIO on the Pro v1.1
|
||
hcfg.irq_pin = CST328_PIN_INT; // 12
|
||
if (hyn_touch_init_with_config(&hcfg)) {
|
||
MESH_DEBUG_PRINTLN("setup() - Touch input initialized (HynTouch, Pro v1.1)");
|
||
} else {
|
||
MESH_DEBUG_PRINTLN("setup() - Touch input FAILED (HynTouch, Pro v1.1)");
|
||
}
|
||
}
|
||
#else
|
||
if (touchInput.begin(CST328_PIN_INT)) {
|
||
MESH_DEBUG_PRINTLN("setup() - Touch input initialized");
|
||
} else {
|
||
MESH_DEBUG_PRINTLN("setup() - Touch input FAILED");
|
||
}
|
||
#endif
|
||
#endif
|
||
|
||
#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)
|
||
Serial.printf(">>> TOUCH DIAG: compiled CST328_PIN_RST = %d (MAX expects -1; a real GPIO means stale Pro variant)\n", (int)CST328_PIN_RST);
|
||
#if defined(CST328_PIN_RST) && !defined(MECK_PRO_HYN_TOUCH)
|
||
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(STM32_PLATFORM)
|
||
MESH_DEBUG_PRINTLN("setup() - STM32 filesystem init");
|
||
InternalFS.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 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()");
|
||
if (!SPIFFS.begin(false)) {
|
||
// First boot or corrupted partition -- format required (can take 1-2 minutes)
|
||
Serial.println("SPIFFS mount failed - formatting (this may take 1-2 minutes)...");
|
||
if (disp) {
|
||
disp->startFrame();
|
||
disp->drawTextCentered(disp->width() / 2, 28, "Loading...");
|
||
disp->setTextSize(1);
|
||
disp->setColor(DisplayDriver::YELLOW);
|
||
disp->drawTextCentered(disp->width() / 2, 52, "Formatting storage...");
|
||
disp->drawTextCentered(disp->width() / 2, 65, "First boot - please wait - may take 1-2 mins");
|
||
disp->endFrame();
|
||
}
|
||
if (!SPIFFS.begin(true)) {
|
||
// Auto-format failed -- partition likely contains non-SPIFFS data from
|
||
// a previous firmware. Erase the entire partition and retry.
|
||
Serial.println("SPIFFS auto-format failed -- erasing partition and retrying...");
|
||
const esp_partition_t* spiffsPart = esp_partition_find_first(
|
||
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, NULL);
|
||
if (spiffsPart) {
|
||
Serial.printf("SPIFFS partition: offset=0x%x size=0x%x -- erasing...\n",
|
||
spiffsPart->address, spiffsPart->size);
|
||
esp_partition_erase_range(spiffsPart, 0, spiffsPart->size);
|
||
}
|
||
bool spiffsMounted = false;
|
||
for (int attempt = 0; attempt < 3 && !spiffsMounted; attempt++) {
|
||
if (attempt > 0) { Serial.printf("SPIFFS retry %d/3...\n", attempt + 1); delay(500); }
|
||
SPIFFS.format();
|
||
spiffsMounted = SPIFFS.begin(false);
|
||
}
|
||
if (spiffsMounted) {
|
||
Serial.println("SPIFFS mounted after explicit format");
|
||
} else {
|
||
Serial.println("ERROR: SPIFFS mount failed after all retries");
|
||
}
|
||
} else {
|
||
Serial.println("SPIFFS format complete");
|
||
}
|
||
}
|
||
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Early SD card init -- needed BEFORE the_mesh.begin() so we can restore
|
||
// settings from a previous firmware flash. The display SPI bus is already
|
||
// up (display.begin() ran earlier), so SD can share it now.
|
||
// ---------------------------------------------------------------------------
|
||
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
|
||
{
|
||
// Deselect ALL SPI devices before SD init to prevent bus contention.
|
||
// E-ink, LoRa, and SD share the same SPI bus (SCK=36, MOSI=33, MISO=47).
|
||
// If LoRa CS is still asserted from board/radio init, it responds on the
|
||
// shared MISO line and corrupts SD card replies (CMD0 fails intermittently).
|
||
pinMode(SDCARD_CS, OUTPUT);
|
||
digitalWrite(SDCARD_CS, HIGH);
|
||
|
||
pinMode(PIN_EINK_CS, OUTPUT);
|
||
digitalWrite(PIN_EINK_CS, HIGH);
|
||
|
||
pinMode(LORA_CS, OUTPUT);
|
||
digitalWrite(LORA_CS, HIGH);
|
||
|
||
// SD cards need 74+ SPI clock cycles after power stabilization before
|
||
// accepting CMD0. A brief delay avoids race conditions on cold boot
|
||
// or with slow-starting cards.
|
||
delay(100);
|
||
|
||
// Retry loop — some SD cards are slow to initialise, especially on
|
||
// cold boot or marginal USB power. Three attempts with increasing
|
||
// settle time covers the vast majority of transient failures.
|
||
bool mounted = false;
|
||
for (int attempt = 0; attempt < 3 && !mounted; attempt++) {
|
||
if (attempt > 0) {
|
||
digitalWrite(SDCARD_CS, HIGH); // Ensure CS released between retries
|
||
delay(250);
|
||
MESH_DEBUG_PRINTLN("setup() - SD card retry %d/3", attempt + 1);
|
||
}
|
||
mounted = SD.begin(SDCARD_CS, displaySpi, 4000000);
|
||
}
|
||
|
||
if (mounted) {
|
||
sdCardReady = true;
|
||
MESH_DEBUG_PRINTLN("setup() - SD card initialized (early)");
|
||
|
||
// If SPIFFS was wiped (fresh flash), restore settings from SD backup
|
||
if (restoreSettingsFromSD()) {
|
||
MESH_DEBUG_PRINTLN("setup() - Settings restored from SD backup");
|
||
}
|
||
} else {
|
||
MESH_DEBUG_PRINTLN("setup() - SD card not available after 3 attempts");
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// Copy bundled notification sounds to SD card (audio variant only).
|
||
// Skips files that already exist so user customisations are preserved.
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
if (sdCardReady) {
|
||
copyBundledSoundsToSD();
|
||
}
|
||
#endif
|
||
|
||
// Initialise per-channel notification sound config (audio + 4G variants).
|
||
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
|
||
if (sdCardReady) {
|
||
notifSounds.begin();
|
||
}
|
||
#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");
|
||
|
||
#if defined(LilyGo_TDeck_Pro_Max)
|
||
// Restore saved LoRa antenna selection (XL9555 boots to internal by default)
|
||
if (the_mesh.getNodePrefs()->lora_antenna) board.loraAntennaExternal();
|
||
else board.loraAntennaInternal();
|
||
#endif
|
||
|
||
// Boot-time config import: check for /meshcore/import.json on SD
|
||
#ifdef HAS_SDCARD
|
||
if (sdCardReady) {
|
||
int importResult = meckImportConfig(the_mesh,
|
||
sensors.node_lat, sensors.node_lon,
|
||
sdCardReady);
|
||
if (importResult > 0) {
|
||
MESH_DEBUG_PRINTLN("setup() - config imported from SD");
|
||
}
|
||
// importResult == 0 means no file found (normal), -1 means error (logged inside)
|
||
// If identity was changed, meckImportConfig already rebooted before returning.
|
||
}
|
||
#endif
|
||
|
||
#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(BLE_PIN_CODE) && defined(MECK_WIFI_COMPANION)
|
||
// Combined BLE + WiFi companion: both off at boot. Bluetooth is only
|
||
// prepared here (its controller starts on first use); WiFi and the app
|
||
// server start when the user turns WiFi on.
|
||
MESH_DEBUG_PRINTLN("setup() - combined BLE + WiFi companion, both off at boot");
|
||
ble_companion.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
||
// After a switch restart, turn the chosen connection on (once, from
|
||
// meckCompanionPoll when the home screen shows)
|
||
if (meckSwitchMagic == MECK_SWITCH_MAGIC &&
|
||
(meckSwitchTarget == MECK_SWITCH_BLE || meckSwitchTarget == MECK_SWITCH_WIFI)) {
|
||
meckBootStart = meckSwitchTarget;
|
||
Serial.printf("Companion: switch restart, turning %s on\n",
|
||
meckBootStart == MECK_SWITCH_WIFI ? "WiFi" : "Bluetooth");
|
||
}
|
||
meckSwitchMagic = 0; // one-off: the next normal start-up has both off
|
||
#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).
|
||
// This is ESP32-specific.
|
||
#if HAS_GPS && defined(ESP32)
|
||
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
|
||
|
||
// Touch input (CST328) is initialised earlier, right after board.begin(),
|
||
// on a freshly-initialised quiet I2C bus (see setup() near board.begin()).
|
||
|
||
// RTC diagnostic + boot-time serial clock sync
|
||
// Works on all Meck builds — T-Deck Pro has no hardware RTC and GPS may
|
||
// not fix immediately.
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
{
|
||
uint32_t rtcTime = rtc_clock.getCurrentTime();
|
||
// Plausible range: Nov 2023 (1700000000) → May 2033 (2000000000)
|
||
bool validTime = (rtcTime > 1700000000 && rtcTime < 2000000000);
|
||
Serial.printf("setup() - RTC time: %lu (valid=%s)\n", rtcTime,
|
||
validTime ? "YES" : "NO");
|
||
if (!validTime) {
|
||
// 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)
|
||
#ifdef MECK_40MHZ_TEST
|
||
bool modemEnabled = false; // 40 MHz test build: Phone gated, modem stays off
|
||
#else
|
||
bool modemEnabled = ModemManager::loadEnabledConfig();
|
||
#endif
|
||
if (modemEnabled) {
|
||
modemManager.begin();
|
||
MESH_DEBUG_PRINTLN("setup() - 4G modem manager started");
|
||
} else {
|
||
// Ensure modem power is off (kills red LED too)
|
||
#if defined(LilyGo_TDeck_Pro_Max)
|
||
board.modemPowerOff(); // XL9555 6609_EN LOW
|
||
#else
|
||
pinMode(MODEM_POWER_EN, OUTPUT);
|
||
digitalWrite(MODEM_POWER_EN, LOW);
|
||
#endif
|
||
MESH_DEBUG_PRINTLN("setup() - 4G modem disabled by config");
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
#endif
|
||
|
||
// Check if node name is still the default hex prefix (first 4 bytes of pub key)
|
||
// If so, launch onboarding wizard to set name and radio preset
|
||
// ---------------------------------------------------------------------------
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
{
|
||
char defaultName[10];
|
||
mesh::Utils::toHex(defaultName, the_mesh.self_id.pub_key, 4);
|
||
NodePrefs* prefs = the_mesh.getNodePrefs();
|
||
if (strcmp(prefs->node_name, defaultName) == 0) {
|
||
MESH_DEBUG_PRINTLN("setup() - Default node name detected, launching onboarding");
|
||
ui_task.gotoOnboarding();
|
||
// Show hint immediately overlaid on the onboarding screen
|
||
if (!prefs->hint_shown) ui_task.showBootHint(true);
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// GPS power — honour saved pref, default to enabled on first boot.
|
||
// GPS is critical for timesync on standalone variants without 4G.
|
||
#if HAS_GPS
|
||
{
|
||
#ifdef MECK_40MHZ_TEST
|
||
bool gps_wanted = false; // 40 MHz test build: GPS gated, rail forced off
|
||
#else
|
||
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
|
||
#endif
|
||
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
|
||
#if defined(LilyGo_TDeck_Pro_Max)
|
||
// Speed up / improve the MAX GPS fix: enable multi-constellation
|
||
// (GPS + GLONASS + BeiDou) and EASY predicted ephemeris (warm/hot starts
|
||
// on subsequent boots). A module that doesn't recognise a given sentence
|
||
// simply ignores it. Sent once here, after the module is powered.
|
||
delay(300); // allow the module to finish booting before accepting config
|
||
Serial2.print("$PCAS04,7*1E\r\n"); // GPS + GLONASS + BeiDou
|
||
Serial2.print("$PMTK869,1,1*35\r\n"); // EASY predicted ephemeris ON
|
||
#endif
|
||
sensors.setSettingValue("gps", "1");
|
||
} else {
|
||
#ifdef PIN_GPS_EN
|
||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||
#endif
|
||
#if defined(LilyGo_TDeck_Pro_Max)
|
||
board.gpsPowerOff(); // MAX: GPS power is XL9555-routed, not PIN_GPS_EN
|
||
#endif
|
||
sensors.setSettingValue("gps", "0");
|
||
}
|
||
Serial.printf("GPS: power %s\n", gps_wanted ? "ON" : "OFF");
|
||
}
|
||
#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(BLE_PIN_CODE)
|
||
serial_interface.disable();
|
||
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
|
||
#endif
|
||
|
||
// Alarm clock: create at boot so config is loaded, background alarm check
|
||
// works from first loop(), and the bell indicator is visible immediately.
|
||
// Audio object is NOT created here — lazy-init when alarm fires or user opens player.
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
{
|
||
AlarmScreen* alarmScr = new AlarmScreen(&ui_task);
|
||
alarmScr->setSDReady(sdCardReady);
|
||
// Audio pointer set later when needed (fireAlarm or 'k'/'p' key)
|
||
ui_task.setAlarmScreen(alarmScr);
|
||
Serial.printf("ALARM: Boot init, %d alarms enabled\n", alarmScr->enabledCount());
|
||
}
|
||
#endif
|
||
|
||
// Register voice-over-LoRa callbacks early so incoming VE3 envelopes and
|
||
// raw voice packets are handled even before user opens the voice screen.
|
||
// The callbacks null-check the voice screen pointer, so they're safe at boot.
|
||
#if defined(MECK_AUDIO_VARIANT) && !defined(MECK_40MHZ_TEST)
|
||
the_mesh.setVoiceHandler(voiceRawCallback);
|
||
the_mesh.setVoiceEnvelopeHandler(voiceEnvelopeCallback);
|
||
#endif
|
||
|
||
#ifdef ESP32
|
||
Serial.printf("setup() complete - free heap: %d, largest block: %d\n",
|
||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||
#endif
|
||
#if defined(BLE_PIN_CODE) && defined(MECK_WIFI_COMPANION)
|
||
meckLogMemory("boot, both off");
|
||
#endif
|
||
{
|
||
void* p25 = malloc(25000); void* p10 = malloc(10000); void* p4 = malloc(4000);
|
||
Serial.printf("setup() heap probe: 25K=%s 10K=%s 4K=%s\n",
|
||
p25?"yes":"no", p10?"yes":"no", p4?"yes":"no");
|
||
if (p4) free(p4); if (p10) free(p10); if (p25) free(p25);
|
||
}
|
||
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
|
||
|
||
#ifdef HAS_SDCARD
|
||
// Settings export: the key handler shows the "Compiling... Please Wait..."
|
||
// popup and sets these; the export runs from loop() 1.5 s later, so the popup
|
||
// is on the e-ink while the export blocks (it can take tens of seconds).
|
||
static bool meckExportPending = false;
|
||
static uint8_t meckExportPendingFlags = 0;
|
||
static unsigned long meckExportAt = 0;
|
||
static void meckExportPoll() {
|
||
if (!meckExportPending || (long)(millis() - meckExportAt) < 0) return;
|
||
meckExportPending = false;
|
||
char exportedPath[64];
|
||
int result = meckExportConfig(the_mesh, meckExportPendingFlags,
|
||
sensors.node_lat, sensors.node_lon,
|
||
rtc_clock, sdCardReady,
|
||
exportedPath, sizeof(exportedPath));
|
||
if (result >= 0) {
|
||
char buf[96];
|
||
snprintf(buf, sizeof(buf), MECK_TR("Exported to %s", "Export\xC3\xA9 vers %s"), exportedPath);
|
||
ui_task.showAlert(buf, 3500);
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Export failed (SD?)", "\xC3\x89" "chec export (SD ?)"), 2000);
|
||
}
|
||
}
|
||
|
||
// Settings import: same as export. If the import replaces the identity,
|
||
// meckImportConfig restarts the device itself and does not return.
|
||
static bool meckImportPending = false;
|
||
static unsigned long meckImportAt = 0;
|
||
static void meckImportPoll() {
|
||
if (!meckImportPending || (long)(millis() - meckImportAt) < 0) return;
|
||
meckImportPending = false;
|
||
int added = meckImportConfig(the_mesh,
|
||
sensors.node_lat, sensors.node_lon,
|
||
sdCardReady);
|
||
if (added > 0) {
|
||
ui_task.showAlert(MECK_TR("Config imported!", "Config import\xC3\xA9" "e !"), 2500);
|
||
} else if (added == 0) {
|
||
ui_task.showAlert(MECK_TR("No import.json found", "import.json introuvable"), 2000);
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Import failed", "\xC3\x89" "chec de l'import"), 2000);
|
||
}
|
||
}
|
||
#endif
|
||
|
||
void loop() {
|
||
#ifdef HAS_SDCARD
|
||
meckExportPoll(); // Settings export: runs once its popup is on screen
|
||
meckImportPoll(); // Settings import: same
|
||
#endif
|
||
#if defined(DISPLAY_CLASS) && defined(MECK_WIFI_COMPANION)
|
||
meckWifiConnectPoll(); // saved-network connect: Connected / Could not connect popup
|
||
#endif
|
||
#ifdef MECK_OTA_UPDATE
|
||
if (!otaRadioPaused) {
|
||
#endif
|
||
|
||
the_mesh.loop();
|
||
|
||
#ifdef MECK_OTA_UPDATE
|
||
} else {
|
||
// OTA/File Manager active — poll the web server from the main loop for fast response.
|
||
// The e-ink render cycle can block during a 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 an e-ink refresh blocks
|
||
// and the render-based check never fires reliably.
|
||
ss->checkOTAComplete(display);
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
|
||
sensors.loop();
|
||
|
||
// Satellite-count diagnostic (MAX): every 10s while GPS is on, print the
|
||
// reported satellite count, fix state and NMEA rate so the multi-constellation
|
||
// change can be watched climbing. Remove once confirmed if you don't want it.
|
||
#if defined(LilyGo_TDeck_Pro_Max) && HAS_GPS
|
||
{
|
||
static unsigned long lastSatDiag = 0;
|
||
if (the_mesh.getNodePrefs()->gps_enabled && millis() - lastSatDiag >= 10000) {
|
||
lastSatDiag = millis();
|
||
auto* lp = sensors.getLocationProvider();
|
||
if (lp) {
|
||
char sv[64];
|
||
gpsStream.getInViewBreakdown(sv, sizeof(sv));
|
||
Serial.printf("[GPS] inview=%u [%s] used=%d fix=%s nmea=%u/s total=%lu\n",
|
||
gpsStream.getSatellitesInView(), sv,
|
||
lp->satellitesCount(), lp->isValid() ? "Y" : "N",
|
||
gpsStream.getSentencesPerSec(),
|
||
(unsigned long)gpsStream.getSentenceCount());
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// 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
|
||
#ifdef DISPLAY_CLASS
|
||
#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
|
||
#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_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) || defined(MECK_AUDIO_VARIANT))
|
||
{
|
||
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
|
||
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
// Game Boy emulator: hold the CPU at boost while a game runs (the core
|
||
// needs 240 MHz), and return to the games menu when the ROM list is
|
||
// backed out of. The screen's own poll() handles in-game quit.
|
||
if (ui_task.isOnGBCScreen()) {
|
||
GBCEmulatorScreen* gbc = (GBCEmulatorScreen*)ui_task.getGBCScreen();
|
||
if (gbc) {
|
||
if (gbc->isRunning()) cpuPower.setBoost();
|
||
if (gbc->wantsExit()) ui_task.gotoGamesMenu();
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// Alarm clock: background alarm check + audio tick
|
||
#if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT)
|
||
{
|
||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||
if (alarmScr) {
|
||
// Service alarm audio decode (like audiobook audioTick)
|
||
alarmScr->alarmAudioTick();
|
||
if (alarmScr->isAlarmAudioActive()) {
|
||
cpuPower.setBoost();
|
||
}
|
||
|
||
#ifndef MECK_40MHZ_TEST
|
||
// Periodic alarm check (~every 10 seconds)
|
||
static unsigned long lastAlarmCheck = 0;
|
||
if (millis() - lastAlarmCheck > ALARM_CHECK_INTERVAL_MS) {
|
||
lastAlarmCheck = millis();
|
||
uint32_t rtcNow = the_mesh.getRTCClock()->getCurrentTime();
|
||
int fireSlot = alarmScr->checkAlarms(rtcNow, the_mesh.getNodePrefs()->utc_offset_hours);
|
||
if (fireSlot >= 0 && !alarmScr->isRinging()) {
|
||
// If audiobook is playing, the alarm will take over the shared Audio*
|
||
// object. The audiobook auto-saves bookmarks every 30s, so at most
|
||
// 30s of position is lost. User can resume from audiobook player after.
|
||
AudiobookPlayerScreen* abPlayer =
|
||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||
if (abPlayer && abPlayer->isAudioActive()) {
|
||
Serial.println("ALARM: Audiobook active — alarm taking over Audio");
|
||
}
|
||
|
||
// Ensure Audio object is shared
|
||
if (!audio) audio = new Audio();
|
||
alarmScr->setAudio(audio);
|
||
|
||
// Fire the alarm
|
||
alarmScr->fireAlarm(fireSlot);
|
||
alarmScr->setLastFiredEpoch(fireSlot, rtcNow);
|
||
|
||
// Let audio buffer fill before e-ink refresh blocks SPI
|
||
for (int i = 0; i < 50; i++) {
|
||
alarmScr->alarmAudioTick();
|
||
delay(2);
|
||
}
|
||
|
||
// Switch UI to alarm screen (ringing mode)
|
||
ui_task.gotoAlarmScreen();
|
||
|
||
// Wake display if asleep
|
||
ui_task.keepAlive();
|
||
ui_task.forceRefresh();
|
||
|
||
Serial.printf("ALARM: Fired slot %d, switched to ringing screen\n", fireSlot);
|
||
}
|
||
}
|
||
#endif // MECK_40MHZ_TEST: alarm firing gated
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// Notification tone: play custom MP3 for per-channel notification sounds.
|
||
// Polls the notifSounds pending request, lazy-inits Audio*, plays the file,
|
||
// and disables DAC when playback finishes.
|
||
#if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT)
|
||
{
|
||
static bool notifTonePlaying = false;
|
||
static bool notifFadingOut = false;
|
||
static bool notifMuted = false;
|
||
static uint32_t notifStartMs = 0;
|
||
static uint32_t notifDurationMs = 0;
|
||
|
||
// Service audio decode while notification tone is playing
|
||
if (notifTonePlaying && audio) {
|
||
audio->loop();
|
||
|
||
// Fade-out: ramp volume down near the end of the file.
|
||
// Window scales with duration -- 15% of estimated length, capped at 400ms.
|
||
// Prevents short tones from losing too much audio to the fade.
|
||
if (!notifFadingOut && notifDurationMs > 0) {
|
||
uint32_t elapsed = millis() - notifStartMs;
|
||
uint32_t fadeWindow = notifDurationMs * 15 / 100; // 15% of duration
|
||
if (fadeWindow > 400) fadeWindow = 400;
|
||
if (fadeWindow < 80) fadeWindow = 80;
|
||
uint32_t fadePoint = notifDurationMs - fadeWindow;
|
||
if (elapsed >= fadePoint) {
|
||
notifFadingOut = true;
|
||
audio->setVolume(3);
|
||
Serial.printf("NotifTone: Fade at %lums (window %lums)\n", elapsed, fadeWindow);
|
||
}
|
||
}
|
||
|
||
// Mute completely at 40% through the fade window
|
||
if (notifFadingOut && !notifMuted && notifDurationMs > 0) {
|
||
uint32_t elapsed = millis() - notifStartMs;
|
||
uint32_t muteWindow = notifDurationMs * 6 / 100; // 6% of duration
|
||
if (muteWindow > 150) muteWindow = 150;
|
||
if (muteWindow < 30) muteWindow = 30;
|
||
uint32_t mutePoint = notifDurationMs - muteWindow;
|
||
if (elapsed >= mutePoint) {
|
||
notifMuted = true;
|
||
audio->setVolume(0);
|
||
}
|
||
}
|
||
|
||
if (!audio->isRunning()) {
|
||
audio->setVolume(0);
|
||
audio->stopSong();
|
||
delay(50);
|
||
#ifndef HAS_ES8311_AUDIO
|
||
digitalWrite(41, LOW);
|
||
#endif
|
||
notifTonePlaying = false;
|
||
ui_task.setNotifAudioActive(false);
|
||
notifFadingOut = false;
|
||
notifMuted = false;
|
||
notifDurationMs = 0;
|
||
Serial.println("NotifTone: Playback finished");
|
||
}
|
||
}
|
||
|
||
// Check for new play request
|
||
if (notifSounds.hasPendingPlay()) {
|
||
const char* file = notifSounds.getPendingFile();
|
||
|
||
// Don't interrupt alarm or active audiobook
|
||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||
AudiobookPlayerScreen* abPlayer =
|
||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||
bool audioBusy = (alarmScr && alarmScr->isRinging()) ||
|
||
(alarmScr && alarmScr->isAlarmAudioActive()) ||
|
||
(abPlayer && abPlayer->isAudioActive());
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
// A running Game Boy game owns the codec and I2S port: skip the tone
|
||
// (vibration and the toast still happen).
|
||
{
|
||
GBCEmulatorScreen* gbcScr = (GBCEmulatorScreen*)ui_task.getGBCScreen();
|
||
if (gbcScr && gbcScr->isRunning()) audioBusy = true;
|
||
}
|
||
#endif
|
||
|
||
if (!audioBusy && file[0] != '\0') {
|
||
// Lazy-init Audio object
|
||
if (!audio) audio = new Audio();
|
||
|
||
// Read file size to estimate duration for fade-out timing.
|
||
// Estimate assumes 256kbps CBR: durationMs = fileBytes / 32.
|
||
// Slightly overestimates for lower bitrates (fade triggers early
|
||
// rather than late — better than hiss at end).
|
||
uint32_t estimatedMs = 0;
|
||
{
|
||
File f = SD.open(file, "r");
|
||
if (f) {
|
||
uint32_t sz = f.size();
|
||
estimatedMs = sz / 32; // 256kbps: bytes * 8 / 256000 * 1000
|
||
Serial.printf("NotifTone: File %lu bytes, est %lums\n", sz, estimatedMs);
|
||
f.close();
|
||
digitalWrite(SDCARD_CS, HIGH);
|
||
}
|
||
}
|
||
|
||
// Stop any stale playback before touching DAC
|
||
audio->stopSong();
|
||
|
||
// MAX: route audio to the ES8311 codec and enable the speaker amp.
|
||
// NOTE: the codec REGISTER init is deferred until AFTER the I2S clocks
|
||
// are actually running (after connecttoFS + the silence-fill loop). The
|
||
// ES8311 appears to need live BCLK/LRCK to lock its clock tree; init'd
|
||
// on a clockless bus it configures correctly (readback confirms) but
|
||
// produces no output. Routing/amp are safe to set here.
|
||
#ifdef HAS_ES8311_AUDIO
|
||
board.selectAudioES8311(); // XL9555 AUDIO_SEL low -> ES8311
|
||
board.amplifierEnable(); // XL9555 AMPLIFIER high -> NS4150B on
|
||
// DIAG: read back the two routing pins to confirm they took effect
|
||
// (AUDIO_SEL should be LOW=0 for ES8311, AMPLIFIER should be HIGH=1).
|
||
Serial.printf("NotifTone: routing AUDIO_SEL=%d (want 0) AMP=%d (want 1)\n",
|
||
board.xl9555_digitalRead(XL_PIN_AUDIO_SEL) ? 1 : 0,
|
||
board.xl9555_digitalRead(XL_PIN_AMPLIFIER) ? 1 : 0);
|
||
#endif
|
||
|
||
// Configure I2S pins. Mirroring Ripple, the ES8311 is driven WITH MCLK
|
||
// (use_mclk=true), so MCLK must be generated on BOARD_I2S_MCLK. Use the
|
||
// 5-arg setPinout: the 4th arg is DIN (data-in, unused -> no change), the
|
||
// 5th is MCK. The earlier 4-arg call mistakenly passed MCLK as the 4th
|
||
// (DIN) arg, so MCLK was never generated -- this passes it as MCK.
|
||
#ifdef HAS_ES8311_AUDIO
|
||
bool ok = audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT,
|
||
I2S_PIN_NO_CHANGE, BOARD_I2S_MCLK);
|
||
Serial.printf("NotifTone: setPinout(BCLK=%d LRC=%d DOUT=%d MCK=%d) -> %s\n",
|
||
BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, BOARD_I2S_MCLK,
|
||
ok ? "ok" : "FAILED");
|
||
#else
|
||
bool ok = audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, 0);
|
||
#endif
|
||
if (!ok) ok = audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT);
|
||
|
||
// Start playback at volume 0 (silence into DAC during power-on)
|
||
audio->setVolume(0);
|
||
audio->connecttoFS(SD, file);
|
||
|
||
// Enable DAC amplifier and let it stabilise with silence flowing
|
||
#ifndef HAS_ES8311_AUDIO
|
||
pinMode(41, OUTPUT);
|
||
digitalWrite(41, HIGH);
|
||
delay(80);
|
||
#endif
|
||
|
||
// Fill audio buffer with silence at volume 0 (prevents power-on pop)
|
||
for (int i = 0; i < 30; i++) {
|
||
audio->loop();
|
||
delay(2);
|
||
}
|
||
|
||
// I2S clocks are now running (connecttoFS started the channel and the
|
||
// loop above has pushed samples). NOW bring up the ES8311 over I2C, so
|
||
// it can lock to the live BCLK/LRCK. Done once per boot.
|
||
#ifdef HAS_ES8311_AUDIO
|
||
static bool es8311_ready = false;
|
||
if (!es8311_ready) {
|
||
es8311_ready = es8311_init_44100_16bit();
|
||
Serial.printf("NotifTone: ES8311 init (post-I2S) %s\n", es8311_ready ? "OK" : "FAILED");
|
||
}
|
||
#endif
|
||
|
||
// Ramp volume up to playback level
|
||
audio->setVolume(17);
|
||
notifTonePlaying = true;
|
||
ui_task.setNotifAudioActive(true);
|
||
notifFadingOut = false;
|
||
notifMuted = false;
|
||
notifStartMs = millis();
|
||
// Subtract ~140ms of pre-decode (80ms DAC warmup + 60ms buffer fill)
|
||
// that elapsed between connecttoFS and now — the file is already
|
||
// that far into decoding when we start timing.
|
||
notifDurationMs = (estimatedMs > 140) ? (estimatedMs - 140) : estimatedMs;
|
||
|
||
Serial.printf("NotifTone: Playing '%s'\n", file);
|
||
} else if (audioBusy) {
|
||
Serial.println("NotifTone: Skipped -- audio busy");
|
||
}
|
||
notifSounds.clearPending();
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// Voice message: service mic DMA capture + playback audio decode
|
||
#if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT)
|
||
{
|
||
VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)ui_task.getVoiceScreen();
|
||
if (voiceScr) {
|
||
voiceScr->voiceTick();
|
||
|
||
// Sync shared audio pointer — playFile() may have recreated Audio*
|
||
Audio* voiceAudio = voiceScr->getAudio();
|
||
if (voiceAudio != audio) {
|
||
Serial.println("Voice: Syncing shared Audio* after recreation");
|
||
audio = voiceAudio;
|
||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||
if (alarmScr) alarmScr->setAudio(audio);
|
||
}
|
||
|
||
// Service audio decode for voice playback (shared Audio* object)
|
||
if (voiceScr->isAudioActive()) {
|
||
if (audio) audio->loop();
|
||
cpuPower.setBoost();
|
||
}
|
||
|
||
// Detect end-of-playback and refresh UI
|
||
voiceScr->checkPlaybackFinished();
|
||
if (voiceScr->consumePlaybackFinished()) {
|
||
ui_task.forceRefresh();
|
||
}
|
||
|
||
// --- Deferred forward-send load: runs the heavy SD read + Codec2 encode
|
||
// once the "Loading" popup has painted (see VoiceMessageScreen::'F'),
|
||
// then enters the contact picker. Gated to the voice screen so a
|
||
// navigate-away during the load cannot fire it off-screen. ---
|
||
if (ui_task.isOnVoiceScreen() && voiceScr->runPendingLoad()) {
|
||
ui_task.forceRefresh();
|
||
}
|
||
|
||
// --- Contact picker: load contacts when mode transitions to CONTACT_PICK ---
|
||
static bool pickContactsLoaded = false;
|
||
if (voiceScr->getMode() == VoiceMessageScreen::CONTACT_PICK) {
|
||
if (!pickContactsLoaded) {
|
||
// Build list of chat contacts with direct paths
|
||
VoiceMessageScreen::PickContact pickBuf[40];
|
||
int pickCount = 0;
|
||
ContactInfo ci;
|
||
for (int idx = 0; idx < the_mesh.getNumContacts() && pickCount < 40; idx++) {
|
||
if (!the_mesh.getContactByIdx(idx, ci)) continue;
|
||
if (ci.type != ADV_TYPE_CHAT) continue; // Only chat nodes
|
||
if (ci.name[0] == '\0') continue;
|
||
pickBuf[pickCount].meshIdx = idx;
|
||
strncpy(pickBuf[pickCount].name, ci.name, 31);
|
||
pickBuf[pickCount].name[31] = '\0';
|
||
pickBuf[pickCount].type = ci.type;
|
||
pickBuf[pickCount].hasDirect = (ci.out_path_len != OUT_PATH_UNKNOWN);
|
||
pickCount++;
|
||
}
|
||
// Sort: direct-path contacts first, then alphabetical within each group
|
||
std::sort(pickBuf, pickBuf + pickCount,
|
||
[](const VoiceMessageScreen::PickContact& a, const VoiceMessageScreen::PickContact& b) {
|
||
if (a.hasDirect != b.hasDirect) return a.hasDirect > b.hasDirect;
|
||
return strcasecmp(a.name, b.name) < 0;
|
||
});
|
||
voiceScr->loadPickContacts(pickBuf, pickCount);
|
||
pickContactsLoaded = true;
|
||
ui_task.forceRefresh();
|
||
}
|
||
} else {
|
||
pickContactsLoaded = false;
|
||
}
|
||
|
||
// --- Detect confirmed send from contact picker ---
|
||
// Queue all packets at once using sendDirect's built-in delay parameter.
|
||
// This lets the Mesh Dispatcher handle timing internally, spacing
|
||
// transmissions so the SPI bus (shared with SD card) isn't contended.
|
||
int sendIdx = voiceScr->consumePendingSend();
|
||
if (sendIdx >= 0 && voiceScr->hasCodec2Data()) {
|
||
cpuPower.setBoost();
|
||
uint32_t sessionId = (uint32_t)(millis() & 0xFFFFFFFF);
|
||
|
||
char envelope[64];
|
||
voiceScr->formatEnvelope(envelope, sizeof(envelope), sessionId);
|
||
|
||
ui_task.showAlert(MECK_TR("Sending voice...", "Envoi du vocal..."), 10000);
|
||
bool dmOk = the_mesh.uiSendDirectMessage(sendIdx, envelope);
|
||
Serial.printf("Voice: VE3 DM '%s' to idx %d: %s\n",
|
||
envelope, sendIdx, dmOk ? "OK" : "FAIL");
|
||
|
||
if (dmOk) {
|
||
// Look up recipient for direct sendDirect calls
|
||
ContactInfo ci;
|
||
the_mesh.getContactByIdx(sendIdx, ci);
|
||
ContactInfo* recipient = the_mesh.lookupContactByPubKey(ci.id.pub_key, PUB_KEY_SIZE);
|
||
|
||
int totalPkts = voiceScr->getOutSessionPacketCount();
|
||
int sentPkts = 0;
|
||
|
||
if (recipient && recipient->out_path_len != OUT_PATH_UNKNOWN) {
|
||
for (int p = 0; p < totalPkts; p++) {
|
||
uint8_t pktBuf[184];
|
||
int pktLen = voiceScr->buildVoicePacket(pktBuf, sizeof(pktBuf), sessionId, p);
|
||
if (pktLen > 0) {
|
||
mesh::Packet* raw = the_mesh.createRawData(pktBuf, pktLen);
|
||
if (raw) {
|
||
// Stagger packets: first at 3s (after VE3 + ACK + contact save),
|
||
// each subsequent 3s apart. The Dispatcher queues them all now
|
||
// and transmits at the specified delay offsets.
|
||
uint32_t delayMs = 3000 + (uint32_t)p * 3000;
|
||
the_mesh.sendDirect(raw, recipient->out_path, recipient->out_path_len, delayMs);
|
||
sentPkts++;
|
||
Serial.printf("Voice: Queued packet %d/%d (delay %dms)\n",
|
||
p + 1, totalPkts, delayMs);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Serial.printf("Voice: Queued %d/%d voice packets to %s\n",
|
||
sentPkts, totalPkts, recipient ? recipient->name : "?");
|
||
voiceScr->onSendComplete(sentPkts == totalPkts);
|
||
ui_task.showAlert(sentPkts == totalPkts ? MECK_TR("Voice sent!", "Vocal envoy\xC3\xA9 !") : MECK_TR("Send partial", "Envoi partiel"), 2000);
|
||
} else {
|
||
voiceScr->onSendComplete(false);
|
||
ui_task.showAlert(MECK_TR("Send failed!", "\xC3\x89" "chec de l'envoi !"), 1500);
|
||
}
|
||
ui_task.forceRefresh();
|
||
}
|
||
|
||
// --- Auto-play incoming voice session when all packets received ---
|
||
if (voiceScr->isIncomingReady()) {
|
||
the_mesh.setDeferSaves(false); // Resume contact saves
|
||
Serial.println("Voice: Incoming session complete — auto-playing");
|
||
cpuPower.setBoost();
|
||
if (voiceScr->playIncoming()) {
|
||
ui_task.showAlert(MECK_TR("Voice msg received!", "Vocal re\xC3\xA7u !"), 2000);
|
||
ui_task.gotoVoiceScreen();
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Voice decode failed", "\xC3\x89" "chec d\xC3\xA9" "codage vocal"), 1500);
|
||
}
|
||
ui_task.forceRefresh();
|
||
}
|
||
|
||
// Safety timeout: if saves are deferred for more than 15s, resume them
|
||
// (in case voice packets never arrive or session is abandoned)
|
||
static unsigned long deferStarted = 0;
|
||
if (the_mesh.isDeferSaves()) {
|
||
if (deferStarted == 0) deferStarted = millis();
|
||
if (millis() - deferStarted > 15000) {
|
||
the_mesh.setDeferSaves(false);
|
||
deferStarted = 0;
|
||
Serial.println("Voice: Save defer timeout — resuming saves");
|
||
}
|
||
} else {
|
||
deferStarted = 0;
|
||
}
|
||
|
||
// During recording: keep CPU fast for DMA reads
|
||
if (voiceScr->isRecording()) {
|
||
cpuPower.setBoost();
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// Canned messages: consume a tapped slot from the channel screen overlay
|
||
// and send it through the normal composed-message path (Max speech-bubble
|
||
// trigger). A channel view sends to that channel; a DM conversation sends
|
||
// a DM to that contact.
|
||
#if defined(LilyGo_TDeck_Pro_Max) && defined(MECK_TOUCH_ENABLED)
|
||
{
|
||
ChannelScreen* cannedChScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||
int cannedSlot = cannedChScr ? cannedChScr->consumeCannedSend() : -1;
|
||
if (cannedSlot >= 0 && cannedSlot < CANNED_MSG_SLOTS) {
|
||
const char* cannedMsg = the_mesh.getNodePrefs()->canned_msgs[cannedSlot];
|
||
if (cannedMsg[0]) {
|
||
strncpy(composeBuffer, cannedMsg, sizeof(composeBuffer) - 1);
|
||
composeBuffer[sizeof(composeBuffer) - 1] = '\0';
|
||
composePos = strlen(composeBuffer);
|
||
if (cannedChScr->isDMConversation() && cannedChScr->getDMContactIdx() >= 0) {
|
||
composeDM = true;
|
||
composeDMContactIdx = cannedChScr->getDMContactIdx();
|
||
ContactInfo cannedCi;
|
||
if (the_mesh.getContactByIdx((uint32_t)composeDMContactIdx, cannedCi)) {
|
||
strncpy(composeDMName, cannedCi.name, sizeof(composeDMName) - 1);
|
||
composeDMName[sizeof(composeDMName) - 1] = '\0';
|
||
} else {
|
||
composeDMName[0] = '\0';
|
||
}
|
||
} else {
|
||
composeDM = false;
|
||
composeChannelIdx = cannedChScr->getViewChannelIdx();
|
||
}
|
||
sendComposedMessage();
|
||
composeBuffer[0] = '\0';
|
||
composePos = 0;
|
||
composeDM = false;
|
||
ui_task.forceRefresh();
|
||
}
|
||
}
|
||
}
|
||
#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), MECK_TR("SMS: %s", "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), MECK_TR("Call: %s", "Appel : %s"), dispName);
|
||
ui_task.showAlert(alertBuf, 3000);
|
||
ui_task.notify(UIEventType::contactMessage);
|
||
|
||
// An incoming call drops the lock screen so the call can be answered.
|
||
// Keyboard and touch input are both blocked while locked, which would
|
||
// otherwise leave the ringing screen unable to answer or reject.
|
||
// The device stays unlocked after the call ends.
|
||
if (ui_task.isLocked()) {
|
||
ui_task.unlockScreen();
|
||
}
|
||
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), MECK_TR("Call Ended %lu:%02lu", "Appel termin\xC3\xA9 %lu:%02lu"),
|
||
(unsigned long)(callEvt.duration / 60),
|
||
(unsigned long)(callEvt.duration % 60));
|
||
} else {
|
||
snprintf(alertBuf, sizeof(alertBuf), MECK_TR("Call Ended", "Appel termin\xC3\xA9"));
|
||
}
|
||
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), MECK_TR("Missed: %s", "Manqu\xC3\xA9 : %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(MECK_TR("Line busy", "Ligne occup\xC3\xA9" "e"), 2000);
|
||
Serial.printf("[Call] Busy: %s\n", callEvt.phone);
|
||
ui_task.forceRefresh();
|
||
} else if (callEvt.type == CallEventType::NO_ANSWER) {
|
||
ui_task.showAlert(MECK_TR("No answer", "Pas de r\xC3\xA9ponse"), 2000);
|
||
Serial.printf("[Call] No answer: %s\n", callEvt.phone);
|
||
ui_task.forceRefresh();
|
||
} else if (callEvt.type == CallEventType::DIAL_FAILED) {
|
||
ui_task.showAlert(MECK_TR("Call failed", "\xC3\x89" "chec de l'appel"), 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() - lastComposeKeystroke) >= COMPOSE_TYPING_PAUSE
|
||
&& (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
|
||
|| dialScr->getSubView() == SMSScreen::APP_MENU)) {
|
||
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 MECK_AUDIO_VARIANT
|
||
voiceMode = ui_task.isOnVoiceScreen();
|
||
#endif
|
||
#ifdef HAS_4G_MODEM
|
||
smsMode = ui_task.isOnSMSScreen();
|
||
#endif
|
||
#else
|
||
ui_task.loop();
|
||
#endif
|
||
#endif
|
||
rtc_clock.tick();
|
||
// Periodic AGC reset - re-assert the RX gain mode to prevent sensitivity
|
||
// drift: boosted unless Settings > Experimental Features > RX Boosted Gain
|
||
// is off
|
||
#ifdef MECK_OTA_UPDATE
|
||
if (!otaRadioPaused)
|
||
#endif
|
||
if ((millis() - lastAGCReset) >= AGC_RESET_INTERVAL_MS) {
|
||
if (the_mesh.getNodePrefs()->rx_boosted_gain) {
|
||
radio_reset_agc();
|
||
} else {
|
||
radio.setRxBoostedGainMode(false);
|
||
}
|
||
lastAGCReset = millis();
|
||
}
|
||
// Handle T-Deck Pro keyboard input
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
handleKeyboardInput();
|
||
|
||
// Deferred Enter processing for contacts screen long-press detection.
|
||
// Enter is captured in handleKeyboardInput() but action is deferred so
|
||
// we can distinguish short press (DM/admin) from long press (select mode).
|
||
if (contactsEnterPending && ui_task.isOnContactsScreen()) {
|
||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||
bool inSelect = cs && cs->isInSelectMode();
|
||
|
||
if (!keyboard.isEnterHeld()) {
|
||
// Released — short press
|
||
contactsEnterPending = false;
|
||
if (inSelect) {
|
||
// In select mode: toggle current contact's checkbox
|
||
if (cs) { cs->toggleSelected(); ui_task.forceRefresh(); }
|
||
} else {
|
||
// Normal mode: fire the DM/admin action
|
||
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 {
|
||
composeDM = true;
|
||
composeDMContactIdx = idx;
|
||
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
|
||
composeMode = true;
|
||
composeBuffer[0] = '\0';
|
||
composePos = 0;
|
||
drawComposeScreen();
|
||
lastComposeRefresh = millis();
|
||
}
|
||
} else if (idx >= 0 && (ctype == ADV_TYPE_REPEATER || ctype == ADV_TYPE_ROOM)) {
|
||
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 (keyboard.enterHeldMs() >= 600 && !inSelect) {
|
||
// Long press threshold — enter select mode
|
||
contactsEnterPending = false;
|
||
if (cs) {
|
||
cs->enterSelectMode();
|
||
ui_task.forceRefresh();
|
||
Serial.println("Contacts: entered select mode (long press)");
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Touch Input -- tap/swipe/long-press state machine (T-Deck Pro)
|
||
// Gestures:
|
||
// Tap = finger down + up with minimal movement → select/open
|
||
// Swipe = finger drag > threshold → scroll/page turn
|
||
// Long press = finger held > 750ms without moving → edit/enter
|
||
// After processing an event, cooldown waits for finger lift before next event.
|
||
// Touch is disabled while lock screen is active.
|
||
// ---------------------------------------------------------------------------
|
||
#ifdef MECK_TOUCH_ENABLED
|
||
{
|
||
// Guard: skip touch when locked
|
||
bool touchBlocked = ui_task.isLocked();
|
||
// Settings > Experimental Features "Delete all contacts" box open: keys
|
||
// only, so a stray touch can't confirm it or leave Settings mid-purge
|
||
if (ui_task.isOnSettingsScreen()) {
|
||
SettingsScreen* purgeSS = (SettingsScreen*)ui_task.getSettingsScreen();
|
||
if (purgeSS && purgeSS->isPurgeBoxOpen()) touchBlocked = true;
|
||
}
|
||
#ifdef HAS_4G_MODEM
|
||
// SMS dialer has its own dedicated touch handler — don't consume touch data here
|
||
if (smsMode) {
|
||
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
|
||
if (smsScr && (smsScr->getSubView() == SMSScreen::PHONE_DIALER
|
||
|| smsScr->getSubView() == SMSScreen::APP_MENU)) {
|
||
touchBlocked = true;
|
||
}
|
||
}
|
||
#endif
|
||
|
||
if (!touchBlocked)
|
||
{
|
||
int16_t tx, ty;
|
||
bool gotPoint = readTouch(&tx, &ty);
|
||
unsigned long now = millis();
|
||
|
||
if (gotPoint) {
|
||
lastTouchSeenMs = now;
|
||
}
|
||
|
||
bool fingerPresent = (now - lastTouchSeenMs) < TOUCH_LIFT_DEBOUNCE_MS;
|
||
|
||
if (touchCooldown) {
|
||
if (!fingerPresent && (now - lastTouchEventMs) >= TOUCH_MIN_INTERVAL_MS) {
|
||
touchCooldown = false;
|
||
touchDown = false;
|
||
}
|
||
}
|
||
else if (gotPoint && !touchDown) {
|
||
touchDown = true;
|
||
touchDownTime = now;
|
||
touchDownX = tx;
|
||
touchDownY = ty;
|
||
touchLastX = tx;
|
||
touchLastY = ty;
|
||
longPressHandled = false;
|
||
swipeHandled = false;
|
||
}
|
||
else if (touchDown && fingerPresent) {
|
||
if (gotPoint) {
|
||
touchLastX = tx;
|
||
touchLastY = ty;
|
||
}
|
||
|
||
int16_t dx = touchLastX - touchDownX;
|
||
int16_t dy = touchLastY - touchDownY;
|
||
int16_t dist = abs(dx) > abs(dy) ? abs(dx) : abs(dy);
|
||
|
||
if (!swipeHandled && !longPressHandled && dist >= TOUCH_SWIPE_THRESHOLD) {
|
||
swipeHandled = true;
|
||
char c = mapTouchSwipe(dx, dy);
|
||
if (c) {
|
||
// Channel screen: horizontal swipe opens picker instead of cycling
|
||
if (ui_task.isOnChannelScreen() && (c == 'a' || c == 'd')) {
|
||
ui_task.gotoChannelPickerScreen();
|
||
} else {
|
||
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);
|
||
// Path editor: check if Save & Exit was triggered
|
||
if (ui_task.isOnPathEditor()) {
|
||
PathEditorScreen* pe = (PathEditorScreen*)ui_task.getPathEditorScreen();
|
||
if (pe && pe->wantsExit()) {
|
||
ui_task.gotoContactsScreen();
|
||
}
|
||
}
|
||
// Trace screen: check if Exit was triggered
|
||
if (ui_task.isOnTraceScreen()) {
|
||
TraceScreen* ts = (TraceScreen*)ui_task.getTraceScreen();
|
||
if (ts && ts->wantsExit()) {
|
||
ui_task.gotoHomeScreen();
|
||
}
|
||
}
|
||
// Snake screen: check if Exit was triggered
|
||
if (ui_task.isOnSnakeScreen()) {
|
||
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
|
||
if (ss && ss->wantsExit()) {
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
}
|
||
// Minesweeper screen: check if Exit was triggered
|
||
if (ui_task.isOnMinesweeperScreen()) {
|
||
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
|
||
if (ms && ms->wantsExit()) {
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
}
|
||
// Games menu: check if game launch was triggered
|
||
if (ui_task.isOnGamesMenu()) {
|
||
GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen();
|
||
if (gm && gm->wantsLaunch()) {
|
||
GameID sel = gm->selectedGame();
|
||
gm->clearFlags();
|
||
switch (sel) {
|
||
case GAME_SNAKE: ui_task.gotoSnakeScreen(); break;
|
||
case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break;
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
case GAME_GBC: ui_task.gotoGBCScreen(); break;
|
||
#endif
|
||
default: break;
|
||
}
|
||
}
|
||
}
|
||
// Channel picker: check if long-press Enter was handled (wantsExit)
|
||
if (ui_task.isOnChannelPickerScreen()) {
|
||
ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen();
|
||
if (pick && pick->wantsExit()) {
|
||
ui_task.gotoChannelScreen(false);
|
||
}
|
||
}
|
||
cpuPower.setBoost();
|
||
}
|
||
lastTouchEventMs = now;
|
||
touchCooldown = true;
|
||
}
|
||
}
|
||
else if (touchDown && !fingerPresent) {
|
||
touchDown = false;
|
||
if (!longPressHandled && !swipeHandled) {
|
||
char c = mapTouchTap(touchDownX, touchDownY);
|
||
ui_task.keepAlive(); // a tap keeps the display awake even if it produces no key
|
||
if (c) {
|
||
ui_task.injectKey(c);
|
||
// Path editor: check if Save & Exit was triggered
|
||
if (ui_task.isOnPathEditor()) {
|
||
PathEditorScreen* pe = (PathEditorScreen*)ui_task.getPathEditorScreen();
|
||
if (pe && pe->wantsExit()) {
|
||
ui_task.gotoContactsScreen();
|
||
}
|
||
}
|
||
// Trace screen: check if Exit was triggered
|
||
if (ui_task.isOnTraceScreen()) {
|
||
TraceScreen* ts = (TraceScreen*)ui_task.getTraceScreen();
|
||
if (ts && ts->wantsExit()) {
|
||
ui_task.gotoHomeScreen();
|
||
}
|
||
}
|
||
// Snake screen: check if Exit was triggered
|
||
if (ui_task.isOnSnakeScreen()) {
|
||
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
|
||
if (ss && ss->wantsExit()) {
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
}
|
||
// Minesweeper screen: check if Exit was triggered
|
||
if (ui_task.isOnMinesweeperScreen()) {
|
||
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
|
||
if (ms && ms->wantsExit()) {
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
}
|
||
// Games menu: check if game launch was triggered
|
||
if (ui_task.isOnGamesMenu()) {
|
||
GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen();
|
||
if (gm && gm->wantsLaunch()) {
|
||
GameID sel = gm->selectedGame();
|
||
gm->clearFlags();
|
||
switch (sel) {
|
||
case GAME_SNAKE: ui_task.gotoSnakeScreen(); break;
|
||
case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break;
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
case GAME_GBC: ui_task.gotoGBCScreen(); break;
|
||
#endif
|
||
default: break;
|
||
}
|
||
}
|
||
}
|
||
// Channel picker: check if Enter/Shift+Del was handled (wantsExit)
|
||
if (ui_task.isOnChannelPickerScreen()) {
|
||
ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen();
|
||
if (pick && pick->wantsExit()) {
|
||
ui_task.gotoChannelScreen(false);
|
||
}
|
||
}
|
||
}
|
||
cpuPower.setBoost();
|
||
lastTouchEventMs = now;
|
||
touchCooldown = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#endif // MECK_TOUCH_ENABLED
|
||
|
||
// 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
|
||
|| smsScr->getSubView() == SMSScreen::APP_MENU)) {
|
||
int16_t tx, ty;
|
||
#if defined(LilyGo_TDeck_Pro_Max) || defined(MECK_PRO_HYN_TOUCH)
|
||
int16_t _htx[1], _hty[1];
|
||
bool _have = (hyn_touch_get_point(_htx, _hty, 1) > 0);
|
||
if (_have) { tx = _htx[0]; ty = _hty[0]; }
|
||
if (_have) {
|
||
#else
|
||
if (touchInput.getPoint(tx, ty)) {
|
||
#endif
|
||
unsigned long now = millis();
|
||
if (!touchFingerDown && (now - lastTouchAccepted >= 150)) {
|
||
touchFingerDown = true;
|
||
lastTouchAccepted = now;
|
||
if (smsScr->handleTouch(tx, ty)) {
|
||
dialerNeedsRefresh = true;
|
||
// Don't reset the gate on touch: COMPOSE_REFRESH_INTERVAL is
|
||
// measured from the last completed render (set at the render
|
||
// site), so the first tap after a pause repaints immediately and
|
||
// only rapid successive taps stay throttled (no overlap).
|
||
}
|
||
}
|
||
} 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_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;
|
||
lastComposeKeystroke = 0;
|
||
} else {
|
||
MESH_DEBUG_PRINTLN("setup() - Keyboard initialization failed!");
|
||
}
|
||
}
|
||
|
||
#if defined(LilyGo_TDeck_Pro_Max)
|
||
// Both-shifts keyboard backlight toggle (MAX only -- IO42). Shared by the
|
||
// normal key path below and by the Game Boy emulator, whose raw joypad mode
|
||
// reads the keyboard itself, so the on/off state lives here rather than in
|
||
// handleKeyboardInput().
|
||
void toggleKeyboardBacklight() {
|
||
static bool kbdBacklightOn = false;
|
||
kbdBacklightOn = !kbdBacklightOn;
|
||
uint8_t kbPct = the_mesh.getNodePrefs()->kb_backlight_pct;
|
||
analogWrite(KB_BL_PIN, kbdBacklightOn ? (uint8_t)((kbPct * 255 + 50) / 100) : 0);
|
||
Serial.printf("Keyboard backlight %s\n", kbdBacklightOn ? "ON" : "OFF");
|
||
}
|
||
#endif
|
||
|
||
void handleKeyboardInput() {
|
||
if (!keyboard.isReady()) return;
|
||
|
||
char key = keyboard.readKey();
|
||
if (key == 0) return;
|
||
|
||
// Shift+Backspace is the universal back/cancel token. Convert it to
|
||
// KEY_CANCEL here at the source so every screen and dispatch path sees one
|
||
// unambiguous back code -- this leaves plain Backspace as text delete and
|
||
// frees physical Q as an ordinary letter. Contexts that bind Shift+Del to a
|
||
// non-back action (notes/contacts item delete) handle KEY_CANCEL explicitly.
|
||
if (key == '\b' && keyboard.wasShiftConsumed()) key = KEY_CANCEL;
|
||
|
||
// Block all keyboard input while lock screen is active.
|
||
// Still read the key above to clear the TCA8418 buffer.
|
||
if (ui_task.isLocked()) return;
|
||
|
||
// Dismiss boot navigation hint on any keypress
|
||
if (ui_task.isHintActive()) {
|
||
ui_task.dismissBootHint();
|
||
return; // Consume the keypress (don't act on it)
|
||
}
|
||
|
||
// Canned messages overlay: any key closes it. Keyboard keys on the Pro/Max
|
||
// are handled directly in this function and never reach
|
||
// ChannelScreen::handleInput, so the overlay's own key handling cannot see
|
||
// them -- close it here and consume the key.
|
||
if (ui_task.isOnChannelScreen()) {
|
||
ChannelScreen* cannedKeyScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||
if (cannedKeyScr && cannedKeyScr->isCannedOpen()) {
|
||
cannedKeyScr->closeCannedList();
|
||
ui_task.forceRefresh();
|
||
return;
|
||
}
|
||
}
|
||
|
||
Serial.printf("handleKeyboardInput: key='%c' (0x%02X) composeMode=%d\n",
|
||
key >= 32 ? key : '?', key, composeMode);
|
||
|
||
// Defer contact saves while user is actively pressing keys
|
||
the_mesh.notifyUserInput();
|
||
|
||
#if defined(LilyGo_TDeck_Pro_Max)
|
||
// Alt+B toggles the e-ink frontlight (MAX only -- working backlight on IO41)
|
||
if (key == KB_KEY_BACKLIGHT) {
|
||
if (board.isBacklightOn()) board.backlightOff();
|
||
else if (the_mesh.getNodePrefs()->backlight_alt_b_only) {
|
||
// Experimental "Change Backlight to Alt+B" on: use the Backlight
|
||
// Brightness setting, as the heart key does
|
||
uint8_t blPct = the_mesh.getNodePrefs()->backlight_brightness_pct;
|
||
board.backlightSetBrightness((uint8_t)((blPct * 255 + 50) / 100));
|
||
}
|
||
else board.backlightOn();
|
||
return;
|
||
}
|
||
// Both shifts together toggle the keyboard backlight (MAX only -- IO42).
|
||
if (key == KB_KEY_KBD_BACKLIGHT) {
|
||
toggleKeyboardBacklight();
|
||
return;
|
||
}
|
||
#endif
|
||
|
||
// Alarm ringing: ANY key dismisses (highest priority after lock screen)
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
{
|
||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||
if (alarmScr && alarmScr->isRinging()) {
|
||
if (key == 'z') {
|
||
alarmScr->handleInput('z'); // Snooze
|
||
} else {
|
||
alarmScr->dismiss(); // Any other key = dismiss
|
||
}
|
||
ui_task.gotoHomeScreen();
|
||
ui_task.forceRefresh();
|
||
return; // Consume the key
|
||
}
|
||
}
|
||
#endif
|
||
|
||
if (composeMode) {
|
||
// Emoji picker sub-mode
|
||
if (emojiPickerMode) {
|
||
uint8_t result = emojiPicker.handleInput(key);
|
||
if (result == 0xFF) {
|
||
// Cancelled - immediate draw to return to compose
|
||
emojiPickerMode = false;
|
||
drawComposeScreen();
|
||
lastComposeRefresh = millis();
|
||
composeNeedsRefresh = false;
|
||
} else if (result >= EMOJI_ESCAPE_START && result <= EMOJI_ESCAPE_END) {
|
||
// Emoji selected - insert escape byte + padding to match UTF-8 wire cost
|
||
int cost = emojiUtf8Cost(result);
|
||
if (composePos + cost <= 137) {
|
||
composeBuffer[composePos++] = (char)result;
|
||
for (int p = 1; p < cost; p++) {
|
||
composeBuffer[composePos++] = (char)EMOJI_PAD_BYTE;
|
||
}
|
||
composeBuffer[composePos] = '\0';
|
||
Serial.printf("Compose: Inserted emoji 0x%02X cost=%d, pos=%d\n", result, cost, composePos);
|
||
}
|
||
emojiPickerMode = false;
|
||
drawComposeScreen();
|
||
lastComposeRefresh = millis();
|
||
composeNeedsRefresh = false;
|
||
} else {
|
||
// Navigation - debounce (don't draw immediately, let loop handle it)
|
||
composeNeedsRefresh = true; lastComposeKeystroke = millis();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// In compose mode - handle text input
|
||
|
||
// Ignore mic key press/release while composing text
|
||
if (key == KB_KEY_MIC || key == KB_KEY_MIC_RELEASE) return;
|
||
|
||
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(MECK_TR("DM sent!", "MP envoy\xC3\xA9 !"), 1500);
|
||
} else if (wasDM) {
|
||
ui_task.gotoContactsScreen();
|
||
} else {
|
||
ui_task.gotoChannelScreen();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (key == '\b' || key == KEY_CANCEL) {
|
||
// Backspace - check if shift was recently pressed for cancel combo
|
||
if (key == KEY_CANCEL) {
|
||
// 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; lastComposeKeystroke = millis(); // Use debounced refresh
|
||
}
|
||
return;
|
||
}
|
||
|
||
// A/D keys switch channels (only when buffer is empty, not in DM mode)
|
||
if ((key == 'a') && composePos == 0 && !composeDM) {
|
||
// Previous channel — skip gaps
|
||
if (composeChannelIdx > 0) {
|
||
bool found = false;
|
||
for (uint8_t prev = composeChannelIdx - 1; ; prev--) {
|
||
ChannelDetails ch;
|
||
if (the_mesh.getChannel(prev, ch) && ch.name[0] != '\0') {
|
||
composeChannelIdx = prev;
|
||
found = true;
|
||
break;
|
||
}
|
||
if (prev == 0) break;
|
||
}
|
||
if (!found) {
|
||
// Wrap to last valid channel
|
||
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
|
||
ChannelDetails ch;
|
||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||
composeChannelIdx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Wrap to last valid channel
|
||
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
|
||
ChannelDetails ch;
|
||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||
composeChannelIdx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
|
||
composeNeedsRefresh = true; lastComposeKeystroke = millis(); // Debounced refresh
|
||
return;
|
||
}
|
||
|
||
if ((key == 'd') && composePos == 0 && !composeDM) {
|
||
// Next channel — skip gaps
|
||
bool found = false;
|
||
for (uint8_t next = composeChannelIdx + 1; next < MAX_GROUP_CHANNELS; next++) {
|
||
ChannelDetails ch;
|
||
if (the_mesh.getChannel(next, ch) && ch.name[0] != '\0') {
|
||
composeChannelIdx = next;
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found) {
|
||
composeChannelIdx = 0; // Wrap to first channel
|
||
}
|
||
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
|
||
composeNeedsRefresh = true; lastComposeKeystroke = millis(); // 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; lastComposeKeystroke = millis(); // Use debounced refresh
|
||
}
|
||
return;
|
||
}
|
||
|
||
// *** AUDIOBOOK MODE ***
|
||
#if !defined(HAS_4G_MODEM) || defined(MECK_AUDIO_VARIANT)
|
||
if (audiobookMode) {
|
||
AudiobookPlayerScreen* abPlayer =
|
||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||
|
||
// Shift+Del: 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 == KEY_CANCEL || 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
|
||
|
||
// *** VOICE MESSAGE MODE ***
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
if (voiceMode) {
|
||
VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)ui_task.getVoiceScreen();
|
||
if (!voiceScr) { voiceMode = false; }
|
||
else {
|
||
// Mic key press starts recording (PTT)
|
||
if (key == KB_KEY_MIC) {
|
||
voiceScr->onMicPress();
|
||
ui_task.forceRefresh();
|
||
return;
|
||
}
|
||
// Mic key release stops recording
|
||
if (key == KB_KEY_MIC_RELEASE) {
|
||
voiceScr->onMicRelease();
|
||
ui_task.forceRefresh();
|
||
return;
|
||
}
|
||
// 'q' = back/cancel in every voice mode (no text entry here); treat as Shift+Del.
|
||
if (key == 'q') key = KEY_CANCEL;
|
||
// Shift+Del from message list exits voice screen
|
||
if (key == KEY_CANCEL && voiceScr->getMode() == VoiceMessageScreen::MESSAGE_LIST) {
|
||
Serial.println("Exiting voice message screen");
|
||
ui_task.gotoHomeScreen();
|
||
return;
|
||
}
|
||
// All other keys pass through to voice screen
|
||
voiceScr->handleInput(key);
|
||
ui_task.forceRefresh();
|
||
return;
|
||
}
|
||
}
|
||
#endif // MECK_AUDIO_VARIANT
|
||
|
||
// *** TEXT READER MODE ***
|
||
if (readerMode) {
|
||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||
|
||
// Shift+Del: if reading, reader handles it (close book -> file list)
|
||
// if on file list, exit reader entirely
|
||
if (key == KEY_CANCEL || key == 'q') {
|
||
if (reader->isReading()) {
|
||
// Let the reader handle Shift+Del (close book, go to file list)
|
||
ui_task.injectKey(KEY_CANCEL);
|
||
} 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+Del (KEY_CANCEL) = save and exit
|
||
if (key == KEY_CANCEL) {
|
||
Serial.println("Notes: Shift+Del, saving...");
|
||
notes->saveAndExit();
|
||
ui_task.forceRefresh();
|
||
return;
|
||
}
|
||
// Plain Backspace = delete before cursor
|
||
if (key == '\b') {
|
||
ui_task.injectKey(key);
|
||
composeNeedsRefresh = true; lastComposeKeystroke = millis();
|
||
return;
|
||
}
|
||
|
||
// Cursor navigation via Shift+WASD (produces uppercase)
|
||
if (key == 'W') { notes->moveCursorUp(); composeNeedsRefresh = true; lastComposeKeystroke = millis(); return; }
|
||
if (key == 'A') { notes->moveCursorLeft(); composeNeedsRefresh = true; lastComposeKeystroke = millis(); return; }
|
||
if (key == 'S') { notes->moveCursorDown(); composeNeedsRefresh = true; lastComposeKeystroke = millis(); return; }
|
||
if (key == 'D') { notes->moveCursorRight(); composeNeedsRefresh = true; lastComposeKeystroke = millis(); return; }
|
||
|
||
// Enter = newline (pass through with debounce)
|
||
if (key == '\r') {
|
||
ui_task.injectKey(key);
|
||
composeNeedsRefresh = true; lastComposeKeystroke = millis();
|
||
return;
|
||
}
|
||
|
||
// All other printable chars (lowercase only - uppercase consumed by cursor nav)
|
||
if (key >= 32 && key < 127) {
|
||
ui_task.injectKey(key);
|
||
composeNeedsRefresh = true; lastComposeKeystroke = millis();
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ---- RENAMING MODE ----
|
||
if (notes->isRenaming()) {
|
||
// All input goes to rename handler (debounced like editing)
|
||
ui_task.injectKey(key);
|
||
composeNeedsRefresh = true; lastComposeKeystroke = millis();
|
||
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+Del on a file = delete with confirmation
|
||
if (key == KEY_CANCEL) {
|
||
if (notes->startDeleteFromList()) {
|
||
ui_task.forceRefresh();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// R on a file = rename
|
||
if (key == 'r') {
|
||
if (notes->startRename()) {
|
||
composeNeedsRefresh = true; lastComposeKeystroke = millis();
|
||
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; lastComposeKeystroke = millis();
|
||
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ---- READING MODE ----
|
||
if (notes->isReading()) {
|
||
if (key == 'q') {
|
||
ui_task.injectKey('q');
|
||
return;
|
||
}
|
||
|
||
// Shift+Del = delete note
|
||
if (key == KEY_CANCEL) {
|
||
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; lastComposeKeystroke = millis();
|
||
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
|
||
}
|
||
return;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// *** SETTINGS MODE ***
|
||
if (ui_task.isOnSettingsScreen()) {
|
||
SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen();
|
||
|
||
// Shift+Del: exit settings (when not editing)
|
||
if (!settings->isEditing() && (key == KEY_CANCEL || 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;
|
||
}
|
||
|
||
// Shift+Backspace during WiFi password entry: back to SSID selection
|
||
#ifdef MECK_WIFI_COMPANION
|
||
if (settings->isInWifiPasswordEntry() && key == KEY_CANCEL) {
|
||
settings->wifiPasswordBack();
|
||
ui_task.forceRefresh();
|
||
return;
|
||
}
|
||
#endif
|
||
|
||
// Shift+Backspace on the WiFi network picker: exit
|
||
#ifdef MECK_WIFI_COMPANION
|
||
if (settings->isInWifiNetworkSelect() && key == KEY_CANCEL) {
|
||
ui_task.injectKey(KEY_CANCEL);
|
||
return;
|
||
}
|
||
#endif
|
||
|
||
// All other keys -> settings screen via injectKey
|
||
ui_task.injectKey(key);
|
||
|
||
// Check for export/import requests from the settings screen
|
||
#ifdef HAS_SDCARD
|
||
if (settings->isExportRequested()) {
|
||
settings->clearExportRequest();
|
||
uint8_t flags = settings->getExportFlags();
|
||
if (flags == 0) {
|
||
ui_task.showAlert(MECK_TR("No sections selected", "Aucune section choisie"), 1500);
|
||
} else {
|
||
// Show the popup first; the export itself runs from loop()
|
||
// (meckExportPoll) once the popup has reached the e-ink.
|
||
ui_task.showAlert(MECK_TR("Compiling...\nPlease Wait...", "Compilation...\nVeuillez patienter..."), 10000);
|
||
meckExportPendingFlags = flags;
|
||
meckExportAt = millis() + 1500; // let the popup reach the e-ink first
|
||
meckExportPending = true;
|
||
}
|
||
}
|
||
if (settings->isImportRequested()) {
|
||
settings->clearImportRequest();
|
||
// Show the popup first; the import itself runs from loop()
|
||
// (meckImportPoll) once the popup has reached the e-ink.
|
||
ui_task.showAlert(MECK_TR("Importing...\nPlease Wait...", "Importation...\nVeuillez patienter..."), 10000);
|
||
meckImportAt = millis() + 1500; // let the popup reach the e-ink first
|
||
meckImportPending = true;
|
||
}
|
||
#endif
|
||
// Check for Rx Log open request from the settings screen
|
||
if (settings->isRxLogRequested()) {
|
||
settings->clearRxLogRequest();
|
||
ui_task.gotoRxLogScreen();
|
||
}
|
||
// Check for channel share request from the settings screen
|
||
if (settings->isShareRequested()) {
|
||
int contactIdx = settings->getShareContactIdx();
|
||
uint8_t channelIdx = settings->getShareChannelIdx();
|
||
settings->clearShareRequest();
|
||
|
||
ChannelDetails ch;
|
||
ContactInfo contact;
|
||
if (the_mesh.getChannel(channelIdx, ch) && ch.name[0] != '\0'
|
||
&& the_mesh.getContactByIdx(contactIdx, contact)) {
|
||
// Build share message: [MECK:CH]name|secret_hex
|
||
char shareMsg[128];
|
||
char hexSecret[33];
|
||
mesh::Utils::toHex(hexSecret, ch.channel.secret, 16);
|
||
snprintf(shareMsg, sizeof(shareMsg), "%s%s|%s",
|
||
MECK_CH_PREFIX, ch.name, hexSecret);
|
||
|
||
uint32_t sendRef = 0;
|
||
uint8_t sendTotal = 0;
|
||
if (the_mesh.uiSendDirectMessage((uint32_t)contactIdx, shareMsg, &sendRef, &sendTotal)) {
|
||
// Add sanitised version to DM conversation view
|
||
char displayMsg[64];
|
||
snprintf(displayMsg, sizeof(displayMsg), MECK_TR("Shared channel: %s", "Canal partag\xC3\xA9 : %s"), ch.name);
|
||
ui_task.addSentDM(contact.name, the_mesh.getNodePrefs()->node_name, displayMsg,
|
||
sendRef, sendTotal);
|
||
|
||
char alertBuf[48];
|
||
snprintf(alertBuf, sizeof(alertBuf), MECK_TR("Shared with %s", "Partag\xC3\xA9 avec %s"), contact.name);
|
||
ui_task.showAlert(alertBuf, 2000);
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Share failed", "\xC3\x89" "chec du partage"), 1500);
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// *** REPEATER ADMIN MODE ***
|
||
if (ui_task.isOnRepeaterAdmin()) {
|
||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
|
||
RepeaterAdminScreen::AdminState astate = admin->getState();
|
||
bool shiftDel = (key == KEY_CANCEL);
|
||
|
||
// 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 || key == 'q') {
|
||
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 || (key == 'q' && astate != RepeaterAdminScreen::STATE_PARAM_ENTRY)) {
|
||
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(MECK_TR("Call Ended", "Appel termin\xC3\xA9"), 2000);
|
||
}
|
||
// Force immediate render (call screen updates or return-to-dialer)
|
||
ui_task.forceRefresh();
|
||
ui_task.loop();
|
||
return;
|
||
}
|
||
|
||
// Shift+Del from app menu -> go home; Shift+Del from inner views is handled by SMSScreen
|
||
if ((key == KEY_CANCEL || key == 'q') && 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, Shift+Del=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 Shift+Del 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; lastComposeKeystroke = millis();
|
||
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;
|
||
|
||
// Shift+Del from HOME mode exits the web reader entirely (like text reader)
|
||
if ((key == KEY_CANCEL || 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
|
||
|
||
// Mic key release outside voice screen — ignore (PTT only matters on voice screen)
|
||
if (key == KB_KEY_MIC_RELEASE) return;
|
||
|
||
// Mic key press from any non-modal screen — open voice message screen
|
||
#if defined(MECK_AUDIO_VARIANT) && !defined(MECK_40MHZ_TEST)
|
||
if (key == KB_KEY_MIC) {
|
||
Serial.println("Opening voice message screen (mic key)");
|
||
if (!ui_task.getVoiceScreen()) {
|
||
Serial.printf("Voice: lazy init - free heap: %d, largest block: %d\n",
|
||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||
if (!audio) audio = new Audio();
|
||
VoiceMessageScreen* voiceScr = new VoiceMessageScreen(&ui_task, audio);
|
||
voiceScr->setSDReady(sdCardReady);
|
||
ui_task.setVoiceScreen(voiceScr);
|
||
Serial.printf("Voice: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||
} else {
|
||
// Ensure Audio* is shared (may have been created by audiobook/alarm)
|
||
VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)ui_task.getVoiceScreen();
|
||
if (!audio) audio = new Audio();
|
||
voiceScr->setAudio(audio);
|
||
}
|
||
ui_task.gotoVoiceScreen();
|
||
// Don't start recording here — user tapped mic to navigate.
|
||
// Recording starts on mic press when already on voice screen.
|
||
return;
|
||
}
|
||
#endif
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Contacts select mode — intercept keys before normal navigation
|
||
// ---------------------------------------------------------------------------
|
||
if (ui_task.isOnContactsScreen()) {
|
||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||
if (cs && cs->isInSelectMode()) {
|
||
switch (key) {
|
||
case KEY_CANCEL: {
|
||
// Shift+Del = delete selected (with confirmation). Select mode is
|
||
// exited via touch long-press (UITask long-press handler); plain
|
||
// Backspace is text-only and does nothing here.
|
||
{
|
||
static unsigned long lastDeleteAttempt = 0;
|
||
int selCount = cs->getSelectedCount();
|
||
if (selCount == 0) {
|
||
ui_task.showAlert(MECK_TR("None selected", "Aucune s\xC3\xA9lection"), 1500);
|
||
return;
|
||
}
|
||
if (millis() - lastDeleteAttempt < 3000) {
|
||
uint16_t* selBuf = (uint16_t*)malloc(selCount * sizeof(uint16_t));
|
||
if (!selBuf) { ui_task.showAlert(MECK_TR("Memory error", "Erreur m\xC3\xA9moire"), 1500); return; }
|
||
int n = cs->getSelectedRawIndices(selBuf, selCount);
|
||
int deleted = deleteSelectedContacts(selBuf, n);
|
||
free(selBuf);
|
||
char msg[48];
|
||
snprintf(msg, sizeof(msg), MECK_TR("Deleted %d contacts", "%d contacts supprim\xC3\xA9s"), deleted);
|
||
ui_task.showAlert(msg, 2000);
|
||
cs->exitSelectMode();
|
||
cs->invalidateCache();
|
||
ui_task.forceRefresh();
|
||
lastDeleteAttempt = 0;
|
||
} else {
|
||
lastDeleteAttempt = millis();
|
||
char msg[48];
|
||
snprintf(msg, sizeof(msg), MECK_TR("Delete %d? Shift+Del again", "Supprimer %d ? Shift+Del"), selCount);
|
||
ui_task.showAlert(msg, 2500);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
case 'x': {
|
||
// Export selected contacts as JSON
|
||
int selCount = cs->getSelectedCount();
|
||
if (selCount == 0) {
|
||
ui_task.showAlert(MECK_TR("None selected", "Aucune s\xC3\xA9lection"), 1500);
|
||
return;
|
||
}
|
||
uint16_t* selBuf = (uint16_t*)malloc(selCount * sizeof(uint16_t));
|
||
if (!selBuf) { ui_task.showAlert(MECK_TR("Memory error", "Erreur m\xC3\xA9moire"), 1500); return; }
|
||
int n = cs->getSelectedRawIndices(selBuf, selCount);
|
||
int exported = exportContactsJSON(selBuf, n);
|
||
free(selBuf);
|
||
if (exported >= 0) {
|
||
char msg[48];
|
||
snprintf(msg, sizeof(msg), MECK_TR("Exported %d to SD (JSON)", "%d export\xC3\xA9s sur SD (JSON)"), exported);
|
||
ui_task.showAlert(msg, 2500);
|
||
cs->exitSelectMode();
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Export failed", "\xC3\x89" "chec de l'export"), 2000);
|
||
}
|
||
ui_task.forceRefresh();
|
||
return;
|
||
}
|
||
|
||
case 'r': {
|
||
// Import contacts from JSON
|
||
int added = importContactsJSON();
|
||
if (added == -1) added = importContactsFromSD();
|
||
if (added > 0) {
|
||
cs->invalidateCache();
|
||
cs->exitSelectMode();
|
||
char msg[48];
|
||
snprintf(msg, sizeof(msg), MECK_TR("+%d imported (JSON)", "+%d import\xC3\xA9s (JSON)"), added);
|
||
ui_task.showAlert(msg, 2500);
|
||
} else if (added == 0) {
|
||
ui_task.showAlert(MECK_TR("No new contacts", "Aucun nouveau contact"), 2000);
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Import failed (no file?)", "\xC3\x89" "chec import (fichier ?)"), 2000);
|
||
}
|
||
ui_task.forceRefresh();
|
||
return;
|
||
}
|
||
|
||
case 'f': {
|
||
// Toggle favourite on selected contacts
|
||
int selCount = cs->getSelectedCount();
|
||
if (selCount == 0) {
|
||
ui_task.showAlert(MECK_TR("None selected", "Aucune s\xC3\xA9lection"), 1500);
|
||
return;
|
||
}
|
||
uint16_t* selBuf = (uint16_t*)malloc(selCount * sizeof(uint16_t));
|
||
if (!selBuf) { ui_task.showAlert(MECK_TR("Memory error", "Erreur m\xC3\xA9moire"), 1500); return; }
|
||
int n = cs->getSelectedRawIndices(selBuf, selCount);
|
||
int toggled = toggleFavouriteSelected(selBuf, n);
|
||
free(selBuf);
|
||
char msg[48];
|
||
snprintf(msg, sizeof(msg), MECK_TR("Toggled fav on %d", "Favoris modifi\xC3\xA9s : %d"), toggled);
|
||
ui_task.showAlert(msg, 1500);
|
||
cs->invalidateCache();
|
||
ui_task.forceRefresh();
|
||
return;
|
||
}
|
||
|
||
// W, S, A, D, Enter — handled by ContactsScreen::handleInput
|
||
default:
|
||
break; // Fall through to normal switch
|
||
}
|
||
}
|
||
}
|
||
|
||
switch (key) {
|
||
case 'c':
|
||
// Open contacts list
|
||
Serial.println("Opening contacts");
|
||
ui_task.gotoContactsScreen();
|
||
break;
|
||
|
||
case 'm':
|
||
// Go to channel picker (channel directory)
|
||
Serial.println("Opening channel picker");
|
||
ui_task.gotoChannelPickerScreen();
|
||
break;
|
||
|
||
case 'e':
|
||
// Open text reader (ebooks)
|
||
Serial.println("Opening text reader");
|
||
ui_task.gotoTextReader();
|
||
break;
|
||
|
||
case 'p':
|
||
// On contacts screen: open path editor for selected contact
|
||
if (ui_task.isOnContactsScreen()) {
|
||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||
if (cs) {
|
||
int idx = cs->getSelectedContactIdx();
|
||
if (idx >= 0) {
|
||
char pname[32];
|
||
cs->getSelectedContactName(pname, sizeof(pname));
|
||
Serial.printf("Opening path editor for %s (idx %d)\n", pname, idx);
|
||
ui_task.gotoPathEditor(idx);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
#if (!defined(HAS_4G_MODEM) || defined(MECK_AUDIO_VARIANT)) && !defined(MECK_40MHZ_TEST)
|
||
// Otherwise: open audiobook player - lazy-init Audio + screen on first use
|
||
Serial.println("Opening audiobook player");
|
||
if (!ui_task.getAudiobookScreen()) {
|
||
Serial.printf("Audiobook: lazy init - free heap: %d, largest block: %d\n",
|
||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||
audio = new Audio();
|
||
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio, the_mesh.getNodePrefs());
|
||
abScreen->setSDReady(sdCardReady);
|
||
ui_task.setAudiobookScreen(abScreen);
|
||
Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||
}
|
||
ui_task.gotoAudiobookPlayer();
|
||
#endif
|
||
break;
|
||
|
||
#if defined(MECK_AUDIO_VARIANT) && !defined(MECK_40MHZ_TEST)
|
||
case 'k':
|
||
// Open alarm clock (screen created at boot; just ensure Audio* is available)
|
||
Serial.println("Opening alarm clock");
|
||
if (!audio) {
|
||
Serial.printf("Alarm: lazy init Audio - free heap: %d, largest block: %d\n",
|
||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||
audio = new Audio();
|
||
}
|
||
{
|
||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||
if (alarmScr) alarmScr->setAudio(audio);
|
||
}
|
||
ui_task.gotoAlarmScreen();
|
||
break;
|
||
#endif
|
||
|
||
#if defined(HAS_4G_MODEM) && !defined(MECK_40MHZ_TEST)
|
||
case 't':
|
||
// Open SMS (4G variant only)
|
||
Serial.println("Opening SMS");
|
||
ui_task.gotoSMSScreen();
|
||
break;
|
||
#endif
|
||
|
||
#if defined(MECK_WEB_READER) && !defined(MECK_40MHZ_TEST)
|
||
case 'b':
|
||
// Open web reader (browser)
|
||
Serial.println("Opening web reader");
|
||
{
|
||
static bool webReaderWifiReady = false;
|
||
#if defined(BLE_PIN_CODE) && defined(MECK_WIFI_COMPANION)
|
||
// Combined build: with the WiFi companion on, WiFi is already up and
|
||
// Bluetooth is off, so nothing needs freeing. Otherwise do as the BLE
|
||
// build does, once per boot: switch the Bluetooth companion off, free
|
||
// Bluetooth's memory (it then needs a reboot) and start WiFi.
|
||
if (!meckCompanionIsWiFi() && !webReaderWifiReady) {
|
||
if (meckCompanionIsBLE()) meckCompanionUseNone();
|
||
Serial.printf("WebReader: heap BEFORE BT release: free=%d, largest=%d\n",
|
||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||
btStop();
|
||
delay(50);
|
||
esp_bt_controller_mem_release(ESP_BT_MODE_BTDM);
|
||
delay(50);
|
||
meckBtReleased = true;
|
||
Serial.printf("WebReader: heap AFTER BT release: free=%d, largest=%d\n",
|
||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||
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);
|
||
}
|
||
}
|
||
meckLogMemory("Web Reader start");
|
||
#else
|
||
#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());
|
||
}
|
||
#endif
|
||
}
|
||
ui_task.gotoWebReader();
|
||
break;
|
||
#endif
|
||
|
||
case 'g':
|
||
#ifndef MECK_40MHZ_TEST
|
||
// 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 {
|
||
openMapScreen();
|
||
}
|
||
#endif // MECK_40MHZ_TEST: map gated
|
||
break;
|
||
|
||
case 'n':
|
||
// Open notes
|
||
Serial.println("Opening notes");
|
||
ui_task.gotoNotesScreen();
|
||
break;
|
||
|
||
case 'S':
|
||
// Shift+S: page scroll down on list screens
|
||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|
||
|| ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen()
|
||
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|
||
|| ui_task.isOnTraceScreen()
|
||
|| ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
|| ui_task.isOnGBCScreen()
|
||
#endif
|
||
#ifdef MECK_WEB_READER
|
||
|| ui_task.isOnWebReader()
|
||
#endif
|
||
|| ui_task.isOnMapScreen()
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
|| ui_task.isOnAlarmScreen()
|
||
#endif
|
||
|| ui_task.isOnRxLogScreen()
|
||
) {
|
||
ui_task.injectKey('S');
|
||
}
|
||
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()
|
||
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|
||
|| ui_task.isOnTraceScreen()
|
||
|| ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
|| ui_task.isOnGBCScreen()
|
||
#endif
|
||
#ifdef MECK_WEB_READER
|
||
|| ui_task.isOnWebReader()
|
||
#endif
|
||
|| ui_task.isOnMapScreen()
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
|| ui_task.isOnAlarmScreen()
|
||
#endif
|
||
|| ui_task.isHomeOnShutdownPage()
|
||
|| ui_task.isHomeOnTimezonesPage()
|
||
|| ui_task.isOnRxLogScreen()
|
||
) {
|
||
ui_task.injectKey('s'); // Pass directly for scrolling
|
||
} else {
|
||
Serial.println("Opening settings");
|
||
ui_task.gotoSettingsScreen();
|
||
}
|
||
break;
|
||
|
||
case 'W':
|
||
// Shift+W: page scroll up on list screens
|
||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|
||
|| ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen()
|
||
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|
||
|| ui_task.isOnTraceScreen()
|
||
|| ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
|| ui_task.isOnGBCScreen()
|
||
#endif
|
||
#ifdef MECK_WEB_READER
|
||
|| ui_task.isOnWebReader()
|
||
#endif
|
||
|| ui_task.isOnMapScreen()
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
|| ui_task.isOnAlarmScreen()
|
||
#endif
|
||
|| ui_task.isOnRxLogScreen()
|
||
) {
|
||
ui_task.injectKey('W');
|
||
}
|
||
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()
|
||
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|
||
|| ui_task.isOnTraceScreen()
|
||
|| ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
|| ui_task.isOnGBCScreen()
|
||
#endif
|
||
#ifdef MECK_WEB_READER
|
||
|| ui_task.isOnWebReader()
|
||
#endif
|
||
|| ui_task.isOnMapScreen()
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
|| ui_task.isOnAlarmScreen()
|
||
#endif
|
||
|| ui_task.isHomeOnTimezonesPage()
|
||
|| ui_task.isOnRxLogScreen()
|
||
) {
|
||
ui_task.injectKey('w'); // Pass directly for scrolling
|
||
} else {
|
||
Serial.println("Nav: Previous");
|
||
ui_task.injectKey(0xF2); // KEY_PREV
|
||
}
|
||
break;
|
||
|
||
case 'a':
|
||
// Navigate left — channel screen opens picker, others pass through
|
||
if (ui_task.isOnChannelScreen()) {
|
||
ui_task.gotoChannelPickerScreen();
|
||
} else if (ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()
|
||
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|
||
|| ui_task.isOnTraceScreen()
|
||
|| ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
|| ui_task.isOnAlarmScreen()
|
||
#endif
|
||
) {
|
||
ui_task.injectKey('a'); // Pass directly for contacts/map/picker navigation
|
||
} else {
|
||
Serial.println("Nav: Previous");
|
||
ui_task.injectKey(0xF2); // KEY_PREV
|
||
}
|
||
break;
|
||
|
||
case 'd':
|
||
// Navigate right — channel screen opens picker, others pass through
|
||
if (ui_task.isOnChannelScreen()) {
|
||
ui_task.gotoChannelPickerScreen();
|
||
} else if (ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()
|
||
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|
||
|| ui_task.isOnTraceScreen()
|
||
|| ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
|| ui_task.isOnAlarmScreen()
|
||
#endif
|
||
) {
|
||
ui_task.injectKey('d'); // Pass directly for contacts/map/picker navigation
|
||
} else {
|
||
Serial.println("Nav: Next");
|
||
ui_task.injectKey(0xF1); // KEY_NEXT
|
||
}
|
||
break;
|
||
|
||
case '\r':
|
||
// Select/Enter - path editor handles Enter internally
|
||
if (ui_task.isOnPathEditor()) {
|
||
ui_task.injectKey('\r');
|
||
// Check if Save & Exit was selected
|
||
PathEditorScreen* pe = (PathEditorScreen*)ui_task.getPathEditorScreen();
|
||
if (pe && pe->wantsExit()) {
|
||
Serial.println("PathEditor: Save & Exit -- returning to contacts");
|
||
ui_task.gotoContactsScreen();
|
||
}
|
||
} else if (ui_task.isOnTraceScreen()) {
|
||
ui_task.injectKey('\r');
|
||
TraceScreen* ts = (TraceScreen*)ui_task.getTraceScreen();
|
||
if (ts && ts->wantsExit()) {
|
||
Serial.println("TraceScreen: Exit -- returning to home");
|
||
ui_task.gotoHomeScreen();
|
||
}
|
||
} else if (ui_task.isOnGamesMenu()) {
|
||
ui_task.injectKey('\r');
|
||
GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen();
|
||
if (gm && gm->wantsLaunch()) {
|
||
GameID sel = gm->selectedGame();
|
||
gm->clearFlags();
|
||
switch (sel) {
|
||
case GAME_SNAKE: ui_task.gotoSnakeScreen(); break;
|
||
case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break;
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
case GAME_GBC: ui_task.gotoGBCScreen(); break;
|
||
#endif
|
||
// case GAME_2048: ui_task.goto2048Screen(); break;
|
||
default: break;
|
||
}
|
||
}
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
} else if (ui_task.isOnGBCScreen()) {
|
||
ui_task.injectKey('\r'); // ROM list: play the highlighted ROM
|
||
#endif
|
||
} else if (ui_task.isOnSnakeScreen()) {
|
||
ui_task.injectKey('\r');
|
||
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
|
||
if (ss && ss->wantsExit()) {
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
} else if (ui_task.isOnMinesweeperScreen()) {
|
||
ui_task.injectKey('\r');
|
||
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
|
||
if (ms && ms->wantsExit()) {
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
} else if (ui_task.isOnChannelPickerScreen()) {
|
||
ui_task.injectKey('\r'); // Picker handles Enter: selects channel + sets wantsExit
|
||
ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen();
|
||
if (pick && pick->wantsExit()) {
|
||
ui_task.gotoChannelScreen(false);
|
||
}
|
||
} else if (ui_task.isOnContactsScreen()) {
|
||
// Defer Enter for long-press detection (select mode vs DM/admin)
|
||
contactsEnterPending = true;
|
||
contactsEnterTime = millis();
|
||
Serial.println("Contacts: Enter pressed, deferring for long-press check");
|
||
} 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(MECK_TR("No contact selected", "Aucun contact choisi"), 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(MECK_TR("Already in contacts", "D\xC3\xA9j\xC3\xA0 dans les contacts"), 800);
|
||
} else if (the_mesh.addDiscoveredToContacts(didx)) {
|
||
char alertBuf[48];
|
||
snprintf(alertBuf, sizeof(alertBuf), MECK_TR("Added: %s", "Ajout\xC3\xA9 : %s"), node.contact.name);
|
||
ui_task.showAlert(alertBuf, 1500);
|
||
ui_task.notify(UIEventType::ack);
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Add failed", "\xC3\x89" "chec de l'ajout"), 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()) {
|
||
// Normal mode: export ALL as JSON + binary backup
|
||
Serial.println("Contacts: Exporting all to SD...");
|
||
exportContactsToSD(); // Binary backup (legacy)
|
||
int exported = exportContactsJSON(NULL, 0); // JSON (interchangeable)
|
||
if (exported >= 0) {
|
||
char alertBuf[48];
|
||
snprintf(alertBuf, sizeof(alertBuf), MECK_TR("Exported %d to SD", "%d export\xC3\xA9s sur SD"), exported);
|
||
ui_task.showAlert(alertBuf, 2000);
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Export failed (check serial)", "\xC3\x89" "chec export (voir s\xC3\xA9rie)"), 2000);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'r':
|
||
// Reply select (channel), import contacts, trace screen passthrough, or open trace (home)
|
||
if (ui_task.isOnChannelScreen()) {
|
||
ui_task.injectKey('r');
|
||
} else if (ui_task.isOnTraceScreen()) {
|
||
ui_task.injectKey('r'); // Pass to trace screen (for edit mode)
|
||
} else if (ui_task.isOnContactsScreen()) {
|
||
// Try JSON first, fall back to binary
|
||
Serial.println("Contacts: Importing from SD...");
|
||
int added = importContactsJSON();
|
||
if (added == -1) {
|
||
// No JSON file -- try legacy binary
|
||
added = importContactsFromSD();
|
||
}
|
||
if (added > 0) {
|
||
ContactsScreen* cs2 = (ContactsScreen*)ui_task.getContactsScreen();
|
||
if (cs2) cs2->invalidateCache();
|
||
char alertBuf[48];
|
||
snprintf(alertBuf, sizeof(alertBuf), MECK_TR("+%d imported (%d total)", "+%d import\xC3\xA9s (total %d)"),
|
||
added, (int)the_mesh.getNumContacts());
|
||
ui_task.showAlert(alertBuf, 2500);
|
||
} else if (added == 0) {
|
||
ui_task.showAlert(MECK_TR("No new contacts to add", "Aucun nouveau contact"), 2000);
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Import failed (no file?)", "\xC3\x89" "chec import (fichier ?)"), 2000);
|
||
}
|
||
} else if (ui_task.isOnHomeScreen()) {
|
||
Serial.println("Opening trace path");
|
||
ui_task.gotoTraceScreen();
|
||
}
|
||
break;
|
||
|
||
case 'f':
|
||
// Minesweeper: F toggles flag on cursor cell
|
||
if (ui_task.isOnMinesweeperScreen()) {
|
||
ui_task.injectKey('f');
|
||
}
|
||
// Start discovery scan from home/contacts screen, or rescan on discovery screen
|
||
else 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 'j':
|
||
// Open games menu from home screen
|
||
if (ui_task.isOnHomeScreen()) {
|
||
Serial.println("Opening games menu");
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
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 KEY_CANCEL:
|
||
// 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(KEY_CANCEL);
|
||
break;
|
||
}
|
||
if (chScr && chScr->isShowingPathOverlay()) {
|
||
ui_task.injectKey(KEY_CANCEL);
|
||
break;
|
||
}
|
||
// DM inbox back is handled by ChannelScreen (returns false -> falls here).
|
||
// DM conversation back is handled internally (returns true -> never reaches here).
|
||
// Normal channel view or DM inbox: go back to picker.
|
||
Serial.println("Nav: Channel -> Picker");
|
||
ui_task.gotoChannelPickerScreen();
|
||
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(KEY_CANCEL);
|
||
break;
|
||
}
|
||
}
|
||
#endif
|
||
// Contacts: select mode handled earlier (Shift+Del = delete); normal -> home
|
||
if (ui_task.isOnContactsScreen()) {
|
||
ContactsScreen* csq = (ContactsScreen*)ui_task.getContactsScreen();
|
||
if (csq && csq->isInSelectMode()) {
|
||
csq->exitSelectMode();
|
||
ui_task.forceRefresh();
|
||
Serial.println("Contacts: exited select mode (Q/backspace)");
|
||
break;
|
||
}
|
||
// Normal mode: fall through to go home
|
||
}
|
||
// Discovery screen: Shift+Del goes back to contacts (not home)
|
||
if (ui_task.isOnDiscoveryScreen()) {
|
||
the_mesh.stopDiscovery();
|
||
Serial.println("Nav: Discovery -> Contacts");
|
||
ui_task.gotoContactsScreen();
|
||
break;
|
||
}
|
||
// Rx Log screen: Shift+Del goes back to settings (screen handles it)
|
||
if (ui_task.isOnRxLogScreen()) {
|
||
ui_task.injectKey(KEY_CANCEL);
|
||
break;
|
||
}
|
||
// Path editor: Shift+Del goes back to contacts (discards unsaved changes)
|
||
if (ui_task.isOnPathEditor()) {
|
||
Serial.println("Nav: PathEditor -> Contacts");
|
||
ui_task.gotoContactsScreen();
|
||
break;
|
||
}
|
||
// Trace screen: Shift+Del/wantsExit goes home
|
||
if (ui_task.isOnTraceScreen()) {
|
||
ui_task.injectKey(KEY_CANCEL);
|
||
TraceScreen* ts = (TraceScreen*)ui_task.getTraceScreen();
|
||
if (ts && ts->wantsExit()) {
|
||
Serial.println("Nav: Trace -> Home");
|
||
ui_task.gotoHomeScreen();
|
||
}
|
||
break;
|
||
}
|
||
#if defined(LilyGo_TDeck_Pro)
|
||
// Game Boy screen: Shift+Del in the ROM list goes back to games menu.
|
||
// While a game runs the keyboard is in raw joypad mode and no key
|
||
// reaches here; the quit chord is handled inside the emulator screen.
|
||
if (ui_task.isOnGBCScreen()) {
|
||
ui_task.injectKey(KEY_CANCEL);
|
||
GBCEmulatorScreen* gbs = (GBCEmulatorScreen*)ui_task.getGBCScreen();
|
||
if (gbs && gbs->wantsExit()) {
|
||
Serial.println("Nav: Game Boy -> Games Menu");
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
break;
|
||
}
|
||
#endif
|
||
// Snake screen: Shift+Del goes back to games menu
|
||
if (ui_task.isOnSnakeScreen()) {
|
||
ui_task.injectKey(KEY_CANCEL);
|
||
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
|
||
if (ss && ss->wantsExit()) {
|
||
Serial.println("Nav: Snake -> Games Menu");
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
break;
|
||
}
|
||
// Minesweeper screen: Shift+Del goes back to games menu
|
||
if (ui_task.isOnMinesweeperScreen()) {
|
||
ui_task.injectKey(KEY_CANCEL);
|
||
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
|
||
if (ms && ms->wantsExit()) {
|
||
Serial.println("Nav: Minesweeper -> Games Menu");
|
||
ui_task.gotoGamesMenu();
|
||
}
|
||
break;
|
||
}
|
||
// Games menu: Shift+Del goes back to home
|
||
if (ui_task.isOnGamesMenu()) {
|
||
Serial.println("Nav: Games Menu -> Home");
|
||
ui_task.gotoHomeScreen();
|
||
break;
|
||
}
|
||
// Alarm screen: Shift+Del/backspace routing depends on sub-mode
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
if (ui_task.isOnAlarmScreen()) {
|
||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||
if (alarmScr && alarmScr->isRinging()) {
|
||
alarmScr->dismiss();
|
||
ui_task.gotoHomeScreen();
|
||
} else if (alarmScr && alarmScr->getMode() != AlarmScreen::ALARM_LIST) {
|
||
// In edit/picker/digit mode -- pass to screen (Shift+Del = back to list, backspace = delete)
|
||
ui_task.injectKey(key);
|
||
} else {
|
||
// On alarm list — go home
|
||
Serial.println("Nav: Alarm -> Home");
|
||
ui_task.gotoHomeScreen();
|
||
}
|
||
break;
|
||
}
|
||
#endif
|
||
// Last Heard: Shift+Del goes back to home
|
||
if (ui_task.isOnLastHeardScreen()) {
|
||
Serial.println("Nav: Last Heard -> Home");
|
||
ui_task.gotoHomeScreen();
|
||
break;
|
||
}
|
||
// Channel picker: Shift+Del goes back to home
|
||
if (ui_task.isOnChannelPickerScreen()) {
|
||
Serial.println("Nav: ChannelPicker -> Home");
|
||
ui_task.gotoHomeScreen();
|
||
break;
|
||
}
|
||
// Go back to home screen (admin mode handled above)
|
||
Serial.println("Nav: Back to home");
|
||
ui_task.gotoHomeScreen();
|
||
break;
|
||
|
||
case ' ':
|
||
// Space - also acts as next/select
|
||
Serial.println("Nav: Space (Next)");
|
||
ui_task.injectKey(0xF1); // KEY_NEXT
|
||
break;
|
||
|
||
case 'u':
|
||
// UTC offset edit (home screen GPS page handles this)
|
||
ui_task.injectKey('u');
|
||
break;
|
||
|
||
case 'v':
|
||
// View path overlay (channel screen only)
|
||
if (ui_task.isOnChannelScreen()) {
|
||
ui_task.injectKey('v');
|
||
}
|
||
break;
|
||
|
||
default:
|
||
#ifdef MECK_WEB_READER
|
||
// Pass unhandled keys to web reader (l=link, g=go, k=bookmark, 0-9=link#)
|
||
if (ui_task.isOnWebReader()) {
|
||
ui_task.injectKey(key);
|
||
break;
|
||
}
|
||
#endif
|
||
// Pass unhandled keys to map screen (+, -, i, o for zoom)
|
||
if (ui_task.isOnMapScreen()) {
|
||
ui_task.injectKey(key);
|
||
break;
|
||
}
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
// Pass unhandled keys to alarm screen (digits for time entry, o for toggle)
|
||
if (ui_task.isOnAlarmScreen()) {
|
||
ui_task.injectKey(key);
|
||
break;
|
||
}
|
||
#endif
|
||
// Pass unhandled keys to trace screen (digits, comma for path entry)
|
||
if (ui_task.isOnTraceScreen()) {
|
||
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), MECK_TR("DM: %s", "MP : %s"), composeDMName);
|
||
} else {
|
||
ChannelDetails channel;
|
||
if (the_mesh.getChannel(composeChannelIdx, channel)) {
|
||
snprintf(headerBuf, sizeof(headerBuf), MECK_TR("To: %s", "\xC3\x80 : %s"), channel.name);
|
||
} else {
|
||
snprintf(headerBuf, sizeof(headerBuf), MECK_TR("To: Channel %d", "\xC3\x80 : Canal %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;
|
||
// The e-ink driver draws everything 2 virtual units to the right of the
|
||
// cursor it is given (its x offset), so a line measured against the full
|
||
// width can run a few physical pixels past the panel edge. Leave that
|
||
// margin out of the usable width.
|
||
int lineW = display.width() - 2;
|
||
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) {
|
||
uint32_t sendRef = 0;
|
||
uint8_t sendTotal = 0;
|
||
if (the_mesh.uiSendDirectMessage((uint32_t)composeDMContactIdx, utf8Buf, &sendRef, &sendTotal)) {
|
||
// Add to channel screen so sent DM appears in conversation view
|
||
ui_task.addSentDM(composeDMName, the_mesh.getNodePrefs()->node_name, utf8Buf,
|
||
sendRef, sendTotal);
|
||
ui_task.showAlert(MECK_TR("DM sent!", "MP envoy\xC3\xA9 !"), 1500);
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("DM failed!", "\xC3\x89" "chec du MP !"), 1500);
|
||
}
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("No contact!", "Aucun 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(MECK_TR("Sent!", "Envoy\xC3\xA9 !"), 1500);
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("Send failed!", "\xC3\x89" "chec de l'envoi !"), 1500);
|
||
}
|
||
} else {
|
||
ui_task.showAlert(MECK_TR("No channel!", "Aucun canal !"), 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).
|
||
|
||
#if !defined(HAS_4G_MODEM) || defined(MECK_AUDIO_VARIANT)
|
||
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
|