implement sms app v1 attempt 1 4g variant only

This commit is contained in:
pelgraine
2026-02-20 08:07:47 +11:00
parent 2576a6590b
commit 458db8d4c4
11 changed files with 1689 additions and 28 deletions

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,21 @@
// 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 "SMSScreen.h"
static bool smsMode = false;
#endif
// Power management
@@ -538,6 +548,23 @@ 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();
// Tell SMS screen that SD is ready
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr) {
smsScr->setSDReady(true);
}
// Start modem background task (runs on Core 0)
modemManager.begin();
MESH_DEBUG_PRINTLN("setup() - 4G modem manager started");
}
#endif
}
#endif
@@ -607,7 +634,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 +646,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 +676,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 +696,10 @@ void loop() {
// Notes editor/rename renders through UITask - force a refresh cycle
ui_task.forceRefresh();
ui_task.loop();
} else if (smsSuppressLoop) {
// SMS compose renders through UITask - force a refresh cycle
ui_task.forceRefresh();
ui_task.loop();
}
lastComposeRefresh = millis();
composeNeedsRefresh = false;
@@ -650,8 +708,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 +902,7 @@ void handleKeyboardInput() {
}
// *** AUDIOBOOK MODE ***
#ifdef MECK_AUDIO_VARIANT
#ifndef HAS_4G_MODEM
if (audiobookMode) {
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
@@ -876,7 +935,7 @@ void handleKeyboardInput() {
ui_task.injectKey(key);
return;
}
#endif // MECK_AUDIO_VARIANT
#endif // !HAS_4G_MODEM
// *** TEXT READER MODE ***
if (readerMode) {
@@ -1108,6 +1167,37 @@ 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;
}
// Shift+Backspace → cancel compose
if (key == 0x18 && smsScr->isComposing()) {
smsScr->handleInput(key);
composeNeedsRefresh = true;
lastComposeRefresh = millis();
return;
}
// All other keys → inject to SMS screen
ui_task.injectKey(key);
if (smsScr->isComposing()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis();
}
return;
}
}
#endif
// Normal mode - not composing
switch (key) {
case 'c':
@@ -1128,25 +1218,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 +1575,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 +1592,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,479 @@
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
// 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 "???";
}
}
// ---------------------------------------------------------------------------
// 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");
// ---- 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();
// 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 = millis() / 1000; // Approximate; modem RTC could be used
// 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,119 @@
#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);
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,644 @@
#pragma once
// =============================================================================
// SMSScreen - SMS messaging UI for T-Deck Pro (4G variant)
//
// Three sub-views:
// INBOX — list of conversations, sorted by most recent
// CONVERSATION — messages for a selected contact, scrollable
// COMPOSE — text input for new SMS
//
// Navigation mirrors ChannelScreen conventions:
// W/S: scroll Enter: select/send C: compose new/reply
// Q: back Sh+Del: cancel compose
//
// 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 "ModemManager.h"
#include "SMSStore.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 };
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;
// 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);
_msgScrollPos = 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)
, _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));
}
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 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();
}
_needsRefresh = true;
}
// =========================================================================
// Signal strength indicator (top-right corner)
// =========================================================================
int renderSignalIndicator(DisplayDriver& display, int rightX, 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 {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(x, y, barW, barH);
}
}
// 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 - 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);
}
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);
// Phone number (highlighted if selected)
display.setCursor(0, y);
display.setColor(selected ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
if (selected) display.print("> ");
display.print(c.phone);
// 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(0); // Must be set before setCursor/getTextWidth
display.setColor(DisplayDriver::LIGHT);
int footerY = display.height() - 10;
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);
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
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print(_activePhone);
// 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
uint32_t now = millis() / 1000;
uint32_t age = (now > msg.timestamp) ? (now - msg.timestamp) : 0;
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));
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(0); // Must be set before setCursor/getTextWidth
display.setColor(DisplayDriver::LIGHT);
int footerY = display.height() - 10;
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);
const char* rt = "C:Reply";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
display.setTextSize(1);
return 5000;
}
// ---- Compose ----
int renderCompose(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
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);
}
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(0); // Must be set before setCursor/getTextWidth
display.setColor(DisplayDriver::LIGHT);
int statusY = display.height() - 10;
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.setCursor(display.width() - display.getTextWidth(rt) - 2, statusY);
display.print(rt);
} else {
char status[30];
snprintf(status, sizeof(status), "%d/%d", _composePos, SMS_COMPOSE_MAX);
display.print(status);
const char* rt = "Ent:Snd S+D:X";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, statusY);
display.print(rt);
}
display.setTextSize(1);
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);
}
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 'q': case 'Q': // Back to home (handled by main.cpp)
return false; // Let main.cpp handle navigation
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 '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);
}
// 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;
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();
_view = INBOX;
return true;
}
case '\b': // Backspace
if (_composePos > 0) {
_composePos--;
_composeBuf[_composePos] = '\0';
}
return true;
case 0x18: // Shift+Backspace (cancel) — same as mesh compose
_composeBuf[0] = '\0';
_composePos = 0;
refreshInbox();
_view = INBOX;
return true;
default:
// Printable character
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': // Enter - 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': // Backspace
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:
// Accept digits, +, *, # for phone numbers
if (_phoneInputPos < SMS_PHONE_LEN - 1 &&
((c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#')) {
_phoneInputBuf[_phoneInputPos++] = c;
_phoneInputBuf[_phoneInputPos] = '\0';
}
return true;
}
}
};
#endif // SMS_SCREEN_H
#endif // HAS_4G_MODEM

View File

@@ -0,0 +1,197 @@
#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 (newest first), up to maxCount
int startIdx = numRecords > maxCount ? numRecords - maxCount : 0;
int loadCount = numRecords - startIdx;
// Read from startIdx and reverse order for display (newest first)
SMSRecord rec;
int outIdx = 0;
for (int i = numRecords - 1; i >= startIdx && 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 (newest 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

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