mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
15 Commits
consolidat
...
phone-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f171a1fc | ||
|
|
099d9a5b6c | ||
|
|
fce999347f | ||
|
|
7f8f70655d | ||
|
|
6e417d1f3e | ||
|
|
38eb4b854b | ||
|
|
e64011112e | ||
|
|
97f9fc9eee | ||
|
|
4a1fe3b190 | ||
|
|
2024dc2a1b | ||
|
|
27b8ea603f | ||
|
|
b812ff75a9 | ||
|
|
4477d5c812 | ||
|
|
f06a1f5499 | ||
|
|
458db8d4c4 |
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.
|
||||
165
SMS & Phone App Guide.md
Normal file
165
SMS & Phone App Guide.md
Normal file
@@ -0,0 +1,165 @@
|
||||
## SMS & Phone App (4G variant only) - Meck v0.9.3 (Alpha)
|
||||
|
||||
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
|
||||
powered. The modem (and its red LED) can be switched off and on from the
|
||||
settings screen. After each modem startup, the system clock syncs from the
|
||||
cellular network, which takes roughly 15 seconds.
|
||||
|
||||
### Key Mapping
|
||||
|
||||
| Context | Key | Action |
|
||||
|---------|-----|--------|
|
||||
| Home screen | T | Open SMS & Phone app |
|
||||
| Inbox | W / S | Scroll conversations |
|
||||
| Inbox | Enter | Open conversation |
|
||||
| Inbox | C | Compose new SMS (enter phone number) |
|
||||
| Inbox | D | Open contacts directory |
|
||||
| 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
|
||||
|
||||
There are three ways to start a new message:
|
||||
|
||||
1. **From inbox** — press **C**, type the destination phone number, press
|
||||
**Enter**, then type your message and press **Enter** to send.
|
||||
2. **From a conversation** — press **C** to reply. The recipient is
|
||||
pre-filled so you go straight to typing the message body.
|
||||
3. **From the contacts directory** — press **D** from the inbox, scroll to a
|
||||
contact, and press **Enter**. The compose screen opens with the number
|
||||
pre-filled.
|
||||
|
||||
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, 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
|
||||
characters long.
|
||||
|
||||
Contacts are stored as a plain text file at `/sms/contacts.txt` on the SD card
|
||||
in `phone=Display Name` format — one per line, human-editable. Up to 30
|
||||
contacts are supported.
|
||||
|
||||
### Conversation History
|
||||
|
||||
Messages are saved to the SD card automatically and persist across reboots.
|
||||
Each phone number gets its own file under `/sms/` on the SD card. The inbox
|
||||
shows the most recent 20 conversations sorted by last activity. Within a
|
||||
conversation, the most recent 30 messages are loaded with the newest at the
|
||||
bottom (chat-style). Sent messages are shown with `>>>` and received messages
|
||||
with `<<<`.
|
||||
|
||||
Message timestamps use the cellular network clock (synced via NITZ roughly 15
|
||||
seconds after each modem startup) and display as relative times (e.g. 5m, 2h,
|
||||
1d). If the modem is toggled off and back on, the clock re-syncs automatically.
|
||||
|
||||
### Modem Power Control
|
||||
|
||||
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 & 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 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. During a call, the signal indicator remains visible.
|
||||
|
||||
### SD Card Structure
|
||||
|
||||
```
|
||||
SD Card
|
||||
├── sms/
|
||||
│ ├── contacts.txt (plain text, phone=Name format)
|
||||
│ ├── modem.cfg (0 or 1, modem enable state)
|
||||
│ ├── 0412345678.sms (binary message log per phone number)
|
||||
│ └── 0498765432.sms
|
||||
├── books/ (text reader)
|
||||
├── audiobooks/ (audio variant only)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause |
|
||||
|---------|-------------|
|
||||
| Modem icon stays at REG / never reaches READY | SIM not inserted, no signal, or SIM requires PIN unlock (not currently supported) |
|
||||
| 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 & 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.1A"
|
||||
#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;
|
||||
@@ -50,11 +59,22 @@
|
||||
|
||||
// Audiobook player — Audio object is heap-allocated on first use to avoid
|
||||
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#include "Audio.h"
|
||||
Audio* audio = nullptr;
|
||||
// Audiobook player — Audio object is heap-allocated on first use to avoid
|
||||
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
|
||||
// Not available on 4G variant (I2S pins conflict with modem control lines).
|
||||
#ifndef HAS_4G_MODEM
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#include "Audio.h"
|
||||
Audio* audio = nullptr;
|
||||
#endif
|
||||
static bool audiobookMode = false;
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "ModemManager.h"
|
||||
#include "SMSStore.h"
|
||||
#include "SMSContacts.h"
|
||||
#include "SMSScreen.h"
|
||||
static bool smsMode = false;
|
||||
#endif
|
||||
|
||||
// Power management
|
||||
@@ -423,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)");
|
||||
|
||||
@@ -435,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
|
||||
@@ -538,6 +586,32 @@ void setup() {
|
||||
|
||||
// Do an initial settings backup to SD (captures any first-boot defaults)
|
||||
backupSettingsToSD();
|
||||
|
||||
// SMS / 4G modem init (after SD is ready)
|
||||
#ifdef HAS_4G_MODEM
|
||||
{
|
||||
smsStore.begin();
|
||||
smsContacts.begin();
|
||||
|
||||
// Tell SMS screen that SD is ready
|
||||
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
|
||||
if (smsScr) {
|
||||
smsScr->setSDReady(true);
|
||||
}
|
||||
|
||||
// Start modem if enabled in config (default = enabled)
|
||||
bool modemEnabled = ModemManager::loadEnabledConfig();
|
||||
if (modemEnabled) {
|
||||
modemManager.begin();
|
||||
MESH_DEBUG_PRINTLN("setup() - 4G modem manager started");
|
||||
} else {
|
||||
// Ensure modem power is off (kills red LED too)
|
||||
pinMode(MODEM_POWER_EN, OUTPUT);
|
||||
digitalWrite(MODEM_POWER_EN, LOW);
|
||||
MESH_DEBUG_PRINTLN("setup() - 4G modem disabled by config");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -607,7 +681,7 @@ void loop() {
|
||||
cpuPower.loop();
|
||||
|
||||
// Audiobook: service audio decode regardless of which screen is active
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#ifndef HAS_4G_MODEM
|
||||
{
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
@@ -619,7 +693,81 @@ void loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// SMS: poll for incoming messages from modem
|
||||
#ifdef HAS_4G_MODEM
|
||||
{
|
||||
SMSIncoming incoming;
|
||||
while (modemManager.recvSMS(incoming)) {
|
||||
// Save to store and notify UI
|
||||
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
|
||||
if (smsScr) {
|
||||
smsScr->onIncomingSMS(incoming.phone, incoming.body, incoming.timestamp);
|
||||
}
|
||||
|
||||
// Alert + buzzer
|
||||
char alertBuf[48];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "SMS: %s", incoming.phone);
|
||||
ui_task.showAlert(alertBuf, 2000);
|
||||
ui_task.notify(UIEventType::contactMessage);
|
||||
|
||||
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
|
||||
// Skip UITask rendering when in compose mode to prevent flickering
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
@@ -627,10 +775,26 @@ void loop() {
|
||||
bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing();
|
||||
bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming();
|
||||
bool notesSuppressLoop = notesEditing || notesRenaming;
|
||||
if (!composeMode && !notesSuppressLoop) {
|
||||
#ifdef HAS_4G_MODEM
|
||||
bool smsSuppressLoop = smsMode && ((SMSScreen*)ui_task.getSMSScreen())->isComposing();
|
||||
#else
|
||||
bool smsSuppressLoop = false;
|
||||
#endif
|
||||
#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) {
|
||||
@@ -642,16 +806,46 @@ void loop() {
|
||||
// Notes editor/rename renders through UITask - force a refresh cycle
|
||||
ui_task.forceRefresh();
|
||||
ui_task.loop();
|
||||
} else if (smsSuppressLoop) {
|
||||
// SMS compose: render directly to display, same as mesh compose
|
||||
#if defined(DISPLAY_CLASS) && defined(HAS_4G_MODEM)
|
||||
display.startFrame();
|
||||
((SMSScreen*)ui_task.getSMSScreen())->render(display);
|
||||
display.endFrame();
|
||||
#endif
|
||||
}
|
||||
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();
|
||||
notesMode = ui_task.isOnNotesScreen();
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
audiobookMode = ui_task.isOnAudiobookPlayer();
|
||||
#ifdef HAS_4G_MODEM
|
||||
smsMode = ui_task.isOnSMSScreen();
|
||||
#endif
|
||||
#else
|
||||
ui_task.loop();
|
||||
@@ -843,7 +1037,7 @@ void handleKeyboardInput() {
|
||||
}
|
||||
|
||||
// *** AUDIOBOOK MODE ***
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#ifndef HAS_4G_MODEM
|
||||
if (audiobookMode) {
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
@@ -876,7 +1070,7 @@ void handleKeyboardInput() {
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
#endif // MECK_AUDIO_VARIANT
|
||||
#endif // !HAS_4G_MODEM
|
||||
|
||||
// *** TEXT READER MODE ***
|
||||
if (readerMode) {
|
||||
@@ -1108,6 +1302,93 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// SMS mode key routing (when on SMS screen)
|
||||
#ifdef HAS_4G_MODEM
|
||||
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");
|
||||
ui_task.gotoHomeScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (smsScr->isComposing()) {
|
||||
// Composing/text input: route directly to screen, bypass injectKey()
|
||||
// to avoid UITask scheduling its own competing refresh
|
||||
smsScr->handleInput(key);
|
||||
if (smsScr->isComposing()) {
|
||||
// Still composing — debounced refresh
|
||||
composeNeedsRefresh = true;
|
||||
lastComposeRefresh = millis();
|
||||
} else {
|
||||
// View changed (sent/cancelled) — immediate UITask refresh
|
||||
composeNeedsRefresh = false;
|
||||
ui_task.forceRefresh();
|
||||
}
|
||||
} else {
|
||||
// Non-compose views (inbox, conversation, contacts): use normal inject
|
||||
ui_task.injectKey(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
#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':
|
||||
@@ -1128,25 +1409,74 @@ void handleKeyboardInput() {
|
||||
ui_task.gotoTextReader();
|
||||
break;
|
||||
|
||||
#ifndef HAS_4G_MODEM
|
||||
case 'p':
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// Open audiobook player -- lazy-init Audio + screen on first use
|
||||
// Open audiobook player - lazy-init Audio + screen on first use
|
||||
Serial.println("Opening audiobook player");
|
||||
if (!ui_task.getAudiobookScreen()) {
|
||||
Serial.printf("Audiobook: lazy init -- free heap: %d, largest block: %d\n",
|
||||
Serial.printf("Audiobook: lazy init - free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
audio = new Audio();
|
||||
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio);
|
||||
abScreen->setSDReady(sdCardReady);
|
||||
ui_task.setAudiobookScreen(abScreen);
|
||||
Serial.printf("Audiobook: init complete -- free heap: %d\n", ESP.getFreeHeap());
|
||||
Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||||
}
|
||||
ui_task.gotoAudiobookPlayer();
|
||||
#else
|
||||
Serial.println("Audio not available on this build variant");
|
||||
ui_task.showAlert("No audio hardware", 1500);
|
||||
#endif
|
||||
break;
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
case 't':
|
||||
// Open SMS (4G variant only)
|
||||
Serial.println("Opening SMS");
|
||||
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
|
||||
@@ -1163,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();
|
||||
@@ -1174,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
|
||||
@@ -1260,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();
|
||||
@@ -1284,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;
|
||||
}
|
||||
@@ -1480,7 +1836,10 @@ void sendComposedMessage() {
|
||||
// ============================================================================
|
||||
// ESP32-audioI2S CALLBACKS
|
||||
// ============================================================================
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// The audio library calls these global functions - must be defined at file scope.
|
||||
// Not available on 4G variant (no audio hardware).
|
||||
|
||||
#ifndef HAS_4G_MODEM
|
||||
void audio_info(const char *info) {
|
||||
Serial.printf("Audio: %s\n", info);
|
||||
}
|
||||
@@ -1494,6 +1853,6 @@ void audio_eof_mp3(const char *info) {
|
||||
abPlayer->onEOF();
|
||||
}
|
||||
}
|
||||
#endif // MECK_AUDIO_VARIANT
|
||||
#endif // !HAS_4G_MODEM
|
||||
|
||||
#endif // LilyGo_TDeck_Pro
|
||||
@@ -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
|
||||
|
||||
1022
examples/companion_radio/ui-new/ModemManager.cpp
Normal file
1022
examples/companion_radio/ui-new/ModemManager.cpp
Normal file
File diff suppressed because it is too large
Load Diff
222
examples/companion_radio/ui-new/ModemManager.h
Normal file
222
examples/companion_radio/ui-new/ModemManager.h
Normal file
@@ -0,0 +1,222 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// ModemManager - A7682E 4G Modem Driver for T-Deck Pro (V1.1 4G variant)
|
||||
//
|
||||
// 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)
|
||||
// =============================================================================
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#ifndef MODEM_MANAGER_H
|
||||
#define MODEM_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <freertos/queue.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include "variant.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modem pins (from variant.h, always defined for reference)
|
||||
// MODEM_POWER_EN 41 Board 6609 enable
|
||||
// MODEM_PWRKEY 40 Power key toggle
|
||||
// MODEM_RST 9 Reset (shared with I2S BCLK on audio board)
|
||||
// MODEM_RI 7 Ring indicator (shared with I2S DOUT on audio)
|
||||
// MODEM_DTR 8 Data terminal ready (shared with I2S LRC on audio)
|
||||
// MODEM_RX 10 UART RX (shared with PIN_PERF_POWERON)
|
||||
// MODEM_TX 11 UART TX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SMS field limits
|
||||
#define SMS_PHONE_LEN 20
|
||||
#define SMS_BODY_LEN 161 // 160 chars + null
|
||||
|
||||
// Task configuration
|
||||
#define MODEM_TASK_PRIORITY 1 // Below mesh (default loop = priority 1 on core 1)
|
||||
#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,
|
||||
INITIALIZING,
|
||||
REGISTERING,
|
||||
READY,
|
||||
ERROR,
|
||||
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];
|
||||
char body[SMS_BODY_LEN];
|
||||
};
|
||||
|
||||
// Incoming SMS (queued from modem task to main loop)
|
||||
struct SMSIncoming {
|
||||
char phone[SMS_PHONE_LEN];
|
||||
char body[SMS_BODY_LEN];
|
||||
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();
|
||||
|
||||
// --- SMS API (unchanged) ---
|
||||
bool sendSMS(const char* phone, const char* body);
|
||||
bool recvSMS(SMSIncoming& out);
|
||||
|
||||
// --- 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();
|
||||
static void saveEnabledConfig(bool enabled);
|
||||
|
||||
private:
|
||||
volatile ModemState _state = ModemState::OFF;
|
||||
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);
|
||||
bool waitResponse(const char* expect, uint32_t timeout_ms, char* buf = nullptr, size_t bufLen = 0);
|
||||
void pollCSQ();
|
||||
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();
|
||||
};
|
||||
|
||||
// Global singleton
|
||||
extern ModemManager modemManager;
|
||||
|
||||
#endif // MODEM_MANAGER_H
|
||||
#endif // HAS_4G_MODEM
|
||||
8
examples/companion_radio/ui-new/SMSContacts.cpp
Normal file
8
examples/companion_radio/ui-new/SMSContacts.cpp
Normal file
@@ -0,0 +1,8 @@
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#include "SMSContacts.h"
|
||||
|
||||
// Global singleton
|
||||
SMSContactStore smsContacts;
|
||||
|
||||
#endif // HAS_4G_MODEM
|
||||
176
examples/companion_radio/ui-new/SMSContacts.h
Normal file
176
examples/companion_radio/ui-new/SMSContacts.h
Normal file
@@ -0,0 +1,176 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// SMSContacts - Phone-to-name lookup for SMS contacts (4G variant)
|
||||
//
|
||||
// Stores contacts in /sms/contacts.txt on SD card.
|
||||
// Format: one contact per line as "phone=Display Name"
|
||||
//
|
||||
// Completely separate from mesh ContactInfo / IdentityStore.
|
||||
//
|
||||
// Guard: HAS_4G_MODEM
|
||||
// =============================================================================
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#ifndef SMS_CONTACTS_H
|
||||
#define SMS_CONTACTS_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#define SMS_CONTACT_NAME_LEN 24
|
||||
#define SMS_CONTACT_MAX 30
|
||||
#define SMS_CONTACTS_FILE "/sms/contacts.txt"
|
||||
|
||||
struct SMSContact {
|
||||
char phone[20]; // matches SMS_PHONE_LEN
|
||||
char name[SMS_CONTACT_NAME_LEN];
|
||||
bool valid;
|
||||
};
|
||||
|
||||
class SMSContactStore {
|
||||
public:
|
||||
void begin() {
|
||||
_count = 0;
|
||||
memset(_contacts, 0, sizeof(_contacts));
|
||||
load();
|
||||
}
|
||||
|
||||
// Look up a name by phone number. Returns nullptr if not found.
|
||||
const char* lookup(const char* phone) const {
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
|
||||
return _contacts[i].name;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Fill buf with display name if found, otherwise copy phone number.
|
||||
// Returns true if a name was found.
|
||||
bool displayName(const char* phone, char* buf, size_t bufLen) const {
|
||||
const char* name = lookup(phone);
|
||||
if (name && name[0]) {
|
||||
strncpy(buf, name, bufLen - 1);
|
||||
buf[bufLen - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
strncpy(buf, phone, bufLen - 1);
|
||||
buf[bufLen - 1] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add or update a contact. Returns true on success.
|
||||
bool set(const char* phone, const char* name) {
|
||||
// Update existing
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
|
||||
strncpy(_contacts[i].name, name, SMS_CONTACT_NAME_LEN - 1);
|
||||
_contacts[i].name[SMS_CONTACT_NAME_LEN - 1] = '\0';
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Add new
|
||||
if (_count >= SMS_CONTACT_MAX) return false;
|
||||
strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1);
|
||||
_contacts[_count].phone[sizeof(_contacts[_count].phone) - 1] = '\0';
|
||||
strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1);
|
||||
_contacts[_count].name[SMS_CONTACT_NAME_LEN - 1] = '\0';
|
||||
_contacts[_count].valid = true;
|
||||
_count++;
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove a contact by phone number
|
||||
bool remove(const char* phone) {
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
|
||||
for (int j = i; j < _count - 1; j++) {
|
||||
_contacts[j] = _contacts[j + 1];
|
||||
}
|
||||
_count--;
|
||||
memset(&_contacts[_count], 0, sizeof(SMSContact));
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Accessors for list browsing
|
||||
int count() const { return _count; }
|
||||
const SMSContact& get(int index) const { return _contacts[index]; }
|
||||
|
||||
// Check if a contact exists
|
||||
bool exists(const char* phone) const { return lookup(phone) != nullptr; }
|
||||
|
||||
private:
|
||||
SMSContact _contacts[SMS_CONTACT_MAX];
|
||||
int _count = 0;
|
||||
|
||||
void load() {
|
||||
File f = SD.open(SMS_CONTACTS_FILE, FILE_READ);
|
||||
if (!f) {
|
||||
Serial.println("[SMSContacts] No contacts file, starting fresh");
|
||||
return;
|
||||
}
|
||||
|
||||
char line[64];
|
||||
while (f.available() && _count < SMS_CONTACT_MAX) {
|
||||
int pos = 0;
|
||||
while (f.available() && pos < (int)sizeof(line) - 1) {
|
||||
char c = f.read();
|
||||
if (c == '\n' || c == '\r') break;
|
||||
line[pos++] = c;
|
||||
}
|
||||
line[pos] = '\0';
|
||||
if (pos == 0) continue;
|
||||
// Consume trailing CR/LF
|
||||
while (f.available()) {
|
||||
int pk = f.peek();
|
||||
if (pk == '\n' || pk == '\r') { f.read(); continue; }
|
||||
break;
|
||||
}
|
||||
|
||||
// Parse "phone=name"
|
||||
char* eq = strchr(line, '=');
|
||||
if (!eq) continue;
|
||||
*eq = '\0';
|
||||
const char* phone = line;
|
||||
const char* name = eq + 1;
|
||||
if (strlen(phone) == 0 || strlen(name) == 0) continue;
|
||||
|
||||
strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1);
|
||||
strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1);
|
||||
_contacts[_count].valid = true;
|
||||
_count++;
|
||||
}
|
||||
f.close();
|
||||
Serial.printf("[SMSContacts] Loaded %d contacts\n", _count);
|
||||
}
|
||||
|
||||
void save() {
|
||||
if (!SD.exists("/sms")) SD.mkdir("/sms");
|
||||
File f = SD.open(SMS_CONTACTS_FILE, FILE_WRITE);
|
||||
if (!f) {
|
||||
Serial.println("[SMSContacts] Failed to write contacts file");
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (!_contacts[i].valid) continue;
|
||||
f.print(_contacts[i].phone);
|
||||
f.print('=');
|
||||
f.println(_contacts[i].name);
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Global singleton
|
||||
extern SMSContactStore smsContacts;
|
||||
|
||||
#endif // SMS_CONTACTS_H
|
||||
#endif // HAS_4G_MODEM
|
||||
1216
examples/companion_radio/ui-new/SMSScreen.h
Normal file
1216
examples/companion_radio/ui-new/SMSScreen.h
Normal file
File diff suppressed because it is too large
Load Diff
196
examples/companion_radio/ui-new/SMSStore.cpp
Normal file
196
examples/companion_radio/ui-new/SMSStore.cpp
Normal file
@@ -0,0 +1,196 @@
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#include "SMSStore.h"
|
||||
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
|
||||
#include "target.h" // For SDCARD_CS macro
|
||||
|
||||
// Global singleton
|
||||
SMSStore smsStore;
|
||||
|
||||
void SMSStore::begin() {
|
||||
// Ensure SMS directory exists
|
||||
if (!SD.exists(SMS_DIR)) {
|
||||
SD.mkdir(SMS_DIR);
|
||||
MESH_DEBUG_PRINTLN("[SMSStore] created %s", SMS_DIR);
|
||||
}
|
||||
_ready = true;
|
||||
MESH_DEBUG_PRINTLN("[SMSStore] ready");
|
||||
}
|
||||
|
||||
void SMSStore::phoneToFilename(const char* phone, char* out, size_t outLen) {
|
||||
// Convert phone number to safe filename: strip non-alphanumeric, prefix with dir
|
||||
// e.g. "+1234567890" -> "/sms/p1234567890.sms"
|
||||
char safe[SMS_PHONE_LEN];
|
||||
int j = 0;
|
||||
for (int i = 0; phone[i] && j < SMS_PHONE_LEN - 1; i++) {
|
||||
char c = phone[i];
|
||||
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
|
||||
safe[j++] = c;
|
||||
}
|
||||
}
|
||||
safe[j] = '\0';
|
||||
snprintf(out, outLen, "%s/p%s.sms", SMS_DIR, safe);
|
||||
}
|
||||
|
||||
bool SMSStore::saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp) {
|
||||
if (!_ready) return false;
|
||||
|
||||
char filepath[64];
|
||||
phoneToFilename(phone, filepath, sizeof(filepath));
|
||||
|
||||
// Build record
|
||||
SMSRecord rec;
|
||||
memset(&rec, 0, sizeof(rec));
|
||||
rec.timestamp = timestamp;
|
||||
rec.isSent = isSent ? 1 : 0;
|
||||
rec.bodyLen = strlen(body);
|
||||
if (rec.bodyLen >= SMS_BODY_LEN) rec.bodyLen = SMS_BODY_LEN - 1;
|
||||
strncpy(rec.phone, phone, SMS_PHONE_LEN - 1);
|
||||
strncpy(rec.body, body, SMS_BODY_LEN - 1);
|
||||
|
||||
// Append to file
|
||||
File f = SD.open(filepath, FILE_APPEND);
|
||||
if (!f) {
|
||||
// Try creating
|
||||
f = SD.open(filepath, FILE_WRITE);
|
||||
if (!f) {
|
||||
MESH_DEBUG_PRINTLN("[SMSStore] can't open %s", filepath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
size_t written = f.write((uint8_t*)&rec, sizeof(rec));
|
||||
f.close();
|
||||
|
||||
// Release SD CS
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
return written == sizeof(rec);
|
||||
}
|
||||
|
||||
int SMSStore::loadConversations(SMSConversation* out, int maxCount) {
|
||||
if (!_ready) return 0;
|
||||
|
||||
File dir = SD.open(SMS_DIR);
|
||||
if (!dir || !dir.isDirectory()) return 0;
|
||||
|
||||
int count = 0;
|
||||
File entry;
|
||||
while ((entry = dir.openNextFile()) && count < maxCount) {
|
||||
const char* name = entry.name();
|
||||
// Only process .sms files
|
||||
if (!strstr(name, ".sms")) { entry.close(); continue; }
|
||||
|
||||
size_t fileSize = entry.size();
|
||||
if (fileSize < sizeof(SMSRecord)) { entry.close(); continue; }
|
||||
|
||||
int numRecords = fileSize / sizeof(SMSRecord);
|
||||
|
||||
// Read the last record for preview
|
||||
SMSRecord lastRec;
|
||||
entry.seek(fileSize - sizeof(SMSRecord));
|
||||
if (entry.read((uint8_t*)&lastRec, sizeof(SMSRecord)) != sizeof(SMSRecord)) {
|
||||
entry.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
SMSConversation& conv = out[count];
|
||||
memset(&conv, 0, sizeof(SMSConversation));
|
||||
strncpy(conv.phone, lastRec.phone, SMS_PHONE_LEN - 1);
|
||||
strncpy(conv.preview, lastRec.body, 39);
|
||||
conv.preview[39] = '\0';
|
||||
conv.lastTimestamp = lastRec.timestamp;
|
||||
conv.messageCount = numRecords;
|
||||
conv.unreadCount = 0; // TODO: track read state
|
||||
conv.valid = true;
|
||||
|
||||
count++;
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
|
||||
// Release SD CS
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
// Sort by most recent (simple bubble sort, small N)
|
||||
for (int i = 0; i < count - 1; i++) {
|
||||
for (int j = 0; j < count - 1 - i; j++) {
|
||||
if (out[j].lastTimestamp < out[j + 1].lastTimestamp) {
|
||||
SMSConversation tmp = out[j];
|
||||
out[j] = out[j + 1];
|
||||
out[j + 1] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
int SMSStore::loadMessages(const char* phone, SMSMessage* out, int maxCount) {
|
||||
if (!_ready) return 0;
|
||||
|
||||
char filepath[64];
|
||||
phoneToFilename(phone, filepath, sizeof(filepath));
|
||||
|
||||
File f = SD.open(filepath, FILE_READ);
|
||||
if (!f) return 0;
|
||||
|
||||
size_t fileSize = f.size();
|
||||
int numRecords = fileSize / sizeof(SMSRecord);
|
||||
|
||||
// Load from end of file (most recent N messages), in chronological order
|
||||
int startIdx = numRecords > maxCount ? numRecords - maxCount : 0;
|
||||
|
||||
// Read chronologically (oldest first) for chat-style display
|
||||
SMSRecord rec;
|
||||
int outIdx = 0;
|
||||
for (int i = startIdx; i < numRecords && outIdx < maxCount; i++) {
|
||||
f.seek(i * sizeof(SMSRecord));
|
||||
if (f.read((uint8_t*)&rec, sizeof(SMSRecord)) != sizeof(SMSRecord)) continue;
|
||||
|
||||
out[outIdx].timestamp = rec.timestamp;
|
||||
out[outIdx].isSent = rec.isSent != 0;
|
||||
out[outIdx].valid = true;
|
||||
strncpy(out[outIdx].phone, rec.phone, SMS_PHONE_LEN - 1);
|
||||
strncpy(out[outIdx].body, rec.body, SMS_BODY_LEN - 1);
|
||||
outIdx++;
|
||||
}
|
||||
|
||||
f.close();
|
||||
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
return outIdx;
|
||||
}
|
||||
|
||||
bool SMSStore::deleteConversation(const char* phone) {
|
||||
if (!_ready) return false;
|
||||
|
||||
char filepath[64];
|
||||
phoneToFilename(phone, filepath, sizeof(filepath));
|
||||
|
||||
bool ok = SD.remove(filepath);
|
||||
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
int SMSStore::getMessageCount(const char* phone) {
|
||||
if (!_ready) return 0;
|
||||
|
||||
char filepath[64];
|
||||
phoneToFilename(phone, filepath, sizeof(filepath));
|
||||
|
||||
File f = SD.open(filepath, FILE_READ);
|
||||
if (!f) return 0;
|
||||
|
||||
int count = f.size() / sizeof(SMSRecord);
|
||||
f.close();
|
||||
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
#endif // HAS_4G_MODEM
|
||||
87
examples/companion_radio/ui-new/SMSStore.h
Normal file
87
examples/companion_radio/ui-new/SMSStore.h
Normal file
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// SMSStore - SD card backed SMS message storage
|
||||
//
|
||||
// Stores sent and received messages in /sms/ on the SD card.
|
||||
// Each conversation is a separate file named by phone number (sanitised).
|
||||
// Messages are appended as fixed-size records for simple random access.
|
||||
//
|
||||
// Guard: HAS_4G_MODEM
|
||||
// =============================================================================
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#ifndef SMS_STORE_H
|
||||
#define SMS_STORE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#define SMS_PHONE_LEN 20
|
||||
#define SMS_BODY_LEN 161
|
||||
#define SMS_MAX_CONVERSATIONS 20
|
||||
#define SMS_DIR "/sms"
|
||||
|
||||
// Fixed-size on-disk record (256 bytes, easy alignment)
|
||||
struct SMSRecord {
|
||||
uint32_t timestamp; // epoch seconds
|
||||
uint8_t isSent; // 1=sent, 0=received
|
||||
uint8_t reserved[2];
|
||||
uint8_t bodyLen; // actual length of body
|
||||
char phone[SMS_PHONE_LEN]; // 20
|
||||
char body[SMS_BODY_LEN]; // 161
|
||||
uint8_t padding[256 - 4 - 3 - 1 - SMS_PHONE_LEN - SMS_BODY_LEN];
|
||||
};
|
||||
|
||||
// In-memory message for UI
|
||||
struct SMSMessage {
|
||||
uint32_t timestamp;
|
||||
bool isSent;
|
||||
bool valid;
|
||||
char phone[SMS_PHONE_LEN];
|
||||
char body[SMS_BODY_LEN];
|
||||
};
|
||||
|
||||
// Conversation summary for inbox view
|
||||
struct SMSConversation {
|
||||
char phone[SMS_PHONE_LEN];
|
||||
char preview[40]; // last message preview
|
||||
uint32_t lastTimestamp;
|
||||
int messageCount;
|
||||
int unreadCount;
|
||||
bool valid;
|
||||
};
|
||||
|
||||
class SMSStore {
|
||||
public:
|
||||
void begin();
|
||||
bool isReady() const { return _ready; }
|
||||
|
||||
// Save a message (sent or received)
|
||||
bool saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp);
|
||||
|
||||
// Load conversation list (sorted by most recent)
|
||||
int loadConversations(SMSConversation* out, int maxCount);
|
||||
|
||||
// Load messages for a specific phone number (chronological, oldest first)
|
||||
int loadMessages(const char* phone, SMSMessage* out, int maxCount);
|
||||
|
||||
// Delete all messages for a phone number
|
||||
bool deleteConversation(const char* phone);
|
||||
|
||||
// Get total message count for a phone number
|
||||
int getMessageCount(const char* phone);
|
||||
|
||||
private:
|
||||
bool _ready = false;
|
||||
|
||||
// Convert phone number to safe filename
|
||||
void phoneToFilename(const char* phone, char* out, size_t outLen);
|
||||
};
|
||||
|
||||
// Global singleton
|
||||
extern SMSStore smsStore;
|
||||
|
||||
#endif // SMS_STORE_H
|
||||
#endif // HAS_4G_MODEM
|
||||
@@ -6,6 +6,10 @@
|
||||
#include <MeshCore.h>
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
@@ -56,6 +60,9 @@ enum SettingsRowType : uint8_t {
|
||||
ROW_TX_POWER, // TX power (1-20 dBm)
|
||||
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
|
||||
ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle
|
||||
#ifdef HAS_4G_MODEM
|
||||
ROW_MODEM_TOGGLE, // 4G modem enable/disable toggle (4G builds only)
|
||||
#endif
|
||||
ROW_CH_HEADER, // "--- Channels ---" separator
|
||||
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
|
||||
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
|
||||
@@ -85,7 +92,7 @@ private:
|
||||
mesh::RTCClock* _rtc;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
// Row table — rebuilt whenever channels change
|
||||
// Row table  rebuilt whenever channels change
|
||||
struct Row {
|
||||
SettingsRowType type;
|
||||
uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET
|
||||
@@ -109,9 +116,14 @@ private:
|
||||
// Onboarding mode
|
||||
bool _onboarding;
|
||||
|
||||
// Dirty flag for radio params — prompt to apply
|
||||
// Dirty flag for radio params  prompt to apply
|
||||
bool _radioChanged;
|
||||
|
||||
// 4G modem state (runtime cache of config)
|
||||
#ifdef HAS_4G_MODEM
|
||||
bool _modemEnabled;
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row table management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -128,6 +140,9 @@ private:
|
||||
addRow(ROW_TX_POWER);
|
||||
addRow(ROW_UTC_OFFSET);
|
||||
addRow(ROW_MSG_NOTIFY);
|
||||
#ifdef HAS_4G_MODEM
|
||||
addRow(ROW_MODEM_TOGGLE);
|
||||
#endif
|
||||
addRow(ROW_CH_HEADER);
|
||||
|
||||
// Enumerate current channels
|
||||
@@ -212,11 +227,11 @@ private:
|
||||
strncpy(newCh.name, chanName, sizeof(newCh.name));
|
||||
newCh.name[31] = '\0';
|
||||
|
||||
// SHA-256 the channel name → first 16 bytes become the secret
|
||||
// SHA-256 the channel name → first 16 bytes become the secret
|
||||
uint8_t hash[32];
|
||||
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
|
||||
memcpy(newCh.channel.secret, hash, 16);
|
||||
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
|
||||
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
|
||||
|
||||
// Find next empty slot
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
@@ -289,6 +304,9 @@ public:
|
||||
_cursor = 0;
|
||||
_scrollTop = 0;
|
||||
_radioChanged = false;
|
||||
#ifdef HAS_4G_MODEM
|
||||
_modemEnabled = ModemManager::loadEnabledConfig();
|
||||
#endif
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
@@ -473,6 +491,14 @@ public:
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
case ROW_MODEM_TOGGLE:
|
||||
snprintf(tmp, sizeof(tmp), "4G Modem: %s",
|
||||
_modemEnabled ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
#endif
|
||||
|
||||
case ROW_CH_HEADER:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("--- Channels ---");
|
||||
@@ -838,6 +864,19 @@ public:
|
||||
Serial.printf("Settings: Msg flash notify = %s\n",
|
||||
_prefs->kb_flash_notify ? "ON" : "OFF");
|
||||
break;
|
||||
#ifdef HAS_4G_MODEM
|
||||
case ROW_MODEM_TOGGLE:
|
||||
_modemEnabled = !_modemEnabled;
|
||||
ModemManager::saveEnabledConfig(_modemEnabled);
|
||||
if (_modemEnabled) {
|
||||
modemManager.begin();
|
||||
Serial.println("Settings: 4G modem ENABLED (started)");
|
||||
} else {
|
||||
modemManager.shutdown();
|
||||
Serial.println("Settings: 4G modem DISABLED (shutdown)");
|
||||
}
|
||||
break;
|
||||
#endif
|
||||
case ROW_ADD_CHANNEL:
|
||||
startEditText("");
|
||||
break;
|
||||
@@ -861,7 +900,7 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// Q: back — if radio changed, prompt to apply first
|
||||
// Q: back  if radio changed, prompt to apply first
|
||||
if (c == 'q' || c == 'Q') {
|
||||
if (_radioChanged) {
|
||||
_editMode = EDIT_CONFIRM;
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "SMSScreen.h"
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
@@ -114,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];
|
||||
@@ -217,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() {
|
||||
@@ -228,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();
|
||||
}
|
||||
}
|
||||
@@ -330,10 +335,16 @@ public:
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#ifdef HAS_4G_MODEM
|
||||
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;
|
||||
|
||||
@@ -391,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);
|
||||
@@ -625,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);
|
||||
@@ -723,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;
|
||||
@@ -881,6 +911,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
#ifdef HAS_4G_MODEM
|
||||
sms_screen = new SMSScreen(this);
|
||||
#endif
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -1029,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();
|
||||
}
|
||||
}
|
||||
@@ -1386,6 +1442,19 @@ void UITask::gotoAudiobookPlayer() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
void UITask::gotoSMSScreen() {
|
||||
SMSScreen* smsScr = (SMSScreen*)sms_screen;
|
||||
smsScr->activate();
|
||||
setCurrScreen(sms_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
uint8_t UITask::getChannelScreenViewIdx() const {
|
||||
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
|
||||
}
|
||||
@@ -1424,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);
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
#include "../AbstractUITask.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "SMSScreen.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
SensorManager* _sensors;
|
||||
@@ -58,7 +66,13 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
#ifdef HAS_4G_MODEM
|
||||
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();
|
||||
@@ -90,6 +104,14 @@ 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; }
|
||||
SMSScreen* getSMSScreen() const { return (SMSScreen*)sms_screen; }
|
||||
#endif
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
void forceRefresh() override { _next_refresh = 100; }
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
@@ -102,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)
|
||||
@@ -138,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
|
||||
@@ -167,8 +167,8 @@ static bool bq27220_writeControl(uint16_t subcmd) {
|
||||
// RAM, so this typically only writes once (or after a full battery disconnect).
|
||||
//
|
||||
// Procedure follows TI TRM SLUUBD4A Section 6.1:
|
||||
// 1. Unseal → 2. Full Access → 3. Enter CFG_UPDATE
|
||||
// 4. Write Design Capacity via MAC → 5. Exit CFG_UPDATE → 6. Seal
|
||||
// 1. Unseal → 2. Full Access → 3. Enter CFG_UPDATE
|
||||
// 4. Write Design Capacity via MAC → 5. Exit CFG_UPDATE → 6. Seal
|
||||
|
||||
bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
#if HAS_BQ27220
|
||||
@@ -353,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 =
|
||||
@@ -151,6 +153,9 @@ build_flags =
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
; -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>
|
||||
@@ -159,4 +164,4 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
Reference in New Issue
Block a user