Compare commits

...

17 Commits
v1.2 ... no-ble

Author SHA1 Message Date
pelgraine
15fcb08441 changed wording of light flash on off to make it slightly clearer 2026-02-17 20:12:04 +11:00
pelgraine
48863a13aa update firmware version and date 2026-02-17 19:59:02 +11:00
pelgraine
98addc5bd3 update uitasks to enable keyboard pulse light notification 2026-02-17 19:54:39 +11:00
pelgraine
a10ce4e7ca update settingscreen to enable keyboard pulse light 2026-02-17 19:51:36 +11:00
pelgraine
dcb59f2e33 update node prefs to enable keyboard pulse light 2026-02-17 19:50:31 +11:00
pelgraine
9e40dc7f30 updated firmware version and date 2026-02-17 18:59:00 +11:00
pelgraine
5ed1edc287 same changes as in audio branch - note to self, files are the same and are ready for branch merging 2026-02-17 18:58:09 +11:00
pelgraine
ba057c8aad 45 m sleep timer; track queing from sub folders; better wav file name data extraction; changed autorefresh while playing to 30 seconds 2026-02-16 18:39:24 +11:00
pelgraine
8e91327230 msgread fix and newmsg alert suppresion when in repeater admin login page 2026-02-16 17:57:50 +11:00
pelgraine
7444a34e5a amended firmware version to align with other env variants 2026-02-15 19:37:01 +11:00
pelgraine
17bad1ddf7 using different HAS_BQ27220 for renderbatteryindicator 2026-02-15 19:35:34 +11:00
pelgraine
a4c86d2d31 battery charge estimated runtime fix - 2 to 3 charge discharge cycles needed for full calibration to 1400mah 2026-02-15 09:31:43 +11:00
pelgraine
8dd661246a battery gauge page and fixes 2026-02-15 07:16:51 +11:00
pelgraine
f07032182e fixed errant connected home display 2026-02-15 05:35:02 +11:00
pelgraine
1d39d9f314 ui fix 2026-02-15 02:21:27 +11:00
pelgraine
a4285d2f24 bumped max contacts up to 700 2026-02-15 02:11:15 +11:00
pelgraine
fad548b995 update firmware version and date 2026-02-15 02:01:03 +11:00
11 changed files with 716 additions and 31 deletions

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "14 Feb 2026"
#define FIRMWARE_BUILD_DATE "17 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.8.8A"
#define FIRMWARE_VERSION "Meck v0.9.1A-NB"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)

View File

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

View File

@@ -1455,8 +1455,12 @@ void audio_info(const char *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 // LilyGo_TDeck_Pro

View File

@@ -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() {
@@ -1385,11 +1642,11 @@ public:
// E-ink refresh takes ~648ms during which audio.loop() can't run
// (SPI bus shared between display and SD card). This causes audible
// glitches during playback. Solution: during active playback, only
// auto-refresh once per minute (for time/progress updates). Key presses
// glitches during playback. Solution: during active playback, auto-refresh
// every 30 seconds for progress/time/sleep timer updates. Key presses
// always trigger immediate refresh regardless of this interval.
// When paused or stopped, refresh every 5s as normal.
if (_isPlaying && !_isPaused) return 60000; // 1 min between auto-refreshes
if (_isPlaying && !_isPaused) return 30000; // 30 sec between auto-refreshes
return 5000;
}
@@ -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

View File

@@ -30,7 +30,7 @@ private:
// Cached filtered contact indices for efficient scrolling
// We rebuild this on filter change or when entering the screen
static const int MAX_VISIBLE = 400; // matches MAX_CONTACTS build flag
static const int MAX_VISIBLE = 700; // must be >= MAX_CONTACTS build flag
uint16_t _filteredIdx[MAX_VISIBLE]; // indices into contact table
uint32_t _filteredTs[MAX_VISIBLE]; // cached last_advert_timestamp for sorting
int _filteredCount; // how many contacts match current filter
@@ -88,7 +88,7 @@ private:
}
}
// Sort by last_advert_timestamp descending (most recently seen first)
// Simple insertion sort — fine for up to 400 entries on ESP32
// Simple insertion sort - fine for up to 700 entries on ESP32
for (int i = 1; i < _filteredCount; i++) {
uint16_t tmpIdx = _filteredIdx[i];
uint32_t tmpTs = _filteredTs[i];

View File

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

View File

@@ -89,13 +89,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
@@ -226,12 +231,12 @@ 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
@@ -290,18 +295,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;
@@ -364,6 +373,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 +381,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 +431,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 +561,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 +673,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 +682,7 @@ public:
}
return true;
}
#endif
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
@@ -785,6 +850,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,7 +910,7 @@ switch(t){
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
if (msgcount == 0 && curr == msg_preview) {
gotoHomeScreen();
}
}
@@ -868,9 +939,12 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
#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 +959,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 +1102,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 +1220,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();

View File

@@ -32,6 +32,7 @@ class UITask : public AbstractUITask {
GenericVibration vibration;
#endif
unsigned long _next_refresh, _auto_off;
unsigned long _kb_flash_off_at; // Keyboard flash turn-off timer
NodePrefs* _node_prefs;
char _alert[80];
unsigned long _alert_expiry;
@@ -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;
}

View File

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

View File

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

View File

@@ -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>
@@ -131,6 +131,38 @@ lib_deps =
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
[env:LilyGo_TDeck_Pro_standalone]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=700
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=256
-D NO_OTA=1
-D FIRMWARE_VERSION='"Meck v0.9.1A-NB"'
; === NO BLE_PIN_CODE, NO WIFI_SSID ===
; By omitting these, the preprocessor selects ArduinoSerialInterface (USB only).
; The BLE stack is never initialized, never linked, and the ESP32-S3 BT/WiFi
; radio hardware stays in its boot-default OFF state — zero RF power draw.
; This is the ONLY reliable way to fully disable BLE on the ESP32-S3.
; Calling esp_bt_controller_disable() after init does NOT fully power off the radio.
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
; Audio libs included because AudiobookPlayerScreen.h unconditionally #includes
; Audio.h when LilyGo_TDeck_Pro is defined. No power cost — the Audio object is
; only heap-allocated when user presses 'P'. Without BLE competing for heap,
; audiobook playback actually works better in standalone mode.
[env:LilyGo_TDeck_Pro_repeater]
extends = LilyGo_TDeck_Pro
build_flags =