Compare commits

..

17 Commits

Author SHA1 Message Date
pelgraine 15fcb08441 changed wording of light flash on off to make it slightly clearer 2026-02-17 20:12:04 +11:00
pelgraine 48863a13aa update firmware version and date 2026-02-17 19:59:02 +11:00
pelgraine 98addc5bd3 update uitasks to enable keyboard pulse light notification 2026-02-17 19:54:39 +11:00
pelgraine a10ce4e7ca update settingscreen to enable keyboard pulse light 2026-02-17 19:51:36 +11:00
pelgraine dcb59f2e33 update node prefs to enable keyboard pulse light 2026-02-17 19:50:31 +11:00
pelgraine 9e40dc7f30 updated firmware version and date 2026-02-17 18:59:00 +11:00
pelgraine 5ed1edc287 same changes as in audio branch - note to self, files are the same and are ready for branch merging 2026-02-17 18:58:09 +11:00
pelgraine ba057c8aad 45 m sleep timer; track queing from sub folders; better wav file name data extraction; changed autorefresh while playing to 30 seconds 2026-02-16 18:39:24 +11:00
pelgraine 8e91327230 msgread fix and newmsg alert suppresion when in repeater admin login page 2026-02-16 17:57:50 +11:00
pelgraine 7444a34e5a amended firmware version to align with other env variants 2026-02-15 19:37:01 +11:00
pelgraine 17bad1ddf7 using different HAS_BQ27220 for renderbatteryindicator 2026-02-15 19:35:34 +11:00
pelgraine a4c86d2d31 battery charge estimated runtime fix - 2 to 3 charge discharge cycles needed for full calibration to 1400mah 2026-02-15 09:31:43 +11:00
pelgraine 8dd661246a battery gauge page and fixes 2026-02-15 07:16:51 +11:00
pelgraine f07032182e fixed errant connected home display 2026-02-15 05:35:02 +11:00
pelgraine 1d39d9f314 ui fix 2026-02-15 02:21:27 +11:00
pelgraine a4285d2f24 bumped max contacts up to 700 2026-02-15 02:11:15 +11:00
pelgraine fad548b995 update firmware version and date 2026-02-15 02:01:03 +11:00
26 changed files with 143 additions and 7289 deletions
+13 -32
View File
@@ -1,6 +1,8 @@
## 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)
@@ -22,11 +24,10 @@ 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
@@ -250,7 +251,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.
The companion firmware can be connected to via BLE. USB is planned for a future update.
> **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.
@@ -264,6 +265,10 @@ The companion firmware can be connected to via BLE.
MeshCore is designed for devices listed in the [MeshCore Flasher](https://flasher.meshcore.co.uk)
## 📜 License
MeshCore is open-source software released under the MIT License. You are free to use, modify, and distribute it for personal and commercial projects.
## Contributing
Please submit PR's using 'dev' as the base branch!
@@ -285,35 +290,11 @@ 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
- [ ] 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
- [ ] 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
## 📞 Get Support
- 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.
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
-165
View File
@@ -1,165 +0,0 @@
## 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 (05) |
| In Call | 09, *, # | 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 (05). The
number keys **09**, **\***, and **#** send DTMF tones for navigating phone
menus and voicemail systems. Press **Enter** or **Q** to hang up.
Audio is routed through the A7682E modem's internal codec to the board speaker
and microphone — no headphones or external audio hardware are required.
### Receiving a Phone Call
When an incoming call arrives, the app automatically switches to the incoming
call screen regardless of which view is active. A short alert and buzzer
notification are triggered. The caller's name is shown if saved in contacts,
otherwise the raw phone number is displayed.
Press **Enter** to answer or **Q** to reject the call. If the call is not
answered it is logged as a missed call and a "Missed: ..." alert is shown
briefly.
### Contacts
The contacts directory lets you assign display names to phone numbers.
Names appear in the inbox list, conversation headers, 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
View File
@@ -1,57 +0,0 @@
# 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
```
+1 -2
View File
@@ -40,8 +40,7 @@ public:
void enableSerial() { _serial->enable(); }
void disableSerial() { _serial->disable(); }
virtual void msgRead(int msgcount) = 0;
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path = nullptr) = 0;
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void loop() = 0;
virtual void showAlert(const char* text, int duration_millis) {}
+4 -13
View File
@@ -439,8 +439,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
// we only want to show text messages on display, not cli data
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
if (should_display && _ui) {
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path);
_ui->newMsg(path_len, from.name, text, offline_queue_len);
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
}
#endif
@@ -526,11 +525,11 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text);
// Forward CLI response to UI admin screen if admin session is active
#ifdef DISPLAY_CLASS
#ifdef DISPLAY_CLASS
if (_admin_contact_idx >= 0 && _ui) {
_ui->onAdminCliResponse(from.name, text);
}
#endif
#endif
}
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
@@ -582,8 +581,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
channel_name = channel_details.name;
}
if (_ui) {
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
_ui->newMsg(path_len, channel_name, text, offline_queue_len, msg_path);
_ui->newMsg(path_len, channel_name, text, offline_queue_len);
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::channelMessage); //buzz if enabled
}
#endif
@@ -1183,8 +1181,6 @@ void MyMesh::handleCmdFrame(size_t len) {
memcpy(&msg_timestamp, &cmd_frame[i], 4);
i += 4;
const char *text = (char *)&cmd_frame[i];
int text_len = len - i;
cmd_frame[len] = '\0'; // Null-terminate for C string use
if (txt_type != TXT_TYPE_PLAIN) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
@@ -1193,11 +1189,6 @@ void MyMesh::handleCmdFrame(size_t len) {
bool success = getChannel(channel_idx, channel);
if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) {
writeOKFrame();
#ifdef DISPLAY_CLASS
if (_ui) {
_ui->addSentChannelMessage(channel_idx, _prefs.node_name, text);
}
#endif
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
}
+2 -2
View File
@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "24 Feb 2026"
#define FIRMWARE_BUILD_DATE "17 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.9.3"
#define FIRMWARE_VERSION "Meck v0.9.1A-NB"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+25 -417
View File
@@ -1,5 +1,4 @@
#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.)
@@ -17,9 +16,6 @@
#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);
@@ -37,11 +33,6 @@
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;
@@ -57,26 +48,13 @@
// Notes mode state
static bool notesMode = false;
// Audiobook player — Audio object is heap-allocated on first use to avoid
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
// 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
#include "AudiobookPlayerScreen.h"
#include "Audio.h"
Audio* audio = nullptr;
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
#if HAS_GPS
GPSDutyCycle gpsDuty;
@@ -350,7 +328,7 @@ void setup() {
}
MESH_DEBUG_PRINTLN("setup() - radio_init() done");
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
// CPU frequency scaling drop to 80 MHz for idle mesh listening
cpuPower.begin();
MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()");
@@ -437,44 +415,16 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
// ---------------------------------------------------------------------------
// Early SD card init — needed BEFORE the_mesh.begin() so we can restore
// Early SD card init — needed BEFORE the_mesh.begin() so we can restore
// settings from a previous firmware flash. The display SPI bus is already
// up (display.begin() ran earlier), so SD can share it now.
// ---------------------------------------------------------------------------
#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);
digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially
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) {
if (SD.begin(SDCARD_CS, displaySpi, 4000000)) {
sdCardReady = true;
MESH_DEBUG_PRINTLN("setup() - SD card initialized (early)");
@@ -483,7 +433,7 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - Settings restored from SD backup");
}
} else {
MESH_DEBUG_PRINTLN("setup() - SD card not available after 3 attempts");
MESH_DEBUG_PRINTLN("setup() - SD card not available");
}
}
#endif
@@ -586,32 +536,6 @@ 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
@@ -632,7 +556,7 @@ void setup() {
}
#endif
// GPS duty cycle — honour saved pref, default to enabled on first boot
// GPS duty cycle †honour saved pref, default to enabled on first boot
#if HAS_GPS
{
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
@@ -654,7 +578,7 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
#endif
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
Serial.printf("setup() complete free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
}
@@ -662,7 +586,7 @@ void setup() {
void loop() {
the_mesh.loop();
// GPS duty cycle — check for fix and manage power state
// GPS duty cycle †check for fix and manage power state
#if HAS_GPS
{
bool gps_hw_on = gpsDuty.loop();
@@ -681,7 +605,6 @@ void loop() {
cpuPower.loop();
// Audiobook: service audio decode regardless of which screen is active
#ifndef HAS_4G_MODEM
{
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
@@ -693,81 +616,6 @@ void loop() {
}
}
}
#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)
@@ -775,26 +623,10 @@ void loop() {
bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing();
bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming();
bool notesSuppressLoop = notesEditing || notesRenaming;
#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
) {
if (!composeMode && !notesSuppressLoop) {
ui_task.loop();
} else {
// Handle debounced screen refresh (compose, emoji picker, notes, or web reader text entry)
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
if (composeMode) {
if (emojiPickerMode) {
@@ -806,47 +638,15 @@ 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();
audiobookMode = ui_task.isOnAudiobookPlayer();
#ifdef HAS_4G_MODEM
smsMode = ui_task.isOnSMSScreen();
#endif
#else
ui_task.loop();
#endif
@@ -1037,7 +837,6 @@ void handleKeyboardInput() {
}
// *** AUDIOBOOK MODE ***
#ifndef HAS_4G_MODEM
if (audiobookMode) {
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
@@ -1049,11 +848,11 @@ void handleKeyboardInput() {
if (key == 'q') {
if (abPlayer->isBookOpen()) {
if (abPlayer->isAudioActive()) {
// Audio is playing — leave screen, audio continues via audioTick()
// Audio is playing leave screen, audio continues via audioTick()
Serial.println("Leaving audiobook player (audio continues in background)");
ui_task.gotoHomeScreen();
} else {
// Paused or stopped — close book, show file list
// Paused or stopped close book, show file list
abPlayer->closeCurrentBook();
Serial.println("Closed audiobook (was paused/stopped)");
// Stay on audiobook screen showing file list
@@ -1070,7 +869,6 @@ void handleKeyboardInput() {
ui_task.injectKey(key);
return;
}
#endif // !HAS_4G_MODEM
// *** TEXT READER MODE ***
if (readerMode) {
@@ -1247,7 +1045,7 @@ void handleKeyboardInput() {
return;
}
// All other keys → settings screen via injectKey
// All other keys → settings screen via injectKey
ui_task.injectKey(key);
return;
}
@@ -1302,93 +1100,6 @@ 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':
@@ -1409,74 +1120,20 @@ void handleKeyboardInput() {
ui_task.gotoTextReader();
break;
#ifndef HAS_4G_MODEM
case 'p':
// 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();
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
@@ -1493,13 +1150,9 @@ void handleKeyboardInput() {
break;
case 's':
// 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
// 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
} else {
Serial.println("Opening settings");
ui_task.gotoSettingsScreen();
@@ -1508,12 +1161,8 @@ void handleKeyboardInput() {
case 'w':
// Navigate up/previous (scroll on channel screen)
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
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
} else {
Serial.println("Nav: Previous");
ui_task.injectKey(0xF2); // KEY_PREV
@@ -1568,11 +1217,6 @@ void handleKeyboardInput() {
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
}
} else if (ui_task.isOnChannelScreen()) {
// Don't enter compose if path overlay is showing
ChannelScreen* chScr2 = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr2 && chScr2->isShowingPathOverlay()) {
break;
}
composeDM = false;
composeDMContactIdx = -1;
composeChannelIdx = ui_task.getChannelScreenViewIdx();
@@ -1590,25 +1234,6 @@ void handleKeyboardInput() {
case 'q':
case '\b':
// If channel screen path overlay is showing, dismiss it instead of going home
if (ui_task.isOnChannelScreen()) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr && chScr->isShowingPathOverlay()) {
ui_task.injectKey('q');
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();
@@ -1624,22 +1249,8 @@ void handleKeyboardInput() {
// UTC offset edit (home screen GPS page handles this)
ui_task.injectKey('u');
break;
case 'v':
// View path overlay (channel screen only)
if (ui_task.isOnChannelScreen()) {
ui_task.injectKey('v');
}
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;
}
@@ -1836,10 +1447,8 @@ void sendComposedMessage() {
// ============================================================================
// ESP32-audioI2S CALLBACKS
// ============================================================================
// The audio library calls these global functions - must be defined at file scope.
// Not available on 4G variant (no audio hardware).
// The audio library calls these global functions must be defined at file scope.
#ifndef HAS_4G_MODEM
void audio_info(const char *info) {
Serial.printf("Audio: %s\n", info);
}
@@ -1853,6 +1462,5 @@ void audio_eof_mp3(const char *info) {
abPlayer->onEOF();
}
}
#endif // !HAS_4G_MODEM
#endif // LilyGo_TDeck_Pro
@@ -1642,11 +1642,11 @@ public:
// E-ink refresh takes ~648ms during which audio.loop() can't run
// (SPI bus shared between display and SD card). This causes audible
// glitches during playback. Solution: during active playback, only
// auto-refresh once per minute (for time/progress updates). Key presses
// glitches during playback. Solution: during active playback, auto-refresh
// every 30 seconds for progress/time/sleep timer updates. Key presses
// always trigger immediate refresh regardless of this interval.
// When paused or stopped, refresh every 5s as normal.
if (_isPlaying && !_isPaused) return 60000; // 1 min between auto-refreshes
if (_isPlaying && !_isPaused) return 30000; // 30 sec between auto-refreshes
return 5000;
}
+16 -258
View File
@@ -14,7 +14,6 @@
// Maximum messages to store in history
#define CHANNEL_MSG_HISTORY_SIZE 300
#define CHANNEL_MSG_TEXT_LEN 160
#define MSG_PATH_MAX 20 // Max repeater hops stored per message
#ifndef MAX_GROUP_CHANNELS
#define MAX_GROUP_CHANNELS 20
@@ -24,7 +23,7 @@
// On-disk format for message persistence (SD card)
// ---------------------------------------------------------------------------
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
#define MSG_FILE_VERSION 3
#define MSG_FILE_VERSION 1
#define MSG_FILE_PATH "/meshcore/messages.bin"
struct __attribute__((packed)) MsgFileHeader {
@@ -42,9 +41,8 @@ struct __attribute__((packed)) MsgFileRecord {
uint8_t channel_idx;
uint8_t valid;
uint8_t reserved;
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key)
char text[CHANNEL_MSG_TEXT_LEN];
// 188 bytes total
// 168 bytes total
};
class UITask; // Forward declaration
@@ -57,7 +55,6 @@ public:
uint32_t timestamp;
uint8_t path_len;
uint8_t channel_idx; // Which channel this message belongs to
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
char text[CHANNEL_MSG_TEXT_LEN];
bool valid;
};
@@ -73,25 +70,21 @@ private:
int _msgsPerPage; // Messages that fit on screen
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), _pathOverlayScroll(0) {
_msgsPerPage(CHANNEL_MSG_HISTORY_SIZE), _viewChannelIdx(0), _sdReady(false) {
// Initialize all messages as invalid
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
_messages[i].valid = false;
memset(_messages[i].path, 0, MSG_PATH_MAX);
}
}
void setSDReady(bool ready) { _sdReady = ready; }
// Add a new message to the history
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
const uint8_t* path_bytes = nullptr) {
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text) {
// Move to next slot in circular buffer
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
@@ -101,13 +94,6 @@ public:
msg->channel_idx = channel_idx;
msg->valid = true;
// Store path hop hashes
memset(msg->path, 0, MSG_PATH_MAX);
if (path_bytes && path_len > 0 && path_len != 0xFF) {
int n = path_len < MSG_PATH_MAX ? path_len : MSG_PATH_MAX;
memcpy(msg->path, path_bytes, n);
}
// Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes
// The text already contains "Sender: message" format
emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN);
@@ -118,8 +104,6 @@ public:
// Reset scroll to show newest message
_scrollPos = 0;
_showPathOverlay = false; // Dismiss overlay on new message
_pathOverlayScroll = 0;
// Persist to SD card
saveToSD();
@@ -139,30 +123,14 @@ public:
int getMessageCount() const { return _msgCount; }
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
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
// (path_len != 0 means received, path_len 0 = locally sent)
ChannelMessage* getNewestReceivedMsg() {
for (int i = 0; i < _msgCount; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx
&& _messages[idx].path_len != 0) {
return &_messages[idx];
}
}
return nullptr;
}
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; }
// -----------------------------------------------------------------------
// SD card persistence
// -----------------------------------------------------------------------
// Save the entire message buffer to SD card.
// File: /meshcore/messages.bin (~56 KB for 300 messages)
// File: /meshcore/messages.bin (~50 KB for 300 messages)
void saveToSD() {
#if defined(HAS_SDCARD) && defined(ESP32)
if (!_sdReady) return;
@@ -195,7 +163,6 @@ public:
rec.channel_idx = _messages[i].channel_idx;
rec.valid = _messages[i].valid ? 1 : 0;
rec.reserved = 0;
memcpy(rec.path, _messages[i].path, MSG_PATH_MAX);
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
f.write((uint8_t*)&rec, sizeof(rec));
}
@@ -261,7 +228,6 @@ public:
_messages[i].path_len = rec.path_len;
_messages[i].channel_idx = rec.channel_idx;
_messages[i].valid = (rec.valid != 0);
memcpy(_messages[i].path, rec.path, MSG_PATH_MAX);
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
if (_messages[i].valid) loaded++;
}
@@ -314,151 +280,6 @@ public:
// Divider line
display.drawRect(0, 11, display.width(), 1);
// --- Path detail overlay ---
if (_showPathOverlay) {
display.setTextSize(0);
int lineH = 9;
int y = 14;
ChannelMessage* msg = getNewestReceivedMsg();
if (!msg) {
display.setCursor(0, y);
display.setColor(DisplayDriver::LIGHT);
display.print("No received messages");
} else {
// Message preview (first ~30 chars)
display.setCursor(0, y);
display.setColor(DisplayDriver::LIGHT);
char preview[32];
strncpy(preview, msg->text, 31);
preview[31] = '\0';
display.print(preview);
y += lineH;
// Age
uint32_t age = _rtc->getCurrentTime() - msg->timestamp;
display.setCursor(0, y);
display.setColor(DisplayDriver::YELLOW);
if (age < 60) sprintf(tmp, "Age: %ds", age);
else if (age < 3600) sprintf(tmp, "Age: %dm", age / 60);
else if (age < 86400) sprintf(tmp, "Age: %dh", age / 3600);
else sprintf(tmp, "Age: %dd", age / 86400);
display.print(tmp);
y += lineH;
// Route type
display.setCursor(0, y);
uint8_t plen = msg->path_len;
if (plen == 0xFF) {
display.setColor(DisplayDriver::LIGHT);
display.print("Route: Direct");
} else if (plen == 0) {
display.setColor(DisplayDriver::LIGHT);
display.print("Route: Local/Sent");
} else {
display.setColor(DisplayDriver::GREEN);
sprintf(tmp, "Route: %d hop%s", plen, plen == 1 ? "" : "s");
display.print(tmp);
}
y += lineH + 2;
// Show each hop resolved against contacts (scrollable)
if (plen > 0 && plen != 0xFF) {
int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX;
int footerHeight = 14;
int scrollBarW = 4;
int maxY = display.height() - footerHeight;
// 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);
sprintf(tmp, " %d: ", h + 1);
display.print(tmp);
// Try to resolve: prefer repeaters, then any contact
bool resolved = false;
int numContacts = the_mesh.getNumContacts();
ContactInfo contact;
// First pass: repeaters only
for (uint32_t ci = 0; ci < numContacts && !resolved; ci++) {
if (the_mesh.getContactByIdx(ci, contact)) {
if (contact.id.pub_key[0] == hopHash && contact.type == ADV_TYPE_REPEATER) {
display.setColor(DisplayDriver::GREEN);
display.print(contact.name);
resolved = true;
}
}
}
// Second pass: any contact type
if (!resolved) {
for (uint32_t ci = 0; ci < numContacts; ci++) {
if (the_mesh.getContactByIdx(ci, contact)) {
if (contact.id.pub_key[0] == hopHash) {
display.setColor(DisplayDriver::YELLOW);
display.print(contact.name);
resolved = true;
break;
}
}
}
}
// Fallback: show hex hash
if (!resolved) {
display.setColor(DisplayDriver::LIGHT);
sprintf(tmp, "?%02X", hopHash);
display.print(tmp);
}
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);
}
}
}
// Overlay footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
display.print("Q:Back W/S:Scroll");
#if AUTO_OFF_MILLIS == 0
return 5000;
#else
return 1000;
#endif
}
if (channelMsgCount == 0) {
display.setTextSize(0); // Tiny font for body text
display.setCursor(0, 20);
@@ -474,7 +295,6 @@ public:
int lineHeight = 9; // 8px font + 1px spacing
int headerHeight = 14;
int footerHeight = 14;
int scrollBarW = 4; // Width of scroll indicator on right edge
// Hard cutoff: no text may START at or beyond this y value
// This ensures rendered glyphs (which extend lineHeight below y) stay above the footer
int maxY = display.height() - footerHeight;
@@ -482,8 +302,7 @@ public:
int y = headerHeight;
// Build list of messages for this channel (newest first)
// Static to avoid 1200-byte stack allocation every render cycle
static int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
int numChannelMsgs = 0;
for (int i = 0; i < _msgCount && numChannelMsgs < CHANNEL_MSG_HISTORY_SIZE; i++) {
@@ -501,10 +320,6 @@ public:
int tmp = channelMsgs[l]; channelMsgs[l] = channelMsgs[r]; channelMsgs[r] = tmp;
}
// Clamp scroll position to valid range
int maxScroll = numChannelMsgs > _msgsPerPage ? numChannelMsgs - _msgsPerPage : 0;
if (_scrollPos > maxScroll) _scrollPos = maxScroll;
// Calculate start index so newest messages appear at the bottom
// scrollPos=0 shows the most recent messages, scrollPos++ scrolls up to older
int startIdx = numChannelMsgs - _msgsPerPage - _scrollPos;
@@ -546,7 +361,7 @@ public:
// Track position in pixels for emoji placement
// Uses advance width (cursor movement) not bounding box for px tracking
int lineW = display.width() - scrollBarW - 1; // Reserve space for scroll bar
int lineW = display.width();
int px = display.getTextWidth(tmp); // Pixel X after timestamp
char dblStr[3] = {0, 0, 0};
@@ -645,39 +460,13 @@ public:
if (y + lineHeight > maxY) screenFull = true;
}
// Only update _msgsPerPage when at the bottom (scrollPos==0) and the
// screen actually filled up. While scrolled, freezing _msgsPerPage
// prevents a feedback loop where variable-height messages cause
// msgsPerPage to oscillate, shifting startIdx every render (flicker).
if (screenFull && msgsDrawn > 0 && _scrollPos == 0) {
// Only update _msgsPerPage when the screen actually filled up.
// If we ran out of messages before filling the screen, keep the
// previous (higher) value so startIdx doesn't under-count.
if (screenFull && msgsDrawn > 0) {
_msgsPerPage = msgsDrawn;
}
// --- Scroll bar (emoji picker style) ---
int sbX = display.width() - scrollBarW;
int sbTop = headerHeight;
int sbHeight = maxY - headerHeight;
// Draw track outline
display.setColor(DisplayDriver::LIGHT);
display.drawRect(sbX, sbTop, scrollBarW, sbHeight);
if (channelMsgCount > _msgsPerPage) {
// Scrollable: draw proportional thumb
int maxScroll = channelMsgCount - _msgsPerPage;
if (maxScroll < 1) maxScroll = 1;
int thumbH = (_msgsPerPage * sbHeight) / channelMsgCount;
if (thumbH < 4) thumbH = 4;
// _scrollPos=0 is newest (bottom), so invert for thumb position
int thumbY = sbTop + ((maxScroll - _scrollPos) * (sbHeight - thumbH)) / maxScroll;
for (int ty = thumbY + 1; ty < thumbY + thumbH - 1; ty++)
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
} else {
// All messages fit: fill entire track
for (int ty = sbTop + 1; ty < sbTop + sbHeight - 1; ty++)
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
}
display.setTextSize(1); // Restore for footer
}
@@ -687,11 +476,11 @@ public:
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
// Left side: abbreviated controls
display.print("Q:Bck A/D:Ch V:Pth");
// Left side: Q:Back A/D:Ch
display.print("Q:Back A/D:Ch");
// Right side: Ent:New
const char* rightText = "Ent:New";
// Right side: Entr:New
const char* rightText = "Entr:New";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
@@ -703,39 +492,8 @@ public:
}
bool handleInput(char c) override {
// If overlay is showing, only handle dismiss
if (_showPathOverlay) {
if (c == 'q' || c == 'Q' || c == '\b' || c == 'v' || c == 'V') {
_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
}
int channelMsgCount = getMessageCountForChannel();
// V - show path detail for last received message
if (c == 'v' || c == 'V') {
if (getNewestReceivedMsg() != nullptr) {
_showPathOverlay = true;
_pathOverlayScroll = 0;
return true;
}
return false; // No received messages to show
}
// W or KEY_PREV - scroll up (older messages)
if (c == 0xF2 || c == 'w' || c == 'W') {
if (_scrollPos + _msgsPerPage < channelMsgCount) {
@@ -30,7 +30,7 @@ private:
// Cached filtered contact indices for efficient scrolling
// We rebuild this on filter change or when entering the screen
static const int MAX_VISIBLE = 400; // matches MAX_CONTACTS build flag
static const int MAX_VISIBLE = 700; // must be >= MAX_CONTACTS build flag
uint16_t _filteredIdx[MAX_VISIBLE]; // indices into contact table
uint32_t _filteredTs[MAX_VISIBLE]; // cached last_advert_timestamp for sorting
int _filteredCount; // how many contacts match current filter
@@ -88,7 +88,7 @@ private:
}
}
// Sort by last_advert_timestamp descending (most recently seen first)
// Simple insertion sort — fine for up to 400 entries on ESP32
// Simple insertion sort - fine for up to 700 entries on ESP32
for (int i = 1; i < _filteredCount; i++) {
uint16_t tmpIdx = _filteredIdx[i];
uint32_t tmpTs = _filteredTs[i];
File diff suppressed because it is too large Load Diff
@@ -1,222 +0,0 @@
#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
@@ -1,8 +0,0 @@
#ifdef HAS_4G_MODEM
#include "SMSContacts.h"
// Global singleton
SMSContactStore smsContacts;
#endif // HAS_4G_MODEM
@@ -1,176 +0,0 @@
#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
File diff suppressed because it is too large Load Diff
@@ -1,196 +0,0 @@
#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
@@ -1,87 +0,0 @@
#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,10 +6,6 @@
#include <MeshCore.h>
#include "../NodePrefs.h"
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#endif
// Forward declarations
class UITask;
class MyMesh;
@@ -60,9 +56,6 @@ 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"
@@ -92,7 +85,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
@@ -116,14 +109,9 @@ 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
// ---------------------------------------------------------------------------
@@ -140,9 +128,6 @@ 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
@@ -227,11 +212,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++) {
@@ -304,9 +289,6 @@ public:
_cursor = 0;
_scrollTop = 0;
_radioChanged = false;
#ifdef HAS_4G_MODEM
_modemEnabled = ModemManager::loadEnabledConfig();
#endif
rebuildRows();
}
@@ -491,14 +473,6 @@ 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 ---");
@@ -864,19 +838,6 @@ 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;
@@ -900,7 +861,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;
+9 -117
View File
@@ -37,13 +37,7 @@
#include "ContactsScreen.h"
#include "TextReaderScreen.h"
#include "SettingsScreen.h"
#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;
@@ -118,7 +112,6 @@ 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];
@@ -174,7 +167,6 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
display.setTextSize(1); // restore default text size
}
#ifdef MECK_AUDIO_VARIANT
// ---- Audio background playback indicator ----
// Shows a small play symbol to the left of the battery icon when an
// audiobook is actively playing in the background.
@@ -190,7 +182,6 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
display.print(">>");
display.setTextSize(1); // restore
}
#endif
CayenneLPP sensors_lpp;
int sensors_nb = 0;
@@ -222,7 +213,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), _shutdown_at(0), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
_shutdown_init(false), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
bool isEditingUTC() const { return _editing_utc; }
void cancelEditUTC() {
@@ -233,7 +224,7 @@ public:
}
void poll() override {
if (_shutdown_init && millis() >= _shutdown_at && !_task->isButtonPressed()) {
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
_task->shutdown();
}
}
@@ -249,15 +240,11 @@ public:
display.print(filtered_name);
// battery voltage
#ifdef MECK_AUDIO_VARIANT
int battLeftX = display.width(); // default if battery doesn't render
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
// audio background playback indicator (>> icon next to battery)
renderAudioIndicator(display, battLeftX);
#else
renderBatteryIndicator(display, _task->getBattMilliVolts());
#endif
// centered clock (tinyfont) - only show when time is valid
{
@@ -335,17 +322,7 @@ public:
y += 10;
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] 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;
// Nav hint
@@ -402,19 +379,8 @@ 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, 72, "toggle: " PRESS_LABEL);
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
#endif
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::GREEN);
@@ -647,13 +613,6 @@ 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);
@@ -752,8 +711,7 @@ public:
}
#endif
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
_shutdown_init = true;
_shutdown_at = millis() + 900; // allow e-ink refresh (644ms) before shutdown
_shutdown_init = true; // need to wait for button to be released
return true;
}
return false;
@@ -911,9 +869,6 @@ 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);
}
@@ -960,8 +915,7 @@ void UITask::msgRead(int msgcount) {
}
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
_msgcount = msgcount;
// Add to preview screen (for notifications on non-keyboard devices)
@@ -979,8 +933,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
}
}
// Add to channel history screen with channel index and path data
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
// Add to channel history screen with channel index
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text);
#if defined(LilyGo_TDeck_Pro)
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
@@ -1062,31 +1016,8 @@ void UITask::shutdown(bool restart){
if (restart) {
_board->reboot();
} else {
// 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();
radio_driver.powerOff();
_board->powerOff();
}
}
@@ -1427,7 +1358,6 @@ void UITask::gotoOnboarding() {
}
void UITask::gotoAudiobookPlayer() {
#ifdef MECK_AUDIO_VARIANT
if (audiobook_screen == nullptr) return; // No audio hardware
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
if (_display != NULL) {
@@ -1439,22 +1369,8 @@ void UITask::gotoAudiobookPlayer() {
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
#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();
}
@@ -1493,28 +1409,6 @@ 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);
@@ -1529,7 +1423,6 @@ void UITask::onAdminCliResponse(const char* from_name, const char* text) {
}
}
#ifdef MECK_AUDIO_VARIANT
bool UITask::isAudioPlayingInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
@@ -1540,5 +1433,4 @@ bool UITask::isAudioPausedInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isBookOpen() && !player->isAudioActive();
}
#endif
}
+1 -32
View File
@@ -22,14 +22,6 @@
#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;
@@ -66,13 +58,7 @@ 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();
@@ -104,14 +90,6 @@ 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; }
@@ -124,15 +102,10 @@ 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)
bool isAudioPlayingInBackground() const;
bool isAudioPausedInBackground() const;
#endif
uint8_t getChannelScreenViewIdx() const;
void toggleBuzzer();
@@ -163,14 +136,10 @@ 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;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path = nullptr) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;
File diff suppressed because it is too large Load Diff
@@ -1,16 +0,0 @@
// 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
+4 -6
View File
@@ -27,7 +27,6 @@ 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
@@ -59,7 +58,6 @@ 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}
@@ -69,10 +67,10 @@ lib_deps =
file://arch/esp32/AsyncElegantOTA
; esp32c6 uses arduino framework 3.x
; WARNING: experimental. May not work as stable as other platforms.
; WARNING: experimental. pioarduino on esp32c6 needs work - it's not considered stable and has issues.
[esp32c6_base]
extends = esp32_base
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.12/platform-espressif32.zip
; ----------------- NRF52 ---------------------
@@ -81,7 +79,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}
@@ -149,4 +147,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
+2 -12
View File
@@ -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,14 +353,4 @@ 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
}
-4
View File
@@ -7,7 +7,6 @@
#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
@@ -83,9 +82,6 @@ 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);
+56 -41
View File
@@ -80,7 +80,7 @@ build_flags =
-D PIN_DISPLAY_BL=45
-D PIN_USER_BTN=0
-D CST328_PIN_RST=38
-D FIRMWARE_VERSION='"Meck v0.9.3A"'
-D FIRMWARE_VERSION='"Meck v0.9.1A"'
-D ARDUINO_LOOP_STACK_SIZE=32768
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_TDeck_Pro>
@@ -92,37 +92,7 @@ lib_deps =
adafruit/Adafruit GFX Library@^1.11.0
bakercp/CRC32@^2.0.0
; ---------------------------------------------------------------------------
; Meck unified builds — one codebase, three variants via build flags
; ---------------------------------------------------------------------------
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
[env:meck_audio_ble]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=400
-D MAX_GROUP_CHANNELS=20
-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>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
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]
[env:LilyGo_TDeck_Pro_companion_radio_usb]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
@@ -130,7 +100,25 @@ build_flags =
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_Pro_companion_radio_ble]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=400
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -143,19 +131,22 @@ lib_deps =
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A)
[env:meck_4g_ble]
[env:LilyGo_TDeck_Pro_standalone]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=400
-D MAX_CONTACTS=700
-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"'
-D NO_OTA=1
-D FIRMWARE_VERSION='"Meck v0.9.1A-NB"'
; === NO BLE_PIN_CODE, NO WIFI_SSID ===
; By omitting these, the preprocessor selects ArduinoSerialInterface (USB only).
; The BLE stack is never initialized, never linked, and the ESP32-S3 BT/WiFi
; radio hardware stays in its boot-default OFF state — zero RF power draw.
; This is the ONLY reliable way to fully disable BLE on the ESP32-S3.
; Calling esp_bt_controller_disable() after init does NOT fully power off the radio.
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -164,4 +155,28 @@ 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
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
; Audio libs included because AudiobookPlayerScreen.h unconditionally #includes
; Audio.h when LilyGo_TDeck_Pro is defined. No power cost — the Audio object is
; only heap-allocated when user presses 'P'. Without BLE competing for heap,
; audiobook playback actually works better in standalone mode.
[env:LilyGo_TDeck_Pro_repeater]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-D ADVERT_NAME='"TDeck Pro Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D NO_OTA=1
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
me-no-dev/AsyncTCP @ ^1.1.1
me-no-dev/ESPAsyncWebServer @ ^1.2.3