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

This commit is contained in:
pelgraine
2026-02-20 22:03:06 +11:00
parent 458db8d4c4
commit f06a1f5499
9 changed files with 646 additions and 89 deletions

View File

@@ -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
}

View File

@@ -2,6 +2,9 @@
#include "ModemManager.h"
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
#include <SD.h> // For modem config persistence
#include <time.h>
#include <sys/time.h>
// 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);

View File

@@ -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

View File

@@ -0,0 +1,8 @@
#ifdef HAS_4G_MODEM
#include "SMSContacts.h"
// Global singleton
SMSContactStore smsContacts;
#endif // HAS_4G_MODEM

View File

@@ -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 <Arduino.h>
#include <SD.h>
#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

View File

@@ -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 <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <time.h>
#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

View File

@@ -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;

View File

@@ -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

View File

@@ -6,6 +6,10 @@
#include <MeshCore.h>
#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;