#ifdef HAS_4G_MODEM #include "ModemManager.h" #include // For MESH_DEBUG_PRINTLN #include // For modem config persistence #include #include // 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]; // Config file paths #define MODEM_CONFIG_FILE "/sms/modem.cfg" #define APN_CONFIG_FILE "/sms/apn.cfg" // --------------------------------------------------------------------------- // Public API - SMS (unchanged) // --------------------------------------------------------------------------- void ModemManager::begin() { MESH_DEBUG_PRINTLN("[Modem] begin()"); _state = ModemState::OFF; _csq = 99; _operator[0] = '\0'; _callPhone[0] = '\0'; _callStartTime = 0; _ringtoneEnabled = false; _ringing = false; _nextRingTone = 0; _toneActive = false; _pendingToneIdx = -1; _tonesTransferred = false; _notifTonePlaying = false; _notifToneStartTime = 0; _urcPos = 0; _imei[0] = '\0'; _imsi[0] = '\0'; _apn[0] = '\0'; strcpy(_apnSource, "none"); // Create FreeRTOS primitives _sendQueue = xQueueCreate(MODEM_SEND_QUEUE_SIZE, sizeof(SMSOutgoing)); _recvQueue = xQueueCreate(MODEM_RECV_QUEUE_SIZE, sizeof(SMSIncoming)); _callCmdQueue = xQueueCreate(MODEM_CALL_CMD_QUEUE_SIZE, sizeof(CallCommand)); _callEvtQueue = xQueueCreate(MODEM_CALL_EVT_QUEUE_SIZE, sizeof(CallEvent)); _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()"); // Stop any playing notification tone if (_notifTonePlaying) { // Best-effort stop -- task is about to be deleted _pendingToneIdx = -1; } // Hang up any active call first if (isCallActive()) { CallCommand cmd; memset(&cmd, 0, sizeof(cmd)); cmd.cmd = CallCmd::HANGUP; xQueueSend(_callCmdQueue, &cmd, pdMS_TO_TICKS(500)); vTaskDelay(pdMS_TO_TICKS(2000)); // Give time for AT+CHUP } // 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; } // --------------------------------------------------------------------------- // Public API - Voice Calls // --------------------------------------------------------------------------- bool ModemManager::dialCall(const char* phone) { if (!_callCmdQueue) return false; if (isCallActive()) return false; // Already in a call CallCommand cmd; memset(&cmd, 0, sizeof(cmd)); cmd.cmd = CallCmd::DIAL; strncpy(cmd.phone, phone, SMS_PHONE_LEN - 1); return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE; } bool ModemManager::answerCall() { if (!_callCmdQueue) return false; CallCommand cmd; memset(&cmd, 0, sizeof(cmd)); cmd.cmd = CallCmd::ANSWER; return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE; } bool ModemManager::hangupCall() { if (!_callCmdQueue) return false; CallCommand cmd; memset(&cmd, 0, sizeof(cmd)); cmd.cmd = CallCmd::HANGUP; return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE; } bool ModemManager::sendDTMF(char digit) { if (!_callCmdQueue) return false; if (_state != ModemState::IN_CALL) return false; CallCommand cmd; memset(&cmd, 0, sizeof(cmd)); cmd.cmd = CallCmd::DTMF; cmd.dtmf = digit; return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE; } bool ModemManager::setCallVolume(uint8_t level) { if (!_callCmdQueue) return false; CallCommand cmd; memset(&cmd, 0, sizeof(cmd)); cmd.cmd = CallCmd::SET_VOLUME; cmd.volume = level > 5 ? 5 : level; return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE; } bool ModemManager::pollCallEvent(CallEvent& out) { if (!_callEvtQueue) return false; return xQueueReceive(_callEvtQueue, &out, 0) == pdTRUE; } // --------------------------------------------------------------------------- // Public API - Notification Tones // --------------------------------------------------------------------------- void ModemManager::requestNotifTone(int8_t toneIdx) { if (toneIdx < 0 || toneIdx >= MODEM_BUNDLED_TONE_COUNT) return; if (!_tonesTransferred) return; // Not ready yet if (isCallActive()) return; // Don't interrupt voice calls _pendingToneIdx = toneIdx; } int8_t ModemManager::findToneByName(const char* name) { if (!name || !name[0]) return -1; for (int i = 0; i < MODEM_BUNDLED_TONE_COUNT; i++) { // Match against filename (with or without .wav extension) const char* fn = modemBundledTones[i].filename; if (strcasecmp(name, fn) == 0) return i; // Try matching without extension const char* dot = strrchr(fn, '.'); if (dot) { int baseLen = dot - fn; if ((int)strlen(name) == baseLen && strncasecmp(name, fn, baseLen) == 0) return i; } // Also match against label if (strcasecmp(name, modemBundledTones[i].label) == 0) return i; } return -1; } // --------------------------------------------------------------------------- // State helpers // --------------------------------------------------------------------------- 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"; case ModemState::DIALING: return "DIALING"; case ModemState::RINGING_IN: return "INCOMING"; case ModemState::IN_CALL: return "IN CALL"; default: return "???"; } } // --------------------------------------------------------------------------- // Persistent modem enable/disable config // --------------------------------------------------------------------------- bool ModemManager::loadEnabledConfig() { File f = SD.open(MODEM_CONFIG_FILE, FILE_READ); if (!f) { // No config file = enabled by default return true; } char c = '1'; if (f.available()) c = f.read(); f.close(); return (c != '0'); } void ModemManager::saveEnabledConfig(bool enabled) { // Ensure /sms directory exists if (!SD.exists("/sms")) SD.mkdir("/sms"); File f = SD.open(MODEM_CONFIG_FILE, FILE_WRITE); if (f) { f.print(enabled ? '1' : '0'); f.close(); Serial.printf("[Modem] Config saved: %s\n", enabled ? "ENABLED" : "DISABLED"); } } // --------------------------------------------------------------------------- // APN Configuration // --------------------------------------------------------------------------- void ModemManager::setAPN(const char* apn) { strncpy(_apn, apn, sizeof(_apn) - 1); _apn[sizeof(_apn) - 1] = '\0'; strcpy(_apnSource, "user"); saveAPNConfig(apn); MESH_DEBUG_PRINTLN("[Modem] APN set by user: %s", _apn); } bool ModemManager::loadAPNConfig(char* apnOut, int maxLen) { File f = SD.open(APN_CONFIG_FILE, FILE_READ); if (!f) { return false; } String line = f.readStringUntil('\n'); f.close(); line.trim(); if (line.length() == 0) return false; strncpy(apnOut, line.c_str(), maxLen - 1); apnOut[maxLen - 1] = '\0'; return true; } void ModemManager::saveAPNConfig(const char* apn) { if (!SD.exists("/sms")) SD.mkdir("/sms"); File f = SD.open(APN_CONFIG_FILE, FILE_WRITE); if (f) { f.println(apn); f.close(); Serial.printf("[Modem] APN config saved: %s\n", apn); } } // --------------------------------------------------------------------------- // APN Resolution -- called during init after network registration // // Priority: // 1. User-configured APN (from /sms/apn.cfg) // 2. Network-provisioned APN (AT+CGDCONT? -- modem already has one) // 3. Auto-detected from IMSI via embedded ApnDatabase // 4. Blank (some carriers work with empty APN) // --------------------------------------------------------------------------- void ModemManager::resolveAPN() { // 1. Check for user-configured APN on SD card char userApn[64]; if (loadAPNConfig(userApn, sizeof(userApn))) { strncpy(_apn, userApn, sizeof(_apn) - 1); strcpy(_apnSource, "user"); MESH_DEBUG_PRINTLN("[Modem] APN from user config: %s", _apn); // Apply to modem char cmd[80]; snprintf(cmd, sizeof(cmd), "AT+CGDCONT=1,\"IP\",\"%s\"", _apn); sendAT(cmd, "OK", 3000); return; } // 2. Check if modem already has a network-provisioned APN if (sendAT("AT+CGDCONT?", "OK", 3000)) { // Response: +CGDCONT: 1,"IP","telstra.internet",,0,0 char* p = strstr(_atBuf, "+CGDCONT:"); if (p) { char* q1 = strchr(p, '"'); // first quote (before IP) if (q1) q1 = strchr(q1 + 1, '"'); // close quote of IP if (q1) q1 = strchr(q1 + 1, '"'); // open quote of APN if (q1) { q1++; char* q2 = strchr(q1, '"'); if (q2 && q2 > q1) { int len = q2 - q1; if (len > 0 && len < (int)sizeof(_apn)) { memcpy(_apn, q1, len); _apn[len] = '\0'; strcpy(_apnSource, "network"); MESH_DEBUG_PRINTLN("[Modem] APN from network/modem: %s", _apn); return; } } } } } // 3. Auto-detect from IMSI using embedded database if (_imsi[0]) { const ApnEntry* entry = apnLookupFromIMSI(_imsi); if (entry) { strncpy(_apn, entry->apn, sizeof(_apn) - 1); strcpy(_apnSource, "auto"); MESH_DEBUG_PRINTLN("[Modem] APN auto-detected: %s (%s)", _apn, entry->carrier); // Apply to modem char cmd[80]; snprintf(cmd, sizeof(cmd), "AT+CGDCONT=1,\"IP\",\"%s\"", _apn); sendAT(cmd, "OK", 3000); return; } } // 4. No APN found -- leave blank _apn[0] = '\0'; strcpy(_apnSource, "none"); MESH_DEBUG_PRINTLN("[Modem] APN: none detected (IMSI=%s)", _imsi[0] ? _imsi : "unknown"); } // --------------------------------------------------------------------------- // URC (Unsolicited Result Code) Handling // --------------------------------------------------------------------------- // The modem can send unsolicited messages at any time: // RING -- incoming call ringing // +CLIP: "+1234...",145,... -- caller ID (after AT+CLIP=1) // NO CARRIER -- call ended by remote // BUSY -- outgoing call busy // NO ANSWER -- outgoing call no answer // +CMTI: "SM", -- new SMS arrived // +AUDIOSTATE: audio play stop -- notification tone finished // // drainURCs() accumulates bytes into a line buffer and calls // processURCLine() for each complete line. // --------------------------------------------------------------------------- void ModemManager::drainURCs() { while (MODEM_SERIAL.available()) { char c = MODEM_SERIAL.read(); // Accumulate into line buffer if (c == '\n') { // End of line -- process if non-empty if (_urcPos > 0) { // Trim trailing \r while (_urcPos > 0 && _urcBuf[_urcPos - 1] == '\r') _urcPos--; _urcBuf[_urcPos] = '\0'; if (_urcPos > 0) { processURCLine(_urcBuf); } } _urcPos = 0; } else if (c != '\r' || _urcPos > 0) { // Accumulate (skip leading \r) if (_urcPos < URC_BUF_SIZE - 1) { _urcBuf[_urcPos++] = c; } } } } void ModemManager::processURCLine(const char* line) { // --- RING: incoming call --- if (strcmp(line, "RING") == 0) { MESH_DEBUG_PRINTLN("[Modem] URC: RING"); if (_state != ModemState::RINGING_IN && _state != ModemState::IN_CALL) { _state = ModemState::RINGING_IN; // Phone number will be filled by +CLIP if available // Queue event with empty phone (updated by +CLIP) // Only queue on first RING; subsequent RINGs are repeats if (_callPhone[0] == '\0') { queueCallEvent(CallEventType::INCOMING, ""); } } return; } // --- +CLIP: caller ID --- // +CLIP: "+61412345678",145,,,,0 if (strncmp(line, "+CLIP:", 6) == 0) { char* q1 = strchr(line + 6, '"'); if (q1) { q1++; char* q2 = strchr(q1, '"'); if (q2) { int len = q2 - q1; if (len >= SMS_PHONE_LEN) len = SMS_PHONE_LEN - 1; memcpy(_callPhone, q1, len); _callPhone[len] = '\0'; MESH_DEBUG_PRINTLN("[Modem] URC: CLIP phone=%s", _callPhone); // Re-queue INCOMING event with the actual phone number // (replaces the empty-phone event from RING) if (_state == ModemState::RINGING_IN) { queueCallEvent(CallEventType::INCOMING, _callPhone); } } } return; } // --- NO CARRIER: call ended --- if (strcmp(line, "NO CARRIER") == 0) { MESH_DEBUG_PRINTLN("[Modem] URC: NO CARRIER"); if (_state == ModemState::RINGING_IN) { // Incoming call ended before we answered -- missed call queueCallEvent(CallEventType::MISSED, _callPhone); } else if (_state == ModemState::DIALING || _state == ModemState::IN_CALL) { uint32_t duration = 0; if (_state == ModemState::IN_CALL && _callStartTime > 0) { duration = (millis() - _callStartTime) / 1000; } queueCallEvent(CallEventType::ENDED, _callPhone, duration); } _state = ModemState::READY; _callPhone[0] = '\0'; _callStartTime = 0; return; } // --- BUSY --- if (strcmp(line, "BUSY") == 0) { MESH_DEBUG_PRINTLN("[Modem] URC: BUSY"); if (_state == ModemState::DIALING) { queueCallEvent(CallEventType::BUSY, _callPhone); _state = ModemState::READY; _callPhone[0] = '\0'; } return; } // --- NO ANSWER --- if (strcmp(line, "NO ANSWER") == 0) { MESH_DEBUG_PRINTLN("[Modem] URC: NO ANSWER"); if (_state == ModemState::DIALING) { queueCallEvent(CallEventType::NO_ANSWER, _callPhone); _state = ModemState::READY; _callPhone[0] = '\0'; } return; } // --- +CMTI: new SMS indication --- // +CMTI: "SM", // We don't need to act on this immediately since we poll for SMS, // but we can trigger an early poll if (strncmp(line, "+CMTI:", 6) == 0) { MESH_DEBUG_PRINTLN("[Modem] URC: CMTI (new SMS)"); // Next SMS poll will pick it up; we just log it return; } // --- VOICE CALL: BEGIN -- A76xx-specific: audio path established --- if (strncmp(line, "VOICE CALL: BEGIN", 17) == 0) { MESH_DEBUG_PRINTLN("[Modem] URC: VOICE CALL: BEGIN"); if (_state == ModemState::DIALING) { _state = ModemState::IN_CALL; _callStartTime = millis(); queueCallEvent(CallEventType::CONNECTED, _callPhone); MESH_DEBUG_PRINTLN("[Modem] Call connected (VOICE CALL: BEGIN)"); } return; } // --- VOICE CALL: END -- A76xx-specific: audio path closed --- // Format: "VOICE CALL: END: " if (strncmp(line, "VOICE CALL: END", 15) == 0) { MESH_DEBUG_PRINTLN("[Modem] URC: %s", line); // Parse duration if present: "VOICE CALL: END: 0:12" uint32_t duration = 0; const char* dp = strstr(line, "END:"); if (dp) { dp += 4; while (*dp == ' ') dp++; int mins = 0, secs = 0; if (sscanf(dp, "%d:%d", &mins, &secs) == 2) { duration = mins * 60 + secs; } } if (_state == ModemState::RINGING_IN) { queueCallEvent(CallEventType::MISSED, _callPhone); } else if (_state == ModemState::IN_CALL || _state == ModemState::DIALING) { queueCallEvent(CallEventType::ENDED, _callPhone, duration); } _state = ModemState::READY; _callPhone[0] = '\0'; _callStartTime = 0; return; } // --- +AUDIOSTATE: notification tone playback status --- // +AUDIOSTATE: audio play -- playback started // +AUDIOSTATE: audio play stop -- playback finished if (strncmp(line, "+AUDIOSTATE:", 12) == 0) { if (strstr(line, "play stop") || strstr(line, "PLAY STOP")) { MESH_DEBUG_PRINTLN("[Modem] URC: AUDIOSTATE play stop"); _notifTonePlaying = false; } else if (strstr(line, "play") || strstr(line, "PLAY")) { MESH_DEBUG_PRINTLN("[Modem] URC: AUDIOSTATE play"); // Playback confirmed started -- _notifTonePlaying already set } return; } } void ModemManager::queueCallEvent(CallEventType type, const char* phone, uint32_t duration) { CallEvent evt; memset(&evt, 0, sizeof(evt)); evt.type = type; evt.duration = duration; if (phone) { strncpy(evt.phone, phone, SMS_PHONE_LEN - 1); } xQueueSend(_callEvtQueue, &evt, 0); } // --------------------------------------------------------------------------- // Call control (executed on modem task) // --------------------------------------------------------------------------- bool ModemManager::doDialCall(const char* phone) { MESH_DEBUG_PRINTLN("[Modem] doDialCall: %s", phone); strncpy(_callPhone, phone, SMS_PHONE_LEN - 1); _callPhone[SMS_PHONE_LEN - 1] = '\0'; _state = ModemState::DIALING; // ATD; -- the semicolon makes it a voice call (not data) char cmd[32]; snprintf(cmd, sizeof(cmd), "ATD%s;", phone); if (!sendAT(cmd, "OK", 30000)) { MESH_DEBUG_PRINTLN("[Modem] ATD failed"); queueCallEvent(CallEventType::DIAL_FAILED, phone); _state = ModemState::READY; _callPhone[0] = '\0'; return false; } // ATD returned OK -- call is being set up. // Connection/failure will come as URCs (NO CARRIER, BUSY, etc.) // or we detect active call via AT+CLCC polling. // For now, assume we're dialing and wait for URCs. MESH_DEBUG_PRINTLN("[Modem] ATD OK -- dialing..."); return true; } bool ModemManager::doAnswerCall() { MESH_DEBUG_PRINTLN("[Modem] doAnswerCall"); if (sendAT("ATA", "OK", 10000)) { _state = ModemState::IN_CALL; _callStartTime = millis(); queueCallEvent(CallEventType::CONNECTED, _callPhone); MESH_DEBUG_PRINTLN("[Modem] Call answered"); return true; } MESH_DEBUG_PRINTLN("[Modem] ATA failed"); return false; } bool ModemManager::doHangup() { MESH_DEBUG_PRINTLN("[Modem] doHangup (state=%d)", (int)_state); uint32_t duration = 0; if (_state == ModemState::IN_CALL && _callStartTime > 0) { duration = (millis() - _callStartTime) / 1000; } bool wasRinging = (_state == ModemState::RINGING_IN); // AT+CHUP is the 3GPP standard hangup for A76xx family (per TinyGSM) if (sendAT("AT+CHUP", "OK", 5000)) { if (wasRinging) { queueCallEvent(CallEventType::MISSED, _callPhone); } else { queueCallEvent(CallEventType::ENDED, _callPhone, duration); } _state = ModemState::READY; _callPhone[0] = '\0'; _callStartTime = 0; MESH_DEBUG_PRINTLN("[Modem] Hangup OK"); return true; } MESH_DEBUG_PRINTLN("[Modem] AT+CHUP failed"); // Force state back to READY even if hangup fails _state = ModemState::READY; _callPhone[0] = '\0'; _callStartTime = 0; return false; } bool ModemManager::doSendDTMF(char digit) { char cmd[16]; snprintf(cmd, sizeof(cmd), "AT+VTS=%c", digit); bool ok = sendAT(cmd, "OK", 3000); MESH_DEBUG_PRINTLN("[Modem] DTMF '%c' %s", digit, ok ? "OK" : "FAIL"); return ok; } bool ModemManager::doSetVolume(uint8_t level) { char cmd[16]; snprintf(cmd, sizeof(cmd), "AT+CLVL=%d", level); bool ok = sendAT(cmd, "OK", 2000); MESH_DEBUG_PRINTLN("[Modem] Volume %d %s", level, ok ? "OK" : "FAIL"); return ok; } // --------------------------------------------------------------------------- // Incoming call ringtone -- tone bursts via AT+SIMTONE on modem speaker // Pattern: 400ms tone -> 1200ms silence -> repeat // --------------------------------------------------------------------------- void ModemManager::handleRingtone() { bool nowRinging = (_state == ModemState::RINGING_IN); if (nowRinging && !_ringing) { // Just started ringing _ringing = true; _nextRingTone = 0; // Play first burst immediately _toneActive = false; } else if (!nowRinging && _ringing) { // Ringing stopped (answered, rejected, missed) _ringing = false; if (_toneActive) { sendAT("AT+SIMTONE=0", "OK", 500); _toneActive = false; } return; } if (!_ringing || !_ringtoneEnabled) return; unsigned long now = millis(); if (now < _nextRingTone) return; if (!_toneActive) { // Play tone burst: 1000 Hz, level 5000 (of 50-25500), 400ms duration sendAT("AT+SIMTONE=1,1000,5000,400", "OK", 500); _toneActive = true; _nextRingTone = now + 400; // Tone plays for 400ms } else { // Tone just finished -- gap before next burst _toneActive = false; _nextRingTone = now + 1200; // 1.2s silence (classic ring cadence) } } // --------------------------------------------------------------------------- // Notification Tone Transfer and Playback // --------------------------------------------------------------------------- // Transfers embedded WAV files from PROGMEM to the modem's internal // filesystem using AT+CFTRANRX (confirmed via probe), then plays them // on demand via AT+CCMXPLAY through the modem's speaker amplifier. // // Confirmed working commands on A7682E: // AT+FSMEM -- filesystem space query // AT+FSDEL="C:/file" -- delete file // AT+CFTRANRX="C:/file",size -- write file (modem responds CONNECT) // AT+CCMXPLAY="C:/file",0,0 -- play audio // AT+CCMXSTOP -- stop audio // AT+CRSL=n -- ringer volume (0-20) // --------------------------------------------------------------------------- bool ModemManager::transferTonesToModem() { MESH_DEBUG_PRINTLN("[Modem] Transferring %d notification tones to modem...", MODEM_BUNDLED_TONE_COUNT); // Verify filesystem is accessible if (sendAT("AT+FSMEM", "OK", 3000)) { MESH_DEBUG_PRINTLN("[Modem] Filesystem: %s", _atBuf); } else { MESH_DEBUG_PRINTLN("[Modem] FSMEM failed -- modem filesystem not accessible"); _tonesTransferred = true; // Don't retry every boot return false; } int successCount = 0; for (int i = 0; i < MODEM_BUNDLED_TONE_COUNT; i++) { const ModemToneEntry& tone = modemBundledTones[i]; // Build modem filesystem path char modemPath[48]; snprintf(modemPath, sizeof(modemPath), "C:/%s", tone.filename); // Delete any existing file first (AT+FSDEL, ignore errors if not found) char delCmd[64]; snprintf(delCmd, sizeof(delCmd), "AT+FSDEL=\"%s\"", modemPath); sendAT(delCmd, "OK", 2000); // Small gap to let modem settle between delete and write vTaskDelay(pdMS_TO_TICKS(100)); // Drain any stale UART data before transfer while (MODEM_SERIAL.available()) MODEM_SERIAL.read(); // Transfer file via AT+CFTRANRX="path", // Modem responds with CONNECT, then expects bytes of binary data, // then responds with OK. char txCmd[80]; snprintf(txCmd, sizeof(txCmd), "AT+CFTRANRX=\"%s\",%d", modemPath, (int)tone.size); MESH_DEBUG_PRINTLN("[Modem] Tone %d/%d: %s (%d bytes) -- sending...", i + 1, MODEM_BUNDLED_TONE_COUNT, tone.filename, (int)tone.size); Serial.printf("[Modem] TX: %s\n", txCmd); MODEM_SERIAL.println(txCmd); // Wait for CONNECT prompt (case-insensitive, also check for ">") unsigned long start = millis(); bool gotPrompt = false; bool gotError = false; char promptBuf[128]; int ppos = 0; while (millis() - start < 8000) { while (MODEM_SERIAL.available()) { char c = MODEM_SERIAL.read(); if (ppos < 127) { promptBuf[ppos++] = c; promptBuf[ppos] = '\0'; } // Check for any known data-ready prompts if (strstr(promptBuf, "CONNECT") || strstr(promptBuf, "connect") || c == '>') { gotPrompt = true; break; } if (strstr(promptBuf, "ERROR")) { gotError = true; break; } } if (gotPrompt || gotError) break; vTaskDelay(pdMS_TO_TICKS(10)); } if (!gotPrompt) { // Log whatever we DID receive for debugging MESH_DEBUG_PRINTLN("[Modem] Tone %d: no CONNECT/> prompt (got: [%s])", i + 1, ppos > 0 ? promptBuf : "TIMEOUT"); // Drain UART and recover vTaskDelay(pdMS_TO_TICKS(1000)); while (MODEM_SERIAL.available()) MODEM_SERIAL.read(); continue; } MESH_DEBUG_PRINTLN("[Modem] Tone %d: got prompt, sending %d bytes...", i + 1, (int)tone.size); // Send the binary WAV data from PROGMEM in chunks const uint8_t* src = tone.data; size_t remaining = tone.size; const size_t CHUNK_SIZE = 256; while (remaining > 0) { size_t chunk = (remaining > CHUNK_SIZE) ? CHUNK_SIZE : remaining; uint8_t buf[CHUNK_SIZE]; memcpy_P(buf, src, chunk); MODEM_SERIAL.write(buf, chunk); src += chunk; remaining -= chunk; // Brief yield to avoid starving other tasks during large transfers if (remaining > 0) vTaskDelay(pdMS_TO_TICKS(5)); } // Wait for OK response after transfer completes if (waitResponse("OK", 15000, _atBuf, AT_BUF_SIZE)) { MESH_DEBUG_PRINTLN("[Modem] Tone %d: %s transferred OK", i + 1, tone.filename); successCount++; } else { MESH_DEBUG_PRINTLN("[Modem] Tone %d: %s transfer FAILED: %s", i + 1, tone.filename, _atBuf); // Drain UART to recover modem state vTaskDelay(pdMS_TO_TICKS(1000)); while (MODEM_SERIAL.available()) MODEM_SERIAL.read(); } // Delay between transfers to let modem flush to storage vTaskDelay(pdMS_TO_TICKS(200)); } _tonesTransferred = (successCount > 0); MESH_DEBUG_PRINTLN("[Modem] Notification tones: %d/%d transferred", successCount, MODEM_BUNDLED_TONE_COUNT); return (successCount == MODEM_BUNDLED_TONE_COUNT); } bool ModemManager::playModemTone(const char* filename) { // AT+CCMXPLAY="C:/filename.wav",0,0 // param 1: 0 = local speaker output // param 2: 0 = no repeat char cmd[64]; snprintf(cmd, sizeof(cmd), "AT+CCMXPLAY=\"C:/%s\",0,0", filename); bool ok = sendAT(cmd, "OK", 2000); if (ok) { _notifTonePlaying = true; _notifToneStartTime = millis(); MESH_DEBUG_PRINTLN("[Modem] Playing tone: %s", filename); } else { MESH_DEBUG_PRINTLN("[Modem] CCMXPLAY failed: %s", filename); } return ok; } bool ModemManager::stopModemTone() { if (!_notifTonePlaying) return true; bool ok = sendAT("AT+CCMXSTOP", "OK", 1000); _notifTonePlaying = false; MESH_DEBUG_PRINTLN("[Modem] Tone stop %s", ok ? "OK" : "FAIL"); return ok; } void ModemManager::handleNotifTone() { // Auto-stop if playback has been running too long (safety net) if (_notifTonePlaying && (millis() - _notifToneStartTime) > NOTIF_TONE_TIMEOUT_MS) { MESH_DEBUG_PRINTLN("[Modem] Tone playback timeout -- stopping"); stopModemTone(); } // Check for pending tone request from main loop int8_t idx = _pendingToneIdx; if (idx < 0) return; _pendingToneIdx = -1; // Consume the request if (idx >= MODEM_BUNDLED_TONE_COUNT) return; // Stop any currently playing tone before starting new one if (_notifTonePlaying) { stopModemTone(); } // Don't play during active calls if (isCallActive()) return; playModemTone(modemBundledTones[idx].filename); } // --------------------------------------------------------------------------- // 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"); // --- Query device identity --- // IMEI (International Mobile Equipment Identity) if (sendAT("AT+GSN", "OK", 3000)) { // Response is just the IMEI number on its own line char* p = _atBuf; while (*p && !isdigit(*p)) p++; // skip to first digit int i = 0; while (isdigit(p[i]) && i < 19) { _imei[i] = p[i]; i++; } _imei[i] = '\0'; MESH_DEBUG_PRINTLN("[Modem] IMEI: %s", _imei); } // IMSI (International Mobile Subscriber Identity) -- for APN auto-detection if (sendAT("AT+CIMI", "OK", 3000)) { char* p = _atBuf; while (*p && !isdigit(*p)) p++; int i = 0; while (isdigit(p[i]) && i < 19) { _imsi[i] = p[i]; i++; } _imsi[i] = '\0'; MESH_DEBUG_PRINTLN("[Modem] IMSI: %s", _imsi); } // Set SMS text mode sendAT("AT+CMGF=1", "OK"); // Set character set to GSM (compatible with most networks) sendAT("AT+CSCS=\"GSM\"", "OK"); // Enable SMS notification via +CMTI URC (new message indication) sendAT("AT+CNMI=2,1,0,0,0", "OK"); // Enable automatic time zone update from network (needed for AT+CCLK) sendAT("AT+CTZU=1", "OK"); // --- Voice call setup --- // Enable caller ID presentation (CLIP) so we get +CLIP URCs on incoming calls sendAT("AT+CLIP=1", "OK"); // Set audio output to loudspeaker mode (device speaker) // 1=earpiece, 3=loudspeaker -- use loudspeaker for T-Deck Pro sendAT("AT+CSDVC=3", "OK", 1000); // Set initial call volume (mid-level) sendAT("AT+CLVL=3", "OK", 1000); // ---- 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" 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"); } // Query operator name // AT+COPS=3,0 sets the format to "long alphanumeric" so AT+COPS? // returns "Optus" instead of "50502" sendAT("AT+COPS=3,0", "OK", 2000); if (sendAT("AT+COPS?", "OK", 5000)) { 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); } } } // If operator is still numeric (all digits), look up friendly name from IMSI if (_operator[0] && isdigit(_operator[0])) { bool allDigits = true; for (int i = 0; _operator[i]; i++) { if (!isdigit(_operator[i])) { allDigits = false; break; } } if (allDigits && _imsi[0]) { const ApnEntry* entry = apnLookupFromIMSI(_imsi); if (entry && entry->carrier) { strncpy(_operator, entry->carrier, sizeof(_operator) - 1); _operator[sizeof(_operator) - 1] = '\0'; MESH_DEBUG_PRINTLN("[Modem] operator (from IMSI lookup): %s", _operator); } } } // Initial signal query pollCSQ(); // Resolve APN (user config -> network provisioned -> IMSI auto-detect) resolveAPN(); // Sync ESP32 system clock from modem network time bool clockSet = false; for (int attempt = 0; attempt < 5 && !clockSet; attempt++) { if (attempt > 0) vTaskDelay(pdMS_TO_TICKS(2000)); if (sendAT("AT+CCLK?", "OK", 3000)) { char* p = strstr(_atBuf, "+CCLK:"); if (p) { int yy = 0, mo = 0, dd = 0, hh = 0, mm = 0, ss = 0, tz = 0; if (sscanf(p, "+CCLK: \"%d/%d/%d,%d:%d:%d", &yy, &mo, &dd, &hh, &mm, &ss) >= 6) { if (yy < 24 || yy > 50) { MESH_DEBUG_PRINTLN("[Modem] CCLK not synced yet (yy=%d), retrying...", yy); continue; } // Parse timezone offset char* tzp = p + 7; while (*tzp && *tzp != '+' && *tzp != '-') tzp++; if (*tzp) tz = atoi(tzp); struct tm t = {}; t.tm_year = yy + 100; t.tm_mon = mo - 1; t.tm_mday = dd; t.tm_hour = hh; t.tm_min = mm; t.tm_sec = ss; time_t epoch = mktime(&t); epoch -= (tz * 15 * 60); struct timeval tv = { .tv_sec = epoch, .tv_usec = 0 }; settimeofday(&tv, nullptr); clockSet = true; MESH_DEBUG_PRINTLN("[Modem] System clock set: %04d-%02d-%02d %02d:%02d:%02d (tz=%+d qh, epoch=%lu)", yy + 2000, mo, dd, hh, mm, ss, tz, (unsigned long)epoch); } } } } if (!clockSet) { MESH_DEBUG_PRINTLN("[Modem] WARNING: Could not sync system clock from network"); } // Delete any stale SMS on SIM to free slots sendAT("AT+CMGD=1,4", "OK", 5000); _state = ModemState::READY; MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s, APN=%s [%s], IMEI=%s)", _csq, _operator, _apn[0] ? _apn : "(none)", _apnSource, _imei); // ---- Phase 3b: Transfer notification tones to modem filesystem ---- // Done after READY so modem is fully initialised. Non-blocking for the // mesh -- runs on Core 0 modem task. Uses AT+FSDEL + AT+CFTRANRX. transferTonesToModem(); // ---- Phase 4: Main loop ---- unsigned long lastCSQPoll = 0; unsigned long lastSMSPoll = 0; unsigned long lastCLCCPoll = 0; const unsigned long CSQ_POLL_INTERVAL = 30000; // 30s const unsigned long SMS_POLL_INTERVAL = 10000; // 10s const unsigned long CLCC_POLL_INTERVAL = 2000; // 2s (during dialing only) while (true) { // ================================================================ // Step 1: Drain URCs -- catch RING, NO CARRIER, +CLIP, +AUDIOSTATE // This must run every iteration to avoid missing time-sensitive // events like incoming calls or call-ended notifications. // ================================================================ drainURCs(); // ================================================================ // Step 1b: Ringtone -- play tone bursts while incoming call rings // ================================================================ handleRingtone(); // ================================================================ // Step 1c: Notification tone -- play/stop requested tones // ================================================================ handleNotifTone(); // ================================================================ // Step 2: Process call commands from main loop // ================================================================ CallCommand callCmd; if (xQueueReceive(_callCmdQueue, &callCmd, 0) == pdTRUE) { switch (callCmd.cmd) { case CallCmd::DIAL: if (_state == ModemState::READY) { doDialCall(callCmd.phone); } else { MESH_DEBUG_PRINTLN("[Modem] Can't dial -- state=%d", (int)_state); queueCallEvent(CallEventType::DIAL_FAILED, callCmd.phone); } break; case CallCmd::ANSWER: if (_state == ModemState::RINGING_IN) { doAnswerCall(); } break; case CallCmd::HANGUP: if (isCallActive()) { doHangup(); } break; case CallCmd::DTMF: if (_state == ModemState::IN_CALL) { doSendDTMF(callCmd.dtmf); } break; case CallCmd::SET_VOLUME: doSetVolume(callCmd.volume); break; } } // ================================================================ // Step 3: Poll AT+CLCC during DIALING as fallback. // Primary detection is via "VOICE CALL: BEGIN" URC (handled by // drainURCs/processURCLine above). CLCC polling is a safety net // in case the URC is missed or delayed. // Skip when paused to avoid Core 0 contention with WiFi TLS. // ================================================================ if (!_paused && _state == ModemState::DIALING && millis() - lastCLCCPoll > CLCC_POLL_INTERVAL) { if (sendAT("AT+CLCC", "OK", 2000)) { // +CLCC: 1,0,0,0,0,"number",129 -- stat field: // 0=active, 1=held, 2=dialing, 3=alerting, 4=incoming, 5=waiting char* p = strstr(_atBuf, "+CLCC:"); if (p) { int idx, dir, stat, mode, mpty; if (sscanf(p, "+CLCC: %d,%d,%d,%d,%d", &idx, &dir, &stat, &mode, &mpty) >= 3) { MESH_DEBUG_PRINTLN("[Modem] CLCC: stat=%d", stat); if (stat == 0) { // Call is active -- remote answered _state = ModemState::IN_CALL; _callStartTime = millis(); queueCallEvent(CallEventType::CONNECTED, _callPhone); MESH_DEBUG_PRINTLN("[Modem] Call connected (detected via CLCC)"); } // stat 2=dialing, 3=alerting -- still setting up, keep polling } } else { // No +CLCC line in response -- no active calls // This shouldn't happen during DIALING unless the call ended // and we missed the URC. Check state and clean up. // (NO CARRIER URC should have been caught by drainURCs) } } lastCLCCPoll = millis(); } // ================================================================ // Step 4: SMS and signal polling (only when not in a call) // Skip when paused to avoid Core 0 contention with WiFi/TLS. // The modem task's sendAT() calls (AT+CMGL 5s, AT+CSQ 2s) do // tight UART poll loops that disrupt WiFi packet timing. // ================================================================ if (!_paused && !isCallActive()) { // 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 if (millis() - lastSMSPoll > SMS_POLL_INTERVAL) { pollIncomingSMS(); lastSMSPoll = millis(); } } // Periodic signal strength update (always, even during calls) if (!_paused && millis() - lastCSQPoll > CSQ_POLL_INTERVAL) { // Only poll CSQ if not actively in a call (avoid interrupting audio) if (!isCallActive()) { pollCSQ(); } lastCSQPoll = millis(); } // Shorter delay during active call states for responsive URC handling if (isCallActive()) { vTaskDelay(pdMS_TO_TICKS(100)); // 100ms -- responsive to URCs } else { vTaskDelay(pdMS_TO_TICKS(500)); // 500ms -- normal idle } } } // --------------------------------------------------------------------------- // 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 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 pinMode(MODEM_PWRKEY, OUTPUT); digitalWrite(MODEM_PWRKEY, HIGH); vTaskDelay(pdMS_TO_TICKS(100)); digitalWrite(MODEM_PWRKEY, LOW); vTaskDelay(pdMS_TO_TICKS(1500)); digitalWrite(MODEM_PWRKEY, HIGH); MESH_DEBUG_PRINTLN("[Modem] PWRKEY toggled, waiting for boot..."); vTaskDelay(pdMS_TO_TICKS(5000)); // Assert DTR LOW pinMode(MODEM_DTR, OUTPUT); digitalWrite(MODEM_DTR, LOW); MESH_DEBUG_PRINTLN("[Modem] DTR asserted LOW (GPIO %d)", MODEM_DTR); // Configure UART 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 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) { // Before flushing, drain any pending URCs so we don't lose them drainURCs(); Serial.printf("[Modem] TX: %s\n", cmd); MODEM_SERIAL.println(cmd); bool ok = waitResponse(expect, timeout_ms, _atBuf, AT_BUF_SIZE); if (_atBuf[0]) { 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; } // Also check for call-related URCs embedded in AT responses // (e.g. NO CARRIER can arrive during an AT+CLCC response) if (buf && strstr(buf, "NO CARRIER")) { processURCLine("NO CARRIER"); } if (buf && strstr(buf, "BUSY")) { // Only process if we're in a call-related state if (_state == ModemState::DIALING) { processURCLine("BUSY"); } } } 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 phone[SMS_PHONE_LEN]; // Parse header line char* lineEnd = strchr(p, '\n'); if (!lineEnd) break; // Extract index if (sscanf(p, "+CMGL: %d", &idx) != 1) { p = lineEnd + 1; continue; } // Extract phone number char* q1 = strchr(p + 7, '"'); if (!q1) { p = lineEnd + 1; continue; } q1++; char* q2 = strchr(q1, '"'); if (!q2) { p = lineEnd + 1; continue; } char* q3 = strchr(q2 + 1, '"'); if (!q3) { p = lineEnd + 1; continue; } q3++; char* q4 = strchr(q3, '"'); if (!q4) { p = lineEnd + 1; continue; } int phoneLen = q4 - q3; if (phoneLen >= SMS_PHONE_LEN) phoneLen = SMS_PHONE_LEN - 1; memcpy(phone, q3, phoneLen); phone[phoneLen] = '\0'; // Body is on the next line p = lineEnd + 1; char* bodyEnd = strchr(p, '\r'); if (!bodyEnd) bodyEnd = strchr(p, '\n'); if (!bodyEnd) break; SMSIncoming incoming; memset(&incoming, 0, sizeof(incoming)); strncpy(incoming.phone, phone, SMS_PHONE_LEN - 1); int bodyLen = bodyEnd - p; if (bodyLen >= SMS_BODY_LEN) bodyLen = SMS_BODY_LEN - 1; memcpy(incoming.body, p, bodyLen); incoming.body[bodyLen] = '\0'; incoming.timestamp = (uint32_t)time(nullptr); // 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