mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f171a1fc | ||
|
|
099d9a5b6c | ||
|
|
fce999347f | ||
|
|
7f8f70655d | ||
|
|
6e417d1f3e | ||
|
|
38eb4b854b | ||
|
|
e64011112e | ||
|
|
97f9fc9eee | ||
|
|
4a1fe3b190 | ||
|
|
2024dc2a1b | ||
|
|
27b8ea603f |
45
README.md
45
README.md
@@ -1,8 +1,6 @@
|
||||
## Meshcore + Fork = Meck
|
||||
This fork was created specifically to focus on enabling BLE companion firmware for the LilyGo T-Deck Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
|
||||
|
||||
⭐ ***Please note as of 1 Feb 2026, the T-Deck Pro repeater & usb firmware has not been finalised nor confirmed as functioning.*** ⭐
|
||||
|
||||
### Contents
|
||||
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
|
||||
- [Navigation (Home Screen)](#navigation-home-screen)
|
||||
@@ -24,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
|
||||
|
||||
@@ -251,7 +250,7 @@ Download a copy of the Meck firmware bin from https://github.com/pelgraine/Meck/
|
||||
|
||||
**Companion Firmware**
|
||||
|
||||
The companion firmware can be connected to via BLE. USB is planned for a future update.
|
||||
The companion firmware can be connected to via BLE.
|
||||
|
||||
> **Note:** On the T-Deck Pro, BLE is disabled by default at boot. Navigate to the Bluetooth home page and press Enter to enable BLE before connecting with a companion app.
|
||||
|
||||
@@ -265,10 +264,6 @@ The companion firmware can be connected to via BLE. USB is planned for a future
|
||||
|
||||
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!
|
||||
@@ -290,11 +285,35 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [X] Standalone repeater admin access for Companion BLE firmware
|
||||
- [X] GPS time sync with on-device timezone setting
|
||||
- [X] Settings screen with radio presets, channel management, and first-boot onboarding
|
||||
- [ ] Companion radio: USB
|
||||
- [ ] Simple Repeater firmware for the T-Deck Pro
|
||||
- [ ] Get pin 45 with the screen backlight functioning for the T-Deck Pro v1.1
|
||||
- [ ] Canned messages function for Companion BLE firmware
|
||||
- [ ] Fix M4B rendering to enable chaptered audiobook playback
|
||||
- [ ] Expand SMS app to enable phone calls
|
||||
- [ ] Better JPEG and PNG decoding
|
||||
- [ ] Improve EPUB rendering and EPUB format handling
|
||||
- [ ] Map support with GPS
|
||||
- [ ] Basic web reader app for text-centric websites
|
||||
|
||||
## 📞 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.
|
||||
57
Web App Guide.md
Normal file
57
Web App Guide.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Web Reader - Integration Summary
|
||||
|
||||
### Conditional Compilation
|
||||
All web reader code is wrapped in `#ifdef MECK_WEB_READER` guards. The flag is set:
|
||||
- **meck_audio_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi available via BLE radio stack
|
||||
- **meck_4g_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi now, PPP via A7682E in future
|
||||
- **meck_audio_standalone**: No — excluded to preserve zero-radio-power design
|
||||
|
||||
### 4G Modem / PPP Support
|
||||
The web reader uses `isNetworkAvailable()` which checks both WiFi and (future) PPP connectivity. The `fetchPage()` method uses ESP32's standard `HTTPClient` which routes through whatever network interface is active — WiFi or PPP.
|
||||
|
||||
When PPP support is added to the 4G modem driver, the web reader will work over cellular automatically without code changes. The `isNetworkAvailable()` method has a `TODO` placeholder for the PPP status check.
|
||||
|
||||
---
|
||||
|
||||
## Key Bindings
|
||||
|
||||
### From Home Screen
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `b` | Open web reader |
|
||||
|
||||
### Web Reader - Home View
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `w` / `s` | Navigate up/down in bookmarks/history |
|
||||
| `Enter` | Select URL bar or bookmark/history item |
|
||||
| Type | Enter URL (when URL bar is active) |
|
||||
| `q` | Exit to firmware home |
|
||||
|
||||
### Web Reader - Reading View
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `w` / `a` | Previous page |
|
||||
| `s` / `d` / `Space` | Next page |
|
||||
| `l` or `Enter` | Enter link selection (type link number) |
|
||||
| `g` | Go to new URL (return to web reader home) |
|
||||
| `k` | Bookmark current page |
|
||||
| `q` | Back to web reader home |
|
||||
|
||||
### Web Reader - WiFi Setup
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `w` / `s` | Navigate SSID list |
|
||||
| `Enter` | Select SSID / submit password / retry |
|
||||
| Type | Enter WiFi password |
|
||||
| `q` | Back |
|
||||
|
||||
---
|
||||
|
||||
## SD Card Structure
|
||||
```
|
||||
/web/
|
||||
wifi.cfg - Saved WiFi credentials (auto-reconnect)
|
||||
bookmarks.txt - One URL per line
|
||||
history.txt - Recent URLs, newest first
|
||||
```
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "20 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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
|
||||
#include <Mesh.h>
|
||||
#include "MyMesh.h"
|
||||
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
|
||||
@@ -16,6 +17,9 @@
|
||||
#include "ChannelScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
|
||||
|
||||
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
|
||||
@@ -33,6 +37,11 @@
|
||||
static bool composeDM = false;
|
||||
static int composeDMContactIdx = -1;
|
||||
static char composeDMName[32];
|
||||
#ifdef MECK_WEB_READER
|
||||
static unsigned long lastWebReaderRefresh = 0;
|
||||
static bool webReaderNeedsRefresh = false;
|
||||
static bool webReaderTextEntry = false; // True when URL/password entry active
|
||||
#endif
|
||||
// AGC reset - periodically re-assert RX boosted gain to prevent sensitivity drift
|
||||
#define AGC_RESET_INTERVAL_MS 500
|
||||
static unsigned long lastAGCReset = 0;
|
||||
@@ -434,10 +443,38 @@ void setup() {
|
||||
// ---------------------------------------------------------------------------
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
|
||||
{
|
||||
// Deselect ALL SPI devices before SD init to prevent bus contention.
|
||||
// E-ink, LoRa, and SD share the same SPI bus (SCK=36, MOSI=33, MISO=47).
|
||||
// If LoRa CS is still asserted from board/radio init, it responds on the
|
||||
// shared MISO line and corrupts SD card replies (CMD0 fails intermittently).
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
if (SD.begin(SDCARD_CS, displaySpi, 4000000)) {
|
||||
pinMode(PIN_EINK_CS, OUTPUT);
|
||||
digitalWrite(PIN_EINK_CS, HIGH);
|
||||
|
||||
pinMode(LORA_CS, OUTPUT);
|
||||
digitalWrite(LORA_CS, HIGH);
|
||||
|
||||
// SD cards need 74+ SPI clock cycles after power stabilization before
|
||||
// accepting CMD0. A brief delay avoids race conditions on cold boot
|
||||
// or with slow-starting cards.
|
||||
delay(100);
|
||||
|
||||
// Retry loop — some SD cards are slow to initialise, especially on
|
||||
// cold boot or marginal USB power. Three attempts with increasing
|
||||
// settle time covers the vast majority of transient failures.
|
||||
bool mounted = false;
|
||||
for (int attempt = 0; attempt < 3 && !mounted; attempt++) {
|
||||
if (attempt > 0) {
|
||||
digitalWrite(SDCARD_CS, HIGH); // Ensure CS released between retries
|
||||
delay(250);
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card retry %d/3", attempt + 1);
|
||||
}
|
||||
mounted = SD.begin(SDCARD_CS, displaySpi, 4000000);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
sdCardReady = true;
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card initialized (early)");
|
||||
|
||||
@@ -446,7 +483,7 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - Settings restored from SD backup");
|
||||
}
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card not available");
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card not available after 3 attempts");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -677,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
|
||||
@@ -691,10 +780,21 @@ void loop() {
|
||||
#else
|
||||
bool smsSuppressLoop = false;
|
||||
#endif
|
||||
if (!composeMode && !notesSuppressLoop && !smsSuppressLoop) {
|
||||
#ifdef MECK_WEB_READER
|
||||
// Safety: clear web reader text entry flag if we're no longer on the web reader
|
||||
if (webReaderTextEntry && !ui_task.isOnWebReader()) {
|
||||
webReaderTextEntry = false;
|
||||
webReaderNeedsRefresh = false;
|
||||
}
|
||||
#endif
|
||||
if (!composeMode && !notesSuppressLoop && !smsSuppressLoop
|
||||
#ifdef MECK_WEB_READER
|
||||
&& !webReaderTextEntry
|
||||
#endif
|
||||
) {
|
||||
ui_task.loop();
|
||||
} else {
|
||||
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
|
||||
// Handle debounced screen refresh (compose, emoji picker, notes, or web reader text entry)
|
||||
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
|
||||
if (composeMode) {
|
||||
if (emojiPickerMode) {
|
||||
@@ -708,7 +808,7 @@ void loop() {
|
||||
ui_task.loop();
|
||||
} else if (smsSuppressLoop) {
|
||||
// SMS compose: render directly to display, same as mesh compose
|
||||
#ifdef DISPLAY_CLASS
|
||||
#if defined(DISPLAY_CLASS) && defined(HAS_4G_MODEM)
|
||||
display.startFrame();
|
||||
((SMSScreen*)ui_task.getSMSScreen())->render(display);
|
||||
display.endFrame();
|
||||
@@ -717,6 +817,28 @@ void loop() {
|
||||
lastComposeRefresh = millis();
|
||||
composeNeedsRefresh = false;
|
||||
}
|
||||
#ifdef MECK_WEB_READER
|
||||
if (webReaderNeedsRefresh && (millis() - lastWebReaderRefresh) >= COMPOSE_REFRESH_INTERVAL) {
|
||||
WebReaderScreen* wr2 = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
if (wr2) {
|
||||
display.startFrame();
|
||||
wr2->render(display);
|
||||
display.endFrame();
|
||||
}
|
||||
lastWebReaderRefresh = millis();
|
||||
webReaderNeedsRefresh = false;
|
||||
}
|
||||
// Password reveal expiry: re-render to mask character after 800ms
|
||||
if (webReaderTextEntry && !webReaderNeedsRefresh) {
|
||||
WebReaderScreen* wr3 = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
if (wr3 && wr3->needsRevealRefresh() && (millis() - lastWebReaderRefresh) >= 850) {
|
||||
display.startFrame();
|
||||
wr3->render(display);
|
||||
display.endFrame();
|
||||
lastWebReaderRefresh = millis();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Track reader/notes/audiobook mode state for key routing
|
||||
readerMode = ui_task.isOnTextReader();
|
||||
@@ -1185,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");
|
||||
@@ -1214,6 +1344,51 @@ void handleKeyboardInput() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// *** WEB READER TEXT INPUT MODE ***
|
||||
// Match compose mode pattern: key handler sets a flag and returns instantly.
|
||||
// Main loop renders with 100ms debounce (same as COMPOSE_REFRESH_INTERVAL).
|
||||
// This way the key handler never blocks for 648ms during a render.
|
||||
#ifdef MECK_WEB_READER
|
||||
if (ui_task.isOnWebReader()) {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
bool urlEdit = wr ? wr->isUrlEditing() : false;
|
||||
bool passEdit = wr ? wr->isPasswordEntry() : false;
|
||||
bool formEdit = wr ? wr->isFormFilling() : false;
|
||||
if (wr && (urlEdit || passEdit || formEdit)) {
|
||||
webReaderTextEntry = true; // Suppress ui_task.loop() in main loop
|
||||
wr->handleInput(key); // Updates buffer instantly, no render
|
||||
|
||||
// Check if text entry ended (submitted, cancelled, etc.)
|
||||
if (!wr->isUrlEditing() && !wr->isPasswordEntry() && !wr->isFormFilling()) {
|
||||
// Text entry ended
|
||||
webReaderTextEntry = false;
|
||||
webReaderNeedsRefresh = false;
|
||||
// fetchPage()/submitForm() handle their own rendering, or mode changed —
|
||||
// let ui_task.loop() resume on next iteration
|
||||
} else {
|
||||
// Still typing — request debounced refresh
|
||||
webReaderNeedsRefresh = true;
|
||||
lastWebReaderRefresh = millis();
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// Not in text entry — clear flag so ui_task.loop() resumes
|
||||
webReaderTextEntry = false;
|
||||
|
||||
// Q from HOME mode exits the web reader entirely (like text reader)
|
||||
if ((key == 'q' || key == 'Q') && wr && wr->isHome() && !wr->isUrlEditing()) {
|
||||
Serial.println("Exiting web reader");
|
||||
ui_task.gotoHomeScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
// Route keys through normal UITask for navigation/scrolling
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Normal mode - not composing
|
||||
switch (key) {
|
||||
case 'c':
|
||||
@@ -1258,6 +1433,50 @@ void handleKeyboardInput() {
|
||||
ui_task.gotoSMSScreen();
|
||||
break;
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
case 'b':
|
||||
// Open web reader (browser)
|
||||
Serial.println("Opening web reader");
|
||||
{
|
||||
static bool webReaderWifiReady = false;
|
||||
if (!webReaderWifiReady) {
|
||||
// WiFi needs ~40KB contiguous heap. The BLE controller holds ~30KB,
|
||||
// leaving only ~30KB largest block. We MUST release BLE memory first.
|
||||
//
|
||||
// This disables BLE for the duration of the session.
|
||||
// BLE comes back on reboot.
|
||||
Serial.printf("WebReader: heap BEFORE BT release: free=%d, largest=%d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
|
||||
// 1) Stop BLE controller (disable + deinit)
|
||||
btStop();
|
||||
delay(50);
|
||||
|
||||
// 2) Release the BT controller's reserved memory region back to heap
|
||||
esp_bt_controller_mem_release(ESP_BT_MODE_BTDM);
|
||||
delay(50);
|
||||
|
||||
Serial.printf("WebReader: heap AFTER BT release: free=%d, largest=%d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
|
||||
// 3) Now init WiFi while we have maximum contiguous heap
|
||||
if (WiFi.mode(WIFI_STA)) {
|
||||
Serial.println("WebReader: WiFi STA init OK");
|
||||
webReaderWifiReady = true;
|
||||
} else {
|
||||
Serial.println("WebReader: WiFi STA init FAILED even after BT release");
|
||||
// Clean up partial WiFi init to avoid memory leak
|
||||
WiFi.mode(WIFI_OFF);
|
||||
}
|
||||
|
||||
Serial.printf("WebReader: heap after WiFi init: free=%d, largest=%d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
}
|
||||
}
|
||||
ui_task.gotoWebReader();
|
||||
break;
|
||||
#endif
|
||||
|
||||
case 'n':
|
||||
// Open notes
|
||||
@@ -1274,9 +1493,13 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case 's':
|
||||
// Open settings (from home), or navigate down on channel/contacts/admin
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
|
||||
ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling
|
||||
// Open settings (from home), or navigate down on channel/contacts/admin/web
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|
||||
#ifdef MECK_WEB_READER
|
||||
|| ui_task.isOnWebReader()
|
||||
#endif
|
||||
) {
|
||||
ui_task.injectKey('s'); // Pass directly for scrolling
|
||||
} else {
|
||||
Serial.println("Opening settings");
|
||||
ui_task.gotoSettingsScreen();
|
||||
@@ -1285,8 +1508,12 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'w':
|
||||
// Navigate up/previous (scroll on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
|
||||
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|
||||
#ifdef MECK_WEB_READER
|
||||
|| ui_task.isOnWebReader()
|
||||
#endif
|
||||
) {
|
||||
ui_task.injectKey('w'); // Pass directly for scrolling
|
||||
} else {
|
||||
Serial.println("Nav: Previous");
|
||||
ui_task.injectKey(0xF2); // KEY_PREV
|
||||
@@ -1371,6 +1598,17 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
#ifdef MECK_WEB_READER
|
||||
// If web reader is in reading/link/wifi mode, inject q for internal navigation
|
||||
// (reading→home, wifi→home). Only exit to firmware home if already on web home.
|
||||
if (ui_task.isOnWebReader()) {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
if (wr && !wr->isHome()) {
|
||||
ui_task.injectKey('q');
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Go back to home screen (admin mode handled above)
|
||||
Serial.println("Nav: Back to home");
|
||||
ui_task.gotoHomeScreen();
|
||||
@@ -1395,6 +1633,13 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
default:
|
||||
#ifdef MECK_WEB_READER
|
||||
// Pass unhandled keys to web reader (l=link, g=go, k=bookmark, 0-9=link#)
|
||||
if (ui_task.isOnWebReader()) {
|
||||
ui_task.injectKey(key);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,6 +118,7 @@ class HomeScreen : public UIScreen {
|
||||
NodePrefs* _node_prefs;
|
||||
uint8_t _page;
|
||||
bool _shutdown_init;
|
||||
unsigned long _shutdown_at; // earliest time to proceed with shutdown (after e-ink refresh)
|
||||
bool _editing_utc;
|
||||
int8_t _saved_utc_offset; // for cancel/undo
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
@@ -221,7 +222,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
public:
|
||||
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
||||
_shutdown_init(false), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||||
_shutdown_init(false), _shutdown_at(0), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||||
|
||||
bool isEditingUTC() const { return _editing_utc; }
|
||||
void cancelEditUTC() {
|
||||
@@ -232,7 +233,7 @@ public:
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
|
||||
if (_shutdown_init && millis() >= _shutdown_at && !_task->isButtonPressed()) {
|
||||
_task->shutdown();
|
||||
}
|
||||
}
|
||||
@@ -335,11 +336,15 @@ 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
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#endif
|
||||
y += 14;
|
||||
|
||||
@@ -397,8 +402,19 @@ public:
|
||||
display.drawXbm((display.width() - 32) / 2, 18,
|
||||
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
|
||||
32, 32);
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 53, "< Connected >");
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
display.drawTextCentered(display.width() / 2, 53, tmp);
|
||||
}
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
|
||||
display.drawTextCentered(display.width() / 2, 72, "toggle: " PRESS_LABEL);
|
||||
#endif
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -631,6 +647,13 @@ public:
|
||||
display.drawTextLeftAlign(0, y, "remaining cap");
|
||||
sprintf(buf, "%d mAh", remCap);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Battery temperature
|
||||
int16_t battTemp = board.getBattTemperature();
|
||||
display.drawTextLeftAlign(0, y, "temperature");
|
||||
sprintf(buf, "%d.%d C", battTemp / 10, abs(battTemp % 10));
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
#endif
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -729,7 +752,8 @@ public:
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
|
||||
_shutdown_init = true; // need to wait for button to be released
|
||||
_shutdown_init = true;
|
||||
_shutdown_at = millis() + 900; // allow e-ink refresh (644ms) before shutdown
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -1038,8 +1062,31 @@ void UITask::shutdown(bool restart){
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
_display->turnOff();
|
||||
// Disable BLE if active
|
||||
if (_serial != NULL && _serial->isEnabled()) {
|
||||
_serial->disable();
|
||||
}
|
||||
|
||||
// Disable WiFi if active
|
||||
#ifdef WIFI_SSID
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
#endif
|
||||
|
||||
// Disable GPS if active
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
{
|
||||
extern GPSDutyCycle gpsDuty;
|
||||
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
gpsDuty.disable();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Power off LoRa radio, display, and board
|
||||
radio_driver.powerOff();
|
||||
_display->turnOff();
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
@@ -1446,6 +1493,28 @@ void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
void UITask::gotoWebReader() {
|
||||
// Lazy-initialize on first use (same pattern as audiobook player)
|
||||
if (web_reader == nullptr) {
|
||||
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
web_reader = new WebReaderScreen(this);
|
||||
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||||
}
|
||||
WebReaderScreen* wr = (WebReaderScreen*)web_reader;
|
||||
if (_display != NULL) {
|
||||
wr->enter(*_display);
|
||||
}
|
||||
setCurrScreen(web_reader);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
#include "SMSScreen.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
SensorManager* _sensors;
|
||||
@@ -66,6 +70,9 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
|
||||
#endif
|
||||
UIScreen* repeater_admin; // Repeater admin screen
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
||||
#endif
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
@@ -97,6 +104,9 @@ public:
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
#ifdef MECK_WEB_READER
|
||||
void gotoWebReader(); // Navigate to web reader (browser)
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
void gotoSMSScreen();
|
||||
bool isOnSMSScreen() const { return curr == sms_screen; }
|
||||
@@ -114,6 +124,9 @@ public:
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
#ifdef MECK_WEB_READER
|
||||
bool isOnWebReader() const { return curr == web_reader; }
|
||||
#endif
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// Check if audio is playing/paused in the background (for status indicators)
|
||||
@@ -150,6 +163,9 @@ public:
|
||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* getWebReaderScreen() const { return web_reader; }
|
||||
#endif
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
|
||||
3139
examples/companion_radio/ui-new/Webreaderscreen.h
Normal file
3139
examples/companion_radio/ui-new/Webreaderscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
16
examples/companion_radio/ui-new/webreaderdeps.cpp
Normal file
16
examples/companion_radio/ui-new/webreaderdeps.cpp
Normal file
@@ -0,0 +1,16 @@
|
||||
// WebReaderDeps.cpp
|
||||
// -----------------------------------------------------------------------
|
||||
// PlatformIO library dependency finder (LDF) hint file.
|
||||
//
|
||||
// The web reader's WiFi/HTTP includes live in WebReaderScreen.h (header-only),
|
||||
// but PlatformIO's LDF can't always trace framework library dependencies
|
||||
// through conditional #include chains in headers. This .cpp file exposes
|
||||
// the includes at the top level where the scanner reliably finds them.
|
||||
//
|
||||
// No actual code here — just #include directives for the dependency finder.
|
||||
// -----------------------------------------------------------------------
|
||||
#ifdef MECK_WEB_READER
|
||||
#include <WiFi.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#endif
|
||||
@@ -27,6 +27,7 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
|
||||
-D LORA_FREQ=869.525
|
||||
-D LORA_BW=250
|
||||
-D LORA_SF=11
|
||||
-D ENABLE_ADVERT_ON_BOOT=1
|
||||
-D ENABLE_PRIVATE_KEY_IMPORT=1 ; NOTE: comment these out for more secure firmware
|
||||
-D ENABLE_PRIVATE_KEY_EXPORT=1
|
||||
-D RADIOLIB_EXCLUDE_CC1101=1
|
||||
@@ -58,6 +59,7 @@ platform = platformio/espressif32@6.11.0
|
||||
monitor_filters = esp32_exception_decoder
|
||||
extra_scripts = merge-bin.py
|
||||
build_flags = ${arduino_base.build_flags}
|
||||
-D ESP32_PLATFORM
|
||||
; -D ESP32_CPU_FREQ=80 ; change it to your need
|
||||
build_src_filter = ${arduino_base.build_src_filter}
|
||||
|
||||
@@ -67,10 +69,10 @@ lib_deps =
|
||||
file://arch/esp32/AsyncElegantOTA
|
||||
|
||||
; esp32c6 uses arduino framework 3.x
|
||||
; WARNING: experimental. pioarduino on esp32c6 needs work - it's not considered stable and has issues.
|
||||
; WARNING: experimental. May not work as stable as other platforms.
|
||||
[esp32c6_base]
|
||||
extends = esp32_base
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.12/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zip
|
||||
|
||||
; ----------------- NRF52 ---------------------
|
||||
|
||||
@@ -79,7 +81,7 @@ extends = arduino_base
|
||||
platform = nordicnrf52
|
||||
platform_packages =
|
||||
framework-arduinoadafruitnrf52 @ 1.10700.0
|
||||
extra_scripts =
|
||||
extra_scripts =
|
||||
create-uf2.py
|
||||
arch/nrf52/extra_scripts/patch_bluefruit.py
|
||||
build_flags = ${arduino_base.build_flags}
|
||||
@@ -147,4 +149,4 @@ lib_deps =
|
||||
adafruit/Adafruit_VL53L0X @ ^1.2.4
|
||||
stevemarple/MicroNMEA @ ^2.0.6
|
||||
adafruit/Adafruit BME680 Library @ ^2.0.4
|
||||
adafruit/Adafruit BMP085 Library @ ^1.2.4
|
||||
adafruit/Adafruit BMP085 Library @ ^1.2.4
|
||||
@@ -46,10 +46,9 @@ void TDeckBoard::begin() {
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
|
||||
#endif
|
||||
|
||||
// 4G Modem power management
|
||||
// On 4G builds, ModemManager::begin() handles power-on — don't kill it here.
|
||||
// On non-4G builds, disable modem power to save current and turn off red LED.
|
||||
#if defined(MODEM_POWER_EN) && !defined(HAS_4G_MODEM)
|
||||
// Disable 4G modem power (only present on 4G version, not audio version)
|
||||
// This turns off the red status LED on the modem module
|
||||
#ifdef MODEM_POWER_EN
|
||||
pinMode(MODEM_POWER_EN, OUTPUT);
|
||||
digitalWrite(MODEM_POWER_EN, LOW); // Cut power to modem
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - 4G modem power disabled");
|
||||
@@ -354,4 +353,14 @@ uint16_t TDeckBoard::getDesignCapacity() {
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t TDeckBoard::getBattTemperature() {
|
||||
#if HAS_BQ27220
|
||||
uint16_t raw = bq27220_read16(BQ27220_REG_TEMPERATURE);
|
||||
// BQ27220 returns 0.1°K, convert to 0.1°C (273.1K = 0°C)
|
||||
return (int16_t)(raw - 2731);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
// BQ27220 Fuel Gauge Registers
|
||||
#define BQ27220_REG_TEMPERATURE 0x06 // Temperature (0.1°K)
|
||||
#define BQ27220_REG_VOLTAGE 0x08
|
||||
#define BQ27220_REG_CURRENT 0x0C // Instantaneous current (mA, signed)
|
||||
#define BQ27220_REG_SOC 0x2C
|
||||
@@ -82,6 +83,9 @@ public:
|
||||
// Read design capacity in mAh (the configured battery size)
|
||||
uint16_t getDesignCapacity();
|
||||
|
||||
// Read battery temperature in 0.1°C units (e.g., 256 = 25.6°C)
|
||||
int16_t getBattTemperature();
|
||||
|
||||
// Configure BQ27220 design capacity (checks on boot, writes only if wrong)
|
||||
bool configureFuelGauge(uint16_t designCapacity_mAh = BQ27220_DESIGN_CAPACITY_MAH);
|
||||
|
||||
|
||||
@@ -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.1A"'
|
||||
-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>
|
||||
@@ -107,6 +107,7 @@ build_flags =
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
; -D MECK_WEB_READER=1
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -120,6 +121,7 @@ lib_deps =
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
|
||||
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
|
||||
[env:meck_audio_standalone]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
@@ -152,7 +154,8 @@ build_flags =
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.2-4G"'
|
||||
; -D MECK_WEB_READER=1
|
||||
-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