53 Commits

Author SHA1 Message Date
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
pelgraine
2dd5c4f59f Slight nav bar ui change in notes mode and improved rename function responsiveness 2026-02-12 21:04:05 +11:00
pelgraine
ee2a27258b improved cursor tracking and rename file handling implented with r key in Notes file list view 2026-02-12 20:45:41 +11:00
pelgraine
5b868d51ca improved responsiveness and cursor tracking in notes function. updated firmware version in mymesh. 2026-02-12 20:32:21 +11:00
pelgraine
220006c229 "Increased buffer size. Shift+WASD → cursor nav in editing mode.
Shift+Backspace → save (editing), delete (reading/file list).
Shift+Enter → rename from file list.
RTC time passed via setTimestamp() when opening notes."
2026-02-12 20:18:57 +11:00
pelgraine
a60f4146d5 Create, edit, save and delete txt notes from the N menu from any home view screen 2026-02-12 19:41:59 +11:00
pelgraine
017b170e81 Moved epubprocessor files to better tree location and fixed txt file accented character rendering 2026-02-11 20:07:38 +11:00
pelgraine
9b0c13fd4c fixed redundant uppercase key handling and updated firmware date in mymesh 2026-02-11 11:42:59 +11:00
pelgraine
5e3a252748 fixed backupSettingsToSD bug 2026-02-11 11:36:24 +11:00
pelgraine
6c3fb569f4 Fixed accidental regressions caused by commit f0dc218 and minor bug fixes 2026-02-11 11:34:12 +11:00
pelgraine
fa747bfce2 increased gps duty cycle timing to 3 minutes awake 2026-02-10 22:40:05 +11:00
pelgraine
f0dc218a57 GPS duty cycle and cpu power management for extended battery life implemented 2026-02-10 22:26:59 +11:00
29 changed files with 6115 additions and 273 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)
│ └── ...
└── ...
```

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 "10 Feb 2026"
#define FIRMWARE_BUILD_DATE "20 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.8.3"
#define FIRMWARE_VERSION "Meck v0.9.1A"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -72,11 +72,6 @@
/* -------------------------------------------------------------------------------------- */
// SD-backed settings persistence (defined in main.cpp for T-Deck Pro)
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
extern void backupSettingsToSD();
#endif
#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS
#define REQ_TYPE_KEEP_ALIVE 0x02
#define REQ_TYPE_GET_TELEMETRY_DATA 0x03
@@ -174,23 +169,12 @@ protected:
}
public:
void savePrefs() {
_store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon);
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
backupSettingsToSD();
#endif
}
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
void saveChannels() {
_store->saveChannels(this);
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
backupSettingsToSD();
#endif
}
void saveContacts() {
_store->saveContacts(this);
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
backupSettingsToSD();
#endif
}
private:

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

@@ -3,15 +3,19 @@
#include "MyMesh.h"
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
#include "target.h" // For sensors, board, etc.
#include "GPSDutyCycle.h"
#include "CPUPowerManager.h"
// T-Deck Pro Keyboard support
#if defined(LilyGo_TDeck_Pro)
#include "TCA8418Keyboard.h"
#include <SD.h>
#include "TextReaderScreen.h"
#include "NotesScreen.h"
#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);
@@ -23,7 +27,7 @@
static uint8_t composeChannelIdx = 0;
static unsigned long lastComposeRefresh = 0;
static bool composeNeedsRefresh = false;
#define COMPOSE_REFRESH_INTERVAL 600 // ms between e-ink refreshes while typing (refresh takes ~650ms)
#define COMPOSE_REFRESH_INTERVAL 100 // ms before starting e-ink refresh after keypress (refresh itself takes ~644ms)
// DM compose mode (direct message to a specific contact)
static bool composeDM = false;
@@ -40,6 +44,24 @@
// Text reader mode state
static bool readerMode = false;
// 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).
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#include "Audio.h"
Audio* audio = nullptr;
static bool audiobookMode = false;
#endif
// Power management
#if HAS_GPS
GPSDutyCycle gpsDuty;
#endif
CPUPowerManager cpuPower;
void initKeyboard();
void handleKeyboardInput();
@@ -308,6 +330,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");
@@ -392,7 +417,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.
// ---------------------------------------------------------------------------
@@ -448,12 +473,6 @@ void setup() {
the_mesh.startInterface(serial_interface);
MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done");
// T-Deck Pro: default BLE to OFF on boot (user can toggle with Bluetooth page)
#if defined(LilyGo_TDeck_Pro)
serial_interface.disable();
MESH_DEBUG_PRINTLN("setup() - BLE disabled by default (toggle via home screen)");
#endif
#else
#error "need to define filesystem"
#endif
@@ -501,10 +520,22 @@ void setup() {
if (reader) {
reader->setSDReady(true);
if (disp) {
cpuPower.setBoost(); // Boost CPU for EPUB processing
reader->bootIndex(*disp);
}
}
// Tell notes screen that SD is ready
NotesScreen* notesScr = (NotesScreen*)ui_task.getNotesScreen();
if (notesScr) {
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();
}
@@ -527,40 +558,101 @@ void setup() {
}
#endif
// Enable GPS by default on T-Deck Pro
// GPS duty cycle — honour saved pref, default to enabled on first boot
#if HAS_GPS
// Set GPS enabled in both sensor manager and node prefs
sensors.setSettingValue("gps", "1");
the_mesh.getNodePrefs()->gps_enabled = 1;
the_mesh.savePrefs(); // SD backup triggered automatically
MESH_DEBUG_PRINTLN("setup() - GPS enabled by default");
{
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
gpsDuty.setStreamCounter(&gpsStream);
gpsDuty.begin(gps_wanted);
if (gps_wanted) {
sensors.setSettingValue("gps", "1");
} else {
sensors.setSettingValue("gps", "0");
}
MESH_DEBUG_PRINTLN("setup() - GPS duty cycle started (enabled=%d)", gps_wanted);
}
#endif
// 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)
serial_interface.disable();
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
#if HAS_GPS
{
bool gps_hw_on = gpsDuty.loop();
if (gps_hw_on) {
LocationProvider* lp = sensors.getLocationProvider();
if (lp != NULL && lp->isValid()) {
gpsDuty.notifyFix();
}
}
}
#endif
sensors.loop();
// CPU frequency auto-timeout back to idle
cpuPower.loop();
// Audiobook: service audio decode regardless of which screen is active
#ifdef MECK_AUDIO_VARIANT
{
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
#ifdef DISPLAY_CLASS
// Skip UITask rendering when in compose mode to prevent flickering
#if defined(LilyGo_TDeck_Pro)
if (!composeMode) {
// Also suppress during notes editing (same debounce pattern as compose)
bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing();
bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming();
bool notesSuppressLoop = notesEditing || notesRenaming;
if (!composeMode && !notesSuppressLoop) {
ui_task.loop();
} else {
// Handle debounced compose/emoji picker screen refresh
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
if (emojiPickerMode) {
drawEmojiPicker();
} else {
drawComposeScreen();
if (composeMode) {
if (emojiPickerMode) {
drawEmojiPicker();
} else {
drawComposeScreen();
}
} else if (notesSuppressLoop) {
// Notes editor/rename renders through UITask - force a refresh cycle
ui_task.forceRefresh();
ui_task.loop();
}
lastComposeRefresh = millis();
composeNeedsRefresh = false;
}
}
// Track reader mode state for key routing
// Track reader/notes/audiobook mode state for key routing
readerMode = ui_task.isOnTextReader();
notesMode = ui_task.isOnNotesScreen();
#ifdef MECK_AUDIO_VARIANT
audiobookMode = ui_task.isOnAudiobookPlayer();
#endif
#else
ui_task.loop();
#endif
@@ -697,7 +789,7 @@ void handleKeyboardInput() {
}
// A/D keys switch channels (only when buffer is empty, not in DM mode)
if ((key == 'a' || key == 'A') && composePos == 0 && !composeDM) {
if ((key == 'a') && composePos == 0 && !composeDM) {
// Previous channel
if (composeChannelIdx > 0) {
composeChannelIdx--;
@@ -716,7 +808,7 @@ void handleKeyboardInput() {
return;
}
if ((key == 'd' || key == 'D') && composePos == 0 && !composeDM) {
if ((key == 'd') && composePos == 0 && !composeDM) {
// Next channel
ChannelDetails ch;
uint8_t nextIdx = composeChannelIdx + 1;
@@ -750,13 +842,49 @@ void handleKeyboardInput() {
return;
}
// *** AUDIOBOOK MODE ***
#ifdef MECK_AUDIO_VARIANT
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 // MECK_AUDIO_VARIANT
// *** TEXT READER MODE ***
if (readerMode) {
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
// Q key: if reading, reader handles it (close book -> file list)
// if on file list, exit reader entirely
if (key == 'q' || key == 'Q') {
if (key == 'q') {
if (reader->isReading()) {
// Let the reader handle Q (close book, go to file list)
ui_task.injectKey('q');
@@ -774,12 +902,147 @@ void handleKeyboardInput() {
return;
}
// *** NOTES MODE ***
if (notesMode) {
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
// ---- EDITING MODE ----
if (notes->isEditing()) {
// Shift+Backspace = save and exit
if (key == '\b') {
if (keyboard.wasShiftConsumed()) {
Serial.println("Notes: Shift+Backspace, saving...");
notes->saveAndExit();
ui_task.forceRefresh();
return;
}
// Regular backspace - delete before cursor
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
// Cursor navigation via Shift+WASD (produces uppercase)
if (key == 'W') { notes->moveCursorUp(); composeNeedsRefresh = true; return; }
if (key == 'A') { notes->moveCursorLeft(); composeNeedsRefresh = true; return; }
if (key == 'S') { notes->moveCursorDown(); composeNeedsRefresh = true; return; }
if (key == 'D') { notes->moveCursorRight(); composeNeedsRefresh = true; return; }
// Q when buffer is empty or unchanged = exit (nothing to lose)
if (key == 'q' && (notes->isEmpty() || !notes->isDirty())) {
Serial.println("Notes: Q exit (nothing to save)");
notes->discardAndExit();
ui_task.forceRefresh();
return;
}
// Enter = newline (pass through with debounce)
if (key == '\r') {
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
// All other printable chars (lowercase only - uppercase consumed by cursor nav)
if (key >= 32 && key < 127) {
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
return;
}
// ---- RENAMING MODE ----
if (notes->isRenaming()) {
// All input goes to rename handler (debounced like editing)
ui_task.injectKey(key);
composeNeedsRefresh = true;
if (!notes->isRenaming()) {
// Exited rename mode (confirmed or cancelled)
ui_task.forceRefresh();
}
return;
}
// ---- DELETE CONFIRMATION MODE ----
if (notes->isConfirmingDelete()) {
ui_task.injectKey(key);
if (!notes->isConfirmingDelete()) {
// Exited confirm mode
ui_task.forceRefresh();
}
return;
}
// ---- FILE LIST MODE ----
if (notes->isInFileList()) {
if (key == 'q') {
notes->exitNotes();
Serial.println("Exiting notes");
ui_task.gotoHomeScreen();
return;
}
// Shift+Backspace on a file = delete with confirmation
if (key == '\b' && keyboard.wasShiftConsumed()) {
if (notes->startDeleteFromList()) {
ui_task.forceRefresh();
}
return;
}
// R on a file = rename
if (key == 'r') {
if (notes->startRename()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis() - COMPOSE_REFRESH_INTERVAL; // Trigger on next loop iteration
}
return;
}
// Normal keys pass through
ui_task.injectKey(key);
// Check if we just entered editing mode (new note via Enter)
if (notes->isEditing()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
}
return;
}
// ---- READING MODE ----
if (notes->isReading()) {
if (key == 'q') {
ui_task.injectKey('q');
return;
}
// Shift+Backspace = delete note
if (key == '\b' && keyboard.wasShiftConsumed()) {
Serial.println("Notes: Deleting current note");
notes->deleteCurrentNote();
ui_task.forceRefresh();
return;
}
// All other keys (Enter for edit, W/S for page nav)
ui_task.injectKey(key);
if (notes->isEditing()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
}
return;
}
return;
}
// *** SETTINGS MODE ***
if (ui_task.isOnSettingsScreen()) {
SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen();
// Q key: exit settings (when not editing)
if (!settings->isEditing() && (key == 'q' || key == 'Q')) {
if (!settings->isEditing() && (key == 'q')) {
if (settings->hasRadioChanges()) {
// Let settings show "apply changes?" confirm dialog
ui_task.injectKey(key);
@@ -790,58 +1053,136 @@ void handleKeyboardInput() {
return;
}
// All other keys settings screen via injectKey (no forceRefresh)
// 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;
}
// Normal mode - not composing
switch (key) {
case 'c':
case 'C':
// Open contacts list
Serial.println("Opening contacts");
ui_task.gotoContactsScreen();
break;
case 'm':
case 'M':
// Go to channel message screen
Serial.println("Opening channel messages");
ui_task.gotoChannelScreen();
break;
case 'e':
case 'E':
// Open text reader (ebooks)
Serial.println("Opening text reader");
ui_task.gotoTextReader();
break;
case 'p':
#ifdef MECK_AUDIO_VARIANT
// 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();
#else
Serial.println("Audio not available on this build variant");
ui_task.showAlert("No audio hardware", 1500);
#endif
break;
case 'n':
// Open notes
Serial.println("Opening notes");
{
NotesScreen* notesScr2 = (NotesScreen*)ui_task.getNotesScreen();
if (notesScr2) {
uint32_t ts = rtc_clock.getCurrentTime();
int8_t utcOff = the_mesh.getNodePrefs()->utc_offset_hours;
notesScr2->setTimestamp(ts, utcOff);
}
}
ui_task.gotoNotesScreen();
break;
case 's':
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");
ui_task.gotoSettingsScreen();
}
break;
case 'w':
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");
ui_task.injectKey(0xF2); // KEY_PREV
}
break;
case 'a':
case 'A':
// Navigate left or switch channel (on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
ui_task.injectKey('a'); // Pass directly for channel/contacts switching
@@ -852,7 +1193,6 @@ void handleKeyboardInput() {
break;
case 'd':
case 'D':
// Navigate right or switch channel (on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
ui_task.injectKey('d'); // Pass directly for channel/contacts switching
@@ -863,7 +1203,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();
@@ -878,10 +1219,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();
@@ -898,9 +1251,16 @@ void handleKeyboardInput() {
break;
case 'q':
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;
@@ -910,6 +1270,18 @@ void handleKeyboardInput() {
Serial.println("Nav: Space (Next)");
ui_task.injectKey(0xF1); // KEY_NEXT
break;
case 'u':
// 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);
@@ -1058,6 +1430,8 @@ void drawEmojiPicker() {
void sendComposedMessage() {
if (composePos == 0) return;
cpuPower.setBoost(); // Boost CPU for crypto + radio TX
// Convert escape bytes back to UTF-8 for mesh transmission and BLE app
char utf8Buf[512];
@@ -1103,4 +1477,23 @@ void sendComposedMessage() {
}
}
// ============================================================================
// ESP32-audioI2S CALLBACKS
// ============================================================================
#ifdef MECK_AUDIO_VARIANT
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 // MECK_AUDIO_VARIANT
#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

@@ -2,8 +2,8 @@
// =============================================================================
// EpubProcessor.h - Convert EPUB files to plain text for TextReaderScreen
//
// Pipeline: EPUB (ZIP) container.xml OPF spine extract chapters
// strip XHTML tags concatenated plain text cached .txt on SD
// Pipeline: EPUB (ZIP) → container.xml → OPF spine → extract chapters →
// strip XHTML tags → concatenated plain text → cached .txt on SD
//
// The resulting .txt file is placed in /books/ and picked up automatically
// by TextReaderScreen's existing pagination, indexing, and bookmarking.
@@ -14,6 +14,7 @@
#include <SD.h>
#include <FS.h>
#include "EpubZipReader.h"
#include "Utf8CP437.h"
// Maximum chapters in spine (most novels have 20-80)
#define EPUB_MAX_CHAPTERS 200
@@ -426,7 +427,7 @@ private:
//
// Handles:
// - Tag removal (everything between < and >)
// - <p>, <br>, <div>, <h1>-<h6> newlines
// - <p>, <br>, <div>, <h1>-<h6> → newlines
// - HTML entity decoding (&amp; &lt; &gt; &quot; &apos; &#NNN; &#xHH;)
// - Collapse multiple whitespace/newlines
// - Skip <head>, <style>, <script> content entirely
@@ -547,9 +548,9 @@ private:
}
}
// Handle UTF-8 multi-byte sequences (smart quotes, em dashes, etc.)
// These appear as raw bytes in XHTML and must be mapped to ASCII
// since the e-ink font only supports ASCII characters.
// Handle UTF-8 multi-byte sequences (smart quotes, em dashes, accented chars, etc.)
// These appear as raw bytes in XHTML. Typographic chars are mapped to ASCII;
// accented Latin chars are preserved as UTF-8 for CP437 rendering on e-ink.
if ((uint8_t)c >= 0xC0) {
uint32_t codepoint = 0;
int extraBytes = 0;
@@ -579,7 +580,8 @@ private:
if (valid && extraBytes > 0) {
p += extraBytes; // Skip continuation bytes (loop increments past lead byte)
// Map Unicode codepoints to ASCII equivalents
// Map Unicode codepoints to displayable equivalents
// Typographic chars → ASCII, accented chars → preserved as UTF-8
char mapped = 0;
switch (codepoint) {
case 0x2018: case 0x2019: mapped = '\''; break; // Smart single quotes
@@ -598,6 +600,21 @@ private:
default:
if (codepoint >= 0x20 && codepoint < 0x7F) {
mapped = (char)codepoint; // Basic ASCII range
} else if (unicodeToCP437(codepoint)) {
// Accented character that the e-ink font can render via CP437.
// Preserve as UTF-8 in the output; the text reader will decode
// and map to CP437 at render time.
if (codepoint <= 0x7FF) {
output[outPos++] = 0xC0 | (codepoint >> 6);
output[outPos++] = 0x80 | (codepoint & 0x3F);
} else if (codepoint <= 0xFFFF) {
output[outPos++] = 0xE0 | (codepoint >> 12);
output[outPos++] = 0x80 | ((codepoint >> 6) & 0x3F);
output[outPos++] = 0x80 | (codepoint & 0x3F);
}
lastWasNewline = false;
lastWasSpace = false;
continue; // Already wrote to output
} else {
continue; // Skip unmappable characters
}
@@ -608,7 +625,7 @@ private:
continue; // Skip malformed UTF-8
}
} else if ((uint8_t)c >= 0x80) {
// Stray continuation byte (0x80-0xBF) skip
// Stray continuation byte (0x80-0xBF) — skip
continue;
}
@@ -683,6 +700,37 @@ private:
if (entityLen == 5 && strncmp(entity, "ldquo", 5) == 0) return '"';
if (entityLen == 5 && strncmp(entity, "rdquo", 5) == 0) return '"';
// Common accented character entities → CP437 bytes for built-in font
if (entityLen == 6 && strncmp(entity, "eacute", 6) == 0) return (char)0x82; // é
if (entityLen == 6 && strncmp(entity, "egrave", 6) == 0) return (char)0x8A; // è
if (entityLen == 5 && strncmp(entity, "ecirc", 5) == 0) return (char)0x88; // ê
if (entityLen == 4 && strncmp(entity, "euml", 4) == 0) return (char)0x89; // ë
if (entityLen == 6 && strncmp(entity, "agrave", 6) == 0) return (char)0x85; // à
if (entityLen == 6 && strncmp(entity, "aacute", 6) == 0) return (char)0xA0; // á
if (entityLen == 5 && strncmp(entity, "acirc", 5) == 0) return (char)0x83; // â
if (entityLen == 4 && strncmp(entity, "auml", 4) == 0) return (char)0x84; // ä
if (entityLen == 6 && strncmp(entity, "ccedil", 6) == 0) return (char)0x87; // ç
if (entityLen == 6 && strncmp(entity, "iacute", 6) == 0) return (char)0xA1; // í
if (entityLen == 5 && strncmp(entity, "icirc", 5) == 0) return (char)0x8C; // î
if (entityLen == 4 && strncmp(entity, "iuml", 4) == 0) return (char)0x8B; // ï
if (entityLen == 6 && strncmp(entity, "igrave", 6) == 0) return (char)0x8D; // ì
if (entityLen == 6 && strncmp(entity, "oacute", 6) == 0) return (char)0xA2; // ó
if (entityLen == 5 && strncmp(entity, "ocirc", 5) == 0) return (char)0x93; // ô
if (entityLen == 4 && strncmp(entity, "ouml", 4) == 0) return (char)0x94; // ö
if (entityLen == 6 && strncmp(entity, "ograve", 6) == 0) return (char)0x95; // ò
if (entityLen == 6 && strncmp(entity, "uacute", 6) == 0) return (char)0xA3; // ú
if (entityLen == 5 && strncmp(entity, "ucirc", 5) == 0) return (char)0x96; // û
if (entityLen == 4 && strncmp(entity, "uuml", 4) == 0) return (char)0x81; // ü
if (entityLen == 6 && strncmp(entity, "ugrave", 6) == 0) return (char)0x97; // ù
if (entityLen == 6 && strncmp(entity, "ntilde", 6) == 0) return (char)0xA4; // ñ
if (entityLen == 6 && strncmp(entity, "Eacute", 6) == 0) return (char)0x90; // É
if (entityLen == 6 && strncmp(entity, "Ccedil", 6) == 0) return (char)0x80; // Ç
if (entityLen == 6 && strncmp(entity, "Ntilde", 6) == 0) return (char)0xA5; // Ñ
if (entityLen == 4 && strncmp(entity, "Auml", 4) == 0) return (char)0x8E; // Ä
if (entityLen == 4 && strncmp(entity, "Ouml", 4) == 0) return (char)0x99; // Ö
if (entityLen == 4 && strncmp(entity, "Uuml", 4) == 0) return (char)0x9A; // Ü
if (entityLen == 5 && strncmp(entity, "szlig", 5) == 0) return (char)0xE1; // ß
// Numeric entities: &#NNN; or &#xHH;
if (entityLen >= 2 && entity[0] == '#') {
int codepoint = 0;
@@ -701,14 +749,13 @@ private:
if (ch >= '0' && ch <= '9') codepoint = codepoint * 10 + (ch - '0');
}
}
// Map to ASCII (best effort - e-ink font is ASCII only)
// Map to displayable character (best effort)
if (codepoint >= 32 && codepoint < 127) return (char)codepoint;
if (codepoint == 160) return ' '; // non-breaking space
if (codepoint == 8211 || codepoint == 8212) return '-'; // en/em dash
if (codepoint == 8216 || codepoint == 8217) return '\''; // smart quotes
if (codepoint == 8220 || codepoint == 8221) return '"'; // smart quotes
if (codepoint == 8230) return '.'; // ellipsis
if (codepoint == 8226) return '*'; // bullet
// Try CP437 mapping for accented characters.
// The byte value will be passed through to the built-in font.
uint8_t cp437 = unicodeToCP437(codepoint);
if (cp437) return (char)cp437;
// Unknown codepoint > 127: skip it
return ' ';
}

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

File diff suppressed because it is too large Load Diff

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

@@ -24,10 +24,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 +55,7 @@ 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
ROW_CH_HEADER, // "--- Channels ---" separator
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
@@ -72,7 +85,7 @@ private:
mesh::RTCClock* _rtc;
NodePrefs* _prefs;
// Row table rebuilt whenever channels change
// Row table — rebuilt whenever channels change
struct Row {
SettingsRowType type;
uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET
@@ -96,7 +109,7 @@ private:
// Onboarding mode
bool _onboarding;
// Dirty flag for radio params prompt to apply
// Dirty flag for radio params — prompt to apply
bool _radioChanged;
// ---------------------------------------------------------------------------
@@ -114,6 +127,7 @@ private:
addRow(ROW_CR);
addRow(ROW_TX_POWER);
addRow(ROW_UTC_OFFSET);
addRow(ROW_MSG_NOTIFY);
addRow(ROW_CH_HEADER);
// Enumerate current channels
@@ -198,11 +212,11 @@ private:
strncpy(newCh.name, chanName, sizeof(newCh.name));
newCh.name[31] = '\0';
// SHA-256 the channel name first 16 bytes become the secret
// SHA-256 the channel name → first 16 bytes become the secret
uint8_t hash[32];
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
memcpy(newCh.channel.secret, hash, 16);
// Upper 16 bytes left as zero setChannel uses 128-bit mode
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
// Find next empty slot
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
@@ -400,8 +414,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 +467,12 @@ 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;
case ROW_CH_HEADER:
display.setColor(DisplayDriver::YELLOW);
display.print("--- Channels ---");
@@ -611,6 +631,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 +713,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 +731,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 +748,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 +810,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 +832,12 @@ 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;
case ROW_ADD_CHANNEL:
startEditText("");
break;
@@ -828,7 +861,7 @@ public:
}
}
// Q: back if radio changed, prompt to apply first
// Q: back — if radio changed, prompt to apply first
if (c == 'q' || c == 'Q') {
if (_radioChanged) {
_editMode = EDIT_CONFIRM;

View File

@@ -4,6 +4,7 @@
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#include "EpubProcessor.h"
// Forward declarations
@@ -14,7 +15,7 @@ class UITask;
// ============================================================================
#define BOOKS_FOLDER "/books"
#define INDEX_FOLDER "/.indexes"
#define INDEX_VERSION 4
#define INDEX_VERSION 5 // v5: UTF-8 aware word wrap (accented char support)
#define PREINDEX_PAGES 100
#define READER_MAX_FILES 50
#define READER_BUF_SIZE 4096
@@ -57,6 +58,10 @@ inline WrapResult findLineBreak(const char* buffer, int bufLen, int lineStart, i
}
if (c >= 32) {
// Skip UTF-8 continuation bytes (0x80-0xBF) - the lead byte already
// counted as one display character, so don't double-count these.
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) continue;
charCount++;
if (c == ' ' || c == '\t') {
if (inWord) {
@@ -177,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;
@@ -386,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
@@ -477,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 ----
@@ -513,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) {
@@ -603,7 +671,7 @@ private:
_currentPage = cache->lastReadPage;
}
// Already fully indexed open immediately
// Already fully indexed — open immediately
if (cache->fullyIndexed) {
_totalPages = _pagePositions.size();
_mode = READING;
@@ -613,7 +681,7 @@ private:
return;
}
// Partially indexed finish indexing with splash
// Partially indexed — finish indexing with splash
Serial.printf("TextReader: Finishing index for %s (have %d pages so far)\n",
actualFilename.c_str(), (int)_pagePositions.size());
@@ -629,7 +697,7 @@ private:
drawSplash("Indexing...", "Please wait", shortName);
if (_pagePositions.empty()) {
// Cache had no pages (e.g. dummy entry) full index from scratch
// Cache had no pages (e.g. dummy entry) — full index from scratch
_pagePositions.push_back(0);
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
@@ -639,7 +707,7 @@ private:
_linesPerPage, _charsPerLine, 0);
}
} else {
// No cache full index from scratch
// No cache — full index from scratch
Serial.printf("TextReader: Full index for %s\n", actualFilename.c_str());
char shortName[28];
@@ -750,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");
@@ -775,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++) {
@@ -795,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
@@ -855,12 +948,40 @@ private:
if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break;
display.setCursor(0, y);
// Print line character by character (only printable)
// Print line with UTF-8 decoding: multi-byte sequences are decoded
// to Unicode codepoints, then mapped to CP437 for the built-in font.
char charStr[2] = {0, 0};
for (int j = pos; j < wrap.lineEnd && j < _pageBufLen; j++) {
if (_pageBuf[j] >= 32) {
charStr[0] = _pageBuf[j];
int j = pos;
while (j < wrap.lineEnd && j < _pageBufLen) {
uint8_t b = (uint8_t)_pageBuf[j];
if (b < 32) {
// Control character — skip
j++;
continue;
}
if (b < 0x80) {
// Plain ASCII — print directly
charStr[0] = (char)b;
display.print(charStr);
j++;
} else if (b >= 0xC0) {
// UTF-8 lead byte — decode full sequence and map to CP437
int savedJ = j;
uint32_t cp = decodeUtf8Char(_pageBuf, wrap.lineEnd, &j);
uint8_t glyph = unicodeToCP437(cp);
if (glyph) {
charStr[0] = (char)glyph;
display.print(charStr);
}
// If unmappable (glyph==0), just skip the character
} else {
// Standalone byte 0x80-0xBF: not a valid UTF-8 lead byte.
// Treat as CP437 pass-through (e.g. from EPUB numeric entity decoding).
charStr[0] = (char)b;
display.print(charStr);
j++;
}
}
@@ -895,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) {
}
@@ -997,7 +1119,7 @@ public:
// --- Pass 1: Fast cache load (no per-file splash screens) ---
// Try to load existing .idx files from SD for every file.
// This is just SD reads no indexing, no e-ink refreshes.
// This is just SD reads — no indexing, no e-ink refreshes.
_fileCache.clear();
_fileCache.resize(_fileList.size()); // Pre-allocate slots to maintain alignment with _fileList
@@ -1026,7 +1148,7 @@ public:
// Skip files that loaded from cache
if (_fileCache[i].filename.length() > 0) continue;
// Skip .epub files they'll be converted on first open via openBook()
// Skip .epub files — they'll be converted on first open via openBook()
if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) {
needsIndexCount--; // Don't count epubs in progress display
continue;
@@ -1035,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];
@@ -1133,6 +1255,8 @@ public:
}
bool handleFileListInput(char c) {
int total = totalListItems();
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_selectedFile > 0) {
@@ -1144,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;
}
@@ -1163,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

@@ -1,7 +1,10 @@
#include "UITask.h"
#include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h"
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "target.h"
#include "GPSDutyCycle.h"
#ifdef WIFI_SSID
#include <WiFi.h>
#endif
@@ -34,11 +37,14 @@
#include "ContactsScreen.h"
#include "TextReaderScreen.h"
#include "SettingsScreen.h"
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.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) {
@@ -85,13 +91,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
@@ -108,7 +119,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) {
@@ -137,6 +148,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);
@@ -156,6 +169,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;
@@ -204,16 +235,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
{
@@ -250,28 +289,58 @@ 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 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);
@@ -316,6 +385,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,
@@ -323,27 +393,44 @@ 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);
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
#if ENV_INCLUDE_GPS == 1
} else if (_page == HomePage::GPS) {
extern GPSDutyCycle gpsDuty;
extern GPSStreamCounter gpsStream;
LocationProvider* nmea = sensors.getLocationProvider();
char buf[50];
int y = 18;
bool gps_state = _task->getGPSState();
#ifdef PIN_GPS_SWITCH
bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
if (gps_state != hw_gps_state) {
strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)");
// GPS state line with duty cycle info
if (!_node_prefs->gps_enabled) {
strcpy(buf, "gps off");
} else {
strcpy(buf, gps_state ? "gps on" : "gps off");
switch (gpsDuty.getState()) {
case GPSDutyState::ACQUIRING: {
uint32_t elapsed = gpsDuty.acquireElapsedSecs();
sprintf(buf, "acquiring %us", (unsigned)elapsed);
break;
}
case GPSDutyState::SLEEPING: {
uint32_t remain = gpsDuty.sleepRemainingSecs();
if (remain >= 60) {
sprintf(buf, "sleep %um%02us", (unsigned)(remain / 60), (unsigned)(remain % 60));
} else {
sprintf(buf, "sleep %us", (unsigned)remain);
}
break;
}
default:
strcpy(buf, "gps off");
}
}
#else
strcpy(buf, gps_state ? "gps on" : "gps off");
#endif
display.drawTextLeftAlign(0, y, buf);
if (nmea == NULL) {
y = y + 12;
display.drawTextLeftAlign(0, y, "Can't access GPS");
@@ -355,6 +442,19 @@ public:
sprintf(buf, "%d", nmea->satellitesCount());
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
// NMEA sentence counter — confirms baud rate and data flow
display.drawTextLeftAlign(0, y, "sentences");
if (gpsDuty.isHardwareOn()) {
uint16_t sps = gpsStream.getSentencesPerSec();
uint32_t total = gpsStream.getSentenceCount();
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
} else {
strcpy(buf, "hw off");
}
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
display.drawTextLeftAlign(0, y, "pos");
sprintf(buf, "%.4f %.4f",
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
@@ -473,6 +573,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);
@@ -533,6 +685,7 @@ public:
}
return true;
}
#ifdef BLE_PIN_CODE
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
_task->disableSerial();
@@ -541,6 +694,7 @@ public:
}
return true;
}
#endif
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
@@ -708,6 +862,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;
@@ -717,7 +877,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
channel_screen = new ChannelScreen(this, &rtc_clock);
contacts_screen = new ContactsScreen(this, &rtc_clock);
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
setCurrScreen(splash);
}
@@ -759,12 +922,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)
@@ -782,15 +946,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);
@@ -805,6 +972,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() {
@@ -940,6 +1115,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
@@ -1037,39 +1220,36 @@ char UITask::handleTripleClick(char c) {
}
bool UITask::getGPSState() {
if (_sensors != NULL) {
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
return !strcmp(_sensors->getSettingValue(i), "1");
}
}
}
return false;
#if ENV_INCLUDE_GPS == 1
return _node_prefs != NULL && _node_prefs->gps_enabled;
#else
return false;
#endif
}
void UITask::toggleGPS() {
#if ENV_INCLUDE_GPS == 1
extern GPSDutyCycle gpsDuty;
if (_sensors != NULL) {
// toggle GPS on/off
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
notify(UIEventType::ack);
} else {
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
break;
if (_node_prefs->gps_enabled) {
// 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
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
gpsDuty.enable();
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
}
}
#endif
}
void UITask::toggleBuzzer() {
@@ -1157,8 +1337,21 @@ void UITask::gotoTextReader() {
_next_refresh = 100;
}
void UITask::gotoNotesScreen() {
NotesScreen* notes = (NotesScreen*)notes_screen;
if (_display != NULL) {
notes->enter(*_display);
}
setCurrScreen(notes_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoSettingsScreen() {
((SettingsScreen*)settings_screen)->enter();
((SettingsScreen *) settings_screen)->enter();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -1168,7 +1361,7 @@ void UITask::gotoSettingsScreen() {
}
void UITask::gotoOnboarding() {
((SettingsScreen*)settings_screen)->enterOnboarding();
((SettingsScreen *) settings_screen)->enterOnboarding();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -1177,6 +1370,22 @@ 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
}
uint8_t UITask::getChannelScreenViewIdx() const {
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
}
@@ -1188,4 +1397,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

@@ -32,6 +32,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;
@@ -54,7 +55,10 @@ class UITask : public AbstractUITask {
UIScreen* channel_screen; // Channel message history screen
UIScreen* contacts_screen; // Contacts list screen
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)
UIScreen* repeater_admin; // Repeater admin screen
UIScreen* curr;
void userLedHandler();
@@ -71,6 +75,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;
}
@@ -80,8 +85,11 @@ public:
void gotoChannelScreen(); // Navigate to channel message screen
void gotoContactsScreen(); // Navigate to contacts list
void gotoTextReader(); // *** NEW: Navigate to text reader ***
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
void showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
int getMsgCount() const { return _msgcount; }
@@ -90,7 +98,16 @@ public:
bool isOnChannelScreen() const { return curr == channel_screen; }
bool isOnContactsScreen() const { return curr == contacts_screen; }
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();
@@ -105,18 +122,27 @@ 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; }
UIScreen* getMsgPreviewScreen() const { return msg_preview; }
UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW ***
UIScreen* getNotesScreen() const { return notes_screen; }
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

@@ -0,0 +1,152 @@
#pragma once
// =============================================================================
// Utf8CP437.h - UTF-8 decoding and Unicode-to-CP437 mapping
//
// The Adafruit GFX built-in 6x8 font uses the CP437 character set for codes
// 128-255. This header provides utilities to:
// 1. Decode UTF-8 multi-byte sequences into Unicode codepoints
// 2. Map Unicode codepoints to CP437 byte values for display
//
// Used by both EpubProcessor (at XHTML→text conversion time) and
// TextReaderScreen (at render time for plain .txt files).
// =============================================================================
// Map a Unicode codepoint to its CP437 equivalent byte.
// Returns the CP437 byte (0x80-0xFF) for supported accented characters,
// the codepoint itself for ASCII (0x20-0x7E), or 0 if unmappable.
inline uint8_t unicodeToCP437(uint32_t cp) {
// ASCII passthrough
if (cp >= 0x20 && cp < 0x7F) return (uint8_t)cp;
switch (cp) {
// Uppercase accented
case 0x00C7: return 0x80; // Ç
case 0x00C9: return 0x90; // É
case 0x00C4: return 0x8E; // Ä
case 0x00C5: return 0x8F; // Å
case 0x00C6: return 0x92; // Æ
case 0x00D6: return 0x99; // Ö
case 0x00DC: return 0x9A; // Ü
case 0x00D1: return 0xA5; // Ñ
// Lowercase accented
case 0x00E9: return 0x82; // é
case 0x00E2: return 0x83; // â
case 0x00E4: return 0x84; // ä
case 0x00E0: return 0x85; // à
case 0x00E5: return 0x86; // å
case 0x00E7: return 0x87; // ç
case 0x00EA: return 0x88; // ê
case 0x00EB: return 0x89; // ë
case 0x00E8: return 0x8A; // è
case 0x00EF: return 0x8B; // ï
case 0x00EE: return 0x8C; // î
case 0x00EC: return 0x8D; // ì
case 0x00E6: return 0x91; // æ
case 0x00F4: return 0x93; // ô
case 0x00F6: return 0x94; // ö
case 0x00F2: return 0x95; // ò
case 0x00FB: return 0x96; // û
case 0x00F9: return 0x97; // ù
case 0x00FF: return 0x98; // ÿ
case 0x00FC: return 0x81; // ü
case 0x00E1: return 0xA0; // á
case 0x00ED: return 0xA1; // í
case 0x00F3: return 0xA2; // ó
case 0x00FA: return 0xA3; // ú
case 0x00F1: return 0xA4; // ñ
// Currency / symbols
case 0x00A2: return 0x9B; // ¢
case 0x00A3: return 0x9C; // £
case 0x00A5: return 0x9D; // ¥
case 0x00BF: return 0xA8; // ¿
case 0x00A1: return 0xAD; // ¡
case 0x00AB: return 0xAE; // «
case 0x00BB: return 0xAF; // »
case 0x00B0: return 0xF8; // °
case 0x00B1: return 0xF1; // ±
case 0x00B5: return 0xE6; // µ
case 0x00DF: return 0xE1; // ß
// Typographic (smart quotes, dashes, etc.)
case 0x2018: case 0x2019: return '\''; // Smart single quotes
case 0x201C: case 0x201D: return '"'; // Smart double quotes
case 0x2013: case 0x2014: return '-'; // En/em dash
case 0x2010: case 0x2011: case 0x2012: case 0x2015: return '-'; // Hyphens/bars
case 0x2026: return 0xFD; // Ellipsis (CP437 has no …, use ²? no, skip)
case 0x2022: return 0x07; // Bullet → CP437 bullet
case 0x00A0: return ' '; // Non-breaking space
case 0x2039: case 0x203A: return '\''; // Single guillemets
case 0x2032: return '\''; // Prime
case 0x2033: return '"'; // Double prime
default: return 0; // Unmappable
}
}
// Decode a single UTF-8 character from a byte buffer.
// Returns the Unicode codepoint and advances *pos past the full sequence.
// If the sequence is invalid, returns 0xFFFD (replacement char) and advances by 1.
//
// buf: input buffer
// bufLen: total buffer length
// pos: pointer to current position (updated on return)
inline uint32_t decodeUtf8Char(const char* buf, int bufLen, int* pos) {
int i = *pos;
if (i >= bufLen) return 0;
uint8_t c = (uint8_t)buf[i];
// ASCII (single byte)
if (c < 0x80) {
*pos = i + 1;
return c;
}
// Continuation byte without lead byte — skip
if (c < 0xC0) {
*pos = i + 1;
return 0xFFFD;
}
uint32_t codepoint;
int extraBytes;
if ((c & 0xE0) == 0xC0) {
codepoint = c & 0x1F;
extraBytes = 1;
} else if ((c & 0xF0) == 0xE0) {
codepoint = c & 0x0F;
extraBytes = 2;
} else if ((c & 0xF8) == 0xF0) {
codepoint = c & 0x07;
extraBytes = 3;
} else {
*pos = i + 1;
return 0xFFFD;
}
// Verify we have enough bytes and they're valid continuation bytes
if (i + extraBytes >= bufLen) {
*pos = i + 1;
return 0xFFFD;
}
for (int b = 1; b <= extraBytes; b++) {
uint8_t cb = (uint8_t)buf[i + b];
if ((cb & 0xC0) != 0x80) {
*pos = i + 1;
return 0xFFFD;
}
codepoint = (codepoint << 6) | (cb & 0x3F);
}
*pos = i + 1 + extraBytes;
return codepoint;
}
// Check if a byte is a UTF-8 continuation byte (10xxxxxx)
inline bool isUtf8Continuation(uint8_t c) {
return (c & 0xC0) == 0x80;
}

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

@@ -0,0 +1,70 @@
#pragma once
#include <Arduino.h>
// CPU Frequency Scaling for ESP32-S3
//
// Typical current draw (CPU only, rough):
// 240 MHz ~70-80 mA
// 160 MHz ~50-60 mA
// 80 MHz ~30-40 mA
//
// SPI peripherals and UART use their own clock dividers from the APB clock,
// so LoRa, e-ink, and GPS serial all work fine at 80MHz.
#ifdef ESP32
#ifndef CPU_FREQ_IDLE
#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening
#endif
#ifndef CPU_FREQ_BOOST
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
#endif
#ifndef CPU_BOOST_TIMEOUT_MS
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
#endif
class CPUPowerManager {
public:
CPUPowerManager() : _boosted(false), _boost_started(0) {}
void begin() {
setCpuFrequencyMhz(CPU_FREQ_IDLE);
_boosted = false;
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
}
void loop() {
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
setIdle();
}
}
void setBoost() {
if (!_boosted) {
setCpuFrequencyMhz(CPU_FREQ_BOOST);
_boosted = true;
MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST);
}
_boost_started = millis();
}
void setIdle() {
if (_boosted) {
setCpuFrequencyMhz(CPU_FREQ_IDLE);
_boosted = false;
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
}
}
bool isBoosted() const { return _boosted; }
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
private:
bool _boosted;
unsigned long _boost_started;
};
#endif // ESP32

View File

@@ -0,0 +1,185 @@
#pragma once
#include <Arduino.h>
#include "variant.h"
#include "GPSStreamCounter.h"
// GPS Duty Cycle Manager
// Controls the hardware GPS enable pin (PIN_GPS_EN) to save power.
// When enabled, cycles between acquiring a fix and sleeping with power cut.
//
// States:
// OFF User has disabled GPS. Hardware power is cut.
// ACQUIRING GPS module powered on, waiting for a fix or timeout.
// SLEEPING GPS module powered off, timer counting down to next cycle.
#if HAS_GPS
// How long to leave GPS powered on while acquiring a fix (ms)
#ifndef GPS_ACQUIRE_TIMEOUT_MS
#define GPS_ACQUIRE_TIMEOUT_MS 180000 // 3 minutes
#endif
// How long to sleep between acquisition cycles (ms)
#ifndef GPS_SLEEP_DURATION_MS
#define GPS_SLEEP_DURATION_MS 900000 // 15 minutes
#endif
// If we get a fix quickly, power off immediately but still respect
// a minimum on-time so the RTC can sync properly
#ifndef GPS_MIN_ON_TIME_MS
#define GPS_MIN_ON_TIME_MS 5000 // 5 seconds after fix
#endif
enum class GPSDutyState : uint8_t {
OFF = 0, // User-disabled, hardware power off
ACQUIRING, // Hardware on, waiting for fix
SLEEPING // Hardware off, timer running
};
class GPSDutyCycle {
public:
GPSDutyCycle() : _state(GPSDutyState::OFF), _state_entered(0),
_last_fix_time(0), _got_fix(false), _time_synced(false),
_stream(nullptr) {}
// Attach the stream counter so we can reset it on power cycles
void setStreamCounter(GPSStreamCounter* stream) { _stream = stream; }
// Call once in setup() after board.begin() and GPS serial init.
void begin(bool initial_enable) {
if (initial_enable) {
_powerOn();
_setState(GPSDutyState::ACQUIRING);
} else {
_powerOff();
_setState(GPSDutyState::OFF);
}
}
// Call every iteration of loop().
// Returns true if GPS hardware is currently powered on.
bool loop() {
switch (_state) {
case GPSDutyState::OFF:
return false;
case GPSDutyState::ACQUIRING: {
unsigned long elapsed = millis() - _state_entered;
if (_got_fix && elapsed >= GPS_MIN_ON_TIME_MS) {
MESH_DEBUG_PRINTLN("GPS duty: fix acquired, powering off for %u min",
(unsigned)(GPS_SLEEP_DURATION_MS / 60000));
_powerOff();
_setState(GPSDutyState::SLEEPING);
return false;
}
if (elapsed >= GPS_ACQUIRE_TIMEOUT_MS) {
MESH_DEBUG_PRINTLN("GPS duty: acquire timeout (%us), sleeping",
(unsigned)(GPS_ACQUIRE_TIMEOUT_MS / 1000));
_powerOff();
_setState(GPSDutyState::SLEEPING);
return false;
}
return true;
}
case GPSDutyState::SLEEPING: {
if (millis() - _state_entered >= GPS_SLEEP_DURATION_MS) {
MESH_DEBUG_PRINTLN("GPS duty: waking up for next acquisition cycle");
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
return true;
}
return false;
}
}
return false;
}
void notifyFix() {
if (_state == GPSDutyState::ACQUIRING && !_got_fix) {
_got_fix = true;
_last_fix_time = millis();
MESH_DEBUG_PRINTLN("GPS duty: fix notification received");
}
}
void notifyTimeSync() {
_time_synced = true;
}
void enable() {
if (_state == GPSDutyState::OFF) {
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
MESH_DEBUG_PRINTLN("GPS duty: enabled, starting acquisition");
}
}
void disable() {
_powerOff();
_setState(GPSDutyState::OFF);
_got_fix = false;
MESH_DEBUG_PRINTLN("GPS duty: disabled, power off");
}
void forceWake() {
if (_state == GPSDutyState::SLEEPING) {
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
MESH_DEBUG_PRINTLN("GPS duty: forced wake for user request");
}
}
GPSDutyState getState() const { return _state; }
bool isHardwareOn() const { return _state == GPSDutyState::ACQUIRING; }
bool hadFix() const { return _got_fix; }
bool hasTimeSynced() const { return _time_synced; }
uint32_t sleepRemainingSecs() const {
if (_state != GPSDutyState::SLEEPING) return 0;
unsigned long elapsed = millis() - _state_entered;
if (elapsed >= GPS_SLEEP_DURATION_MS) return 0;
return (GPS_SLEEP_DURATION_MS - elapsed) / 1000;
}
uint32_t acquireElapsedSecs() const {
if (_state != GPSDutyState::ACQUIRING) return 0;
return (millis() - _state_entered) / 1000;
}
private:
void _powerOn() {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
delay(10);
#endif
if (_stream) _stream->resetCounters();
}
void _powerOff() {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
}
void _setState(GPSDutyState s) {
_state = s;
_state_entered = millis();
}
GPSDutyState _state;
unsigned long _state_entered;
unsigned long _last_fix_time;
bool _got_fix;
bool _time_synced;
GPSStreamCounter* _stream;
};
#endif // HAS_GPS

View File

@@ -0,0 +1,72 @@
#pragma once
#include <Arduino.h>
// Transparent Stream wrapper that counts NMEA sentences (newline-delimited)
// flowing from the GPS serial port to the MicroNMEA parser.
//
// Usage: Instead of MicroNMEALocationProvider gps(Serial2, &rtc_clock);
// Use: GPSStreamCounter gpsStream(Serial2);
// MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
//
// Every read() call passes through to the underlying stream; when a '\n'
// is seen the sentence counter increments. This lets the UI display a
// live "nmea" count so users can confirm the baud rate is correct and
// the GPS module is actually sending data.
class GPSStreamCounter : public Stream {
public:
GPSStreamCounter(Stream& inner)
: _inner(inner), _sentences(0), _sentences_snapshot(0),
_last_snapshot(0), _sentences_per_sec(0) {}
// --- Stream read interface (passes through) ---
int available() override { return _inner.available(); }
int peek() override { return _inner.peek(); }
int read() override {
int c = _inner.read();
if (c == '\n') {
_sentences++;
}
return c;
}
// --- Stream write interface (pass through for NMEA commands if needed) ---
size_t write(uint8_t b) override { return _inner.write(b); }
// --- Sentence counting API ---
// Total sentences received since boot (or last reset)
uint32_t getSentenceCount() const { return _sentences; }
// Sentences received per second (updated each time you call it,
// with a 1-second rolling window)
uint16_t getSentencesPerSec() {
unsigned long now = millis();
unsigned long elapsed = now - _last_snapshot;
if (elapsed >= 1000) {
uint32_t delta = _sentences - _sentences_snapshot;
// Scale to per-second if interval wasn't exactly 1000ms
_sentences_per_sec = (uint16_t)((delta * 1000UL) / elapsed);
_sentences_snapshot = _sentences;
_last_snapshot = now;
}
return _sentences_per_sec;
}
// Reset all counters (e.g. when GPS hardware power cycles)
void resetCounters() {
_sentences = 0;
_sentences_snapshot = 0;
_sentences_per_sec = 0;
_last_snapshot = millis();
}
private:
Stream& _inner;
volatile uint32_t _sentences;
uint32_t _sentences_snapshot;
unsigned long _last_snapshot;
uint16_t _sentences_per_sec;
};

View File

@@ -72,10 +72,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 +124,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

@@ -28,7 +28,10 @@ private:
uint8_t _addr;
TwoWire* _wire;
bool _initialized;
bool _shiftActive; // Sticky shift (one-shot)
bool _shiftActive; // Sticky shift (one-shot or held)
bool _shiftConsumed; // Was shift active for the last returned key
bool _shiftHeld; // Shift key physically held down
bool _shiftUsedWhileHeld; // Was shift consumed by any key while held
bool _altActive; // Sticky alt (one-shot)
bool _symActive; // Sticky sym (one-shot)
unsigned long _lastShiftTime; // For Shift+key combos
@@ -148,7 +151,7 @@ private:
public:
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
: _addr(addr), _wire(wire), _initialized(false),
_shiftActive(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
bool begin() {
// Check if device responds
@@ -203,6 +206,19 @@ public:
Serial.printf("KB raw: event=0x%02X code=%d pressed=%d count=%d\n",
keyEvent, keyCode, pressed, keyCount);
// Track shift release (before the general release-ignore)
if (!pressed && (keyCode == 35 || keyCode == 31)) {
_shiftHeld = false;
// If shift was used while held (e.g. cursor nav), clear it completely
// so the next bare keypress isn't treated as shifted.
// If shift was NOT used (tap-then-release), keep _shiftActive for one-shot.
if (_shiftUsedWhileHeld) {
_shiftActive = false;
}
_shiftUsedWhileHeld = false;
return 0;
}
// Only act on key press, not release
if (!pressed || keyCode == 0) {
return 0;
@@ -211,6 +227,8 @@ public:
// Handle modifier keys - set sticky state and return 0
if (keyCode == 35 || keyCode == 31) { // Shift keys
_shiftActive = true;
_shiftHeld = true;
_shiftUsedWhileHeld = false;
_lastShiftTime = millis();
Serial.println("KB: Shift activated");
return 0;
@@ -276,7 +294,17 @@ public:
if (c >= 'a' && c <= 'z') {
c = c - 'a' + 'A';
}
_shiftActive = false; // Reset sticky shift
// Track that shift was used while physically held
if (_shiftHeld) {
_shiftUsedWhileHeld = true;
}
// Only clear shift if it's one-shot (tap), not held down
if (!_shiftHeld) {
_shiftActive = false;
}
_shiftConsumed = true; // Record that shift was active for this key
} else {
_shiftConsumed = false;
}
if (c != 0) {
@@ -294,4 +322,10 @@ public:
bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const {
return (millis() - _lastShiftTime) < withinMs;
}
// Check if shift was active when the most recent key was produced
// (immune to e-ink refresh timing unlike wasShiftRecentlyPressed)
bool wasShiftConsumed() const {
return _shiftConsumed;
}
};

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,14 +92,21 @@ lib_deps =
adafruit/Adafruit GFX Library@^1.11.0
bakercp/CRC32@^2.0.0
[env:LilyGo_TDeck_Pro_companion_radio_usb]
; ---------------------------------------------------------------------------
; Meck unified builds — one codebase, three variants via build flags
; ---------------------------------------------------------------------------
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
[env:meck_audio_ble]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D MAX_CONTACTS=400
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -107,8 +116,33 @@ 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_companion_radio_ble]
; 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}
-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}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
; 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}
@@ -126,21 +160,3 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_Pro_repeater]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-D ADVERT_NAME='"TDeck Pro Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D NO_OTA=1
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
me-no-dev/AsyncTCP @ ^1.1.1
me-no-dev/ESPAsyncWebServer @ ^1.2.3

View File

@@ -17,7 +17,10 @@ ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if HAS_GPS
MicroNMEALocationProvider gps(Serial2, &rtc_clock);
// Wrap Serial2 with a sentence counter so the UI can show NMEA throughput.
// MicroNMEALocationProvider reads through this wrapper transparently.
GPSStreamCounter gpsStream(Serial2);
MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#else
SensorManager sensors;

View File

@@ -18,6 +18,7 @@
#if HAS_GPS
#include "helpers/sensors/EnvironmentSensorManager.h"
#include "helpers/sensors/MicroNMEALocationProvider.h"
#include "GPSStreamCounter.h"
#else
#include <helpers/SensorManager.h>
#endif
@@ -27,6 +28,7 @@ extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
#if HAS_GPS
extern GPSStreamCounter gpsStream;
extern EnvironmentSensorManager sensors;
#else
extern SensorManager sensors;