7 Commits

23 changed files with 141 additions and 5627 deletions

View File

@@ -1,78 +0,0 @@
## Audiobook Player (Audio variant only)
Press **P** from the home screen to open the audiobook player.
Place `.mp3`, `.m4b`, `.m4a`, or `.wav` files in `/audiobooks/` on the SD card.
Files can be organised into subfolders (e.g. by author) — use **Enter** to
browse into folders and **.. (up)** to go back.
| Key | Action |
|-----|--------|
| W / S | Scroll file list / Volume up-down |
| Enter | Select book or folder / Play-Pause |
| A | Seek back 30 seconds |
| D | Seek forward 30 seconds |
| [ | Previous chapter (M4B only) |
| ] | Next chapter (M4B only) |
| Q | Leave player (audio continues) / Close book (when paused) / Exit (from file list) |
### Recommended Format
**MP3 is the recommended format.** M4B/M4A files are supported but currently
have playback issues with the ESP32-audioI2S library — some files may fail to
decode or produce silence. MP3 files play reliably and are the safest choice.
MP3 files should be encoded at a **44100 Hz sample rate**. Lower sample rates
(e.g. 22050 Hz) can cause distortion or playback failure due to ESP32-S3 I2S
hardware limitations.
**Bookmarks** are saved automatically every 30 seconds during playback and when
you stop or exit. Reopening a book resumes from your last position.
**Cover art** from M4B files is displayed as dithered monochrome on the e-ink
screen, along with title, author, and chapter information.
**Metadata caching** — the first time you open the audiobook player, it reads
title and author tags from each file (which can take a few seconds with many
files). This metadata is cached to the SD card so subsequent visits load
near-instantly. If you add or remove files the cache updates automatically.
### Background Playback
Audio continues playing when you leave the audiobook player screen. Press **Q**
while audio is playing to return to the home screen — a **>>** indicator will
appear in the status bar next to the battery icon to show that audio is active
in the background. Press **P** at any time to return to the player screen and
resume control.
If you pause or stop playback first and then press **Q**, the book is closed
and you're returned to the file list instead.
### Audio Hardware
The audiobook player uses the PCM5102A I2S DAC on the audio variant of the
T-Deck Pro (I2S pins: BCLK=7, DOUT=8, LRC=9). Audio is output via the 3.5mm
headphone jack.
> **Note:** The audiobook player is not available on the 4G modem variant
> due to I2S pin conflicts.
### SD Card Folder Structure
```
SD Card
├── audiobooks/
│ ├── .bookmarks/ (auto-created, stores resume positions)
│ │ ├── mybook.bmk
│ │ └── another.bmk
│ ├── .metacache (auto-created, speeds up file list loading)
│ ├── Ann Leckie/
│ │ ├── Ancillary Justice.mp3
│ │ └── Ancillary Sword.mp3
│ ├── Iain M. Banks/
│ │ └── The Algebraist.mp3
│ ├── mybook.mp3
│ └── podcast.mp3
├── books/ (existing — text reader)
│ └── ...
└── ...
```

View File

@@ -1,116 +0,0 @@
## SMS App (4G variant only) - Meck v0.9.2 (Alpha)
Press **T** from the home screen to open the SMS 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 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 | 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 | Q | Back to inbox |
| Edit Contact | Enter | Save contact name |
| Edit Contact | Shift+Del | Cancel without saving |
### 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.
### Contacts
The contacts directory lets you assign display names to phone numbers.
Names appear in the inbox list, conversation headers, and compose screen
instead of raw numbers.
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 app remains accessible when the modem
is off but will not be able to send or receive messages.
### Signal Indicator
A signal strength indicator is shown in the top-right corner of all SMS
screens. Bars are derived from the modem's CSQ (signal quality) reading,
updated every 30 seconds. The modem state (REG, READY, OFF, etc.) is shown
when not yet connected.
### 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 |
> **Note:** The SMS app is only available on the 4G modem variant of the
> T-Deck Pro. It is not present on the audio or standalone BLE builds due to
> shared GPIO pin conflicts between the A7682E modem and PCM5102A DAC.

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) {}

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

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "20 Feb 2026"
#define FIRMWARE_BUILD_DATE "15 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.9.2"
#define FIRMWARE_VERSION "Meck v0.8.9"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)

View File

@@ -29,5 +29,4 @@ struct NodePrefs { // persisted to file
uint32_t gps_interval; // GPS read interval in seconds
uint8_t autoadd_config; // bitmask for auto-add contacts config
int8_t utc_offset_hours; // UTC offset in hours (-12 to +14), default 0
uint8_t kb_flash_notify; // Keyboard backlight flash on new message (0=off, 1=on)
};

View File

@@ -48,26 +48,6 @@
// 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
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;
@@ -341,9 +321,6 @@ void setup() {
}
MESH_DEBUG_PRINTLN("setup() - radio_init() done");
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
cpuPower.begin();
MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()");
fast_rng.begin(radio_get_rng_seed());
MESH_DEBUG_PRINTLN("setup() - fast_rng.begin() done");
@@ -542,39 +519,8 @@ void setup() {
notesScr->setSDReady(true);
}
// Audiobook player screen creation is deferred to first use (case 'p' in
// handleKeyboardInput) to avoid allocating Audio I2S/DMA buffers at boot,
// which would starve BLE of heap memory.
MESH_DEBUG_PRINTLN("setup() - Audiobook player deferred (lazy init on first use)");
// 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
@@ -610,6 +556,9 @@ void setup() {
}
#endif
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
cpuPower.begin();
// T-Deck Pro: BLE starts disabled for standalone-first operation
// User can toggle it on from the Bluetooth home page (Enter or long-press)
#if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE)
@@ -617,8 +566,6 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
#endif
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
}
@@ -642,43 +589,6 @@ void loop() {
// CPU frequency auto-timeout back to idle
cpuPower.loop();
// Audiobook: service audio decode regardless of which screen is active
#ifndef HAS_4G_MODEM
{
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
if (abPlayer) {
abPlayer->audioTick();
// Keep CPU at high freq during active audio decode
if (abPlayer->isAudioActive()) {
cpuPower.setBoost();
}
}
}
#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);
}
}
#endif
#ifdef DISPLAY_CLASS
// Skip UITask rendering when in compose mode to prevent flickering
#if defined(LilyGo_TDeck_Pro)
@@ -686,12 +596,7 @@ 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
if (!composeMode && !notesSuppressLoop && !smsSuppressLoop) {
if (!composeMode && !notesSuppressLoop) {
ui_task.loop();
} else {
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
@@ -706,25 +611,14 @@ 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
#ifdef DISPLAY_CLASS
display.startFrame();
((SMSScreen*)ui_task.getSMSScreen())->render(display);
display.endFrame();
#endif
}
lastComposeRefresh = millis();
composeNeedsRefresh = false;
}
}
// Track reader/notes/audiobook mode state for key routing
// Track reader/notes 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
@@ -914,42 +808,6 @@ void handleKeyboardInput() {
return;
}
// *** AUDIOBOOK MODE ***
#ifndef HAS_4G_MODEM
if (audiobookMode) {
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
// Q key: behavior depends on playback state
// - Playing: navigate home, audio continues in background
// - Paused/stopped: close book, return to file list
// - File list: exit player entirely
if (key == 'q') {
if (abPlayer->isBookOpen()) {
if (abPlayer->isAudioActive()) {
// 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
abPlayer->closeCurrentBook();
Serial.println("Closed audiobook (was paused/stopped)");
// Stay on audiobook screen showing file list
}
} else {
abPlayer->exitPlayer();
Serial.println("Exiting audiobook player");
ui_task.gotoHomeScreen();
}
return;
}
// All other keys pass through to the player screen
ui_task.injectKey(key);
return;
}
#endif // !HAS_4G_MODEM
// *** TEXT READER MODE ***
if (readerMode) {
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
@@ -1180,40 +1038,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) {
// 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
// Normal mode - not composing
switch (key) {
case 'c':
@@ -1234,31 +1058,6 @@ void handleKeyboardInput() {
ui_task.gotoTextReader();
break;
#ifndef HAS_4G_MODEM
case 'p':
// 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",
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());
}
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
case 'n':
// Open notes
Serial.println("Opening notes");
@@ -1315,7 +1114,7 @@ void handleKeyboardInput() {
case '\r':
// Select/Enter - if on contacts screen, enter DM compose for chat contacts
// or repeater admin for repeater contacts
// or repeater admin for repeater contacts
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
int idx = cs->getSelectedContactIdx();
@@ -1341,11 +1140,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();
@@ -1363,14 +1157,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;
}
}
// Go back to home screen (admin mode handled above)
Serial.println("Nav: Back to home");
ui_task.gotoHomeScreen();
@@ -1386,13 +1172,6 @@ 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:
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
@@ -1588,26 +1367,4 @@ 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).
#ifndef HAS_4G_MODEM
void audio_info(const char *info) {
Serial.printf("Audio: %s\n", info);
}
void audio_eof_mp3(const char *info) {
Serial.printf("Audio: End of file - %s\n", info);
// Signal the player screen for auto-advance to next track
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
if (abPlayer) {
abPlayer->onEOF();
}
}
#endif // !HAS_4G_MODEM
#endif // LilyGo_TDeck_Pro

