1370 lines
43 KiB
C++
1370 lines
43 KiB
C++
#include <Arduino.h> // needed for PlatformIO
|
|
#include <Mesh.h>
|
|
#include "MyMesh.h"
|
|
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
|
|
#include "target.h" // For sensors, board, etc.
|
|
#include "GPSDutyCycle.h"
|
|
#include "CPUPowerManager.h"
|
|
|
|
// T-Deck Pro Keyboard support
|
|
#if defined(LilyGo_TDeck_Pro)
|
|
#include "TCA8418Keyboard.h"
|
|
#include <SD.h>
|
|
#include "TextReaderScreen.h"
|
|
#include "NotesScreen.h"
|
|
#include "ContactsScreen.h"
|
|
#include "ChannelScreen.h"
|
|
#include "SettingsScreen.h"
|
|
#include "RepeaterAdminScreen.h"
|
|
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
|
|
|
|
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
|
|
|
|
// Compose mode state
|
|
static bool composeMode = false;
|
|
static char composeBuffer[138]; // 137 bytes max + null terminator (matches BLE wire cost)
|
|
static int composePos = 0; // Current wire-cost byte count
|
|
static uint8_t composeChannelIdx = 0;
|
|
static unsigned long lastComposeRefresh = 0;
|
|
static bool composeNeedsRefresh = false;
|
|
#define COMPOSE_REFRESH_INTERVAL 100 // ms before starting e-ink refresh after keypress (refresh itself takes ~644ms)
|
|
|
|
// DM compose mode (direct message to a specific contact)
|
|
static bool composeDM = false;
|
|
static int composeDMContactIdx = -1;
|
|
static char composeDMName[32];
|
|
// AGC reset - periodically re-assert RX boosted gain to prevent sensitivity drift
|
|
#define AGC_RESET_INTERVAL_MS 500
|
|
static unsigned long lastAGCReset = 0;
|
|
|
|
// Emoji picker state
|
|
#include "EmojiPicker.h"
|
|
static bool emojiPickerMode = false;
|
|
static EmojiPicker emojiPicker;
|
|
|
|
// Text reader mode state
|
|
static bool readerMode = false;
|
|
|
|
// Notes mode state
|
|
static bool notesMode = false;
|
|
|
|
// Power management
|
|
#if HAS_GPS
|
|
GPSDutyCycle gpsDuty;
|
|
#endif
|
|
CPUPowerManager cpuPower;
|
|
|
|
void initKeyboard();
|
|
void handleKeyboardInput();
|
|
void drawComposeScreen();
|
|
void drawEmojiPicker();
|
|
void sendComposedMessage();
|
|
|
|
// SD-backed persistence state
|
|
static bool sdCardReady = false;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SD Settings Backup / Restore
|
|
// ---------------------------------------------------------------------------
|
|
// Copies a file byte-for-byte between two filesystem objects.
|
|
// Works across SPIFFS <-> SD because both use the Arduino File API.
|
|
static bool copyFile(fs::FS& srcFS, const char* srcPath,
|
|
fs::FS& dstFS, const char* dstPath) {
|
|
File src = srcFS.open(srcPath, "r");
|
|
if (!src) return false;
|
|
File dst = dstFS.open(dstPath, "w", true);
|
|
if (!dst) { src.close(); return false; }
|
|
|
|
uint8_t buf[128];
|
|
while (src.available()) {
|
|
int n = src.read(buf, sizeof(buf));
|
|
if (n > 0) dst.write(buf, n);
|
|
}
|
|
src.close();
|
|
dst.close();
|
|
return true;
|
|
}
|
|
|
|
// Backup prefs, channels, and identity from SPIFFS to SD card.
|
|
// Called after any savePrefs() to keep the SD mirror current.
|
|
void backupSettingsToSD() {
|
|
if (!sdCardReady) return;
|
|
|
|
if (!SD.exists("/meshcore")) SD.mkdir("/meshcore");
|
|
|
|
if (SPIFFS.exists("/new_prefs")) {
|
|
copyFile(SPIFFS, "/new_prefs", SD, "/meshcore/prefs.bin");
|
|
}
|
|
// Channels may live on SPIFFS or ExtraFS - on ESP32 they are on SPIFFS
|
|
if (SPIFFS.exists("/channels2")) {
|
|
copyFile(SPIFFS, "/channels2", SD, "/meshcore/channels.bin");
|
|
}
|
|
// Identity
|
|
if (SPIFFS.exists("/identity/_main.id")) {
|
|
if (!SD.exists("/meshcore/identity")) SD.mkdir("/meshcore/identity");
|
|
copyFile(SPIFFS, "/identity/_main.id", SD, "/meshcore/identity/_main.id");
|
|
}
|
|
// Contacts
|
|
if (SPIFFS.exists("/contacts3")) {
|
|
copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin");
|
|
}
|
|
|
|
digitalWrite(SDCARD_CS, HIGH); // Release SD CS
|
|
Serial.println("Settings backed up to SD");
|
|
}
|
|
|
|
// Restore prefs, channels, and identity from SD card to SPIFFS.
|
|
// Called at boot if SPIFFS prefs file is missing (e.g. after a fresh flash).
|
|
// Returns true if anything was restored.
|
|
bool restoreSettingsFromSD() {
|
|
if (!sdCardReady) return false;
|
|
|
|
bool restored = false;
|
|
|
|
// Only restore if SPIFFS is missing the prefs file (fresh flash)
|
|
if (!SPIFFS.exists("/new_prefs") && SD.exists("/meshcore/prefs.bin")) {
|
|
if (copyFile(SD, "/meshcore/prefs.bin", SPIFFS, "/new_prefs")) {
|
|
Serial.println("Restored prefs from SD");
|
|
restored = true;
|
|
}
|
|
}
|
|
|
|
if (!SPIFFS.exists("/channels2") && SD.exists("/meshcore/channels.bin")) {
|
|
if (copyFile(SD, "/meshcore/channels.bin", SPIFFS, "/channels2")) {
|
|
Serial.println("Restored channels from SD");
|
|
restored = true;
|
|
}
|
|
}
|
|
|
|
// Identity - most critical; keeps the same device pub key across reflashes
|
|
if (!SPIFFS.exists("/identity/_main.id") && SD.exists("/meshcore/identity/_main.id")) {
|
|
SPIFFS.mkdir("/identity");
|
|
if (copyFile(SD, "/meshcore/identity/_main.id", SPIFFS, "/identity/_main.id")) {
|
|
Serial.println("Restored identity from SD");
|
|
restored = true;
|
|
}
|
|
}
|
|
|
|
if (!SPIFFS.exists("/contacts3") && SD.exists("/meshcore/contacts.bin")) {
|
|
if (copyFile(SD, "/meshcore/contacts.bin", SPIFFS, "/contacts3")) {
|
|
Serial.println("Restored contacts from SD");
|
|
restored = true;
|
|
}
|
|
}
|
|
|
|
if (restored) {
|
|
Serial.println("=== Settings restored from SD card backup ===");
|
|
}
|
|
digitalWrite(SDCARD_CS, HIGH);
|
|
return restored;
|
|
}
|
|
#endif
|
|
|
|
// Believe it or not, this std C function is busted on some platforms!
|
|
static uint32_t _atoi(const char* sp) {
|
|
uint32_t n = 0;
|
|
while (*sp && *sp >= '0' && *sp <= '9') {
|
|
n *= 10;
|
|
n += (*sp++ - '0');
|
|
}
|
|
return n;
|
|
}
|
|
|
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
|
#include <InternalFileSystem.h>
|
|
#if defined(QSPIFLASH)
|
|
#include <CustomLFS_QSPIFlash.h>
|
|
DataStore store(InternalFS, QSPIFlash, rtc_clock);
|
|
#else
|
|
#if defined(EXTRAFS)
|
|
#include <CustomLFS.h>
|
|
CustomLFS ExtraFS(0xD4000, 0x19000, 128);
|
|
DataStore store(InternalFS, ExtraFS, rtc_clock);
|
|
#else
|
|
DataStore store(InternalFS, rtc_clock);
|
|
#endif
|
|
#endif
|
|
#elif defined(RP2040_PLATFORM)
|
|
#include <LittleFS.h>
|
|
DataStore store(LittleFS, rtc_clock);
|
|
#elif defined(ESP32)
|
|
#include <SPIFFS.h>
|
|
DataStore store(SPIFFS, rtc_clock);
|
|
#endif
|
|
|
|
#ifdef ESP32
|
|
#ifdef WIFI_SSID
|
|
#include <helpers/esp32/SerialWifiInterface.h>
|
|
SerialWifiInterface serial_interface;
|
|
#ifndef TCP_PORT
|
|
#define TCP_PORT 5000
|
|
#endif
|
|
#elif defined(BLE_PIN_CODE)
|
|
#include <helpers/esp32/SerialBLEInterface.h>
|
|
SerialBLEInterface serial_interface;
|
|
#elif defined(SERIAL_RX)
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
HardwareSerial companion_serial(1);
|
|
#else
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
#endif
|
|
#elif defined(RP2040_PLATFORM)
|
|
//#ifdef WIFI_SSID
|
|
// #include <helpers/rp2040/SerialWifiInterface.h>
|
|
// SerialWifiInterface serial_interface;
|
|
// #ifndef TCP_PORT
|
|
// #define TCP_PORT 5000
|
|
// #endif
|
|
// #elif defined(BLE_PIN_CODE)
|
|
// #include <helpers/rp2040/SerialBLEInterface.h>
|
|
// SerialBLEInterface serial_interface;
|
|
#if defined(SERIAL_RX)
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
HardwareSerial companion_serial(1);
|
|
#else
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
#endif
|
|
#elif defined(NRF52_PLATFORM)
|
|
#ifdef BLE_PIN_CODE
|
|
#include <helpers/nrf52/SerialBLEInterface.h>
|
|
SerialBLEInterface serial_interface;
|
|
#else
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
#endif
|
|
#elif defined(STM32_PLATFORM)
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface serial_interface;
|
|
#else
|
|
#error "need to define a serial interface"
|
|
#endif
|
|
|
|
/* GLOBAL OBJECTS */
|
|
#ifdef DISPLAY_CLASS
|
|
#include "UITask.h"
|
|
UITask ui_task(&board, &serial_interface);
|
|
#endif
|
|
|
|
StdRNG fast_rng;
|
|
SimpleMeshTables tables;
|
|
MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
|
|
#ifdef DISPLAY_CLASS
|
|
, &ui_task
|
|
#endif
|
|
);
|
|
|
|
/* END GLOBAL OBJECTS */
|
|
|
|
void halt() {
|
|
while (1) ;
|
|
}
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
delay(100); // Give serial time to initialize
|
|
MESH_DEBUG_PRINTLN("=== setup() - STARTING ===");
|
|
|
|
board.begin();
|
|
MESH_DEBUG_PRINTLN("setup() - board.begin() done");
|
|
|
|
#ifdef DISPLAY_CLASS
|
|
DisplayDriver* disp = NULL;
|
|
MESH_DEBUG_PRINTLN("setup() - about to call display.begin()");
|
|
|
|
// =========================================================================
|
|
// T-Deck Pro V1.1: Initialize E-Ink reset pin BEFORE display.begin()
|
|
// This is critical - the display won't work without proper reset sequence
|
|
// =========================================================================
|
|
#if defined(LilyGo_TDeck_Pro)
|
|
// Initialize E-Ink reset pin (GPIO 16)
|
|
pinMode(PIN_DISPLAY_RST, OUTPUT);
|
|
digitalWrite(PIN_DISPLAY_RST, HIGH);
|
|
MESH_DEBUG_PRINTLN("setup() - E-Ink reset pin initialized");
|
|
|
|
// Initialize Touch reset pin (GPIO 38)
|
|
#ifdef CST328_PIN_RST
|
|
pinMode(CST328_PIN_RST, OUTPUT);
|
|
digitalWrite(CST328_PIN_RST, HIGH);
|
|
delay(20);
|
|
digitalWrite(CST328_PIN_RST, LOW);
|
|
delay(80);
|
|
digitalWrite(CST328_PIN_RST, HIGH);
|
|
delay(20);
|
|
MESH_DEBUG_PRINTLN("setup() - Touch reset pin initialized");
|
|
#endif
|
|
#endif
|
|
// =========================================================================
|
|
|
|
if (display.begin()) {
|
|
MESH_DEBUG_PRINTLN("setup() - display.begin() returned true");
|
|
disp = &display;
|
|
disp->startFrame();
|
|
#ifdef ST7789
|
|
disp->setTextSize(2);
|
|
#endif
|
|
disp->drawTextCentered(disp->width() / 2, 28, "Loading...");
|
|
disp->endFrame();
|
|
MESH_DEBUG_PRINTLN("setup() - Loading screen drawn");
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("setup() - display.begin() returned false!");
|
|
}
|
|
#endif
|
|
|
|
MESH_DEBUG_PRINTLN("setup() - about to call radio_init()");
|
|
if (!radio_init()) {
|
|
MESH_DEBUG_PRINTLN("setup() - radio_init() FAILED! Halting.");
|
|
halt();
|
|
}
|
|
MESH_DEBUG_PRINTLN("setup() - radio_init() done");
|
|
|
|
MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()");
|
|
fast_rng.begin(radio_get_rng_seed());
|
|
MESH_DEBUG_PRINTLN("setup() - fast_rng.begin() done");
|
|
|
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
|
MESH_DEBUG_PRINTLN("setup() - NRF52/STM32 filesystem init");
|
|
InternalFS.begin();
|
|
#if defined(QSPIFLASH)
|
|
if (!QSPIFlash.begin()) {
|
|
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: failed to initialize");
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: initialized successfully");
|
|
}
|
|
#else
|
|
#if defined(EXTRAFS)
|
|
ExtraFS.begin();
|
|
#endif
|
|
#endif
|
|
MESH_DEBUG_PRINTLN("setup() - about to call store.begin()");
|
|
store.begin();
|
|
MESH_DEBUG_PRINTLN("setup() - store.begin() done");
|
|
|
|
MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.begin()");
|
|
the_mesh.begin(
|
|
#ifdef DISPLAY_CLASS
|
|
disp != NULL
|
|
#else
|
|
false
|
|
#endif
|
|
);
|
|
MESH_DEBUG_PRINTLN("setup() - the_mesh.begin() done");
|
|
|
|
#ifdef BLE_PIN_CODE
|
|
MESH_DEBUG_PRINTLN("setup() - about to call serial_interface.begin() with BLE");
|
|
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
|
MESH_DEBUG_PRINTLN("setup() - serial_interface.begin() done");
|
|
#else
|
|
serial_interface.begin(Serial);
|
|
#endif
|
|
MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.startInterface()");
|
|
the_mesh.startInterface(serial_interface);
|
|
MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done");
|
|
|
|
#elif defined(RP2040_PLATFORM)
|
|
MESH_DEBUG_PRINTLN("setup() - RP2040 filesystem init");
|
|
LittleFS.begin();
|
|
MESH_DEBUG_PRINTLN("setup() - about to call store.begin()");
|
|
store.begin();
|
|
MESH_DEBUG_PRINTLN("setup() - store.begin() done");
|
|
|
|
MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.begin()");
|
|
the_mesh.begin(
|
|
#ifdef DISPLAY_CLASS
|
|
disp != NULL
|
|
#else
|
|
false
|
|
#endif
|
|
);
|
|
MESH_DEBUG_PRINTLN("setup() - the_mesh.begin() done");
|
|
|
|
//#ifdef WIFI_SSID
|
|
// WiFi.begin(WIFI_SSID, WIFI_PWD);
|
|
// serial_interface.begin(TCP_PORT);
|
|
// #elif defined(BLE_PIN_CODE)
|
|
// char dev_name[32+16];
|
|
// sprintf(dev_name, "%s%s", BLE_NAME_PREFIX, the_mesh.getNodeName());
|
|
// serial_interface.begin(dev_name, the_mesh.getBLEPin());
|
|
#if defined(SERIAL_RX)
|
|
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
|
companion_serial.begin(115200);
|
|
serial_interface.begin(companion_serial);
|
|
#else
|
|
serial_interface.begin(Serial);
|
|
#endif
|
|
MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.startInterface()");
|
|
the_mesh.startInterface(serial_interface);
|
|
MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done");
|
|
|
|
#elif defined(ESP32)
|
|
MESH_DEBUG_PRINTLN("setup() - ESP32 filesystem init - calling SPIFFS.begin()");
|
|
SPIFFS.begin(true);
|
|
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Early SD card init  needed BEFORE the_mesh.begin() so we can restore
|
|
// settings from a previous firmware flash. The display SPI bus is already
|
|
// up (display.begin() ran earlier), so SD can share it now.
|
|
// ---------------------------------------------------------------------------
|
|
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
|
|
{
|
|
pinMode(SDCARD_CS, OUTPUT);
|
|
digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially
|
|
|
|
if (SD.begin(SDCARD_CS, displaySpi, 4000000)) {
|
|
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");
|
|
}
|
|
}
|
|
#endif
|
|
|
|
MESH_DEBUG_PRINTLN("setup() - about to call store.begin()");
|
|
store.begin();
|
|
MESH_DEBUG_PRINTLN("setup() - store.begin() done");
|
|
|
|
MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.begin()");
|
|
the_mesh.begin(
|
|
#ifdef DISPLAY_CLASS
|
|
disp != NULL
|
|
#else
|
|
false
|
|
#endif
|
|
);
|
|
MESH_DEBUG_PRINTLN("setup() - the_mesh.begin() done");
|
|
|
|
#ifdef WIFI_SSID
|
|
MESH_DEBUG_PRINTLN("setup() - WiFi mode");
|
|
WiFi.begin(WIFI_SSID, WIFI_PWD);
|
|
serial_interface.begin(TCP_PORT);
|
|
#elif defined(BLE_PIN_CODE)
|
|
MESH_DEBUG_PRINTLN("setup() - about to call serial_interface.begin() with BLE");
|
|
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
|
MESH_DEBUG_PRINTLN("setup() - serial_interface.begin() done");
|
|
#elif defined(SERIAL_RX)
|
|
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
|
companion_serial.begin(115200);
|
|
serial_interface.begin(companion_serial);
|
|
#else
|
|
serial_interface.begin(Serial);
|
|
#endif
|
|
MESH_DEBUG_PRINTLN("setup() - about to call the_mesh.startInterface()");
|
|
the_mesh.startInterface(serial_interface);
|
|
MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done");
|
|
|
|
#else
|
|
#error "need to define filesystem"
|
|
#endif
|
|
|
|
MESH_DEBUG_PRINTLN("setup() - about to call sensors.begin()");
|
|
sensors.begin();
|
|
MESH_DEBUG_PRINTLN("setup() - sensors.begin() done");
|
|
|
|
// IMPORTANT: sensors.begin() calls initBasicGPS() which steals the GPS pins for Serial1
|
|
// We need to reinitialize Serial2 to reclaim them
|
|
#if HAS_GPS
|
|
Serial2.end(); // Close any existing Serial2
|
|
Serial2.begin(GPS_BAUDRATE, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
|
|
MESH_DEBUG_PRINTLN("setup() - Reinitialized Serial2 for GPS after sensors.begin()");
|
|
#endif
|
|
|
|
#ifdef DISPLAY_CLASS
|
|
MESH_DEBUG_PRINTLN("setup() - about to call ui_task.begin()");
|
|
ui_task.begin(disp, &sensors, the_mesh.getNodePrefs());
|
|
MESH_DEBUG_PRINTLN("setup() - ui_task.begin() done");
|
|
#endif
|
|
|
|
// Initialize T-Deck Pro keyboard
|
|
#if defined(LilyGo_TDeck_Pro)
|
|
initKeyboard();
|
|
#endif
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SD card is already initialized (early init above).
|
|
// Now set up SD-dependent features: message history + text reader.
|
|
// ---------------------------------------------------------------------------
|
|
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
|
|
if (sdCardReady) {
|
|
// Load persisted channel messages from SD
|
|
ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen();
|
|
if (chanScr) {
|
|
chanScr->setSDReady(true);
|
|
if (chanScr->loadFromSD()) {
|
|
MESH_DEBUG_PRINTLN("setup() - Message history loaded from SD");
|
|
}
|
|
}
|
|
|
|
// Tell the text reader that SD is ready, then pre-index books at boot
|
|
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
|
if (reader) {
|
|
reader->setSDReady(true);
|
|
if (disp) {
|
|
cpuPower.setBoost(); // Boost CPU for EPUB processing
|
|
reader->bootIndex(*disp);
|
|
}
|
|
}
|
|
|
|
// Tell notes screen that SD is ready
|
|
NotesScreen* notesScr = (NotesScreen*)ui_task.getNotesScreen();
|
|
if (notesScr) {
|
|
notesScr->setSDReady(true);
|
|
}
|
|
|
|
// Do an initial settings backup to SD (captures any first-boot defaults)
|
|
backupSettingsToSD();
|
|
}
|
|
#endif
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// First-boot onboarding detection
|
|
// Check if node name is still the default hex prefix (first 4 bytes of pub key)
|
|
// If so, launch onboarding wizard to set name and radio preset
|
|
// ---------------------------------------------------------------------------
|
|
#if defined(LilyGo_TDeck_Pro)
|
|
{
|
|
char defaultName[10];
|
|
mesh::Utils::toHex(defaultName, the_mesh.self_id.pub_key, 4);
|
|
NodePrefs* prefs = the_mesh.getNodePrefs();
|
|
if (strcmp(prefs->node_name, defaultName) == 0) {
|
|
MESH_DEBUG_PRINTLN("setup() - Default node name detected, launching onboarding");
|
|
ui_task.gotoOnboarding();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// GPS duty cycle  honour saved pref, default to enabled on first boot
|
|
#if HAS_GPS
|
|
{
|
|
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
|
|
gpsDuty.setStreamCounter(&gpsStream);
|
|
gpsDuty.begin(gps_wanted);
|
|
if (gps_wanted) {
|
|
sensors.setSettingValue("gps", "1");
|
|
} else {
|
|
sensors.setSettingValue("gps", "0");
|
|
}
|
|
MESH_DEBUG_PRINTLN("setup() - GPS duty cycle started (enabled=%d)", gps_wanted);
|
|
}
|
|
#endif
|
|
|
|
// CPU frequency scaling  drop to 80 MHz for idle mesh listening
|
|
cpuPower.begin();
|
|
|
|
// T-Deck Pro: BLE starts disabled for standalone-first operation
|
|
// User can toggle it on from the Bluetooth home page (Enter or long-press)
|
|
#if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE)
|
|
serial_interface.disable();
|
|
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
|
|
#endif
|
|
|
|
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
|
|
}
|
|
|
|
void loop() {
|
|
the_mesh.loop();
|
|
|
|
// GPS duty cycle  check for fix and manage power state
|
|
#if HAS_GPS
|
|
{
|
|
bool gps_hw_on = gpsDuty.loop();
|
|
if (gps_hw_on) {
|
|
LocationProvider* lp = sensors.getLocationProvider();
|
|
if (lp != NULL && lp->isValid()) {
|
|
gpsDuty.notifyFix();
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
sensors.loop();
|
|
|
|
// CPU frequency auto-timeout back to idle
|
|
cpuPower.loop();
|
|
#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;
|
|
if (!composeMode && !notesSuppressLoop) {
|
|
ui_task.loop();
|
|
} else {
|
|
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
|
|
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
|
|
if (composeMode) {
|
|
if (emojiPickerMode) {
|
|
drawEmojiPicker();
|
|
} else {
|
|
drawComposeScreen();
|
|
}
|
|
} else if (notesSuppressLoop) {
|
|
// Notes editor/rename renders through UITask - force a refresh cycle
|
|
ui_task.forceRefresh();
|
|
ui_task.loop();
|
|
}
|
|
lastComposeRefresh = millis();
|
|
composeNeedsRefresh = false;
|
|
}
|
|
}
|
|
// Track reader/notes mode state for key routing
|
|
readerMode = ui_task.isOnTextReader();
|
|
notesMode = ui_task.isOnNotesScreen();
|
|
#else
|
|
ui_task.loop();
|
|
#endif
|
|
#endif
|
|
rtc_clock.tick();
|
|
// Periodic AGC reset - re-assert boosted RX gain to prevent sensitivity drift
|
|
if ((millis() - lastAGCReset) >= AGC_RESET_INTERVAL_MS) {
|
|
radio_reset_agc();
|
|
lastAGCReset = millis();
|
|
}
|
|
// Handle T-Deck Pro keyboard input
|
|
#if defined(LilyGo_TDeck_Pro)
|
|
handleKeyboardInput();
|
|
#endif
|
|
}
|
|
|
|
// ============================================================================
|
|
// T-DECK PRO KEYBOARD FUNCTIONS
|
|
// ============================================================================
|
|
|
|
#if defined(LilyGo_TDeck_Pro)
|
|
|
|
void initKeyboard() {
|
|
// Keyboard uses the same I2C bus as other peripherals (already initialized)
|
|
if (keyboard.begin()) {
|
|
MESH_DEBUG_PRINTLN("setup() - Keyboard initialized");
|
|
composeBuffer[0] = '\0';
|
|
composePos = 0;
|
|
composeMode = false;
|
|
composeNeedsRefresh = false;
|
|
lastComposeRefresh = 0;
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("setup() - Keyboard initialization failed!");
|
|
}
|
|
}
|
|
|
|
void handleKeyboardInput() {
|
|
if (!keyboard.isReady()) return;
|
|
|
|
char key = keyboard.readKey();
|
|
if (key == 0) return;
|
|
|
|
Serial.printf("handleKeyboardInput: key='%c' (0x%02X) composeMode=%d\n",
|
|
key >= 32 ? key : '?', key, composeMode);
|
|
|
|
if (composeMode) {
|
|
// Emoji picker sub-mode
|
|
if (emojiPickerMode) {
|
|
uint8_t result = emojiPicker.handleInput(key);
|
|
if (result == 0xFF) {
|
|
// Cancelled - immediate draw to return to compose
|
|
emojiPickerMode = false;
|
|
drawComposeScreen();
|
|
lastComposeRefresh = millis();
|
|
composeNeedsRefresh = false;
|
|
} else if (result >= EMOJI_ESCAPE_START && result <= EMOJI_ESCAPE_END) {
|
|
// Emoji selected - insert escape byte + padding to match UTF-8 wire cost
|
|
int cost = emojiUtf8Cost(result);
|
|
if (composePos + cost <= 137) {
|
|
composeBuffer[composePos++] = (char)result;
|
|
for (int p = 1; p < cost; p++) {
|
|
composeBuffer[composePos++] = (char)EMOJI_PAD_BYTE;
|
|
}
|
|
composeBuffer[composePos] = '\0';
|
|
Serial.printf("Compose: Inserted emoji 0x%02X cost=%d, pos=%d\n", result, cost, composePos);
|
|
}
|
|
emojiPickerMode = false;
|
|
drawComposeScreen();
|
|
lastComposeRefresh = millis();
|
|
composeNeedsRefresh = false;
|
|
} else {
|
|
// Navigation - debounce (don't draw immediately, let loop handle it)
|
|
composeNeedsRefresh = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// In compose mode - handle text input
|
|
if (key == '\r') {
|
|
// Enter - send the message
|
|
Serial.println("Compose: Enter pressed, sending...");
|
|
if (composePos > 0) {
|
|
sendComposedMessage();
|
|
}
|
|
bool wasDM = composeDM;
|
|
composeMode = false;
|
|
emojiPickerMode = false;
|
|
composeDM = false;
|
|
composeDMContactIdx = -1;
|
|
composeBuffer[0] = '\0';
|
|
composePos = 0;
|
|
if (wasDM) {
|
|
ui_task.gotoContactsScreen();
|
|
} else {
|
|
ui_task.gotoHomeScreen();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key == '\b') {
|
|
// Backspace - check if shift was recently pressed for cancel combo
|
|
if (keyboard.wasShiftRecentlyPressed(500)) {
|
|
// Shift+Backspace = Cancel (works anytime)
|
|
Serial.println("Compose: Shift+Backspace, cancelling...");
|
|
bool wasDM = composeDM;
|
|
composeMode = false;
|
|
emojiPickerMode = false;
|
|
composeDM = false;
|
|
composeDMContactIdx = -1;
|
|
composeBuffer[0] = '\0';
|
|
composePos = 0;
|
|
if (wasDM) {
|
|
ui_task.gotoContactsScreen();
|
|
} else {
|
|
ui_task.gotoHomeScreen();
|
|
}
|
|
return;
|
|
}
|
|
// Regular backspace - delete last character (or entire emoji including pads)
|
|
if (composePos > 0) {
|
|
// Delete trailing pad bytes first, then the escape byte
|
|
while (composePos > 0 && (uint8_t)composeBuffer[composePos - 1] == EMOJI_PAD_BYTE) {
|
|
composePos--;
|
|
}
|
|
// Now delete the actual character (escape byte or regular char)
|
|
if (composePos > 0) {
|
|
composePos--;
|
|
}
|
|
composeBuffer[composePos] = '\0';
|
|
Serial.printf("Compose: Backspace, pos=%d\n", composePos);
|
|
composeNeedsRefresh = true; // Use debounced refresh
|
|
}
|
|
return;
|
|
}
|
|
|
|
// A/D keys switch channels (only when buffer is empty, not in DM mode)
|
|
if ((key == 'a') && composePos == 0 && !composeDM) {
|
|
// Previous channel
|
|
if (composeChannelIdx > 0) {
|
|
composeChannelIdx--;
|
|
} else {
|
|
// Wrap to last valid channel
|
|
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
|
|
ChannelDetails ch;
|
|
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
|
composeChannelIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
|
|
composeNeedsRefresh = true; // Debounced refresh
|
|
return;
|
|
}
|
|
|
|
if ((key == 'd') && composePos == 0 && !composeDM) {
|
|
// Next channel
|
|
ChannelDetails ch;
|
|
uint8_t nextIdx = composeChannelIdx + 1;
|
|
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
|
|
composeChannelIdx = nextIdx;
|
|
} else {
|
|
composeChannelIdx = 0; // Wrap to first channel
|
|
}
|
|
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
|
|
composeNeedsRefresh = true; // Debounced refresh
|
|
return;
|
|
}
|
|
|
|
// '$' key (without Sym) opens emoji picker
|
|
if (key == KB_KEY_EMOJI) {
|
|
emojiPicker.reset();
|
|
emojiPickerMode = true;
|
|
drawEmojiPicker();
|
|
lastComposeRefresh = millis();
|
|
composeNeedsRefresh = false;
|
|
return;
|
|
}
|
|
|
|
// Regular character input
|
|
if (key >= 32 && key < 127 && composePos < 137) {
|
|
composeBuffer[composePos++] = key;
|
|
composeBuffer[composePos] = '\0';
|
|
Serial.printf("Compose: Added '%c', pos=%d\n", key, composePos);
|
|
composeNeedsRefresh = true; // Use debounced refresh
|
|
}
|
|
return;
|
|
}
|
|
|
|
// *** TEXT READER MODE ***
|
|
if (readerMode) {
|
|
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
|
|
|
// Q key: if reading, reader handles it (close book -> file list)
|
|
// if on file list, exit reader entirely
|
|
if (key == 'q') {
|
|
if (reader->isReading()) {
|
|
// Let the reader handle Q (close book, go to file list)
|
|
ui_task.injectKey('q');
|
|
} else {
|
|
// On file list - exit reader, go home
|
|
reader->exitReader();
|
|
Serial.println("Exiting text reader");
|
|
ui_task.gotoHomeScreen();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// All other keys pass through to the reader screen
|
|
ui_task.injectKey(key);
|
|
return;
|
|
}
|
|
|
|
// *** NOTES MODE ***
|
|
if (notesMode) {
|
|
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
|
|
|
|
// ---- EDITING MODE ----
|
|
if (notes->isEditing()) {
|
|
// Shift+Backspace = save and exit
|
|
if (key == '\b') {
|
|
if (keyboard.wasShiftConsumed()) {
|
|
Serial.println("Notes: Shift+Backspace, saving...");
|
|
notes->saveAndExit();
|
|
ui_task.forceRefresh();
|
|
return;
|
|
}
|
|
// Regular backspace - delete before cursor
|
|
ui_task.injectKey(key);
|
|
composeNeedsRefresh = true;
|
|
return;
|
|
}
|
|
|
|
// Cursor navigation via Shift+WASD (produces uppercase)
|
|
if (key == 'W') { notes->moveCursorUp(); composeNeedsRefresh = true; return; }
|
|
if (key == 'A') { notes->moveCursorLeft(); composeNeedsRefresh = true; return; }
|
|
if (key == 'S') { notes->moveCursorDown(); composeNeedsRefresh = true; return; }
|
|
if (key == 'D') { notes->moveCursorRight(); composeNeedsRefresh = true; return; }
|
|
|
|
// Q when buffer is empty or unchanged = exit (nothing to lose)
|
|
if (key == 'q' && (notes->isEmpty() || !notes->isDirty())) {
|
|
Serial.println("Notes: Q exit (nothing to save)");
|
|
notes->discardAndExit();
|
|
ui_task.forceRefresh();
|
|
return;
|
|
}
|
|
|
|
// Enter = newline (pass through with debounce)
|
|
if (key == '\r') {
|
|
ui_task.injectKey(key);
|
|
composeNeedsRefresh = true;
|
|
return;
|
|
}
|
|
|
|
// All other printable chars (lowercase only - uppercase consumed by cursor nav)
|
|
if (key >= 32 && key < 127) {
|
|
ui_task.injectKey(key);
|
|
composeNeedsRefresh = true;
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ---- RENAMING MODE ----
|
|
if (notes->isRenaming()) {
|
|
// All input goes to rename handler (debounced like editing)
|
|
ui_task.injectKey(key);
|
|
composeNeedsRefresh = true;
|
|
if (!notes->isRenaming()) {
|
|
// Exited rename mode (confirmed or cancelled)
|
|
ui_task.forceRefresh();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ---- DELETE CONFIRMATION MODE ----
|
|
if (notes->isConfirmingDelete()) {
|
|
ui_task.injectKey(key);
|
|
if (!notes->isConfirmingDelete()) {
|
|
// Exited confirm mode
|
|
ui_task.forceRefresh();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ---- FILE LIST MODE ----
|
|
if (notes->isInFileList()) {
|
|
if (key == 'q') {
|
|
notes->exitNotes();
|
|
Serial.println("Exiting notes");
|
|
ui_task.gotoHomeScreen();
|
|
return;
|
|
}
|
|
|
|
// Shift+Backspace on a file = delete with confirmation
|
|
if (key == '\b' && keyboard.wasShiftConsumed()) {
|
|
if (notes->startDeleteFromList()) {
|
|
ui_task.forceRefresh();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// R on a file = rename
|
|
if (key == 'r') {
|
|
if (notes->startRename()) {
|
|
composeNeedsRefresh = true;
|
|
lastComposeRefresh = millis() - COMPOSE_REFRESH_INTERVAL; // Trigger on next loop iteration
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Normal keys pass through
|
|
ui_task.injectKey(key);
|
|
// Check if we just entered editing mode (new note via Enter)
|
|
if (notes->isEditing()) {
|
|
composeNeedsRefresh = true;
|
|
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ---- READING MODE ----
|
|
if (notes->isReading()) {
|
|
if (key == 'q') {
|
|
ui_task.injectKey('q');
|
|
return;
|
|
}
|
|
|
|
// Shift+Backspace = delete note
|
|
if (key == '\b' && keyboard.wasShiftConsumed()) {
|
|
Serial.println("Notes: Deleting current note");
|
|
notes->deleteCurrentNote();
|
|
ui_task.forceRefresh();
|
|
return;
|
|
}
|
|
|
|
// All other keys (Enter for edit, W/S for page nav)
|
|
ui_task.injectKey(key);
|
|
if (notes->isEditing()) {
|
|
composeNeedsRefresh = true;
|
|
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
|
|
}
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// *** SETTINGS MODE ***
|
|
if (ui_task.isOnSettingsScreen()) {
|
|
SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen();
|
|
|
|
// Q key: exit settings (when not editing)
|
|
if (!settings->isEditing() && (key == 'q')) {
|
|
if (settings->hasRadioChanges()) {
|
|
// Let settings show "apply changes?" confirm dialog
|
|
ui_task.injectKey(key);
|
|
} else {
|
|
Serial.println("Exiting settings");
|
|
ui_task.gotoHomeScreen();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// All other keys → settings screen via injectKey
|
|
ui_task.injectKey(key);
|
|
return;
|
|
}
|
|
|
|
// *** REPEATER ADMIN MODE ***
|
|
if (ui_task.isOnRepeaterAdmin()) {
|
|
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
|
|
RepeaterAdminScreen::AdminState astate = admin->getState();
|
|
bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed());
|
|
|
|
// In password entry: Shift+Del exits, all other keys pass through normally
|
|
if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
|
|
if (shiftDel) {
|
|
Serial.println("Nav: Back to contacts from admin login");
|
|
ui_task.gotoContactsScreen();
|
|
} else {
|
|
ui_task.injectKey(key);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// In menu state: Shift+Del exits to contacts, C opens compose
|
|
if (astate == RepeaterAdminScreen::STATE_MENU) {
|
|
if (shiftDel) {
|
|
Serial.println("Nav: Back to contacts from admin menu");
|
|
ui_task.gotoContactsScreen();
|
|
return;
|
|
}
|
|
// C key: allow entering compose mode from admin menu
|
|
if (key == 'c' || key == 'C') {
|
|
composeDM = false;
|
|
composeDMContactIdx = -1;
|
|
composeMode = true;
|
|
composeBuffer[0] = '\0';
|
|
composePos = 0;
|
|
drawComposeScreen();
|
|
lastComposeRefresh = millis();
|
|
return;
|
|
}
|
|
// All other keys pass to admin screen
|
|
ui_task.injectKey(key);
|
|
return;
|
|
}
|
|
|
|
// In waiting/response/error states: convert Shift+Del to exit signal,
|
|
// pass all other keys through
|
|
if (shiftDel) {
|
|
ui_task.injectKey(KEY_ADMIN_EXIT);
|
|
} else {
|
|
ui_task.injectKey(key);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Normal mode - not composing
|
|
switch (key) {
|
|
case 'c':
|
|
// Open contacts list
|
|
Serial.println("Opening contacts");
|
|
ui_task.gotoContactsScreen();
|
|
break;
|
|
|
|
case 'm':
|
|
// Go to channel message screen
|
|
Serial.println("Opening channel messages");
|
|
ui_task.gotoChannelScreen();
|
|
break;
|
|
|
|
case 'e':
|
|
// Open text reader (ebooks)
|
|
Serial.println("Opening text reader");
|
|
ui_task.gotoTextReader();
|
|
break;
|
|
|
|
case 'n':
|
|
// Open notes
|
|
Serial.println("Opening notes");
|
|
{
|
|
NotesScreen* notesScr2 = (NotesScreen*)ui_task.getNotesScreen();
|
|
if (notesScr2) {
|
|
uint32_t ts = rtc_clock.getCurrentTime();
|
|
int8_t utcOff = the_mesh.getNodePrefs()->utc_offset_hours;
|
|
notesScr2->setTimestamp(ts, utcOff);
|
|
}
|
|
}
|
|
ui_task.gotoNotesScreen();
|
|
break;
|
|
|
|
case 's':
|
|
// Open settings (from home), or navigate down on channel/contacts/admin
|
|
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
|
|
ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling
|
|
} else {
|
|
Serial.println("Opening settings");
|
|
ui_task.gotoSettingsScreen();
|
|
}
|
|
break;
|
|
|
|
case 'w':
|
|
// Navigate up/previous (scroll on channel screen)
|
|
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
|
|
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
|
|
} else {
|
|
Serial.println("Nav: Previous");
|
|
ui_task.injectKey(0xF2); // KEY_PREV
|
|
}
|
|
break;
|
|
|
|
case 'a':
|
|
// Navigate left or switch channel (on channel screen)
|
|
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
|
ui_task.injectKey('a'); // Pass directly for channel/contacts switching
|
|
} else {
|
|
Serial.println("Nav: Previous");
|
|
ui_task.injectKey(0xF2); // KEY_PREV
|
|
}
|
|
break;
|
|
|
|
case 'd':
|
|
// Navigate right or switch channel (on channel screen)
|
|
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
|
ui_task.injectKey('d'); // Pass directly for channel/contacts switching
|
|
} else {
|
|
Serial.println("Nav: Next");
|
|
ui_task.injectKey(0xF1); // KEY_NEXT
|
|
}
|
|
break;
|
|
|
|
case '\r':
|
|
// Select/Enter - if on contacts screen, enter DM compose for chat contacts
|
|
// or repeater admin for repeater contacts
|
|
if (ui_task.isOnContactsScreen()) {
|
|
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
|
int idx = cs->getSelectedContactIdx();
|
|
uint8_t ctype = cs->getSelectedContactType();
|
|
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
|
|
composeDM = true;
|
|
composeDMContactIdx = idx;
|
|
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
|
|
composeMode = true;
|
|
composeBuffer[0] = '\0';
|
|
composePos = 0;
|
|
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
|
|
drawComposeScreen();
|
|
lastComposeRefresh = millis();
|
|
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
|
|
// Open repeater admin screen
|
|
char rname[32];
|
|
cs->getSelectedContactName(rname, sizeof(rname));
|
|
Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx);
|
|
ui_task.gotoRepeaterAdmin(idx);
|
|
} else if (idx >= 0) {
|
|
// Non-chat, non-repeater contact (room, sensor, etc.) - future use
|
|
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
|
|
}
|
|
} else if (ui_task.isOnChannelScreen()) {
|
|
composeDM = false;
|
|
composeDMContactIdx = -1;
|
|
composeChannelIdx = ui_task.getChannelScreenViewIdx();
|
|
composeMode = true;
|
|
composeBuffer[0] = '\0';
|
|
composePos = 0;
|
|
Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx);
|
|
drawComposeScreen();
|
|
lastComposeRefresh = millis();
|
|
} else {
|
|
// Other screens: pass Enter as generic select
|
|
ui_task.injectKey(13);
|
|
}
|
|
break;
|
|
|
|
case 'q':
|
|
case '\b':
|
|
// 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;
|
|
|
|
default:
|
|
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void drawComposeScreen() {
|
|
#ifdef DISPLAY_CLASS
|
|
display.startFrame();
|
|
display.setTextSize(1);
|
|
display.setColor(DisplayDriver::GREEN);
|
|
display.setCursor(0, 0);
|
|
|
|
// Get the channel name for display
|
|
char headerBuf[40];
|
|
if (composeDM) {
|
|
snprintf(headerBuf, sizeof(headerBuf), "DM: %s", composeDMName);
|
|
} else {
|
|
ChannelDetails channel;
|
|
if (the_mesh.getChannel(composeChannelIdx, channel)) {
|
|
snprintf(headerBuf, sizeof(headerBuf), "To: %s", channel.name);
|
|
} else {
|
|
snprintf(headerBuf, sizeof(headerBuf), "To: Channel %d", composeChannelIdx);
|
|
}
|
|
}
|
|
display.print(headerBuf);
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
display.drawRect(0, 11, display.width(), 1);
|
|
|
|
display.setCursor(0, 14);
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
// Word wrap the compose buffer with word-boundary awareness
|
|
// Uses advance width (cursor movement) not bounding box width for px tracking.
|
|
// Advance = getTextWidth("cc") - getTextWidth("c") to get true cursor step.
|
|
int y = 14;
|
|
char charStr[2] = {0, 0};
|
|
char dblStr[3] = {0, 0, 0};
|
|
|
|
int px = 0;
|
|
int lineW = display.width();
|
|
bool atWordBoundary = true;
|
|
|
|
for (int i = 0; i < composePos; i++) {
|
|
uint8_t b = (uint8_t)composeBuffer[i];
|
|
|
|
if (b == EMOJI_PAD_BYTE) continue;
|
|
|
|
// Word wrap: when starting a new text word, check if it fits on this line
|
|
if (atWordBoundary && b != ' ' && !isEmojiEscape(b) && px > 0) {
|
|
int wordW = 0;
|
|
for (int j = i; j < composePos; j++) {
|
|
uint8_t wb = (uint8_t)composeBuffer[j];
|
|
if (wb == EMOJI_PAD_BYTE) continue;
|
|
if (wb == ' ' || isEmojiEscape(wb)) break;
|
|
dblStr[0] = dblStr[1] = (char)wb;
|
|
charStr[0] = (char)wb;
|
|
wordW += display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
|
}
|
|
if (px + wordW > lineW) {
|
|
px = 0;
|
|
y += 12;
|
|
}
|
|
}
|
|
|
|
if (isEmojiEscape(b)) {
|
|
if (px + EMOJI_LG_W > lineW) {
|
|
px = 0;
|
|
y += 12;
|
|
}
|
|
const uint8_t* sprite = getEmojiSpriteLg(b);
|
|
if (sprite) {
|
|
display.drawXbm(px, y, sprite, EMOJI_LG_W, EMOJI_LG_H);
|
|
}
|
|
px += EMOJI_LG_W + 1;
|
|
display.setCursor(px, y);
|
|
atWordBoundary = true;
|
|
} else if (b == ' ') {
|
|
charStr[0] = ' ';
|
|
dblStr[0] = dblStr[1] = ' ';
|
|
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
|
if (px + adv > lineW) {
|
|
px = 0;
|
|
y += 12;
|
|
} else {
|
|
display.setCursor(px, y);
|
|
display.print(charStr);
|
|
px += adv;
|
|
}
|
|
atWordBoundary = true;
|
|
} else {
|
|
charStr[0] = (char)b;
|
|
dblStr[0] = dblStr[1] = (char)b;
|
|
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
|
if (px + adv > lineW) {
|
|
px = 0;
|
|
y += 12;
|
|
}
|
|
display.setCursor(px, y);
|
|
display.print(charStr);
|
|
px += adv;
|
|
atWordBoundary = false;
|
|
}
|
|
}
|
|
|
|
// Show cursor
|
|
display.setCursor(px, y);
|
|
display.print("_");
|
|
|
|
// Status bar
|
|
int statusY = display.height() - 12;
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
display.drawRect(0, statusY - 2, display.width(), 1);
|
|
display.setCursor(0, statusY);
|
|
display.setColor(DisplayDriver::YELLOW);
|
|
|
|
char status[40];
|
|
if (composePos == 0) {
|
|
// Empty buffer - show channel switching hint
|
|
display.print("A/D:Ch $:Emoji");
|
|
sprintf(status, "Sh+Del:X");
|
|
display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY);
|
|
display.print(status);
|
|
} else {
|
|
// Has text - show send/cancel hint
|
|
sprintf(status, "%d/137 $:Emj", composePos);
|
|
display.print(status);
|
|
sprintf(status, "Sh+Del:X");
|
|
display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY);
|
|
display.print(status);
|
|
}
|
|
|
|
display.endFrame();
|
|
#endif
|
|
}
|
|
|
|
void drawEmojiPicker() {
|
|
#ifdef DISPLAY_CLASS
|
|
display.startFrame();
|
|
emojiPicker.draw(display);
|
|
display.endFrame();
|
|
#endif
|
|
}
|
|
|
|
void sendComposedMessage() {
|
|
if (composePos == 0) return;
|
|
|
|
cpuPower.setBoost(); // Boost CPU for crypto + radio TX
|
|
|
|
// Convert escape bytes back to UTF-8 for mesh transmission and BLE app
|
|
char utf8Buf[512];
|
|
emojiUnescape(composeBuffer, utf8Buf, sizeof(utf8Buf));
|
|
|
|
if (composeDM) {
|
|
// Direct message to a specific contact
|
|
if (composeDMContactIdx >= 0) {
|
|
if (the_mesh.uiSendDirectMessage((uint32_t)composeDMContactIdx, utf8Buf)) {
|
|
ui_task.showAlert("DM sent!", 1500);
|
|
} else {
|
|
ui_task.showAlert("DM failed!", 1500);
|
|
}
|
|
} else {
|
|
ui_task.showAlert("No contact!", 1500);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Channel (group) message
|
|
ChannelDetails channel;
|
|
if (the_mesh.getChannel(composeChannelIdx, channel)) {
|
|
uint32_t timestamp = rtc_clock.getCurrentTime();
|
|
int utf8Len = strlen(utf8Buf);
|
|
|
|
if (the_mesh.sendGroupMessage(timestamp, channel.channel,
|
|
the_mesh.getNodePrefs()->node_name,
|
|
utf8Buf, utf8Len)) {
|
|
ui_task.addSentChannelMessage(composeChannelIdx,
|
|
the_mesh.getNodePrefs()->node_name,
|
|
utf8Buf);
|
|
|
|
the_mesh.queueSentChannelMessage(composeChannelIdx, timestamp,
|
|
the_mesh.getNodePrefs()->node_name,
|
|
utf8Buf);
|
|
|
|
ui_task.showAlert("Sent!", 1500);
|
|
} else {
|
|
ui_task.showAlert("Send failed!", 1500);
|
|
}
|
|
} else {
|
|
ui_task.showAlert("No channel!", 1500);
|
|
}
|
|
}
|
|
|
|
#endif // LilyGo_TDeck_Pro
|