diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 4d6141c..245c28f 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f6c185f..a23dd58 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -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 \ No newline at end of file diff --git a/examples/companion_radio/ui-new/ModemManager.cpp b/examples/companion_radio/ui-new/ModemManager.cpp new file mode 100644 index 0000000..755e1f2 --- /dev/null +++ b/examples/companion_radio/ui-new/ModemManager.cpp @@ -0,0 +1,479 @@ +#ifdef HAS_4G_MODEM + +#include "ModemManager.h" +#include // 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(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: ,,,,\r\n\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 \ No newline at end of file diff --git a/examples/companion_radio/ui-new/ModemManager.h b/examples/companion_radio/ui-new/ModemManager.h new file mode 100644 index 0000000..602ef88 --- /dev/null +++ b/examples/companion_radio/ui-new/ModemManager.h @@ -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 +#include +#include +#include +#include +#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 diff --git a/examples/companion_radio/ui-new/SMSScreen.h b/examples/companion_radio/ui-new/SMSScreen.h new file mode 100644 index 0000000..86739cd --- /dev/null +++ b/examples/companion_radio/ui-new/SMSScreen.h @@ -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 +#include +#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 \ No newline at end of file diff --git a/examples/companion_radio/ui-new/SMSStore.cpp b/examples/companion_radio/ui-new/SMSStore.cpp new file mode 100644 index 0000000..2bf9b5c --- /dev/null +++ b/examples/companion_radio/ui-new/SMSStore.cpp @@ -0,0 +1,197 @@ +#ifdef HAS_4G_MODEM + +#include "SMSStore.h" +#include // 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 \ No newline at end of file diff --git a/examples/companion_radio/ui-new/SMSStore.h b/examples/companion_radio/ui-new/SMSStore.h new file mode 100644 index 0000000..c04add5 --- /dev/null +++ b/examples/companion_radio/ui-new/SMSStore.h @@ -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 +#include + +#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 diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ec30f16..c4c9323 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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(); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index e20dbff..ba75db1 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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; } diff --git a/variants/lilygo_tdeck_pro/TDeckBoard.cpp b/variants/lilygo_tdeck_pro/TDeckBoard.cpp index b0168ca..7cf55d8 100644 --- a/variants/lilygo_tdeck_pro/TDeckBoard.cpp +++ b/variants/lilygo_tdeck_pro/TDeckBoard.cpp @@ -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 diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index 89ad476..78620d8 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -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} + + @@ -159,4 +161,4 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + lib_deps = ${LilyGo_TDeck_Pro.lib_deps} - densaugeo/base64 @ ~1.4.0 + densaugeo/base64 @ ~1.4.0 \ No newline at end of file