File diff suppressed because it is too large Load Diff

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 8 // 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 2
#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];
// 176 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,24 +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
public:
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false) {
_msgsPerPage(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;
@@ -100,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);
@@ -117,7 +104,6 @@ public:
// Reset scroll to show newest message
_scrollPos = 0;
_showPathOverlay = false; // Dismiss overlay on new message
// Persist to SD card
saveToSD();
@@ -137,23 +123,7 @@ public:
int getMessageCount() const { return _msgCount; }
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; _showPathOverlay = false; }
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
@@ -193,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));
}
@@ -259,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++;
}
@@ -312,120 +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
if (plen > 0 && plen != 0xFF) {
int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX;
int maxY = display.height() - 26;
for (int h = 0; 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;
}
}
}
// 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");
#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);
@@ -441,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;
@@ -449,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++) {
@@ -468,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;
@@ -513,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};
@@ -612,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
}
@@ -654,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);
@@ -670,26 +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;
}
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;
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) {

View File

@@ -1,651 +0,0 @@
#pragma once
// =============================================================================
// M4BMetadata.h - Lightweight MP4/M4B atom parser for metadata extraction
//
// Walks the MP4 atom (box) tree to extract:
// - Title (moov/udta/meta/ilst/©nam)
// - Author (moov/udta/meta/ilst/©ART)
// - Cover art (moov/udta/meta/ilst/covr) - JPEG offset+size within file
// - Duration (moov/mvhd timescale + duration)
// - Chapter markers (moov/udta/chpl) - Nero-style chapter list
//
// Designed for embedded use: no dynamic allocation, reads directly from SD
// via Arduino File API, uses a small stack buffer for atom headers.
//
// Usage:
// M4BMetadata meta;
// File f = SD.open("/audiobooks/mybook.m4b");
// if (meta.parse(f)) {
// Serial.printf("Title: %s\n", meta.title);
// Serial.printf("Author: %s\n", meta.author);
// if (meta.hasCoverArt) {
// // JPEG data is at meta.coverOffset, meta.coverSize bytes
// }
// }
// f.close();
// =============================================================================
#include <SD.h>
// Maximum metadata string lengths (including null terminator)
#define M4B_MAX_TITLE 128
#define M4B_MAX_AUTHOR 64
#define M4B_MAX_CHAPTERS 100
struct M4BChapter {
uint32_t startMs; // Chapter start time in milliseconds
char name[48]; // Chapter title (truncated to fit)
};
class M4BMetadata {
public:
// Extracted metadata
char title[M4B_MAX_TITLE];
char author[M4B_MAX_AUTHOR];
bool hasCoverArt;
uint32_t coverOffset; // Byte offset of JPEG/PNG data within file
uint32_t coverSize; // Size of cover image data in bytes
uint8_t coverFormat; // 13=JPEG, 14=PNG (from MP4 well-known type)
uint32_t durationMs; // Total duration in milliseconds
uint32_t sampleRate; // Audio sample rate (from audio stsd)
uint32_t bitrate; // Approximate bitrate in bps
// Chapter data
M4BChapter chapters[M4B_MAX_CHAPTERS];
int chapterCount;
M4BMetadata() { clear(); }
void clear() {
title[0] = '\0';
author[0] = '\0';
hasCoverArt = false;
coverOffset = 0;
coverSize = 0;
coverFormat = 0;
durationMs = 0;
sampleRate = 44100;
bitrate = 0;
chapterCount = 0;
}
// Parse an open file. Returns true if at least title or duration was found.
// File position is NOT preserved — caller should seek as needed afterward.
bool parse(File& file) {
clear();
if (!file || file.size() < 8) return false;
_fileSize = file.size();
// Walk top-level atoms looking for 'moov'
uint32_t pos = 0;
while (pos < _fileSize) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_MOOV) {
parseMoov(file, hdr.dataOffset, hdr.dataOffset + hdr.dataSize);
break; // moov found and parsed, we're done
}
// Skip to next top-level atom
pos += hdr.size;
if (hdr.size == 0) break; // size=0 means "extends to EOF"
}
return (title[0] != '\0' || durationMs > 0);
}
// Get chapter index for a given playback position (milliseconds).
// Returns -1 if no chapters or position is before first chapter.
int getChapterForPosition(uint32_t positionMs) const {
if (chapterCount == 0) return -1;
int ch = 0;
for (int i = 1; i < chapterCount; i++) {
if (chapters[i].startMs > positionMs) break;
ch = i;
}
return ch;
}
// Get the start position of the next chapter after the given position.
// Returns 0 if no next chapter.
uint32_t getNextChapterMs(uint32_t positionMs) const {
for (int i = 0; i < chapterCount; i++) {
if (chapters[i].startMs > positionMs) return chapters[i].startMs;
}
return 0;
}
// Get the start position of the current or previous chapter.
uint32_t getPrevChapterMs(uint32_t positionMs) const {
uint32_t prev = 0;
for (int i = 0; i < chapterCount; i++) {
if (chapters[i].startMs >= positionMs) break;
prev = chapters[i].startMs;
}
return prev;
}
private:
uint32_t _fileSize;
// MP4 atom type codes (big-endian FourCC)
static constexpr uint32_t ATOM_MOOV = 0x6D6F6F76; // 'moov'
static constexpr uint32_t ATOM_MVHD = 0x6D766864; // 'mvhd'
static constexpr uint32_t ATOM_UDTA = 0x75647461; // 'udta'
static constexpr uint32_t ATOM_META = 0x6D657461; // 'meta'
static constexpr uint32_t ATOM_ILST = 0x696C7374; // 'ilst'
static constexpr uint32_t ATOM_NAM = 0xA96E616D; // '©nam'
static constexpr uint32_t ATOM_ART = 0xA9415254; // '©ART'
static constexpr uint32_t ATOM_COVR = 0x636F7672; // 'covr'
static constexpr uint32_t ATOM_DATA = 0x64617461; // 'data'
static constexpr uint32_t ATOM_CHPL = 0x6368706C; // 'chpl' (Nero chapters)
static constexpr uint32_t ATOM_TRAK = 0x7472616B; // 'trak'
static constexpr uint32_t ATOM_MDIA = 0x6D646961; // 'mdia'
static constexpr uint32_t ATOM_MDHD = 0x6D646864; // 'mdhd'
static constexpr uint32_t ATOM_HDLR = 0x68646C72; // 'hdlr'
struct AtomHeader {
uint32_t type;
uint64_t size; // Total atom size including header
uint32_t dataOffset; // File offset where data begins (after header)
uint64_t dataSize; // size - header_length
};
// Read a 32-bit big-endian value from file at current position
static uint32_t readU32BE(File& file) {
uint8_t buf[4];
file.read(buf, 4);
return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) |
((uint32_t)buf[2] << 8) | buf[3];
}
// Read a 64-bit big-endian value
static uint64_t readU64BE(File& file) {
uint32_t hi = readU32BE(file);
uint32_t lo = readU32BE(file);
return ((uint64_t)hi << 32) | lo;
}
// Read a 16-bit big-endian value
static uint16_t readU16BE(File& file) {
uint8_t buf[2];
file.read(buf, 2);
return ((uint16_t)buf[0] << 8) | buf[1];
}
// Read atom header at given file offset
bool readAtomHeader(File& file, uint32_t offset, AtomHeader& hdr) {
if (offset + 8 > _fileSize) return false;
file.seek(offset);
uint32_t size32 = readU32BE(file);
hdr.type = readU32BE(file);
if (size32 == 1) {
// 64-bit extended size
if (offset + 16 > _fileSize) return false;
hdr.size = readU64BE(file);
hdr.dataOffset = offset + 16;
hdr.dataSize = (hdr.size > 16) ? hdr.size - 16 : 0;
} else if (size32 == 0) {
// Atom extends to end of file
hdr.size = _fileSize - offset;
hdr.dataOffset = offset + 8;
hdr.dataSize = hdr.size - 8;
} else {
hdr.size = size32;
hdr.dataOffset = offset + 8;
hdr.dataSize = (size32 > 8) ? size32 - 8 : 0;
}
return true;
}
// Parse the moov container atom
void parseMoov(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
switch (hdr.type) {
case ATOM_MVHD:
parseMvhd(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
break;
case ATOM_UDTA:
parseUdta(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
break;
case ATOM_TRAK:
break;
}
pos += (uint32_t)hdr.size;
}
}
// Parse mvhd (movie header) for duration
void parseMvhd(File& file, uint32_t offset, uint32_t size) {
file.seek(offset);
uint8_t version = file.read();
if (version == 0) {
file.seek(offset + 4); // skip version(1) + flags(3)
/* create_time */ readU32BE(file);
/* modify_time */ readU32BE(file);
uint32_t timescale = readU32BE(file);
uint32_t duration = readU32BE(file);
if (timescale > 0) {
durationMs = (uint32_t)((uint64_t)duration * 1000 / timescale);
}
} else if (version == 1) {
file.seek(offset + 4);
/* create_time */ readU64BE(file);
/* modify_time */ readU64BE(file);
uint32_t timescale = readU32BE(file);
uint64_t duration = readU64BE(file);
if (timescale > 0) {
durationMs = (uint32_t)(duration * 1000 / timescale);
}
}
}
// Parse udta container — contains meta and/or chpl
void parseUdta(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_META) {
parseMeta(file, hdr.dataOffset + 4,
hdr.dataOffset + (uint32_t)hdr.dataSize);
} else if (hdr.type == ATOM_CHPL) {
parseChpl(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
}
pos += (uint32_t)hdr.size;
}
}
// Parse meta container — contains hdlr + ilst
void parseMeta(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_ILST) {
parseIlst(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
}
pos += (uint32_t)hdr.size;
}
}
// Parse ilst (iTunes metadata list) — contains ©nam, ©ART, covr etc.
void parseIlst(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
switch (hdr.type) {
case ATOM_NAM:
extractTextData(file, hdr.dataOffset,
hdr.dataOffset + (uint32_t)hdr.dataSize,
title, M4B_MAX_TITLE);
break;
case ATOM_ART:
extractTextData(file, hdr.dataOffset,
hdr.dataOffset + (uint32_t)hdr.dataSize,
author, M4B_MAX_AUTHOR);
break;
case ATOM_COVR:
extractCoverData(file, hdr.dataOffset,
hdr.dataOffset + (uint32_t)hdr.dataSize);
break;
}
pos += (uint32_t)hdr.size;
}
}
// Extract text from a 'data' sub-atom within an ilst entry.
void extractTextData(File& file, uint32_t start, uint32_t end,
char* dest, int maxLen) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
uint32_t textOffset = hdr.dataOffset + 8;
uint32_t textLen = (uint32_t)hdr.dataSize - 8;
if (textLen > (uint32_t)(maxLen - 1)) textLen = maxLen - 1;
file.seek(textOffset);
file.read((uint8_t*)dest, textLen);
dest[textLen] = '\0';
return;
}
pos += (uint32_t)hdr.size;
}
}
// Extract cover art location from 'data' sub-atom within covr.
void extractCoverData(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
file.seek(hdr.dataOffset);
uint32_t typeIndicator = readU32BE(file);
uint8_t wellKnownType = typeIndicator & 0xFF;
coverOffset = hdr.dataOffset + 8;
coverSize = (uint32_t)hdr.dataSize - 8;
coverFormat = wellKnownType; // 13=JPEG, 14=PNG
hasCoverArt = (coverSize > 0);
Serial.printf("M4B: Cover art found - %s, %u bytes at offset %u\n",
wellKnownType == 13 ? "JPEG" :
wellKnownType == 14 ? "PNG" : "unknown",
coverSize, coverOffset);
return;
}
pos += (uint32_t)hdr.size;
}
}
// =====================================================================
// ID3v2 Parser for MP3 files
// =====================================================================
public:
// Parse ID3v2 tags from an MP3 file. Extracts title (TIT2), artist
// (TPE1), and cover art (APIC). Fills the same metadata fields as
// the M4B parser so decodeCoverArt() works unchanged.
bool parseID3v2(File& file) {
clear();
if (!file || file.size() < 10) return false;
file.seek(0);
uint8_t hdr[10];
if (file.read(hdr, 10) != 10) return false;
// Verify "ID3" magic
if (hdr[0] != 'I' || hdr[1] != 'D' || hdr[2] != '3') {
Serial.println("ID3: No ID3v2 header found");
return false;
}
uint8_t versionMajor = hdr[3]; // 3 = ID3v2.3, 4 = ID3v2.4
bool v24 = (versionMajor == 4);
bool hasExtHeader = (hdr[5] & 0x40) != 0;
// Tag size is syncsafe integer (4 x 7-bit bytes)
uint32_t tagSize = ((uint32_t)(hdr[6] & 0x7F) << 21) |
((uint32_t)(hdr[7] & 0x7F) << 14) |
((uint32_t)(hdr[8] & 0x7F) << 7) |
(hdr[9] & 0x7F);
uint32_t tagEnd = 10 + tagSize;
if (tagEnd > file.size()) tagEnd = file.size();
Serial.printf("ID3: v2.%d, %u bytes\n", versionMajor, tagSize);
// Skip extended header if present
uint32_t pos = 10;
if (hasExtHeader && pos + 4 < tagEnd) {
file.seek(pos);
uint32_t extSize;
if (v24) {
uint8_t eb[4];
file.read(eb, 4);
extSize = ((uint32_t)(eb[0] & 0x7F) << 21) |
((uint32_t)(eb[1] & 0x7F) << 14) |
((uint32_t)(eb[2] & 0x7F) << 7) |
(eb[3] & 0x7F);
} else {
extSize = readU32BE(file) + 4;
}
pos += extSize;
}
// Walk ID3v2 frames
bool foundTitle = false, foundArtist = false, foundCover = false;
while (pos + 10 < tagEnd) {
file.seek(pos);
uint8_t fhdr[10];
if (file.read(fhdr, 10) != 10) break;
if (fhdr[0] == 0) break;
char frameId[5] = { (char)fhdr[0], (char)fhdr[1],
(char)fhdr[2], (char)fhdr[3], '\0' };
uint32_t frameSize;
if (v24) {
frameSize = ((uint32_t)(fhdr[4] & 0x7F) << 21) |
((uint32_t)(fhdr[5] & 0x7F) << 14) |
((uint32_t)(fhdr[6] & 0x7F) << 7) |
(fhdr[7] & 0x7F);
} else {
frameSize = ((uint32_t)fhdr[4] << 24) | ((uint32_t)fhdr[5] << 16) |
((uint32_t)fhdr[6] << 8) | fhdr[7];
}
if (frameSize == 0 || pos + 10 + frameSize > tagEnd) break;
uint32_t dataStart = pos + 10;
// --- TIT2 (Title) ---
if (!foundTitle && strcmp(frameId, "TIT2") == 0 && frameSize > 1) {
id3ExtractText(file, dataStart, frameSize, title, M4B_MAX_TITLE);
foundTitle = (title[0] != '\0');
}
// --- TPE1 (Artist/Author) ---
if (!foundArtist && strcmp(frameId, "TPE1") == 0 && frameSize > 1) {
id3ExtractText(file, dataStart, frameSize, author, M4B_MAX_AUTHOR);
foundArtist = (author[0] != '\0');
}
// --- APIC (Attached Picture) ---
if (!foundCover && strcmp(frameId, "APIC") == 0 && frameSize > 20) {
id3ExtractAPIC(file, dataStart, frameSize);
foundCover = hasCoverArt;
}
pos = dataStart + frameSize;
// Early exit once we have everything
if (foundTitle && foundArtist && foundCover) break;
}
if (foundTitle) Serial.printf("ID3: Title: %s\n", title);
if (foundArtist) Serial.printf("ID3: Author: %s\n", author);
return (foundTitle || foundCover);
}
private:
// Extract text from a TIT2/TPE1 frame.
// Format: encoding(1) + text data
void id3ExtractText(File& file, uint32_t offset, uint32_t size,
char* dest, int maxLen) {
file.seek(offset);
uint8_t encoding = file.read();
uint32_t textLen = size - 1;
if (textLen == 0) return;
if (encoding == 0 || encoding == 3) {
// ISO-8859-1 or UTF-8 — read directly
uint32_t readLen = (textLen < (uint32_t)(maxLen - 1))
? textLen : (uint32_t)(maxLen - 1);
file.read((uint8_t*)dest, readLen);
dest[readLen] = '\0';
// Strip trailing nulls
while (readLen > 0 && dest[readLen - 1] == '\0') readLen--;
dest[readLen] = '\0';
}
else if (encoding == 1 || encoding == 2) {
// UTF-16 (with or without BOM) — crude ASCII extraction
// Static buffer to avoid stack overflow (loopTask has limited stack)
static uint8_t u16buf[128];
uint32_t readLen = (textLen > sizeof(u16buf)) ? sizeof(u16buf) : textLen;
file.read(u16buf, readLen);
uint32_t srcStart = 0;
// Skip BOM if present
if (readLen >= 2 && ((u16buf[0] == 0xFF && u16buf[1] == 0xFE) ||
(u16buf[0] == 0xFE && u16buf[1] == 0xFF))) {
srcStart = 2;
}
bool littleEndian = (srcStart >= 2 && u16buf[0] == 0xFF);
int dstIdx = 0;
for (uint32_t i = srcStart; i + 1 < readLen && dstIdx < maxLen - 1; i += 2) {
uint8_t lo = littleEndian ? u16buf[i] : u16buf[i + 1];
uint8_t hi = littleEndian ? u16buf[i + 1] : u16buf[i];
if (lo == 0 && hi == 0) break; // null terminator
if (hi == 0 && lo >= 0x20 && lo < 0x7F) {
dest[dstIdx++] = (char)lo;
} else {
dest[dstIdx++] = '?';
}
}
dest[dstIdx] = '\0';
}
}
// Extract APIC (cover art) frame.
// Format: encoding(1) + MIME(null-term) + picType(1) + desc(null-term) + imageData
void id3ExtractAPIC(File& file, uint32_t offset, uint32_t frameSize) {
file.seek(offset);
uint8_t encoding = file.read();
// Read MIME type (null-terminated ASCII)
char mime[32] = {0};
int mimeLen = 0;
while (mimeLen < 31) {
int b = file.read();
if (b < 0) return; // Read error
if (b == 0) break; // Null terminator = end of MIME string
mime[mimeLen++] = (char)b;
}
mime[mimeLen] = '\0';
// Picture type (1 byte)
uint8_t picType = file.read();
(void)picType;
// Skip description (null-terminated, encoding-dependent)
if (encoding == 0 || encoding == 3) {
// Single-byte null terminator
while (true) {
int b = file.read();
if (b < 0) return; // Read error
if (b == 0) break; // Null terminator
}
} else {
// UTF-16: double-null terminator
while (true) {
int b1 = file.read();
int b2 = file.read();
if (b1 < 0 || b2 < 0) return; // Read error
if (b1 == 0 && b2 == 0) break; // Double-null terminator
}
}
// Everything from here to end of frame is image data
uint32_t imgOffset = file.position();
uint32_t imgEnd = offset + frameSize;
if (imgOffset >= imgEnd) return;
uint32_t imgSize = imgEnd - imgOffset;
// Determine format from MIME type
bool isJpeg = (strstr(mime, "jpeg") || strstr(mime, "jpg"));
bool isPng = (strstr(mime, "png") != nullptr);
// Also detect by magic bytes if MIME is generic
if (!isJpeg && !isPng && imgSize > 4) {
file.seek(imgOffset);
uint8_t magic[4];
file.read(magic, 4);
if (magic[0] == 0xFF && magic[1] == 0xD8) isJpeg = true;
else if (magic[0] == 0x89 && magic[1] == 'P' &&
magic[2] == 'N' && magic[3] == 'G') isPng = true;
}
coverOffset = imgOffset;
coverSize = imgSize;
coverFormat = isJpeg ? 13 : (isPng ? 14 : 0);
hasCoverArt = (imgSize > 100 && (isJpeg || isPng));
if (hasCoverArt) {
Serial.printf("ID3: Cover %s, %u bytes\n",
isJpeg ? "JPEG" : "PNG", imgSize);
}
}
// Parse Nero-style chapter list (chpl atom).
void parseChpl(File& file, uint32_t offset, uint32_t size) {
if (size < 9) return;
file.seek(offset);
uint8_t version = file.read();
file.read(); // flags byte 1
file.read(); // flags byte 2
file.read(); // flags byte 3
file.read(); // reserved
uint32_t count;
if (version == 1) {
count = readU32BE(file);
} else {
count = file.read();
}
if (count > M4B_MAX_CHAPTERS) count = M4B_MAX_CHAPTERS;
chapterCount = 0;
for (uint32_t i = 0; i < count; i++) {
if (!file.available()) break;
uint64_t timestamp = readU64BE(file);
uint32_t startMs = (uint32_t)(timestamp / 10000); // 100ns -> ms
uint8_t nameLen = file.read();
if (nameLen == 0 || !file.available()) break;
M4BChapter& ch = chapters[chapterCount];
ch.startMs = startMs;
uint8_t readLen = (nameLen < sizeof(ch.name) - 1) ? nameLen : sizeof(ch.name) - 1;
file.read((uint8_t*)ch.name, readLen);
ch.name[readLen] = '\0';
if (nameLen > readLen) {
file.seek(file.position() + (nameLen - readLen));
}
chapterCount++;
}
Serial.printf("M4B: Found %d chapters\n", chapterCount);
}
};

