4 Commits

15 changed files with 2373 additions and 33 deletions

116
SMS App Guide.md Normal file
View File

@@ -0,0 +1,116 @@
## SMS App (4G variant only) - Meck v0.9.2 (Alpha)
Press **T** from the home screen to open the SMS app.
Requires a nano SIM card inserted in the T-Deck Pro V1.1 4G modem slot and an
SD card formatted as FAT32. The modem registers on the cellular network
automatically at boot — the red LED on the board indicates the modem is
powered. The modem (and its red LED) can be switched off and on from the
settings screen. After each modem startup, the system clock syncs from the
cellular network, which takes roughly 15 seconds.
### Key Mapping
| Context | Key | Action |
|---------|-----|--------|
| Home screen | T | Open SMS app |
| Inbox | W / S | Scroll conversations |
| Inbox | Enter | Open conversation |
| Inbox | C | Compose new SMS (enter phone number) |
| Inbox | D | Open contacts directory |
| Inbox | Q | Back to home screen |
| Conversation | W / S | Scroll messages |
| Conversation | C | Reply to this conversation |
| Conversation | A | Add or edit contact name for this number |
| Conversation | Q | Back to inbox |
| Compose | Enter | Send SMS (from body) / Confirm phone number (from phone input) |
| Compose | Shift+Del | Cancel and return |
| Contacts | W / S | Scroll contact list |
| Contacts | Enter | Compose SMS to selected contact |
| Contacts | Q | Back to inbox |
| Edit Contact | Enter | Save contact name |
| Edit Contact | Shift+Del | Cancel without saving |
### Sending an SMS
There are three ways to start a new message:
1. **From inbox** — press **C**, type the destination phone number, press
**Enter**, then type your message and press **Enter** to send.
2. **From a conversation** — press **C** to reply. The recipient is
pre-filled so you go straight to typing the message body.
3. **From the contacts directory** — press **D** from the inbox, scroll to a
contact, and press **Enter**. The compose screen opens with the number
pre-filled.
Messages are limited to 160 characters (standard SMS). A character counter is
shown in the footer while composing.
### Contacts
The contacts directory lets you assign display names to phone numbers.
Names appear in the inbox list, conversation headers, and compose screen
instead of raw numbers.
To add or edit a contact, open a conversation with that number and press **A**.
Type the display name and press **Enter** to save. Names can be up to 23
characters long.
Contacts are stored as a plain text file at `/sms/contacts.txt` on the SD card
in `phone=Display Name` format — one per line, human-editable. Up to 30
contacts are supported.
### Conversation History
Messages are saved to the SD card automatically and persist across reboots.
Each phone number gets its own file under `/sms/` on the SD card. The inbox
shows the most recent 20 conversations sorted by last activity. Within a
conversation, the most recent 30 messages are loaded with the newest at the
bottom (chat-style). Sent messages are shown with `>>>` and received messages
with `<<<`.
Message timestamps use the cellular network clock (synced via NITZ roughly 15
seconds after each modem startup) and display as relative times (e.g. 5m, 2h,
1d). If the modem is toggled off and back on, the clock re-syncs automatically.
### Modem Power Control
The 4G modem can be toggled on or off from the settings screen. Scroll to
**4G Modem: ON/OFF** and press **Enter** to toggle. Switching the modem off
kills its red status LED and stops all cellular activity. The setting persists
to SD card and is respected on subsequent boots — if disabled, the modem and
LED stay off until re-enabled. The SMS app remains accessible when the modem
is off but will not be able to send or receive messages.
### Signal Indicator
A signal strength indicator is shown in the top-right corner of all SMS
screens. Bars are derived from the modem's CSQ (signal quality) reading,
updated every 30 seconds. The modem state (REG, READY, OFF, etc.) is shown
when not yet connected.
### SD Card Structure
```
SD Card
├── sms/
│ ├── contacts.txt (plain text, phone=Name format)
│ ├── modem.cfg (0 or 1, modem enable state)
│ ├── 0412345678.sms (binary message log per phone number)
│ └── 0498765432.sms
├── books/ (text reader)
├── audiobooks/ (audio variant only)
└── ...
```
### Troubleshooting
| Symptom | Likely Cause |
|---------|-------------|
| Modem icon stays at REG / never reaches READY | SIM not inserted, no signal, or SIM requires PIN unlock (not currently supported) |
| Timestamps show `---` | Modem clock hasn't synced yet (wait ~15 seconds after modem startup), or messages were saved before clock sync was available |
| Red LED stays on after disabling modem | Toggle the setting off, then reboot — the boot sequence ensures power is cut when disabled |
| SMS sends but no delivery | Check signal strength; below 5 bars is marginal. Move to better coverage |
> **Note:** The SMS app is only available on the 4G modem variant of the
> T-Deck Pro. It is not present on the audio or standalone BLE builds due to
> shared GPIO pin conflicts between the A7682E modem and PCM5102A DAC.

View File

@@ -12,7 +12,7 @@
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.9.1A"
#define FIRMWARE_VERSION "Meck v0.9.2"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)

View File

