mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
implement sms app v1 attempt 1 4g variant only
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
479
examples/companion_radio/ui-new/ModemManager.cpp
Normal file
479
examples/companion_radio/ui-new/ModemManager.cpp
Normal 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
|
||||
119
examples/companion_radio/ui-new/ModemManager.h
Normal file
119
examples/companion_radio/ui-new/ModemManager.h
Normal 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
|
||||
644
examples/companion_radio/ui-new/SMSScreen.h
Normal file
644
examples/companion_radio/ui-new/SMSScreen.h
Normal 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
|
||||
197
examples/companion_radio/ui-new/SMSStore.cpp
Normal file
197
examples/companion_radio/ui-new/SMSStore.cpp
Normal 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
|
||||
87
examples/companion_radio/ui-new/SMSStore.h
Normal file
87
examples/companion_radio/ui-new/SMSStore.h
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user