View File

@@ -1,559 +0,0 @@
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
#include <SD.h> // For modem config persistence
#include <time.h>
#include <sys/time.h>
// Global singleton
ModemManager modemManager;
// Use Serial1 for modem UART
#define MODEM_SERIAL Serial1
#define MODEM_BAUD 115200
// AT response buffer
#define AT_BUF_SIZE 512
static char _atBuf[AT_BUF_SIZE];
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
void ModemManager::begin() {
MESH_DEBUG_PRINTLN("[Modem] begin()");
_state = ModemState::OFF;
_csq = 99;
_operator[0] = '\0';
// Create FreeRTOS primitives
_sendQueue = xQueueCreate(MODEM_SEND_QUEUE_SIZE, sizeof(SMSOutgoing));
_recvQueue = xQueueCreate(MODEM_RECV_QUEUE_SIZE, sizeof(SMSIncoming));
_uartMutex = xSemaphoreCreateMutex();
// Launch background task on Core 0
xTaskCreatePinnedToCore(
taskEntry,
"modem",
MODEM_TASK_STACK_SIZE,
this,
MODEM_TASK_PRIORITY,
&_taskHandle,
MODEM_TASK_CORE
);
}
void ModemManager::shutdown() {
if (!_taskHandle) return;
MESH_DEBUG_PRINTLN("[Modem] shutdown()");
// Tell modem to power off gracefully
if (xSemaphoreTake(_uartMutex, pdMS_TO_TICKS(2000))) {
sendAT("AT+CPOF", "OK", 5000);
xSemaphoreGive(_uartMutex);
}
// Cut modem power
digitalWrite(MODEM_POWER_EN, LOW);
// Delete task
vTaskDelete(_taskHandle);
_taskHandle = nullptr;
_state = ModemState::OFF;
}
bool ModemManager::sendSMS(const char* phone, const char* body) {
if (!_sendQueue) return false;
SMSOutgoing msg;
memset(&msg, 0, sizeof(msg));
strncpy(msg.phone, phone, SMS_PHONE_LEN - 1);
strncpy(msg.body, body, SMS_BODY_LEN - 1);
return xQueueSend(_sendQueue, &msg, 0) == pdTRUE;
}
bool ModemManager::recvSMS(SMSIncoming& out) {
if (!_recvQueue) return false;
return xQueueReceive(_recvQueue, &out, 0) == pdTRUE;
}
int ModemManager::getSignalBars() const {
if (_csq == 99 || _csq == 0) return 0;
if (_csq <= 5) return 1;
if (_csq <= 10) return 2;
if (_csq <= 15) return 3;
if (_csq <= 20) return 4;
return 5;
}
const char* ModemManager::stateToString(ModemState s) {
switch (s) {
case ModemState::OFF: return "OFF";
case ModemState::POWERING_ON: return "PWR ON";
case ModemState::INITIALIZING: return "INIT";
case ModemState::REGISTERING: return "REG";
case ModemState::READY: return "READY";
case ModemState::ERROR: return "ERROR";
case ModemState::SENDING_SMS: return "SENDING";
default: return "???";
}
}
// ---------------------------------------------------------------------------
// Persistent modem enable/disable config
// ---------------------------------------------------------------------------
#define MODEM_CONFIG_FILE "/sms/modem.cfg"
bool ModemManager::loadEnabledConfig() {
File f = SD.open(MODEM_CONFIG_FILE, FILE_READ);
if (!f) {
// No config file = enabled by default
return true;
}
char c = '1';
if (f.available()) c = f.read();
f.close();
return (c != '0');
}
void ModemManager::saveEnabledConfig(bool enabled) {
// Ensure /sms directory exists
if (!SD.exists("/sms")) SD.mkdir("/sms");
File f = SD.open(MODEM_CONFIG_FILE, FILE_WRITE);
if (f) {
f.print(enabled ? '1' : '0');
f.close();
Serial.printf("[Modem] Config saved: %s\n", enabled ? "ENABLED" : "DISABLED");
}
}
// ---------------------------------------------------------------------------
// FreeRTOS Task
// ---------------------------------------------------------------------------
void ModemManager::taskEntry(void* param) {
static_cast<ModemManager*>(param)->taskLoop();
}
void ModemManager::taskLoop() {
MESH_DEBUG_PRINTLN("[Modem] task started on core %d", xPortGetCoreID());
restart:
// ---- Phase 1: Power on ----
_state = ModemState::POWERING_ON;
if (!modemPowerOn()) {
MESH_DEBUG_PRINTLN("[Modem] power-on failed, retry in 30s");
_state = ModemState::ERROR;
vTaskDelay(pdMS_TO_TICKS(30000));
goto restart;
}
// ---- Phase 2: Initialize ----
_state = ModemState::INITIALIZING;
MESH_DEBUG_PRINTLN("[Modem] initializing...");
// Basic AT check
{
bool atOk = false;
for (int i = 0; i < 10; i++) {
MESH_DEBUG_PRINTLN("[Modem] init AT check %d/10", i + 1);
if (sendAT("AT", "OK", 1000)) { atOk = true; break; }
vTaskDelay(pdMS_TO_TICKS(500));
}
if (!atOk) {
MESH_DEBUG_PRINTLN("[Modem] AT check failed — retry from power-on in 30s");
_state = ModemState::ERROR;
vTaskDelay(pdMS_TO_TICKS(30000));
goto restart;
}
}
// Disable echo
sendAT("ATE0", "OK");
// Set SMS text mode
sendAT("AT+CMGF=1", "OK");
// Set character set to GSM (compatible with most networks)
sendAT("AT+CSCS=\"GSM\"", "OK");
// Enable SMS notification via +CMTI URC (new message indication)
sendAT("AT+CNMI=2,1,0,0,0", "OK");
// Enable automatic time zone update from network (needed for AT+CCLK)
sendAT("AT+CTZU=1", "OK");
// ---- Phase 3: Wait for network registration ----
_state = ModemState::REGISTERING;
MESH_DEBUG_PRINTLN("[Modem] waiting for network registration...");
bool registered = false;
for (int i = 0; i < 60; i++) { // up to 60 seconds
if (sendAT("AT+CREG?", "OK", 2000)) {
// Full response now in _atBuf, e.g.: "\r\n+CREG: 0,1\r\n\r\nOK\r\n"
// stat: 1=registered home, 5=registered roaming
char* p = strstr(_atBuf, "+CREG:");
if (p) {
int n, stat;
if (sscanf(p, "+CREG: %d,%d", &n, &stat) == 2) {
MESH_DEBUG_PRINTLN("[Modem] CREG: n=%d stat=%d", n, stat);
if (stat == 1 || stat == 5) {
registered = true;
MESH_DEBUG_PRINTLN("[Modem] registered (stat=%d)", stat);
break;
}
}
}
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
if (!registered) {
MESH_DEBUG_PRINTLN("[Modem] registration timeout - continuing anyway");
// Don't set ERROR; some networks are slow but SMS may still work
}
// Query operator name
if (sendAT("AT+COPS?", "OK", 5000)) {
// +COPS: 0,0,"Operator Name",7
char* p = strchr(_atBuf, '"');
if (p) {
p++;
char* e = strchr(p, '"');
if (e) {
int len = e - p;
if (len >= (int)sizeof(_operator)) len = sizeof(_operator) - 1;
memcpy(_operator, p, len);
_operator[len] = '\0';
MESH_DEBUG_PRINTLN("[Modem] operator: %s", _operator);
}
}
}
// Initial signal query
pollCSQ();
// Sync ESP32 system clock from modem network time
// Network time may take a few seconds to arrive after registration
bool clockSet = false;
for (int attempt = 0; attempt < 5 && !clockSet; attempt++) {
if (attempt > 0) vTaskDelay(pdMS_TO_TICKS(2000));
if (sendAT("AT+CCLK?", "OK", 3000)) {
// Response: +CCLK: "YY/MM/DD,HH:MM:SS±TZ" (TZ in quarter-hours)
char* p = strstr(_atBuf, "+CCLK:");
if (p) {
int yy = 0, mo = 0, dd = 0, hh = 0, mm = 0, ss = 0, tz = 0;
if (sscanf(p, "+CCLK: \"%d/%d/%d,%d:%d:%d", &yy, &mo, &dd, &hh, &mm, &ss) >= 6) {
// Skip if modem clock not synced (default is 1970 = yy 70, or yy 0)
if (yy < 24 || yy > 50) {
MESH_DEBUG_PRINTLN("[Modem] CCLK not synced yet (yy=%d), retrying...", yy);
continue;
}
// Parse timezone offset (e.g. "+40" = UTC+10 in quarter-hours)
char* tzp = p + 7; // skip "+CCLK: "
while (*tzp && *tzp != '+' && *tzp != '-') tzp++;
if (*tzp) tz = atoi(tzp);
struct tm t = {};
t.tm_year = yy + 100; // years since 1900
t.tm_mon = mo - 1; // 0-based
t.tm_mday = dd;
t.tm_hour = hh;
t.tm_min = mm;
t.tm_sec = ss;
time_t epoch = mktime(&t); // treats input as UTC (no TZ set on ESP32)
epoch -= (tz * 15 * 60); // subtract local offset to get real UTC
struct timeval tv = { .tv_sec = epoch, .tv_usec = 0 };
settimeofday(&tv, nullptr);
clockSet = true;
MESH_DEBUG_PRINTLN("[Modem] System clock set: %04d-%02d-%02d %02d:%02d:%02d (tz=%+d qh, epoch=%lu)",
yy + 2000, mo, dd, hh, mm, ss, tz, (unsigned long)epoch);
}
}
}
}
if (!clockSet) {
MESH_DEBUG_PRINTLN("[Modem] WARNING: Could not sync system clock from network");
}
// Delete any stale SMS on SIM to free slots
sendAT("AT+CMGD=1,4", "OK", 5000); // Delete all read messages
_state = ModemState::READY;
MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s)", _csq, _operator);
// ---- Phase 4: Main loop ----
unsigned long lastCSQPoll = 0;
unsigned long lastSMSPoll = 0;
const unsigned long CSQ_POLL_INTERVAL = 30000; // 30s
const unsigned long SMS_POLL_INTERVAL = 10000; // 10s
while (true) {
// Check for outgoing SMS in queue
SMSOutgoing outMsg;
if (xQueueReceive(_sendQueue, &outMsg, 0) == pdTRUE) {
_state = ModemState::SENDING_SMS;
bool ok = doSendSMS(outMsg.phone, outMsg.body);
MESH_DEBUG_PRINTLN("[Modem] SMS send %s to %s", ok ? "OK" : "FAIL", outMsg.phone);
_state = ModemState::READY;
}
// Poll for incoming SMS periodically (not every loop iteration)
if (millis() - lastSMSPoll > SMS_POLL_INTERVAL) {
pollIncomingSMS();
lastSMSPoll = millis();
}
// Periodic signal strength update
if (millis() - lastCSQPoll > CSQ_POLL_INTERVAL) {
pollCSQ();
lastCSQPoll = millis();
}
vTaskDelay(pdMS_TO_TICKS(500)); // 500ms loop — responsive for sends, calm for polls
}
}
// ---------------------------------------------------------------------------
// Hardware Control
// ---------------------------------------------------------------------------
bool ModemManager::modemPowerOn() {
MESH_DEBUG_PRINTLN("[Modem] powering on...");
// Enable modem power supply (BOARD_6609_EN)
pinMode(MODEM_POWER_EN, OUTPUT);
digitalWrite(MODEM_POWER_EN, HIGH);
vTaskDelay(pdMS_TO_TICKS(500));
MESH_DEBUG_PRINTLN("[Modem] power supply enabled (GPIO %d HIGH)", MODEM_POWER_EN);
// Reset pulse — drive RST low briefly then release
// (Some A7682E boards need this to clear stuck states)
pinMode(MODEM_RST, OUTPUT);
digitalWrite(MODEM_RST, LOW);
vTaskDelay(pdMS_TO_TICKS(200));
digitalWrite(MODEM_RST, HIGH);
vTaskDelay(pdMS_TO_TICKS(500));
MESH_DEBUG_PRINTLN("[Modem] reset pulse done (GPIO %d)", MODEM_RST);
// PWRKEY toggle: pull low for ≥1.5s then release
// A7682E datasheet: PWRKEY low >1s triggers power-on
pinMode(MODEM_PWRKEY, OUTPUT);
digitalWrite(MODEM_PWRKEY, HIGH); // Start high (idle state)
vTaskDelay(pdMS_TO_TICKS(100));
digitalWrite(MODEM_PWRKEY, LOW); // Active-low trigger
vTaskDelay(pdMS_TO_TICKS(1500));
digitalWrite(MODEM_PWRKEY, HIGH); // Release
MESH_DEBUG_PRINTLN("[Modem] PWRKEY toggled, waiting for boot...");
// Wait for modem to boot — A7682E needs 3-5 seconds after PWRKEY
vTaskDelay(pdMS_TO_TICKS(5000));
// Assert DTR LOW — many cellular modems require DTR active (LOW) for AT mode
pinMode(MODEM_DTR, OUTPUT);
digitalWrite(MODEM_DTR, LOW);
MESH_DEBUG_PRINTLN("[Modem] DTR asserted LOW (GPIO %d)", MODEM_DTR);
// Configure UART
// NOTE: variant.h pin names are modem-perspective, so:
// MODEM_RX (GPIO 10) = modem receives = ESP32 TX out
// MODEM_TX (GPIO 11) = modem transmits = ESP32 RX in
// Serial1.begin(baud, config, ESP32_RX, ESP32_TX)
MODEM_SERIAL.begin(MODEM_BAUD, SERIAL_8N1, MODEM_TX, MODEM_RX);
vTaskDelay(pdMS_TO_TICKS(500));
MESH_DEBUG_PRINTLN("[Modem] UART started (ESP32 RX=%d TX=%d @ %d)", MODEM_TX, MODEM_RX, MODEM_BAUD);
// Drain any boot garbage from UART
while (MODEM_SERIAL.available()) MODEM_SERIAL.read();
// Test communication — generous attempts
for (int i = 0; i < 10; i++) {
MESH_DEBUG_PRINTLN("[Modem] AT probe attempt %d/10", i + 1);
if (sendAT("AT", "OK", 1500)) {
MESH_DEBUG_PRINTLN("[Modem] AT responded OK");
return true;
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
MESH_DEBUG_PRINTLN("[Modem] no AT response after power-on");
return false;
}
// ---------------------------------------------------------------------------
// AT Command Helpers (called only from modem task)
// ---------------------------------------------------------------------------
bool ModemManager::sendAT(const char* cmd, const char* expect, uint32_t timeout_ms) {
// Flush any pending data
while (MODEM_SERIAL.available()) MODEM_SERIAL.read();
Serial.printf("[Modem] TX: %s\n", cmd);
MODEM_SERIAL.println(cmd);
bool ok = waitResponse(expect, timeout_ms, _atBuf, AT_BUF_SIZE);
if (_atBuf[0]) {
// Trim trailing whitespace for cleaner log output
int len = strlen(_atBuf);
while (len > 0 && (_atBuf[len-1] == '\r' || _atBuf[len-1] == '\n')) _atBuf[--len] = '\0';
Serial.printf("[Modem] RX: %s [%s]\n", _atBuf, ok ? "OK" : "FAIL");
} else {
Serial.printf("[Modem] RX: (no response) [TIMEOUT]\n");
}
return ok;
}
bool ModemManager::waitResponse(const char* expect, uint32_t timeout_ms,
char* buf, size_t bufLen) {
unsigned long start = millis();
int pos = 0;
if (buf && bufLen > 0) buf[0] = '\0';
while (millis() - start < timeout_ms) {
while (MODEM_SERIAL.available()) {
char c = MODEM_SERIAL.read();
if (buf && pos < (int)bufLen - 1) {
buf[pos++] = c;
buf[pos] = '\0';
}
// Check for expected response in accumulated buffer
if (buf && expect && strstr(buf, expect)) {
return true;
}
}
vTaskDelay(pdMS_TO_TICKS(10));
}
// Timeout — check one more time
if (buf && expect && strstr(buf, expect)) return true;
return false;
}
void ModemManager::pollCSQ() {
if (sendAT("AT+CSQ", "OK", 2000)) {
char* p = strstr(_atBuf, "+CSQ:");
if (p) {
int csq, ber;
if (sscanf(p, "+CSQ: %d,%d", &csq, &ber) >= 1) {
_csq = csq;
MESH_DEBUG_PRINTLN("[Modem] CSQ=%d (bars=%d)", _csq, getSignalBars());
}
}
}
}
void ModemManager::pollIncomingSMS() {
// List all unread messages (wait for full OK response)
if (!sendAT("AT+CMGL=\"REC UNREAD\"", "OK", 5000)) return;
// Parse response: +CMGL: <index>,<stat>,<phone>,,<timestamp>\r\n<body>\r\n
char* p = _atBuf;
while ((p = strstr(p, "+CMGL:")) != nullptr) {
int idx;
char stat[16], phone[SMS_PHONE_LEN], timestamp[24];
// Parse header line
// +CMGL: 1,"REC UNREAD","+1234567890","","26/02/15,10:30:00+00"
char* lineEnd = strchr(p, '\n');
if (!lineEnd) break;
// Extract index
if (sscanf(p, "+CMGL: %d", &idx) != 1) { p = lineEnd + 1; continue; }
// Extract phone number (between first and second quote pair after stat)
char* q1 = strchr(p + 7, '"'); // skip "+CMGL: N,"
if (!q1) { p = lineEnd + 1; continue; }
q1++; // skip opening quote of stat
char* q2 = strchr(q1, '"'); // end of stat
if (!q2) { p = lineEnd + 1; continue; }
// Next quoted field is the phone number
char* q3 = strchr(q2 + 1, '"');
if (!q3) { p = lineEnd + 1; continue; }
q3++;
char* q4 = strchr(q3, '"');
if (!q4) { p = lineEnd + 1; continue; }
int phoneLen = q4 - q3;
if (phoneLen >= SMS_PHONE_LEN) phoneLen = SMS_PHONE_LEN - 1;
memcpy(phone, q3, phoneLen);
phone[phoneLen] = '\0';
// Body is on the next line
p = lineEnd + 1;
char* bodyEnd = strchr(p, '\r');
if (!bodyEnd) bodyEnd = strchr(p, '\n');
if (!bodyEnd) break;
SMSIncoming incoming;
memset(&incoming, 0, sizeof(incoming));
strncpy(incoming.phone, phone, SMS_PHONE_LEN - 1);
int bodyLen = bodyEnd - p;
if (bodyLen >= SMS_BODY_LEN) bodyLen = SMS_BODY_LEN - 1;
memcpy(incoming.body, p, bodyLen);
incoming.body[bodyLen] = '\0';
incoming.timestamp = (uint32_t)time(nullptr); // Real epoch from modem-synced clock
// Queue for main loop
xQueueSend(_recvQueue, &incoming, 0);
// Delete the message from SIM
char delCmd[20];
snprintf(delCmd, sizeof(delCmd), "AT+CMGD=%d", idx);
sendAT(delCmd, "OK", 2000);
MESH_DEBUG_PRINTLN("[Modem] SMS received from %s: %.40s...", phone, incoming.body);
p = bodyEnd + 1;
}
}
bool ModemManager::doSendSMS(const char* phone, const char* body) {
MESH_DEBUG_PRINTLN("[Modem] doSendSMS to=%s len=%d", phone, strlen(body));
// Set text mode (in case it was reset)
sendAT("AT+CMGF=1", "OK");
// Start SMS send
char cmd[40];
snprintf(cmd, sizeof(cmd), "AT+CMGS=\"%s\"", phone);
Serial.printf("[Modem] TX: %s\n", cmd);
MODEM_SERIAL.println(cmd);
// Wait for '>' prompt
unsigned long start = millis();
bool gotPrompt = false;
while (millis() - start < 5000) {
if (MODEM_SERIAL.available()) {
char c = MODEM_SERIAL.read();
if (c == '>') { gotPrompt = true; break; }
}
vTaskDelay(pdMS_TO_TICKS(10));
}
if (!gotPrompt) {
MESH_DEBUG_PRINTLN("[Modem] no '>' prompt for SMS send");
MODEM_SERIAL.write(0x1B); // ESC to cancel
return false;
}
// Send body + Ctrl+Z
MESH_DEBUG_PRINTLN("[Modem] got '>' prompt, sending body...");
MODEM_SERIAL.print(body);
MODEM_SERIAL.write(0x1A); // Ctrl+Z to send
// Wait for +CMGS or ERROR
if (waitResponse("+CMGS:", 30000, _atBuf, AT_BUF_SIZE)) {
MESH_DEBUG_PRINTLN("[Modem] SMS sent OK: %s", _atBuf);
return true;
}
MESH_DEBUG_PRINTLN("[Modem] SMS send timeout/error: %s", _atBuf);
return false;
}
#endif // HAS_4G_MODEM

View File

@@ -1,123 +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.
//
// 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 4096
#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
// Modem state machine
enum class ModemState {
OFF,
POWERING_ON,
INITIALIZING,
REGISTERING,
READY,
ERROR,
SENDING_SMS
};
// 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)
};
class ModemManager {
public:
void begin();
void shutdown();
// Non-blocking: queue an SMS for sending (returns false if queue full)
bool sendSMS(const char* phone, const char* body);
// Non-blocking: poll for received SMS (returns true if one was dequeued)
bool recvSMS(SMSIncoming& out);
// State queries (lock-free reads)
ModemState getState() const { return _state; }
int getSignalBars() const; // 0-5
int getCSQ() const { return _csq; }
bool isReady() const { return _state == ModemState::READY; }
const char* getOperator() const { return _operator; }
static const char* stateToString(ModemState s);
// Persistent enable/disable config (SD file /sms/modem.cfg)
static bool loadEnabledConfig(); // returns true if enabled (default)
static void saveEnabledConfig(bool enabled);
private:
volatile ModemState _state = ModemState::OFF;
volatile int _csq = 99; // 99 = unknown
char _operator[24] = {0};
TaskHandle_t _taskHandle = nullptr;
QueueHandle_t _sendQueue = nullptr;
QueueHandle_t _recvQueue = nullptr;
SemaphoreHandle_t _uartMutex = nullptr;
// 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);
// FreeRTOS task
static void taskEntry(void* param);
void taskLoop();
};
// Global singleton
extern ModemManager modemManager;
#endif // MODEM_MANAGER_H
#endif // HAS_4G_MODEM

View File

@@ -1,8 +0,0 @@
#ifdef HAS_4G_MODEM
#include "SMSContacts.h"
// Global singleton
SMSContactStore smsContacts;
#endif // HAS_4G_MODEM

View File

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

View File

@@ -1,885 +0,0 @@
#pragma once
// =============================================================================
// SMSScreen - SMS messaging UI for T-Deck Pro (4G variant)
//
// Sub-views:
// INBOX — list of conversations (names resolved via SMSContacts)
// CONVERSATION — messages for a selected contact, scrollable
// COMPOSE — text input for new SMS
// CONTACTS — browsable contacts list, pick to compose
// EDIT_CONTACT — add or edit a contact name for a phone number
//
// Navigation mirrors ChannelScreen conventions:
// W/S: scroll Enter: select/send C: compose new/reply
// Q: back Sh+Del: cancel compose
// D: contacts (from inbox)
// A: add/edit contact (from conversation)
//
// Guard: HAS_4G_MODEM
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef SMS_SCREEN_H
#define SMS_SCREEN_H
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <time.h>
#include "ModemManager.h"
#include "SMSStore.h"
#include "SMSContacts.h"
// Limits
#define SMS_INBOX_PAGE_SIZE 4
#define SMS_MSG_PAGE_SIZE 30
#define SMS_COMPOSE_MAX 160
class UITask; // forward declaration
class SMSScreen : public UIScreen {
public:
enum SubView { INBOX, CONVERSATION, COMPOSE, CONTACTS, EDIT_CONTACT };
private:
UITask* _task;
SubView _view;
// Inbox state
SMSConversation _conversations[SMS_MAX_CONVERSATIONS];
int _convCount;
int _inboxCursor;
int _inboxScrollTop;
// Conversation state
char _activePhone[SMS_PHONE_LEN];
SMSMessage _msgs[SMS_MSG_PAGE_SIZE];
int _msgCount;
int _msgScrollPos;
// Compose state
char _composeBuf[SMS_COMPOSE_MAX + 1];
int _composePos;
char _composePhone[SMS_PHONE_LEN];
bool _composeNewConversation;
// Phone input state (for new conversation)
char _phoneInputBuf[SMS_PHONE_LEN];
int _phoneInputPos;
bool _enteringPhone;
// Contacts list state
int _contactsCursor;
int _contactsScrollTop;
// Edit contact state
char _editPhone[SMS_PHONE_LEN];
char _editNameBuf[SMS_CONTACT_NAME_LEN];
int _editNamePos;
bool _editIsNew; // true = adding new, false = editing existing
SubView _editReturnView; // where to return after save/cancel
// Refresh debounce
bool _needsRefresh;
unsigned long _lastRefresh;
static const unsigned long REFRESH_INTERVAL = 600;
// SD ready flag
bool _sdReady;
// Reload helpers
void refreshInbox() {
_convCount = smsStore.loadConversations(_conversations, SMS_MAX_CONVERSATIONS);
}
void refreshConversation() {
_msgCount = smsStore.loadMessages(_activePhone, _msgs, SMS_MSG_PAGE_SIZE);
// Scroll to bottom (newest messages are at end now, chat-style)
_msgScrollPos = (_msgCount > 3) ? _msgCount - 3 : 0;
}
public:
SMSScreen(UITask* task)
: _task(task), _view(INBOX)
, _convCount(0), _inboxCursor(0), _inboxScrollTop(0)
, _msgCount(0), _msgScrollPos(0)
, _composePos(0), _composeNewConversation(false)
, _phoneInputPos(0), _enteringPhone(false)
, _contactsCursor(0), _contactsScrollTop(0)
, _editNamePos(0), _editIsNew(false), _editReturnView(INBOX)
, _needsRefresh(false), _lastRefresh(0)
, _sdReady(false)
{
memset(_composeBuf, 0, sizeof(_composeBuf));
memset(_composePhone, 0, sizeof(_composePhone));
memset(_phoneInputBuf, 0, sizeof(_phoneInputBuf));
memset(_activePhone, 0, sizeof(_activePhone));
memset(_editPhone, 0, sizeof(_editPhone));
memset(_editNameBuf, 0, sizeof(_editNameBuf));
}
void setSDReady(bool ready) { _sdReady = ready; }
void activate() {
_view = INBOX;
_inboxCursor = 0;
_inboxScrollTop = 0;
if (_sdReady) refreshInbox();
}
SubView getSubView() const { return _view; }
bool isComposing() const { return _view == COMPOSE; }
bool isEnteringPhone() const { return _enteringPhone; }
// Called from main loop when an SMS arrives (saves to store + refreshes)
void onIncomingSMS(const char* phone, const char* body, uint32_t timestamp) {
if (_sdReady) {
smsStore.saveMessage(phone, body, false, timestamp);
}
if (_view == CONVERSATION && strcmp(_activePhone, phone) == 0) {
refreshConversation();
}
if (_view == INBOX) {
refreshInbox();
}
_needsRefresh = true;
}
// =========================================================================
// Signal strength indicator (top-right corner)
// =========================================================================
int renderSignalIndicator(DisplayDriver& display, int startX, int topY) {
ModemState ms = modemManager.getState();
int bars = modemManager.getSignalBars();
// Draw signal bars (4 bars, increasing height)
int barWidth = 3;
int barGap = 2;
int maxBarH = 10;
int totalWidth = 4 * barWidth + 3 * barGap;
int x = startX - totalWidth;
int iconWidth = totalWidth;
for (int b = 0; b < 4; b++) {
int barH = 3 + b * 2;
int barY = topY + (maxBarH - barH);
if (b < bars) {
display.setColor(DisplayDriver::LIGHT);
} else {
display.setColor(DisplayDriver::DARK);
}
display.fillRect(x, barY, barWidth, barH);
x += barWidth + barGap;
}
// Show modem state text if not ready
if (ms != ModemState::READY && ms != ModemState::SENDING_SMS) {
display.setTextSize(0);
display.setColor(DisplayDriver::YELLOW);
const char* label = ModemManager::stateToString(ms);
uint16_t labelW = display.getTextWidth(label);
display.setCursor(startX - totalWidth - labelW - 2, topY - 3);
display.print(label);
display.setTextSize(1);
return iconWidth + labelW + 2;
}
return iconWidth;
}
// =========================================================================
// RENDER
// =========================================================================
int render(DisplayDriver& display) override {
_lastRefresh = millis();
switch (_view) {
case INBOX: return renderInbox(display);
case CONVERSATION: return renderConversation(display);
case COMPOSE: return renderCompose(display);
case CONTACTS: return renderContacts(display);
case EDIT_CONTACT: return renderEditContact(display);
}
return 1000;
}
// ---- Inbox ----
int renderInbox(DisplayDriver& display) {
ModemState ms = modemManager.getState();
// Header
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print("SMS Inbox");
// Signal strength at top-right
renderSignalIndicator(display, display.width() - 2, 0);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
if (_convCount == 0) {
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 20);
display.print("No conversations");
display.setCursor(0, 32);
display.print("Press C for new SMS");
if (ms != ModemState::READY) {
display.setCursor(0, 48);
display.setColor(DisplayDriver::YELLOW);
char statBuf[40];
snprintf(statBuf, sizeof(statBuf), "Modem: %s", ModemManager::stateToString(ms));
display.print(statBuf);
}
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
int y = 14;
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
if (visibleCount < 1) visibleCount = 1;
// Adjust scroll to keep cursor visible
if (_inboxCursor < _inboxScrollTop) _inboxScrollTop = _inboxCursor;
if (_inboxCursor >= _inboxScrollTop + visibleCount) {
_inboxScrollTop = _inboxCursor - visibleCount + 1;
}
for (int vi = 0; vi < visibleCount && (_inboxScrollTop + vi) < _convCount; vi++) {
int idx = _inboxScrollTop + vi;
SMSConversation& c = _conversations[idx];
if (!c.valid) continue;
bool selected = (idx == _inboxCursor);
// Resolve contact name (shows name if saved, phone otherwise)
char dispName[SMS_CONTACT_NAME_LEN];
smsContacts.displayName(c.phone, dispName, sizeof(dispName));
display.setCursor(0, y);
display.setColor(selected ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
if (selected) display.print("> ");
display.print(dispName);
// Message count at right
char countStr[8];
snprintf(countStr, sizeof(countStr), "[%d]", c.messageCount);
display.setCursor(display.width() - display.getTextWidth(countStr) - 2, y);
display.print(countStr);
y += lineHeight;
// Preview (dimmer)
display.setColor(DisplayDriver::LIGHT);
display.setCursor(12, y);
char prev[36];
strncpy(prev, c.preview, 35);
prev[35] = '\0';
display.print(prev);
y += lineHeight + 2;
}
display.setTextSize(1);
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
display.print("Q:Back");
const char* mid = "D:Contacts";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
const char* rt = "C:New";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
return 5000;
}
// ---- Conversation view ----
int renderConversation(DisplayDriver& display) {
// Header - show contact name if available, phone otherwise
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
char convTitle[SMS_CONTACT_NAME_LEN];
smsContacts.displayName(_activePhone, convTitle, sizeof(convTitle));
display.print(convTitle);
// Signal icon
renderSignalIndicator(display, display.width() - 2, 0);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
if (_msgCount == 0) {
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 25);
display.print("No messages");
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
int headerHeight = 14;
int footerHeight = 14;
// Estimate chars per line
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM");
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
if (charsPerLine < 12) charsPerLine = 12;
if (charsPerLine > 40) charsPerLine = 40;
int y = headerHeight;
for (int i = _msgScrollPos;
i < _msgCount && y < display.height() - footerHeight - lineHeight;
i++) {
SMSMessage& msg = _msgs[i];
if (!msg.valid) continue;
// Direction indicator
display.setCursor(0, y);
display.setColor(msg.isSent ? DisplayDriver::BLUE : DisplayDriver::YELLOW);
// Time formatting (epoch-aware)
char timeStr[16];
time_t now = time(nullptr);
bool haveEpoch = (now > 1700000000); // system clock is set
bool msgIsEpoch = (msg.timestamp > 1700000000); // msg has real timestamp
if (haveEpoch && msgIsEpoch) {
uint32_t age = (uint32_t)(now - msg.timestamp);
if (age < 60) snprintf(timeStr, sizeof(timeStr), "%lus", (unsigned long)age);
else if (age < 3600) snprintf(timeStr, sizeof(timeStr), "%lum", (unsigned long)(age / 60));
else if (age < 86400) snprintf(timeStr, sizeof(timeStr), "%luh", (unsigned long)(age / 3600));
else snprintf(timeStr, sizeof(timeStr), "%lud", (unsigned long)(age / 86400));
} else {
strncpy(timeStr, "---", sizeof(timeStr));
}
char header[32];
snprintf(header, sizeof(header), "%s %s",
msg.isSent ? ">>>" : "<<<", timeStr);
display.print(header);
y += lineHeight;
// Message body with simple word wrap
display.setColor(DisplayDriver::LIGHT);
int textLen = strlen(msg.body);
int pos = 0;
int linesForMsg = 0;
int maxLines = 4;
int x = 0;
char cs[2] = {0, 0};
display.setCursor(0, y);
while (pos < textLen && linesForMsg < maxLines &&
y < display.height() - footerHeight - 2) {
cs[0] = msg.body[pos++];
display.print(cs);
x++;
if (x >= charsPerLine) {
x = 0;
linesForMsg++;
y += lineHeight;
if (linesForMsg < maxLines && y < display.height() - footerHeight - 2) {
display.setCursor(0, y);
}
}
}
if (x > 0) y += lineHeight;
y += 2;
}
display.setTextSize(1);
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
display.print("Q:Bk A:Add Contact");
const char* rt = "C:Reply";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
return 5000;
}
// ---- Compose ----
int renderCompose(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
if (_enteringPhone) {
display.print("To: ");
display.setColor(DisplayDriver::LIGHT);
display.print(_phoneInputBuf);
display.print("_");
} else {
// Show contact name if available
char dispName[SMS_CONTACT_NAME_LEN];
smsContacts.displayName(_composePhone, dispName, sizeof(dispName));
char toLabel[40];
snprintf(toLabel, sizeof(toLabel), "To: %s", dispName);
display.print(toLabel);
}
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
if (!_enteringPhone) {
// Message body
display.setCursor(0, 14);
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM");
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
if (charsPerLine < 12) charsPerLine = 12;
int y = 14;
int x = 0;
char cs[2] = {0, 0};
for (int i = 0; i < _composePos; i++) {
cs[0] = _composeBuf[i];
display.setCursor(x * (display.width() / charsPerLine), y);
display.print(cs);
x++;
if (x >= charsPerLine) {
x = 0;
y += 10;
}
}
// Cursor
display.setCursor(x * (display.width() / charsPerLine), y);
display.print("_");
display.setTextSize(1);
}
// Status bar
display.setTextSize(1);
int statusY = display.height() - 12;
display.drawRect(0, statusY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, statusY);
if (_enteringPhone) {
display.print("Phone#");
const char* rt = "Ent S+D:X";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, statusY);
display.print(rt);
} else {
char status[16];
snprintf(status, sizeof(status), "%d/%d", _composePos, SMS_COMPOSE_MAX);
display.print(status);
const char* rt = "Ent S+D:X";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, statusY);
display.print(rt);
}
return 2000;
}
// ---- Contacts list ----
int renderContacts(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print("SMS Contacts");
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
int cnt = smsContacts.count();
if (cnt == 0) {
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 25);
display.print("No contacts saved");
display.setCursor(0, 37);
display.print("Open a conversation");
display.setCursor(0, 49);
display.print("and press A to add");
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
int y = 14;
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
if (visibleCount < 1) visibleCount = 1;
// Adjust scroll
if (_contactsCursor >= cnt) _contactsCursor = cnt - 1;
if (_contactsCursor < 0) _contactsCursor = 0;
if (_contactsCursor < _contactsScrollTop) _contactsScrollTop = _contactsCursor;
if (_contactsCursor >= _contactsScrollTop + visibleCount) {
_contactsScrollTop = _contactsCursor - visibleCount + 1;
}
for (int vi = 0; vi < visibleCount && (_contactsScrollTop + vi) < cnt; vi++) {
int idx = _contactsScrollTop + vi;
const SMSContact& ct = smsContacts.get(idx);
if (!ct.valid) continue;
bool selected = (idx == _contactsCursor);
// Name
display.setCursor(0, y);
display.setColor(selected ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
if (selected) display.print("> ");
display.print(ct.name);
y += lineHeight;
// Phone (dimmer)
display.setColor(DisplayDriver::LIGHT);
display.setCursor(12, y);
display.print(ct.phone);
y += lineHeight + 2;
}
display.setTextSize(1);
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
display.print("Q:Back");
const char* rt = "Ent:SMS";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
return 5000;
}
// ---- Edit contact ----
int renderEditContact(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print(_editIsNew ? "Add Contact" : "Edit Contact");
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
// Phone number (read-only)
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 16);
display.print("Phone: ");
display.print(_editPhone);
// Name input
display.setCursor(0, 30);
display.setColor(DisplayDriver::YELLOW);
display.print("Name: ");
display.setColor(DisplayDriver::LIGHT);
display.print(_editNameBuf);
display.print("_");
display.setTextSize(1);
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
display.print("S+D:X");
const char* rt = "Ent:Save";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
return 2000;
}
// =========================================================================
// INPUT HANDLING
// =========================================================================
bool handleInput(char c) override {
switch (_view) {
case INBOX: return handleInboxInput(c);
case CONVERSATION: return handleConversationInput(c);
case COMPOSE: return handleComposeInput(c);
case CONTACTS: return handleContactsInput(c);
case EDIT_CONTACT: return handleEditContactInput(c);
}
return false;
}
// ---- Inbox input ----
bool handleInboxInput(char c) {
switch (c) {
case 'w': case 'W':
if (_inboxCursor > 0) _inboxCursor--;
return true;
case 's': case 'S':
if (_inboxCursor < _convCount - 1) _inboxCursor++;
return true;
case '\r': // Enter - open conversation
if (_convCount > 0 && _inboxCursor < _convCount) {
strncpy(_activePhone, _conversations[_inboxCursor].phone, SMS_PHONE_LEN - 1);
refreshConversation();
_view = CONVERSATION;
}
return true;
case 'c': case 'C': // New conversation
_composeNewConversation = true;
_enteringPhone = true;
_phoneInputBuf[0] = '\0';
_phoneInputPos = 0;
_composeBuf[0] = '\0';
_composePos = 0;
_view = COMPOSE;
return true;
case 'd': case 'D': // Open contacts list
_contactsCursor = 0;
_contactsScrollTop = 0;
_view = CONTACTS;
return true;
case 'q': case 'Q': // Back to home (handled by main.cpp)
return false;
default:
return false;
}
}
// ---- Conversation input ----
bool handleConversationInput(char c) {
switch (c) {
case 'w': case 'W':
if (_msgScrollPos > 0) _msgScrollPos--;
return true;
case 's': case 'S':
if (_msgScrollPos < _msgCount - 1) _msgScrollPos++;
return true;
case 'c': case 'C': // Reply to this conversation
_composeNewConversation = false;
_enteringPhone = false;
strncpy(_composePhone, _activePhone, SMS_PHONE_LEN - 1);
_composeBuf[0] = '\0';
_composePos = 0;
_view = COMPOSE;
return true;
case 'a': case 'A': { // Add/edit contact for this number
strncpy(_editPhone, _activePhone, SMS_PHONE_LEN - 1);
_editPhone[SMS_PHONE_LEN - 1] = '\0';
_editReturnView = CONVERSATION;
const char* existing = smsContacts.lookup(_activePhone);
if (existing) {
_editIsNew = false;
strncpy(_editNameBuf, existing, SMS_CONTACT_NAME_LEN - 1);
_editNameBuf[SMS_CONTACT_NAME_LEN - 1] = '\0';
_editNamePos = strlen(_editNameBuf);
} else {
_editIsNew = true;
_editNameBuf[0] = '\0';
_editNamePos = 0;
}
_view = EDIT_CONTACT;
return true;
}
case 'q': case 'Q': // Back to inbox
refreshInbox();
_view = INBOX;
return true;
default:
return false;
}
}
// ---- Compose input ----
bool handleComposeInput(char c) {
if (_enteringPhone) {
return handlePhoneInput(c);
}
switch (c) {
case '\r': { // Enter - send SMS
if (_composePos > 0) {
_composeBuf[_composePos] = '\0';
bool queued = modemManager.sendSMS(_composePhone, _composeBuf);
if (_sdReady) {
uint32_t ts = (uint32_t)time(nullptr);
smsStore.saveMessage(_composePhone, _composeBuf, true, ts);
}
Serial.printf("[SMS] %s to %s: %s\n",
queued ? "Queued" : "Queue full", _composePhone, _composeBuf);
}
_composeBuf[0] = '\0';
_composePos = 0;
refreshInbox();
_view = INBOX;
return true;
}
case '\b': // Backspace
if (_composePos > 0) {
_composePos--;
_composeBuf[_composePos] = '\0';
}
return true;
case 0x18: // Shift+Backspace (cancel)
_composeBuf[0] = '\0';
_composePos = 0;
refreshInbox();
_view = INBOX;
return true;
default:
if (c >= 32 && c < 127 && _composePos < SMS_COMPOSE_MAX) {
_composeBuf[_composePos++] = c;
_composeBuf[_composePos] = '\0';
}
return true;
}
}
// ---- Phone number input ----
bool handlePhoneInput(char c) {
switch (c) {
case '\r': // Done entering phone, move to body
if (_phoneInputPos > 0) {
_phoneInputBuf[_phoneInputPos] = '\0';
strncpy(_composePhone, _phoneInputBuf, SMS_PHONE_LEN - 1);
_enteringPhone = false;
_composeBuf[0] = '\0';
_composePos = 0;
}
return true;
case '\b':
if (_phoneInputPos > 0) {
_phoneInputPos--;
_phoneInputBuf[_phoneInputPos] = '\0';
}
return true;
case 0x18: // Shift+Backspace (cancel)
_phoneInputBuf[0] = '\0';
_phoneInputPos = 0;
refreshInbox();
_view = INBOX;
_enteringPhone = false;
return true;
default:
if (_phoneInputPos < SMS_PHONE_LEN - 1 &&
((c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#')) {
_phoneInputBuf[_phoneInputPos++] = c;
_phoneInputBuf[_phoneInputPos] = '\0';
}
return true;
}
}
// ---- Contacts list input ----
bool handleContactsInput(char c) {
int cnt = smsContacts.count();
switch (c) {
case 'w': case 'W':
if (_contactsCursor > 0) _contactsCursor--;
return true;
case 's': case 'S':
if (_contactsCursor < cnt - 1) _contactsCursor++;
return true;
case '\r': // Enter - compose to selected contact
if (cnt > 0 && _contactsCursor < cnt) {
const SMSContact& ct = smsContacts.get(_contactsCursor);
_composeNewConversation = true;
_enteringPhone = false;
strncpy(_composePhone, ct.phone, SMS_PHONE_LEN - 1);
_composeBuf[0] = '\0';
_composePos = 0;
_view = COMPOSE;
}
return true;
case 'q': case 'Q': // Back to inbox
refreshInbox();
_view = INBOX;
return true;
default:
return false;
}
}
// ---- Edit contact input ----
bool handleEditContactInput(char c) {
switch (c) {
case '\r': // Enter - save contact
if (_editNamePos > 0) {
_editNameBuf[_editNamePos] = '\0';
smsContacts.set(_editPhone, _editNameBuf);
Serial.printf("[SMSContacts] Saved: %s = %s\n", _editPhone, _editNameBuf);
}
if (_editReturnView == CONVERSATION) {
refreshConversation();
} else {
refreshInbox();
}
_view = _editReturnView;
return true;
case '\b': // Backspace
if (_editNamePos > 0) {
_editNamePos--;
_editNameBuf[_editNamePos] = '\0';
}
return true;
case 0x18: // Shift+Backspace (cancel without saving)
if (_editReturnView == CONVERSATION) {
refreshConversation();
} else {
refreshInbox();
}
_view = _editReturnView;
return true;
default:
if (c >= 32 && c < 127 && _editNamePos < SMS_CONTACT_NAME_LEN - 1) {
_editNameBuf[_editNamePos++] = c;
_editNameBuf[_editNamePos] = '\0';
}
return true;
}
}
};
#endif // SMS_SCREEN_H
#endif // HAS_4G_MODEM

View File

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

View File

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

View File

@@ -6,10 +6,6 @@
#include <MeshCore.h>
#include "../NodePrefs.h"
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#endif
// Forward declarations
class UITask;
class MyMesh;
@@ -59,10 +55,6 @@ enum SettingsRowType : uint8_t {
ROW_CR, // Coding rate (5-8)
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 +84,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 +108,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
// ---------------------------------------------------------------------------
@@ -139,10 +126,6 @@ private:
addRow(ROW_CR);
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 +210,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 +287,6 @@ public:
_cursor = 0;
_scrollTop = 0;
_radioChanged = false;
#ifdef HAS_4G_MODEM
_modemEnabled = ModemManager::loadEnabledConfig();
#endif
rebuildRows();
}
@@ -485,20 +465,6 @@ public:
display.print(tmp);
break;
case ROW_MSG_NOTIFY:
snprintf(tmp, sizeof(tmp), "Msg Rcvd LED Light Pulse: %s",
_prefs->kb_flash_notify ? "ON" : "OFF");
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 ---");
@@ -858,25 +824,6 @@ public:
case ROW_UTC_OFFSET:
startEditInt(_prefs->utc_offset_hours);
break;
case ROW_MSG_NOTIFY:
_prefs->kb_flash_notify = _prefs->kb_flash_notify ? 0 : 1;
the_mesh.savePrefs();
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 +847,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;

View File

@@ -182,10 +182,8 @@ private:
// File list state
std::vector<String> _fileList;
std::vector<String> _dirList; // Subdirectories at current path
std::vector<FileCache> _fileCache;
int _selectedFile;
String _currentPath; // Current browsed directory
// Reading state
File _file;
@@ -393,8 +391,8 @@ private:
idxFile.read(&fullyFlag, 1);
idxFile.read((uint8_t*)&lastRead, 4);
// Verify file hasn't changed - try current path first, then epub cache
String fullPath = _currentPath + "/" + filename;
// Verify file hasn't changed - try BOOKS_FOLDER first, then epub cache
String fullPath = String(BOOKS_FOLDER) + "/" + filename;
File txtFile = SD.open(fullPath.c_str(), FILE_READ);
if (!txtFile) {
// Fallback: check epub cache directory
@@ -484,94 +482,33 @@ private:
// ---- File Scanning ----
// ---- Folder Navigation Helpers ----
bool isAtBooksRoot() const {
return _currentPath == String(BOOKS_FOLDER);
}
// Number of non-file entries at the start of the visual list
int dirEntryCount() const {
int count = _dirList.size();
if (!isAtBooksRoot()) count++; // ".." entry
return count;
}
// Total items in the visual list (parent + dirs + files)
int totalListItems() const {
return dirEntryCount() + (int)_fileList.size();
}
// What type of entry is at visual list index idx?
// Returns: 0 = ".." parent, 1 = directory, 2 = file
int itemTypeAt(int idx) const {
bool hasParent = !isAtBooksRoot();
if (hasParent && idx == 0) return 0; // ".."
int dirStart = hasParent ? 1 : 0;
if (idx < dirStart + (int)_dirList.size()) return 1; // directory
return 2; // file
}
// Get directory name for visual index (only valid when itemTypeAt == 1)
const String& dirNameAt(int idx) const {
int dirStart = isAtBooksRoot() ? 0 : 1;
return _dirList[idx - dirStart];
}
// Get file list index for visual index (only valid when itemTypeAt == 2)
int fileIndexAt(int idx) const {
return idx - dirEntryCount();
}
void navigateToParent() {
int lastSlash = _currentPath.lastIndexOf('/');
if (lastSlash > 0) {
_currentPath = _currentPath.substring(0, lastSlash);
} else {
_currentPath = BOOKS_FOLDER;
}
}
void navigateToChild(const String& dirName) {
_currentPath = _currentPath + "/" + dirName;
}
// ---- File Scanning ----
void scanFiles() {
_fileList.clear();
_dirList.clear();
if (!SD.exists(BOOKS_FOLDER)) {
SD.mkdir(BOOKS_FOLDER);
Serial.printf("TextReader: Created %s\n", BOOKS_FOLDER);
}
File root = SD.open(_currentPath.c_str());
File root = SD.open(BOOKS_FOLDER);
if (!root || !root.isDirectory()) return;
File f = root.openNextFile();
while (f && (_fileList.size() + _dirList.size()) < READER_MAX_FILES) {
String name = String(f.name());
int slash = name.lastIndexOf('/');
if (slash >= 0) name = name.substring(slash + 1);
while (f && _fileList.size() < READER_MAX_FILES) {
if (!f.isDirectory()) {
String name = String(f.name());
int slash = name.lastIndexOf('/');
if (slash >= 0) name = name.substring(slash + 1);
// Skip hidden files/dirs
if (name.startsWith(".")) {
f = root.openNextFile();
continue;
}
if (f.isDirectory()) {
_dirList.push_back(name);
} else if (name.endsWith(".txt") || name.endsWith(".TXT") ||
name.endsWith(".epub") || name.endsWith(".EPUB")) {
_fileList.push_back(name);
if (!name.startsWith(".") &&
(name.endsWith(".txt") || name.endsWith(".TXT") ||
name.endsWith(".epub") || name.endsWith(".EPUB"))) {
_fileList.push_back(name);
}
}
f = root.openNextFile();
}
root.close();
Serial.printf("TextReader: %s — %d dirs, %d files\n",
_currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size());
Serial.printf("TextReader: Found %d files\n", _fileList.size());
}
// ---- Book Open/Close ----
@@ -581,7 +518,7 @@ private:
// ---- EPUB auto-conversion ----
String actualFilename = filename;
String actualFullPath = _currentPath + "/" + filename;
String actualFullPath = String(BOOKS_FOLDER) + "/" + filename;
bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");
if (isEpub) {
@@ -818,26 +755,15 @@ private:
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
if (isAtBooksRoot()) {
display.print("Text Reader");
} else {
// Show current subfolder name
int lastSlash = _currentPath.lastIndexOf('/');
String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath;
char hdrBuf[20];
strncpy(hdrBuf, folderName.c_str(), 17);
hdrBuf[17] = '\0';
display.print(hdrBuf);
}
display.print("Text Reader");
int totalItems = totalListItems();
sprintf(tmp, "[%d]", totalItems);
sprintf(tmp, "[%d]", (int)_fileList.size());
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.print(tmp);
display.drawRect(0, 11, display.width(), 1);
if (totalItems == 0) {
if (_fileList.size() == 0) {
display.setCursor(0, 18);
display.setColor(DisplayDriver::LIGHT);
display.print("No files found");
@@ -854,8 +780,8 @@ private:
if (maxVisible > 15) maxVisible = 15;
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
totalItems - maxVisible));
int endIdx = min(totalItems, startIdx + maxVisible);
(int)_fileList.size() - maxVisible));
int endIdx = min((int)_fileList.size(), startIdx + maxVisible);
int y = startY;
for (int i = startIdx; i < endIdx; i++) {
@@ -874,41 +800,27 @@ private:
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
int type = itemTypeAt(i);
// Build display string: "> filename.txt *" (asterisk if has bookmark)
String line = selected ? "> " : " ";
String name = _fileList[i];
if (type == 0) {
// ".." parent directory
line += ".. (up)";
} else if (type == 1) {
// Subdirectory
line += "/" + dirNameAt(i);
// Truncate if needed
if ((int)line.length() > _charsPerLine) {
line = line.substring(0, _charsPerLine - 3) + "...";
// Check for resume indicator
String suffix = "";
for (int j = 0; j < (int)_fileCache.size(); j++) {
if (_fileCache[j].filename == name && _fileCache[j].lastReadPage > 0) {
suffix = " *";
break;
}
} else {
// File
int fi = fileIndexAt(i);
String name = _fileList[fi];
// Check for resume indicator
String suffix = "";
if (fi < (int)_fileCache.size()) {
if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) {
suffix = " *";
}
}
// Truncate if needed
int maxLen = _charsPerLine - 4 - suffix.length();
if ((int)name.length() > maxLen) {
name = name.substring(0, maxLen - 3) + "...";
}
line += name + suffix;
}
// Truncate if needed
int maxLen = _charsPerLine - 4 - suffix.length();
if ((int)name.length() > maxLen) {
name = name.substring(0, maxLen - 3) + "...";
}
line += name + suffix;
display.print(line.c_str());
y += listLineH;
}
display.setTextSize(1); // Restore
@@ -1016,8 +928,7 @@ public:
_bootIndexed(false), _display(nullptr),
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
_headerHeight(14), _footerHeight(14),
_selectedFile(0), _currentPath(BOOKS_FOLDER),
_fileOpen(false), _currentPage(0), _totalPages(0),
_selectedFile(0), _fileOpen(false), _currentPage(0), _totalPages(0),
_pageBufLen(0), _contentDirty(true) {
}
@@ -1157,8 +1068,8 @@ public:
indexProgress++;
drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);
// Try current path first, then epub cache fallback
String fullPath = _currentPath + "/" + _fileList[i];
// Try BOOKS_FOLDER first, then epub cache fallback
String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i];
File file = SD.open(fullPath.c_str(), FILE_READ);
if (!file) {
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
@@ -1255,8 +1166,6 @@ public:
}
bool handleFileListInput(char c) {
int total = totalListItems();
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_selectedFile > 0) {
@@ -1268,36 +1177,18 @@ public:
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_selectedFile < total - 1) {
if (_selectedFile < (int)_fileList.size() - 1) {
_selectedFile++;
return true;
}
return false;
}
// Enter - open selected item (directory or file)
// Enter - open selected file
if (c == '\r' || c == 13) {
if (total == 0 || _selectedFile >= total) return false;
int type = itemTypeAt(_selectedFile);
if (type == 0) {
// ".." — navigate to parent
navigateToParent();
rescanAndIndex();
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
openBook(_fileList[_selectedFile]);
return true;
} else if (type == 1) {
// Subdirectory — navigate into it
navigateToChild(dirNameAt(_selectedFile));
rescanAndIndex();
return true;
} else {
// File — open it
int fi = fileIndexAt(_selectedFile);
if (fi >= 0 && fi < (int)_fileList.size()) {
openBook(_fileList[fi]);
return true;
}
}
return false;
}
@@ -1305,53 +1196,6 @@ public:
return false;
}
// Rescan current directory and re-index its files.
// Called when navigating into or out of a subfolder.
void rescanAndIndex() {
scanFiles();
_selectedFile = 0;
// Rebuild file cache for the new directory's files
_fileCache.clear();
_fileCache.resize(_fileList.size());
for (int i = 0; i < (int)_fileList.size(); i++) {
if (!loadIndex(_fileList[i], _fileCache[i])) {
// Not cached — skip EPUB auto-indexing here (it happens on open)
// For .txt files, index now
if (!(_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB"))) {
String fullPath = _currentPath + "/" + _fileList[i];
File file = SD.open(fullPath.c_str(), FILE_READ);
if (!file) {
// Try epub cache fallback
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
file = SD.open(cacheFallback.c_str(), FILE_READ);
}
if (file) {
FileCache& cache = _fileCache[i];
cache.filename = _fileList[i];
cache.fileSize = file.size();
cache.fullyIndexed = false;
cache.lastReadPage = 0;
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1);
cache.fullyIndexed = !file.available();
file.close();
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
cache.fullyIndexed, 0);
}
} else {
_fileCache[i].filename = "";
}
}
yield(); // Feed WDT between files
}
digitalWrite(SDCARD_CS, HIGH);
}
bool handleReadingInput(char c) {
// W/A - previous page
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {

View File

@@ -2,7 +2,6 @@
#include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h"
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "target.h"
#include "GPSDutyCycle.h"
#ifdef WIFI_SSID
@@ -37,13 +36,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
#include "RepeaterAdminScreen.h"
class SplashScreen : public UIScreen {
UITask* _task;
@@ -124,8 +117,12 @@ class HomeScreen : public UIScreen {
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
// Use voltage-based estimation to match BLE app readings
uint8_t batteryPercentage = 0;
#if HAS_BQ27220
// Use fuel gauge SOC directly — accurate across the full discharge curve
batteryPercentage = board.getBatteryPercent();
#else
// Fallback: voltage-based linear estimation for boards without fuel gauge
if (batteryMilliVolts > 0) {
const int minMilliVolts = 3000;
const int maxMilliVolts = 4200;
@@ -134,6 +131,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
if (pct > 100) pct = 100;
batteryPercentage = (uint8_t)pct;
}
#endif
display.setColor(DisplayDriver::GREEN);
@@ -152,8 +150,6 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
int iconX = display.width() - totalWidth;
int iconY = 0; // vertically align with node name text
if (outIconX) *outIconX = iconX;
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
@@ -173,24 +169,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.
// Uses the font renderer (not manual pixel drawing) since it handles
// the e-ink coordinate scaling correctly.
void renderAudioIndicator(DisplayDriver& display, int batteryLeftX) {
if (!_task->isAudioPlayingInBackground()) return;
display.setColor(DisplayDriver::GREEN);
display.setTextSize(0); // tiny font (same as clock & battery %)
int x = batteryLeftX - display.getTextWidth(">>") - 2;
display.setCursor(x, -3); // align vertically with battery text
display.print(">>");
display.setTextSize(1); // restore
}
#endif
CayenneLPP sensors_lpp;
int sensors_nb = 0;
bool sensors_scroll = false;
@@ -248,15 +226,7 @@ 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
{
@@ -330,17 +300,11 @@ public:
display.setTextSize(0); // tinyfont 6x8 monospaced
display.drawTextCentered(display.width() / 2, y, "Press:");
y += 12;
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings");
y += 10;
#ifdef HAS_4G_MODEM
display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] SMS ");
#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
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
y += 14;
// Nav hint
@@ -449,7 +413,7 @@ public:
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
// NMEA sentence counter — confirms baud rate and data flow
// NMEA sentence counter — confirms baud rate and data flow
display.drawTextLeftAlign(0, y, "sentences");
if (gpsDuty.isHardwareOn()) {
uint16_t sps = gpsStream.getSentencesPerSec();
@@ -624,13 +588,6 @@ public:
display.drawTextLeftAlign(0, y, "voltage");
sprintf(buf, "%d.%03d V", mv / 1000, mv % 1000);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Remaining capacity
uint16_t remCap = board.getRemainingCapacity();
display.drawTextLeftAlign(0, y, "remaining cap");
sprintf(buf, "%d mAh", remCap);
display.drawTextRightAlign(display.width()-1, y, buf);
#endif
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::GREEN);
@@ -868,12 +825,6 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
vibration.begin();
#endif
// Keyboard backlight for message flash notifications
#ifdef KB_BL_PIN
pinMode(KB_BL_PIN, OUTPUT);
digitalWrite(KB_BL_PIN, LOW);
#endif
ui_started_at = millis();
_alert_expiry = 0;
@@ -885,11 +836,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
text_reader = new TextReaderScreen(this);
notes_screen = new NotesScreen(this);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
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);
}
@@ -936,8 +884,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)
@@ -955,8 +902,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
@@ -981,14 +928,6 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
_next_refresh = 100; // trigger refresh
}
}
// Keyboard flash notification
#ifdef KB_BL_PIN
if (_node_prefs->kb_flash_notify) {
digitalWrite(KB_BL_PIN, HIGH);
_kb_flash_off_at = millis() + 200; // 200ms flash
}
#endif
}
void UITask::userLedHandler() {
@@ -1124,14 +1063,6 @@ void UITask::loop() {
userLedHandler();
// Turn off keyboard flash after timeout
#ifdef KB_BL_PIN
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
digitalWrite(KB_BL_PIN, LOW);
_kb_flash_off_at = 0;
}
#endif
#ifdef PIN_BUZZER
if (buzzer.isPlaying()) buzzer.loop();
#endif
@@ -1242,13 +1173,13 @@ void UITask::toggleGPS() {
if (_sensors != NULL) {
if (_node_prefs->gps_enabled) {
// Disable GPS — cut hardware power
// Disable GPS — cut hardware power
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
gpsDuty.disable();
notify(UIEventType::ack);
} else {
// Enable GPS — start duty cycle
// Enable GPS — start duty cycle
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
gpsDuty.enable();
@@ -1380,53 +1311,16 @@ 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) {
abPlayer->enter(*_display);
}
setCurrScreen(audiobook_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_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();
}
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
// Format the message as "Sender: message"
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
// Add to channel history with path_len=0 (local message)
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
}
void UITask::gotoRepeaterAdmin(int contactIdx) {
// Lazy-initialize on first use (same pattern as audiobook player)
if (repeater_admin == nullptr) {
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
}
// Get contact name for the screen header
ContactInfo contact;
char name[32] = "Unknown";
@@ -1446,30 +1340,29 @@ void UITask::gotoRepeaterAdmin(int contactIdx) {
_next_refresh = 100;
}
uint8_t UITask::getChannelScreenViewIdx() const {
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
}
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
// Format the message as "Sender: message"
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
// Add to channel history with path_len=0 (local message)
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
}
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
if (repeater_admin && isOnRepeaterAdmin()) {
if (isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
_next_refresh = 100; // trigger re-render
}
}
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
if (repeater_admin && isOnRepeaterAdmin()) {
if (isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
_next_refresh = 100; // trigger re-render
}
}
#ifdef MECK_AUDIO_VARIANT
bool UITask::isAudioPlayingInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isAudioActive();
}
bool UITask::isAudioPausedInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isBookOpen() && !player->isAudioActive();
}
#endif
}

