t-echo lite screen: removed diag diagnostic prints, sorted compose mode with cardkb, fixed enter & esc handlers; increased e-ink offset for home screen centering; condensed footer text for all screens; datastore chunked saved guarded for esp32 ; still encountering memory problems with ble build even w 250 contacts and 10 chanel message history so trying standalone

This commit is contained in:
pelgraine
2026-04-21 22:43:29 +10:00
parent 7e1009f31c
commit f461777214
11 changed files with 485 additions and 149 deletions
+56 -102
View File
@@ -9,8 +9,7 @@
DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(nullptr), _clock(&clock),
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
identity_store(fs, ""),
_saveFile(fs)
identity_store(fs, "")
#elif defined(RP2040_PLATFORM)
identity_store(fs, "/identity")
#else
@@ -22,8 +21,7 @@ DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra
#if defined(EXTRAFS) || defined(QSPIFLASH)
DataStore::DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(&fsExtra), _clock(&clock),
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
identity_store(fs, ""),
_saveFile(fs)
identity_store(fs, "")
#elif defined(RP2040_PLATFORM)
identity_store(fs, "/identity")
#else
@@ -276,9 +274,6 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
if (file.read((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)) != sizeof(_prefs.large_font)) {
_prefs.large_font = 0; // default: tiny font
}
if (file.read((uint8_t *)&_prefs.ui_font_style, sizeof(_prefs.ui_font_style)) != sizeof(_prefs.ui_font_style)) {
_prefs.ui_font_style = 0; // default: Classic (FreeSans)
}
if (file.read((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)) != sizeof(_prefs.tx_fail_reset_threshold)) {
_prefs.tx_fail_reset_threshold = 3; // default: 3
}
@@ -286,20 +281,11 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
_prefs.rx_fail_reboot_threshold = 3; // default: 3
}
// v1.7+ Meck region scope fields — may not exist in older prefs files
if (file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)) != sizeof(_prefs.default_scope_name)) {
memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // default: unscoped
}
if (file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)) != sizeof(_prefs.default_scope_key)) {
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); // default: null key
}
// Clamp to valid ranges
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
if (_prefs.large_font > 1) _prefs.large_font = 0;
if (_prefs.ui_font_style >= 3) _prefs.ui_font_style = 0;
if (_prefs.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_threshold = 3;
if (_prefs.rx_fail_reboot_threshold > 10) _prefs.rx_fail_reboot_threshold = 3;
// auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30)
@@ -356,11 +342,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)); // 100
file.write((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)); // 101
file.write((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)); // 102
file.write((uint8_t *)&_prefs.ui_font_style, sizeof(_prefs.ui_font_style)); // 103
file.write((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)); // 104
file.write((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)); // 105
file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 106
file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 137
file.write((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)); // 103
file.write((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)); // 104
file.close();
}
@@ -447,10 +430,42 @@ void DataStore::loadContacts(DataStoreHost* host) {
void DataStore::saveContacts(DataStoreHost* host) {
FILESYSTEM* fs = _getContactsChannelsFS();
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
// nRF52/STM32: direct write (no tmp+rename — InternalFS doesn't need atomic pattern)
File file = openWrite(fs, "/contacts3");
if (file) {
uint32_t idx = 0;
ContactInfo c;
uint8_t unused = 0;
uint32_t recordsWritten = 0;
while (host->getContactForSave(idx, c)) {
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) break;
recordsWritten++;
idx++;
}
file.close();
Serial.printf("DataStore: saved %d contacts\n", recordsWritten);
}
#else
// ESP32: atomic tmp+rename pattern (protects against SD card corruption on power loss)
const char* finalPath = "/contacts3";
const char* tmpPath = "/contacts3.tmp";
// --- Step 1: Write all contacts to a temporary file ---
File file = openWrite(fs, tmpPath);
if (!file) {
Serial.println("DataStore: saveContacts FAILED — cannot open tmp file");
@@ -489,9 +504,8 @@ void DataStore::saveContacts(DataStoreHost* host) {
file.close();
// --- Step 2: Verify the write completed ---
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
size_t expectedBytes = recordsWritten * 152; // 152 bytes per contact record
// Verify the write completed
size_t expectedBytes = recordsWritten * 152;
File verify = openRead(fs, tmpPath);
size_t bytesWritten = verify ? verify.size() : 0;
if (verify) verify.close();
@@ -499,23 +513,25 @@ void DataStore::saveContacts(DataStoreHost* host) {
if (!writeOk || bytesWritten != expectedBytes) {
Serial.printf("DataStore: saveContacts ABORTED — wrote %d bytes, expected %d (%d records)\n",
(int)bytesWritten, (int)expectedBytes, recordsWritten);
fs->remove(tmpPath); // Clean up failed tmp file
return; // Original /contacts3 is untouched
fs->remove(tmpPath);
return;
}
// --- Step 3: Replace original with verified temp file ---
// Replace original with verified temp file
fs->remove(finalPath);
if (fs->rename(tmpPath, finalPath)) {
Serial.printf("DataStore: saved %d contacts (%d bytes)\n", recordsWritten, (int)bytesWritten);
} else {
// Rename failed — tmp file still has the good data
Serial.println("DataStore: rename failed, tmp file preserved");
}
#endif
}
// =========================================================================
// Chunked contact save — non-blocking across multiple loop iterations
// Only for ESP32 with SD card — nRF52 uses blocking saveContacts() above
// =========================================================================
#if !defined(NRF52_PLATFORM) && !defined(STM32_PLATFORM)
bool DataStore::beginSaveContacts(DataStoreHost* host) {
if (_saveInProgress) return false; // Already saving
@@ -607,62 +623,14 @@ void DataStore::finishSaveContacts() {
Serial.println("DataStore: rename failed, tmp file preserved");
}
}
#endif // !NRF52_PLATFORM && !STM32_PLATFORM
void DataStore::loadChannels(DataStoreHost* host) {
FILESYSTEM* fs = _getContactsChannelsFS();
// Crash recovery (same pattern as contacts)
if (!fs->exists("/channels3") && fs->exists("/channels3.tmp")) {
Serial.println("DataStore: recovering channels3 from .tmp file");
fs->rename("/channels3.tmp", "/channels3");
}
if (fs->exists("/channels3.tmp")) {
fs->remove("/channels3.tmp");
}
// Try channels3 (new format with scope_name) first
if (fs->exists("/channels3")) {
File file = openRead(fs, "/channels3");
if (file) {
bool full = false;
uint8_t channel_idx = 0;
while (!full) {
ChannelDetails ch;
memset(ch.scope_name, 0, sizeof(ch.scope_name));
uint8_t unused[4];
bool success = (file.read(unused, 4) == 4);
success = success && (file.read((uint8_t *)ch.name, 32) == 32);
success = success && (file.read((uint8_t *)ch.channel.secret, 32) == 32);
success = success && (file.read((uint8_t *)ch.scope_name, 31) == 31);
if (!success) break; // EOF
// Sanitize scope_name — reject if it contains non-region characters
// (catches garbage from uninitialised memory in early channels3 files)
ch.scope_name[30] = '\0'; // force null-terminate
for (int s = 0; ch.scope_name[s]; s++) {
char sc = ch.scope_name[s];
if (!((sc >= 'a' && sc <= 'z') || (sc >= '0' && sc <= '9') || sc == '-')) {
memset(ch.scope_name, 0, sizeof(ch.scope_name)); // invalid — clear
break;
}
}
if (host->onChannelLoaded(channel_idx, ch)) {
channel_idx++;
} else {
full = true;
}
}
file.close();
return; // channels3 loaded successfully
}
}
// Fall back to channels2 (legacy format without scope_name)
if (!fs->exists("/channels2") && fs->exists("/channels2.tmp")) {
Serial.println("DataStore: recovering channels2 from .tmp file");
Serial.println("DataStore: recovering channels from .tmp file");
fs->rename("/channels2.tmp", "/channels2");
}
if (fs->exists("/channels2.tmp")) {
@@ -675,7 +643,6 @@ void DataStore::loadChannels(DataStoreHost* host) {
uint8_t channel_idx = 0;
while (!full) {
ChannelDetails ch;
memset(ch.scope_name, 0, sizeof(ch.scope_name)); // default: no scope
uint8_t unused[4];
bool success = (file.read(unused, 4) == 4);
@@ -691,18 +658,13 @@ void DataStore::loadChannels(DataStoreHost* host) {
}
}
file.close();
// Migrate: save as channels3 and remove channels2
Serial.println("DataStore: migrating channels2 → channels3");
saveChannels(host);
fs->remove("/channels2");
}
}
void DataStore::saveChannels(DataStoreHost* host) {
FILESYSTEM* fs = _getContactsChannelsFS();
const char* finalPath = "/channels3";
const char* tmpPath = "/channels3.tmp";
const char* finalPath = "/channels2";
const char* tmpPath = "/channels2.tmp";
File file = openWrite(fs, tmpPath);
if (!file) {
@@ -720,7 +682,6 @@ void DataStore::saveChannels(DataStoreHost* host) {
bool success = (file.write(unused, 4) == 4);
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
success = success && (file.write((uint8_t *)ch.scope_name, 31) == 31);
if (!success) {
writeOk = false;
@@ -733,7 +694,7 @@ void DataStore::saveChannels(DataStoreHost* host) {
file.close();
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
size_t expectedBytes = channel_idx * 99; // 4 + 32 + 32 + 31 = 99 bytes per channel
size_t expectedBytes = channel_idx * 68; // 4 + 32 + 32 = 68 bytes per channel
File verify = openRead(fs, tmpPath);
size_t bytesWritten = verify ? verify.size() : 0;
if (verify) verify.close();
@@ -778,7 +739,7 @@ void DataStore::checkAdvBlobFile() {
}
void DataStore::migrateToSecondaryFS() {
// migrate old adv_blobs, contacts3 and channels3/channels2 files to secondary FS if they don't already exist
// migrate old adv_blobs, contacts3 and channels2 files to secondary FS if they don't already exist
if (!_fsExtra->exists("/adv_blobs")) {
if (_fs->exists("/adv_blobs")) {
File oldAdvBlobs = openRead(_fs, "/adv_blobs");
@@ -817,14 +778,10 @@ void DataStore::migrateToSecondaryFS() {
_fs->remove("/contacts3");
}
}
if (!_fsExtra->exists("/channels3") && !_fsExtra->exists("/channels2")) {
// Migrate channels3 (preferred) or channels2 (legacy) to secondary FS
const char* srcName = _fs->exists("/channels3") ? "/channels3"
: _fs->exists("/channels2") ? "/channels2"
: nullptr;
if (srcName) {
File oldFile = openRead(_fs, srcName);
File newFile = openWrite(_fsExtra, srcName);
if (!_fsExtra->exists("/channels2")) {
if (_fs->exists("/channels2")) {
File oldFile = openRead(_fs, "/channels2");
File newFile = openWrite(_fsExtra, "/channels2");
if (oldFile && newFile) {
uint8_t buf[64];
@@ -835,7 +792,7 @@ void DataStore::migrateToSecondaryFS() {
}
if (oldFile) oldFile.close();
if (newFile) newFile.close();
_fs->remove(srcName);
_fs->remove("/channels2");
}
}
// cleanup nodes which have been testing the extra fs, copy _main.id and new_prefs back to primary
@@ -878,9 +835,6 @@ void DataStore::migrateToSecondaryFS() {
if (_fs->exists("/contacts3")) {
_fs->remove("/contacts3");
}
if (_fs->exists("/channels3")) {
_fs->remove("/channels3");
}
if (_fs->exists("/channels2")) {
_fs->remove("/channels2");
}
+6 -5
View File
@@ -24,13 +24,15 @@ class DataStore {
void checkAdvBlobFile();
#endif
// Chunked save state
#if !defined(NRF52_PLATFORM) && !defined(STM32_PLATFORM)
// Chunked save state (ESP32 with SD card only)
File _saveFile;
DataStoreHost* _saveHost = nullptr;
uint32_t _saveIdx = 0;
uint32_t _saveRecordsWritten = 0;
bool _saveInProgress = false;
bool _saveWriteOk = true;
#endif
public:
DataStore(FILESYSTEM& fs, mesh::RTCClock& clock);
@@ -45,14 +47,13 @@ public:
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
void loadContacts(DataStoreHost* host);
void saveContacts(DataStoreHost* host);
#if !defined(NRF52_PLATFORM) && !defined(STM32_PLATFORM)
// Chunked save — splits contact write across multiple loop iterations
// to prevent blocking the main loop for 500ms+ on large contact lists.
// Call beginSaveContacts(), then saveContactsChunk() each loop until it
// returns false (done), then finishSaveContacts() to verify and commit.
bool beginSaveContacts(DataStoreHost* host);
bool saveContactsChunk(int batchSize = 20); // returns true if more to write
bool saveContactsChunk(int batchSize = 20);
void finishSaveContacts();
bool isSaveInProgress() const { return _saveInProgress; }
#endif
void loadChannels(DataStoreHost* host);
void saveChannels(DataStoreHost* host);
void migrateToSecondaryFS();
+12
View File
@@ -3361,6 +3361,15 @@ void MyMesh::loop() {
// is there are pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
// nRF52/STM32: blocking save (fast on internal flash, no chunking needed)
if (!_deferSaves) {
_store->saveContacts(this);
dirty_contacts_expiry = 0;
} else {
dirty_contacts_expiry = futureMillis(2000);
}
#else
if (_deferSaves) {
// Voice session receiving — push save forward to avoid SPI contention
dirty_contacts_expiry = futureMillis(2000);
@@ -3368,14 +3377,17 @@ void MyMesh::loop() {
_store->beginSaveContacts(this);
dirty_contacts_expiry = 0;
}
#endif
}
#if !defined(NRF52_PLATFORM) && !defined(STM32_PLATFORM)
// Drive chunked contact save — write a batch each loop iteration
if (_store->isSaveInProgress() && !_deferSaves) {
if (!_store->saveContactsChunk(20)) { // 20 contacts per chunk (~3KB, ~30ms)
_store->finishSaveContacts(); // Done or error — verify and commit
}
}
#endif
// Discovery scan timeout
if (_discoveryActive && millisHasNowPassed(_discoveryTimeout)) {
+259 -11
View File
@@ -906,6 +906,21 @@
static CardKBKeyboard cardkb;
static unsigned long lastCardKBProbe = 0;
#define CARDKB_PROBE_INTERVAL_MS 5000
// CardKB compose mode state
static bool ckbComposeMode = false;
static char ckbComposeBuf[138]; // 137 bytes max + null
static int ckbComposePos = 0;
static uint8_t ckbComposeChIdx = 0;
static bool ckbComposeDM = false;
static int ckbComposeDMIdx = -1;
static char ckbComposeDMName[32];
static unsigned long ckbLastKeystroke = 0;
static bool ckbComposeRefresh = false;
#define CKB_COMPOSE_DEBOUNCE 600
void drawCardKBCompose();
void sendCardKBMessage();
#endif
#endif
@@ -2869,6 +2884,16 @@ void loop() {
#ifdef HAS_4G_MODEM
smsMode = ui_task.isOnSMSScreen();
#endif
#elif defined(MECK_CARDKB)
if (!ckbComposeMode) {
ui_task.loop();
} else {
// Compose mode: debounced rendering
if (ckbComposeRefresh && (millis() - ckbLastKeystroke) >= CKB_COMPOSE_DEBOUNCE) {
drawCardKBCompose();
ckbComposeRefresh = false;
}
}
#else
ui_task.loop();
#endif
@@ -3177,7 +3202,42 @@ void loop() {
char ckb = cardkb.readKey();
if (ckb != 0) {
Serial.printf("[CardKB] key=0x%02X '%c'\n", (uint8_t)ckb, (ckb >= 32 && ckb < 127) ? ckb : '?');
// Block input while locked (T5S3 only — T-Echo Lite has no lock screen yet)
// --- CardKB compose mode: intercept ALL keys ---
if (ckbComposeMode) {
cpuPower.setBoost();
ui_task.keepAlive();
if (ckb == 0x1B) {
// ESC: cancel compose
ckbComposeMode = false;
ui_task.forceRefresh();
} else if (ckb == '\r') {
// Enter: send message
if (ckbComposePos > 0) {
sendCardKBMessage();
} else {
ckbComposeMode = false;
ui_task.forceRefresh();
}
} else if (ckb == '\b') {
// Backspace: delete last character
if (ckbComposePos > 0) {
ckbComposeBuf[--ckbComposePos] = '\0';
ckbComposeRefresh = true;
ckbLastKeystroke = millis();
}
} else if (ckb >= 32 && ckb < 127) {
// Printable character
if (ckbComposePos < 137) {
ckbComposeBuf[ckbComposePos++] = ckb;
ckbComposeBuf[ckbComposePos] = '\0';
ckbComposeRefresh = true;
ckbLastKeystroke = millis();
}
}
// All keys consumed in compose mode — skip normal routing
} else {
// --- Normal (non-compose) key routing ---
#if defined(LilyGo_T5S3_EPaper_Pro)
if (!ui_task.isLocked()) {
#else
@@ -3255,23 +3315,21 @@ void loop() {
#endif
if (!handled) {
// ESC → back (same as 'q' on T-Deck Pro) for all non-notes screens
if (ckb == 0x1B) {
// Channel picker: ESC goes home
// ESC or Q → back navigation
if (ckb == 0x1B || ckb == 'q') {
if (ui_task.isOnChannelPickerScreen()) {
ui_task.gotoHomeScreen();
// Channel screen: ESC goes to picker
} else if (ui_task.isOnChannelScreen()) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr && (chScr->isReplySelectMode() || chScr->isShowingPathOverlay())) {
ui_task.injectKey('q'); // dismiss overlay/reply first
} else if (chScr && chScr->isDMConversation()) {
ui_task.injectKey('q'); // DM conversation → inbox (handled internally)
ui_task.injectKey('q'); // DM conversation → inbox
} else {
ui_task.gotoChannelPickerScreen();
}
} else {
ui_task.injectKey('q');
ui_task.gotoHomeScreen(); // All other screens → home
}
} else if (ckb == '\r') {
// Enter key — screen-specific compose or select
@@ -3294,8 +3352,17 @@ void loop() {
snprintf(label, sizeof(label), "DM: %s", dmName);
#if defined(LilyGo_T5S3_EPaper_Pro)
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, j);
#elif defined(MECK_CARDKB)
ckbComposeMode = true;
ckbComposeBuf[0] = '\0';
ckbComposePos = 0;
ckbComposeDM = true;
ckbComposeDMIdx = (int)j;
strncpy(ckbComposeDMName, dmName, sizeof(ckbComposeDMName) - 1);
ckbComposeRefresh = true;
ckbLastKeystroke = millis();
#else
ui_task.injectKey('\r'); // T-Echo Lite: compose via native handler
ui_task.injectKey('\r');
#endif
ui_task.clearDMUnread(j);
break;
@@ -3311,8 +3378,16 @@ void loop() {
snprintf(label, sizeof(label), "To: %s", ch.name);
#if defined(LilyGo_T5S3_EPaper_Pro)
ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx);
#elif defined(MECK_CARDKB)
ckbComposeMode = true;
ckbComposeBuf[0] = '\0';
ckbComposePos = 0;
ckbComposeDM = false;
ckbComposeChIdx = chIdx;
ckbComposeRefresh = true;
ckbLastKeystroke = millis();
#else
ui_task.injectKey('\r'); // T-Echo Lite: compose via native handler
ui_task.injectKey('\r');
#endif
}
}
@@ -3339,8 +3414,17 @@ void loop() {
snprintf(label, sizeof(label), "DM: %s", dname);
#if defined(LilyGo_T5S3_EPaper_Pro)
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx);
#elif defined(MECK_CARDKB)
ckbComposeMode = true;
ckbComposeBuf[0] = '\0';
ckbComposePos = 0;
ckbComposeDM = true;
ckbComposeDMIdx = idx;
strncpy(ckbComposeDMName, dname, sizeof(ckbComposeDMName) - 1);
ckbComposeRefresh = true;
ckbLastKeystroke = millis();
#else
ui_task.injectKey('\r'); // T-Echo Lite: compose via native handler
ui_task.injectKey('\r');
#endif
}
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
@@ -3436,6 +3520,7 @@ void loop() {
}
}
}
} // end compose mode else
}
}
#endif
@@ -5112,4 +5197,167 @@ void audio_eof_mp3(const char *info) {
}
#endif // !HAS_4G_MODEM
#endif // LilyGo_TDeck_Pro
#endif // LilyGo_TDeck_Pro
// ============================================================================
// CARDKB COMPOSE FUNCTIONS (T-Echo Lite)
// ============================================================================
#if defined(MECK_CARDKB)
void drawCardKBCompose() {
#ifdef DISPLAY_CLASS
display.startFrame();
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
// Header: "To: channel" or "DM: contact"
char headerBuf[40];
if (ckbComposeDM) {
snprintf(headerBuf, sizeof(headerBuf), "DM: %s", ckbComposeDMName);
} else {
ChannelDetails channel;
if (the_mesh.getChannel(ckbComposeChIdx, channel)) {
snprintf(headerBuf, sizeof(headerBuf), "To: %s", channel.name);
} else {
snprintf(headerBuf, sizeof(headerBuf), "To: Channel %d", ckbComposeChIdx);
}
}
display.print(headerBuf);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
// Body: word-wrapped compose buffer
int y = 14;
int px = 0;
int lineW = display.width();
char charStr[2] = {0, 0};
char dblStr[3] = {0, 0, 0};
bool atWordBoundary = true;
display.setCursor(0, y);
display.setColor(DisplayDriver::LIGHT);
for (int i = 0; i < ckbComposePos; i++) {
uint8_t b = (uint8_t)ckbComposeBuf[i];
// Word wrap: check if next word fits on this line
if (atWordBoundary && b != ' ' && px > 0) {
int wordW = 0;
for (int j = i; j < ckbComposePos; j++) {
uint8_t wb = (uint8_t)ckbComposeBuf[j];
if (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 (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;
}
}
// Cursor
display.setCursor(px, y);
display.print("_");
// Footer 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[32];
if (ckbComposePos == 0) {
display.print("Esc:Cancel");
} else {
snprintf(status, sizeof(status), "Esc:X %d/137", ckbComposePos);
display.print(status);
}
const char* rt = "Ent:Send";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, statusY);
display.print(rt);
display.endFrame();
#endif
}
void sendCardKBMessage() {
if (ckbComposePos == 0) return;
cpuPower.setBoost();
if (ckbComposeDM) {
// Direct message
if (ckbComposeDMIdx >= 0) {
if (the_mesh.uiSendDirectMessage((uint32_t)ckbComposeDMIdx, ckbComposeBuf)) {
ui_task.addSentDM(ckbComposeDMName, the_mesh.getNodePrefs()->node_name, ckbComposeBuf);
ui_task.showAlert("DM sent!", 1500);
} else {
ui_task.showAlert("DM failed!", 1500);
}
} else {
ui_task.showAlert("No contact!", 1500);
}
} else {
// Channel message
ChannelDetails channel;
if (the_mesh.getChannel(ckbComposeChIdx, channel)) {
uint32_t timestamp = rtc_clock.getCurrentTime();
int len = strlen(ckbComposeBuf);
if (the_mesh.sendGroupMessage(timestamp, channel.channel,
the_mesh.getNodePrefs()->node_name,
ckbComposeBuf, len)) {
ui_task.addSentChannelMessage(ckbComposeChIdx,
the_mesh.getNodePrefs()->node_name,
ckbComposeBuf);
the_mesh.queueSentChannelMessage(ckbComposeChIdx, timestamp,
the_mesh.getNodePrefs()->node_name,
ckbComposeBuf);
ui_task.showAlert("Sent!", 1500);
} else {
ui_task.showAlert("Send failed!", 1500);
}
} else {
ui_task.showAlert("No channel!", 1500);
}
}
ckbComposeMode = false;
ckbComposeBuf[0] = '\0';
ckbComposePos = 0;
ui_task.forceRefresh();
}
#endif // MECK_CARDKB
@@ -732,6 +732,12 @@ public:
const char* rtInbox = "Hold:Open";
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
display.print(rtInbox);
#elif defined(LILYGO_TECHO_LITE)
display.setCursor(0, footerY);
display.print("Q:Bk");
const char* rtInbox = "Ent:Open";
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
display.print(rtInbox);
#else
display.setCursor(0, footerY);
display.print("Q:Bck A/D:Ch");
@@ -968,6 +974,10 @@ public:
display.print("Swipe: Switch channel");
display.setCursor(0, 40);
display.print("Long press: Compose");
#elif defined(LILYGO_TECHO_LITE)
display.print("Arrows: Switch channel");
display.setCursor(0, 40);
display.print("Ent: Compose message");
#else
display.print("A/D: Switch channel");
display.setCursor(0, 40);
@@ -1400,6 +1410,19 @@ public:
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
}
#elif defined(LILYGO_TECHO_LITE)
// T-Echo Lite: minimal footer for narrow display
if (_viewChannelIdx == 0xFF) {
display.print("Q:Bk");
const char* rightText = "Ent:Reply";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
} else {
display.print("Q:Bk");
const char* rightText = "Ent:New";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
}
#else
// Left side: abbreviated controls
if (_replySelectMode) {
@@ -341,6 +341,11 @@ public:
const char* rt = "Boot:Back";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
#elif defined(LILYGO_TECHO_LITE)
display.print("Q:Bk");
const char* rt = "Ent:Open";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
#else
display.print("W/S:Nav Q:Back");
const char* rt = "Ent:Open";
@@ -466,6 +466,16 @@ public:
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
#elif defined(LILYGO_TECHO_LITE)
display.setCursor(0, footerY);
if (_selectMode) {
display.print("Q:Done");
} else {
display.print("Q:Bk");
const char* right = "Ent:Sel";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
#else
display.setCursor(0, footerY);
if (_selectMode) {
@@ -402,12 +402,16 @@ private:
addRow(ROW_TX_POWER);
addRow(ROW_UTC_OFFSET);
addRow(ROW_MSG_NOTIFY);
#if HAS_GPS
addRow(ROW_GPS_BAUD);
#endif
addRow(ROW_PATH_HASH_SIZE);
addRow(ROW_DEFAULT_SCOPE);
addRow(ROW_DARK_MODE);
#if !defined(LILYGO_TECHO_LITE)
addRow(ROW_LARGE_FONT);
addRow(ROW_FONT_STYLE);
#endif
#if defined(LilyGo_T5S3_EPaper_Pro)
addRow(ROW_PORTRAIT_MODE);
#endif
@@ -2404,8 +2408,22 @@ public:
} else {
display.print("Editing...");
}
#else
#elif defined(LILYGO_TECHO_LITE)
if (_editMode == EDIT_TEXT) {
display.print("Ent:Ok Q:Cancel");
} else if (_editMode == EDIT_PICKER) {
display.print("A/D:Pick Ent:Ok");
} else if (_editMode == EDIT_NUMBER) {
display.print("W/S:Adj Ent:Ok");
} else if (_editMode == EDIT_CONFIRM) {
// overlay handles it
} else {
display.print("Q:Bk");
const char* r = "Ent:Edit";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
}
#else
display.print("Type, Enter:Ok Q:Cancel");
#ifdef MECK_WIFI_COMPANION
} else if (_editMode == EDIT_WIFI) {
+45 -10
View File
@@ -179,6 +179,16 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
display.setCursor(textX, 0);
display.print(battStr);
display.setTextSize(1); // restore default text size
#elif defined(LILYGO_TECHO_LITE)
// T-Echo Lite: text-only battery (icon misaligns due to fillRect/setCursor offset mismatch at 2× scale)
char battStr[8];
snprintf(battStr, sizeof(battStr), "%d%%", batteryPercentage);
uint16_t textWidth = display.getTextWidth(battStr);
int textX = display.width() - textWidth - 2;
if (outIconX) *outIconX = textX;
display.setCursor(textX, 0); // Same baseline as node name (HOME_HDR_Y)
display.print(battStr);
display.setTextSize(1);
#else
// T-Deck Pro: icon + percentage text (icon hidden in large font)
int iconWidth = 16;
@@ -322,6 +332,8 @@ public:
// T5S3: FreeSans12pt ascenders need more room than built-in font.
// Shift header elements down by 4 virtual units (~17px physical).
#define HOME_HDR_Y 1
#elif defined(LILYGO_TECHO_LITE)
#define HOME_HDR_Y 0
#else
#define HOME_HDR_Y -3
#endif
@@ -369,7 +381,9 @@ public:
}
}
// curr page indicator
#if defined(LilyGo_T5S3_EPaper_Pro)
#if defined(LILYGO_TECHO_LITE)
int y = 13; // Below header
#elif defined(LilyGo_T5S3_EPaper_Pro)
int y = 14; // Closer to header
#else
int y = 14;
@@ -393,6 +407,8 @@ public:
#else
int y = 26; // Standalone: extra line below dots (no IP/Connected row)
#endif
#elif defined(LILYGO_TECHO_LITE)
int y = 18; // Below page dots
#else
int y = 20;
#endif
@@ -400,7 +416,11 @@ public:
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount());
display.drawTextCentered(display.width() / 2, y, tmp);
#if defined(LILYGO_TECHO_LITE)
y += 12; // Compact
#else
y += 14; // Reduced from 18
#endif
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
IPAddress ip = WiFi.localIP();
@@ -423,7 +443,11 @@ public:
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, y, tmp);
#if defined(LILYGO_TECHO_LITE)
y += 14; // Compact
#else
y += 18;
#endif
#endif
}
#endif
@@ -480,6 +504,24 @@ public:
}
display.setTextSize(1);
#else
// Non-T5S3: keyboard shortcut menu
#if defined(LILYGO_TECHO_LITE)
// T-Echo Lite: compact centered menu (tiny font fits 117px virtual width)
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0); // 6×8 built-in font
y += 2;
display.drawTextCentered(display.width() / 2, y, "M:Msgs C:Contacts");
y += 8;
display.drawTextCentered(display.width() / 2, y, "S:Set F:Discover");
y += 8;
display.drawTextCentered(display.width() / 2, y, "H:Last Heard");
y += 9;
if (y < display.height() - 14) {
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Arrows: cycle views");
}
display.setTextSize(1); // restore
#else
// ----- T-Deck Pro: Keyboard shortcut text menu -----
display.setColor(DisplayDriver::LIGHT);
@@ -491,12 +533,10 @@ public:
y += 2;
int col1, col2;
if (_node_prefs->large_font) {
// 9pt font: measure widest left entry and place col2 just past it
col1 = 2;
int leftW = display.getTextWidth("[M] Messages");
col2 = col1 + leftW + 3;
} else {
// Custom tiny (7pt): centered layout
col1 = display.width() / 10;
col2 = display.width() * 11 / 20;
}
@@ -578,6 +618,7 @@ public:
(_node_prefs->large_font || display.getFontStyle() > 0) ? "A/D: cycle views" : "Press A/D to cycle home views");
}
display.setTextSize(1); // restore
#endif // LILYGO_TECHO_LITE
#endif
} else if (_page == HomePage::RECENT) {
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
@@ -1299,14 +1340,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
channel_screen = new ChannelScreen(this, &rtc_clock);
Serial.printf("[DIAG] channel_screen=%p, unread=%d\n", channel_screen,
channel_screen ? ((ChannelScreen*)channel_screen)->getTotalUnread() : -999);
((ChannelScreen*)channel_screen)->setDMUnreadPtr(_dmUnread);
channel_picker_screen = new ChannelPickerScreen(this);
Serial.printf("[DIAG] channel_picker=%p\n", channel_picker_screen);
((ChannelPickerScreen*)channel_picker_screen)->setChannelScreen((ChannelScreen*)channel_screen);
contacts_screen = new ContactsScreen(this, &rtc_clock);
Serial.printf("[DIAG] contacts=%p\n", contacts_screen);
((ContactsScreen*)contacts_screen)->setDMUnreadPtr(_dmUnread);
#if !defined(LILYGO_TECHO_LITE)
text_reader = new TextReaderScreen(this, node_prefs);
@@ -1316,12 +1353,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
notes_screen = nullptr;
#endif
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
Serial.printf("[DIAG] settings=%p\n", settings_screen);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
path_editor = nullptr; // Lazy-initialized on first use from contacts screen
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
last_heard_screen = new LastHeardScreen(&rtc_clock);
Serial.printf("[DIAG] discovery=%p, last_heard=%p\n", discovery_screen, last_heard_screen);
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
lock_screen = new LockScreen(this, &rtc_clock, node_prefs);
#endif
@@ -2014,7 +2049,7 @@ if (curr) curr->poll();
// Without this floor, changing readings (battery, uptime) trigger
// back-to-back renders that cause continuous flashing.
#ifdef EINK_FULL_REFRESH_ONLY
unsigned long minNext = millis() + 15000; // Full refresh: 15s floor
unsigned long minNext = millis() + 60000; // Full refresh: 60s idle (clock ticks per minute)
#else
unsigned long minNext = millis() + 800; // Partial refresh: 800ms floor
#endif
+10 -9
View File
@@ -19,13 +19,6 @@
#include <Fonts/FreeSansBold12pt7b.h>
#include <Fonts/FreeSans18pt7b.h>
// Meck custom font styles (Noto Sans, Montserrat) — only available in
// companion radio builds which have -I examples/companion_radio/ui-new
#if __has_include("MeckFonts.h")
#include "MeckFonts.h"
#define HAS_MECK_FONTS 1
#endif
// Inline CRC32 for frame change detection (replaces bakercp/CRC32
// to avoid naming collision with PNGdec's bundled CRC32.h)
class FrameCRC32 {
@@ -76,10 +69,18 @@ class GxEPDDisplay : public DisplayDriver {
int last_display_crc_value = 0;
public:
// Virtual canvas dimensions — default 128×128 (MeshCore standard).
// Override for displays where physical resolution / scale < 128.
#ifndef EINK_VIRTUAL_W
#define EINK_VIRTUAL_W 128
#endif
#ifndef EINK_VIRTUAL_H
#define EINK_VIRTUAL_H 128
#endif
#if defined(EINK_DISPLAY_MODEL)
GxEPDDisplay() : DisplayDriver(128, 128), display(EINK_DISPLAY_MODEL(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {}
GxEPDDisplay() : DisplayDriver(EINK_VIRTUAL_W, EINK_VIRTUAL_H), display(EINK_DISPLAY_MODEL(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {}
#else
GxEPDDisplay() : DisplayDriver(128, 128), display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {}
GxEPDDisplay() : DisplayDriver(EINK_VIRTUAL_W, EINK_VIRTUAL_H), display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {}
#endif
bool begin();
+40 -11
View File
@@ -42,10 +42,12 @@ build_flags = ${nrf52_base.build_flags}
-D EINK_DISPLAY_MODEL=GxEPD2_122_T61
-D EINK_SCALE_X=1.5f
-D EINK_SCALE_Y=2.0f
-D EINK_X_OFFSET=0
-D EINK_Y_OFFSET=10
-D EINK_X_OFFSET=6
-D EINK_Y_OFFSET=1
-D DISPLAY_ROTATION=4
-D EINK_FULL_REFRESH_ONLY=1
-D EINK_VIRTUAL_W=117
-D EINK_VIRTUAL_H=88
-D AUTO_OFF_MILLIS=0
build_src_filter = ${nrf52_base.build_src_filter}
+<helpers/*.cpp>
@@ -79,9 +81,9 @@ build_flags =
${lilygo_techo_lite_meck.build_flags}
-I src/helpers/ui
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_CONTACTS=250
-D MAX_GROUP_CHANNELS=8
-D CHANNEL_MSG_HISTORY_SIZE=50
-D CHANNEL_MSG_HISTORY_SIZE=20
-D BLE_PIN_CODE=123456
; -D BLE_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=64
@@ -90,7 +92,7 @@ build_flags =
-D UI_SENSORS_PAGE=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
-D AUTO_SHUTDOWN_MILLIVOLTS=3300
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
build_src_filter = ${lilygo_techo_lite_meck.build_src_filter}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
@@ -99,6 +101,33 @@ lib_deps =
${lilygo_techo_lite_meck.lib_deps}
densaugeo/base64 @ ~1.4.0
; --- Standalone Radio (no BLE, CardKB + display only) ---
; No companion app — device IS the terminal.
; USB serial for CLI configuration.
; Frees ~20-30KB BLE RAM → room for 500 contacts + larger message history.
[env:meck_techo_lite_standalone]
extends = lilygo_techo_lite_meck
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${lilygo_techo_lite_meck.build_flags}
-I src/helpers/ui
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_GROUP_CHANNELS=8
-D CHANNEL_MSG_HISTORY_SIZE=150
-D OFFLINE_QUEUE_SIZE=1
-D MECK_CARDKB
-D UI_RECENT_LIST_SIZE=9
-D UI_SENSORS_PAGE=1
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
build_src_filter = ${lilygo_techo_lite_meck.build_src_filter}
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${lilygo_techo_lite_meck.lib_deps}
densaugeo/base64 @ ~1.4.0
; --- BLE Companion Radio (with GPS) ---
; Same as above + L76K GPS for location and time sync.
; Requires external GPS module connected to UART1 (TX=P0.29, RX=P1.10).
@@ -113,15 +142,15 @@ build_flags =
-D ENV_INCLUDE_GPS=1
-D GPS_BAUD_RATE=9600
-D PIN_GPS_EN=GPS_EN
-D MAX_CONTACTS=500
-D MAX_CONTACTS=250
-D MAX_GROUP_CHANNELS=8
-D CHANNEL_MSG_HISTORY_SIZE=50
-D CHANNEL_MSG_HISTORY_SIZE=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=64
-D MECK_CARDKB
-D UI_RECENT_LIST_SIZE=9
-D UI_SENSORS_PAGE=1
-D AUTO_SHUTDOWN_MILLIVOLTS=3300
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
build_src_filter = ${lilygo_techo_lite_meck.build_src_filter}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
@@ -227,12 +256,12 @@ board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${lilygo_techo_lite_meck_core.build_flags}
-D MAX_CONTACTS=500
-D MAX_CONTACTS=250
-D MAX_GROUP_CHANNELS=8
-D CHANNEL_MSG_HISTORY_SIZE=50
-D CHANNEL_MSG_HISTORY_SIZE=20
-D BLE_PIN_CODE=234567
-D OFFLINE_QUEUE_SIZE=64
-D AUTO_SHUTDOWN_MILLIVOLTS=3300
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
build_src_filter = ${lilygo_techo_lite_meck_core.build_src_filter}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>