T5S3 - fixed touch selector fav contacts bug

TDPro - Update firmware build date

Contactsscreen.h — five changes:

- EPOCH_2026 = 1735689600UL constant added (Jan 1 2026 UTC), used in sort
  and formatAge.

- typeChar replaced by typeStr returning const char*, with "RS" for room
  servers (previously "S", easily confused with sensors). prefix buffer
  bumped to [5], all three snprintf calls updated to %s.

- Hop display: out_path_len == 0xFF branch now performs a live lookup
  against the 12 most recently heard advert paths (via
  getRecentlyHeard). Matches on first 7 bytes of pub_key, extracts hop
  count with a bph-aware sanity cap (64/bph max) to reject impossible
  values. Shows "~D" for direct flood neighbours, "~N" for N-hop flood
  path, "?" if not in the recent-heard cache. Resets to "?" on reboot
  until each contact re-advertises — intentional, ensures hop count is
  always fresh.

- Sort: _filteredTs now stores contact.lastmod (our local receive time)
  instead of contact.last_advert_timestamp (sender's claimed time).
  lastmod values below EPOCH_2026 are stored as 0 so stale repeaters
  with unsynced clocks and contacts received before our own timesync
  sink to the bottom of the list.

- formatAge rewritten: rejects timestamp == 0, timestamp < EPOCH_2026,
  and now < timestamp (all show "--" instead of wrapping or displaying
  garbage). Arithmetic changed from int to uint32_t, eliminating the
  signed overflow path that produced negative hour values. Age display
  call site switched from last_advert_timestamp to lastmod, so display
  self-corrects after a GPS or 4G timesync.
This commit is contained in:
pelgraine
2026-04-12 12:44:10 +10:00
parent ec42ac73a8
commit c578dcadc8
5 changed files with 102 additions and 42 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
#define FIRMWARE_VER_CODE 11
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "7 April 2026"
#define FIRMWARE_BUILD_DATE "12 April 2026"
#endif
#ifndef FIRMWARE_VERSION
+47 -11
View File
@@ -1525,24 +1525,60 @@ static void lastHeardToggleContact() {
}
// Contacts screen: long press
// If NOT in select mode → enter select mode
// If already in select mode → exit select mode
// T-Deck Pro: toggle select mode (DM/admin handled by keyboard Enter)
// T5S3: DM for chat contacts, admin for repeaters/rooms (no physical keyboard)
// If in select mode, long press exits it on both platforms.
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
if (cs) {
if (!cs->isInSelectMode()) {
// Enter select mode on long press
cs->enterSelectMode();
if (cs->isInSelectMode()) {
// Both platforms: long press exits select mode
cs->exitSelectMode();
ui_task.forceRefresh();
Serial.println("Contacts: entered select mode (touch long press)");
Serial.println("Contacts: exited select mode (touch long press)");
return 0;
}
// Already in select mode: long press exits select mode on both platforms
// (T-Deck Pro uses keyboard for actions; T5S3 can use CardKB if attached)
cs->exitSelectMode();
ui_task.forceRefresh();
Serial.println("Contacts: exited select mode (touch long press)");
#if defined(LilyGo_T5S3_EPaper_Pro)
// T5S3: long press = DM/admin/room action (primary interaction path)
{
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);
return 0;
}
char dname[32];
cs->getSelectedContactName(dname, sizeof(dname));
char label[40];
snprintf(label, sizeof(label), "DM: %s", dname);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx);
return 0;
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
return 0;
}
}
return 0;
#else
// T-Deck Pro: long press enters select mode
cs->enterSelectMode();
ui_task.forceRefresh();
Serial.println("Contacts: entered select mode (touch long press)");
return 0;
#endif
}
return KEY_ENTER;
}
@@ -4,7 +4,11 @@
#include <helpers/ui/DisplayDriver.h>
#include <MeshCore.h>
// Forward declarations
// Timestamps before this (Jan 1 2026 UTC) are treated as invalid/unsynced
#define EPOCH_2026 1735689600UL
// Forward declarations — MyMesh.h (which defines AdvertPath) is always
// included by the translation unit before this header.
class UITask;
class MyMesh;
extern MyMesh the_mesh;
@@ -33,8 +37,10 @@ private:
// We rebuild this on filter change or when entering the screen
// Arrays allocated in PSRAM when available (supports 1000+ contacts)
uint16_t* _filteredIdx; // indices into contact table
uint32_t* _filteredTs; // cached last_advert_timestamp for sorting
uint32_t* _filteredTs; // cached lastmod for sorting
int _filteredCount; // how many contacts match current filter
AdvertPath _hopBuf[12]; // recently heard advert paths for hop-count display
int _hopBufCount;
bool _cacheValid;
// How many rows fit on screen (computed during render)
@@ -61,12 +67,12 @@ private:
}
}
static char typeChar(uint8_t adv_type) {
static const char* typeStr(uint8_t adv_type) {
switch (adv_type) {
case ADV_TYPE_CHAT: return 'C';
case ADV_TYPE_REPEATER: return 'R';
case ADV_TYPE_ROOM: return 'S'; // Server
default: return '?';
case ADV_TYPE_CHAT: return "C";
case ADV_TYPE_REPEATER: return "R";
case ADV_TYPE_ROOM: return "RS";
default: return "?";
}
}
@@ -92,12 +98,13 @@ private:
if (the_mesh.getContactByIdx(i, contact)) {
if (matchesFilter(contact.type, contact.flags)) {
_filteredIdx[_filteredCount] = (uint16_t)i;
_filteredTs[_filteredCount] = contact.last_advert_timestamp;
// Use lastmod (our receive time) for sort/age; pre-2026 or zero → 0 sinks to bottom
_filteredTs[_filteredCount] = (contact.lastmod >= EPOCH_2026) ? contact.lastmod : 0;
_filteredCount++;
}
}
}
// Sort by last_advert_timestamp descending (most recently seen first)
// Sort by lastmod descending (most recently heard first; pre-2026/unsynced sink to bottom)
// Insertion sort — fine for up to ~1000 entries on ESP32
for (int i = 1; i < _filteredCount; i++) {
uint16_t tmpIdx = _filteredIdx[i];
@@ -112,28 +119,29 @@ private:
_filteredTs[j + 1] = tmpTs;
}
_cacheValid = true;
// Refresh hop-count cache from the 12 most recently heard adverts
_hopBufCount = the_mesh.getRecentlyHeard(_hopBuf, 12);
// Clamp scroll position
if (_scrollPos >= _filteredCount) {
_scrollPos = (_filteredCount > 0) ? _filteredCount - 1 : 0;
}
}
// Format seconds-ago as compact string: "3s" "5m" "2h" "4d" "??"
// Format seconds-ago as compact string: "3s" "5m" "2h" "4d" "--"
static void formatAge(char* buf, size_t bufLen, uint32_t now, uint32_t timestamp) {
if (timestamp == 0) {
if (timestamp == 0 || timestamp < EPOCH_2026 || now < timestamp) {
strncpy(buf, "--", bufLen);
return;
}
int secs = (int)(now - timestamp);
if (secs < 0) secs = 0;
uint32_t secs = now - timestamp;
if (secs < 60) {
snprintf(buf, bufLen, "%ds", secs);
snprintf(buf, bufLen, "%ds", (int)secs);
} else if (secs < 3600) {
snprintf(buf, bufLen, "%dm", secs / 60);
snprintf(buf, bufLen, "%dm", (int)(secs / 60));
} else if (secs < 86400) {
snprintf(buf, bufLen, "%dh", secs / 3600);
snprintf(buf, bufLen, "%dh", (int)(secs / 3600));
} else {
snprintf(buf, bufLen, "%dd", secs / 86400);
snprintf(buf, bufLen, "%dd", (int)(secs / 86400));
}
}
@@ -152,7 +160,7 @@ public:
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
_filteredCount(0), _cacheValid(false), _rowsPerPage(5),
_selectMode(false) {
_selectMode(false), _hopBufCount(0) {
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_filteredIdx = (uint16_t*)ps_calloc(MAX_CONTACTS, sizeof(uint16_t));
_filteredTs = (uint32_t*)ps_calloc(MAX_CONTACTS, sizeof(uint32_t));
@@ -362,15 +370,15 @@ public:
display.setCursor(0, y);
// Prefix: select mode uses * for selected, normal uses > for cursor
char prefix[4];
char prefix[5];
if (_selectMode) {
snprintf(prefix, sizeof(prefix), "%c%c",
snprintf(prefix, sizeof(prefix), "%c%s",
sel ? '*' : (selected ? '>' : ' '),
typeChar(contact.type));
typeStr(contact.type));
} else if (selected) {
snprintf(prefix, sizeof(prefix), ">%c", typeChar(contact.type));
snprintf(prefix, sizeof(prefix), ">%s", typeStr(contact.type));
} else {
snprintf(prefix, sizeof(prefix), " %c", typeChar(contact.type));
snprintf(prefix, sizeof(prefix), " %s", typeStr(contact.type));
}
display.print(prefix);
@@ -381,7 +389,22 @@ public:
// Reserve space for hops + age on right side
char hopStr[6];
if (contact.out_path_len == 0xFF) {
strcpy(hopStr, "?"); // unknown path
// No confirmed direct path — look up flood hop estimate from recent advert cache
hopStr[0] = '?'; hopStr[1] = '\0'; // default
for (int h = 0; h < _hopBufCount; h++) {
if (memcmp(contact.id.pub_key, _hopBuf[h].pubkey_prefix, 7) == 0) {
uint8_t bph = (_hopBuf[h].path_len >> 6) + 1;
uint8_t hops = _hopBuf[h].path_len & 0x3F;
uint8_t max_hops = 64 / bph; // sanity cap based on path encoding
if (hops <= max_hops) {
if (hops == 0)
strcpy(hopStr, "~D");
else
snprintf(hopStr, sizeof(hopStr), "~%d", (int)hops);
}
break;
}
}
} else if (contact.out_path_len == 0) {
bool customDirect = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
strcpy(hopStr, customDirect ? "D*" : "D");
@@ -396,7 +419,7 @@ public:
}
char ageStr[6];
formatAge(ageStr, sizeof(ageStr), now, contact.last_advert_timestamp);
formatAge(ageStr, sizeof(ageStr), now, contact.lastmod);
// Build right-side string: "*N hops age" if unread, else "hops age"
int dmCount = (_dmUnread && _filteredIdx[i] < MAX_CONTACTS) ? _dmUnread[_filteredIdx[i]] : 0;
@@ -1847,7 +1847,7 @@ public:
break;
case ROW_AUTOADD_CHAT:
snprintf(tmp, sizeof(tmp), " Chat: %s",
snprintf(tmp, sizeof(tmp), " Companion: %s",
(_prefs->autoadd_config & AUTO_ADD_CHAT) ? "ON" : "OFF");
display.print(tmp);
break;
+5 -4
View File
@@ -148,7 +148,7 @@ extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D MECK_WIFI_COMPANION=1
-D TCP_PORT=5000
@@ -184,7 +184,7 @@ extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=1
-D MECK_AUDIO_VARIANT
@@ -249,7 +249,7 @@ extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D MECK_WIFI_COMPANION=1
-D TCP_PORT=5000
@@ -285,7 +285,7 @@ extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=1
-D HAS_4G_MODEM=1
@@ -294,6 +294,7 @@ build_flags =
-D FIRMWARE_VERSION='"Meck v1.6.1.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
-<helpers/esp32/SerialBLEInterface.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>