mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f171a1fc | ||
|
|
099d9a5b6c | ||
|
|
fce999347f |
31
README.md
31
README.md
@@ -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.
|
||||
@@ -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.
|
||||
> **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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user