3 Commits

10 changed files with 1213 additions and 146 deletions

View File

@@ -22,10 +22,11 @@ This fork was created specifically to focus on enabling BLE companion firmware f
- [MeshCore Flasher](#meshcore-flasher)
- [MeshCore Clients](#meshcore-clients)
- [Hardware Compatibility](#-hardware-compatibility)
- [License](#-license)
- [Contributing](#contributing)
- [Road-Map / To-Do](#road-map--to-do)
- [Get Support](#-get-support)
- [License](#-license)
- [Third-Party Libraries](#third-party-libraries)
## T-Deck Pro Keyboard Controls
@@ -263,10 +264,6 @@ The companion firmware can be connected to via BLE.
MeshCore is designed for devices listed in the [MeshCore Flasher](https://flasher.meshcore.co.uk)
## 📜 License
MeshCore is open-source software released under the MIT License. You are free to use, modify, and distribute it for personal and commercial projects.
## Contributing
Please submit PR's using 'dev' as the base branch!
@@ -297,4 +294,26 @@ There are a number of fairly major features in the pipeline, with no particular
## 📞 Get Support
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
## 📜 License
The upstream [MeshCore](https://github.com/meshcore-dev/MeshCore) library is released under the **MIT License** (Copyright © 2025 Scott Powell / rippleradios.com). Meck-specific code (UI screens, display helpers, device integration) is also provided under the MIT License.
However, this firmware links against libraries with different license terms. Because two dependencies use the **GPL-3.0** copyleft license, the combined firmware binary is effectively subject to GPL-3.0 obligations when distributed. Please review the individual licenses below if you intend to redistribute or modify this firmware.
### Third-Party Libraries
| Library | License | Author / Source |
|---------|---------|-----------------|
| [MeshCore](https://github.com/meshcore-dev/MeshCore) | MIT | Scott Powell / rippleradios.com |
| [GxEPD2](https://github.com/ZinggJM/GxEPD2) | GPL-3.0 | Jean-Marc Zingg |
| [ESP32-audioI2S](https://github.com/schreibfaul1/ESP32-audioI2S) | GPL-3.0 | schreibfaul1 (Wolle) |
| [Adafruit GFX Library](https://github.com/adafruit/Adafruit-GFX-Library) | BSD | Adafruit |
| [RadioLib](https://github.com/jgromes/RadioLib) | MIT | Jan Gromeš |
| [JPEGDEC](https://github.com/bitbank2/JPEGDEC) | Apache-2.0 | Larry Bank (bitbank2) |
| [CRC32](https://github.com/bakercp/CRC32) | MIT | Christopher Baker |
| [base64](https://github.com/Densaugeo/base64_arduino) | MIT | densaugeo |
| [Arduino Crypto](https://github.com/rweather/arduinolibs) | MIT | Rhys Weatherley |
Full license texts for each dependency are available in their respective repositories linked above.

View File

@@ -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 (05) |
| In Call | 09, *, # | 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 (05). The
number keys **09**, **\***, 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.
> **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.

View File

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

View File

@@ -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");

View File

@@ -14,7 +14,7 @@
// Maximum messages to store in history
#define CHANNEL_MSG_HISTORY_SIZE 300
#define CHANNEL_MSG_TEXT_LEN 160
#define MSG_PATH_MAX 8 // Max repeater hops stored per message
#define MSG_PATH_MAX 20 // Max repeater hops stored per message
#ifndef MAX_GROUP_CHANNELS
#define MAX_GROUP_CHANNELS 20
@@ -24,7 +24,7 @@
// On-disk format for message persistence (SD card)
// ---------------------------------------------------------------------------
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
#define MSG_FILE_VERSION 2
#define MSG_FILE_VERSION 3
#define MSG_FILE_PATH "/meshcore/messages.bin"
struct __attribute__((packed)) MsgFileHeader {
@@ -44,7 +44,7 @@ struct __attribute__((packed)) MsgFileRecord {
uint8_t reserved;
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key)
char text[CHANNEL_MSG_TEXT_LEN];
// 176 bytes total
// 188 bytes total
};
class UITask; // Forward declaration
@@ -74,11 +74,12 @@ private:
uint8_t _viewChannelIdx; // Which channel we're currently viewing
bool _sdReady; // SD card is available for persistence
bool _showPathOverlay; // Show path detail overlay for last received msg
int _pathOverlayScroll; // Scroll offset for hop list in path overlay
public:
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false) {
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathOverlayScroll(0) {
// Initialize all messages as invalid
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
_messages[i].valid = false;
@@ -118,6 +119,7 @@ public:
// Reset scroll to show newest message
_scrollPos = 0;
_showPathOverlay = false; // Dismiss overlay on new message
_pathOverlayScroll = 0;
// Persist to SD card
saveToSD();
@@ -137,7 +139,7 @@ public:
int getMessageCount() const { return _msgCount; }
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; _showPathOverlay = false; }
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; _showPathOverlay = false; _pathOverlayScroll = 0; }
bool isShowingPathOverlay() const { return _showPathOverlay; }
// Find the newest RECEIVED message for the current channel
@@ -160,7 +162,7 @@ public:
// -----------------------------------------------------------------------
// Save the entire message buffer to SD card.
// File: /meshcore/messages.bin (~50 KB for 300 messages)
// File: /meshcore/messages.bin (~56 KB for 300 messages)
void saveToSD() {
#if defined(HAS_SDCARD) && defined(ESP32)
if (!_sdReady) return;
@@ -360,12 +362,25 @@ public:
}
y += lineH + 2;
// Show each hop resolved against contacts
// Show each hop resolved against contacts (scrollable)
if (plen > 0 && plen != 0xFF) {
int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX;
int maxY = display.height() - 26;
int footerHeight = 14;
int scrollBarW = 4;
int maxY = display.height() - footerHeight;
for (int h = 0; h < displayHops && y + lineH <= maxY; h++) {
// Calculate how many hops fit in the visible area
int hopsAreaTop = y;
int visibleHops = (maxY - y) / lineH;
if (visibleHops < 1) visibleHops = 1;
// Clamp scroll position
int maxScroll = displayHops > visibleHops ? displayHops - visibleHops : 0;
if (_pathOverlayScroll > maxScroll) _pathOverlayScroll = maxScroll;
int startHop = _pathOverlayScroll;
for (int h = startHop; h < displayHops && y + lineH <= maxY; h++) {
uint8_t hopHash = msg->path[h];
display.setCursor(0, y);
display.setColor(DisplayDriver::LIGHT);
@@ -408,6 +423,24 @@ public:
}
y += lineH;
}
// --- Scroll bar for hop list ---
if (displayHops > visibleHops) {
int sbX = display.width() - scrollBarW;
int sbTop = hopsAreaTop;
int sbHeight = maxY - hopsAreaTop;
// Draw track outline
display.setColor(DisplayDriver::LIGHT);
display.drawRect(sbX, sbTop, scrollBarW, sbHeight);
// Draw proportional thumb
int thumbH = (visibleHops * sbHeight) / displayHops;
if (thumbH < 4) thumbH = 4;
int thumbY = sbTop + (_pathOverlayScroll * (sbHeight - thumbH)) / maxScroll;
for (int ty = thumbY + 1; ty < thumbY + thumbH - 1; ty++)
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
}
}
}
@@ -417,7 +450,7 @@ public:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
display.print("Q:Back");
display.print("Q:Back W/S:Scroll");
#if AUTO_OFF_MILLIS == 0
return 5000;
@@ -676,6 +709,18 @@ public:
_showPathOverlay = false;
return true;
}
// W - scroll up in hop list
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_pathOverlayScroll > 0) {
_pathOverlayScroll--;
return true;
}
}
// S - scroll down in hop list
if (c == 's' || c == 'S' || c == 0xF1) {
_pathOverlayScroll++; // Clamped during render
return true;
}
return true; // Consume all keys while overlay is up
}
@@ -685,6 +730,7 @@ public:
if (c == 'v' || c == 'V') {
if (getNewestReceivedMsg() != nullptr) {
_showPathOverlay = true;
_pathOverlayScroll = 0;
return true;
}
return false; // No received messages to show

View File

@@ -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",<idx> — 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",<index>
// 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: <duration>"
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<number>; — 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);

View File

@@ -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();

View File

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

View File

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

View File

@@ -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}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>