From f06a1f5499a8b0416ffdbde6cc1d94ee8bf3b091 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:03:06 +1100 Subject: [PATCH] Sms app implementation phase 2 - add contact in message view screen; time of message displayed fix using 4G modem network sync - need to wait about 10-ish seconds after boot for auto network clock sync --- examples/companion_radio/main.cpp | 16 +- .../companion_radio/ui-new/ModemManager.cpp | 82 +++- .../companion_radio/ui-new/ModemManager.h | 6 +- .../companion_radio/ui-new/SMSContacts.cpp | 8 + examples/companion_radio/ui-new/SMSContacts.h | 176 ++++++++ examples/companion_radio/ui-new/SMSScreen.h | 387 ++++++++++++++---- examples/companion_radio/ui-new/SMSStore.cpp | 7 +- examples/companion_radio/ui-new/SMSStore.h | 4 +- .../companion_radio/ui-new/Settingsscreen.h | 49 ++- 9 files changed, 646 insertions(+), 89 deletions(-) create mode 100644 examples/companion_radio/ui-new/SMSContacts.cpp create mode 100644 examples/companion_radio/ui-new/SMSContacts.h diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index a23dd58..748e469 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -63,6 +63,7 @@ #ifdef HAS_4G_MODEM #include "ModemManager.h" #include "SMSStore.h" + #include "SMSContacts.h" #include "SMSScreen.h" static bool smsMode = false; #endif @@ -553,6 +554,7 @@ void setup() { #ifdef HAS_4G_MODEM { smsStore.begin(); + smsContacts.begin(); // Tell SMS screen that SD is ready SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen(); @@ -560,9 +562,17 @@ void setup() { smsScr->setSDReady(true); } - // Start modem background task (runs on Core 0) - modemManager.begin(); - MESH_DEBUG_PRINTLN("setup() - 4G modem manager started"); + // Start modem if enabled in config (default = enabled) + bool modemEnabled = ModemManager::loadEnabledConfig(); + if (modemEnabled) { + modemManager.begin(); + MESH_DEBUG_PRINTLN("setup() - 4G modem manager started"); + } else { + // Ensure modem power is off (kills red LED too) + pinMode(MODEM_POWER_EN, OUTPUT); + digitalWrite(MODEM_POWER_EN, LOW); + MESH_DEBUG_PRINTLN("setup() - 4G modem disabled by config"); + } } #endif } diff --git a/examples/companion_radio/ui-new/ModemManager.cpp b/examples/companion_radio/ui-new/ModemManager.cpp index 755e1f2..3eaf113 100644 --- a/examples/companion_radio/ui-new/ModemManager.cpp +++ b/examples/companion_radio/ui-new/ModemManager.cpp @@ -2,6 +2,9 @@ #include "ModemManager.h" #include // For MESH_DEBUG_PRINTLN +#include // For modem config persistence +#include +#include // Global singleton ModemManager modemManager; @@ -100,6 +103,35 @@ const char* ModemManager::stateToString(ModemState s) { } } +// --------------------------------------------------------------------------- +// Persistent modem enable/disable config +// --------------------------------------------------------------------------- + +#define MODEM_CONFIG_FILE "/sms/modem.cfg" + +bool ModemManager::loadEnabledConfig() { + File f = SD.open(MODEM_CONFIG_FILE, FILE_READ); + if (!f) { + // No config file = enabled by default + return true; + } + char c = '1'; + if (f.available()) c = f.read(); + f.close(); + return (c != '0'); +} + +void ModemManager::saveEnabledConfig(bool enabled) { + // Ensure /sms directory exists + if (!SD.exists("/sms")) SD.mkdir("/sms"); + File f = SD.open(MODEM_CONFIG_FILE, FILE_WRITE); + if (f) { + f.print(enabled ? '1' : '0'); + f.close(); + Serial.printf("[Modem] Config saved: %s\n", enabled ? "ENABLED" : "DISABLED"); + } +} + // --------------------------------------------------------------------------- // FreeRTOS Task // --------------------------------------------------------------------------- @@ -153,6 +185,9 @@ restart: // Enable SMS notification via +CMTI URC (new message indication) sendAT("AT+CNMI=2,1,0,0,0", "OK"); + // Enable automatic time zone update from network (needed for AT+CCLK) + sendAT("AT+CTZU=1", "OK"); + // ---- Phase 3: Wait for network registration ---- _state = ModemState::REGISTERING; MESH_DEBUG_PRINTLN("[Modem] waiting for network registration..."); @@ -203,6 +238,51 @@ restart: // Initial signal query pollCSQ(); + // Sync ESP32 system clock from modem network time + // Network time may take a few seconds to arrive after registration + bool clockSet = false; + for (int attempt = 0; attempt < 5 && !clockSet; attempt++) { + if (attempt > 0) vTaskDelay(pdMS_TO_TICKS(2000)); + if (sendAT("AT+CCLK?", "OK", 3000)) { + // Response: +CCLK: "YY/MM/DD,HH:MM:SS±TZ" (TZ in quarter-hours) + char* p = strstr(_atBuf, "+CCLK:"); + if (p) { + int yy = 0, mo = 0, dd = 0, hh = 0, mm = 0, ss = 0, tz = 0; + if (sscanf(p, "+CCLK: \"%d/%d/%d,%d:%d:%d", &yy, &mo, &dd, &hh, &mm, &ss) >= 6) { + // Skip if modem clock not synced (default is 1970 = yy 70, or yy 0) + if (yy < 24 || yy > 50) { + MESH_DEBUG_PRINTLN("[Modem] CCLK not synced yet (yy=%d), retrying...", yy); + continue; + } + + // Parse timezone offset (e.g. "+40" = UTC+10 in quarter-hours) + char* tzp = p + 7; // skip "+CCLK: " + while (*tzp && *tzp != '+' && *tzp != '-') tzp++; + if (*tzp) tz = atoi(tzp); + + struct tm t = {}; + t.tm_year = yy + 100; // years since 1900 + t.tm_mon = mo - 1; // 0-based + t.tm_mday = dd; + t.tm_hour = hh; + t.tm_min = mm; + t.tm_sec = ss; + time_t epoch = mktime(&t); // treats input as UTC (no TZ set on ESP32) + epoch -= (tz * 15 * 60); // subtract local offset to get real UTC + + struct timeval tv = { .tv_sec = epoch, .tv_usec = 0 }; + settimeofday(&tv, nullptr); + clockSet = true; + MESH_DEBUG_PRINTLN("[Modem] System clock set: %04d-%02d-%02d %02d:%02d:%02d (tz=%+d qh, epoch=%lu)", + yy + 2000, mo, dd, hh, mm, ss, tz, (unsigned long)epoch); + } + } + } + } + if (!clockSet) { + MESH_DEBUG_PRINTLN("[Modem] WARNING: Could not sync system clock from network"); + } + // Delete any stale SMS on SIM to free slots sendAT("AT+CMGD=1,4", "OK", 5000); // Delete all read messages @@ -417,7 +497,7 @@ void ModemManager::pollIncomingSMS() { if (bodyLen >= SMS_BODY_LEN) bodyLen = SMS_BODY_LEN - 1; memcpy(incoming.body, p, bodyLen); incoming.body[bodyLen] = '\0'; - incoming.timestamp = millis() / 1000; // Approximate; modem RTC could be used + incoming.timestamp = (uint32_t)time(nullptr); // Real epoch from modem-synced clock // Queue for main loop xQueueSend(_recvQueue, &incoming, 0); diff --git a/examples/companion_radio/ui-new/ModemManager.h b/examples/companion_radio/ui-new/ModemManager.h index 602ef88..d073293 100644 --- a/examples/companion_radio/ui-new/ModemManager.h +++ b/examples/companion_radio/ui-new/ModemManager.h @@ -89,6 +89,10 @@ public: static const char* stateToString(ModemState s); + // Persistent enable/disable config (SD file /sms/modem.cfg) + static bool loadEnabledConfig(); // returns true if enabled (default) + static void saveEnabledConfig(bool enabled); + private: volatile ModemState _state = ModemState::OFF; volatile int _csq = 99; // 99 = unknown @@ -116,4 +120,4 @@ private: extern ModemManager modemManager; #endif // MODEM_MANAGER_H -#endif // HAS_4G_MODEM +#endif // HAS_4G_MODEM \ No newline at end of file diff --git a/examples/companion_radio/ui-new/SMSContacts.cpp b/examples/companion_radio/ui-new/SMSContacts.cpp new file mode 100644 index 0000000..e14ad19 --- /dev/null +++ b/examples/companion_radio/ui-new/SMSContacts.cpp @@ -0,0 +1,8 @@ +#ifdef HAS_4G_MODEM + +#include "SMSContacts.h" + +// Global singleton +SMSContactStore smsContacts; + +#endif // HAS_4G_MODEM \ No newline at end of file diff --git a/examples/companion_radio/ui-new/SMSContacts.h b/examples/companion_radio/ui-new/SMSContacts.h new file mode 100644 index 0000000..0d53186 --- /dev/null +++ b/examples/companion_radio/ui-new/SMSContacts.h @@ -0,0 +1,176 @@ +#pragma once + +// ============================================================================= +// SMSContacts - Phone-to-name lookup for SMS contacts (4G variant) +// +// Stores contacts in /sms/contacts.txt on SD card. +// Format: one contact per line as "phone=Display Name" +// +// Completely separate from mesh ContactInfo / IdentityStore. +// +// Guard: HAS_4G_MODEM +// ============================================================================= + +#ifdef HAS_4G_MODEM + +#ifndef SMS_CONTACTS_H +#define SMS_CONTACTS_H + +#include +#include + +#define SMS_CONTACT_NAME_LEN 24 +#define SMS_CONTACT_MAX 30 +#define SMS_CONTACTS_FILE "/sms/contacts.txt" + +struct SMSContact { + char phone[20]; // matches SMS_PHONE_LEN + char name[SMS_CONTACT_NAME_LEN]; + bool valid; +}; + +class SMSContactStore { +public: + void begin() { + _count = 0; + memset(_contacts, 0, sizeof(_contacts)); + load(); + } + + // Look up a name by phone number. Returns nullptr if not found. + const char* lookup(const char* phone) const { + for (int i = 0; i < _count; i++) { + if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) { + return _contacts[i].name; + } + } + return nullptr; + } + + // Fill buf with display name if found, otherwise copy phone number. + // Returns true if a name was found. + bool displayName(const char* phone, char* buf, size_t bufLen) const { + const char* name = lookup(phone); + if (name && name[0]) { + strncpy(buf, name, bufLen - 1); + buf[bufLen - 1] = '\0'; + return true; + } + strncpy(buf, phone, bufLen - 1); + buf[bufLen - 1] = '\0'; + return false; + } + + // Add or update a contact. Returns true on success. + bool set(const char* phone, const char* name) { + // Update existing + for (int i = 0; i < _count; i++) { + if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) { + strncpy(_contacts[i].name, name, SMS_CONTACT_NAME_LEN - 1); + _contacts[i].name[SMS_CONTACT_NAME_LEN - 1] = '\0'; + save(); + return true; + } + } + // Add new + if (_count >= SMS_CONTACT_MAX) return false; + strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1); + _contacts[_count].phone[sizeof(_contacts[_count].phone) - 1] = '\0'; + strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1); + _contacts[_count].name[SMS_CONTACT_NAME_LEN - 1] = '\0'; + _contacts[_count].valid = true; + _count++; + save(); + return true; + } + + // Remove a contact by phone number + bool remove(const char* phone) { + for (int i = 0; i < _count; i++) { + if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) { + for (int j = i; j < _count - 1; j++) { + _contacts[j] = _contacts[j + 1]; + } + _count--; + memset(&_contacts[_count], 0, sizeof(SMSContact)); + save(); + return true; + } + } + return false; + } + + // Accessors for list browsing + int count() const { return _count; } + const SMSContact& get(int index) const { return _contacts[index]; } + + // Check if a contact exists + bool exists(const char* phone) const { return lookup(phone) != nullptr; } + +private: + SMSContact _contacts[SMS_CONTACT_MAX]; + int _count = 0; + + void load() { + File f = SD.open(SMS_CONTACTS_FILE, FILE_READ); + if (!f) { + Serial.println("[SMSContacts] No contacts file, starting fresh"); + return; + } + + char line[64]; + while (f.available() && _count < SMS_CONTACT_MAX) { + int pos = 0; + while (f.available() && pos < (int)sizeof(line) - 1) { + char c = f.read(); + if (c == '\n' || c == '\r') break; + line[pos++] = c; + } + line[pos] = '\0'; + if (pos == 0) continue; + // Consume trailing CR/LF + while (f.available()) { + int pk = f.peek(); + if (pk == '\n' || pk == '\r') { f.read(); continue; } + break; + } + + // Parse "phone=name" + char* eq = strchr(line, '='); + if (!eq) continue; + *eq = '\0'; + const char* phone = line; + const char* name = eq + 1; + if (strlen(phone) == 0 || strlen(name) == 0) continue; + + strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1); + strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1); + _contacts[_count].valid = true; + _count++; + } + f.close(); + Serial.printf("[SMSContacts] Loaded %d contacts\n", _count); + } + + void save() { + if (!SD.exists("/sms")) SD.mkdir("/sms"); + File f = SD.open(SMS_CONTACTS_FILE, FILE_WRITE); + if (!f) { + Serial.println("[SMSContacts] Failed to write contacts file"); + return; + } + for (int i = 0; i < _count; i++) { + if (!_contacts[i].valid) continue; + f.print(_contacts[i].phone); + f.print('='); + f.println(_contacts[i].name); + } + f.close(); + } +}; + +// Global singleton +extern SMSContactStore smsContacts; + +#endif // SMS_CONTACTS_H +#endif // HAS_4G_MODEM \ No newline at end of file diff --git a/examples/companion_radio/ui-new/SMSScreen.h b/examples/companion_radio/ui-new/SMSScreen.h index 86739cd..03510e2 100644 --- a/examples/companion_radio/ui-new/SMSScreen.h +++ b/examples/companion_radio/ui-new/SMSScreen.h @@ -3,14 +3,18 @@ // ============================================================================= // SMSScreen - SMS messaging UI for T-Deck Pro (4G variant) // -// Three sub-views: -// INBOX — list of conversations, sorted by most recent +// Sub-views: +// INBOX — list of conversations (names resolved via SMSContacts) // CONVERSATION — messages for a selected contact, scrollable // COMPOSE — text input for new SMS +// CONTACTS — browsable contacts list, pick to compose +// EDIT_CONTACT — add or edit a contact name for a phone number // // Navigation mirrors ChannelScreen conventions: // W/S: scroll Enter: select/send C: compose new/reply // Q: back Sh+Del: cancel compose +// D: contacts (from inbox) +// A: add/edit contact (from conversation) // // Guard: HAS_4G_MODEM // ============================================================================= @@ -22,8 +26,10 @@ #include #include +#include #include "ModemManager.h" #include "SMSStore.h" +#include "SMSContacts.h" // Limits #define SMS_INBOX_PAGE_SIZE 4 @@ -34,7 +40,7 @@ class UITask; // forward declaration class SMSScreen : public UIScreen { public: - enum SubView { INBOX, CONVERSATION, COMPOSE }; + enum SubView { INBOX, CONVERSATION, COMPOSE, CONTACTS, EDIT_CONTACT }; private: UITask* _task; @@ -63,6 +69,17 @@ private: int _phoneInputPos; bool _enteringPhone; + // Contacts list state + int _contactsCursor; + int _contactsScrollTop; + + // Edit contact state + char _editPhone[SMS_PHONE_LEN]; + char _editNameBuf[SMS_CONTACT_NAME_LEN]; + int _editNamePos; + bool _editIsNew; // true = adding new, false = editing existing + SubView _editReturnView; // where to return after save/cancel + // Refresh debounce bool _needsRefresh; unsigned long _lastRefresh; @@ -78,7 +95,8 @@ private: void refreshConversation() { _msgCount = smsStore.loadMessages(_activePhone, _msgs, SMS_MSG_PAGE_SIZE); - _msgScrollPos = 0; + // Scroll to bottom (newest messages are at end now, chat-style) + _msgScrollPos = (_msgCount > 3) ? _msgCount - 3 : 0; } public: @@ -88,6 +106,8 @@ public: , _msgCount(0), _msgScrollPos(0) , _composePos(0), _composeNewConversation(false) , _phoneInputPos(0), _enteringPhone(false) + , _contactsCursor(0), _contactsScrollTop(0) + , _editNamePos(0), _editIsNew(false), _editReturnView(INBOX) , _needsRefresh(false), _lastRefresh(0) , _sdReady(false) { @@ -95,6 +115,8 @@ public: memset(_composePhone, 0, sizeof(_composePhone)); memset(_phoneInputBuf, 0, sizeof(_phoneInputBuf)); memset(_activePhone, 0, sizeof(_activePhone)); + memset(_editPhone, 0, sizeof(_editPhone)); + memset(_editNameBuf, 0, sizeof(_editNameBuf)); } void setSDReady(bool ready) { _sdReady = ready; } @@ -115,11 +137,9 @@ public: if (_sdReady) { smsStore.saveMessage(phone, body, false, timestamp); } - // If we're viewing this conversation, refresh it if (_view == CONVERSATION && strcmp(_activePhone, phone) == 0) { refreshConversation(); } - // If on inbox, refresh the list if (_view == INBOX) { refreshInbox(); } @@ -129,26 +149,29 @@ public: // ========================================================================= // Signal strength indicator (top-right corner) // ========================================================================= - int renderSignalIndicator(DisplayDriver& display, int rightX, int topY) { + + int renderSignalIndicator(DisplayDriver& display, int startX, int topY) { ModemState ms = modemManager.getState(); int bars = modemManager.getSignalBars(); - int iconWidth = 16; // Draw signal bars (4 bars, increasing height) - int barW = 3; - int gap = 1; - int startX = rightX - (4 * (barW + gap)); - for (int i = 0; i < 4; i++) { - int barH = 2 + i * 2; // 2, 4, 6, 8 - int x = startX + i * (barW + gap); - int y = topY + (8 - barH); - if (i < bars) { - display.setColor(DisplayDriver::GREEN); - display.fillRect(x, y, barW, barH); - } else { + int barWidth = 3; + int barGap = 2; + int maxBarH = 10; + int totalWidth = 4 * barWidth + 3 * barGap; + int x = startX - totalWidth; + int iconWidth = totalWidth; + + for (int b = 0; b < 4; b++) { + int barH = 3 + b * 2; + int barY = topY + (maxBarH - barH); + if (b < bars) { display.setColor(DisplayDriver::LIGHT); - display.drawRect(x, y, barW, barH); + } else { + display.setColor(DisplayDriver::DARK); } + display.fillRect(x, barY, barWidth, barH); + x += barWidth + barGap; } // Show modem state text if not ready @@ -157,7 +180,7 @@ public: display.setColor(DisplayDriver::YELLOW); const char* label = ModemManager::stateToString(ms); uint16_t labelW = display.getTextWidth(label); - display.setCursor(startX - labelW - 2, topY - 3); + display.setCursor(startX - totalWidth - labelW - 2, topY - 3); display.print(label); display.setTextSize(1); return iconWidth + labelW + 2; @@ -177,6 +200,8 @@ public: case INBOX: return renderInbox(display); case CONVERSATION: return renderConversation(display); case COMPOSE: return renderCompose(display); + case CONTACTS: return renderContacts(display); + case EDIT_CONTACT: return renderEditContact(display); } return 1000; } @@ -204,7 +229,7 @@ public: display.print("No conversations"); display.setCursor(0, 32); display.print("Press C for new SMS"); - + if (ms != ModemState::READY) { display.setCursor(0, 48); display.setColor(DisplayDriver::YELLOW); @@ -234,11 +259,14 @@ public: bool selected = (idx == _inboxCursor); - // Phone number (highlighted if selected) + // Resolve contact name (shows name if saved, phone otherwise) + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(c.phone, dispName, sizeof(dispName)); + display.setCursor(0, y); display.setColor(selected ? DisplayDriver::GREEN : DisplayDriver::LIGHT); if (selected) display.print("> "); - display.print(c.phone); + display.print(dispName); // Message count at right char countStr[8]; @@ -261,31 +289,31 @@ public: } // Footer - display.setTextSize(0); // Must be set before setCursor/getTextWidth - display.setColor(DisplayDriver::LIGHT); - int footerY = display.height() - 10; + display.setTextSize(1); + int footerY = display.height() - 12; display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); display.setCursor(0, footerY); - display.print("Q:Back"); - const char* mid = "W/S:Scrll"; + display.print("Q:Bk"); + const char* mid = "D:Contacts"; display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY); display.print(mid); const char* rt = "C:New"; display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); display.print(rt); - display.setTextSize(1); return 5000; } // ---- Conversation view ---- int renderConversation(DisplayDriver& display) { - // Header + // Header - show contact name if available, phone otherwise display.setTextSize(1); display.setColor(DisplayDriver::GREEN); display.setCursor(0, 0); - display.print(_activePhone); + char convTitle[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(_activePhone, convTitle, sizeof(convTitle)); + display.print(convTitle); // Signal icon renderSignalIndicator(display, display.width() - 2, 0); @@ -322,14 +350,21 @@ public: display.setCursor(0, y); display.setColor(msg.isSent ? DisplayDriver::BLUE : DisplayDriver::YELLOW); - // Time formatting - uint32_t now = millis() / 1000; - uint32_t age = (now > msg.timestamp) ? (now - msg.timestamp) : 0; + // Time formatting (epoch-aware) char timeStr[16]; - if (age < 60) snprintf(timeStr, sizeof(timeStr), "%lus", (unsigned long)age); - else if (age < 3600) snprintf(timeStr, sizeof(timeStr), "%lum", (unsigned long)(age / 60)); - else if (age < 86400) snprintf(timeStr, sizeof(timeStr), "%luh", (unsigned long)(age / 3600)); - else snprintf(timeStr, sizeof(timeStr), "%lud", (unsigned long)(age / 86400)); + time_t now = time(nullptr); + bool haveEpoch = (now > 1700000000); // system clock is set + bool msgIsEpoch = (msg.timestamp > 1700000000); // msg has real timestamp + + if (haveEpoch && msgIsEpoch) { + uint32_t age = (uint32_t)(now - msg.timestamp); + if (age < 60) snprintf(timeStr, sizeof(timeStr), "%lus", (unsigned long)age); + else if (age < 3600) snprintf(timeStr, sizeof(timeStr), "%lum", (unsigned long)(age / 60)); + else if (age < 86400) snprintf(timeStr, sizeof(timeStr), "%luh", (unsigned long)(age / 3600)); + else snprintf(timeStr, sizeof(timeStr), "%lud", (unsigned long)(age / 86400)); + } else { + strncpy(timeStr, "---", sizeof(timeStr)); + } char header[32]; snprintf(header, sizeof(header), "%s %s", @@ -368,20 +403,15 @@ public: } // Footer - display.setTextSize(0); // Must be set before setCursor/getTextWidth - display.setColor(DisplayDriver::LIGHT); - int footerY = display.height() - 10; + display.setTextSize(1); + int footerY = display.height() - 12; display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); display.setCursor(0, footerY); - display.print("Q:Back"); - const char* mid = "W/S:Scrll"; - display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY); - display.print(mid); + display.print("Q:Bk A:Add"); const char* rt = "C:Reply"; display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); display.print(rt); - display.setTextSize(1); return 5000; } @@ -393,15 +423,17 @@ public: display.setCursor(0, 0); if (_enteringPhone) { - // Phone number input mode display.print("To: "); display.setColor(DisplayDriver::LIGHT); display.print(_phoneInputBuf); display.print("_"); } else { - char header[40]; - snprintf(header, sizeof(header), "To: %s", _composePhone); - display.print(header); + // Show contact name if available + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(_composePhone, dispName, sizeof(dispName)); + char toLabel[40]; + snprintf(toLabel, sizeof(toLabel), "To: %s", dispName); + display.print(toLabel); } display.setColor(DisplayDriver::LIGHT); @@ -438,27 +470,141 @@ public: } // Status bar - display.setTextSize(0); // Must be set before setCursor/getTextWidth - display.setColor(DisplayDriver::LIGHT); - int statusY = display.height() - 10; + display.setTextSize(1); + int statusY = display.height() - 12; display.drawRect(0, statusY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); display.setCursor(0, statusY); if (_enteringPhone) { - display.print("Phone# then Ent"); - const char* rt = "S+D:X"; + display.print("Phone#"); + const char* rt = "Ent S+D:X"; display.setCursor(display.width() - display.getTextWidth(rt) - 2, statusY); display.print(rt); } else { - char status[30]; + char status[16]; snprintf(status, sizeof(status), "%d/%d", _composePos, SMS_COMPOSE_MAX); display.print(status); - const char* rt = "Ent:Snd S+D:X"; + const char* rt = "Ent S+D:X"; display.setCursor(display.width() - display.getTextWidth(rt) - 2, statusY); display.print(rt); } + + return 2000; + } + + // ---- Contacts list ---- + int renderContacts(DisplayDriver& display) { display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print("SMS Contacts"); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, display.width(), 1); + + int cnt = smsContacts.count(); + + if (cnt == 0) { + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, 25); + display.print("No contacts saved"); + display.setCursor(0, 37); + display.print("Open a conversation"); + display.setCursor(0, 49); + display.print("and press A to add"); + display.setTextSize(1); + } else { + display.setTextSize(0); + int lineHeight = 10; + int y = 14; + + int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2); + if (visibleCount < 1) visibleCount = 1; + + // Adjust scroll + if (_contactsCursor >= cnt) _contactsCursor = cnt - 1; + if (_contactsCursor < 0) _contactsCursor = 0; + if (_contactsCursor < _contactsScrollTop) _contactsScrollTop = _contactsCursor; + if (_contactsCursor >= _contactsScrollTop + visibleCount) { + _contactsScrollTop = _contactsCursor - visibleCount + 1; + } + + for (int vi = 0; vi < visibleCount && (_contactsScrollTop + vi) < cnt; vi++) { + int idx = _contactsScrollTop + vi; + const SMSContact& ct = smsContacts.get(idx); + if (!ct.valid) continue; + + bool selected = (idx == _contactsCursor); + + // Name + display.setCursor(0, y); + display.setColor(selected ? DisplayDriver::GREEN : DisplayDriver::LIGHT); + if (selected) display.print("> "); + display.print(ct.name); + y += lineHeight; + + // Phone (dimmer) + display.setColor(DisplayDriver::LIGHT); + display.setCursor(12, y); + display.print(ct.phone); + y += lineHeight + 2; + } + display.setTextSize(1); + } + + // Footer + display.setTextSize(1); + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, footerY); + display.print("Q:Back"); + const char* rt = "Ent:SMS"; + display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); + display.print(rt); + + return 5000; + } + + // ---- Edit contact ---- + int renderEditContact(DisplayDriver& display) { + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print(_editIsNew ? "Add Contact" : "Edit Contact"); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, display.width(), 1); + + // Phone number (read-only) + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, 16); + display.print("Phone: "); + display.print(_editPhone); + + // Name input + display.setCursor(0, 30); + display.setColor(DisplayDriver::YELLOW); + display.print("Name: "); + display.setColor(DisplayDriver::LIGHT); + display.print(_editNameBuf); + display.print("_"); + + display.setTextSize(1); + + // Footer + display.setTextSize(1); + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, footerY); + display.print("S+D:X"); + const char* rt = "Ent:Save"; + display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); + display.print(rt); return 2000; } @@ -472,6 +618,8 @@ public: case INBOX: return handleInboxInput(c); case CONVERSATION: return handleConversationInput(c); case COMPOSE: return handleComposeInput(c); + case CONTACTS: return handleContactsInput(c); + case EDIT_CONTACT: return handleEditContactInput(c); } return false; } @@ -505,8 +653,14 @@ public: _view = COMPOSE; return true; + case 'd': case 'D': // Open contacts list + _contactsCursor = 0; + _contactsScrollTop = 0; + _view = CONTACTS; + return true; + case 'q': case 'Q': // Back to home (handled by main.cpp) - return false; // Let main.cpp handle navigation + return false; default: return false; @@ -533,6 +687,26 @@ public: _view = COMPOSE; return true; + case 'a': case 'A': { // Add/edit contact for this number + strncpy(_editPhone, _activePhone, SMS_PHONE_LEN - 1); + _editPhone[SMS_PHONE_LEN - 1] = '\0'; + _editReturnView = CONVERSATION; + + const char* existing = smsContacts.lookup(_activePhone); + if (existing) { + _editIsNew = false; + strncpy(_editNameBuf, existing, SMS_CONTACT_NAME_LEN - 1); + _editNameBuf[SMS_CONTACT_NAME_LEN - 1] = '\0'; + _editNamePos = strlen(_editNameBuf); + } else { + _editIsNew = true; + _editNameBuf[0] = '\0'; + _editNamePos = 0; + } + _view = EDIT_CONTACT; + return true; + } + case 'q': case 'Q': // Back to inbox refreshInbox(); _view = INBOX; @@ -549,26 +723,18 @@ public: return handlePhoneInput(c); } - // Message body input switch (c) { case '\r': { // Enter - send SMS if (_composePos > 0) { _composeBuf[_composePos] = '\0'; - - // Queue for sending via modem bool queued = modemManager.sendSMS(_composePhone, _composeBuf); - - // Save to store (as sent) if (_sdReady) { - uint32_t ts = millis() / 1000; + uint32_t ts = (uint32_t)time(nullptr); smsStore.saveMessage(_composePhone, _composeBuf, true, ts); } - Serial.printf("[SMS] %s to %s: %s\n", queued ? "Queued" : "Queue full", _composePhone, _composeBuf); } - - // Return to inbox _composeBuf[0] = '\0'; _composePos = 0; refreshInbox(); @@ -583,7 +749,7 @@ public: } return true; - case 0x18: // Shift+Backspace (cancel) — same as mesh compose + case 0x18: // Shift+Backspace (cancel) _composeBuf[0] = '\0'; _composePos = 0; refreshInbox(); @@ -591,7 +757,6 @@ public: return true; default: - // Printable character if (c >= 32 && c < 127 && _composePos < SMS_COMPOSE_MAX) { _composeBuf[_composePos++] = c; _composeBuf[_composePos] = '\0'; @@ -603,7 +768,7 @@ public: // ---- Phone number input ---- bool handlePhoneInput(char c) { switch (c) { - case '\r': // Enter - done entering phone, move to body + case '\r': // Done entering phone, move to body if (_phoneInputPos > 0) { _phoneInputBuf[_phoneInputPos] = '\0'; strncpy(_composePhone, _phoneInputBuf, SMS_PHONE_LEN - 1); @@ -613,7 +778,7 @@ public: } return true; - case '\b': // Backspace + case '\b': if (_phoneInputPos > 0) { _phoneInputPos--; _phoneInputBuf[_phoneInputPos] = '\0'; @@ -629,7 +794,6 @@ public: return true; default: - // Accept digits, +, *, # for phone numbers if (_phoneInputPos < SMS_PHONE_LEN - 1 && ((c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#')) { _phoneInputBuf[_phoneInputPos++] = c; @@ -638,6 +802,83 @@ public: return true; } } + + // ---- Contacts list input ---- + bool handleContactsInput(char c) { + int cnt = smsContacts.count(); + + switch (c) { + case 'w': case 'W': + if (_contactsCursor > 0) _contactsCursor--; + return true; + + case 's': case 'S': + if (_contactsCursor < cnt - 1) _contactsCursor++; + return true; + + case '\r': // Enter - compose to selected contact + if (cnt > 0 && _contactsCursor < cnt) { + const SMSContact& ct = smsContacts.get(_contactsCursor); + _composeNewConversation = true; + _enteringPhone = false; + strncpy(_composePhone, ct.phone, SMS_PHONE_LEN - 1); + _composeBuf[0] = '\0'; + _composePos = 0; + _view = COMPOSE; + } + return true; + + case 'q': case 'Q': // Back to inbox + refreshInbox(); + _view = INBOX; + return true; + + default: + return false; + } + } + + // ---- Edit contact input ---- + bool handleEditContactInput(char c) { + switch (c) { + case '\r': // Enter - save contact + if (_editNamePos > 0) { + _editNameBuf[_editNamePos] = '\0'; + smsContacts.set(_editPhone, _editNameBuf); + Serial.printf("[SMSContacts] Saved: %s = %s\n", _editPhone, _editNameBuf); + } + if (_editReturnView == CONVERSATION) { + refreshConversation(); + } else { + refreshInbox(); + } + _view = _editReturnView; + return true; + + case '\b': // Backspace + if (_editNamePos > 0) { + _editNamePos--; + _editNameBuf[_editNamePos] = '\0'; + } + return true; + + case 0x18: // Shift+Backspace (cancel without saving) + if (_editReturnView == CONVERSATION) { + refreshConversation(); + } else { + refreshInbox(); + } + _view = _editReturnView; + return true; + + default: + if (c >= 32 && c < 127 && _editNamePos < SMS_CONTACT_NAME_LEN - 1) { + _editNameBuf[_editNamePos++] = c; + _editNameBuf[_editNamePos] = '\0'; + } + return true; + } + } }; #endif // SMS_SCREEN_H diff --git a/examples/companion_radio/ui-new/SMSStore.cpp b/examples/companion_radio/ui-new/SMSStore.cpp index 2bf9b5c..8d8cd85 100644 --- a/examples/companion_radio/ui-new/SMSStore.cpp +++ b/examples/companion_radio/ui-new/SMSStore.cpp @@ -138,14 +138,13 @@ int SMSStore::loadMessages(const char* phone, SMSMessage* out, int maxCount) { size_t fileSize = f.size(); int numRecords = fileSize / sizeof(SMSRecord); - // Load from end (newest first), up to maxCount + // Load from end of file (most recent N messages), in chronological order int startIdx = numRecords > maxCount ? numRecords - maxCount : 0; - int loadCount = numRecords - startIdx; - // Read from startIdx and reverse order for display (newest first) + // Read chronologically (oldest first) for chat-style display SMSRecord rec; int outIdx = 0; - for (int i = numRecords - 1; i >= startIdx && outIdx < maxCount; i--) { + for (int i = startIdx; i < numRecords && outIdx < maxCount; i++) { f.seek(i * sizeof(SMSRecord)); if (f.read((uint8_t*)&rec, sizeof(SMSRecord)) != sizeof(SMSRecord)) continue; diff --git a/examples/companion_radio/ui-new/SMSStore.h b/examples/companion_radio/ui-new/SMSStore.h index c04add5..3235909 100644 --- a/examples/companion_radio/ui-new/SMSStore.h +++ b/examples/companion_radio/ui-new/SMSStore.h @@ -64,7 +64,7 @@ public: // Load conversation list (sorted by most recent) int loadConversations(SMSConversation* out, int maxCount); - // Load messages for a specific phone number (newest first) + // Load messages for a specific phone number (chronological, oldest first) int loadMessages(const char* phone, SMSMessage* out, int maxCount); // Delete all messages for a phone number @@ -84,4 +84,4 @@ private: extern SMSStore smsStore; #endif // SMS_STORE_H -#endif // HAS_4G_MODEM +#endif // HAS_4G_MODEM \ No newline at end of file diff --git a/examples/companion_radio/ui-new/Settingsscreen.h b/examples/companion_radio/ui-new/Settingsscreen.h index bd64b9c..1ce8d03 100644 --- a/examples/companion_radio/ui-new/Settingsscreen.h +++ b/examples/companion_radio/ui-new/Settingsscreen.h @@ -6,6 +6,10 @@ #include #include "../NodePrefs.h" +#ifdef HAS_4G_MODEM + #include "ModemManager.h" +#endif + // Forward declarations class UITask; class MyMesh; @@ -56,6 +60,9 @@ enum SettingsRowType : uint8_t { ROW_TX_POWER, // TX power (1-20 dBm) ROW_UTC_OFFSET, // UTC offset (-12 to +14) ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle + #ifdef HAS_4G_MODEM + ROW_MODEM_TOGGLE, // 4G modem enable/disable toggle (4G builds only) + #endif ROW_CH_HEADER, // "--- Channels ---" separator ROW_CHANNEL, // A channel entry (dynamic, index stored separately) ROW_ADD_CHANNEL, // "+ Add Hashtag Channel" @@ -85,7 +92,7 @@ private: mesh::RTCClock* _rtc; NodePrefs* _prefs; - // Row table — rebuilt whenever channels change + // Row table — rebuilt whenever channels change struct Row { SettingsRowType type; uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET @@ -109,9 +116,14 @@ private: // Onboarding mode bool _onboarding; - // Dirty flag for radio params — prompt to apply + // Dirty flag for radio params — prompt to apply bool _radioChanged; + // 4G modem state (runtime cache of config) + #ifdef HAS_4G_MODEM + bool _modemEnabled; + #endif + // --------------------------------------------------------------------------- // Row table management // --------------------------------------------------------------------------- @@ -128,6 +140,9 @@ private: addRow(ROW_TX_POWER); addRow(ROW_UTC_OFFSET); addRow(ROW_MSG_NOTIFY); + #ifdef HAS_4G_MODEM + addRow(ROW_MODEM_TOGGLE); + #endif addRow(ROW_CH_HEADER); // Enumerate current channels @@ -212,11 +227,11 @@ private: strncpy(newCh.name, chanName, sizeof(newCh.name)); newCh.name[31] = '\0'; - // SHA-256 the channel name → first 16 bytes become the secret + // SHA-256 the channel name → first 16 bytes become the secret uint8_t hash[32]; mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName)); memcpy(newCh.channel.secret, hash, 16); - // Upper 16 bytes left as zero → setChannel uses 128-bit mode + // Upper 16 bytes left as zero → setChannel uses 128-bit mode // Find next empty slot for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { @@ -289,6 +304,9 @@ public: _cursor = 0; _scrollTop = 0; _radioChanged = false; + #ifdef HAS_4G_MODEM + _modemEnabled = ModemManager::loadEnabledConfig(); + #endif rebuildRows(); } @@ -473,6 +491,14 @@ public: display.print(tmp); break; + #ifdef HAS_4G_MODEM + case ROW_MODEM_TOGGLE: + snprintf(tmp, sizeof(tmp), "4G Modem: %s", + _modemEnabled ? "ON" : "OFF"); + display.print(tmp); + break; + #endif + case ROW_CH_HEADER: display.setColor(DisplayDriver::YELLOW); display.print("--- Channels ---"); @@ -838,6 +864,19 @@ public: Serial.printf("Settings: Msg flash notify = %s\n", _prefs->kb_flash_notify ? "ON" : "OFF"); break; + #ifdef HAS_4G_MODEM + case ROW_MODEM_TOGGLE: + _modemEnabled = !_modemEnabled; + ModemManager::saveEnabledConfig(_modemEnabled); + if (_modemEnabled) { + modemManager.begin(); + Serial.println("Settings: 4G modem ENABLED (started)"); + } else { + modemManager.shutdown(); + Serial.println("Settings: 4G modem DISABLED (shutdown)"); + } + break; + #endif case ROW_ADD_CHANNEL: startEditText(""); break; @@ -861,7 +900,7 @@ public: } } - // Q: back — if radio changed, prompt to apply first + // Q: back — if radio changed, prompt to apply first if (c == 'q' || c == 'Q') { if (_radioChanged) { _editMode = EDIT_CONFIRM;