From 96f171a1fcc6c3dd8f05616b18f11efc8d1114de Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:18:41 +1100 Subject: [PATCH] phone! updated relevant files and renamed sms app accordingly; changed firmware version and date to match --- SMS App Guide.md => SMS & Phone App Guide.md | 75 ++- examples/companion_radio/MyMesh.h | 4 +- examples/companion_radio/main.cpp | 60 ++ .../companion_radio/ui-new/ModemManager.cpp | 581 ++++++++++++++++-- .../companion_radio/ui-new/ModemManager.h | 113 +++- examples/companion_radio/ui-new/SMSScreen.h | 423 +++++++++++-- examples/companion_radio/ui-new/UITask.cpp | 2 +- variants/lilygo_tdeck_pro/platformio.ini | 4 +- 8 files changed, 1132 insertions(+), 130 deletions(-) rename SMS App Guide.md => SMS & Phone App Guide.md (57%) diff --git a/SMS App Guide.md b/SMS & Phone App Guide.md similarity index 57% rename from SMS App Guide.md rename to SMS & Phone App Guide.md index 3d0390be..a7ccff1a 100644 --- a/SMS App Guide.md +++ b/SMS & Phone App Guide.md @@ -1,6 +1,6 @@ -## SMS App (4G variant only) - Meck v0.9.2 (Alpha) +## SMS & Phone App (4G variant only) - Meck v0.9.3 (Alpha) -Press **T** from the home screen to open the SMS app. +Press **T** from the home screen to open the SMS & Phone app. Requires a nano SIM card inserted in the T-Deck Pro V1.1 4G modem slot and an SD card formatted as FAT32. The modem registers on the cellular network automatically at boot — the red LED on the board indicates the modem is @@ -12,7 +12,7 @@ cellular network, which takes roughly 15 seconds. | Context | Key | Action | |---------|-----|--------| -| Home screen | T | Open SMS app | +| Home screen | T | Open SMS & Phone app | | Inbox | W / S | Scroll conversations | | Inbox | Enter | Open conversation | | Inbox | C | Compose new SMS (enter phone number) | @@ -20,15 +20,23 @@ cellular network, which takes roughly 15 seconds. | Inbox | Q | Back to home screen | | Conversation | W / S | Scroll messages | | Conversation | C | Reply to this conversation | +| Conversation | F | Call this number | | Conversation | A | Add or edit contact name for this number | | Conversation | Q | Back to inbox | | Compose | Enter | Send SMS (from body) / Confirm phone number (from phone input) | | Compose | Shift+Del | Cancel and return | | Contacts | W / S | Scroll contact list | | Contacts | Enter | Compose SMS to selected contact | +| Contacts | F | Call selected contact | | Contacts | Q | Back to inbox | | Edit Contact | Enter | Save contact name | | Edit Contact | Shift+Del | Cancel without saving | +| Dialing | Enter or Q | Cancel / hang up | +| Incoming Call | Enter | Answer call | +| Incoming Call | Q | Reject call | +| In Call | Enter or Q | Hang up | +| In Call | W / S | Volume up / down (0–5) | +| In Call | 0–9, *, # | Send DTMF tone | ### Sending an SMS @@ -45,11 +53,49 @@ There are three ways to start a new message: Messages are limited to 160 characters (standard SMS). A character counter is shown in the footer while composing. +### Making a Phone Call + +Press **F** to call from either the conversation view or the contacts +directory. The display switches to a dialing screen showing the contact name +(or phone number) and an animated progress indicator. Once the remote party +answers, the screen transitions to the in-call view with a live call timer. + +There are two ways to start a call: + +1. **From a conversation** — open a conversation and press **F**. You can call + any number you have previously exchanged messages with, whether or not it is + saved as a named contact. +2. **From the contacts directory** — press **D** from the inbox, scroll to a + contact, and press **F**. + +> **Note:** There is currently no way to dial an arbitrary phone number without +> first creating a conversation. To call a new number, press **C** from the +> inbox to compose a new SMS, enter the phone number, send a short message, +> then open the resulting conversation and press **F** to call. + +During an active call, **W** and **S** adjust the speaker volume (0–5). The +number keys **0–9**, **\***, and **#** send DTMF tones for navigating phone +menus and voicemail systems. Press **Enter** or **Q** to hang up. + +Audio is routed through the A7682E modem's internal codec to the board speaker +and microphone — no headphones or external audio hardware are required. + +### Receiving a Phone Call + +When an incoming call arrives, the app automatically switches to the incoming +call screen regardless of which view is active. A short alert and buzzer +notification are triggered. The caller's name is shown if saved in contacts, +otherwise the raw phone number is displayed. + +Press **Enter** to answer or **Q** to reject the call. If the call is not +answered it is logged as a missed call and a "Missed: ..." alert is shown +briefly. + ### Contacts The contacts directory lets you assign display names to phone numbers. -Names appear in the inbox list, conversation headers, and compose screen -instead of raw numbers. +Names appear in the inbox list, conversation headers, call screens, and +compose screen instead of raw numbers. To add or edit a contact, open a conversation with that number and press **A**. Type the display name and press **Enter** to save. Names can be up to 23 @@ -78,15 +124,15 @@ The 4G modem can be toggled on or off from the settings screen. Scroll to **4G Modem: ON/OFF** and press **Enter** to toggle. Switching the modem off kills its red status LED and stops all cellular activity. The setting persists to SD card and is respected on subsequent boots — if disabled, the modem and -LED stay off until re-enabled. The SMS app remains accessible when the modem -is off but will not be able to send or receive messages. +LED stay off until re-enabled. The SMS & Phone app remains accessible when the +modem is off but will not be able to send or receive messages or calls. ### Signal Indicator -A signal strength indicator is shown in the top-right corner of all SMS -screens. Bars are derived from the modem's CSQ (signal quality) reading, +A signal strength indicator is shown in the top-right corner of all SMS and +call screens. Bars are derived from the modem's CSQ (signal quality) reading, updated every 30 seconds. The modem state (REG, READY, OFF, etc.) is shown -when not yet connected. +when not yet connected. During a call, the signal indicator remains visible. ### SD Card Structure @@ -110,7 +156,10 @@ SD Card | Timestamps show `---` | Modem clock hasn't synced yet (wait ~15 seconds after modem startup), or messages were saved before clock sync was available | | Red LED stays on after disabling modem | Toggle the setting off, then reboot — the boot sequence ensures power is cut when disabled | | SMS sends but no delivery | Check signal strength; below 5 bars is marginal. Move to better coverage | +| Call drops immediately after dialing | Check signal strength and ensure the SIM plan supports voice calls | +| No audio during call | The A7682E routes audio through its own codec; ensure the board speaker is not obstructed. Try adjusting volume with W/S | +| Cannot dial a number | You must first have a conversation or saved contact for that number. Send a short SMS to create a conversation, then press F | -> **Note:** The SMS app is only available on the 4G modem variant of the -> T-Deck Pro. It is not present on the audio or standalone BLE builds due to -> shared GPIO pin conflicts between the A7682E modem and PCM5102A DAC. \ No newline at end of file +> **Note:** The SMS & Phone app is only available on the 4G modem variant of +> the T-Deck Pro. It is not present on the audio or standalone BLE builds due +> to shared GPIO pin conflicts between the A7682E modem and PCM5102A DAC. \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index f9b10dbd..c2220db3 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,11 @@ #define FIRMWARE_VER_CODE 8 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "23 Feb 2026" +#define FIRMWARE_BUILD_DATE "24 Feb 2026" #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.9.2" +#define FIRMWARE_VERSION "Meck v0.9.3" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index c97c50db..df2f2a15 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -714,6 +714,58 @@ void loop() { Serial.printf("[SMS] Received from %s: %.40s...\n", incoming.phone, incoming.body); } + + // Poll for voice call events from modem + CallEvent callEvt; + while (modemManager.pollCallEvent(callEvt)) { + SMSScreen* smsScr2 = (SMSScreen*)ui_task.getSMSScreen(); + if (smsScr2) { + smsScr2->onCallEvent(callEvt); + } + + if (callEvt.type == CallEventType::INCOMING) { + // Incoming call — auto-switch to SMS screen if not already there + char alertBuf[48]; + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(callEvt.phone, dispName, sizeof(dispName)); + snprintf(alertBuf, sizeof(alertBuf), "Call: %s", dispName); + ui_task.showAlert(alertBuf, 3000); + ui_task.notify(UIEventType::contactMessage); + + if (!smsMode) { + ui_task.gotoSMSScreen(); + } + ui_task.forceRefresh(); + Serial.printf("[Call] Incoming from %s\n", callEvt.phone); + } else if (callEvt.type == CallEventType::CONNECTED) { + Serial.printf("[Call] Connected to %s\n", callEvt.phone); + ui_task.forceRefresh(); + } else if (callEvt.type == CallEventType::ENDED) { + Serial.printf("[Call] Ended (%lus) with %s\n", + (unsigned long)callEvt.duration, callEvt.phone); + ui_task.forceRefresh(); + } else if (callEvt.type == CallEventType::MISSED) { + char alertBuf[48]; + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(callEvt.phone, dispName, sizeof(dispName)); + snprintf(alertBuf, sizeof(alertBuf), "Missed: %s", dispName); + ui_task.showAlert(alertBuf, 3000); + Serial.printf("[Call] Missed from %s\n", callEvt.phone); + ui_task.forceRefresh(); + } else if (callEvt.type == CallEventType::BUSY) { + ui_task.showAlert("Line busy", 2000); + Serial.printf("[Call] Busy: %s\n", callEvt.phone); + ui_task.forceRefresh(); + } else if (callEvt.type == CallEventType::NO_ANSWER) { + ui_task.showAlert("No answer", 2000); + Serial.printf("[Call] No answer: %s\n", callEvt.phone); + ui_task.forceRefresh(); + } else if (callEvt.type == CallEventType::DIAL_FAILED) { + ui_task.showAlert("Call failed", 2000); + Serial.printf("[Call] Dial failed: %s\n", callEvt.phone); + ui_task.forceRefresh(); + } + } } #endif #ifdef DISPLAY_CLASS @@ -1255,6 +1307,14 @@ void handleKeyboardInput() { if (smsMode) { SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen(); if (smsScr) { + // During active call views, route all keys directly to the screen + // and force a refresh after each keypress (no debounce needed) + if (smsScr->isInCallView()) { + smsScr->handleInput(key); + ui_task.forceRefresh(); + return; + } + // 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"); diff --git a/examples/companion_radio/ui-new/ModemManager.cpp b/examples/companion_radio/ui-new/ModemManager.cpp index 3eaf1131..c09008f3 100644 --- a/examples/companion_radio/ui-new/ModemManager.cpp +++ b/examples/companion_radio/ui-new/ModemManager.cpp @@ -18,7 +18,7 @@ ModemManager modemManager; static char _atBuf[AT_BUF_SIZE]; // --------------------------------------------------------------------------- -// Public API +// Public API - SMS (unchanged) // --------------------------------------------------------------------------- void ModemManager::begin() { @@ -27,11 +27,16 @@ void ModemManager::begin() { _state = ModemState::OFF; _csq = 99; _operator[0] = '\0'; + _callPhone[0] = '\0'; + _callStartTime = 0; + _urcPos = 0; // Create FreeRTOS primitives - _sendQueue = xQueueCreate(MODEM_SEND_QUEUE_SIZE, sizeof(SMSOutgoing)); - _recvQueue = xQueueCreate(MODEM_RECV_QUEUE_SIZE, sizeof(SMSIncoming)); - _uartMutex = xSemaphoreCreateMutex(); + _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( @@ -50,6 +55,15 @@ void ModemManager::shutdown() { MESH_DEBUG_PRINTLN("[Modem] shutdown()"); + // 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); @@ -81,6 +95,74 @@ bool ModemManager::recvSMS(SMSIncoming& out) { 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; +} + +// --------------------------------------------------------------------------- +// State helpers +// --------------------------------------------------------------------------- + int ModemManager::getSignalBars() const { if (_csq == 99 || _csq == 0) return 0; if (_csq <= 5) return 1; @@ -99,6 +181,9 @@ const char* ModemManager::stateToString(ModemState s) { 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 "???"; } } @@ -132,6 +217,282 @@ void ModemManager::saveEnabledConfig(bool enabled) { } } +// --------------------------------------------------------------------------- +// 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 +// +// 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; + } +} + +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; +} + // --------------------------------------------------------------------------- // FreeRTOS Task // --------------------------------------------------------------------------- @@ -188,6 +549,17 @@ restart: // 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..."); @@ -196,7 +568,6 @@ restart: 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; @@ -215,12 +586,10 @@ restart: 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++; @@ -239,36 +608,33 @@ restart: pollCSQ(); // Sync ESP32 system clock from modem network time - // Network time may take a few seconds to arrive after registration bool clockSet = false; for (int attempt = 0; attempt < 5 && !clockSet; attempt++) { if (attempt > 0) vTaskDelay(pdMS_TO_TICKS(2000)); if (sendAT("AT+CCLK?", "OK", 3000)) { - // Response: +CCLK: "YY/MM/DD,HH:MM:SS±TZ" (TZ in quarter-hours) char* p = strstr(_atBuf, "+CCLK:"); if (p) { int yy = 0, mo = 0, dd = 0, hh = 0, mm = 0, ss = 0, tz = 0; if (sscanf(p, "+CCLK: \"%d/%d/%d,%d:%d:%d", &yy, &mo, &dd, &hh, &mm, &ss) >= 6) { - // Skip if modem clock not synced (default is 1970 = yy 70, or yy 0) if (yy < 24 || yy > 50) { MESH_DEBUG_PRINTLN("[Modem] CCLK not synced yet (yy=%d), retrying...", yy); continue; } - // Parse timezone offset (e.g. "+40" = UTC+10 in quarter-hours) - char* tzp = p + 7; // skip "+CCLK: " + // 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; // years since 1900 - t.tm_mon = mo - 1; // 0-based + 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); // treats input as UTC (no TZ set on ESP32) - epoch -= (tz * 15 * 60); // subtract local offset to get real UTC + time_t epoch = mktime(&t); + epoch -= (tz * 15 * 60); struct timeval tv = { .tv_sec = epoch, .tv_usec = 0 }; settimeofday(&tv, nullptr); @@ -284,7 +650,7 @@ restart: } // Delete any stale SMS on SIM to free slots - sendAT("AT+CMGD=1,4", "OK", 5000); // Delete all read messages + sendAT("AT+CMGD=1,4", "OK", 5000); _state = ModemState::READY; MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s)", _csq, _operator); @@ -292,32 +658,128 @@ restart: // ---- 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 + 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) { - // 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; + // ================================================================ + // Step 1: Drain URCs — catch RING, NO CARRIER, +CLIP, etc. + // This must run every iteration to avoid missing time-sensitive + // events like incoming calls or call-ended notifications. + // ================================================================ + drainURCs(); + + // ================================================================ + // 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; + } } - // Poll for incoming SMS periodically (not every loop iteration) - if (millis() - lastSMSPoll > SMS_POLL_INTERVAL) { - pollIncomingSMS(); - lastSMSPoll = millis(); + // ================================================================ + // 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. + // ================================================================ + if (_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(); } - // Periodic signal strength update + // ================================================================ + // Step 4: SMS and signal polling (only when not in a call) + // ================================================================ + if (!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 (millis() - lastCSQPoll > CSQ_POLL_INTERVAL) { - pollCSQ(); + // Only poll CSQ if not actively in a call (avoid interrupting audio) + if (!isCallActive()) { + pollCSQ(); + } lastCSQPoll = millis(); } - vTaskDelay(pdMS_TO_TICKS(500)); // 500ms loop — responsive for sends, calm for polls + // 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 + } } } @@ -334,8 +796,7 @@ bool ModemManager::modemPowerOn() { 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) + // Reset pulse pinMode(MODEM_RST, OUTPUT); digitalWrite(MODEM_RST, LOW); vTaskDelay(pdMS_TO_TICKS(200)); @@ -343,29 +804,23 @@ bool ModemManager::modemPowerOn() { 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 + // PWRKEY toggle pinMode(MODEM_PWRKEY, OUTPUT); - digitalWrite(MODEM_PWRKEY, HIGH); // Start high (idle state) + digitalWrite(MODEM_PWRKEY, HIGH); vTaskDelay(pdMS_TO_TICKS(100)); - digitalWrite(MODEM_PWRKEY, LOW); // Active-low trigger + digitalWrite(MODEM_PWRKEY, LOW); vTaskDelay(pdMS_TO_TICKS(1500)); - digitalWrite(MODEM_PWRKEY, HIGH); // Release + digitalWrite(MODEM_PWRKEY, HIGH); 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 + // Assert DTR LOW 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); @@ -373,7 +828,7 @@ bool ModemManager::modemPowerOn() { // Drain any boot garbage from UART while (MODEM_SERIAL.available()) MODEM_SERIAL.read(); - // Test communication — generous attempts + // 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)) { @@ -392,14 +847,13 @@ bool ModemManager::modemPowerOn() { // --------------------------------------------------------------------------- bool ModemManager::sendAT(const char* cmd, const char* expect, uint32_t timeout_ms) { - // Flush any pending data - while (MODEM_SERIAL.available()) MODEM_SERIAL.read(); + // 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]) { - // 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"); @@ -427,6 +881,17 @@ bool ModemManager::waitResponse(const char* expect, uint32_t timeout_ms, 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)); } @@ -457,23 +922,21 @@ void ModemManager::pollIncomingSMS() { char* p = _atBuf; while ((p = strstr(p, "+CMGL:")) != nullptr) { int idx; - char stat[16], phone[SMS_PHONE_LEN], timestamp[24]; - + char phone[SMS_PHONE_LEN]; + // 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," + // Extract phone number + char* q1 = strchr(p + 7, '"'); if (!q1) { p = lineEnd + 1; continue; } - q1++; // skip opening quote of stat - char* q2 = strchr(q1, '"'); // end of stat + q1++; + char* q2 = strchr(q1, '"'); 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++; @@ -497,7 +960,7 @@ void ModemManager::pollIncomingSMS() { if (bodyLen >= SMS_BODY_LEN) bodyLen = SMS_BODY_LEN - 1; memcpy(incoming.body, p, bodyLen); incoming.body[bodyLen] = '\0'; - incoming.timestamp = (uint32_t)time(nullptr); // Real epoch from modem-synced clock + incoming.timestamp = (uint32_t)time(nullptr); // Queue for main loop xQueueSend(_recvQueue, &incoming, 0); diff --git a/examples/companion_radio/ui-new/ModemManager.h b/examples/companion_radio/ui-new/ModemManager.h index d073293d..19ac2622 100644 --- a/examples/companion_radio/ui-new/ModemManager.h +++ b/examples/companion_radio/ui-new/ModemManager.h @@ -6,6 +6,8 @@ // 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. // +// Supports: SMS send/receive, voice call dial/answer/hangup/DTMF +// // Guard: HAS_4G_MODEM (defined only for the 4G build environment) // ============================================================================= @@ -38,14 +40,18 @@ // 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_STACK_SIZE 6144 // Increased for call handling #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 +#define MODEM_CALL_CMD_QUEUE_SIZE 4 +#define MODEM_CALL_EVT_QUEUE_SIZE 4 +// --------------------------------------------------------------------------- // Modem state machine +// --------------------------------------------------------------------------- enum class ModemState { OFF, POWERING_ON, @@ -53,9 +59,17 @@ enum class ModemState { REGISTERING, READY, ERROR, - SENDING_SMS + SENDING_SMS, + // Voice call states + DIALING, // ATD sent, waiting for connect/carrier + RINGING_IN, // Incoming call detected (RING URC) + IN_CALL // Voice call active }; +// --------------------------------------------------------------------------- +// SMS structures (unchanged) +// --------------------------------------------------------------------------- + // Outgoing SMS (queued from main loop to modem task) struct SMSOutgoing { char phone[SMS_PHONE_LEN]; @@ -69,28 +83,85 @@ struct SMSIncoming { uint32_t timestamp; // epoch seconds (from modem RTC or millis-based) }; +// --------------------------------------------------------------------------- +// Voice call structures +// --------------------------------------------------------------------------- + +// Commands from main loop → modem task +enum class CallCmd : uint8_t { + DIAL, // Initiate outgoing call + ANSWER, // Answer incoming call + HANGUP, // End active call or reject incoming + DTMF, // Send DTMF tone during call + SET_VOLUME // Set speaker volume +}; + +struct CallCommand { + CallCmd cmd; + char phone[SMS_PHONE_LEN]; // Used by DIAL + char dtmf; // Used by DTMF (single digit: 0-9, *, #) + uint8_t volume; // Used by SET_VOLUME (0-5) +}; + +// Events from modem task → main loop +enum class CallEventType : uint8_t { + INCOMING, // Incoming call ringing (+CLIP parsed) + CONNECTED, // Call answered / outgoing connected + ENDED, // Call ended (local hangup, remote hangup, or no carrier) + MISSED, // Incoming call ended before answer + BUSY, // Outgoing call got busy signal + NO_ANSWER, // Outgoing call not answered + DIAL_FAILED // ATD command failed +}; + +struct CallEvent { + CallEventType type; + char phone[SMS_PHONE_LEN]; // Caller/callee number (from +CLIP or dial) + uint32_t duration; // Call duration in seconds (for ENDED) +}; + +// --------------------------------------------------------------------------- +// ModemManager class +// --------------------------------------------------------------------------- + class ModemManager { public: void begin(); void shutdown(); - // Non-blocking: queue an SMS for sending (returns false if queue full) + // --- SMS API (unchanged) --- 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) + // --- Voice Call API --- + bool dialCall(const char* phone); // Queue outgoing call + bool answerCall(); // Answer incoming call + bool hangupCall(); // End active / reject incoming + bool sendDTMF(char digit); // Send DTMF during call + bool setCallVolume(uint8_t level); // Set volume 0-5 + bool pollCallEvent(CallEvent& out); // Poll from main loop + + // --- 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; } + bool isInCall() const { return _state == ModemState::IN_CALL; } + bool isRinging() const { return _state == ModemState::RINGING_IN; } + bool isDialing() const { return _state == ModemState::DIALING; } + bool isCallActive() const { + return _state == ModemState::IN_CALL || + _state == ModemState::DIALING || + _state == ModemState::RINGING_IN; + } const char* getOperator() const { return _operator; } + const char* getCallPhone() const { return _callPhone; } + uint32_t getCallStartTime() const { return _callStartTime; } static const char* stateToString(ModemState s); // Persistent enable/disable config (SD file /sms/modem.cfg) - static bool loadEnabledConfig(); // returns true if enabled (default) + static bool loadEnabledConfig(); static void saveEnabledConfig(bool enabled); private: @@ -98,11 +169,27 @@ private: volatile int _csq = 99; // 99 = unknown char _operator[24] = {0}; + // Call state (written by modem task, read by main loop) + char _callPhone[SMS_PHONE_LEN] = {0}; // Current call number + volatile uint32_t _callStartTime = 0; // millis() when call connected + TaskHandle_t _taskHandle = nullptr; + + // SMS queues QueueHandle_t _sendQueue = nullptr; QueueHandle_t _recvQueue = nullptr; + + // Call queues + QueueHandle_t _callCmdQueue = nullptr; // main loop → modem task + QueueHandle_t _callEvtQueue = nullptr; // modem task → main loop + SemaphoreHandle_t _uartMutex = nullptr; + // URC line buffer (accumulated between AT commands) + static const int URC_BUF_SIZE = 256; + char _urcBuf[URC_BUF_SIZE]; + int _urcPos = 0; + // UART AT command helpers (called only from modem task) bool modemPowerOn(); bool sendAT(const char* cmd, const char* expect, uint32_t timeout_ms = 2000); @@ -111,6 +198,18 @@ private: void pollIncomingSMS(); bool doSendSMS(const char* phone, const char* body); + // URC (unsolicited result code) handling + void drainURCs(); // Read available UART data, process complete lines + void processURCLine(const char* line); // Handle a single URC line + + // Call control (called from modem task) + bool doDialCall(const char* phone); + bool doAnswerCall(); + bool doHangup(); + bool doSendDTMF(char digit); + bool doSetVolume(uint8_t level); + void queueCallEvent(CallEventType type, const char* phone = nullptr, uint32_t duration = 0); + // FreeRTOS task static void taskEntry(void* param); void taskLoop(); diff --git a/examples/companion_radio/ui-new/SMSScreen.h b/examples/companion_radio/ui-new/SMSScreen.h index b24e96b2..4117b859 100644 --- a/examples/companion_radio/ui-new/SMSScreen.h +++ b/examples/companion_radio/ui-new/SMSScreen.h @@ -1,20 +1,24 @@ #pragma once // ============================================================================= -// SMSScreen - SMS messaging UI for T-Deck Pro (4G variant) +// SMSScreen - SMS messaging & Voice Calls UI for T-Deck Pro (4G variant) // // Sub-views: -// INBOX — list of conversations (names resolved via SMSContacts) -// CONVERSATION — messages for a selected contact, scrollable -// COMPOSE — text input for new SMS -// CONTACTS — browsable contacts list, pick to compose -// EDIT_CONTACT — add or edit a contact name for a phone number +// INBOX — list of conversations (names resolved via SMSContacts) +// CONVERSATION — messages for a selected contact, scrollable +// COMPOSE — text input for new SMS +// CONTACTS — browsable contacts list, pick to compose or call +// EDIT_CONTACT — add or edit a contact name for a phone number +// DIALING — outgoing call in progress +// INCOMING_CALL — incoming call ringing (answer/reject) +// IN_CALL — active voice call (timer, DTMF, volume, hangup) // // Navigation mirrors ChannelScreen conventions: // W/S: scroll Enter: select/send C: compose new/reply // Q: back Sh+Del: cancel compose // D: contacts (from inbox) // A: add/edit contact (from conversation) +// F: call (from conversation or contacts) // // Guard: HAS_4G_MODEM // ============================================================================= @@ -40,7 +44,11 @@ class UITask; // forward declaration class SMSScreen : public UIScreen { public: - enum SubView { INBOX, CONVERSATION, COMPOSE, CONTACTS, EDIT_CONTACT }; + enum SubView { + INBOX, CONVERSATION, COMPOSE, CONTACTS, EDIT_CONTACT, + // Voice call views + DIALING, INCOMING_CALL, IN_CALL + }; private: UITask* _task; @@ -77,8 +85,14 @@ private: char _editPhone[SMS_PHONE_LEN]; char _editNameBuf[SMS_CONTACT_NAME_LEN]; int _editNamePos; - bool _editIsNew; // true = adding new, false = editing existing - SubView _editReturnView; // where to return after save/cancel + bool _editIsNew; + SubView _editReturnView; + + // Voice call state + char _callPhone[SMS_PHONE_LEN]; // Number for current/pending call + unsigned long _callConnectedMillis; // millis() when call connected + SubView _preCallView; // View to return to after call ends + uint8_t _callVolume; // Current volume level 0-5 // Refresh debounce bool _needsRefresh; @@ -95,10 +109,19 @@ private: void refreshConversation() { _msgCount = smsStore.loadMessages(_activePhone, _msgs, SMS_MSG_PAGE_SIZE); - // Scroll to bottom (newest messages are at end now, chat-style) _msgScrollPos = (_msgCount > 3) ? _msgCount - 3 : 0; } + // Helper: initiate a call to a phone number + void startCall(const char* phone) { + strncpy(_callPhone, phone, SMS_PHONE_LEN - 1); + _callPhone[SMS_PHONE_LEN - 1] = '\0'; + _callConnectedMillis = 0; + _preCallView = _view; + modemManager.dialCall(phone); + _view = DIALING; + } + public: SMSScreen(UITask* task) : _task(task), _view(INBOX) @@ -108,6 +131,7 @@ public: , _phoneInputPos(0), _enteringPhone(false) , _contactsCursor(0), _contactsScrollTop(0) , _editNamePos(0), _editIsNew(false), _editReturnView(INBOX) + , _callConnectedMillis(0), _preCallView(INBOX), _callVolume(3) , _needsRefresh(false), _lastRefresh(0) , _sdReady(false) { @@ -117,6 +141,7 @@ public: memset(_activePhone, 0, sizeof(_activePhone)); memset(_editPhone, 0, sizeof(_editPhone)); memset(_editNameBuf, 0, sizeof(_editNameBuf)); + memset(_callPhone, 0, sizeof(_callPhone)); } void setSDReady(bool ready) { _sdReady = ready; } @@ -131,8 +156,11 @@ public: SubView getSubView() const { return _view; } bool isComposing() const { return _view == COMPOSE; } bool isEnteringPhone() const { return _enteringPhone; } + bool isInCallView() const { + return _view == DIALING || _view == INCOMING_CALL || _view == IN_CALL; + } - // Called from main loop when an SMS arrives (saves to store + refreshes) + // Called from main loop when an SMS arrives void onIncomingSMS(const char* phone, const char* body, uint32_t timestamp) { if (_sdReady) { smsStore.saveMessage(phone, body, false, timestamp); @@ -146,6 +174,47 @@ public: _needsRefresh = true; } + // Called from main loop when a call event arrives + void onCallEvent(const CallEvent& evt) { + switch (evt.type) { + case CallEventType::INCOMING: + // Incoming call — switch to incoming call view + strncpy(_callPhone, evt.phone, SMS_PHONE_LEN - 1); + _callPhone[SMS_PHONE_LEN - 1] = '\0'; + if (_view != INCOMING_CALL) { + _preCallView = _view; + _view = INCOMING_CALL; + } + break; + + case CallEventType::CONNECTED: + // Call connected — switch to in-call view + _callConnectedMillis = millis(); + _view = IN_CALL; + break; + + case CallEventType::ENDED: + case CallEventType::MISSED: + case CallEventType::BUSY: + case CallEventType::NO_ANSWER: + case CallEventType::DIAL_FAILED: + // Call ended — return to previous view + _callPhone[0] = '\0'; + _callConnectedMillis = 0; + // Return to pre-call view or inbox + if (_preCallView == DIALING || _preCallView == INCOMING_CALL || _preCallView == IN_CALL) { + _view = INBOX; + if (_sdReady) refreshInbox(); + } else { + _view = _preCallView; + if (_view == INBOX && _sdReady) refreshInbox(); + if (_view == CONVERSATION) refreshConversation(); + } + break; + } + _needsRefresh = true; + } + // ========================================================================= // Signal strength indicator (top-right corner) // ========================================================================= @@ -154,7 +223,6 @@ public: ModemState ms = modemManager.getState(); int bars = modemManager.getSignalBars(); - // Draw signal bars (4 bars, increasing height) int barWidth = 3; int barGap = 2; int maxBarH = 10; @@ -174,8 +242,9 @@ public: x += barWidth + barGap; } - // Show modem state text if not ready - if (ms != ModemState::READY && ms != ModemState::SENDING_SMS) { + if (ms != ModemState::READY && ms != ModemState::SENDING_SMS && + ms != ModemState::DIALING && ms != ModemState::IN_CALL && + ms != ModemState::RINGING_IN) { display.setTextSize(0); display.setColor(DisplayDriver::YELLOW); const char* label = ModemManager::stateToString(ms); @@ -197,11 +266,14 @@ public: _lastRefresh = millis(); switch (_view) { - case INBOX: return renderInbox(display); - case CONVERSATION: return renderConversation(display); - case COMPOSE: return renderCompose(display); - case CONTACTS: return renderContacts(display); - case EDIT_CONTACT: return renderEditContact(display); + case INBOX: return renderInbox(display); + case CONVERSATION: return renderConversation(display); + case COMPOSE: return renderCompose(display); + case CONTACTS: return renderContacts(display); + case EDIT_CONTACT: return renderEditContact(display); + case DIALING: return renderDialing(display); + case INCOMING_CALL: return renderIncomingCall(display); + case IN_CALL: return renderInCall(display); } return 1000; } @@ -246,7 +318,6 @@ public: 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; @@ -259,7 +330,6 @@ public: bool selected = (idx == _inboxCursor); - // Resolve contact name (shows name if saved, phone otherwise) char dispName[SMS_CONTACT_NAME_LEN]; smsContacts.displayName(c.phone, dispName, sizeof(dispName)); @@ -307,7 +377,6 @@ public: // ---- Conversation view ---- int renderConversation(DisplayDriver& display) { - // Header - show contact name if available, phone otherwise display.setTextSize(1); display.setColor(DisplayDriver::GREEN); display.setCursor(0, 0); @@ -315,7 +384,6 @@ public: smsContacts.displayName(_activePhone, convTitle, sizeof(convTitle)); display.print(convTitle); - // Signal icon renderSignalIndicator(display, display.width() - 2, 0); display.setColor(DisplayDriver::LIGHT); @@ -333,7 +401,6 @@ public: 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; @@ -346,15 +413,13 @@ public: SMSMessage& msg = _msgs[i]; if (!msg.valid) continue; - // Direction indicator display.setCursor(0, y); display.setColor(msg.isSent ? DisplayDriver::BLUE : DisplayDriver::YELLOW); - // Time formatting (epoch-aware) char timeStr[16]; time_t now = time(nullptr); - bool haveEpoch = (now > 1700000000); // system clock is set - bool msgIsEpoch = (msg.timestamp > 1700000000); // msg has real timestamp + bool haveEpoch = (now > 1700000000); + bool msgIsEpoch = (msg.timestamp > 1700000000); if (haveEpoch && msgIsEpoch) { uint32_t age = (uint32_t)(now - msg.timestamp); @@ -372,7 +437,6 @@ public: display.print(header); y += lineHeight; - // Message body with simple word wrap display.setColor(DisplayDriver::LIGHT); int textLen = strlen(msg.body); int pos = 0; @@ -402,13 +466,16 @@ public: display.setTextSize(1); } - // Footer + // Footer — now includes F:Call display.setTextSize(1); int footerY = display.height() - 12; display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); display.setCursor(0, footerY); - display.print("Q:Bk A:Add Contact"); + display.print("Q:Bk A:Ct"); + const char* mid = "F:Call"; + 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); @@ -428,7 +495,6 @@ public: display.print(_phoneInputBuf); display.print("_"); } else { - // Show contact name if available char dispName[SMS_CONTACT_NAME_LEN]; smsContacts.displayName(_composePhone, dispName, sizeof(dispName)); char toLabel[40]; @@ -440,7 +506,6 @@ public: display.drawRect(0, 11, display.width(), 1); if (!_enteringPhone) { - // Message body display.setCursor(0, 14); display.setColor(DisplayDriver::LIGHT); display.setTextSize(0); @@ -463,7 +528,6 @@ public: } } - // Cursor display.setCursor(x * (display.width() / charsPerLine), y); display.print("_"); display.setTextSize(1); @@ -523,7 +587,6 @@ public: int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2); if (visibleCount < 1) visibleCount = 1; - // Adjust scroll if (_contactsCursor >= cnt) _contactsCursor = cnt - 1; if (_contactsCursor < 0) _contactsCursor = 0; if (_contactsCursor < _contactsScrollTop) _contactsScrollTop = _contactsCursor; @@ -538,14 +601,12 @@ public: bool selected = (idx == _contactsCursor); - // Name display.setCursor(0, y); display.setColor(selected ? DisplayDriver::GREEN : DisplayDriver::LIGHT); if (selected) display.print("> "); display.print(ct.name); y += lineHeight; - // Phone (dimmer) display.setColor(DisplayDriver::LIGHT); display.setCursor(12, y); display.print(ct.phone); @@ -554,13 +615,16 @@ public: display.setTextSize(1); } - // Footer + // Footer — now includes F:Call display.setTextSize(1); int footerY = display.height() - 12; display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); display.setCursor(0, footerY); display.print("Q:Back"); + const char* mid = "F:Call"; + display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY); + display.print(mid); const char* rt = "Ent:SMS"; display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); display.print(rt); @@ -578,14 +642,12 @@ public: display.setColor(DisplayDriver::LIGHT); display.drawRect(0, 11, display.width(), 1); - // Phone number (read-only) display.setTextSize(0); display.setColor(DisplayDriver::LIGHT); display.setCursor(0, 16); display.print("Phone: "); display.print(_editPhone); - // Name input display.setCursor(0, 30); display.setColor(DisplayDriver::YELLOW); display.print("Name: "); @@ -595,7 +657,6 @@ public: display.setTextSize(1); - // Footer display.setTextSize(1); int footerY = display.height() - 12; display.drawRect(0, footerY - 2, display.width(), 1); @@ -609,17 +670,210 @@ public: return 2000; } + // ========================================================================= + // VOICE CALL RENDER VIEWS + // ========================================================================= + + // ---- Dialing (outgoing call in progress) ---- + int renderDialing(DisplayDriver& display) { + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print("Calling..."); + + renderSignalIndicator(display, display.width() - 2, 0); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, display.width(), 1); + + // Contact name / phone number centred + int centreY = display.height() / 2 - 20; + + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(_callPhone, dispName, sizeof(dispName)); + + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + uint16_t nameW = display.getTextWidth(dispName); + display.setCursor((display.width() - nameW) / 2, centreY); + display.print(dispName); + + // Show raw phone number below name (if name differs from phone) + if (strcmp(dispName, _callPhone) != 0) { + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + uint16_t phoneW = display.getTextWidth(_callPhone); + display.setCursor((display.width() - phoneW) / 2, centreY + 16); + display.print(_callPhone); + } + + // Animated dots indicator + display.setTextSize(0); + display.setColor(DisplayDriver::YELLOW); + unsigned long elapsed = millis() / 500; + int dots = (elapsed % 4); + char dotStr[5] = " "; + for (int i = 0; i < dots; i++) dotStr[i] = '.'; + dotStr[dots] = '\0'; + uint16_t dotW = display.getTextWidth("..."); + display.setCursor((display.width() - dotW) / 2, centreY + 32); + display.print(dotStr); + + // Footer + display.setTextSize(1); + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + const char* hangup = "Ent:Hangup"; + display.setCursor((display.width() - display.getTextWidth(hangup)) / 2, footerY); + display.print(hangup); + + return 500; // Fast refresh for animated dots + } + + // ---- Incoming call ---- + int renderIncomingCall(DisplayDriver& display) { + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print("Incoming Call"); + + renderSignalIndicator(display, display.width() - 2, 0); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, display.width(), 1); + + int centreY = display.height() / 2 - 20; + + char dispName[SMS_CONTACT_NAME_LEN]; + if (_callPhone[0]) { + smsContacts.displayName(_callPhone, dispName, sizeof(dispName)); + } else { + strncpy(dispName, "Unknown", sizeof(dispName)); + } + + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + uint16_t nameW = display.getTextWidth(dispName); + display.setCursor((display.width() - nameW) / 2, centreY); + display.print(dispName); + + if (_callPhone[0] && strcmp(dispName, _callPhone) != 0) { + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + uint16_t phoneW = display.getTextWidth(_callPhone); + display.setCursor((display.width() - phoneW) / 2, centreY + 16); + display.print(_callPhone); + } + + // Ringing indicator + display.setTextSize(0); + display.setColor(DisplayDriver::YELLOW); + unsigned long elapsed = millis() / 300; + const char* ring = (elapsed % 2 == 0) ? "RINGING" : ""; + uint16_t ringW = display.getTextWidth("RINGING"); + display.setCursor((display.width() - ringW) / 2, centreY + 36); + display.print(ring); + + // Footer + display.setTextSize(1); + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, footerY); + display.print("Ent:Answer"); + const char* rt = "Q:Reject"; + display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); + display.print(rt); + + return 500; // Fast refresh for flashing ring indicator + } + + // ---- In-call ---- + int renderInCall(DisplayDriver& display) { + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print("In Call"); + + renderSignalIndicator(display, display.width() - 2, 0); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, display.width(), 1); + + int centreY = 20; + + // Contact name + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(_callPhone, dispName, sizeof(dispName)); + + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + uint16_t nameW = display.getTextWidth(dispName); + display.setCursor((display.width() - nameW) / 2, centreY); + display.print(dispName); + + // Phone number (if name differs) + if (strcmp(dispName, _callPhone) != 0) { + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + uint16_t phoneW = display.getTextWidth(_callPhone); + display.setCursor((display.width() - phoneW) / 2, centreY + 16); + display.print(_callPhone); + } + + // Call duration timer + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + uint32_t durSec = 0; + if (_callConnectedMillis > 0) { + durSec = (millis() - _callConnectedMillis) / 1000; + } + char timerStr[12]; + snprintf(timerStr, sizeof(timerStr), "%02d:%02d", (int)(durSec / 60), (int)(durSec % 60)); + uint16_t timerW = display.getTextWidth(timerStr); + display.setCursor((display.width() - timerW) / 2, centreY + 40); + display.print(timerStr); + + // Volume indicator + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + char volStr[16]; + snprintf(volStr, sizeof(volStr), "Vol: %d/5", _callVolume); + display.setCursor(0, centreY + 60); + display.print(volStr); + + // Footer + display.setTextSize(1); + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, footerY); + display.print("Ent:Hang"); + const char* mid = "W/S:Vol"; + display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY); + display.print(mid); + const char* rt = "0-9:DTMF"; + display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); + display.print(rt); + + return 1000; // 1s refresh for call timer + } + // ========================================================================= // INPUT HANDLING // ========================================================================= bool handleInput(char c) override { switch (_view) { - case INBOX: return handleInboxInput(c); - case CONVERSATION: return handleConversationInput(c); - case COMPOSE: return handleComposeInput(c); - case CONTACTS: return handleContactsInput(c); - case EDIT_CONTACT: return handleEditContactInput(c); + case INBOX: return handleInboxInput(c); + case CONVERSATION: return handleConversationInput(c); + case COMPOSE: return handleComposeInput(c); + case CONTACTS: return handleContactsInput(c); + case EDIT_CONTACT: return handleEditContactInput(c); + case DIALING: return handleDialingInput(c); + case INCOMING_CALL: return handleIncomingCallInput(c); + case IN_CALL: return handleInCallInput(c); } return false; } @@ -687,6 +941,12 @@ public: _view = COMPOSE; return true; + case 'f': case 'F': // Call this contact + if (modemManager.isReady() && _activePhone[0]) { + startCall(_activePhone); + } + return true; + case 'a': case 'A': { // Add/edit contact for this number strncpy(_editPhone, _activePhone, SMS_PHONE_LEN - 1); _editPhone[SMS_PHONE_LEN - 1] = '\0'; @@ -828,6 +1088,13 @@ public: } return true; + case 'f': case 'F': // Call selected contact + if (cnt > 0 && _contactsCursor < cnt && modemManager.isReady()) { + const SMSContact& ct = smsContacts.get(_contactsCursor); + startCall(ct.phone); + } + return true; + case 'q': case 'Q': // Back to inbox refreshInbox(); _view = INBOX; @@ -879,6 +1146,70 @@ public: return true; } } + + // ========================================================================= + // VOICE CALL INPUT HANDLERS + // ========================================================================= + + // ---- Dialing input ---- + bool handleDialingInput(char c) { + switch (c) { + case '\r': // Enter — hangup / cancel dial + case 'q': case 'Q': + modemManager.hangupCall(); + return true; + + default: + return true; // Absorb all keys during dialing + } + } + + // ---- Incoming call input ---- + bool handleIncomingCallInput(char c) { + switch (c) { + case '\r': // Enter — answer call + modemManager.answerCall(); + return true; + + case 'q': case 'Q': // Reject call + modemManager.hangupCall(); + return true; + + default: + return true; // Absorb all keys + } + } + + // ---- In-call input ---- + bool handleInCallInput(char c) { + switch (c) { + case '\r': // Enter — hangup + case 'q': case 'Q': + modemManager.hangupCall(); + return true; + + case 'w': case 'W': // Volume up + if (_callVolume < 5) { + _callVolume++; + modemManager.setCallVolume(_callVolume); + } + return true; + + case 's': case 'S': // Volume down + if (_callVolume > 0) { + _callVolume--; + modemManager.setCallVolume(_callVolume); + } + return true; + + default: + // 0-9, *, # — send as DTMF + if ((c >= '0' && c <= '9') || c == '*' || c == '#') { + modemManager.sendDTMF(c); + } + return true; // Absorb all keys during call + } + } }; #endif // SMS_SCREEN_H diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 3c14aa92..dd975d5a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -336,7 +336,7 @@ public: display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings "); y += 10; #ifdef HAS_4G_MODEM - display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] SMS "); + display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] Phone "); #elif defined(MECK_AUDIO_VARIANT) display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks"); #else diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index 838b1932..21bb9a94 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -80,7 +80,7 @@ build_flags = -D PIN_DISPLAY_BL=45 -D PIN_USER_BTN=0 -D CST328_PIN_RST=38 - -D FIRMWARE_VERSION='"Meck v0.9.2A"' + -D FIRMWARE_VERSION='"Meck v0.9.3A"' -D ARDUINO_LOOP_STACK_SIZE=32768 build_src_filter = ${esp32_base.build_src_filter} +<../variants/LilyGo_TDeck_Pro> @@ -155,7 +155,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D HAS_4G_MODEM=1 ; -D MECK_WEB_READER=1 - -D FIRMWARE_VERSION='"Meck v0.9.2-4G"' + -D FIRMWARE_VERSION='"Meck v0.9.3-4G"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + +