46 Commits

Author SHA1 Message Date
pelgraine
b812ff75a9 edited sms app guide 2026-02-20 22:29:27 +11:00
pelgraine
4477d5c812 updated slight eink refresh lag; minor nav bar ui fixes to sms app; added sms app guide 2026-02-20 22:27:59 +11:00
pelgraine
f06a1f5499 Sms app implementation phase 2 - add contact in message view screen; time of message displayed fix using 4G modem network sync - need to wait about 10-ish seconds after boot for auto network clock sync 2026-02-20 22:03:06 +11:00
pelgraine
458db8d4c4 implement sms app v1 attempt 1 4g variant only 2026-02-20 08:07:47 +11:00
pelgraine
2576a6590b codebase into one branch consolidation 2026-02-20 06:23:59 +11:00
pelgraine
5cc9feb3e9 fix ble send message buffer handling 2026-02-20 05:53:39 +11:00
pelgraine
d76fa04613 updated firmware version and date; same changes as main branch implementing repeater hop path view for last most recent channel msg rcd 2026-02-20 05:47:00 +11:00
pelgraine
5473f29eec scroll bar in channel message view - W or S for up down 2026-02-20 05:01:20 +11:00
pelgraine
b85172bcc4 fix ble message history bug app to device 2026-02-20 04:44:06 +11:00
pelgraine
3a32555add changed wording of light flash on off to make it slightly clearer 2026-02-17 20:14:12 +11:00
pelgraine
034cc64f8c update uitasks to enable keyboard pulse light notifcation 2026-02-17 19:53:50 +11:00
pelgraine
16bc0ed69d update settingscreen to enable keyboard pulse light 2026-02-17 19:51:51 +11:00
pelgraine
644eb432b5 update node prefs to enable keyboard pulse light 2026-02-17 19:50:06 +11:00
pelgraine
f2956e9d26 changed firmware version date; changed render battery indicator back to meshcore app display method 2026-02-17 18:55:10 +11:00
pelgraine
8e83155698 45 m sleep timer; track queing from sub folders; better wav file name data extraction 2026-02-16 18:28:18 +11:00
pelgraine
cd594c4116 updated firmware version and date 2026-02-16 18:10:59 +11:00
pelgraine
b43ffe9578 msgread fix and newmsg alert suppression when in repeater admin login page 2026-02-16 17:58:46 +11:00
pelgraine
d4b1824b1c using different HAS_BQ27220 for renderbatteryindicator 2026-02-15 19:33:19 +11:00
pelgraine
9809f47d29 battery charge estimated runtime fix - 2 to 3 charge discharge cycles needed for full calibration to 1400mah 2026-02-15 09:19:43 +11:00
pelgraine
bf89da0eb5 incorporated battery gauge view on home screen including estimated run time duration 2026-02-15 07:48:48 +11:00
pelgraine
aa2e1af999 improvements to bookmark and cache optimisation for load times super fast and reduced serial output 2026-02-15 01:39:14 +11:00
pelgraine
472b0ee662 fixed background audio play and >> icon regression 2026-02-15 01:27:47 +11:00
pelgraine
1f5cbbd4db same repeater admin fixes as main 2026-02-15 01:02:35 +11:00
pelgraine
f451b49226 minor text alignment fix 2026-02-14 16:21:38 +11:00
pelgraine
d10aa2c571 fixed home view so that pin isn't shown when ble off. Add instructions for keyboard nav to home screen 2026-02-14 16:16:17 +11:00
pelgraine
a2e099f095 fixed home view so that pin isn't shown when ble off. Add instructions for keyboard nav to home screen 2026-02-14 16:06:39 +11:00
pelgraine
e5e41ff50b ui fixes for audiobook player, firmware version number updates, subdirectory support for both ereader and audiobook player file lists 2026-02-14 15:43:13 +11:00
pelgraine
2dc6977c20 updated audiobook player guide 2026-02-14 14:32:06 +11:00
pelgraine
5c540e9092 fixed settings page so corrected radio preset list options available and custom frequency edits refined. 2026-02-14 14:13:43 +11:00
pelgraine
670efa75b0 Fixed heap allocation order to sort out ble pairing for audiobook player version. Expanded char in uitask to allow firmware version suffix to display in splash screen. 2026-02-14 11:25:21 +11:00
pelgraine
3a486832c8 Merge branch 'main' into audio-player 2026-02-14 10:41:33 +11:00
pelgraine
0a892f2dad changed to hybrid render battery indicator method 2026-02-14 10:41:03 +11:00
pelgraine
7f75ea8309 Merge branch 'main' into audio-player 2026-02-14 10:17:58 +11:00
pelgraine
b1e3f2ac28 Back to original serialbleinterface to start afresh 2026-02-14 10:17:11 +11:00
pelgraine
ddfe05ad20 m4b incompatibility workaround by renaming file extension to m4a when playing 2026-02-14 01:49:25 +11:00
pelgraine
d51ca6db0b "Updated home screen 'background audio playing' icon to >> for clarity" 2026-02-14 01:05:01 +11:00
pelgraine
3ab8191d19 amened ble connection parameter (battery-saving) fix that was causing performance issues; updated firrmware date in mymesh; ui update for audiobook player; audiobook player now lkeeps playing on exit unless you pause it so background audio is enabled 2026-02-14 00:17:44 +11:00
pelgraine
546ce55c2b Gave up on trying to extract cover art from mp3 files and removed debug logs. Updated firmware version to match variant type 2026-02-13 23:27:32 +11:00
pelgraine
1f46bc1970 updated simple audio player ui to make control/nav more evident and i2s reset to try accommodate sample rate diffs between files added 2026-02-13 22:53:35 +11:00
pelgraine
db8a73004e audiobook function redo - initial success - attempt 1 2026-02-13 22:38:37 +11:00
pelgraine
209a2f1693 updated firmware version 2026-02-13 21:02:33 +11:00
pelgraine
4683711877 added firmware version build flag to stop device unbonding on new firmware version flash via vscode 2026-02-13 18:43:23 +11:00
pelgraine
9610277b83 ble battery life extension improvements and firmware bond ble pairing bug fix:
- Bond clearing on firmware version change (lines 38-67) — stores the firmware version string in SPIFFS at /ble_ver. On boot, if it doesn't match, all stored bonds are wiped. This fixes the forget/re-pair issue after flashing. Normal reboots keep pairing intact.
- TX power -3 dBm (line 73).
Connection parameter negotiation (lines 137-147) — latency=4 for power saving when connected.
- Advertising intervals 300ms/600ms (three places) — compromise between discovery speed and power.
- No controller power-down — the header file is unchanged from stock.
2026-02-13 18:40:51 +11:00
pelgraine
745efc4cc1 updated firmware version and date 2026-02-13 18:37:34 +11:00
pelgraine
7223395740 Improved device ui battery rendering for more accurate battery indicator 2026-02-13 18:36:17 +11:00
pelgraine
9ef1fa4f1b moved cpu.begin to earlier to reduce risk of brownout boot stuck at low voltage 2026-02-13 18:29:06 +11:00
27 changed files with 6223 additions and 173 deletions

78
Audiobook Player Guide.md Normal file
View File

@@ -0,0 +1,78 @@
## 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)
│ └── ...
└── ...
```

116
SMS App Guide.md Normal file
View File

@@ -0,0 +1,116 @@
## 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,7 +40,8 @@ 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) = 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 notify(UIEventType t = UIEventType::none) = 0;
virtual void loop() = 0;
virtual void showAlert(const char* text, int duration_millis) {}

View File

@@ -439,7 +439,8 @@ 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) {
_ui->newMsg(path_len, from.name, text, offline_queue_len);
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);
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
}
#endif
@@ -523,6 +524,13 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
const char *text) {
markConnectionActive(from); // in case this is from a server, and we have a connection
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text);
// Forward CLI response to UI admin screen if admin session is active
#ifdef DISPLAY_CLASS
if (_admin_contact_idx >= 0 && _ui) {
_ui->onAdminCliResponse(from.name, text);
}
#endif
}
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
@@ -574,7 +582,8 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
channel_name = channel_details.name;
}
if (_ui) {
_ui->newMsg(path_len, channel_name, text, offline_queue_len);
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);
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::channelMessage); //buzz if enabled
}
#endif
@@ -650,6 +659,53 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
return true;
}
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
uint32_t est_timeout;
int result = sendLogin(*recipient, password, est_timeout);
if (result == MSG_SEND_FAILED) {
MESH_DEBUG_PRINTLN("UI: Admin login send failed to %s", recipient->name);
return false;
}
clearPendingReqs();
memcpy(&pending_login, recipient->id.pub_key, 4);
_admin_contact_idx = contact_idx;
MESH_DEBUG_PRINTLN("UI: Admin login sent to %s (%s), timeout=%dms",
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
est_timeout);
return true;
}
bool MyMesh::uiSendCliCommand(uint32_t contact_idx, const char* command) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
uint32_t est_timeout;
int result = sendCommandData(*recipient, timestamp, 0, command, est_timeout);
if (result == MSG_SEND_FAILED) {
MESH_DEBUG_PRINTLN("UI: CLI command send failed to %s: %s", recipient->name, command);
return false;
}
_admin_contact_idx = contact_idx;
MESH_DEBUG_PRINTLN("UI: CLI command sent to %s (%s): %s, timeout=%dms",
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
command, est_timeout);
return true;
}
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
uint8_t len, uint8_t *reply) {
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
@@ -708,6 +764,11 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
out_frame[i++] = 0; // legacy: is_admin = false
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
#ifdef DISPLAY_CLASS
// Notify UI of successful legacy login
if (_ui) _ui->onAdminLoginResult(true, 0, tag);
#endif
} else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
if (keep_alive_secs > 0) {
@@ -721,11 +782,21 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
i += 4; // NEW: include server timestamp
out_frame[i++] = data[7]; // NEW (v7): ACL permissions
out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL
#ifdef DISPLAY_CLASS
// Notify UI of successful login
if (_ui) _ui->onAdminLoginResult(true, data[6], tag);
#endif
} else {
out_frame[i++] = PUSH_CODE_LOGIN_FAIL;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
#ifdef DISPLAY_CLASS
// Notify UI of login failure
if (_ui) _ui->onAdminLoginResult(false, 0, 0);
#endif
}
_serial->writeFrame(out_frame, i);
} else if (len > 4 && // check for status response
@@ -897,6 +968,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
memset(send_scope.key, 0, sizeof(send_scope.key));
memset(_sent_track, 0, sizeof(_sent_track));
_sent_track_idx = 0;
_admin_contact_idx = -1;
// defaults
memset(&_prefs, 0, sizeof(_prefs));
@@ -1111,6 +1183,8 @@ 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);
@@ -1119,6 +1193,11 @@ 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 "12 Feb 2026"
#define FIRMWARE_BUILD_DATE "20 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.8.5"
#define FIRMWARE_VERSION "Meck v0.9.2"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)

View File

@@ -29,4 +29,5 @@ 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

@@ -15,6 +15,7 @@
#include "ContactsScreen.h"
#include "ChannelScreen.h"
#include "SettingsScreen.h"
#include "RepeaterAdminScreen.h"
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
@@ -47,6 +48,26 @@
// 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;
@@ -320,6 +341,9 @@ 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");
@@ -404,7 +428,7 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
// ---------------------------------------------------------------------------
// Early SD card init — needed BEFORE the_mesh.begin() so we can restore
// Early SD card init — needed BEFORE the_mesh.begin() so we can restore
// settings from a previous firmware flash. The display SPI bus is already
// up (display.begin() ran earlier), so SD can share it now.
// ---------------------------------------------------------------------------
@@ -518,8 +542,39 @@ 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
@@ -540,7 +595,7 @@ void setup() {
}
#endif
// GPS duty cycle †honour saved pref, default to enabled on first boot
// GPS duty cycle — honour saved pref, default to enabled on first boot
#if HAS_GPS
{
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
@@ -555,9 +610,6 @@ 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)
@@ -565,13 +617,15 @@ 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 ===");
}
void loop() {
the_mesh.loop();
// GPS duty cycle †check for fix and manage power state
// GPS duty cycle — check for fix and manage power state
#if HAS_GPS
{
bool gps_hw_on = gpsDuty.loop();
@@ -588,6 +642,43 @@ 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)
@@ -595,7 +686,12 @@ void loop() {
bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing();
bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming();
bool notesSuppressLoop = notesEditing || notesRenaming;
if (!composeMode && !notesSuppressLoop) {
#ifdef HAS_4G_MODEM
bool smsSuppressLoop = smsMode && ((SMSScreen*)ui_task.getSMSScreen())->isComposing();
#else
bool smsSuppressLoop = false;
#endif
if (!composeMode && !notesSuppressLoop && !smsSuppressLoop) {
ui_task.loop();
} else {
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
@@ -610,14 +706,25 @@ 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 mode state for key routing
// Track reader/notes/audiobook mode state for key routing
readerMode = ui_task.isOnTextReader();
notesMode = ui_task.isOnNotesScreen();
audiobookMode = ui_task.isOnAudiobookPlayer();
#ifdef HAS_4G_MODEM
smsMode = ui_task.isOnSMSScreen();
#endif
#else
ui_task.loop();
#endif
@@ -807,6 +914,42 @@ 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();
@@ -982,11 +1125,95 @@ void handleKeyboardInput() {
return;
}
// All other keys → settings screen via injectKey
// All other keys → settings screen via injectKey
ui_task.injectKey(key);
return;
}
// *** REPEATER ADMIN MODE ***
if (ui_task.isOnRepeaterAdmin()) {
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
RepeaterAdminScreen::AdminState astate = admin->getState();
bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed());
// In password entry: Shift+Del exits, all other keys pass through normally
if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
if (shiftDel) {
Serial.println("Nav: Back to contacts from admin login");
ui_task.gotoContactsScreen();
} else {
ui_task.injectKey(key);
}
return;
}
// In menu state: Shift+Del exits to contacts, C opens compose
if (astate == RepeaterAdminScreen::STATE_MENU) {
if (shiftDel) {
Serial.println("Nav: Back to contacts from admin menu");
ui_task.gotoContactsScreen();
return;
}
// C key: allow entering compose mode from admin menu
if (key == 'c' || key == 'C') {
composeDM = false;
composeDMContactIdx = -1;
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
drawComposeScreen();
lastComposeRefresh = millis();
return;
}
// All other keys pass to admin screen
ui_task.injectKey(key);
return;
}
// In waiting/response/error states: convert Shift+Del to exit signal,
// pass all other keys through
if (shiftDel) {
ui_task.injectKey(KEY_ADMIN_EXIT);
} else {
ui_task.injectKey(key);
}
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':
@@ -1007,6 +1234,31 @@ 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");
@@ -1022,8 +1274,8 @@ void handleKeyboardInput() {
break;
case 's':
// Open settings (from home), or navigate down on channel/contacts
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
// Open settings (from home), or navigate down on channel/contacts/admin
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling
} else {
Serial.println("Opening settings");
@@ -1033,7 +1285,7 @@ void handleKeyboardInput() {
case 'w':
// Navigate up/previous (scroll on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
} else {
Serial.println("Nav: Previous");
@@ -1062,7 +1314,8 @@ void handleKeyboardInput() {
break;
case '\r':
// Enter = compose (only from channel or contacts screen)
// Select/Enter - if on contacts screen, enter DM compose for chat contacts
// or repeater admin for repeater contacts
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
int idx = cs->getSelectedContactIdx();
@@ -1077,10 +1330,22 @@ void handleKeyboardInput() {
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
drawComposeScreen();
lastComposeRefresh = millis();
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
// Open repeater admin screen
char rname[32];
cs->getSelectedContactName(rname, sizeof(rname));
Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx);
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0) {
Serial.printf("Selected non-chat contact type=%d idx=%d\n", ctype, idx);
// Non-chat, non-repeater contact (room, sensor, etc.) - future use
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();
@@ -1098,7 +1363,15 @@ void handleKeyboardInput() {
case 'q':
case '\b':
// Go back to home screen
// 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();
break;
@@ -1113,6 +1386,13 @@ 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);
@@ -1308,4 +1588,26 @@ 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,6 +14,7 @@
// Maximum messages to store in history
#define CHANNEL_MSG_HISTORY_SIZE 300
#define CHANNEL_MSG_TEXT_LEN 160
#define MSG_PATH_MAX 8 // Max repeater hops stored per message
#ifndef MAX_GROUP_CHANNELS
#define MAX_GROUP_CHANNELS 20
@@ -23,7 +24,7 @@
// On-disk format for message persistence (SD card)
// ---------------------------------------------------------------------------
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
#define MSG_FILE_VERSION 1
#define MSG_FILE_VERSION 2
#define MSG_FILE_PATH "/meshcore/messages.bin"
struct __attribute__((packed)) MsgFileHeader {
@@ -41,8 +42,9 @@ 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];
// 168 bytes total
// 176 bytes total
};
class UITask; // Forward declaration
@@ -55,6 +57,7 @@ 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;
};
@@ -70,21 +73,24 @@ 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(CHANNEL_MSG_HISTORY_SIZE), _viewChannelIdx(0), _sdReady(false) {
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(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) {
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
const uint8_t* path_bytes = nullptr) {
// Move to next slot in circular buffer
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
@@ -94,6 +100,13 @@ 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);
@@ -104,6 +117,7 @@ public:
// Reset scroll to show newest message
_scrollPos = 0;
_showPathOverlay = false; // Dismiss overlay on new message
// Persist to SD card
saveToSD();
@@ -123,7 +137,23 @@ public:
int getMessageCount() const { return _msgCount; }
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; }
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;
}
// -----------------------------------------------------------------------
// SD card persistence
@@ -163,6 +193,7 @@ 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));
}
@@ -228,6 +259,7 @@ 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++;
}
@@ -280,6 +312,120 @@ 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);
@@ -295,6 +441,7 @@ 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;
@@ -302,7 +449,8 @@ public:
int y = headerHeight;
// Build list of messages for this channel (newest first)
int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
// Static to avoid 1200-byte stack allocation every render cycle
static int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
int numChannelMsgs = 0;
for (int i = 0; i < _msgCount && numChannelMsgs < CHANNEL_MSG_HISTORY_SIZE; i++) {
@@ -320,6 +468,10 @@ 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;
@@ -361,7 +513,7 @@ public:
// Track position in pixels for emoji placement
// Uses advance width (cursor movement) not bounding box for px tracking
int lineW = display.width();
int lineW = display.width() - scrollBarW - 1; // Reserve space for scroll bar
int px = display.getTextWidth(tmp); // Pixel X after timestamp
char dblStr[3] = {0, 0, 0};
@@ -460,13 +612,39 @@ public:
if (y + lineHeight > maxY) screenFull = true;
}
// 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) {
// 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) {
_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
}
@@ -476,11 +654,11 @@ public:
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
// Left side: Q:Back A/D:Ch
display.print("Q:Back A/D:Ch");
// Left side: abbreviated controls
display.print("Q:Bck A/D:Ch V:Pth");
// Right side: Entr:New
const char* rightText = "Entr:New";
// Right side: Ent:New
const char* rightText = "Ent:New";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
@@ -492,8 +670,26 @@ 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

@@ -0,0 +1,651 @@
#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

@@ -0,0 +1,559 @@
#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

@@ -0,0 +1,123 @@
#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

@@ -12,6 +12,7 @@ extern MyMesh the_mesh;
#define ADMIN_PASSWORD_MAX 32
#define ADMIN_RESPONSE_MAX 512 // CLI responses can be multi-line
#define ADMIN_TIMEOUT_MS 15000 // 15s timeout for login/commands
#define KEY_ADMIN_EXIT 0xFE // Special key: Shift+Backspace exit (injected by main.cpp)
class RepeaterAdminScreen : public UIScreen {
public:
@@ -280,31 +281,34 @@ public:
switch (_state) {
case STATE_PASSWORD_ENTRY:
display.print("Q:Back");
display.print("Sh+Del:Exit");
{
const char* right = "Enter:Login";
const char* right = "Ent:Login";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
break;
case STATE_LOGGING_IN:
case STATE_COMMAND_PENDING:
display.print("Q:Cancel");
display.print("Sh+Del:Cancel");
break;
case STATE_MENU:
display.print("Q:Back");
display.print("Sh+Del:Exit");
{
const char* mid = "W/S:Sel";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
const char* right = "Ent:Run";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
int leftEnd = display.getTextWidth("Sh+Del:Exit") + 2;
int rightStart = display.width() - display.getTextWidth(right) - 2;
int midX = leftEnd + (rightStart - leftEnd - display.getTextWidth(mid)) / 2;
display.setCursor(midX, footerY);
display.print(mid);
display.setCursor(rightStart, footerY);
display.print(right);
}
break;
case STATE_RESPONSE_VIEW:
case STATE_ERROR:
display.print("Q:Menu");
display.print("Sh+Del:Menu");
if (_responseLen > bodyHeight / 9) { // if scrollable
const char* right = "W/S:Scrll";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
@@ -327,8 +331,8 @@ public:
return handlePasswordInput(c);
case STATE_LOGGING_IN:
case STATE_COMMAND_PENDING:
// Q to cancel and go back
if (c == 'q' || c == 'Q') {
// Shift+Del to cancel and go back
if (c == KEY_ADMIN_EXIT) {
_state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU;
return true;
}
@@ -370,9 +374,9 @@ private:
}
bool handlePasswordInput(char c) {
// Q without any password typed = go back (return false to signal "not handled")
if ((c == 'q' || c == 'Q') && _pwdLen == 0) {
return false;
// Shift+Del = exit (always, regardless of password content)
if (c == KEY_ADMIN_EXIT) {
return false; // signal main.cpp to navigate back
}
// Enter to submit
@@ -472,8 +476,8 @@ private:
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
return executeMenuCommand((MenuItem)_menuSel);
}
// Q - back to contacts
if (c == 'q' || c == 'Q') {
// Shift+Del - back to contacts
if (c == KEY_ADMIN_EXIT) {
return false; // let UITask handle back navigation
}
// Number keys for quick selection
@@ -535,8 +539,8 @@ private:
_responseScroll++;
return true;
}
// Q - back to menu (or back to password on error)
if (c == 'q' || c == 'Q') {
// Shift+Del - back to menu (or back to password on error)
if (c == KEY_ADMIN_EXIT) {
if (_state == STATE_ERROR && _permissions == 0) {
// Not yet logged in, go back to password
_state = STATE_PASSWORD_ENTRY;

View File

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

View File

@@ -0,0 +1,176 @@
#pragma once
// =============================================================================
// SMSContacts - Phone-to-name lookup for SMS contacts (4G variant)
//
// Stores contacts in /sms/contacts.txt on SD card.
// Format: one contact per line as "phone=Display Name"
//
// Completely separate from mesh ContactInfo / IdentityStore.
//
// Guard: HAS_4G_MODEM
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef SMS_CONTACTS_H
#define SMS_CONTACTS_H
#include <Arduino.h>
#include <SD.h>
#define SMS_CONTACT_NAME_LEN 24
#define SMS_CONTACT_MAX 30
#define SMS_CONTACTS_FILE "/sms/contacts.txt"
struct SMSContact {
char phone[20]; // matches SMS_PHONE_LEN
char name[SMS_CONTACT_NAME_LEN];
bool valid;
};
class SMSContactStore {
public:
void begin() {
_count = 0;
memset(_contacts, 0, sizeof(_contacts));
load();
}
// Look up a name by phone number. Returns nullptr if not found.
const char* lookup(const char* phone) const {
for (int i = 0; i < _count; i++) {
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
return _contacts[i].name;
}
}
return nullptr;
}
// Fill buf with display name if found, otherwise copy phone number.
// Returns true if a name was found.
bool displayName(const char* phone, char* buf, size_t bufLen) const {
const char* name = lookup(phone);
if (name && name[0]) {
strncpy(buf, name, bufLen - 1);
buf[bufLen - 1] = '\0';
return true;
}
strncpy(buf, phone, bufLen - 1);
buf[bufLen - 1] = '\0';
return false;
}
// Add or update a contact. Returns true on success.
bool set(const char* phone, const char* name) {
// Update existing
for (int i = 0; i < _count; i++) {
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
strncpy(_contacts[i].name, name, SMS_CONTACT_NAME_LEN - 1);
_contacts[i].name[SMS_CONTACT_NAME_LEN - 1] = '\0';
save();
return true;
}
}
// Add new
if (_count >= SMS_CONTACT_MAX) return false;
strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1);
_contacts[_count].phone[sizeof(_contacts[_count].phone) - 1] = '\0';
strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1);
_contacts[_count].name[SMS_CONTACT_NAME_LEN - 1] = '\0';
_contacts[_count].valid = true;
_count++;
save();
return true;
}
// Remove a contact by phone number
bool remove(const char* phone) {
for (int i = 0; i < _count; i++) {
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
for (int j = i; j < _count - 1; j++) {
_contacts[j] = _contacts[j + 1];
}
_count--;
memset(&_contacts[_count], 0, sizeof(SMSContact));
save();
return true;
}
}
return false;
}
// Accessors for list browsing
int count() const { return _count; }
const SMSContact& get(int index) const { return _contacts[index]; }
// Check if a contact exists
bool exists(const char* phone) const { return lookup(phone) != nullptr; }
private:
SMSContact _contacts[SMS_CONTACT_MAX];
int _count = 0;
void load() {
File f = SD.open(SMS_CONTACTS_FILE, FILE_READ);
if (!f) {
Serial.println("[SMSContacts] No contacts file, starting fresh");
return;
}
char line[64];
while (f.available() && _count < SMS_CONTACT_MAX) {
int pos = 0;
while (f.available() && pos < (int)sizeof(line) - 1) {
char c = f.read();
if (c == '\n' || c == '\r') break;
line[pos++] = c;
}
line[pos] = '\0';
if (pos == 0) continue;
// Consume trailing CR/LF
while (f.available()) {
int pk = f.peek();
if (pk == '\n' || pk == '\r') { f.read(); continue; }
break;
}
// Parse "phone=name"
char* eq = strchr(line, '=');
if (!eq) continue;
*eq = '\0';
const char* phone = line;
const char* name = eq + 1;
if (strlen(phone) == 0 || strlen(name) == 0) continue;
strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1);
strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1);
_contacts[_count].valid = true;
_count++;
}
f.close();
Serial.printf("[SMSContacts] Loaded %d contacts\n", _count);
}
void save() {
if (!SD.exists("/sms")) SD.mkdir("/sms");
File f = SD.open(SMS_CONTACTS_FILE, FILE_WRITE);
if (!f) {
Serial.println("[SMSContacts] Failed to write contacts file");
return;
}
for (int i = 0; i < _count; i++) {
if (!_contacts[i].valid) continue;
f.print(_contacts[i].phone);
f.print('=');
f.println(_contacts[i].name);
}
f.close();
}
};
// Global singleton
extern SMSContactStore smsContacts;
#endif // SMS_CONTACTS_H
#endif // HAS_4G_MODEM

View File

@@ -0,0 +1,885 @@
#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

@@ -0,0 +1,196 @@
#ifdef HAS_4G_MODEM
#include "SMSStore.h"
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
#include "target.h" // For SDCARD_CS macro
// Global singleton
SMSStore smsStore;
void SMSStore::begin() {
// Ensure SMS directory exists
if (!SD.exists(SMS_DIR)) {
SD.mkdir(SMS_DIR);
MESH_DEBUG_PRINTLN("[SMSStore] created %s", SMS_DIR);
}
_ready = true;
MESH_DEBUG_PRINTLN("[SMSStore] ready");
}
void SMSStore::phoneToFilename(const char* phone, char* out, size_t outLen) {
// Convert phone number to safe filename: strip non-alphanumeric, prefix with dir
// e.g. "+1234567890" -> "/sms/p1234567890.sms"
char safe[SMS_PHONE_LEN];
int j = 0;
for (int i = 0; phone[i] && j < SMS_PHONE_LEN - 1; i++) {
char c = phone[i];
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
safe[j++] = c;
}
}
safe[j] = '\0';
snprintf(out, outLen, "%s/p%s.sms", SMS_DIR, safe);
}
bool SMSStore::saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp) {
if (!_ready) return false;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
// Build record
SMSRecord rec;
memset(&rec, 0, sizeof(rec));
rec.timestamp = timestamp;
rec.isSent = isSent ? 1 : 0;
rec.bodyLen = strlen(body);
if (rec.bodyLen >= SMS_BODY_LEN) rec.bodyLen = SMS_BODY_LEN - 1;
strncpy(rec.phone, phone, SMS_PHONE_LEN - 1);
strncpy(rec.body, body, SMS_BODY_LEN - 1);
// Append to file
File f = SD.open(filepath, FILE_APPEND);
if (!f) {
// Try creating
f = SD.open(filepath, FILE_WRITE);
if (!f) {
MESH_DEBUG_PRINTLN("[SMSStore] can't open %s", filepath);
return false;
}
}
size_t written = f.write((uint8_t*)&rec, sizeof(rec));
f.close();
// Release SD CS
digitalWrite(SDCARD_CS, HIGH);
return written == sizeof(rec);
}
int SMSStore::loadConversations(SMSConversation* out, int maxCount) {
if (!_ready) return 0;
File dir = SD.open(SMS_DIR);
if (!dir || !dir.isDirectory()) return 0;
int count = 0;
File entry;
while ((entry = dir.openNextFile()) && count < maxCount) {
const char* name = entry.name();
// Only process .sms files
if (!strstr(name, ".sms")) { entry.close(); continue; }
size_t fileSize = entry.size();
if (fileSize < sizeof(SMSRecord)) { entry.close(); continue; }
int numRecords = fileSize / sizeof(SMSRecord);
// Read the last record for preview
SMSRecord lastRec;
entry.seek(fileSize - sizeof(SMSRecord));
if (entry.read((uint8_t*)&lastRec, sizeof(SMSRecord)) != sizeof(SMSRecord)) {
entry.close();
continue;
}
SMSConversation& conv = out[count];
memset(&conv, 0, sizeof(SMSConversation));
strncpy(conv.phone, lastRec.phone, SMS_PHONE_LEN - 1);
strncpy(conv.preview, lastRec.body, 39);
conv.preview[39] = '\0';
conv.lastTimestamp = lastRec.timestamp;
conv.messageCount = numRecords;
conv.unreadCount = 0; // TODO: track read state
conv.valid = true;
count++;
entry.close();
}
dir.close();
// Release SD CS
digitalWrite(SDCARD_CS, HIGH);
// Sort by most recent (simple bubble sort, small N)
for (int i = 0; i < count - 1; i++) {
for (int j = 0; j < count - 1 - i; j++) {
if (out[j].lastTimestamp < out[j + 1].lastTimestamp) {
SMSConversation tmp = out[j];
out[j] = out[j + 1];
out[j + 1] = tmp;
}
}
}
return count;
}
int SMSStore::loadMessages(const char* phone, SMSMessage* out, int maxCount) {
if (!_ready) return 0;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
File f = SD.open(filepath, FILE_READ);
if (!f) return 0;
size_t fileSize = f.size();
int numRecords = fileSize / sizeof(SMSRecord);
// Load from end of file (most recent N messages), in chronological order
int startIdx = numRecords > maxCount ? numRecords - maxCount : 0;
// Read chronologically (oldest first) for chat-style display
SMSRecord rec;
int outIdx = 0;
for (int i = startIdx; i < numRecords && outIdx < maxCount; i++) {
f.seek(i * sizeof(SMSRecord));
if (f.read((uint8_t*)&rec, sizeof(SMSRecord)) != sizeof(SMSRecord)) continue;
out[outIdx].timestamp = rec.timestamp;
out[outIdx].isSent = rec.isSent != 0;
out[outIdx].valid = true;
strncpy(out[outIdx].phone, rec.phone, SMS_PHONE_LEN - 1);
strncpy(out[outIdx].body, rec.body, SMS_BODY_LEN - 1);
outIdx++;
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
return outIdx;
}
bool SMSStore::deleteConversation(const char* phone) {
if (!_ready) return false;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
bool ok = SD.remove(filepath);
digitalWrite(SDCARD_CS, HIGH);
return ok;
}
int SMSStore::getMessageCount(const char* phone) {
if (!_ready) return 0;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
File f = SD.open(filepath, FILE_READ);
if (!f) return 0;
int count = f.size() / sizeof(SMSRecord);
f.close();
digitalWrite(SDCARD_CS, HIGH);
return count;
}
#endif // HAS_4G_MODEM

View File

@@ -0,0 +1,87 @@
#pragma once
// =============================================================================
// SMSStore - SD card backed SMS message storage
//
// Stores sent and received messages in /sms/ on the SD card.
// Each conversation is a separate file named by phone number (sanitised).
// Messages are appended as fixed-size records for simple random access.
//
// Guard: HAS_4G_MODEM
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef SMS_STORE_H
#define SMS_STORE_H
#include <Arduino.h>
#include <SD.h>
#define SMS_PHONE_LEN 20
#define SMS_BODY_LEN 161
#define SMS_MAX_CONVERSATIONS 20
#define SMS_DIR "/sms"
// Fixed-size on-disk record (256 bytes, easy alignment)
struct SMSRecord {
uint32_t timestamp; // epoch seconds
uint8_t isSent; // 1=sent, 0=received
uint8_t reserved[2];
uint8_t bodyLen; // actual length of body
char phone[SMS_PHONE_LEN]; // 20
char body[SMS_BODY_LEN]; // 161
uint8_t padding[256 - 4 - 3 - 1 - SMS_PHONE_LEN - SMS_BODY_LEN];
};
// In-memory message for UI
struct SMSMessage {
uint32_t timestamp;
bool isSent;
bool valid;
char phone[SMS_PHONE_LEN];
char body[SMS_BODY_LEN];
};
// Conversation summary for inbox view
struct SMSConversation {
char phone[SMS_PHONE_LEN];
char preview[40]; // last message preview
uint32_t lastTimestamp;
int messageCount;
int unreadCount;
bool valid;
};
class SMSStore {
public:
void begin();
bool isReady() const { return _ready; }
// Save a message (sent or received)
bool saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp);
// Load conversation list (sorted by most recent)
int loadConversations(SMSConversation* out, int maxCount);
// Load messages for a specific phone number (chronological, oldest first)
int loadMessages(const char* phone, SMSMessage* out, int maxCount);
// Delete all messages for a phone number
bool deleteConversation(const char* phone);
// Get total message count for a phone number
int getMessageCount(const char* phone);
private:
bool _ready = false;
// Convert phone number to safe filename
void phoneToFilename(const char* phone, char* out, size_t outLen);
};
// Global singleton
extern SMSStore smsStore;
#endif // SMS_STORE_H
#endif // HAS_4G_MODEM

View File

@@ -6,6 +6,10 @@
#include <MeshCore.h>
#include "../NodePrefs.h"
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#endif
// Forward declarations
class UITask;
class MyMesh;
@@ -24,10 +28,22 @@ struct RadioPreset {
};
static const RadioPreset RADIO_PRESETS[] = {
{ "MeshCore Default", 915.0f, 250.0f, 10, 5, 20 },
{ "Long Range", 915.0f, 125.0f, 12, 8, 20 },
{ "Fast/Short", 915.0f, 500.0f, 7, 5, 20 },
{ "EU Default", 869.4f, 250.0f, 10, 5, 14 },
{ "Australia", 915.800f, 250.0f, 10, 5, 22 },
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22 },
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22 },
{ "Australia: QLD", 923.125f, 62.5f, 8, 5, 22 },
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 14 },
{ "EU/UK (Long Range)", 869.525f, 250.0f, 11, 5, 14 },
{ "EU/UK (Medium Range)", 869.525f, 250.0f, 10, 5, 14 },
{ "Czech Republic (Narrow)",869.432f, 62.5f, 7, 5, 14 },
{ "EU 433 (Long Range)", 433.650f, 250.0f, 11, 5, 14 },
{ "New Zealand", 917.375f, 250.0f, 11, 5, 22 },
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22 },
{ "Portugal 433", 433.375f, 62.5f, 9, 6, 14 },
{ "Portugal 868", 869.618f, 62.5f, 7, 6, 14 },
{ "Switzerland", 869.618f, 62.5f, 8, 8, 14 },
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5, 22 },
{ "Vietnam", 920.250f, 250.0f, 11, 5, 22 },
};
#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]))
@@ -43,6 +59,10 @@ 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"
@@ -72,7 +92,7 @@ private:
mesh::RTCClock* _rtc;
NodePrefs* _prefs;
// Row table rebuilt whenever channels change
// Row table — rebuilt whenever channels change
struct Row {
SettingsRowType type;
uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET
@@ -96,9 +116,14 @@ private:
// Onboarding mode
bool _onboarding;
// Dirty flag for radio params prompt to apply
// Dirty flag for radio params — prompt to apply
bool _radioChanged;
// 4G modem state (runtime cache of config)
#ifdef HAS_4G_MODEM
bool _modemEnabled;
#endif
// ---------------------------------------------------------------------------
// Row table management
// ---------------------------------------------------------------------------
@@ -114,6 +139,10 @@ 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
@@ -198,11 +227,11 @@ private:
strncpy(newCh.name, chanName, sizeof(newCh.name));
newCh.name[31] = '\0';
// SHA-256 the channel name first 16 bytes become the secret
// SHA-256 the channel name → first 16 bytes become the secret
uint8_t hash[32];
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
memcpy(newCh.channel.secret, hash, 16);
// Upper 16 bytes left as zero setChannel uses 128-bit mode
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
// Find next empty slot
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
@@ -275,6 +304,9 @@ public:
_cursor = 0;
_scrollTop = 0;
_radioChanged = false;
#ifdef HAS_4G_MODEM
_modemEnabled = ModemManager::loadEnabledConfig();
#endif
rebuildRows();
}
@@ -400,8 +432,8 @@ public:
}
case ROW_FREQ:
if (editing && _editMode == EDIT_NUMBER) {
snprintf(tmp, sizeof(tmp), "Freq: %.3f <W/S>", _editFloat);
if (editing && _editMode == EDIT_TEXT) {
snprintf(tmp, sizeof(tmp), "Freq: %s_ MHz", _editBuf);
} else {
snprintf(tmp, sizeof(tmp), "Freq: %.3f MHz", _prefs->freq);
}
@@ -453,6 +485,20 @@ 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 ---");
@@ -611,6 +657,15 @@ public:
_cursor = 1; // ROW_RADIO_PRESET
startEditPicker(max(0, detectCurrentPreset()));
}
} else if (type == ROW_FREQ) {
if (_editPos > 0) {
float f = strtof(_editBuf, nullptr);
f = constrain(f, 400.0f, 2500.0f);
_prefs->freq = f;
_radioChanged = true;
Serial.printf("Settings: Freq typed to %.3f\n", f);
}
_editMode = EDIT_NONE;
} else if (type == ROW_ADD_CHANNEL) {
if (_editPos > 0) {
createHashtagChannel(_editBuf);
@@ -684,7 +739,6 @@ public:
if (c == 'w' || c == 'W') {
switch (type) {
case ROW_FREQ: _editFloat += 0.1f; break;
case ROW_BW:
// Cycle through common bandwidths
if (_editFloat < 31.25f) _editFloat = 31.25f;
@@ -703,7 +757,6 @@ public:
}
if (c == 's' || c == 'S') {
switch (type) {
case ROW_FREQ: _editFloat -= 0.1f; break;
case ROW_BW:
if (_editFloat > 250.0f) _editFloat = 250.0f;
else if (_editFloat > 125.0f) _editFloat = 125.0f;
@@ -721,10 +774,6 @@ public:
if (c == '\r' || c == 13) {
// Confirm number edit
switch (type) {
case ROW_FREQ:
_prefs->freq = constrain(_editFloat, 400.0f, 2500.0f);
_radioChanged = true;
break;
case ROW_BW:
_prefs->bw = _editFloat;
_radioChanged = true;
@@ -787,9 +836,13 @@ public:
case ROW_RADIO_PRESET:
startEditPicker(max(0, detectCurrentPreset()));
break;
case ROW_FREQ:
startEditFloat(_prefs->freq);
case ROW_FREQ: {
// Use text input so user can type exact frequencies like 916.575
char freqStr[16];
snprintf(freqStr, sizeof(freqStr), "%.3f", _prefs->freq);
startEditText(freqStr);
break;
}
case ROW_BW:
startEditFloat(_prefs->bw);
break;
@@ -805,6 +858,25 @@ 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;
@@ -828,7 +900,7 @@ public:
}
}
// Q: back if radio changed, prompt to apply first
// Q: back — if radio changed, prompt to apply first
if (c == 'q' || c == 'Q') {
if (_radioChanged) {
_editMode = EDIT_CONFIRM;

View File

@@ -182,8 +182,10 @@ 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;
@@ -391,8 +393,8 @@ private:
idxFile.read(&fullyFlag, 1);
idxFile.read((uint8_t*)&lastRead, 4);
// Verify file hasn't changed - try BOOKS_FOLDER first, then epub cache
String fullPath = String(BOOKS_FOLDER) + "/" + filename;
// Verify file hasn't changed - try current path first, then epub cache
String fullPath = _currentPath + "/" + filename;
File txtFile = SD.open(fullPath.c_str(), FILE_READ);
if (!txtFile) {
// Fallback: check epub cache directory
@@ -482,33 +484,94 @@ 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(BOOKS_FOLDER);
File root = SD.open(_currentPath.c_str());
if (!root || !root.isDirectory()) return;
File f = root.openNextFile();
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);
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);
if (!name.startsWith(".") &&
(name.endsWith(".txt") || name.endsWith(".TXT") ||
name.endsWith(".epub") || name.endsWith(".EPUB"))) {
_fileList.push_back(name);
}
// 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);
}
f = root.openNextFile();
}
root.close();
Serial.printf("TextReader: Found %d files\n", _fileList.size());
Serial.printf("TextReader: %s — %d dirs, %d files\n",
_currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size());
}
// ---- Book Open/Close ----
@@ -518,7 +581,7 @@ private:
// ---- EPUB auto-conversion ----
String actualFilename = filename;
String actualFullPath = String(BOOKS_FOLDER) + "/" + filename;
String actualFullPath = _currentPath + "/" + filename;
bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");
if (isEpub) {
@@ -755,15 +818,26 @@ private:
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.print("Text Reader");
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);
}
sprintf(tmp, "[%d]", (int)_fileList.size());
int totalItems = totalListItems();
sprintf(tmp, "[%d]", totalItems);
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.print(tmp);
display.drawRect(0, 11, display.width(), 1);
if (_fileList.size() == 0) {
if (totalItems == 0) {
display.setCursor(0, 18);
display.setColor(DisplayDriver::LIGHT);
display.print("No files found");
@@ -780,8 +854,8 @@ private:
if (maxVisible > 15) maxVisible = 15;
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
(int)_fileList.size() - maxVisible));
int endIdx = min((int)_fileList.size(), startIdx + maxVisible);
totalItems - maxVisible));
int endIdx = min(totalItems, startIdx + maxVisible);
int y = startY;
for (int i = startIdx; i < endIdx; i++) {
@@ -800,27 +874,41 @@ private:
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
// Build display string: "> filename.txt *" (asterisk if has bookmark)
int type = itemTypeAt(i);
String line = selected ? "> " : " ";
String name = _fileList[i];
// 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;
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) + "...";
}
} 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
@@ -928,7 +1016,8 @@ public:
_bootIndexed(false), _display(nullptr),
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
_headerHeight(14), _footerHeight(14),
_selectedFile(0), _fileOpen(false), _currentPage(0), _totalPages(0),
_selectedFile(0), _currentPath(BOOKS_FOLDER),
_fileOpen(false), _currentPage(0), _totalPages(0),
_pageBufLen(0), _contentDirty(true) {
}
@@ -1068,8 +1157,8 @@ public:
indexProgress++;
drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);
// Try BOOKS_FOLDER first, then epub cache fallback
String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i];
// Try current path first, then epub cache fallback
String fullPath = _currentPath + "/" + _fileList[i];
File file = SD.open(fullPath.c_str(), FILE_READ);
if (!file) {
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
@@ -1166,6 +1255,8 @@ public:
}
bool handleFileListInput(char c) {
int total = totalListItems();
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_selectedFile > 0) {
@@ -1177,18 +1268,36 @@ public:
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_selectedFile < (int)_fileList.size() - 1) {
if (_selectedFile < total - 1) {
_selectedFile++;
return true;
}
return false;
}
// Enter - open selected file
// Enter - open selected item (directory or file)
if (c == '\r' || c == 13) {
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
openBook(_fileList[_selectedFile]);
if (total == 0 || _selectedFile >= total) return false;
int type = itemTypeAt(_selectedFile);
if (type == 0) {
// ".." — navigate to parent
navigateToParent();
rescanAndIndex();
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;
}
@@ -1196,6 +1305,53 @@ 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,6 +2,7 @@
#include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h"
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "target.h"
#include "GPSDutyCycle.h"
#ifdef WIFI_SSID
@@ -36,11 +37,18 @@
#include "ContactsScreen.h"
#include "TextReaderScreen.h"
#include "SettingsScreen.h"
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#endif
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#include "ModemManager.h"
#endif
class SplashScreen : public UIScreen {
UITask* _task;
unsigned long dismiss_after;
char _version_info[12];
char _version_info[24];
public:
SplashScreen(UITask* task) : _task(task) {
@@ -87,13 +95,18 @@ class HomeScreen : public UIScreen {
FIRST,
RECENT,
RADIO,
#ifdef BLE_PIN_CODE
BLUETOOTH,
#endif
ADVERT,
#if ENV_INCLUDE_GPS == 1
GPS,
#endif
#if UI_SENSORS_PAGE == 1
SENSORS,
#endif
#if HAS_BQ27220
BATTERY,
#endif
SHUTDOWN,
Count // keep as last
@@ -110,7 +123,7 @@ class HomeScreen : public UIScreen {
AdvertPath recent[UI_RECENT_LIST_SIZE];
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
// Use voltage-based estimation to match BLE app readings
uint8_t batteryPercentage = 0;
if (batteryMilliVolts > 0) {
@@ -139,6 +152,8 @@ 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);
@@ -158,6 +173,24 @@ 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;
@@ -206,16 +239,24 @@ public:
int render(DisplayDriver& display) override {
char tmp[80];
// node name
display.setTextSize(1);
// node name (tinyfont to avoid overlapping clock)
display.setTextSize(0);
display.setColor(DisplayDriver::GREEN);
char filtered_name[sizeof(_node_prefs->node_name)];
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
display.setCursor(0, 0);
display.setCursor(0, -3);
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
{
@@ -252,28 +293,60 @@ public:
}
if (_page == HomePage::FIRST) {
int y = 20;
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getMsgCount());
display.drawTextCentered(display.width() / 2, 20, tmp);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
#ifdef WIFI_SSID
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 54, tmp);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 12;
#endif
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID)
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
} else if (the_mesh.getBLEPin() != 0) { // BT pin
display.drawTextCentered(display.width() / 2, y, "< Connected >");
y += 12;
#ifdef BLE_PIN_CODE
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 43, tmp);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
#endif
}
#endif
// Menu shortcuts - tinyfont monospaced grid
y += 6;
display.setColor(DisplayDriver::LIGHT);
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 ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
#ifdef HAS_4G_MODEM
display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] 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
y += 14;
// Nav hint
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
display.setTextSize(1); // restore
} else if (_page == HomePage::RECENT) {
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
display.setColor(DisplayDriver::GREEN);
@@ -318,6 +391,7 @@ public:
display.setCursor(0, 53);
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
display.print(tmp);
#ifdef BLE_PIN_CODE
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18,
@@ -325,6 +399,7 @@ public:
32, 32);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
#endif
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
@@ -374,7 +449,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();
@@ -504,6 +579,58 @@ public:
}
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
else sensors_scroll_offset = 0;
#endif
#if HAS_BQ27220
} else if (_page == HomePage::BATTERY) {
char buf[30];
int y = 18;
// Title
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Battery Gauge");
y += 12;
display.setColor(DisplayDriver::LIGHT);
// Time to empty
uint16_t tte = board.getTimeToEmpty();
display.drawTextLeftAlign(0, y, "remaining");
if (tte == 0xFFFF || tte == 0) {
strcpy(buf, tte == 0 ? "depleted" : "charging");
} else if (tte >= 60) {
sprintf(buf, "%dh %dm", tte / 60, tte % 60);
} else {
sprintf(buf, "%d min", tte);
}
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Average current
int16_t avgCur = board.getAvgCurrent();
display.drawTextLeftAlign(0, y, "avg current");
sprintf(buf, "%d mA", avgCur);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Average power
int16_t avgPow = board.getAvgPower();
display.drawTextLeftAlign(0, y, "avg power");
sprintf(buf, "%d mW", avgPow);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Voltage (already available)
uint16_t mv = board.getBattMilliVolts();
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);
@@ -564,6 +691,7 @@ public:
}
return true;
}
#ifdef BLE_PIN_CODE
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
_task->disableSerial();
@@ -572,6 +700,7 @@ public:
}
return true;
}
#endif
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
@@ -739,6 +868,12 @@ 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;
@@ -750,6 +885,11 @@ 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
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);
}
@@ -791,12 +931,13 @@ switch(t){
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
if (msgcount == 0 && curr == msg_preview) {
gotoHomeScreen();
}
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path) {
_msgcount = msgcount;
// Add to preview screen (for notifications on non-keyboard devices)
@@ -814,15 +955,18 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
}
}
// Add to channel history screen with channel index
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text);
// Add to channel history screen with channel index and path data
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
#if defined(LilyGo_TDeck_Pro)
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
// Messages are stored in channel history, accessible via 'M' key
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
// Suppress alert entirely on admin screen - it needs focused interaction
if (!isOnRepeaterAdmin()) {
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
}
#else
// Other devices: Show full preview screen (legacy behavior)
setCurrScreen(msg_preview);
@@ -837,6 +981,14 @@ 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() {
@@ -972,6 +1124,14 @@ 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
@@ -1082,13 +1242,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();
@@ -1219,6 +1379,35 @@ void UITask::gotoOnboarding() {
_next_refresh = 100;
}
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();
}
@@ -1230,4 +1419,57 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
// 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";
if (the_mesh.getContactByIdx(contactIdx, contact)) {
strncpy(name, contact.name, sizeof(name) - 1);
name[sizeof(name) - 1] = '\0';
}
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
admin->openForContact(contactIdx, name);
setCurrScreen(repeater_admin);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
if (repeater_admin && 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()) {
((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,6 +22,10 @@
#include "../AbstractUITask.h"
#include "../NodePrefs.h"
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#endif
class UITask : public AbstractUITask {
DisplayDriver* _display;
SensorManager* _sensors;
@@ -32,6 +36,7 @@ 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;
@@ -56,6 +61,11 @@ 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* curr;
void userLedHandler();
@@ -72,6 +82,7 @@ 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;
}
@@ -84,6 +95,13 @@ public:
void gotoNotesScreen(); // Navigate to notes editor
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 showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
int getMsgCount() const { return _msgcount; }
@@ -94,6 +112,14 @@ public:
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
bool isOnNotesScreen() const { return curr == notes_screen; }
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();
@@ -108,6 +134,10 @@ 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; }
@@ -117,10 +147,14 @@ public:
UIScreen* getContactsScreen() const { return contacts_screen; }
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; }
// from AbstractUITask
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, 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 notify(UIEventType t = UIEventType::none) override;
void loop() override;

View File

@@ -249,4 +249,4 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
bool SerialBLEInterface::isConnected() const {
return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0;
}
}

View File

@@ -88,4 +88,4 @@ public:
#else
#define BLE_DEBUG_PRINT(...) {}
#define BLE_DEBUG_PRINTLN(...) {}
#endif
#endif

View File

@@ -46,9 +46,10 @@ void TDeckBoard::begin() {
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
#endif
// 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
// 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)
pinMode(MODEM_POWER_EN, OUTPUT);
digitalWrite(MODEM_POWER_EN, LOW); // Cut power to modem
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - 4G modem power disabled");
@@ -72,10 +73,11 @@ void TDeckBoard::begin() {
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
}
// Test BQ27220 communication
// Test BQ27220 communication and configure design capacity
#if HAS_BQ27220
uint16_t voltage = getBattMilliVolts();
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - Battery voltage: %d mV", voltage);
configureFuelGauge();
#endif
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
@@ -123,4 +125,233 @@ uint8_t TDeckBoard::getBatteryPercent() {
#else
return 0;
#endif
}
// ---- BQ27220 extended register helpers ----
#if HAS_BQ27220
// Read a 16-bit register from BQ27220. Returns 0 on I2C error.
static uint16_t bq27220_read16(uint8_t reg) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return 0;
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2) != 2) return 0;
uint16_t val = Wire.read();
val |= (Wire.read() << 8);
return val;
}
// Read a single byte from BQ27220 register.
static uint8_t bq27220_read8(uint8_t reg) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return 0;
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)1) != 1) return 0;
return Wire.read();
}
// Write a 16-bit subcommand to BQ27220 Control register (0x00).
// Subcommands control unsealing, config mode, sealing, etc.
static bool bq27220_writeControl(uint16_t subcmd) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x00); // Control register
Wire.write(subcmd & 0xFF); // LSB first
Wire.write((subcmd >> 8) & 0xFF); // MSB
return Wire.endTransmission() == 0;
}
#endif
// ---- BQ27220 Design Capacity configuration ----
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 1400 mAh
// cell. This function checks on boot and writes the correct value via the
// MAC Data Memory interface if needed. The value persists in battery-backed
// 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
bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
#if HAS_BQ27220
// Read current design capacity from standard command register
uint16_t currentDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
if (currentDC == designCapacity_mAh) {
Serial.println("BQ27220: Design Capacity already correct, skipping");
return true;
}
Serial.printf("BQ27220: Updating Design Capacity from %d to %d mAh\n", currentDC, designCapacity_mAh);
// Step 1: Unseal (default unseal keys)
bq27220_writeControl(0x0414);
delay(2);
bq27220_writeControl(0x3672);
delay(2);
// Step 2: Enter Full Access mode
bq27220_writeControl(0xFFFF);
delay(2);
bq27220_writeControl(0xFFFF);
delay(2);
// Step 3: Enter CFG_UPDATE mode
bq27220_writeControl(0x0090);
// Wait for CFGUPMODE bit (bit 10) in OperationStatus register
bool cfgReady = false;
for (int i = 0; i < 50; i++) {
delay(20);
uint16_t opStatus = bq27220_read16(BQ27220_REG_OP_STATUS);
Serial.printf("BQ27220: OperationStatus = 0x%04X (attempt %d)\n", opStatus, i);
if (opStatus & 0x0400) { // CFGUPMODE is bit 10
cfgReady = true;
break;
}
}
if (!cfgReady) {
Serial.println("BQ27220: ERROR - Timeout waiting for CFGUPDATE mode");
bq27220_writeControl(0x0092); // Try to exit cleanly
bq27220_writeControl(0x0030); // Re-seal
return false;
}
Serial.println("BQ27220: Entered CFGUPDATE mode");
// Step 4: Write Design Capacity via MAC Data Memory interface
// Design Capacity mAh lives at data memory address 0x929F
// 4a. Select the data memory block by writing address to 0x3E-0x3F
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); // MACDataControl register
Wire.write(0x9F); // Address low byte
Wire.write(0x92); // Address high byte
Wire.endTransmission();
delay(10);
// 4b. Read old data (MSB, LSB) and checksum for differential update
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint8_t oldChksum = bq27220_read8(0x60);
uint8_t dataLen = bq27220_read8(0x61);
Serial.printf("BQ27220: Old DC bytes=0x%02X 0x%02X chk=0x%02X len=%d\n",
oldMSB, oldLSB, oldChksum, dataLen);
// 4c. Compute new values (BQ27220 stores big-endian in data memory)
uint8_t newMSB = (designCapacity_mAh >> 8) & 0xFF;
uint8_t newLSB = designCapacity_mAh & 0xFF;
// Differential checksum: remove old bytes, add new bytes
uint8_t temp = (255 - oldChksum - oldMSB - oldLSB);
uint8_t newChksum = 255 - ((temp + newMSB + newLSB) & 0xFF);
Serial.printf("BQ27220: New DC bytes=0x%02X 0x%02X chk=0x%02X\n",
newMSB, newLSB, newChksum);
// 4d. Write address + new data as a single block transaction
// BQ27220 MAC requires: [0x3E] [addr_lo] [addr_hi] [data...]
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); // Start at MACDataControl
Wire.write(0x9F); // Address low byte
Wire.write(0x92); // Address high byte
Wire.write(newMSB); // Data byte 0 (at 0x40)
Wire.write(newLSB); // Data byte 1 (at 0x41)
uint8_t writeResult = Wire.endTransmission();
Serial.printf("BQ27220: Write block result = %d\n", writeResult);
// 4e. Write updated checksum and length
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60);
Wire.write(newChksum);
Wire.write(dataLen);
writeResult = Wire.endTransmission();
Serial.printf("BQ27220: Write checksum result = %d\n", writeResult);
delay(10);
// 4f. Verify the write took effect before exiting config mode
// Re-read the block to confirm
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E);
Wire.write(0x9F);
Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t verMSB = bq27220_read8(0x40);
uint8_t verLSB = bq27220_read8(0x41);
Serial.printf("BQ27220: Verify in CFGUPDATE: DC bytes=0x%02X 0x%02X (%d mAh)\n",
verMSB, verLSB, (verMSB << 8) | verLSB);
// Step 5: Exit CFG_UPDATE (with reinit to apply changes immediately)
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
delay(200); // Allow gauge to reinitialize
// Verify
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
Serial.printf("BQ27220: Design Capacity now reads %d mAh (expected %d)\n",
verifyDC, designCapacity_mAh);
if (verifyDC == designCapacity_mAh) {
Serial.println("BQ27220: Configuration SUCCESS");
} else {
Serial.println("BQ27220: Configuration FAILED");
}
// Step 7: Seal the device
bq27220_writeControl(0x0030);
delay(5);
return verifyDC == designCapacity_mAh;
#else
return false;
#endif
}
int16_t TDeckBoard::getAvgCurrent() {
#if HAS_BQ27220
return (int16_t)bq27220_read16(BQ27220_REG_AVG_CURRENT);
#else
return 0;
#endif
}
int16_t TDeckBoard::getAvgPower() {
#if HAS_BQ27220
return (int16_t)bq27220_read16(BQ27220_REG_AVG_POWER);
#else
return 0;
#endif
}
uint16_t TDeckBoard::getTimeToEmpty() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_TIME_TO_EMPTY);
#else
return 0xFFFF;
#endif
}
uint16_t TDeckBoard::getRemainingCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_REMAIN_CAP);
#else
return 0;
#endif
}
uint16_t TDeckBoard::getFullChargeCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_FULL_CAP);
#else
return 0;
#endif
}
uint16_t TDeckBoard::getDesignCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_DESIGN_CAP);
#else
return 0;
#endif
}

View File

@@ -7,11 +7,23 @@
#include <driver/rtc_io.h>
// BQ27220 Fuel Gauge Registers
#define BQ27220_REG_VOLTAGE 0x08
#define BQ27220_REG_CURRENT 0x0C
#define BQ27220_REG_SOC 0x2C
#define BQ27220_REG_VOLTAGE 0x08
#define BQ27220_REG_CURRENT 0x0C // Instantaneous current (mA, signed)
#define BQ27220_REG_SOC 0x2C
#define BQ27220_REG_REMAIN_CAP 0x10 // Remaining capacity (mAh)
#define BQ27220_REG_FULL_CAP 0x12 // Full charge capacity (mAh)
#define BQ27220_REG_AVG_CURRENT 0x14 // Average current (mA, signed)
#define BQ27220_REG_TIME_TO_EMPTY 0x16 // Minutes until empty
#define BQ27220_REG_AVG_POWER 0x24 // Average power (mW, signed)
#define BQ27220_REG_DESIGN_CAP 0x3C // Design capacity (mAh, read-only standard cmd)
#define BQ27220_REG_OP_STATUS 0x3A // Operation status
#define BQ27220_I2C_ADDR 0x55
// T-Deck Pro battery capacity (all variants use 1400 mAh cell)
#ifndef BQ27220_DESIGN_CAPACITY_MAH
#define BQ27220_DESIGN_CAPACITY_MAH 1400
#endif
class TDeckBoard : public ESP32Board {
public:
void begin();
@@ -52,6 +64,27 @@ public:
// Read state of charge percentage from BQ27220
uint8_t getBatteryPercent();
// Read average current in mA (negative = discharging, positive = charging)
int16_t getAvgCurrent();
// Read average power in mW (negative = discharging, positive = charging)
int16_t getAvgPower();
// Read time-to-empty in minutes (0xFFFF if charging/unavailable)
uint16_t getTimeToEmpty();
// Read remaining capacity in mAh
uint16_t getRemainingCapacity();
// Read full charge capacity in mAh (learned value, may need cycling to update)
uint16_t getFullChargeCapacity();
// Read design capacity in mAh (the configured battery size)
uint16_t getDesignCapacity();
// Configure BQ27220 design capacity (checks on boot, writes only if wrong)
bool configureFuelGauge(uint16_t designCapacity_mAh = BQ27220_DESIGN_CAPACITY_MAH);
const char* getManufacturerName() const {
return "LilyGo T-Deck Pro";
}

View File

@@ -80,6 +80,8 @@ 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
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_TDeck_Pro>
+<helpers/sensors/*.cpp>
@@ -90,25 +92,12 @@ lib_deps =
adafruit/Adafruit GFX Library@^1.11.0
bakercp/CRC32@^2.0.0
[env:LilyGo_TDeck_Pro_companion_radio_usb]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=256
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
; ---------------------------------------------------------------------------
; Meck unified builds — one codebase, three variants via build flags
; ---------------------------------------------------------------------------
[env:LilyGo_TDeck_Pro_companion_radio_ble]
; 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}
@@ -117,6 +106,7 @@ build_flags =
-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>
@@ -126,21 +116,49 @@ 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
[env:LilyGo_TDeck_Pro_repeater]
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
[env:meck_audio_standalone]
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
-I examples/companion_radio/ui-new
-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}
+<../examples/simple_repeater>
+<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}
me-no-dev/AsyncTCP @ ^1.1.1
me-no-dev/ESPAsyncWebServer @ ^1.2.3
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]
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 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>
+<../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