Compare commits
16 Commits
no-ble
...
consolidat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2576a6590b | ||
|
|
5cc9feb3e9 | ||
|
|
d76fa04613 | ||
|
|
5473f29eec | ||
|
|
b85172bcc4 | ||
|
|
3a32555add | ||
|
|
034cc64f8c | ||
|
|
16bc0ed69d | ||
|
|
644eb432b5 | ||
|
|
f2956e9d26 | ||
|
|
8e83155698 | ||
|
|
cd594c4116 | ||
|
|
b43ffe9578 | ||
|
|
d4b1824b1c | ||
|
|
9809f47d29 | ||
|
|
bf89da0eb5 |
@@ -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
|
||||
@@ -525,11 +526,11 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
|
||||
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text);
|
||||
|
||||
// Forward CLI response to UI admin screen if admin session is active
|
||||
#ifdef DISPLAY_CLASS
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_admin_contact_idx >= 0 && _ui) {
|
||||
_ui->onAdminCliResponse(from.name, text);
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
@@ -581,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
|
||||
@@ -1181,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);
|
||||
@@ -1189,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 "14 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "20 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.8.8A"
|
||||
#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)
|
||||
};
|
||||
@@ -48,12 +48,14 @@
|
||||
// Notes mode state
|
||||
static bool notesMode = false;
|
||||
|
||||
// Audiobook player — Audio object is heap-allocated on first use to avoid
|
||||
// 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
|
||||
@@ -328,7 +330,7 @@ void setup() {
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("setup() - radio_init() done");
|
||||
|
||||
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
|
||||
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()");
|
||||
@@ -415,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.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -556,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;
|
||||
@@ -578,7 +580,7 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
|
||||
#endif
|
||||
|
||||
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
|
||||
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
|
||||
}
|
||||
@@ -586,7 +588,7 @@ void setup() {
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
|
||||
// GPS duty cycle — check for fix and manage power state
|
||||
// GPS duty cycle  check for fix and manage power state
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_hw_on = gpsDuty.loop();
|
||||
@@ -605,6 +607,7 @@ void loop() {
|
||||
cpuPower.loop();
|
||||
|
||||
// Audiobook: service audio decode regardless of which screen is active
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
{
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
@@ -616,6 +619,7 @@ void loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Skip UITask rendering when in compose mode to prevent flickering
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
@@ -646,7 +650,9 @@ void loop() {
|
||||
// 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
|
||||
@@ -837,6 +843,7 @@ void handleKeyboardInput() {
|
||||
}
|
||||
|
||||
// *** AUDIOBOOK MODE ***
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
if (audiobookMode) {
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
@@ -848,11 +855,11 @@ void handleKeyboardInput() {
|
||||
if (key == 'q') {
|
||||
if (abPlayer->isBookOpen()) {
|
||||
if (abPlayer->isAudioActive()) {
|
||||
// Audio is playing — leave screen, audio continues via audioTick()
|
||||
// Audio is playing — leave screen, audio continues via audioTick()
|
||||
Serial.println("Leaving audiobook player (audio continues in background)");
|
||||
ui_task.gotoHomeScreen();
|
||||
} else {
|
||||
// Paused or stopped — close book, show file list
|
||||
// Paused or stopped — close book, show file list
|
||||
abPlayer->closeCurrentBook();
|
||||
Serial.println("Closed audiobook (was paused/stopped)");
|
||||
// Stay on audiobook screen showing file list
|
||||
@@ -869,6 +876,7 @@ void handleKeyboardInput() {
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
#endif // MECK_AUDIO_VARIANT
|
||||
|
||||
// *** TEXT READER MODE ***
|
||||
if (readerMode) {
|
||||
@@ -1045,7 +1053,7 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// All other keys → settings screen via injectKey
|
||||
// All other keys → settings screen via injectKey
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
@@ -1121,18 +1129,23 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
// Open audiobook player — lazy-init Audio + screen on first use
|
||||
#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",
|
||||
Serial.printf("Audiobook: lazy init -- free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
audio = new Audio();
|
||||
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio);
|
||||
abScreen->setSDReady(sdCardReady);
|
||||
ui_task.setAudiobookScreen(abScreen);
|
||||
Serial.printf("Audiobook: init complete — free heap: %d\n", ESP.getFreeHeap());
|
||||
Serial.printf("Audiobook: init complete -- free heap: %d\n", ESP.getFreeHeap());
|
||||
}
|
||||
ui_task.gotoAudiobookPlayer();
|
||||
#else
|
||||
Serial.println("Audio not available on this build variant");
|
||||
ui_task.showAlert("No audio hardware", 1500);
|
||||
#endif
|
||||
break;
|
||||
|
||||
case 'n':
|
||||
@@ -1217,6 +1230,11 @@ void handleKeyboardInput() {
|
||||
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
|
||||
}
|
||||
} else if (ui_task.isOnChannelScreen()) {
|
||||
// Don't enter compose if path overlay is showing
|
||||
ChannelScreen* chScr2 = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chScr2 && chScr2->isShowingPathOverlay()) {
|
||||
break;
|
||||
}
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
composeChannelIdx = ui_task.getChannelScreenViewIdx();
|
||||
@@ -1234,6 +1252,14 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'q':
|
||||
case '\b':
|
||||
// If channel screen path overlay is showing, dismiss it instead of going home
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chScr && chScr->isShowingPathOverlay()) {
|
||||
ui_task.injectKey('q');
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Go back to home screen (admin mode handled above)
|
||||
Serial.println("Nav: Back to home");
|
||||
ui_task.gotoHomeScreen();
|
||||
@@ -1249,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);
|
||||
@@ -1447,16 +1480,20 @@ void sendComposedMessage() {
|
||||
// ============================================================================
|
||||
// ESP32-audioI2S CALLBACKS
|
||||
// ============================================================================
|
||||
// The audio library calls these global functions — must be defined at file scope.
|
||||
|
||||
#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);
|
||||
// Playback finished — the player screen will detect this
|
||||
// via audio.isRunning() returning false
|
||||
// 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
|
||||
@@ -18,6 +18,8 @@
|
||||
// PLAYER mode: Enter = play/pause, A = -30s, D = +30s,
|
||||
// W = volume up, S = volume down,
|
||||
// [ = prev chapter, ] = next chapter,
|
||||
// N = next track in playlist,
|
||||
// Z = toggle 45-min sleep timer,
|
||||
// Q = leave (audio continues) / close book (if paused)
|
||||
//
|
||||
// Library dependencies (add to platformio.ini lib_deps):
|
||||
@@ -29,6 +31,7 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <SD.h>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include "M4BMetadata.h"
|
||||
|
||||
// Audio library — ESP32-audioI2S by schreibfaul1
|
||||
@@ -200,6 +203,15 @@ private:
|
||||
int _transportSel;
|
||||
bool _showingInfo;
|
||||
|
||||
// Sleep timer — press Z to start 45min countdown, Z again or pause to cancel
|
||||
bool _sleepTimerActive;
|
||||
unsigned long _sleepTimerEnd; // millis() when timer expires
|
||||
|
||||
// Playlist / track queue — all playable files in the current directory
|
||||
std::vector<String> _playlist; // Sorted filenames in _currentPath
|
||||
int _playlistIdx; // Current track index (-1 = no playlist)
|
||||
volatile bool _eofFlag; // Set by audio_eof_mp3 callback
|
||||
|
||||
// Power on the PCM5102A DAC via GPIO 41 (BOARD_6609_EN).
|
||||
// On the audio variant, this pin supplies power to the DAC circuit.
|
||||
// TDeckBoard::begin() sets it LOW ("disable modem") which starves the DAC.
|
||||
@@ -671,6 +683,17 @@ private:
|
||||
int dot = cleaned.lastIndexOf('.');
|
||||
if (dot > 0) cleaned = cleaned.substring(0, dot);
|
||||
cleaned.replace("_", " ");
|
||||
|
||||
// In subdirectories, filenames often follow "Artist - Album - NN Track"
|
||||
// pattern. The folder already provides context, so extract just the
|
||||
// last segment after " - " to show the track-relevant part.
|
||||
if (_currentPath != String(AUDIOBOOKS_FOLDER)) {
|
||||
int lastSep = cleaned.lastIndexOf(" - ");
|
||||
if (lastSep > 0 && lastSep < (int)cleaned.length() - 3) {
|
||||
cleaned = cleaned.substring(lastSep + 3);
|
||||
}
|
||||
}
|
||||
|
||||
entry.displayTitle = cleaned;
|
||||
}
|
||||
|
||||
@@ -694,6 +717,18 @@ private:
|
||||
root.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
// Sort directories and files alphabetically (case-insensitive)
|
||||
std::sort(dirs.begin(), dirs.end(), [](const AudiobookFileEntry& a, const AudiobookFileEntry& b) {
|
||||
String la = a.name; la.toLowerCase();
|
||||
String lb = b.name; lb.toLowerCase();
|
||||
return la < lb;
|
||||
});
|
||||
std::sort(files.begin(), files.end(), [](const AudiobookFileEntry& a, const AudiobookFileEntry& b) {
|
||||
String la = a.name; la.toLowerCase();
|
||||
String lb = b.name; lb.toLowerCase();
|
||||
return la < lb;
|
||||
});
|
||||
|
||||
// Append directories first, then files
|
||||
for (auto& d : dirs) _fileList.push_back(d);
|
||||
for (auto& fi : files) _fileList.push_back(fi);
|
||||
@@ -709,6 +744,154 @@ private:
|
||||
_currentPath.c_str(), (int)dirs.size(), (int)files.size(), cacheHits);
|
||||
}
|
||||
|
||||
// ---- Playlist / Track Queue ----
|
||||
// Builds a sorted list of all playable files in the current directory.
|
||||
// Called when opening a file — enables auto-advance and skip.
|
||||
|
||||
void buildPlaylist(const String& startingFile) {
|
||||
_playlist.clear();
|
||||
_playlistIdx = -1;
|
||||
|
||||
File dir = SD.open(_currentPath.c_str());
|
||||
if (!dir || !dir.isDirectory()) return;
|
||||
|
||||
File f = dir.openNextFile();
|
||||
while (f) {
|
||||
if (!f.isDirectory()) {
|
||||
String name = String(f.name());
|
||||
int slash = name.lastIndexOf('/');
|
||||
if (slash >= 0) name = name.substring(slash + 1);
|
||||
if (!name.startsWith(".") && !name.startsWith("._") && isAudiobookFile(name)) {
|
||||
_playlist.push_back(name);
|
||||
}
|
||||
}
|
||||
f = dir.openNextFile();
|
||||
}
|
||||
dir.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
// Sort alphabetically (case-insensitive)
|
||||
std::sort(_playlist.begin(), _playlist.end(), [](const String& a, const String& b) {
|
||||
String la = a; la.toLowerCase();
|
||||
String lb = b; lb.toLowerCase();
|
||||
return la < lb;
|
||||
});
|
||||
|
||||
// Find the starting file's index
|
||||
for (int i = 0; i < (int)_playlist.size(); i++) {
|
||||
if (_playlist[i] == startingFile) {
|
||||
_playlistIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("AB: Playlist built — %d tracks, current idx %d\n",
|
||||
(int)_playlist.size(), _playlistIdx);
|
||||
}
|
||||
|
||||
// Advance to next/previous track in playlist.
|
||||
// direction: +1 = next, -1 = previous
|
||||
// Returns true if a new track was started.
|
||||
bool advanceTrack(int direction) {
|
||||
if (_playlist.size() <= 1 || _playlistIdx < 0) return false;
|
||||
|
||||
int newIdx = _playlistIdx + direction;
|
||||
if (newIdx < 0 || newIdx >= (int)_playlist.size()) {
|
||||
Serial.println("AB: End of playlist reached");
|
||||
// End of playlist — stop playback
|
||||
stopPlayback();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop current track cleanly
|
||||
if (_audio) {
|
||||
_audio->stopSong();
|
||||
}
|
||||
_isPlaying = false;
|
||||
_isPaused = false;
|
||||
_pendingSeekSec = 0;
|
||||
_streamReady = false;
|
||||
restoreM4bRename();
|
||||
// Power down DAC briefly (startPlayback will re-enable it)
|
||||
disableDAC();
|
||||
_i2sInitialized = false;
|
||||
|
||||
// Save bookmark for current track before switching
|
||||
saveBookmark();
|
||||
|
||||
// Free old cover art and metadata
|
||||
freeCoverBitmap();
|
||||
_metadata.clear();
|
||||
|
||||
// Switch to new track
|
||||
_playlistIdx = newIdx;
|
||||
String nextFile = _playlist[_playlistIdx];
|
||||
Serial.printf("AB: Advancing to track %d/%d: %s\n",
|
||||
_playlistIdx + 1, (int)_playlist.size(), nextFile.c_str());
|
||||
|
||||
// Reset state for new track
|
||||
_currentFile = nextFile;
|
||||
_currentPosSec = 0;
|
||||
_durationSec = 0;
|
||||
_currentChapter = -1;
|
||||
_lastPosUpdate = 0;
|
||||
_currentFileSize = 0;
|
||||
_eofFlag = false;
|
||||
|
||||
// Find file size from the file list (if available)
|
||||
for (const auto& fe : _fileList) {
|
||||
if (fe.name == nextFile) {
|
||||
_currentFileSize = fe.fileSize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse metadata for new track
|
||||
String fullPath = _currentPath + "/" + nextFile;
|
||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (file) {
|
||||
if (_currentFileSize == 0) _currentFileSize = file.size();
|
||||
String lower = nextFile;
|
||||
lower.toLowerCase();
|
||||
if (lower.endsWith(".m4b") || lower.endsWith(".m4a")) {
|
||||
_metadata.parse(file);
|
||||
yield();
|
||||
decodeCoverArt(file);
|
||||
yield();
|
||||
} else if (lower.endsWith(".mp3")) {
|
||||
_metadata.parseID3v2(file);
|
||||
yield();
|
||||
decodeCoverArt(file);
|
||||
yield();
|
||||
if (_metadata.title[0] == '\0') {
|
||||
String base = nextFile;
|
||||
int dot = base.lastIndexOf('.');
|
||||
if (dot > 0) base = base.substring(0, dot);
|
||||
strncpy(_metadata.title, base.c_str(), M4B_MAX_TITLE - 1);
|
||||
}
|
||||
} else {
|
||||
String base = nextFile;
|
||||
int dot = base.lastIndexOf('.');
|
||||
if (dot > 0) base = base.substring(0, dot);
|
||||
strncpy(_metadata.title, base.c_str(), M4B_MAX_TITLE - 1);
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
// Load bookmark for new track (may resume a previous position)
|
||||
loadBookmark();
|
||||
|
||||
if (_audio) _audio->setVolume(_volume);
|
||||
if (_metadata.durationMs > 0) _durationSec = _metadata.durationMs / 1000;
|
||||
|
||||
// Start playing the new track
|
||||
_lastPositionSave = millis();
|
||||
startPlayback();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Book Open / Close ----
|
||||
|
||||
void openBook(const String& filename, DisplayDriver* display) {
|
||||
@@ -803,6 +986,11 @@ private:
|
||||
}
|
||||
|
||||
_mode = PLAYER;
|
||||
_eofFlag = false;
|
||||
|
||||
// Build playlist from current directory for track queuing
|
||||
buildPlaylist(filename);
|
||||
|
||||
Serial.printf("AB: Opened '%s' -- %s by %s, %us, %d chapters\n",
|
||||
filename.c_str(), _metadata.title, _metadata.author,
|
||||
_durationSec, _metadata.chapterCount);
|
||||
@@ -818,6 +1006,10 @@ private:
|
||||
_metadata.clear();
|
||||
_bookOpen = false;
|
||||
_currentFile = "";
|
||||
_sleepTimerActive = false;
|
||||
_playlist.clear();
|
||||
_playlistIdx = -1;
|
||||
_eofFlag = false;
|
||||
_mode = FILE_LIST;
|
||||
}
|
||||
|
||||
@@ -826,6 +1018,8 @@ private:
|
||||
void startPlayback() {
|
||||
if (!_audio || _currentFile.length() == 0) return;
|
||||
|
||||
_eofFlag = false; // Clear any stale EOF from previous track
|
||||
|
||||
// Ensure DAC has power (must be re-enabled after each stop)
|
||||
enableDAC();
|
||||
|
||||
@@ -886,6 +1080,8 @@ private:
|
||||
_isPaused = false;
|
||||
_pendingSeekSec = 0;
|
||||
_streamReady = false;
|
||||
_sleepTimerActive = false; // Cancel sleep timer on stop
|
||||
_eofFlag = false;
|
||||
saveBookmark();
|
||||
|
||||
// Restore .m4b filename if we renamed it for playback
|
||||
@@ -906,6 +1102,7 @@ private:
|
||||
if (_isPlaying && !_isPaused) {
|
||||
_audio->pauseResume();
|
||||
_isPaused = true;
|
||||
_sleepTimerActive = false; // Cancel sleep timer on pause
|
||||
saveBookmark();
|
||||
} else if (_isPaused) {
|
||||
_audio->pauseResume();
|
||||
@@ -1216,25 +1413,54 @@ private:
|
||||
{
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Show track position in playlist (if multiple tracks)
|
||||
if (_playlist.size() > 1 && _playlistIdx >= 0) {
|
||||
char trackBuf[24];
|
||||
snprintf(trackBuf, sizeof(trackBuf), "Track %d/%d",
|
||||
_playlistIdx + 1, (int)_playlist.size());
|
||||
display.drawTextCentered(display.width() / 2, y, trackBuf);
|
||||
y += 10;
|
||||
}
|
||||
|
||||
display.drawTextCentered(display.width() / 2, y, "Enter: Play/Pause");
|
||||
y += 10;
|
||||
|
||||
// Only show second hint when there's space (no cover art)
|
||||
// Sleep timer or additional hints
|
||||
if (y < display.height() - 26) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
if (_isPlaying && !_isPaused) {
|
||||
if (_sleepTimerActive) {
|
||||
// Show countdown
|
||||
unsigned long remaining = 0;
|
||||
if (millis() < _sleepTimerEnd) remaining = (_sleepTimerEnd - millis()) / 1000;
|
||||
int mins = remaining / 60;
|
||||
int secs = remaining % 60;
|
||||
char sleepBuf[32];
|
||||
snprintf(sleepBuf, sizeof(sleepBuf), "Sleep: %d:%02d (Z:Off)", mins, secs);
|
||||
display.drawTextCentered(display.width() / 2, y, sleepBuf);
|
||||
} else if (_isPlaying && !_isPaused) {
|
||||
display.drawTextCentered(display.width() / 2, y,
|
||||
"Screen updates on keypress");
|
||||
"Z: Start 45m sleep timer");
|
||||
} else if (_metadata.chapterCount > 0) {
|
||||
display.drawTextCentered(display.width() / 2, y,
|
||||
"[/]: Prev/Next Chapter");
|
||||
} else if (_playlist.size() > 1) {
|
||||
display.drawTextCentered(display.width() / 2, y,
|
||||
"N: Next Track");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Transport controls drawn — footer is at fixed position below
|
||||
|
||||
// ---- Footer Nav Bar ----
|
||||
drawFooter(display, "A/D:Seek W/S:Vol", (_isPlaying && !_isPaused) ? "Q:Leave" : "Q:Close");
|
||||
{
|
||||
const char* rightText = (_isPlaying && !_isPaused) ? "Q:Leave" : "Q:Close";
|
||||
if (_playlist.size() > 1) {
|
||||
drawFooter(display, "A/D:Seek N:Next", rightText);
|
||||
} else {
|
||||
drawFooter(display, "A/D:Seek W/S:Vol", rightText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
@@ -1253,7 +1479,9 @@ public:
|
||||
_pendingSeekSec(0), _streamReady(false),
|
||||
_currentFileSize(0),
|
||||
_m4bRenamed(false),
|
||||
_transportSel(2), _showingInfo(false) {}
|
||||
_transportSel(2), _showingInfo(false),
|
||||
_sleepTimerActive(false), _sleepTimerEnd(0),
|
||||
_playlistIdx(-1), _eofFlag(false) {}
|
||||
|
||||
~AudiobookPlayerScreen() {
|
||||
freeCoverBitmap();
|
||||
@@ -1317,12 +1545,41 @@ public:
|
||||
saveBookmark();
|
||||
_lastPositionSave = millis();
|
||||
}
|
||||
|
||||
// Sleep timer — auto-pause when countdown expires
|
||||
if (_sleepTimerActive && millis() >= _sleepTimerEnd) {
|
||||
_sleepTimerActive = false;
|
||||
Serial.println("AB: Sleep timer expired — pausing playback");
|
||||
togglePause();
|
||||
return; // Don't process further this tick
|
||||
}
|
||||
|
||||
// EOF auto-advance — when a track finishes, play the next one
|
||||
if (_eofFlag) {
|
||||
_eofFlag = false;
|
||||
Serial.println("AB: EOF detected");
|
||||
if (_playlist.size() > 1 && _playlistIdx >= 0 &&
|
||||
_playlistIdx < (int)_playlist.size() - 1) {
|
||||
// More tracks in playlist — advance
|
||||
advanceTrack(1);
|
||||
} else {
|
||||
// Last track or no playlist — just stop
|
||||
stopPlayback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool isAudioActive() const { return _isPlaying && !_isPaused; }
|
||||
bool isPaused() const { return _isPaused; }
|
||||
bool isBookOpenAndPaused() const { return _bookOpen && (_isPaused || !_isPlaying); }
|
||||
|
||||
// Called from audio_eof_mp3 callback in main.cpp to signal end of file
|
||||
void onEOF() { _eofFlag = true; }
|
||||
|
||||
// Playlist info for external queries
|
||||
int getPlaylistSize() const { return (int)_playlist.size(); }
|
||||
int getPlaylistIndex() const { return _playlistIdx; }
|
||||
|
||||
// Public method to close the current book (stops playback, saves bookmark,
|
||||
// returns to file list). Used by main.cpp when user presses Q while paused.
|
||||
void closeCurrentBook() {
|
||||
@@ -1493,7 +1750,7 @@ public:
|
||||
return true; // Always consume & refresh
|
||||
}
|
||||
|
||||
// [ - previous chapter
|
||||
// [ - previous chapter (shift key required on T-Deck Pro)
|
||||
if (c == '[') {
|
||||
if (_metadata.chapterCount > 0 && _currentChapter > 0) {
|
||||
seekToChapter(_currentChapter - 1);
|
||||
@@ -1502,7 +1759,7 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// ] - next chapter
|
||||
// ] - next chapter (shift key required on T-Deck Pro)
|
||||
if (c == ']') {
|
||||
if (_metadata.chapterCount > 0 && _currentChapter < _metadata.chapterCount - 1) {
|
||||
seekToChapter(_currentChapter + 1);
|
||||
@@ -1511,6 +1768,28 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// N - next track in playlist (always, regardless of chapters)
|
||||
if (c == 'n') {
|
||||
if (_playlist.size() > 1 && _playlistIdx < (int)_playlist.size() - 1) {
|
||||
advanceTrack(1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Z - toggle 45-minute sleep timer
|
||||
if (c == 'z') {
|
||||
if (_sleepTimerActive) {
|
||||
_sleepTimerActive = false;
|
||||
Serial.println("AB: Sleep timer cancelled");
|
||||
} else {
|
||||
_sleepTimerActive = true;
|
||||
_sleepTimerEnd = millis() + (45UL * 60UL * 1000UL);
|
||||
Serial.println("AB: Sleep timer set for 45 minutes");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Q - handled by main.cpp (leave screen or close book depending on play state)
|
||||
// Not handled here - main.cpp intercepts Q before it reaches the player
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -55,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"
|
||||
@@ -126,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
|
||||
@@ -465,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 ---");
|
||||
@@ -824,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;
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
#include "ContactsScreen.h"
|
||||
#include "TextReaderScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#endif
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
@@ -89,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
|
||||
@@ -162,6 +169,7 @@ 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.
|
||||
@@ -177,6 +185,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
display.print(">>");
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
#endif
|
||||
|
||||
CayenneLPP sensors_lpp;
|
||||
int sensors_nb = 0;
|
||||
@@ -226,20 +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
|
||||
{
|
||||
@@ -290,18 +303,22 @@ public:
|
||||
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, 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, y, tmp);
|
||||
y += 18;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// Menu shortcuts - tinyfont monospaced grid
|
||||
y += 6;
|
||||
@@ -313,7 +330,11 @@ public:
|
||||
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
|
||||
@@ -364,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,
|
||||
@@ -371,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);
|
||||
@@ -420,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();
|
||||
@@ -550,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);
|
||||
@@ -610,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();
|
||||
@@ -618,6 +694,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
@@ -785,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;
|
||||
|
||||
@@ -839,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)
|
||||
@@ -862,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);
|
||||
@@ -885,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() {
|
||||
@@ -1020,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
|
||||
@@ -1130,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();
|
||||
@@ -1268,6 +1371,7 @@ void UITask::gotoOnboarding() {
|
||||
}
|
||||
|
||||
void UITask::gotoAudiobookPlayer() {
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
if (audiobook_screen == nullptr) return; // No audio hardware
|
||||
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
if (_display != NULL) {
|
||||
@@ -1279,6 +1383,7 @@ void UITask::gotoAudiobookPlayer() {
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint8_t UITask::getChannelScreenViewIdx() const {
|
||||
@@ -1333,6 +1438,7 @@ void UITask::onAdminCliResponse(const char* from_name, const char* text) {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
bool UITask::isAudioPlayingInBackground() const {
|
||||
if (!audiobook_screen) return false;
|
||||
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
@@ -1343,4 +1449,5 @@ 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;
|
||||
@@ -74,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;
|
||||
}
|
||||
@@ -101,9 +103,11 @@ public:
|
||||
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();
|
||||
@@ -137,7 +141,8 @@ public:
|
||||
|
||||
// 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,7 @@ build_flags =
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D CST328_PIN_RST=38
|
||||
-D FIRMWARE_VERSION='"Meck v0.8.8A"'
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.1A"'
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_TDeck_Pro>
|
||||
@@ -92,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>
|
||||
@@ -109,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}
|
||||
@@ -128,23 +160,3 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
[env:LilyGo_TDeck_Pro_repeater]
|
||||
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