View File

@@ -22,10 +22,6 @@
#include "../AbstractUITask.h"
#include "../NodePrefs.h"
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#endif
class UITask : public AbstractUITask {
DisplayDriver* _display;
SensorManager* _sensors;
@@ -36,7 +32,6 @@ class UITask : public AbstractUITask {
GenericVibration vibration;
#endif
unsigned long _next_refresh, _auto_off;
unsigned long _kb_flash_off_at; // Keyboard flash turn-off timer
NodePrefs* _node_prefs;
char _alert[80];
unsigned long _alert_expiry;
@@ -61,11 +56,8 @@ class UITask : public AbstractUITask {
UIScreen* text_reader; // *** NEW: Text reader screen ***
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
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
UIScreen* curr;
void userLedHandler();
@@ -82,7 +74,6 @@ public:
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
next_batt_chck = _next_refresh = 0;
_kb_flash_off_at = 0;
ui_started_at = 0;
curr = NULL;
}
@@ -96,12 +87,7 @@ public:
void gotoSettingsScreen(); // Navigate to settings
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
#ifdef HAS_4G_MODEM
void gotoSMSScreen();
bool isOnSMSScreen() const { return curr == sms_screen; }
SMSScreen* getSMSScreen() const { return (SMSScreen*)sms_screen; }
#endif
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
void showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
int getMsgCount() const { return _msgcount; }
@@ -114,12 +100,6 @@ public:
bool isOnSettingsScreen() const { return curr == settings_screen; }
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
#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();
@@ -134,10 +114,6 @@ public:
// Add a sent message to the channel screen history
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
// Repeater admin callbacks
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
void onAdminCliResponse(const char* from_name, const char* text) override;
// Get current screen for checking state
UIScreen* getCurrentScreen() const { return curr; }
@@ -148,15 +124,18 @@ public:
UIScreen* getChannelScreen() const { return channel_screen; }
UIScreen* getSettingsScreen() const { return settings_screen; }
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
// 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;
// Repeater admin callbacks (from MyMesh via AbstractUITask)
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
void onAdminCliResponse(const char* from_name, const char* text) override;
void shutdown(bool restart = false);
};

View File

@@ -46,10 +46,9 @@ void TDeckBoard::begin() {
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
#endif
// 4G Modem power management
// On 4G builds, ModemManager::begin() handles power-on — don't kill it here.
// On non-4G builds, disable modem power to save current and turn off red LED.
#if defined(MODEM_POWER_EN) && !defined(HAS_4G_MODEM)
// Disable 4G modem power (only present on 4G version, not audio version)
// This turns off the red status LED on the modem module
#ifdef MODEM_POWER_EN
pinMode(MODEM_POWER_EN, OUTPUT);
digitalWrite(MODEM_POWER_EN, LOW); // Cut power to modem
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - 4G modem power disabled");
@@ -168,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

View File

@@ -80,8 +80,7 @@ build_flags =
-D PIN_DISPLAY_BL=45
-D PIN_USER_BTN=0
-D CST328_PIN_RST=38
-D FIRMWARE_VERSION='"Meck v0.9.1A"'
-D ARDUINO_LOOP_STACK_SIZE=32768
-D FIRMWARE_VERSION='"Meck v0.8.9"'
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_TDeck_Pro>
+<helpers/sensors/*.cpp>
@@ -92,35 +91,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
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)
[env:meck_audio_standalone]
[env:LilyGo_TDeck_Pro_companion_radio_usb]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
@@ -128,7 +99,6 @@ 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>
@@ -138,11 +108,8 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
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_companion_radio_ble]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
@@ -151,8 +118,6 @@ build_flags =
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D FIRMWARE_VERSION='"Meck v0.9.2-4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -161,4 +126,22 @@ 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
[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