@@ -50,11 +50,22 @@
// Audiobook player — Audio object is heap-allocated on first use to avoid
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#include "Audio.h"
Audio* audio = nullptr;
// Audiobook player — Audio object is heap-allocated on first use to avoid
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
// Not available on 4G variant (I2S pins conflict with modem control lines).
#ifndef HAS_4G_MODEM
#include "AudiobookPlayerScreen.h"
#include "Audio.h"
Audio* audio = nullptr;
#endif
static bool audiobookMode = false;
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#include "SMSStore.h"
#include "SMSContacts.h"
#include "SMSScreen.h"
static bool smsMode = false;
#endif
// Power management
@@ -538,6 +549,32 @@ void setup() {
// Do an initial settings backup to SD (captures any first-boot defaults)
backupSettingsToSD();
// SMS / 4G modem init (after SD is ready)
#ifdef HAS_4G_MODEM
{
smsStore.begin();
smsContacts.begin();
// Tell SMS screen that SD is ready
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr) {
smsScr->setSDReady(true);
}
// 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
}
#endif
@@ -607,7 +644,7 @@ void loop() {
cpuPower.loop();
// Audiobook: service audio decode regardless of which screen is active
#ifdef MECK_AUDIO_VARIANT
#ifndef HAS_4G_MODEM
{
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
@@ -619,7 +656,29 @@ void loop() {
}
}
}
#endif
#endif
// SMS: poll for incoming messages from modem
#ifdef HAS_4G_MODEM
{
SMSIncoming incoming;
while (modemManager.recvSMS(incoming)) {
// Save to store and notify UI
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr) {
smsScr->onIncomingSMS(incoming.phone, incoming.body, incoming.timestamp);
}
// Alert + buzzer
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "SMS: %s", incoming.phone);
ui_task.showAlert(alertBuf, 2000);
ui_task.notify(UIEventType::contactMessage);
Serial.printf("[SMS] Received from %s: %.40s...\n", incoming.phone, incoming.body);
}
}
#endif
#ifdef DISPLAY_CLASS
// Skip UITask rendering when in compose mode to prevent flickering
#if defined(LilyGo_TDeck_Pro)
@@ -627,7 +686,12 @@ void loop() {
bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing();
bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming();
bool notesSuppressLoop = notesEditing || notesRenaming;
if (!composeMode && !notesSuppressLoop) {
#ifdef HAS_4G_MODEM
bool smsSuppressLoop = smsMode && ((SMSScreen*)ui_task.getSMSScreen())->isComposing();
#else
bool smsSuppressLoop = false;
#endif
if (!composeMode && !notesSuppressLoop && !smsSuppressLoop) {
ui_task.loop();
} else {
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
@@ -642,6 +706,13 @@ void loop() {
// Notes editor/rename renders through UITask - force a refresh cycle
ui_task.forceRefresh();
ui_task.loop();
} else if (smsSuppressLoop) {
// SMS compose: render directly to display, same as mesh compose
#ifdef DISPLAY_CLASS
display.startFrame();
((SMSScreen*)ui_task.getSMSScreen())->render(display);
display.endFrame();
#endif
}
lastComposeRefresh = millis();
composeNeedsRefresh = false;
@@ -650,8 +721,9 @@ void loop() {
// Track reader/notes/audiobook mode state for key routing
readerMode = ui_task.isOnTextReader();
notesMode = ui_task.isOnNotesScreen();
#ifdef MECK_AUDIO_VARIANT
audiobookMode = ui_task.isOnAudiobookPlayer();
#ifdef HAS_4G_MODEM
smsMode = ui_task.isOnSMSScreen();
#endif
#else
ui_task.loop();
@@ -843,7 +915,7 @@ void handleKeyboardInput() {
}
// *** AUDIOBOOK MODE ***
#ifdef MECK_AUDIO_VARIANT
#ifndef HAS_4G_MODEM
if (audiobookMode) {
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
@@ -876,7 +948,7 @@ void handleKeyboardInput() {
ui_task.injectKey(key);
return;
}
#endif // MECK_AUDIO_VARIANT
#endif // !HAS_4G_MODEM
// *** TEXT READER MODE ***
if (readerMode) {
@@ -1108,6 +1180,40 @@ void handleKeyboardInput() {
return;
}
// SMS mode key routing (when on SMS screen)
#ifdef HAS_4G_MODEM
if (smsMode) {
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr) {
// Q from inbox → go home; Q from inner views is handled by SMSScreen
if ((key == 'q' || key == '\b') && smsScr->getSubView() == SMSScreen::INBOX) {
Serial.println("Nav: SMS -> Home");
ui_task.gotoHomeScreen();
return;
}
if (smsScr->isComposing()) {
// Composing/text input: route directly to screen, bypass injectKey()
// to avoid UITask scheduling its own competing refresh
smsScr->handleInput(key);
if (smsScr->isComposing()) {
// Still composing — debounced refresh
composeNeedsRefresh = true;
lastComposeRefresh = millis();
} else {
// View changed (sent/cancelled) — immediate UITask refresh
composeNeedsRefresh = false;
ui_task.forceRefresh();
}
} else {
// Non-compose views (inbox, conversation, contacts): use normal inject
ui_task.injectKey(key);
}
return;
}
}
#endif
// Normal mode - not composing
switch (key) {
case 'c':
@@ -1128,25 +1234,30 @@ void handleKeyboardInput() {
ui_task.gotoTextReader();
break;
#ifndef HAS_4G_MODEM
case 'p':
#ifdef MECK_AUDIO_VARIANT
// Open audiobook player -- lazy-init Audio + screen on first use
// Open audiobook player - lazy-init Audio + screen on first use
Serial.println("Opening audiobook player");
if (!ui_task.getAudiobookScreen()) {
Serial.printf("Audiobook: lazy init -- free heap: %d, largest block: %d\n",
Serial.printf("Audiobook: lazy init - free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
audio = new Audio();
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio);
abScreen->setSDReady(sdCardReady);
ui_task.setAudiobookScreen(abScreen);
Serial.printf("Audiobook: init complete -- free heap: %d\n", ESP.getFreeHeap());
Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap());
}
ui_task.gotoAudiobookPlayer();
#else
Serial.println("Audio not available on this build variant");
ui_task.showAlert("No audio hardware", 1500);
#endif
break;
#endif
#ifdef HAS_4G_MODEM
case 't':
// Open SMS (4G variant only)
Serial.println("Opening SMS");
ui_task.gotoSMSScreen();
break;
#endif
case 'n':
// Open notes
@@ -1480,7 +1591,10 @@ void sendComposedMessage() {
// ============================================================================
// ESP32-audioI2S CALLBACKS
// ============================================================================
#ifdef MECK_AUDIO_VARIANT
// The audio library calls these global functions - must be defined at file scope.
// Not available on 4G variant (no audio hardware).
#ifndef HAS_4G_MODEM
void audio_info(const char *info) {
Serial.printf("Audio: %s\n", info);
}
@@ -1494,6 +1608,6 @@ void audio_eof_mp3(const char *info) {
abPlayer->onEOF();
}
}
#endif // MECK_AUDIO_VARIANT
#endif // !HAS_4G_MODEM
#endif // LilyGo_TDeck_Pro

View File

@@ -0,0 +1,559 @@
#ifdef HAS_4G_MODEM
#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;
// Use Serial1 for modem UART
#define MODEM_SERIAL Serial1
#define MODEM_BAUD 115200
// AT response buffer
#define AT_BUF_SIZE 512
static char _atBuf[AT_BUF_SIZE];
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
void ModemManager::begin() {
MESH_DEBUG_PRINTLN("[Modem] begin()");
_state = ModemState::OFF;
_csq = 99;
_operator[0] = '\0';
// Create FreeRTOS primitives
_sendQueue = xQueueCreate(MODEM_SEND_QUEUE_SIZE, sizeof(SMSOutgoing));
_recvQueue = xQueueCreate(MODEM_RECV_QUEUE_SIZE, sizeof(SMSIncoming));
_uartMutex = xSemaphoreCreateMutex();
// Launch background task on Core 0
xTaskCreatePinnedToCore(
taskEntry,
"modem",
MODEM_TASK_STACK_SIZE,
this,
MODEM_TASK_PRIORITY,
&_taskHandle,
MODEM_TASK_CORE
);
}
void ModemManager::shutdown() {
if (!_taskHandle) return;
MESH_DEBUG_PRINTLN("[Modem] shutdown()");
// Tell modem to power off gracefully
if (xSemaphoreTake(_uartMutex, pdMS_TO_TICKS(2000))) {
sendAT("AT+CPOF", "OK", 5000);
xSemaphoreGive(_uartMutex);
}
// Cut modem power
digitalWrite(MODEM_POWER_EN, LOW);
// Delete task
vTaskDelete(_taskHandle);
_taskHandle = nullptr;
_state = ModemState::OFF;
}
bool ModemManager::sendSMS(const char* phone, const char* body) {
if (!_sendQueue) return false;
SMSOutgoing msg;
memset(&msg, 0, sizeof(msg));
strncpy(msg.phone, phone, SMS_PHONE_LEN - 1);
strncpy(msg.body, body, SMS_BODY_LEN - 1);
return xQueueSend(_sendQueue, &msg, 0) == pdTRUE;
}
bool ModemManager::recvSMS(SMSIncoming& out) {
if (!_recvQueue) return false;
return xQueueReceive(_recvQueue, &out, 0) == pdTRUE;
}
int ModemManager::getSignalBars() const {
if (_csq == 99 || _csq == 0) return 0;
if (_csq <= 5) return 1;
if (_csq <= 10) return 2;
if (_csq <= 15) return 3;
if (_csq <= 20) return 4;
return 5;
}
const char* ModemManager::stateToString(ModemState s) {
switch (s) {
case ModemState::OFF: return "OFF";
case ModemState::POWERING_ON: return "PWR ON";
case ModemState::INITIALIZING: return "INIT";
case ModemState::REGISTERING: return "REG";
case ModemState::READY: return "READY";
case ModemState::ERROR: return "ERROR";
case ModemState::SENDING_SMS: return "SENDING";
default: return "???";
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
void ModemManager::taskEntry(void* param) {
static_cast<ModemManager*>(param)->taskLoop();
}
void ModemManager::taskLoop() {
MESH_DEBUG_PRINTLN("[Modem] task started on core %d", xPortGetCoreID());
restart:
// ---- Phase 1: Power on ----
_state = ModemState::POWERING_ON;
if (!modemPowerOn()) {
MESH_DEBUG_PRINTLN("[Modem] power-on failed, retry in 30s");
_state = ModemState::ERROR;
vTaskDelay(pdMS_TO_TICKS(30000));
goto restart;
}
// ---- Phase 2: Initialize ----
_state = ModemState::INITIALIZING;
MESH_DEBUG_PRINTLN("[Modem] initializing...");
// Basic AT check
{
bool atOk = false;
for (int i = 0; i < 10; i++) {
MESH_DEBUG_PRINTLN("[Modem] init AT check %d/10", i + 1);
if (sendAT("AT", "OK", 1000)) { atOk = true; break; }
vTaskDelay(pdMS_TO_TICKS(500));
}
if (!atOk) {
MESH_DEBUG_PRINTLN("[Modem] AT check failed — retry from power-on in 30s");
_state = ModemState::ERROR;
vTaskDelay(pdMS_TO_TICKS(30000));
goto restart;
}
}
// Disable echo
sendAT("ATE0", "OK");
// Set SMS text mode
sendAT("AT+CMGF=1", "OK");
// Set character set to GSM (compatible with most networks)
sendAT("AT+CSCS=\"GSM\"", "OK");
// 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...");
bool registered = false;
for (int i = 0; i < 60; i++) { // up to 60 seconds
if (sendAT("AT+CREG?", "OK", 2000)) {
// Full response now in _atBuf, e.g.: "\r\n+CREG: 0,1\r\n\r\nOK\r\n"
// stat: 1=registered home, 5=registered roaming
char* p = strstr(_atBuf, "+CREG:");
if (p) {
int n, stat;
if (sscanf(p, "+CREG: %d,%d", &n, &stat) == 2) {
MESH_DEBUG_PRINTLN("[Modem] CREG: n=%d stat=%d", n, stat);
if (stat == 1 || stat == 5) {
registered = true;
MESH_DEBUG_PRINTLN("[Modem] registered (stat=%d)", stat);
break;
}
}
}
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
if (!registered) {
MESH_DEBUG_PRINTLN("[Modem] registration timeout - continuing anyway");
// Don't set ERROR; some networks are slow but SMS may still work
}
// Query operator name
if (sendAT("AT+COPS?", "OK", 5000)) {
// +COPS: 0,0,"Operator Name",7
char* p = strchr(_atBuf, '"');
if (p) {
p++;
char* e = strchr(p, '"');
if (e) {
int len = e - p;
if (len >= (int)sizeof(_operator)) len = sizeof(_operator) - 1;
memcpy(_operator, p, len);
_operator[len] = '\0';
MESH_DEBUG_PRINTLN("[Modem] operator: %s", _operator);
}
}
}
// 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
_state = ModemState::READY;
MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s)", _csq, _operator);
// ---- Phase 4: Main loop ----
unsigned long lastCSQPoll = 0;
unsigned long lastSMSPoll = 0;
const unsigned long CSQ_POLL_INTERVAL = 30000; // 30s
const unsigned long SMS_POLL_INTERVAL = 10000; // 10s
while (true) {
// Check for outgoing SMS in queue
SMSOutgoing outMsg;
if (xQueueReceive(_sendQueue, &outMsg, 0) == pdTRUE) {
_state = ModemState::SENDING_SMS;
bool ok = doSendSMS(outMsg.phone, outMsg.body);
MESH_DEBUG_PRINTLN("[Modem] SMS send %s to %s", ok ? "OK" : "FAIL", outMsg.phone);
_state = ModemState::READY;
}
// Poll for incoming SMS periodically (not every loop iteration)
if (millis() - lastSMSPoll > SMS_POLL_INTERVAL) {
pollIncomingSMS();
lastSMSPoll = millis();
}
// Periodic signal strength update
if (millis() - lastCSQPoll > CSQ_POLL_INTERVAL) {
pollCSQ();
lastCSQPoll = millis();
}
vTaskDelay(pdMS_TO_TICKS(500)); // 500ms loop — responsive for sends, calm for polls
}
}
// ---------------------------------------------------------------------------
// Hardware Control
// ---------------------------------------------------------------------------
bool ModemManager::modemPowerOn() {
MESH_DEBUG_PRINTLN("[Modem] powering on...");
// Enable modem power supply (BOARD_6609_EN)
pinMode(MODEM_POWER_EN, OUTPUT);
digitalWrite(MODEM_POWER_EN, HIGH);
vTaskDelay(pdMS_TO_TICKS(500));
MESH_DEBUG_PRINTLN("[Modem] power supply enabled (GPIO %d HIGH)", MODEM_POWER_EN);
// Reset pulse — drive RST low briefly then release
// (Some A7682E boards need this to clear stuck states)
pinMode(MODEM_RST, OUTPUT);
digitalWrite(MODEM_RST, LOW);
vTaskDelay(pdMS_TO_TICKS(200));
digitalWrite(MODEM_RST, HIGH);
vTaskDelay(pdMS_TO_TICKS(500));
MESH_DEBUG_PRINTLN("[Modem] reset pulse done (GPIO %d)", MODEM_RST);
// PWRKEY toggle: pull low for ≥1.5s then release
// A7682E datasheet: PWRKEY low >1s triggers power-on
pinMode(MODEM_PWRKEY, OUTPUT);
digitalWrite(MODEM_PWRKEY, HIGH); // Start high (idle state)
vTaskDelay(pdMS_TO_TICKS(100));
digitalWrite(MODEM_PWRKEY, LOW); // Active-low trigger
vTaskDelay(pdMS_TO_TICKS(1500));
digitalWrite(MODEM_PWRKEY, HIGH); // Release
MESH_DEBUG_PRINTLN("[Modem] PWRKEY toggled, waiting for boot...");
// Wait for modem to boot — A7682E needs 3-5 seconds after PWRKEY
vTaskDelay(pdMS_TO_TICKS(5000));
// Assert DTR LOW — many cellular modems require DTR active (LOW) for AT mode
pinMode(MODEM_DTR, OUTPUT);
digitalWrite(MODEM_DTR, LOW);
MESH_DEBUG_PRINTLN("[Modem] DTR asserted LOW (GPIO %d)", MODEM_DTR);
// Configure UART
// NOTE: variant.h pin names are modem-perspective, so:
// MODEM_RX (GPIO 10) = modem receives = ESP32 TX out
// MODEM_TX (GPIO 11) = modem transmits = ESP32 RX in
// Serial1.begin(baud, config, ESP32_RX, ESP32_TX)
MODEM_SERIAL.begin(MODEM_BAUD, SERIAL_8N1, MODEM_TX, MODEM_RX);
vTaskDelay(pdMS_TO_TICKS(500));
MESH_DEBUG_PRINTLN("[Modem] UART started (ESP32 RX=%d TX=%d @ %d)", MODEM_TX, MODEM_RX, MODEM_BAUD);
// Drain any boot garbage from UART
while (MODEM_SERIAL.available()) MODEM_SERIAL.read();
// Test communication — generous attempts
for (int i = 0; i < 10; i++) {
MESH_DEBUG_PRINTLN("[Modem] AT probe attempt %d/10", i + 1);
if (sendAT("AT", "OK", 1500)) {
MESH_DEBUG_PRINTLN("[Modem] AT responded OK");
return true;
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
MESH_DEBUG_PRINTLN("[Modem] no AT response after power-on");
return false;
}
// ---------------------------------------------------------------------------
// AT Command Helpers (called only from modem task)
// ---------------------------------------------------------------------------
bool ModemManager::sendAT(const char* cmd, const char* expect, uint32_t timeout_ms) {
// Flush any pending data
while (MODEM_SERIAL.available()) MODEM_SERIAL.read();
Serial.printf("[Modem] TX: %s\n", cmd);
MODEM_SERIAL.println(cmd);
bool ok = waitResponse(expect, timeout_ms, _atBuf, AT_BUF_SIZE);
if (_atBuf[0]) {
// Trim trailing whitespace for cleaner log output
int len = strlen(_atBuf);
while (len > 0 && (_atBuf[len-1] == '\r' || _atBuf[len-1] == '\n')) _atBuf[--len] = '\0';
Serial.printf("[Modem] RX: %s [%s]\n", _atBuf, ok ? "OK" : "FAIL");
} else {
Serial.printf("[Modem] RX: (no response) [TIMEOUT]\n");
}
return ok;
}
bool ModemManager::waitResponse(const char* expect, uint32_t timeout_ms,
char* buf, size_t bufLen) {
unsigned long start = millis();
int pos = 0;
if (buf && bufLen > 0) buf[0] = '\0';
while (millis() - start < timeout_ms) {
while (MODEM_SERIAL.available()) {
char c = MODEM_SERIAL.read();
if (buf && pos < (int)bufLen - 1) {
buf[pos++] = c;
buf[pos] = '\0';
}
// Check for expected response in accumulated buffer
if (buf && expect && strstr(buf, expect)) {
return true;
}
}
vTaskDelay(pdMS_TO_TICKS(10));
}
// Timeout — check one more time
if (buf && expect && strstr(buf, expect)) return true;
return false;
}
void ModemManager::pollCSQ() {
if (sendAT("AT+CSQ", "OK", 2000)) {
char* p = strstr(_atBuf, "+CSQ:");
if (p) {
int csq, ber;
if (sscanf(p, "+CSQ: %d,%d", &csq, &ber) >= 1) {
_csq = csq;
MESH_DEBUG_PRINTLN("[Modem] CSQ=%d (bars=%d)", _csq, getSignalBars());
}
}
}
}
void ModemManager::pollIncomingSMS() {
// List all unread messages (wait for full OK response)
if (!sendAT("AT+CMGL=\"REC UNREAD\"", "OK", 5000)) return;
// Parse response: +CMGL: <index>,<stat>,<phone>,,<timestamp>\r\n<body>\r\n
char* p = _atBuf;
while ((p = strstr(p, "+CMGL:")) != nullptr) {
int idx;
char stat[16], phone[SMS_PHONE_LEN], timestamp[24];
// Parse header line
// +CMGL: 1,"REC UNREAD","+1234567890","","26/02/15,10:30:00+00"
char* lineEnd = strchr(p, '\n');
if (!lineEnd) break;
// Extract index
if (sscanf(p, "+CMGL: %d", &idx) != 1) { p = lineEnd + 1; continue; }
// Extract phone number (between first and second quote pair after stat)
char* q1 = strchr(p + 7, '"'); // skip "+CMGL: N,"
if (!q1) { p = lineEnd + 1; continue; }
q1++; // skip opening quote of stat
char* q2 = strchr(q1, '"'); // end of stat
if (!q2) { p = lineEnd + 1; continue; }
// Next quoted field is the phone number
char* q3 = strchr(q2 + 1, '"');
if (!q3) { p = lineEnd + 1; continue; }
q3++;
char* q4 = strchr(q3, '"');
if (!q4) { p = lineEnd + 1; continue; }
int phoneLen = q4 - q3;
if (phoneLen >= SMS_PHONE_LEN) phoneLen = SMS_PHONE_LEN - 1;
memcpy(phone, q3, phoneLen);
phone[phoneLen] = '\0';
// Body is on the next line
p = lineEnd + 1;
char* bodyEnd = strchr(p, '\r');
if (!bodyEnd) bodyEnd = strchr(p, '\n');
if (!bodyEnd) break;
SMSIncoming incoming;
memset(&incoming, 0, sizeof(incoming));
strncpy(incoming.phone, phone, SMS_PHONE_LEN - 1);
int bodyLen = bodyEnd - p;
if (bodyLen >= SMS_BODY_LEN) bodyLen = SMS_BODY_LEN - 1;
memcpy(incoming.body, p, bodyLen);
incoming.body[bodyLen] = '\0';
incoming.timestamp = (uint32_t)time(nullptr); // Real epoch from modem-synced clock
// Queue for main loop
xQueueSend(_recvQueue, &incoming, 0);
// Delete the message from SIM
char delCmd[20];
snprintf(delCmd, sizeof(delCmd), "AT+CMGD=%d", idx);
sendAT(delCmd, "OK", 2000);
MESH_DEBUG_PRINTLN("[Modem] SMS received from %s: %.40s...", phone, incoming.body);
p = bodyEnd + 1;
}
}
bool ModemManager::doSendSMS(const char* phone, const char* body) {
MESH_DEBUG_PRINTLN("[Modem] doSendSMS to=%s len=%d", phone, strlen(body));
// Set text mode (in case it was reset)
sendAT("AT+CMGF=1", "OK");
// Start SMS send
char cmd[40];
snprintf(cmd, sizeof(cmd), "AT+CMGS=\"%s\"", phone);
Serial.printf("[Modem] TX: %s\n", cmd);
MODEM_SERIAL.println(cmd);
// Wait for '>' prompt
unsigned long start = millis();
bool gotPrompt = false;
while (millis() - start < 5000) {
if (MODEM_SERIAL.available()) {
char c = MODEM_SERIAL.read();
if (c == '>') { gotPrompt = true; break; }
}
vTaskDelay(pdMS_TO_TICKS(10));
}
if (!gotPrompt) {
MESH_DEBUG_PRINTLN("[Modem] no '>' prompt for SMS send");
MODEM_SERIAL.write(0x1B); // ESC to cancel
return false;
}
// Send body + Ctrl+Z
MESH_DEBUG_PRINTLN("[Modem] got '>' prompt, sending body...");
MODEM_SERIAL.print(body);
MODEM_SERIAL.write(0x1A); // Ctrl+Z to send
// Wait for +CMGS or ERROR
if (waitResponse("+CMGS:", 30000, _atBuf, AT_BUF_SIZE)) {
MESH_DEBUG_PRINTLN("[Modem] SMS sent OK: %s", _atBuf);
return true;
}
MESH_DEBUG_PRINTLN("[Modem] SMS send timeout/error: %s", _atBuf);
return false;
}
#endif // HAS_4G_MODEM

View File

@@ -0,0 +1,123 @@
#pragma once
// =============================================================================
// ModemManager - A7682E 4G Modem Driver for T-Deck Pro (V1.1 4G variant)
//
// Runs AT commands on a dedicated FreeRTOS task (Core 0, priority 1) to never
// block the mesh radio loop. Communicates with main loop via lock-free queues.
//
// Guard: HAS_4G_MODEM (defined only for the 4G build environment)
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef MODEM_MANAGER_H
#define MODEM_MANAGER_H
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include "variant.h"
// ---------------------------------------------------------------------------
// Modem pins (from variant.h, always defined for reference)
// MODEM_POWER_EN 41 Board 6609 enable
// MODEM_PWRKEY 40 Power key toggle
// MODEM_RST 9 Reset (shared with I2S BCLK on audio board)
// MODEM_RI 7 Ring indicator (shared with I2S DOUT on audio)
// MODEM_DTR 8 Data terminal ready (shared with I2S LRC on audio)
// MODEM_RX 10 UART RX (shared with PIN_PERF_POWERON)
// MODEM_TX 11 UART TX
// ---------------------------------------------------------------------------
// SMS field limits
#define SMS_PHONE_LEN 20
#define SMS_BODY_LEN 161 // 160 chars + null
// Task configuration
#define MODEM_TASK_PRIORITY 1 // Below mesh (default loop = priority 1 on core 1)
#define MODEM_TASK_STACK_SIZE 4096
#define MODEM_TASK_CORE 0 // Run on core 0 (mesh runs on core 1)
// Queue sizes
#define MODEM_SEND_QUEUE_SIZE 4
#define MODEM_RECV_QUEUE_SIZE 8
// Modem state machine
enum class ModemState {
OFF,
POWERING_ON,
INITIALIZING,
REGISTERING,
READY,
ERROR,
SENDING_SMS
};
// Outgoing SMS (queued from main loop to modem task)
struct SMSOutgoing {
char phone[SMS_PHONE_LEN];
char body[SMS_BODY_LEN];
};
// Incoming SMS (queued from modem task to main loop)
struct SMSIncoming {
char phone[SMS_PHONE_LEN];
char body[SMS_BODY_LEN];
uint32_t timestamp; // epoch seconds (from modem RTC or millis-based)
};
class ModemManager {
public:
void begin();
void shutdown();
// Non-blocking: queue an SMS for sending (returns false if queue full)
bool sendSMS(const char* phone, const char* body);
// Non-blocking: poll for received SMS (returns true if one was dequeued)
bool recvSMS(SMSIncoming& out);
// State queries (lock-free reads)
ModemState getState() const { return _state; }
int getSignalBars() const; // 0-5
int getCSQ() const { return _csq; }
bool isReady() const { return _state == ModemState::READY; }
const char* getOperator() const { return _operator; }
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
char _operator[24] = {0};
TaskHandle_t _taskHandle = nullptr;
QueueHandle_t _sendQueue = nullptr;
QueueHandle_t _recvQueue = nullptr;
SemaphoreHandle_t _uartMutex = nullptr;
// UART AT command helpers (called only from modem task)
bool modemPowerOn();
bool sendAT(const char* cmd, const char* expect, uint32_t timeout_ms = 2000);
bool waitResponse(const char* expect, uint32_t timeout_ms, char* buf = nullptr, size_t bufLen = 0);
void pollCSQ();
void pollIncomingSMS();
bool doSendSMS(const char* phone, const char* body);
// FreeRTOS task
static void taskEntry(void* param);
void taskLoop();
};
// Global singleton
extern ModemManager modemManager;
#endif // MODEM_MANAGER_H
#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

@@ -0,0 +1,885 @@
#pragma once
// =============================================================================
// SMSScreen - SMS messaging UI for T-Deck Pro (4G variant)
//
// 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
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef SMS_SCREEN_H
#define SMS_SCREEN_H
#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
#define SMS_MSG_PAGE_SIZE 30
#define SMS_COMPOSE_MAX 160
class UITask; // forward declaration
class SMSScreen : public UIScreen {
public:
enum SubView { INBOX, CONVERSATION, COMPOSE, CONTACTS, EDIT_CONTACT };
private:
UITask* _task;
SubView _view;
// Inbox state
SMSConversation _conversations[SMS_MAX_CONVERSATIONS];
int _convCount;
int _inboxCursor;
int _inboxScrollTop;
// Conversation state
char _activePhone[SMS_PHONE_LEN];
SMSMessage _msgs[SMS_MSG_PAGE_SIZE];
int _msgCount;
int _msgScrollPos;
// Compose state
char _composeBuf[SMS_COMPOSE_MAX + 1];
int _composePos;
char _composePhone[SMS_PHONE_LEN];
bool _composeNewConversation;
// Phone input state (for new conversation)
char _phoneInputBuf[SMS_PHONE_LEN];
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;
static const unsigned long REFRESH_INTERVAL = 600;
// SD ready flag
bool _sdReady;
// Reload helpers
void refreshInbox() {
_convCount = smsStore.loadConversations(_conversations, SMS_MAX_CONVERSATIONS);
}
void refreshConversation() {
_msgCount = smsStore.loadMessages(_activePhone, _msgs, SMS_MSG_PAGE_SIZE);
// Scroll to bottom (newest messages are at end now, chat-style)
_msgScrollPos = (_msgCount > 3) ? _msgCount - 3 : 0;
}
public:
SMSScreen(UITask* task)
: _task(task), _view(INBOX)
, _convCount(0), _inboxCursor(0), _inboxScrollTop(0)
, _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)
{
memset(_composeBuf, 0, sizeof(_composeBuf));
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; }
void activate() {
_view = INBOX;
_inboxCursor = 0;
_inboxScrollTop = 0;
if (_sdReady) refreshInbox();
}
SubView getSubView() const { return _view; }
bool isComposing() const { return _view == COMPOSE; }
bool isEnteringPhone() const { return _enteringPhone; }
// Called from main loop when an SMS arrives (saves to store + refreshes)
void onIncomingSMS(const char* phone, const char* body, uint32_t timestamp) {
if (_sdReady) {
smsStore.saveMessage(phone, body, false, timestamp);
}
if (_view == CONVERSATION && strcmp(_activePhone, phone) == 0) {
refreshConversation();
}
if (_view == INBOX) {
refreshInbox();
}
_needsRefresh = true;
}
// =========================================================================
// Signal strength indicator (top-right corner)
// =========================================================================
int renderSignalIndicator(DisplayDriver& display, int startX, int topY) {
ModemState ms = modemManager.getState();
int bars = modemManager.getSignalBars();
// Draw signal bars (4 bars, increasing height)
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);
} else {
display.setColor(DisplayDriver::DARK);
}
display.fillRect(x, barY, barWidth, barH);
x += barWidth + barGap;
}
// Show modem state text if not ready
if (ms != ModemState::READY && ms != ModemState::SENDING_SMS) {
display.setTextSize(0);
display.setColor(DisplayDriver::YELLOW);
const char* label = ModemManager::stateToString(ms);
uint16_t labelW = display.getTextWidth(label);
display.setCursor(startX - totalWidth - labelW - 2, topY - 3);
display.print(label);
display.setTextSize(1);
return iconWidth + labelW + 2;
}
return iconWidth;
}
// =========================================================================
// RENDER
// =========================================================================
int render(DisplayDriver& display) override {
_lastRefresh = millis();
switch (_view) {
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;
}
// ---- Inbox ----
int renderInbox(DisplayDriver& display) {
ModemState ms = modemManager.getState();
// Header
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print("SMS Inbox");
// Signal strength at top-right
renderSignalIndicator(display, display.width() - 2, 0);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
if (_convCount == 0) {
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 20);
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);
char statBuf[40];
snprintf(statBuf, sizeof(statBuf), "Modem: %s", ModemManager::stateToString(ms));
display.print(statBuf);
}
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 to keep cursor visible
if (_inboxCursor < _inboxScrollTop) _inboxScrollTop = _inboxCursor;
if (_inboxCursor >= _inboxScrollTop + visibleCount) {
_inboxScrollTop = _inboxCursor - visibleCount + 1;
}
for (int vi = 0; vi < visibleCount && (_inboxScrollTop + vi) < _convCount; vi++) {
int idx = _inboxScrollTop + vi;
SMSConversation& c = _conversations[idx];
if (!c.valid) continue;
bool selected = (idx == _inboxCursor);
// 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(dispName);
// Message count at right
char countStr[8];
snprintf(countStr, sizeof(countStr), "[%d]", c.messageCount);
display.setCursor(display.width() - display.getTextWidth(countStr) - 2, y);
display.print(countStr);
y += lineHeight;
// Preview (dimmer)
display.setColor(DisplayDriver::LIGHT);
display.setCursor(12, y);
char prev[36];
strncpy(prev, c.preview, 35);
prev[35] = '\0';
display.print(prev);
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* 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);
return 5000;
}
// ---- Conversation view ----
int renderConversation(DisplayDriver& display) {
// Header - show contact name if available, phone otherwise
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
char convTitle[SMS_CONTACT_NAME_LEN];
smsContacts.displayName(_activePhone, convTitle, sizeof(convTitle));
display.print(convTitle);
// Signal icon
renderSignalIndicator(display, display.width() - 2, 0);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
if (_msgCount == 0) {
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 25);
display.print("No messages");
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
int headerHeight = 14;
int footerHeight = 14;
// Estimate chars per line
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM");
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
if (charsPerLine < 12) charsPerLine = 12;
if (charsPerLine > 40) charsPerLine = 40;
int y = headerHeight;
for (int i = _msgScrollPos;
i < _msgCount && y < display.height() - footerHeight - lineHeight;
i++) {
SMSMessage& msg = _msgs[i];
if (!msg.valid) continue;
// Direction indicator
display.setCursor(0, y);
display.setColor(msg.isSent ? DisplayDriver::BLUE : DisplayDriver::YELLOW);
// Time formatting (epoch-aware)
char timeStr[16];
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",
msg.isSent ? ">>>" : "<<<", timeStr);
display.print(header);
y += lineHeight;
// Message body with simple word wrap
display.setColor(DisplayDriver::LIGHT);
int textLen = strlen(msg.body);
int pos = 0;
int linesForMsg = 0;
int maxLines = 4;
int x = 0;
char cs[2] = {0, 0};
display.setCursor(0, y);
while (pos < textLen && linesForMsg < maxLines &&
y < display.height() - footerHeight - 2) {
cs[0] = msg.body[pos++];
display.print(cs);
x++;
if (x >= charsPerLine) {
x = 0;
linesForMsg++;
y += lineHeight;
if (linesForMsg < maxLines && y < display.height() - footerHeight - 2) {
display.setCursor(0, y);
}
}
}
if (x > 0) y += lineHeight;
y += 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:Bk A:Add Contact");
const char* rt = "C:Reply";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
return 5000;
}
// ---- Compose ----
int renderCompose(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
if (_enteringPhone) {
display.print("To: ");
display.setColor(DisplayDriver::LIGHT);
display.print(_phoneInputBuf);
display.print("_");
} else {
// 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);
display.drawRect(0, 11, display.width(), 1);
if (!_enteringPhone) {
// Message body
display.setCursor(0, 14);
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM");
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
if (charsPerLine < 12) charsPerLine = 12;
int y = 14;
int x = 0;
char cs[2] = {0, 0};
for (int i = 0; i < _composePos; i++) {
cs[0] = _composeBuf[i];
display.setCursor(x * (display.width() / charsPerLine), y);
display.print(cs);
x++;
if (x >= charsPerLine) {
x = 0;
y += 10;
}
}
// Cursor
display.setCursor(x * (display.width() / charsPerLine), y);
display.print("_");
display.setTextSize(1);
}
// Status bar
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#");
const char* rt = "Ent S+D:X";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, statusY);
display.print(rt);
} else {
char status[16];
snprintf(status, sizeof(status), "%d/%d", _composePos, SMS_COMPOSE_MAX);
display.print(status);
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;
}
// =========================================================================
// INPUT HANDLING
// =========================================================================
bool handleInput(char c) override {
switch (_view) {
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;
}
// ---- Inbox input ----
bool handleInboxInput(char c) {
switch (c) {
case 'w': case 'W':
if (_inboxCursor > 0) _inboxCursor--;
return true;
case 's': case 'S':
if (_inboxCursor < _convCount - 1) _inboxCursor++;
return true;
case '\r': // Enter - open conversation
if (_convCount > 0 && _inboxCursor < _convCount) {
strncpy(_activePhone, _conversations[_inboxCursor].phone, SMS_PHONE_LEN - 1);
refreshConversation();
_view = CONVERSATION;
}
return true;
case 'c': case 'C': // New conversation
_composeNewConversation = true;
_enteringPhone = true;
_phoneInputBuf[0] = '\0';
_phoneInputPos = 0;
_composeBuf[0] = '\0';
_composePos = 0;
_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;
default:
return false;
}
}
// ---- Conversation input ----
bool handleConversationInput(char c) {
switch (c) {
case 'w': case 'W':
if (_msgScrollPos > 0) _msgScrollPos--;
return true;
case 's': case 'S':
if (_msgScrollPos < _msgCount - 1) _msgScrollPos++;
return true;
case 'c': case 'C': // Reply to this conversation
_composeNewConversation = false;
_enteringPhone = false;
strncpy(_composePhone, _activePhone, SMS_PHONE_LEN - 1);
_composeBuf[0] = '\0';
_composePos = 0;
_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;
return true;
default:
return false;
}
}
// ---- Compose input ----
bool handleComposeInput(char c) {
if (_enteringPhone) {
return handlePhoneInput(c);
}
switch (c) {
case '\r': { // Enter - send SMS
if (_composePos > 0) {
_composeBuf[_composePos] = '\0';
bool queued = modemManager.sendSMS(_composePhone, _composeBuf);
if (_sdReady) {
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);
}
_composeBuf[0] = '\0';
_composePos = 0;
refreshInbox();
_view = INBOX;
return true;
}
case '\b': // Backspace
if (_composePos > 0) {
_composePos--;
_composeBuf[_composePos] = '\0';
}
return true;
case 0x18: // Shift+Backspace (cancel)
_composeBuf[0] = '\0';
_composePos = 0;
refreshInbox();
_view = INBOX;
return true;
default:
if (c >= 32 && c < 127 && _composePos < SMS_COMPOSE_MAX) {
_composeBuf[_composePos++] = c;
_composeBuf[_composePos] = '\0';
}
return true;
}
}
// ---- Phone number input ----
bool handlePhoneInput(char c) {
switch (c) {
case '\r': // Done entering phone, move to body
if (_phoneInputPos > 0) {
_phoneInputBuf[_phoneInputPos] = '\0';
strncpy(_composePhone, _phoneInputBuf, SMS_PHONE_LEN - 1);
_enteringPhone = false;
_composeBuf[0] = '\0';
_composePos = 0;
}
return true;
case '\b':
if (_phoneInputPos > 0) {
_phoneInputPos--;
_phoneInputBuf[_phoneInputPos] = '\0';
}
return true;
case 0x18: // Shift+Backspace (cancel)
_phoneInputBuf[0] = '\0';
_phoneInputPos = 0;
refreshInbox();
_view = INBOX;
_enteringPhone = false;
return true;
default:
if (_phoneInputPos < SMS_PHONE_LEN - 1 &&
((c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#')) {
_phoneInputBuf[_phoneInputPos++] = c;
_phoneInputBuf[_phoneInputPos] = '\0';
}
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
#endif // HAS_4G_MODEM

View File

@@ -0,0 +1,196 @@
#ifdef HAS_4G_MODEM
#include "SMSStore.h"
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
#include "target.h" // For SDCARD_CS macro
// Global singleton
SMSStore smsStore;
void SMSStore::begin() {
// Ensure SMS directory exists
if (!SD.exists(SMS_DIR)) {
SD.mkdir(SMS_DIR);
MESH_DEBUG_PRINTLN("[SMSStore] created %s", SMS_DIR);
}
_ready = true;
MESH_DEBUG_PRINTLN("[SMSStore] ready");
}
void SMSStore::phoneToFilename(const char* phone, char* out, size_t outLen) {
// Convert phone number to safe filename: strip non-alphanumeric, prefix with dir
// e.g. "+1234567890" -> "/sms/p1234567890.sms"
char safe[SMS_PHONE_LEN];
int j = 0;
for (int i = 0; phone[i] && j < SMS_PHONE_LEN - 1; i++) {
char c = phone[i];
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
safe[j++] = c;
}
}
safe[j] = '\0';
snprintf(out, outLen, "%s/p%s.sms", SMS_DIR, safe);
}
bool SMSStore::saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp) {
if (!_ready) return false;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
// Build record
SMSRecord rec;
memset(&rec, 0, sizeof(rec));
rec.timestamp = timestamp;
rec.isSent = isSent ? 1 : 0;
rec.bodyLen = strlen(body);
if (rec.bodyLen >= SMS_BODY_LEN) rec.bodyLen = SMS_BODY_LEN - 1;
strncpy(rec.phone, phone, SMS_PHONE_LEN - 1);
strncpy(rec.body, body, SMS_BODY_LEN - 1);
// Append to file
File f = SD.open(filepath, FILE_APPEND);
if (!f) {
// Try creating
f = SD.open(filepath, FILE_WRITE);
if (!f) {
MESH_DEBUG_PRINTLN("[SMSStore] can't open %s", filepath);
return false;
}
}
size_t written = f.write((uint8_t*)&rec, sizeof(rec));
f.close();
// Release SD CS
digitalWrite(SDCARD_CS, HIGH);
return written == sizeof(rec);
}
int SMSStore::loadConversations(SMSConversation* out, int maxCount) {
if (!_ready) return 0;
File dir = SD.open(SMS_DIR);
if (!dir || !dir.isDirectory()) return 0;
int count = 0;
File entry;
while ((entry = dir.openNextFile()) && count < maxCount) {
const char* name = entry.name();
// Only process .sms files
if (!strstr(name, ".sms")) { entry.close(); continue; }
size_t fileSize = entry.size();
if (fileSize < sizeof(SMSRecord)) { entry.close(); continue; }
int numRecords = fileSize / sizeof(SMSRecord);
// Read the last record for preview
SMSRecord lastRec;
entry.seek(fileSize - sizeof(SMSRecord));
if (entry.read((uint8_t*)&lastRec, sizeof(SMSRecord)) != sizeof(SMSRecord)) {
entry.close();
continue;
}
SMSConversation& conv = out[count];
memset(&conv, 0, sizeof(SMSConversation));
strncpy(conv.phone, lastRec.phone, SMS_PHONE_LEN - 1);
strncpy(conv.preview, lastRec.body, 39);
conv.preview[39] = '\0';
conv.lastTimestamp = lastRec.timestamp;
conv.messageCount = numRecords;
conv.unreadCount = 0; // TODO: track read state
conv.valid = true;
count++;
entry.close();
}
dir.close();
// Release SD CS
digitalWrite(SDCARD_CS, HIGH);
// Sort by most recent (simple bubble sort, small N)
for (int i = 0; i < count - 1; i++) {
for (int j = 0; j < count - 1 - i; j++) {
if (out[j].lastTimestamp < out[j + 1].lastTimestamp) {
SMSConversation tmp = out[j];
out[j] = out[j + 1];
out[j + 1] = tmp;
}
}
}
return count;
}
int SMSStore::loadMessages(const char* phone, SMSMessage* out, int maxCount) {
if (!_ready) return 0;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
File f = SD.open(filepath, FILE_READ);
if (!f) return 0;
size_t fileSize = f.size();
int numRecords = fileSize / sizeof(SMSRecord);
// Load from end of file (most recent N messages), in chronological order
int startIdx = numRecords > maxCount ? numRecords - maxCount : 0;
// Read chronologically (oldest first) for chat-style display
SMSRecord rec;
int outIdx = 0;
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;
out[outIdx].timestamp = rec.timestamp;
out[outIdx].isSent = rec.isSent != 0;
out[outIdx].valid = true;
strncpy(out[outIdx].phone, rec.phone, SMS_PHONE_LEN - 1);
strncpy(out[outIdx].body, rec.body, SMS_BODY_LEN - 1);
outIdx++;
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
return outIdx;
}
bool SMSStore::deleteConversation(const char* phone) {
if (!_ready) return false;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
bool ok = SD.remove(filepath);
digitalWrite(SDCARD_CS, HIGH);
return ok;
}
int SMSStore::getMessageCount(const char* phone) {
if (!_ready) return 0;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
File f = SD.open(filepath, FILE_READ);
if (!f) return 0;
int count = f.size() / sizeof(SMSRecord);
f.close();
digitalWrite(SDCARD_CS, HIGH);
return count;
}
#endif // HAS_4G_MODEM

View File

@@ -0,0 +1,87 @@
#pragma once
// =============================================================================
// SMSStore - SD card backed SMS message storage
//
// Stores sent and received messages in /sms/ on the SD card.
// Each conversation is a separate file named by phone number (sanitised).
// Messages are appended as fixed-size records for simple random access.
//
// Guard: HAS_4G_MODEM
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef SMS_STORE_H
#define SMS_STORE_H
#include <Arduino.h>
#include <SD.h>
#define SMS_PHONE_LEN 20
#define SMS_BODY_LEN 161
#define SMS_MAX_CONVERSATIONS 20
#define SMS_DIR "/sms"
// Fixed-size on-disk record (256 bytes, easy alignment)
struct SMSRecord {
uint32_t timestamp; // epoch seconds
uint8_t isSent; // 1=sent, 0=received
uint8_t reserved[2];
uint8_t bodyLen; // actual length of body
char phone[SMS_PHONE_LEN]; // 20
char body[SMS_BODY_LEN]; // 161
uint8_t padding[256 - 4 - 3 - 1 - SMS_PHONE_LEN - SMS_BODY_LEN];
};
// In-memory message for UI
struct SMSMessage {
uint32_t timestamp;
bool isSent;
bool valid;
char phone[SMS_PHONE_LEN];
char body[SMS_BODY_LEN];
};
// Conversation summary for inbox view
struct SMSConversation {
char phone[SMS_PHONE_LEN];
char preview[40]; // last message preview
uint32_t lastTimestamp;
int messageCount;
int unreadCount;
bool valid;
};
class SMSStore {
public:
void begin();
bool isReady() const { return _ready; }
// Save a message (sent or received)
bool saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp);
// Load conversation list (sorted by most recent)
int loadConversations(SMSConversation* out, int maxCount);
// 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
bool deleteConversation(const char* phone);
// Get total message count for a phone number
int getMessageCount(const char* phone);
private:
bool _ready = false;
// Convert phone number to safe filename
void phoneToFilename(const char* phone, char* out, size_t outLen);
};
// Global singleton
extern SMSStore smsStore;
#endif // SMS_STORE_H
#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;

View File

@@ -40,6 +40,10 @@
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#endif
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#include "ModemManager.h"
#endif
class SplashScreen : public UIScreen {
UITask* _task;
@@ -330,7 +334,9 @@ public:
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
#ifdef MECK_AUDIO_VARIANT
#ifdef HAS_4G_MODEM
display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] SMS ");
#elif defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks");
#else
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
@@ -881,6 +887,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#ifdef HAS_4G_MODEM
sms_screen = new SMSScreen(this);
#endif
setCurrScreen(splash);
}
@@ -1386,6 +1395,19 @@ void UITask::gotoAudiobookPlayer() {
#endif
}
#ifdef HAS_4G_MODEM
void UITask::gotoSMSScreen() {
SMSScreen* smsScr = (SMSScreen*)sms_screen;
smsScr->activate();
setCurrScreen(sms_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
uint8_t UITask::getChannelScreenViewIdx() const {
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
}

View File

@@ -22,6 +22,10 @@
#include "../AbstractUITask.h"
#include "../NodePrefs.h"
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#endif
class UITask : public AbstractUITask {
DisplayDriver* _display;
SensorManager* _sensors;
@@ -58,6 +62,9 @@ class UITask : public AbstractUITask {
UIScreen* notes_screen; // Notes editor screen
UIScreen* settings_screen; // Settings/onboarding screen
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
#ifdef HAS_4G_MODEM
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
#endif
UIScreen* repeater_admin; // Repeater admin screen
UIScreen* curr;
@@ -90,6 +97,11 @@ public:
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
#ifdef HAS_4G_MODEM
void gotoSMSScreen();
bool isOnSMSScreen() const { return curr == sms_screen; }
SMSScreen* getSMSScreen() const { return (SMSScreen*)sms_screen; }
#endif
void showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
int getMsgCount() const { return _msgcount; }

View File

@@ -46,9 +46,10 @@ void TDeckBoard::begin() {
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
#endif
// Disable 4G modem power (only present on 4G version, not audio version)
// This turns off the red status LED on the modem module
#ifdef MODEM_POWER_EN
// 4G Modem power management
// On 4G builds, ModemManager::begin() handles power-on — don't kill it here.
// On non-4G builds, disable modem power to save current and turn off red LED.
#if defined(MODEM_POWER_EN) && !defined(HAS_4G_MODEM)
pinMode(MODEM_POWER_EN, OUTPUT);
digitalWrite(MODEM_POWER_EN, LOW); // Cut power to modem
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - 4G modem power disabled");
@@ -167,8 +168,8 @@ static bool bq27220_writeControl(uint16_t subcmd) {
// RAM, so this typically only writes once (or after a full battery disconnect).
//
// Procedure follows TI TRM SLUUBD4A Section 6.1:
// 1. Unseal 2. Full Access 3. Enter CFG_UPDATE
// 4. Write Design Capacity via MAC 5. Exit CFG_UPDATE 6. Seal
// 1. Unseal → 2. Full Access → 3. Enter CFG_UPDATE
// 4. Write Design Capacity via MAC → 5. Exit CFG_UPDATE → 6. Seal
bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
#if HAS_BQ27220

View File

@@ -151,6 +151,8 @@ build_flags =
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D FIRMWARE_VERSION='"Meck v0.9.2-4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -159,4 +161,4 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
densaugeo/base64 @ ~1.4.0