mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
35 Commits
audio-upda
...
consolidat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2576a6590b | ||
|
|
5cc9feb3e9 | ||
|
|
d76fa04613 | ||
|
|
5473f29eec | ||
|
|
b85172bcc4 | ||
|
|
3a32555add | ||
|
|
034cc64f8c | ||
|
|
16bc0ed69d | ||
|
|
644eb432b5 | ||
|
|
f2956e9d26 | ||
|
|
8e83155698 | ||
|
|
cd594c4116 | ||
|
|
b43ffe9578 | ||
|
|
d4b1824b1c | ||
|
|
9809f47d29 | ||
|
|
bf89da0eb5 | ||
|
|
aa2e1af999 | ||
|
|
472b0ee662 | ||
|
|
1f5cbbd4db | ||
|
|
f451b49226 | ||
|
|
d10aa2c571 | ||
|
|
a2e099f095 | ||
|
|
e5e41ff50b | ||
|
|
2dc6977c20 | ||
|
|
5c540e9092 | ||
|
|
670efa75b0 | ||
|
|
3a486832c8 | ||
|
|
7f75ea8309 | ||
|
|
ddfe05ad20 | ||
|
|
d51ca6db0b | ||
|
|
3ab8191d19 | ||
|
|
546ce55c2b | ||
|
|
1f46bc1970 | ||
|
|
db8a73004e | ||
|
|
209a2f1693 |
78
Audiobook Player Guide.md
Normal file
78
Audiobook Player Guide.md
Normal 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)
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "13 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "20 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.8.6"
|
||||
#define FIRMWARE_VERSION "Meck v0.9.1A"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "ContactsScreen.h"
|
||||
#include "ChannelScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
|
||||
|
||||
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
|
||||
@@ -47,6 +48,15 @@
|
||||
// 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;
|
||||
@@ -407,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.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -521,6 +531,11 @@ void setup() {
|
||||
notesScr->setSDReady(true);
|
||||
}
|
||||
|
||||
// Audiobook player screen creation is deferred to first use (case 'p' in
|
||||
// handleKeyboardInput) to avoid allocating Audio I2S/DMA buffers at boot,
|
||||
// which would starve BLE of heap memory.
|
||||
MESH_DEBUG_PRINTLN("setup() - Audiobook player deferred (lazy init on first use)");
|
||||
|
||||
// Do an initial settings backup to SD (captures any first-boot defaults)
|
||||
backupSettingsToSD();
|
||||
}
|
||||
@@ -543,7 +558,7 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// GPS duty cycle — honour saved pref, default to enabled on first boot
|
||||
// GPS duty cycle  honour saved pref, default to enabled on first boot
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
|
||||
@@ -565,13 +580,15 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
|
||||
#endif
|
||||
|
||||
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
|
||||
// GPS duty cycle — check for fix and manage power state
|
||||
// GPS duty cycle  check for fix and manage power state
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_hw_on = gpsDuty.loop();
|
||||
@@ -588,6 +605,21 @@ void 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)
|
||||
@@ -615,9 +647,12 @@ void loop() {
|
||||
composeNeedsRefresh = false;
|
||||
}
|
||||
}
|
||||
// Track reader/notes mode state for key routing
|
||||
// Track reader/notes/audiobook mode state for key routing
|
||||
readerMode = ui_task.isOnTextReader();
|
||||
notesMode = ui_task.isOnNotesScreen();
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
audiobookMode = ui_task.isOnAudiobookPlayer();
|
||||
#endif
|
||||
#else
|
||||
ui_task.loop();
|
||||
#endif
|
||||
@@ -807,6 +842,42 @@ 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();
|
||||
@@ -982,11 +1053,61 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// All other keys → settings screen via injectKey
|
||||
// All other keys → settings screen via injectKey
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// *** REPEATER ADMIN MODE ***
|
||||
if (ui_task.isOnRepeaterAdmin()) {
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
|
||||
RepeaterAdminScreen::AdminState astate = admin->getState();
|
||||
bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed());
|
||||
|
||||
// In password entry: Shift+Del exits, all other keys pass through normally
|
||||
if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
|
||||
if (shiftDel) {
|
||||
Serial.println("Nav: Back to contacts from admin login");
|
||||
ui_task.gotoContactsScreen();
|
||||
} else {
|
||||
ui_task.injectKey(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In menu state: Shift+Del exits to contacts, C opens compose
|
||||
if (astate == RepeaterAdminScreen::STATE_MENU) {
|
||||
if (shiftDel) {
|
||||
Serial.println("Nav: Back to contacts from admin menu");
|
||||
ui_task.gotoContactsScreen();
|
||||
return;
|
||||
}
|
||||
// C key: allow entering compose mode from admin menu
|
||||
if (key == 'c' || key == 'C') {
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
composeMode = true;
|
||||
composeBuffer[0] = '\0';
|
||||
composePos = 0;
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
return;
|
||||
}
|
||||
// All other keys pass to admin screen
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// In waiting/response/error states: convert Shift+Del to exit signal,
|
||||
// pass all other keys through
|
||||
if (shiftDel) {
|
||||
ui_task.injectKey(KEY_ADMIN_EXIT);
|
||||
} else {
|
||||
ui_task.injectKey(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode - not composing
|
||||
switch (key) {
|
||||
case 'c':
|
||||
@@ -1007,6 +1128,26 @@ void handleKeyboardInput() {
|
||||
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");
|
||||
@@ -1022,8 +1163,8 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case 's':
|
||||
// Open settings (from home), or navigate down on channel/contacts
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
// Open settings (from home), or navigate down on channel/contacts/admin
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
|
||||
ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling
|
||||
} else {
|
||||
Serial.println("Opening settings");
|
||||
@@ -1033,7 +1174,7 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'w':
|
||||
// Navigate up/previous (scroll on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
|
||||
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
|
||||
} else {
|
||||
Serial.println("Nav: Previous");
|
||||
@@ -1062,7 +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();
|
||||
@@ -1077,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();
|
||||
@@ -1098,7 +1252,15 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'q':
|
||||
case '\b':
|
||||
// Go back to home screen
|
||||
// If channel screen path overlay is showing, dismiss it instead of going home
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chScr && chScr->isShowingPathOverlay()) {
|
||||
ui_task.injectKey('q');
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Go back to home screen (admin mode handled above)
|
||||
Serial.println("Nav: Back to home");
|
||||
ui_task.gotoHomeScreen();
|
||||
break;
|
||||
@@ -1113,6 +1275,13 @@ void handleKeyboardInput() {
|
||||
// UTC offset edit (home screen GPS page handles this)
|
||||
ui_task.injectKey('u');
|
||||
break;
|
||||
|
||||
case 'v':
|
||||
// View path overlay (channel screen only)
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ui_task.injectKey('v');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
|
||||
@@ -1308,4 +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
|
||||
1802
examples/companion_radio/ui-new/Audiobookplayerscreen.h
Normal file
1802
examples/companion_radio/ui-new/Audiobookplayerscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
651
examples/companion_radio/ui-new/M4BMetadata.h
Normal file
651
examples/companion_radio/ui-new/M4BMetadata.h
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -182,8 +182,10 @@ private:
|
||||
|
||||
// File list state
|
||||
std::vector<String> _fileList;
|
||||
std::vector<String> _dirList; // Subdirectories at current path
|
||||
std::vector<FileCache> _fileCache;
|
||||
int _selectedFile;
|
||||
String _currentPath; // Current browsed directory
|
||||
|
||||
// Reading state
|
||||
File _file;
|
||||
@@ -391,8 +393,8 @@ private:
|
||||
idxFile.read(&fullyFlag, 1);
|
||||
idxFile.read((uint8_t*)&lastRead, 4);
|
||||
|
||||
// Verify file hasn't changed - try BOOKS_FOLDER first, then epub cache
|
||||
String fullPath = String(BOOKS_FOLDER) + "/" + filename;
|
||||
// Verify file hasn't changed - try current path first, then epub cache
|
||||
String fullPath = _currentPath + "/" + filename;
|
||||
File txtFile = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!txtFile) {
|
||||
// Fallback: check epub cache directory
|
||||
@@ -482,33 +484,94 @@ private:
|
||||
|
||||
// ---- File Scanning ----
|
||||
|
||||
// ---- Folder Navigation Helpers ----
|
||||
|
||||
bool isAtBooksRoot() const {
|
||||
return _currentPath == String(BOOKS_FOLDER);
|
||||
}
|
||||
|
||||
// Number of non-file entries at the start of the visual list
|
||||
int dirEntryCount() const {
|
||||
int count = _dirList.size();
|
||||
if (!isAtBooksRoot()) count++; // ".." entry
|
||||
return count;
|
||||
}
|
||||
|
||||
// Total items in the visual list (parent + dirs + files)
|
||||
int totalListItems() const {
|
||||
return dirEntryCount() + (int)_fileList.size();
|
||||
}
|
||||
|
||||
// What type of entry is at visual list index idx?
|
||||
// Returns: 0 = ".." parent, 1 = directory, 2 = file
|
||||
int itemTypeAt(int idx) const {
|
||||
bool hasParent = !isAtBooksRoot();
|
||||
if (hasParent && idx == 0) return 0; // ".."
|
||||
int dirStart = hasParent ? 1 : 0;
|
||||
if (idx < dirStart + (int)_dirList.size()) return 1; // directory
|
||||
return 2; // file
|
||||
}
|
||||
|
||||
// Get directory name for visual index (only valid when itemTypeAt == 1)
|
||||
const String& dirNameAt(int idx) const {
|
||||
int dirStart = isAtBooksRoot() ? 0 : 1;
|
||||
return _dirList[idx - dirStart];
|
||||
}
|
||||
|
||||
// Get file list index for visual index (only valid when itemTypeAt == 2)
|
||||
int fileIndexAt(int idx) const {
|
||||
return idx - dirEntryCount();
|
||||
}
|
||||
|
||||
void navigateToParent() {
|
||||
int lastSlash = _currentPath.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
_currentPath = _currentPath.substring(0, lastSlash);
|
||||
} else {
|
||||
_currentPath = BOOKS_FOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
void navigateToChild(const String& dirName) {
|
||||
_currentPath = _currentPath + "/" + dirName;
|
||||
}
|
||||
|
||||
// ---- File Scanning ----
|
||||
|
||||
void scanFiles() {
|
||||
_fileList.clear();
|
||||
_dirList.clear();
|
||||
if (!SD.exists(BOOKS_FOLDER)) {
|
||||
SD.mkdir(BOOKS_FOLDER);
|
||||
Serial.printf("TextReader: Created %s\n", BOOKS_FOLDER);
|
||||
}
|
||||
|
||||
File root = SD.open(BOOKS_FOLDER);
|
||||
File root = SD.open(_currentPath.c_str());
|
||||
if (!root || !root.isDirectory()) return;
|
||||
|
||||
File f = root.openNextFile();
|
||||
while (f && _fileList.size() < READER_MAX_FILES) {
|
||||
if (!f.isDirectory()) {
|
||||
String name = String(f.name());
|
||||
int slash = name.lastIndexOf('/');
|
||||
if (slash >= 0) name = name.substring(slash + 1);
|
||||
while (f && (_fileList.size() + _dirList.size()) < READER_MAX_FILES) {
|
||||
String name = String(f.name());
|
||||
int slash = name.lastIndexOf('/');
|
||||
if (slash >= 0) name = name.substring(slash + 1);
|
||||
|
||||
if (!name.startsWith(".") &&
|
||||
(name.endsWith(".txt") || name.endsWith(".TXT") ||
|
||||
name.endsWith(".epub") || name.endsWith(".EPUB"))) {
|
||||
_fileList.push_back(name);
|
||||
}
|
||||
// Skip hidden files/dirs
|
||||
if (name.startsWith(".")) {
|
||||
f = root.openNextFile();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.isDirectory()) {
|
||||
_dirList.push_back(name);
|
||||
} else if (name.endsWith(".txt") || name.endsWith(".TXT") ||
|
||||
name.endsWith(".epub") || name.endsWith(".EPUB")) {
|
||||
_fileList.push_back(name);
|
||||
}
|
||||
f = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
Serial.printf("TextReader: Found %d files\n", _fileList.size());
|
||||
Serial.printf("TextReader: %s — %d dirs, %d files\n",
|
||||
_currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size());
|
||||
}
|
||||
|
||||
// ---- Book Open/Close ----
|
||||
@@ -518,7 +581,7 @@ private:
|
||||
|
||||
// ---- EPUB auto-conversion ----
|
||||
String actualFilename = filename;
|
||||
String actualFullPath = String(BOOKS_FOLDER) + "/" + filename;
|
||||
String actualFullPath = _currentPath + "/" + filename;
|
||||
bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");
|
||||
|
||||
if (isEpub) {
|
||||
@@ -755,15 +818,26 @@ private:
|
||||
display.setCursor(0, 0);
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.print("Text Reader");
|
||||
if (isAtBooksRoot()) {
|
||||
display.print("Text Reader");
|
||||
} else {
|
||||
// Show current subfolder name
|
||||
int lastSlash = _currentPath.lastIndexOf('/');
|
||||
String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath;
|
||||
char hdrBuf[20];
|
||||
strncpy(hdrBuf, folderName.c_str(), 17);
|
||||
hdrBuf[17] = '\0';
|
||||
display.print(hdrBuf);
|
||||
}
|
||||
|
||||
sprintf(tmp, "[%d]", (int)_fileList.size());
|
||||
int totalItems = totalListItems();
|
||||
sprintf(tmp, "[%d]", totalItems);
|
||||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||
display.print(tmp);
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (_fileList.size() == 0) {
|
||||
if (totalItems == 0) {
|
||||
display.setCursor(0, 18);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("No files found");
|
||||
@@ -780,8 +854,8 @@ private:
|
||||
if (maxVisible > 15) maxVisible = 15;
|
||||
|
||||
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
|
||||
(int)_fileList.size() - maxVisible));
|
||||
int endIdx = min((int)_fileList.size(), startIdx + maxVisible);
|
||||
totalItems - maxVisible));
|
||||
int endIdx = min(totalItems, startIdx + maxVisible);
|
||||
|
||||
int y = startY;
|
||||
for (int i = startIdx; i < endIdx; i++) {
|
||||
@@ -800,27 +874,41 @@ private:
|
||||
// Set cursor AFTER fillRect so text draws on top of highlight
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Build display string: "> filename.txt *" (asterisk if has bookmark)
|
||||
int type = itemTypeAt(i);
|
||||
String line = selected ? "> " : " ";
|
||||
String name = _fileList[i];
|
||||
|
||||
// Check for resume indicator
|
||||
String suffix = "";
|
||||
for (int j = 0; j < (int)_fileCache.size(); j++) {
|
||||
if (_fileCache[j].filename == name && _fileCache[j].lastReadPage > 0) {
|
||||
suffix = " *";
|
||||
break;
|
||||
if (type == 0) {
|
||||
// ".." parent directory
|
||||
line += ".. (up)";
|
||||
} else if (type == 1) {
|
||||
// Subdirectory
|
||||
line += "/" + dirNameAt(i);
|
||||
// Truncate if needed
|
||||
if ((int)line.length() > _charsPerLine) {
|
||||
line = line.substring(0, _charsPerLine - 3) + "...";
|
||||
}
|
||||
} else {
|
||||
// File
|
||||
int fi = fileIndexAt(i);
|
||||
String name = _fileList[fi];
|
||||
|
||||
// Check for resume indicator
|
||||
String suffix = "";
|
||||
if (fi < (int)_fileCache.size()) {
|
||||
if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) {
|
||||
suffix = " *";
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4 - suffix.length();
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name + suffix;
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4 - suffix.length();
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name + suffix;
|
||||
display.print(line.c_str());
|
||||
|
||||
y += listLineH;
|
||||
}
|
||||
display.setTextSize(1); // Restore
|
||||
@@ -928,7 +1016,8 @@ public:
|
||||
_bootIndexed(false), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
|
||||
_headerHeight(14), _footerHeight(14),
|
||||
_selectedFile(0), _fileOpen(false), _currentPage(0), _totalPages(0),
|
||||
_selectedFile(0), _currentPath(BOOKS_FOLDER),
|
||||
_fileOpen(false), _currentPage(0), _totalPages(0),
|
||||
_pageBufLen(0), _contentDirty(true) {
|
||||
}
|
||||
|
||||
@@ -1068,8 +1157,8 @@ public:
|
||||
indexProgress++;
|
||||
drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);
|
||||
|
||||
// Try BOOKS_FOLDER first, then epub cache fallback
|
||||
String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i];
|
||||
// Try current path first, then epub cache fallback
|
||||
String fullPath = _currentPath + "/" + _fileList[i];
|
||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!file) {
|
||||
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
|
||||
@@ -1166,6 +1255,8 @@ public:
|
||||
}
|
||||
|
||||
bool handleFileListInput(char c) {
|
||||
int total = totalListItems();
|
||||
|
||||
// W - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_selectedFile > 0) {
|
||||
@@ -1177,18 +1268,36 @@ public:
|
||||
|
||||
// S - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_selectedFile < (int)_fileList.size() - 1) {
|
||||
if (_selectedFile < total - 1) {
|
||||
_selectedFile++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter - open selected file
|
||||
// Enter - open selected item (directory or file)
|
||||
if (c == '\r' || c == 13) {
|
||||
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
|
||||
openBook(_fileList[_selectedFile]);
|
||||
if (total == 0 || _selectedFile >= total) return false;
|
||||
|
||||
int type = itemTypeAt(_selectedFile);
|
||||
|
||||
if (type == 0) {
|
||||
// ".." — navigate to parent
|
||||
navigateToParent();
|
||||
rescanAndIndex();
|
||||
return true;
|
||||
} else if (type == 1) {
|
||||
// Subdirectory — navigate into it
|
||||
navigateToChild(dirNameAt(_selectedFile));
|
||||
rescanAndIndex();
|
||||
return true;
|
||||
} else {
|
||||
// File — open it
|
||||
int fi = fileIndexAt(_selectedFile);
|
||||
if (fi >= 0 && fi < (int)_fileList.size()) {
|
||||
openBook(_fileList[fi]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1196,6 +1305,53 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rescan current directory and re-index its files.
|
||||
// Called when navigating into or out of a subfolder.
|
||||
void rescanAndIndex() {
|
||||
scanFiles();
|
||||
_selectedFile = 0;
|
||||
|
||||
// Rebuild file cache for the new directory's files
|
||||
_fileCache.clear();
|
||||
_fileCache.resize(_fileList.size());
|
||||
|
||||
for (int i = 0; i < (int)_fileList.size(); i++) {
|
||||
if (!loadIndex(_fileList[i], _fileCache[i])) {
|
||||
// Not cached — skip EPUB auto-indexing here (it happens on open)
|
||||
// For .txt files, index now
|
||||
if (!(_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB"))) {
|
||||
String fullPath = _currentPath + "/" + _fileList[i];
|
||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!file) {
|
||||
// Try epub cache fallback
|
||||
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
|
||||
file = SD.open(cacheFallback.c_str(), FILE_READ);
|
||||
}
|
||||
if (file) {
|
||||
FileCache& cache = _fileCache[i];
|
||||
cache.filename = _fileList[i];
|
||||
cache.fileSize = file.size();
|
||||
cache.fullyIndexed = false;
|
||||
cache.lastReadPage = 0;
|
||||
cache.pagePositions.clear();
|
||||
cache.pagePositions.push_back(0);
|
||||
indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
|
||||
cache.fullyIndexed, 0);
|
||||
}
|
||||
} else {
|
||||
_fileCache[i].filename = "";
|
||||
}
|
||||
}
|
||||
yield(); // Feed WDT between files
|
||||
}
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
}
|
||||
|
||||
bool handleReadingInput(char c) {
|
||||
// W/A - previous page
|
||||
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "target.h"
|
||||
#include "GPSDutyCycle.h"
|
||||
#ifdef WIFI_SSID
|
||||
@@ -36,11 +37,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) {
|
||||
@@ -87,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
|
||||
@@ -111,21 +120,15 @@ class HomeScreen : public UIScreen {
|
||||
|
||||
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
|
||||
// Use the BQ27220 fuel gauge SOC register for accurate percentage.
|
||||
// Falls back to voltage estimation if the fuel gauge is uncalibrated.
|
||||
uint8_t batteryPercentage = board.getBatteryPercent();
|
||||
|
||||
// Sanity check: if voltage says full but gauge disagrees significantly,
|
||||
// the gauge hasn't calibrated yet — fall back to voltage estimate
|
||||
int voltagePct = 0;
|
||||
// Use voltage-based estimation to match BLE app readings
|
||||
uint8_t batteryPercentage = 0;
|
||||
if (batteryMilliVolts > 0) {
|
||||
voltagePct = ((batteryMilliVolts - 3000) * 100) / (4200 - 3000);
|
||||
if (voltagePct < 0) voltagePct = 0;
|
||||
if (voltagePct > 100) voltagePct = 100;
|
||||
}
|
||||
|
||||
if (batteryPercentage == 0 || abs((int)batteryPercentage - voltagePct) > 30) {
|
||||
batteryPercentage = (uint8_t)voltagePct;
|
||||
const int minMilliVolts = 3000;
|
||||
const int maxMilliVolts = 4200;
|
||||
int pct = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
batteryPercentage = (uint8_t)pct;
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -145,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);
|
||||
|
||||
@@ -164,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;
|
||||
@@ -212,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
|
||||
{
|
||||
@@ -258,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);
|
||||
@@ -324,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,
|
||||
@@ -331,6 +393,7 @@ public:
|
||||
32, 32);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
|
||||
#endif
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
|
||||
@@ -380,7 +443,7 @@ public:
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
display.drawTextLeftAlign(0, y, "sentences");
|
||||
if (gpsDuty.isHardwareOn()) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
@@ -510,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);
|
||||
@@ -570,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();
|
||||
@@ -578,6 +694,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
@@ -745,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;
|
||||
|
||||
@@ -756,6 +879,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
text_reader = new TextReaderScreen(this);
|
||||
notes_screen = new NotesScreen(this);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -797,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)
|
||||
@@ -820,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);
|
||||
@@ -843,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() {
|
||||
@@ -978,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
|
||||
@@ -1088,13 +1233,13 @@ void UITask::toggleGPS() {
|
||||
|
||||
if (_sensors != NULL) {
|
||||
if (_node_prefs->gps_enabled) {
|
||||
// Disable GPS  cut hardware power
|
||||
// Disable GPS  cut hardware power
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
gpsDuty.disable();
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
// Enable GPS  start duty cycle
|
||||
// Enable GPS  start duty cycle
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
gpsDuty.enable();
|
||||
@@ -1225,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();
|
||||
}
|
||||
@@ -1236,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
|
||||
@@ -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;
|
||||
@@ -56,6 +57,8 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* text_reader; // *** NEW: Text reader screen ***
|
||||
UIScreen* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
UIScreen* repeater_admin; // Repeater admin screen
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
@@ -72,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;
|
||||
}
|
||||
@@ -84,6 +88,8 @@ public:
|
||||
void gotoNotesScreen(); // Navigate to notes editor
|
||||
void gotoSettingsScreen(); // Navigate to settings
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
void forceRefresh() override { _next_refresh = 100; }
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
@@ -94,6 +100,14 @@ public:
|
||||
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
|
||||
bool isOnNotesScreen() const { return curr == notes_screen; }
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// Check if audio is playing/paused in the background (for status indicators)
|
||||
bool isAudioPlayingInBackground() const;
|
||||
bool isAudioPausedInBackground() const;
|
||||
#endif
|
||||
uint8_t getChannelScreenViewIdx() const;
|
||||
|
||||
void toggleBuzzer();
|
||||
@@ -108,6 +122,10 @@ public:
|
||||
|
||||
// Add a sent message to the channel screen history
|
||||
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
|
||||
|
||||
// Repeater admin callbacks
|
||||
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
|
||||
void onAdminCliResponse(const char* from_name, const char* text) override;
|
||||
|
||||
// Get current screen for checking state
|
||||
UIScreen* getCurrentScreen() const { return curr; }
|
||||
@@ -117,10 +135,14 @@ public:
|
||||
UIScreen* getContactsScreen() const { return contacts_screen; }
|
||||
UIScreen* getChannelScreen() const { return channel_screen; }
|
||||
UIScreen* getSettingsScreen() const { return settings_screen; }
|
||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path = nullptr) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ build_flags =
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D CST328_PIN_RST=38
|
||||
-D FIRMWARE_VERSION='"Meck v0.8.6"'
|
||||
-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>
|
||||
@@ -91,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>
|
||||
@@ -108,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}
|
||||
@@ -127,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
|
||||
Reference in New Issue
Block a user