13 Commits
ota-1.3 ... dev

Author SHA1 Message Date
pelgraine
ce93cfa033 sd file manager ota system 2026-03-27 03:36:20 +11:00
pelgraine
2be399f65a undo accidental battery size change commit 2026-03-27 02:59:51 +11:00
pelgraine
5679cda38e tdpro touch paches - dialpad touch system conflict fix and longpress changed to 750ms 2026-03-27 02:43:06 +11:00
pelgraine
1ea883783c update firmware version for incoming ota file handler updates 2026-03-27 02:29:09 +11:00
pelgraine
bf8cf32bc2 speed up ble sync time; fix version in tdpro platformio 2026-03-27 01:56:17 +11:00
pelgraine
465a29bb23 fix bootindex method so ereader subdirectory files are recognised and pre-cache is completed properly 2026-03-27 00:58:02 +11:00
pelgraine
81eca29b69 implement meshcore PR 2151 changes 2026-03-27 00:43:10 +11:00
pelgraine
342cf4e745 tdpro large font pref option; various large font ui fixes; fix fcc recognition in t5s3 to match 1500 2026-03-26 15:34:09 +11:00
pelgraine
c52a190ace update build date 2026-03-26 00:56:20 +11:00
pelgraine
a7bc7a4733 t5s3 only lightsleep mode 2026-03-25 20:17:42 +11:00
pelgraine
47a0d2cc95 Update README.md
Made it really stupidly clear that this is vibecoded
2026-03-25 19:57:47 +11:00
pelgraine
5dda0b686e Incorporate PR 2044 and 2141; tdpro alarm screen - needs 44khz mp3 for sounds 2026-03-25 19:57:35 +11:00
pelgraine
60dcd6a89e tdpro - remove hint after boot for non-first time flash 2026-03-25 07:25:48 +11:00
31 changed files with 2992 additions and 368 deletions

View File

@@ -1,6 +1,6 @@
## Meshcore + Fork = Meck
A fork created specifically to focus on enabling BLE & WiFi companion firmware for the LilyGo T-Deck Pro & LilyGo T5 E-Paper S3 Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
A fork created specifically to focus on enabling BLE & WiFi companion firmware for the LilyGo T-Deck Pro & LilyGo T5 E-Paper S3 Pro. Created wholly with Claude AI using Meshcore v1.11 code. 100% vibecoded.
[Check out the Meck discussion channel on the MeshCore Discord](https://discord.com/channels/1343693475589263471/1460136499390447670)

View File

@@ -268,10 +268,26 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
if (file.read((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)) != sizeof(_prefs.auto_lock_minutes)) {
_prefs.auto_lock_minutes = 0; // default: disabled
}
if (file.read((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)) != sizeof(_prefs.hint_shown)) {
_prefs.hint_shown = 0; // default: show boot hint
}
if (file.read((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)) != sizeof(_prefs.large_font)) {
_prefs.large_font = 0; // default: tiny font
}
if (file.read((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)) != sizeof(_prefs.tx_fail_reset_threshold)) {
_prefs.tx_fail_reset_threshold = 3; // default: 3
}
if (file.read((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)) != sizeof(_prefs.rx_fail_reboot_threshold)) {
_prefs.rx_fail_reboot_threshold = 3; // default: 3
}
// Clamp to valid ranges
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
if (_prefs.large_font > 1) _prefs.large_font = 0;
if (_prefs.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_threshold = 3;
if (_prefs.rx_fail_reboot_threshold > 10) _prefs.rx_fail_reboot_threshold = 3;
// auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30)
{
uint8_t alm = _prefs.auto_lock_minutes;
@@ -324,6 +340,10 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)); // 98
file.write((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)); // 99
file.write((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)); // 100
file.write((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)); // 101
file.write((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)); // 102
file.write((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)); // 103
file.write((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)); // 104
file.close();
}

View File

@@ -264,6 +264,16 @@ int MyMesh::getInterferenceThreshold() const {
return _prefs.interference_threshold;
}
uint8_t MyMesh::getTxFailResetThreshold() const {
return _prefs.tx_fail_reset_threshold;
}
uint8_t MyMesh::getRxFailRebootThreshold() const {
return _prefs.rx_fail_reboot_threshold;
}
void MyMesh::onRxUnrecoverable() {
board.reboot();
}
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
if (_prefs.rx_delay_base <= 0.0f) return 0;
return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time);
@@ -560,12 +570,12 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
recipient.name, delay_millis, _prefs.path_hash_mode, _prefs.path_hash_mode + 1);
// TODO: dynamic send_scope, depending on recipient and current 'home' Region
if (send_scope.isNull()) {
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, delay_millis, getPathHashSize());
} else {
uint16_t codes[2];
codes[0] = send_scope.calcTransportCode(pkt);
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, codes, delay_millis, getPathHashSize());
}
}
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
@@ -582,12 +592,12 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
// TODO: have per-channel send_scope
if (send_scope.isNull()) {
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, delay_millis, getPathHashSize());
} else {
uint16_t codes[2];
codes[0] = send_scope.calcTransportCode(pkt);
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, codes, delay_millis, getPathHashSize());
}
}
@@ -1490,7 +1500,7 @@ void MyMesh::handleCmdFrame(size_t len) {
if (pkt) {
if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
unsigned long delay_millis = 0;
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, delay_millis, getPathHashSize());
} else {
sendZeroHop(pkt);
}
@@ -2255,6 +2265,10 @@ void MyMesh::checkCLIRescueCmd() {
Serial.printf(" > %d\n", _prefs.multi_acks);
} else if (strcmp(key, "int.thresh") == 0) {
Serial.printf(" > %d\n", _prefs.interference_threshold);
} else if (strcmp(key, "tx.fail.threshold") == 0) {
Serial.printf(" > %d\n", _prefs.tx_fail_reset_threshold);
} else if (strcmp(key, "rx.fail.threshold") == 0) {
Serial.printf(" > %d\n", _prefs.rx_fail_reboot_threshold);
} else if (strcmp(key, "gps.baud") == 0) {
uint32_t effective = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
Serial.printf(" > %lu (effective: %lu)\n",
@@ -2315,6 +2329,8 @@ void MyMesh::checkCLIRescueCmd() {
Serial.printf(" af: %.1f\n", _prefs.airtime_factor);
Serial.printf(" multi.acks: %d\n", _prefs.multi_acks);
Serial.printf(" int.thresh: %d\n", _prefs.interference_threshold);
Serial.printf(" tx.fail: %d\n", _prefs.tx_fail_reset_threshold);
Serial.printf(" rx.fail: %d\n", _prefs.rx_fail_reboot_threshold);
{
uint32_t eff_baud = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
Serial.printf(" gps.baud: %lu\n", (unsigned long)eff_baud);
@@ -2710,6 +2726,30 @@ void MyMesh::checkCLIRescueCmd() {
Serial.println(" Error: use 0 (disabled) or 14+ (typical: 14)");
}
} else if (memcmp(config, "tx.fail.threshold ", 18) == 0) {
int val = atoi(&config[18]);
if (val < 0) val = 0;
if (val > 10) val = 10;
_prefs.tx_fail_reset_threshold = (uint8_t)val;
savePrefs();
if (val == 0) {
Serial.println(" > tx fail reset disabled");
} else {
Serial.printf(" > tx fail reset after %d failures\n", val);
}
} else if (memcmp(config, "rx.fail.threshold ", 18) == 0) {
int val = atoi(&config[18]);
if (val < 0) val = 0;
if (val > 10) val = 10;
_prefs.rx_fail_reboot_threshold = (uint8_t)val;
savePrefs();
if (val == 0) {
Serial.println(" > rx fail reboot disabled");
} else {
Serial.printf(" > reboot after %d rx recovery failures\n", val);
}
} else if (memcmp(config, "gps.baud ", 9) == 0) {
uint32_t val = (uint32_t)atol(&config[9]);
if (val == 0 || val == 4800 || val == 9600 || val == 19200 ||
@@ -2807,6 +2847,8 @@ void MyMesh::checkCLIRescueCmd() {
Serial.println(" af <0-9> Airtime factor");
Serial.println(" multi.acks <0|1> Redundant ACKs (default: 1)");
Serial.println(" int.thresh <0|14+> Interference threshold dB (0=off, 14=typical)");
Serial.println(" tx.fail.threshold <0-10> TX fail radio reset (0=off, default 3)");
Serial.println(" rx.fail.threshold <0-10> RX stuck reboot (0=off, default 3)");
Serial.println(" gps.baud <rate> GPS baud (0=default, reboot to apply)");
Serial.println("");
Serial.println(" Clock:");

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 10
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "23 March 2026"
#define FIRMWARE_BUILD_DATE "27 March 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v1.3"
#define FIRMWARE_VERSION "Meck v1.5"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -143,6 +143,9 @@ public:
protected:
float getAirtimeBudgetFactor() const override;
int getInterferenceThreshold() const override;
uint8_t getTxFailResetThreshold() const override;
uint8_t getRxFailRebootThreshold() const override;
void onRxUnrecoverable() override;
int calcRxDelay(float score, uint32_t air_time) const override;
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
@@ -150,6 +153,7 @@ protected:
uint8_t getAutoAddMaxHops() const override;
bool filterRecvFloodPacket(mesh::Packet* packet) override;
uint8_t getPathHashSize() const override { return _prefs.path_hash_mode + 1; }
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;

View File

@@ -39,4 +39,41 @@ struct NodePrefs { // persisted to file
uint8_t portrait_mode; // 0=landscape, 1=portrait — T5S3 only
uint8_t auto_lock_minutes; // 0=disabled, 2/5/10/15/30=auto-lock after idle
uint8_t hint_shown; // 0=show nav hint on boot, 1=already shown (dismiss permanently)
uint8_t large_font; // 0=tiny (built-in 6x8), 1=larger (FreeSans9pt) — T-Deck Pro only
uint8_t tx_fail_reset_threshold; // 0=disabled, 1-10, default 3
uint8_t rx_fail_reboot_threshold; // 0=disabled, 1-10, default 3
// --- Font helpers (inline, no overhead) ---
// Returns the DisplayDriver text-size index for "small/body" text.
// T-Deck Pro: 0 = built-in 6×8, 1 = FreeSans9pt.
// T5S3: both 0 and 1 are 12pt fonts (regular vs bold) with identical line
// height, so large_font has no layout effect there.
inline uint8_t smallTextSize() const {
return large_font ? 1 : 0;
}
// Returns the virtual-coordinate line height matching smallTextSize().
// T-Deck Pro size 0 → 9 (6×8 + 1px gap), size 1 → 11 (9pt ascent+descent).
// T5S3 size 0/1 → same 12pt height → always 9 in virtual coords.
inline int smallLineH() const {
#if defined(LilyGo_T5S3_EPaper_Pro)
return 9;
#else
return large_font ? 11 : 9;
#endif
}
// Returns the Y offset for selection highlight fillRect (T-Deck Pro only).
// Size 0 (built-in font): cursor positions at top-left, +5 offset in
// setCursor places text below → fillRect at y+5 aligns with text.
// Size 1 (FreeSans9pt): cursor positions at baseline, ascenders render
// upward → fillRect must start above baseline to cover ascenders.
// T5S3: always 0 (both sizes use baseline fonts with highlight at y).
inline int smallHighlightOff() const {
#if defined(LilyGo_T5S3_EPaper_Pro)
return 0;
#else
return large_font ? -2 : 5;
#endif
}
};

View File

@@ -489,7 +489,7 @@
static int16_t touchLastX = 0;
static int16_t touchLastY = 0;
static unsigned long lastTouchSeenMs = 0;
#define TOUCH_LONG_PRESS_MS 500
#define TOUCH_LONG_PRESS_MS 750
#if defined(LilyGo_T5S3_EPaper_Pro)
#define TOUCH_SWIPE_THRESHOLD 60 // T5S3: 960×540 — 60px ≈ 6% of width
#else
@@ -931,6 +931,12 @@ static void lastHeardToggleContact() {
return KEY_ENTER; // Editing mode or header/footer tap
}
// SMS screen: dedicated dialer/touch handler runs separately (HAS_4G_MODEM block)
// Return 0 so the general handler doesn't inject spurious keys
#ifdef HAS_4G_MODEM
if (ui_task.isOnSMSScreen()) return 0;
#endif
// All other screens: tap = select
return KEY_ENTER;
}
@@ -939,6 +945,11 @@ static void lastHeardToggleContact() {
static char mapTouchSwipe(int16_t dx, int16_t dy) {
bool horizontal = abs(dx) > abs(dy);
// SMS screen: dedicated touch handler covers all interaction
#ifdef HAS_4G_MODEM
if (ui_task.isOnSMSScreen()) return 0;
#endif
// Reader (reading mode): swipe left/right for page turn
if (ui_task.isOnTextReader()) {
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
@@ -1002,6 +1013,11 @@ static void lastHeardToggleContact() {
// Map a long press to a key
static char mapTouchLongPress(int16_t x, int16_t y) {
// SMS screen: dedicated touch handler covers all interaction
#ifdef HAS_4G_MODEM
if (ui_task.isOnSMSScreen()) return 0;
#endif
// Home screen: long press = activate current page action
// (BLE toggle, send advert, hibernate, GPS toggle, etc.)
if (ui_task.isOnHomeScreen()) {
@@ -1739,19 +1755,6 @@ void setup() {
ui_task.gotoOnboarding();
// Show hint immediately overlaid on the onboarding screen
if (!prefs->hint_shown) ui_task.showBootHint(true);
} else if (!prefs->hint_shown) {
// Not a first-time flash (has a name), but hint never dismissed yet
// Deferred — will activate after splash screen transitions to home
ui_task.showBootHint(false);
}
}
#endif
#if defined(LilyGo_T5S3_EPaper_Pro)
{
NodePrefs* prefs = the_mesh.getNodePrefs();
if (!prefs->hint_shown) {
ui_task.showBootHint(false); // Deferred — after splash
}
}
#endif
@@ -1784,6 +1787,19 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
#endif
// Alarm clock: create at boot so config is loaded, background alarm check
// works from first loop(), and the bell indicator is visible immediately.
// Audio object is NOT created here — lazy-init when alarm fires or user opens player.
#ifdef MECK_AUDIO_VARIANT
{
AlarmScreen* alarmScr = new AlarmScreen(&ui_task);
alarmScr->setSDReady(sdCardReady);
// Audio pointer set later when needed (fireAlarm or 'k'/'p' key)
ui_task.setAlarmScreen(alarmScr);
Serial.printf("ALARM: Boot init, %d alarms enabled\n", alarmScr->enabledCount());
}
#endif
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
@@ -1819,7 +1835,7 @@ void loop() {
the_mesh.loop();
#ifdef MECK_OTA_UPDATE
} else {
// OTA active — poll the web server from the main loop for fast response.
// OTA/File Manager active — poll the web server from the main loop for fast response.
// The render cycle on T5S3 (960×540 FastEPD) can block for 500ms+ during
// e-ink refresh, causing the browser to timeout before handleClient() runs.
// Polling here gives us ~1-5ms response time instead.
@@ -1920,6 +1936,61 @@ void loop() {
}
#endif
// Alarm clock: background alarm check + audio tick
#if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT)
{
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
if (alarmScr) {
// Service alarm audio decode (like audiobook audioTick)
alarmScr->alarmAudioTick();
if (alarmScr->isAlarmAudioActive()) {
cpuPower.setBoost();
}
// Periodic alarm check (~every 10 seconds)
static unsigned long lastAlarmCheck = 0;
if (millis() - lastAlarmCheck > ALARM_CHECK_INTERVAL_MS) {
lastAlarmCheck = millis();
uint32_t rtcNow = the_mesh.getRTCClock()->getCurrentTime();
int fireSlot = alarmScr->checkAlarms(rtcNow, the_mesh.getNodePrefs()->utc_offset_hours);
if (fireSlot >= 0 && !alarmScr->isRinging()) {
// If audiobook is playing, the alarm will take over the shared Audio*
// object. The audiobook auto-saves bookmarks every 30s, so at most
// 30s of position is lost. User can resume from audiobook player after.
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
if (abPlayer && abPlayer->isAudioActive()) {
Serial.println("ALARM: Audiobook active — alarm taking over Audio");
}
// Ensure Audio object is shared
if (!audio) audio = new Audio();
alarmScr->setAudio(audio);
// Fire the alarm
alarmScr->fireAlarm(fireSlot);
alarmScr->setLastFiredEpoch(fireSlot, rtcNow);
// Let audio buffer fill before e-ink refresh blocks SPI
for (int i = 0; i < 50; i++) {
alarmScr->alarmAudioTick();
delay(2);
}
// Switch UI to alarm screen (ringing mode)
ui_task.gotoAlarmScreen();
// Wake display if asleep
ui_task.keepAlive();
ui_task.forceRefresh();
Serial.printf("ALARM: Fired slot %d, switched to ringing screen\n", fireSlot);
}
}
}
}
#endif
// SMS: poll for incoming messages from modem
#ifdef HAS_4G_MODEM
{
@@ -2123,7 +2194,7 @@ void loop() {
// Gestures:
// Tap = finger down + up with minimal movement → select/open
// Swipe = finger drag > threshold → scroll/page turn
// Long press = finger held > 500ms without moving → edit/enter
// Long press = finger held > 750ms without moving → edit/enter
// After processing an event, cooldown waits for finger lift before next event.
// Touch is disabled while lock screen is active.
// When virtual keyboard is active (T5S3), taps route to keyboard.
@@ -2135,6 +2206,15 @@ void loop() {
#if defined(LilyGo_T5S3_EPaper_Pro)
touchBlocked = touchBlocked || ui_task.isVKBActive();
#endif
#ifdef HAS_4G_MODEM
// SMS dialer has its own dedicated touch handler — don't consume touch data here
if (smsMode) {
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr && smsScr->getSubView() == SMSScreen::PHONE_DIALER) {
touchBlocked = true;
}
}
#endif
if (!touchBlocked)
{
@@ -2518,6 +2598,23 @@ void handleKeyboardInput() {
Serial.printf("handleKeyboardInput: key='%c' (0x%02X) composeMode=%d\n",
key >= 32 ? key : '?', key, composeMode);
// Alarm ringing: ANY key dismisses (highest priority after lock screen)
#ifdef MECK_AUDIO_VARIANT
{
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
if (alarmScr && alarmScr->isRinging()) {
if (key == 'z') {
alarmScr->handleInput('z'); // Snooze
} else {
alarmScr->dismiss(); // Any other key = dismiss
}
ui_task.gotoHomeScreen();
ui_task.forceRefresh();
return; // Consume the key
}
}
#endif
if (composeMode) {
// Emoji picker sub-mode
if (emojiPickerMode) {
@@ -2629,9 +2726,28 @@ void handleKeyboardInput() {
// A/D keys switch channels (only when buffer is empty, not in DM mode)
if ((key == 'a') && composePos == 0 && !composeDM) {
// Previous channel
// Previous channel — skip gaps
if (composeChannelIdx > 0) {
composeChannelIdx--;
bool found = false;
for (uint8_t prev = composeChannelIdx - 1; ; prev--) {
ChannelDetails ch;
if (the_mesh.getChannel(prev, ch) && ch.name[0] != '\0') {
composeChannelIdx = prev;
found = true;
break;
}
if (prev == 0) break;
}
if (!found) {
// Wrap to last valid channel
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
composeChannelIdx = i;
break;
}
}
}
} else {
// Wrap to last valid channel
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
@@ -2648,12 +2764,17 @@ void handleKeyboardInput() {
}
if ((key == 'd') && composePos == 0 && !composeDM) {
// Next channel
ChannelDetails ch;
uint8_t nextIdx = composeChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
composeChannelIdx = nextIdx;
} else {
// Next channel — skip gaps
bool found = false;
for (uint8_t next = composeChannelIdx + 1; next < MAX_GROUP_CHANNELS; next++) {
ChannelDetails ch;
if (the_mesh.getChannel(next, ch) && ch.name[0] != '\0') {
composeChannelIdx = next;
found = true;
break;
}
}
if (!found) {
composeChannelIdx = 0; // Wrap to first channel
}
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
@@ -3101,7 +3222,7 @@ void handleKeyboardInput() {
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);
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio, the_mesh.getNodePrefs());
abScreen->setSDReady(sdCardReady);
ui_task.setAudiobookScreen(abScreen);
Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap());
@@ -3110,6 +3231,23 @@ void handleKeyboardInput() {
break;
#endif
#ifdef MECK_AUDIO_VARIANT
case 'k':
// Open alarm clock (screen created at boot; just ensure Audio* is available)
Serial.println("Opening alarm clock");
if (!audio) {
Serial.printf("Alarm: lazy init Audio - free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
audio = new Audio();
}
{
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
if (alarmScr) alarmScr->setAudio(audio);
}
ui_task.gotoAlarmScreen();
break;
#endif
#ifdef HAS_4G_MODEM
case 't':
// Open SMS (4G variant only)
@@ -3214,6 +3352,9 @@ void handleKeyboardInput() {
|| ui_task.isOnWebReader()
#endif
|| ui_task.isOnMapScreen()
#ifdef MECK_AUDIO_VARIANT
|| ui_task.isOnAlarmScreen()
#endif
) {
ui_task.injectKey('s'); // Pass directly for scrolling
} else {
@@ -3230,6 +3371,9 @@ void handleKeyboardInput() {
|| ui_task.isOnWebReader()
#endif
|| ui_task.isOnMapScreen()
#ifdef MECK_AUDIO_VARIANT
|| ui_task.isOnAlarmScreen()
#endif
) {
ui_task.injectKey('w'); // Pass directly for scrolling
} else {
@@ -3240,7 +3384,11 @@ void handleKeyboardInput() {
case 'a':
// Navigate left or switch channel (on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) {
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()
#ifdef MECK_AUDIO_VARIANT
|| ui_task.isOnAlarmScreen()
#endif
) {
ui_task.injectKey('a'); // Pass directly for channel/contacts switching
} else {
Serial.println("Nav: Previous");
@@ -3250,7 +3398,11 @@ void handleKeyboardInput() {
case 'd':
// Navigate right or switch channel (on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) {
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()
#ifdef MECK_AUDIO_VARIANT
|| ui_task.isOnAlarmScreen()
#endif
) {
ui_task.injectKey('d'); // Pass directly for channel/contacts switching
} else {
Serial.println("Nav: Next");
@@ -3528,6 +3680,24 @@ void handleKeyboardInput() {
ui_task.gotoContactsScreen();
break;
}
// Alarm screen: Q/backspace routing depends on sub-mode
#ifdef MECK_AUDIO_VARIANT
if (ui_task.isOnAlarmScreen()) {
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
if (alarmScr && alarmScr->isRinging()) {
alarmScr->dismiss();
ui_task.gotoHomeScreen();
} else if (alarmScr && alarmScr->getMode() != AlarmScreen::ALARM_LIST) {
// In edit/picker/digit mode — pass to screen (Q = back to list, backspace = delete)
ui_task.injectKey(key);
} else {
// On alarm list — go home
Serial.println("Nav: Alarm -> Home");
ui_task.gotoHomeScreen();
}
break;
}
#endif
// Last Heard: Q goes back to home
if (ui_task.isOnLastHeardScreen()) {
Serial.println("Nav: Last Heard -> Home");
@@ -3570,6 +3740,13 @@ void handleKeyboardInput() {
ui_task.injectKey(key);
break;
}
#ifdef MECK_AUDIO_VARIANT
// Pass unhandled keys to alarm screen (digits for time entry, o for toggle)
if (ui_task.isOnAlarmScreen()) {
ui_task.injectKey(key);
break;
}
#endif
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
break;
}

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,8 @@
// JPEG decoder for cover art — JPEGDEC by bitbank2
#include <JPEGDEC.h>
#include "../NodePrefs.h"
// Forward declarations
class UITask;
@@ -151,6 +153,7 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
Audio* _audio;
Mode _mode;
bool _sdReady;
@@ -1193,10 +1196,10 @@ private:
}
// Switch to tiny font for file list (6x8 built-in)
display.setTextSize(0);
display.setTextSize(_prefs ? _prefs->smallTextSize() : 0);
// Calculate visible items — tiny font uses ~8 virtual units per line
int itemHeight = 8;
// Calculate visible items
int itemHeight = (_prefs ? _prefs->smallLineH() : 9) - 1;
int listTop = 13;
int listBottom = display.height() - 14; // Reserve footer space
int visibleItems = (listBottom - listTop) / itemHeight;
@@ -1208,7 +1211,7 @@ private:
_scrollOffset = _selectedFile - visibleItems + 1;
}
// Approx chars that fit in tiny font (~36 on 128 virtual width)
// Approx chars for suffix/type tag sizing (still needed for type tag assembly)
const int charsPerLine = 36;
// Draw file list
@@ -1218,9 +1221,7 @@ private:
if (fileIdx == _selectedFile) {
display.setColor(DisplayDriver::LIGHT);
// setCursor adds +5 to y internally, but fillRect does not.
// Offset fillRect by +5 to align highlight bar with text.
display.fillRect(0, y + 5, display.width(), itemHeight - 1);
display.fillRect(0, y + (_prefs ? _prefs->smallHighlightOff() : 5), display.width(), itemHeight - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -1231,29 +1232,15 @@ private:
char fullLine[96];
if (fe.isDir) {
// Directory entry: show as "/ FolderName" or just ".."
if (fe.name == "..") {
snprintf(fullLine, sizeof(fullLine), ".. (up)");
} else {
snprintf(fullLine, sizeof(fullLine), "/%s", fe.name.c_str());
// Truncate if needed
if ((int)strlen(fullLine) > charsPerLine - 1) {
fullLine[charsPerLine - 4] = '.';
fullLine[charsPerLine - 3] = '.';
fullLine[charsPerLine - 2] = '.';
fullLine[charsPerLine - 1] = '\0';
}
}
} else {
// Audio file: "Title - Author [TYPE]"
char lineBuf[80];
// Reserve space for type tag and bookmark indicator
int suffixLen = fe.fileType.length() + 3; // " [M4B]" or " [MP3]"
int bmkLen = fe.hasBookmark ? 2 : 0; // " >"
int availChars = charsPerLine - suffixLen - bmkLen;
if (availChars < 10) availChars = 10;
if (fe.displayAuthor.length() > 0) {
snprintf(lineBuf, sizeof(lineBuf), "%s - %s",
fe.displayTitle.c_str(), fe.displayAuthor.c_str());
@@ -1261,24 +1248,13 @@ private:
snprintf(lineBuf, sizeof(lineBuf), "%s", fe.displayTitle.c_str());
}
// Truncate with ellipsis if needed
if ((int)strlen(lineBuf) > availChars) {
if (availChars > 3) {
lineBuf[availChars - 3] = '.';
lineBuf[availChars - 2] = '.';
lineBuf[availChars - 1] = '.';
lineBuf[availChars] = '\0';
} else {
lineBuf[availChars] = '\0';
}
}
// Append file type tag
snprintf(fullLine, sizeof(fullLine), "%s [%s]", lineBuf, fe.fileType.c_str());
}
display.setCursor(2, y);
display.print(fullLine);
// Pixel-aware ellipsis — reserve space for bookmark indicator
int reserveRight = (!fe.isDir && fe.hasBookmark) ? 10 : 2;
display.drawTextEllipsized(2, y, display.width() - reserveRight, fullLine);
// Bookmark indicator (right-aligned, files only)
if (!fe.isDir && fe.hasBookmark) {
@@ -1464,8 +1440,8 @@ private:
}
public:
AudiobookPlayerScreen(UITask* task, Audio* audio)
: _task(task), _audio(audio), _mode(FILE_LIST),
AudiobookPlayerScreen(UITask* task, Audio* audio, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _audio(audio), _mode(FILE_LIST),
_sdReady(false), _i2sInitialized(false), _dacPowered(false),
_displayRef(nullptr),
_selectedFile(0), _scrollOffset(0),

View File

@@ -637,8 +637,8 @@ public:
}
// Render inbox list
display.setTextSize(0);
int lineH = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineH = the_mesh.getNodePrefs()->smallLineH();
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
@@ -672,7 +672,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + 5, display.width(), lineH);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -745,8 +745,8 @@ public:
// --- Path detail overlay ---
if (_showPathOverlay) {
display.setTextSize(0);
int lineH = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineH = the_mesh.getNodePrefs()->smallLineH();
int y = 14;
ChannelMessage* msg = getNewestReceivedMsg();
@@ -942,7 +942,7 @@ public:
}
if (channelMsgCount == 0) {
display.setTextSize(0); // Tiny font for body text
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for body text
display.setCursor(0, 20);
display.setColor(DisplayDriver::LIGHT);
if (_viewChannelIdx == 0xFF) {
@@ -975,8 +975,8 @@ public:
// =================================================================
// DM Inbox: list of contacts/rooms you have DM history with
// =================================================================
display.setTextSize(0);
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
@@ -1056,7 +1056,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -1094,8 +1094,8 @@ public:
}
display.setTextSize(1);
} else {
display.setTextSize(0); // Tiny font for message body
int lineHeight = 9; // 8px font + 1px spacing
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for message body
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px spacing
int headerHeight = 14;
int footerHeight = 14;
int scrollBarW = 4; // Width of scroll indicator on right edge
@@ -1163,7 +1163,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, maxFillH);
#else
display.fillRect(0, y + 5, contentW, maxFillH);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH);
#endif
}
@@ -1324,7 +1324,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, maxFillH - usedH);
#else
display.fillRect(0, y + 5, contentW, maxFillH - usedH);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH - usedH);
#endif
}
}
@@ -1646,7 +1646,26 @@ public:
}
}
} else if (_viewChannelIdx > 0) {
_viewChannelIdx--;
// Skip backwards over any empty/gap slots
uint8_t prev = _viewChannelIdx - 1;
bool found = false;
while (true) {
ChannelDetails ch;
if (the_mesh.getChannel(prev, ch) && ch.name[0] != '\0') {
_viewChannelIdx = prev;
found = true;
break;
}
if (prev == 0) break;
prev--;
}
if (!found) {
// No valid channel below → wrap to DM tab
_viewChannelIdx = 0xFF;
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
}
} else {
// Channel 0 → wrap to DM tab
_viewChannelIdx = 0xFF;
@@ -1667,11 +1686,17 @@ public:
// DM tab → wrap to channel 0
_viewChannelIdx = 0;
} else {
ChannelDetails ch;
uint8_t nextIdx = _viewChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
_viewChannelIdx = nextIdx;
} else {
// Skip forward over any empty/gap slots
bool found = false;
for (uint8_t next = _viewChannelIdx + 1; next < MAX_GROUP_CHANNELS; next++) {
ChannelDetails ch;
if (the_mesh.getChannel(next, ch) && ch.name[0] != '\0') {
_viewChannelIdx = next;
found = true;
break;
}
}
if (!found) {
// Past last channel → go to DM tab
_viewChannelIdx = 0xFF;
_dmInboxMode = true;

View File

@@ -162,11 +162,11 @@ public:
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_filteredCount == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
@@ -235,8 +235,8 @@ public:
display.drawRect(0, 11, display.width(), 1);
// === Body - contact rows ===
display.setTextSize(0); // tiny font for compact rows
int lineHeight = 9; // 8px font + 1px gap
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px gap
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
@@ -275,7 +275,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {

View File

@@ -49,11 +49,11 @@ public:
int selectRowAtVY(int vy) {
int count = the_mesh.getDiscoveredCount();
if (count == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
@@ -91,8 +91,8 @@ public:
display.drawRect(0, 11, display.width(), 1);
// === Body — discovered node rows ===
display.setTextSize(0); // tiny font for compact rows
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
@@ -129,7 +129,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {

View File

@@ -68,11 +68,11 @@ public:
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_count == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
@@ -117,8 +117,8 @@ public:
display.drawRect(0, 11, display.width(), 1);
// === Body — node rows ===
display.setTextSize(0);
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
@@ -147,7 +147,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {

View File

@@ -5,6 +5,7 @@
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#include "../NodePrefs.h"
// Forward declarations
class UITask;
@@ -52,9 +53,11 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
Mode _mode;
bool _sdReady;
bool _initialized;
uint8_t _lastFontPref;
DisplayDriver* _display;
// Display layout (calculated once from display metrics)
@@ -518,8 +521,8 @@ private:
display.drawRect(0, 11, display.width(), 1);
// File list with "+ New Note" at index 0
display.setTextSize(0);
int listLineH = 9; // Match contacts/discovery for consistent selection highlight
display.setTextSize(_prefs->smallTextSize());
int listLineH = _prefs->smallLineH();
int startY = 14;
int totalItems = 1 + (int)_fileList.size();
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
@@ -539,27 +542,21 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), listLineH);
#else
display.fillRect(0, y + 5, display.width(), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
if (i == 0) {
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
display.print(selected ? "> + New Note" : " + New Note");
display.drawTextEllipsized(0, y, display.width() - 4,
selected ? "> + New Note" : " + New Note");
} else {
String line = selected ? "> " : " ";
String name = _fileList[i - 1];
int maxLen = _charsPerLine - 4;
if ((int)name.length() > maxLen) {
name = name.substring(0, maxLen - 3) + "...";
}
line += name;
display.print(line.c_str());
line += _fileList[i - 1];
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
}
y += listLineH;
}
@@ -605,7 +602,7 @@ private:
}
// Render current page using tiny font
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
int pageStart = _pageOffsets[_currentPage];
@@ -722,7 +719,7 @@ private:
int textAreaTop = 14;
int textAreaBottom = display.height() - 16;
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
// Find cursor line
int cursorLine = lineForPos(_cursorPos);
@@ -771,7 +768,7 @@ private:
// If buffer is empty, show cursor at top
if (_bufLen == 0) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, textAreaTop);
display.print("|");
@@ -829,7 +826,7 @@ private:
display.setCursor(0, 20);
display.setColor(DisplayDriver::LIGHT);
display.print("From: ");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
String origDisplay = _renameOriginal;
if (origDisplay.length() > 30) origDisplay = origDisplay.substring(0, 27) + "...";
display.print(origDisplay.c_str());
@@ -840,7 +837,7 @@ private:
display.setColor(DisplayDriver::LIGHT);
display.print("To: ");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
char displayName[NOTES_RENAME_MAX + 2];
snprintf(displayName, sizeof(displayName), "%s|", _renameBuf);
@@ -880,7 +877,7 @@ private:
display.setCursor(0, 25);
display.print("File:");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setCursor(0, 38);
String nameDisplay = _deleteTarget;
if (nameDisplay.length() > 35) nameDisplay = nameDisplay.substring(0, 32) + "...";
@@ -1096,9 +1093,9 @@ private:
}
public:
NotesScreen(UITask* task)
: _task(task), _mode(FILE_LIST),
_sdReady(false), _initialized(false), _display(nullptr),
NotesScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _mode(FILE_LIST),
_sdReady(false), _initialized(false), _lastFontPref(0), _display(nullptr),
_charsPerLine(38), _linesPerPage(22), _lineHeight(5), _footerHeight(14),
_editCharsPerLine(20), _editLineHeight(12), _editMaxLines(8),
_selectedFile(0), _buf(nullptr), _bufLen(0), _cursorPos(0),
@@ -1133,15 +1130,31 @@ public:
// ---- Layout Init ----
void initLayout(DisplayDriver& display) {
// Re-init if font preference changed since last layout
uint8_t curFont = _prefs ? _prefs->large_font : 0;
if (_initialized && curFont != _lastFontPref) {
_initialized = false;
Serial.println("Notes: font changed, recalculating layout");
}
if (_initialized) return;
_lastFontPref = curFont;
_display = &display;
// Tiny font metrics (for read mode)
display.setTextSize(0);
// Font metrics (for read mode)
display.setTextSize(_prefs->smallTextSize());
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
if (tenCharsW > 0) {
_charsPerLine = (display.width() * 10) / tenCharsW;
}
// Proportional font: use average-width measurement instead of M-width
if (_prefs && _prefs->large_font) {
const char* sample = "the quick brown fox jumps over lazy dog";
uint16_t sampleW = display.getTextWidth(sample);
int sampleLen = strlen(sample);
if (sampleW > 0 && sampleLen > 0) {
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
}
}
if (_charsPerLine < 15) _charsPerLine = 15;
if (_charsPerLine > 60) _charsPerLine = 60;
@@ -1151,6 +1164,10 @@ public:
} else {
_lineHeight = 5;
}
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
if (_prefs && _prefs->large_font) {
_lineHeight = _prefs->smallLineH();
}
_footerHeight = 14;
int textAreaHeight = display.height() - _footerHeight;

View File

@@ -777,8 +777,8 @@ private:
// =====================================================================
void renderCategoryMenu(DisplayDriver& display, int y, int bodyHeight) {
display.setTextSize(0);
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
// Clock drift info line
if (_serverTime > 0) {
@@ -862,8 +862,8 @@ private:
// =====================================================================
void renderCommandMenu(DisplayDriver& display, int y, int bodyHeight) {
display.setTextSize(0);
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
const AdminCategoryDef& cat = CATEGORIES[_catSel];
// Category title
@@ -1025,7 +1025,7 @@ private:
if (_pendingCmd) display.print(_pendingCmd->label);
y += 14;
display.setTextSize(0);
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
display.setCursor(0, y);
// Show the param value if one was collected
@@ -1033,7 +1033,7 @@ private:
char preview[80];
snprintf(preview, sizeof(preview), "Value: %s", _paramBuf);
display.print(preview);
y += 10;
y += the_mesh.getNodePrefs()->smallLineH() + 1;
display.setCursor(0, y);
}
@@ -1071,8 +1071,8 @@ private:
// =====================================================================
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
display.setTextSize(0);
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
@@ -1166,7 +1166,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else if (warn) {

View File

@@ -36,6 +36,7 @@
#include "ModemManager.h"
#include "SMSStore.h"
#include "SMSContacts.h"
#include "../NodePrefs.h"
// Limits
#define SMS_INBOX_PAGE_SIZE 4
@@ -51,6 +52,7 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
SubView _view;
// App menu state
@@ -117,8 +119,8 @@ private:
}
public:
SMSScreen(UITask* task)
: _task(task), _view(APP_MENU)
SMSScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _view(APP_MENU)
, _menuCursor(0)
, _convCount(0), _inboxCursor(0), _inboxScrollTop(0)
, _msgCount(0), _msgScrollPos(0)
@@ -276,7 +278,7 @@ public:
// Show modem state text if not ready
if (ms != ModemState::READY && ms != ModemState::SENDING_SMS) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::YELLOW);
const char* label = ModemManager::stateToString(ms);
uint16_t labelW = display.getTextWidth(label);
@@ -356,7 +358,7 @@ public:
// Modem status indicator
ModemState ms = modemManager.getState();
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setCursor(4, y + lineHeight + 8);
if (ms == ModemState::OFF || ms == ModemState::POWERING_ON ||
ms == ModemState::INITIALIZING) {
@@ -483,7 +485,7 @@ public:
bool isAction = (row == 4); // Bottom row has action buttons
if (isAction) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
if (col == 2 && _phoneInputPos > 0) {
display.setColor(DisplayDriver::GREEN); // CALL
} else if (col == 1) {
@@ -544,7 +546,7 @@ public:
display.drawRect(0, 11, display.width(), 1);
if (_convCount == 0) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 20);
display.print("No conversations");
@@ -560,8 +562,8 @@ public:
}
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
display.setTextSize(_prefs->smallTextSize());
int lineHeight = _prefs->smallLineH() + 1;
int y = 14;
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
@@ -643,14 +645,14 @@ public:
display.drawRect(0, 11, display.width(), 1);
if (_msgCount == 0) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 25);
display.print("No messages");
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
display.setTextSize(_prefs->smallTextSize());
int lineHeight = _prefs->smallLineH() + 1;
int headerHeight = 14;
int footerHeight = 14;
@@ -764,12 +766,13 @@ public:
// Message body
display.setCursor(0, 14);
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM");
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
if (charsPerLine < 12) charsPerLine = 12;
int composeLH = _prefs->smallLineH() + 1;
int y = 14;
int x = 0;
char cs[2] = {0, 0};
@@ -780,7 +783,7 @@ public:
x++;
if (x >= charsPerLine) {
x = 0;
y += 10;
y += composeLH;
}
}
@@ -827,7 +830,7 @@ public:
int cnt = smsContacts.count();
if (cnt == 0) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 25);
display.print("No contacts saved");
@@ -837,8 +840,8 @@ public:
display.print("and press A to add");
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
display.setTextSize(_prefs->smallTextSize());
int lineHeight = _prefs->smallLineH() + 1;
int y = 14;
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
@@ -900,7 +903,7 @@ public:
display.drawRect(0, 11, display.width(), 1);
// Phone number (read-only)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 16);
display.print("Phone: ");
@@ -956,7 +959,7 @@ public:
display.print(dispName);
// Phone number below name (smaller, dimmer)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 36);
display.print(_callPhone);
@@ -1011,7 +1014,7 @@ public:
display.print(dispName);
// Phone number below name (smaller, dimmer)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 36);
display.print(_callPhone);
@@ -1070,7 +1073,7 @@ public:
display.print(dispName);
// Phone number below name (smaller, dimmer)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 36);
display.print(_callPhone);
@@ -1090,7 +1093,7 @@ public:
display.print(timeBuf);
// Volume (left-aligned)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
char volLabel[12];
snprintf(volLabel, sizeof(volLabel), "Vol: %d/5", _callVolume);

View File

@@ -28,6 +28,7 @@
#include <SD.h>
#endif
#include <WebServer.h>
#include <DNSServer.h>
#include <Update.h>
#include <esp_ota_ops.h>
#endif
@@ -112,6 +113,7 @@ enum SettingsRowType : uint8_t {
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle
ROW_DARK_MODE, // Dark mode toggle (inverted display)
ROW_LARGE_FONT, // Font size toggle: 0=tiny (default), 1=larger
#if defined(LilyGo_T5S3_EPaper_Pro)
ROW_PORTRAIT_MODE, // Portrait orientation toggle
#endif
@@ -142,7 +144,9 @@ enum SettingsRowType : uint8_t {
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
ROW_INFO_HEADER, // "--- Info ---" separator
#ifdef MECK_OTA_UPDATE
ROW_OTA_TOOLS_SUBMENU, // Folder row → enters OTA Tools sub-screen
ROW_FW_UPDATE, // "Firmware Update" — WiFi upload + flash
ROW_SD_FILE_MGR, // "SD File Manager" — WiFi file browser
#endif
ROW_PUB_KEY, // Public key display
ROW_FIRMWARE, // Firmware version
@@ -167,6 +171,7 @@ enum EditMode : uint8_t {
#endif
#ifdef MECK_OTA_UPDATE
EDIT_OTA, // OTA firmware update flow (multi-phase overlay)
EDIT_FILEMGR, // SD file manager flow (WiFi file browser)
#endif
};
@@ -177,6 +182,9 @@ enum SubScreen : uint8_t {
SUB_NONE, // Top-level settings list
SUB_CONTACTS, // Contacts settings sub-screen
SUB_CHANNELS, // Channels management sub-screen
#ifdef MECK_OTA_UPDATE
SUB_OTA_TOOLS, // OTA Tools sub-screen (FW update + File Manager)
#endif
};
#ifdef MECK_OTA_UPDATE
@@ -191,6 +199,13 @@ enum OtaPhase : uint8_t {
OTA_PHASE_DONE, // Success, rebooting
OTA_PHASE_ERROR, // Error with message
};
// File manager phases
enum FmPhase : uint8_t {
FM_PHASE_CONFIRM, // "Start SD file manager? Enter:Yes Q:No"
FM_PHASE_WAITING, // AP up, file browser active
FM_PHASE_ERROR, // Error with message
};
#endif
// Max rows in the settings list (increased for contact sub-toggles + WiFi)
@@ -242,6 +257,9 @@ private:
// Dirty flag for radio params — prompt to apply
bool _radioChanged;
// T5S3: signal UITask to open VKB when entering text edit mode
bool _needsTextVKB;
// 4G modem state (runtime cache of config)
#ifdef HAS_4G_MODEM
bool _modemEnabled;
@@ -277,6 +295,10 @@ private:
bool _otaUploadOk;
char _otaApName[24];
const char* _otaError;
// File manager state
FmPhase _fmPhase;
const char* _fmError;
DNSServer* _dnsServer;
#endif
// ---------------------------------------------------------------------------
@@ -349,15 +371,21 @@ private:
}
} else if (_subScreen == SUB_CHANNELS) {
// --- Channels sub-screen: only channel-related rows ---
// Scan ALL slots — companion app may write non-contiguously, and
// gaps can appear after channel deletion if compaction is incomplete.
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
addRow(ROW_CHANNEL, i);
} else {
break;
}
}
addRow(ROW_ADD_CHANNEL);
#ifdef MECK_OTA_UPDATE
} else if (_subScreen == SUB_OTA_TOOLS) {
// --- OTA Tools sub-screen ---
addRow(ROW_FW_UPDATE);
addRow(ROW_SD_FILE_MGR);
#endif
} else {
// --- Top-level settings list ---
addRow(ROW_NAME);
@@ -372,6 +400,7 @@ private:
addRow(ROW_GPS_BAUD);
addRow(ROW_PATH_HASH_SIZE);
addRow(ROW_DARK_MODE);
addRow(ROW_LARGE_FONT);
#if defined(LilyGo_T5S3_EPaper_Pro)
addRow(ROW_PORTRAIT_MODE);
#endif
@@ -389,12 +418,12 @@ private:
// Folder rows for sub-screens
addRow(ROW_CONTACTS_SUBMENU);
addRow(ROW_CHANNELS_SUBMENU);
#ifdef MECK_OTA_UPDATE
addRow(ROW_OTA_TOOLS_SUBMENU);
#endif
// Info section (stays at top level)
addRow(ROW_INFO_HEADER);
#ifdef MECK_OTA_UPDATE
addRow(ROW_FW_UPDATE);
#endif
addRow(ROW_PUB_KEY);
addRow(ROW_FIRMWARE);
@@ -501,14 +530,12 @@ private:
ChannelDetails empty;
memset(&empty, 0, sizeof(empty));
// Find total channel count
// Find highest used channel slot (scan all — gaps may exist)
int total = 0;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
total = i + 1;
} else {
break;
}
}
@@ -545,7 +572,7 @@ public:
_editMode(EDIT_NONE), _editPos(0), _editPickerIdx(0),
_editFloat(0), _editInt(0), _confirmAction(0),
_onboarding(false), _subScreen(SUB_NONE), _savedTopCursor(0),
_radioChanged(false) {
_radioChanged(false), _needsTextVKB(false) {
memset(_editBuf, 0, sizeof(_editBuf));
#ifdef MECK_OTA_UPDATE
_otaServer = nullptr;
@@ -553,6 +580,9 @@ public:
_otaBytesReceived = 0;
_otaUploadOk = false;
_otaError = nullptr;
_fmPhase = FM_PHASE_CONFIRM;
_fmError = nullptr;
_dnsServer = nullptr;
#endif
}
@@ -603,13 +633,13 @@ public:
// and move cursor there. Returns: 0=miss, 1=moved to new row, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_editMode != EDIT_NONE) return 0; // Don't change cursor while editing
const int headerH = 14, footerH = 14, lineH = 9;
// T-Deck Pro render offsets fillRect by +5 (GxEPD baseline compensation),
// so visual rows start 5 units below headerH. T5S3 renders at y directly.
const int headerH = 14, footerH = 14, lineH = _prefs->smallLineH();
// bodyTop must match where the visual rows start (highlight bar position).
// T5S3 renders highlight at y directly. T-Deck Pro offsets by smallHighlightOff().
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
const int bodyTop = headerH + _prefs->smallHighlightOff();
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0; // Outside body area
@@ -740,6 +770,19 @@ public:
#endif
// T5S3 VKB integration for text editing (channel name, device name, freq, APN)
bool needsTextVKB() const { return _needsTextVKB; }
void clearTextNeedsVKB() { _needsTextVKB = false; }
const char* getEditBuf() const { return _editBuf; }
SettingsRowType getCurrentRowType() const { return _rows[_cursor].type; }
void submitEditText(const char* text) {
strncpy(_editBuf, text, SETTINGS_TEXT_BUF - 1);
_editBuf[SETTINGS_TEXT_BUF - 1] = '\0';
_editPos = strlen(_editBuf);
// Simulate Enter to confirm the edit through the normal path
handleInput('\r');
}
// ---------------------------------------------------------------------------
// OTA firmware update
// ---------------------------------------------------------------------------
@@ -963,7 +1006,7 @@ public:
display.fillRect(2, 14, display.width() - 4, display.height() - 28);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(2, 14, display.width() - 4, display.height() - 28);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.drawTextCentered(display.width() / 2, 22, "Flashing Firmware");
snprintf(tmp, sizeof(tmp), "%d / %d KB", (int)(totalWritten / 1024), (int)(fileSize / 1024));
display.drawTextCentered(display.width() / 2, 42, tmp);
@@ -987,10 +1030,18 @@ public:
return true;
}
// Called from render loop AND main loop to poll the web server
// Called from render loop AND main loop to poll the web server.
// Handles both OTA firmware upload and SD file manager modes.
void pollOTAServer() {
if (_otaServer && (_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) {
_otaServer->handleClient();
if (_otaServer) {
if ((_editMode == EDIT_OTA && (_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) ||
(_editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING)) {
_otaServer->handleClient();
}
}
// Process DNS for captive portal redirect (file manager only)
if (_dnsServer && _editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING) {
_dnsServer->processNextRequest();
}
}
@@ -1037,7 +1088,7 @@ public:
display.fillRect(2, 14, display.width() - 4, display.height() - 28);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(2, 14, display.width() - 4, display.height() - 28);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, 30, "Update Complete!");
display.setColor(DisplayDriver::LIGHT);
@@ -1057,6 +1108,443 @@ public:
ESP.restart();
}
// ---------------------------------------------------------------------------
// SD File Manager — WiFi file browser, upload, download, delete
// ---------------------------------------------------------------------------
void startFileMgr() {
_editMode = EDIT_FILEMGR;
_fmPhase = FM_PHASE_CONFIRM;
_fmError = nullptr;
}
void startFileMgrServer() {
// Build AP name with last 4 of MAC for uniqueness
uint8_t mac[6];
WiFi.macAddress(mac);
snprintf(_otaApName, sizeof(_otaApName), "Meck-Files-%02X%02X", mac[4], mac[5]);
// Pause LoRa radio — SD and LoRa share the same SPI bus on both
// platforms. Incoming packets during SD writes cause bus contention.
extern void otaPauseRadio();
otaPauseRadio();
// Clean WiFi init from any state
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
delay(200);
WiFi.mode(WIFI_AP);
WiFi.softAP(_otaApName);
delay(500);
Serial.printf("FM: AP '%s' started, IP: %s\n",
_otaApName, WiFi.softAPIP().toString().c_str());
// Start DNS server — redirect ALL DNS lookups to our AP IP.
// This triggers captive portal detection on phones, which opens the
// page in a real browser instead of the restricted captive webview.
if (_dnsServer) { delete _dnsServer; }
_dnsServer = new DNSServer();
_dnsServer->start(53, "*", WiFi.softAPIP());
Serial.println("FM: DNS captive portal started");
// Start web server
if (_otaServer) { _otaServer->stop(); delete _otaServer; }
_otaServer = new WebServer(80);
// --- Captive portal detection handlers ---
// Phones/OS probe these URLs to detect captive portals. Redirecting
// them to our page causes the OS to open a real browser.
// iOS / macOS
_otaServer->on("/hotspot-detect.html", HTTP_GET, [this]() {
Serial.println("FM: captive probe (Apple)");
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
// Android
_otaServer->on("/generate_204", HTTP_GET, [this]() {
Serial.println("FM: captive probe (Android)");
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
_otaServer->on("/gen_204", HTTP_GET, [this]() {
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
// Windows
_otaServer->on("/connecttest.txt", HTTP_GET, [this]() {
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
_otaServer->on("/redirect", HTTP_GET, [this]() {
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
// Firefox
_otaServer->on("/canonical.html", HTTP_GET, [this]() {
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
_otaServer->on("/success.txt", HTTP_GET, [this]() {
_otaServer->send(200, "text/plain", "success");
});
// --- Main page: server-rendered directory listing (no JS needed) ---
_otaServer->on("/", HTTP_GET, [this]() {
String path = _otaServer->arg("path");
if (path.isEmpty()) path = "/";
String msg = _otaServer->arg("msg");
Serial.printf("FM: page request path='%s'\n", path.c_str());
String html = fmBuildPage(path, msg);
_otaServer->send(200, "text/html", html);
});
// --- File download: GET /dl?path=/file.txt ---
_otaServer->on("/dl", HTTP_GET, [this]() {
String path = _otaServer->arg("path");
File f = SD.open(path, FILE_READ);
if (!f || f.isDirectory()) {
if (f) f.close();
digitalWrite(SDCARD_CS, HIGH);
_otaServer->send(404, "text/plain", "Not found");
return;
}
String name = path;
int lastSlash = name.lastIndexOf('/');
if (lastSlash >= 0) name = name.substring(lastSlash + 1);
_otaServer->sendHeader("Content-Disposition",
"attachment; filename=\"" + name + "\"");
size_t fileSize = f.size();
_otaServer->setContentLength(fileSize);
_otaServer->send(200, "application/octet-stream", "");
uint8_t* buf = (uint8_t*)ps_malloc(4096);
if (!buf) buf = (uint8_t*)malloc(4096);
if (buf) {
while (f.available()) {
int n = f.read(buf, 4096);
if (n > 0) _otaServer->sendContent((const char*)buf, n);
}
free(buf);
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
});
// --- File upload: POST /upload?dir=/ → redirect back to listing ---
_otaServer->on("/upload", HTTP_POST,
[this]() {
String dir = _otaServer->arg("dir");
if (dir.isEmpty()) dir = "/";
_otaServer->sendHeader("Location", "/?path=" + dir + "&msg=Upload+complete");
_otaServer->send(303, "text/plain", "Redirecting...");
},
[this]() {
HTTPUpload& upload = _otaServer->upload();
static File fmUploadFile;
if (upload.status == UPLOAD_FILE_START) {
String dir = _otaServer->arg("dir");
if (dir.isEmpty()) dir = "/";
if (!dir.endsWith("/")) dir += "/";
String fullPath = dir + upload.filename;
Serial.printf("FM: Upload start: %s\n", fullPath.c_str());
fmUploadFile = SD.open(fullPath, FILE_WRITE);
if (!fmUploadFile) Serial.println("FM: Failed to open file for write");
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (fmUploadFile) fmUploadFile.write(upload.buf, upload.currentSize);
} else if (upload.status == UPLOAD_FILE_END) {
if (fmUploadFile) {
fmUploadFile.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("FM: Upload done: %s (%d bytes)\n",
upload.filename.c_str(), upload.totalSize);
}
} else if (upload.status == UPLOAD_FILE_ABORTED) {
if (fmUploadFile) fmUploadFile.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.println("FM: Upload aborted");
}
}
);
// --- Create directory: GET /mkdir?name=xxx&dir=/path ---
_otaServer->on("/mkdir", HTTP_GET, [this]() {
String dir = _otaServer->arg("dir");
String name = _otaServer->arg("name");
if (dir.isEmpty()) dir = "/";
if (name.isEmpty()) {
_otaServer->sendHeader("Location", "/?path=" + dir + "&msg=No+name");
_otaServer->send(303);
return;
}
String full = dir + (dir.endsWith("/") ? "" : "/") + name;
bool ok = SD.mkdir(full);
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("FM: mkdir '%s' %s\n", full.c_str(), ok ? "OK" : "FAIL");
_otaServer->sendHeader("Location",
"/?path=" + dir + "&msg=" + (ok ? "Folder+created" : "mkdir+failed"));
_otaServer->send(303);
});
// --- Delete file/folder: GET /rm?path=/file&ret=/parent ---
_otaServer->on("/rm", HTTP_GET, [this]() {
String path = _otaServer->arg("path");
String ret = _otaServer->arg("ret");
if (ret.isEmpty()) ret = "/";
if (path.isEmpty() || path == "/") {
_otaServer->sendHeader("Location", "/?path=" + ret + "&msg=Bad+path");
_otaServer->send(303);
return;
}
File f = SD.open(path);
bool ok = false;
if (f) {
bool isDir = f.isDirectory();
f.close();
ok = isDir ? SD.rmdir(path) : SD.remove(path);
}
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("FM: rm '%s' %s\n", path.c_str(), ok ? "OK" : "FAIL");
_otaServer->sendHeader("Location",
"/?path=" + ret + "&msg=" + (ok ? "Deleted" : "Delete+failed"));
_otaServer->send(303);
});
// --- Confirm delete page: GET /confirm-rm?path=/file&ret=/parent ---
_otaServer->on("/confirm-rm", HTTP_GET, [this]() {
String path = _otaServer->arg("path");
String ret = _otaServer->arg("ret");
if (ret.isEmpty()) ret = "/";
String name = path;
int sl = name.lastIndexOf('/');
if (sl >= 0) name = name.substring(sl + 1);
String html = "<!DOCTYPE html><html><head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Confirm Delete</title>"
"<style>"
"body{font-family:-apple-system,sans-serif;max-width:480px;margin:40px auto;"
"padding:0 20px;background:#1a1a2e;color:#e0e0e0;text-align:center}"
".b{display:inline-block;padding:10px 24px;border-radius:6px;text-decoration:none;"
"font-weight:bold;margin:8px;font-size:1em}"
".br{background:#e74c3c;color:#fff}.bg{background:#4ecca3;color:#1a1a2e}"
"</style></head><body>"
"<h2 style='color:#e74c3c'>Delete?</h2>"
"<p style='font-size:1.1em'>" + fmHtmlEscape(name) + "</p>"
"<a class='b br' href='/rm?path=" + fmUrlEncode(path) + "&ret=" + fmUrlEncode(ret) + "'>Delete</a>"
"<a class='b bg' href='/?path=" + fmUrlEncode(ret) + "'>Cancel</a>"
"</body></html>";
_otaServer->send(200, "text/html", html);
});
// Catch-all: redirect unknown URLs to file manager (catches captive portal probes)
_otaServer->onNotFound([this]() {
Serial.printf("FM: redirect %s -> /\n", _otaServer->uri().c_str());
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
_otaServer->begin();
Serial.println("FM: Web server started on port 80");
_fmPhase = FM_PHASE_WAITING;
}
void stopFileMgr() {
if (_otaServer) { _otaServer->stop(); delete _otaServer; _otaServer = nullptr; }
if (_dnsServer) { _dnsServer->stop(); delete _dnsServer; _dnsServer = nullptr; }
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_OFF);
delay(100);
_editMode = EDIT_NONE;
extern void otaResumeRadio();
otaResumeRadio();
#ifdef MECK_WIFI_COMPANION
WiFi.mode(WIFI_STA);
wifiReconnectSaved();
#endif
Serial.println("FM: Stopped, AP down, radio resumed");
}
// --- Helpers for server-rendered HTML ---
static String fmHtmlEscape(const String& s) {
String r;
r.reserve(s.length());
for (unsigned int i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '&') r += "&amp;";
else if (c == '<') r += "&lt;";
else if (c == '>') r += "&gt;";
else if (c == '"') r += "&quot;";
else r += c;
}
return r;
}
static String fmUrlEncode(const String& s) {
String r;
for (unsigned int i = 0; i < s.length(); i++) {
char c = s[i];
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') {
r += c;
} else {
char hex[4];
snprintf(hex, sizeof(hex), "%%%02X", (uint8_t)c);
r += hex;
}
}
return r;
}
static String fmFormatSize(size_t bytes) {
if (bytes < 1024) return String(bytes) + " B";
if (bytes < 1048576) return String(bytes / 1024) + " KB";
return String(bytes / 1048576) + "." + String((bytes % 1048576) * 10 / 1048576) + " MB";
}
// Build the complete HTML page with inline directory listing
String fmBuildPage(const String& path, const String& msg) {
String html;
html.reserve(4096);
// --- Head + CSS ---
html += "<!DOCTYPE html><html><head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Meck SD Files</title>"
"<style>"
"body{font-family:-apple-system,sans-serif;max-width:600px;margin:20px auto;"
"padding:0 16px;background:#1a1a2e;color:#e0e0e0}"
"h1{color:#4ecca3;font-size:1.3em;margin:8px 0}"
".pa{background:#16213e;padding:8px 12px;border-radius:6px;margin:8px 0;"
"font-family:monospace;font-size:0.9em;word-break:break-all}"
".tb{display:flex;gap:6px;margin:8px 0;flex-wrap:wrap}"
".b{background:#4ecca3;color:#1a1a2e;border:none;padding:7px 14px;"
"border-radius:5px;font-size:0.85em;font-weight:bold;cursor:pointer;"
"text-decoration:none;display:inline-block}"
".b:active{background:#3ba88f}"
".br{background:#e74c3c;color:#fff;padding:3px 8px;font-size:0.75em}.br:active{background:#c0392b}"
".it{display:flex;align-items:center;padding:8px 4px;border-bottom:1px solid #16213e;gap:6px}"
".ic{font-size:1.1em;width:22px;text-align:center}"
".nm{flex:1;word-break:break-all;color:#e0e0e0;text-decoration:none}"
".nm:hover{color:#4ecca3}"
".sz{color:#888;font-size:0.8em;min-width:54px;text-align:right;margin-right:4px}"
".up{background:#16213e;border:2px dashed #4ecca3;border-radius:8px;"
"padding:14px;margin:10px 0;text-align:center}"
".em{color:#888;text-align:center;padding:20px}"
".ms{background:#16213e;padding:8px 12px;border-radius:6px;margin:8px 0;"
"border-left:3px solid #4ecca3;font-size:0.9em}"
"</style></head><body>";
// --- Title + path ---
html += "<h1>Meck SD File Manager</h1>";
html += "<div class='pa'>" + fmHtmlEscape(path) + "</div>";
// --- Status message (from redirects) ---
if (msg.length() > 0) {
html += "<div class='ms'>" + fmHtmlEscape(msg) + "</div>";
}
// --- Navigation buttons ---
html += "<div class='tb'>";
if (path != "/") {
// Compute parent
String parent = path;
if (parent.endsWith("/")) parent = parent.substring(0, parent.length() - 1);
int sl = parent.lastIndexOf('/');
parent = (sl <= 0) ? "/" : parent.substring(0, sl);
html += "<a class='b' href='/?path=" + fmUrlEncode(parent) + "'>.. Up</a>";
}
html += "<a class='b' href='/?path=" + fmUrlEncode(path) + "'>Refresh</a>";
html += "</div>";
// --- Directory listing (server-rendered) ---
File dir = SD.open(path, FILE_READ);
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
digitalWrite(SDCARD_CS, HIGH);
html += "<div class='em'>Cannot open directory</div>";
} else {
// Collect entries into arrays for sorting (dirs first, then alpha)
struct FmEntry { String name; size_t size; bool isDir; };
FmEntry entries[128]; // max entries to display
int count = 0;
File entry = dir.openNextFile();
while (entry && count < 128) {
const char* fullName = entry.name();
const char* baseName = strrchr(fullName, '/');
baseName = baseName ? baseName + 1 : fullName;
entries[count].name = baseName;
entries[count].size = entry.size();
entries[count].isDir = entry.isDirectory();
count++;
entry.close();
entry = dir.openNextFile();
}
dir.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("FM: listing %d entries for '%s'\n", count, path.c_str());
// Sort: dirs first, then alphabetical
for (int i = 0; i < count - 1; i++) {
for (int j = i + 1; j < count; j++) {
bool swap = false;
if (entries[i].isDir != entries[j].isDir) {
swap = !entries[i].isDir && entries[j].isDir;
} else {
swap = entries[i].name.compareTo(entries[j].name) > 0;
}
if (swap) {
FmEntry tmp = entries[i];
entries[i] = entries[j];
entries[j] = tmp;
}
}
}
if (count == 0) {
html += "<div class='em'>Empty folder</div>";
} else {
for (int i = 0; i < count; i++) {
String fp = path + (path.endsWith("/") ? "" : "/") + entries[i].name;
html += "<div class='it'>";
html += "<span class='ic'>" + String(entries[i].isDir ? "\xF0\x9F\x93\x81" : "\xF0\x9F\x93\x84") + "</span>";
if (entries[i].isDir) {
html += "<a class='nm' href='/?path=" + fmUrlEncode(fp) + "'>" + fmHtmlEscape(entries[i].name) + "</a>";
} else {
html += "<a class='nm' href='/dl?path=" + fmUrlEncode(fp) + "'>" + fmHtmlEscape(entries[i].name) + "</a>";
html += "<span class='sz'>" + fmFormatSize(entries[i].size) + "</span>";
}
html += "<a class='b br' href='/confirm-rm?path=" + fmUrlEncode(fp) + "&ret=" + fmUrlEncode(path) + "'>Del</a>";
html += "</div>";
}
}
}
// --- Upload form (standard HTML form, no JS needed) ---
html += "<div class='up'>"
"<form method='POST' action='/upload?dir=" + fmUrlEncode(path) + "' enctype='multipart/form-data'>"
"<p>Select files to upload</p>"
"<input type='file' name='file' multiple><br><br>"
"<button class='b' type='submit'>Upload</button>"
"</form></div>";
// --- New folder (tiny inline form) ---
html += "<form action='/mkdir' method='GET' style='margin:8px 0;display:flex;gap:6px'>"
"<input type='hidden' name='dir' value='" + fmHtmlEscape(path) + "'>"
"<input type='text' name='name' placeholder='New folder name' "
"style='flex:1;padding:7px;border-radius:5px;border:1px solid #4ecca3;"
"background:#16213e;color:#e0e0e0'>"
"<button class='b' type='submit'>Create</button>"
"</form>";
html += "</body></html>";
return html;
}
#endif
// ---------------------------------------------------------------------------
@@ -1068,6 +1556,9 @@ public:
strncpy(_editBuf, initial, SETTINGS_TEXT_BUF - 1);
_editBuf[SETTINGS_TEXT_BUF - 1] = '\0';
_editPos = strlen(_editBuf);
#if defined(LilyGo_T5S3_EPaper_Pro)
_needsTextVKB = true; // Signal UITask to open virtual keyboard
#endif
}
void startEditPicker(int initialIdx) {
@@ -1102,6 +1593,10 @@ public:
display.print("Settings > Contacts");
} else if (_subScreen == SUB_CHANNELS) {
display.print("Settings > Channels");
#ifdef MECK_OTA_UPDATE
} else if (_subScreen == SUB_OTA_TOOLS) {
display.print("Settings > OTA Tools");
#endif
} else {
display.print("Settings");
}
@@ -1114,8 +1609,8 @@ public:
display.drawRect(0, 11, display.width(), 1);
// === Body ===
display.setTextSize(0); // tiny font
int lineHeight = 9;
display.setTextSize(_prefs->smallTextSize()); // tiny font
int lineHeight = _prefs->smallLineH();
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
@@ -1140,7 +1635,7 @@ public:
// Highlight needs to start above the baseline to cover ascenders.
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -1233,7 +1728,7 @@ public:
break;
case ROW_MSG_NOTIFY:
snprintf(tmp, sizeof(tmp), "Msg Rcvd LED Light Pulse: %s",
snprintf(tmp, sizeof(tmp), "Msg LED Flash: %s",
_prefs->kb_flash_notify ? "ON" : "OFF");
display.print(tmp);
break;
@@ -1266,6 +1761,12 @@ public:
display.print(tmp);
break;
case ROW_LARGE_FONT:
snprintf(tmp, sizeof(tmp), "Font Size: %s",
_prefs->large_font ? "LARGER" : "TINY");
display.print(tmp);
break;
#if defined(LilyGo_T5S3_EPaper_Pro)
case ROW_PORTRAIT_MODE:
snprintf(tmp, sizeof(tmp), "Portrait Mode: %s",
@@ -1421,9 +1922,18 @@ public:
break;
#ifdef MECK_OTA_UPDATE
case ROW_OTA_TOOLS_SUBMENU:
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
display.print("OTA Tools >>");
break;
case ROW_FW_UPDATE:
display.print("Firmware Update");
break;
case ROW_SD_FILE_MGR:
display.print("SD File Manager");
break;
#endif
case ROW_INFO_HEADER:
@@ -1506,7 +2016,7 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
if (_confirmAction == 1) {
uint8_t chIdx = _rows[_cursor].param;
ChannelDetails ch;
@@ -1534,7 +2044,7 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
int wy = by + 4;
if (_wifiPhase == WIFI_PHASE_SCANNING) {
@@ -1620,7 +2130,7 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
int oy = by + 4;
if (_otaPhase == OTA_PHASE_CONFIRM) {
@@ -1700,6 +2210,75 @@ public:
display.setTextSize(1);
}
// === File Manager overlay ===
if (_editMode == EDIT_FILEMGR) {
int bx = 2, by = 14, bw = display.width() - 4;
int bh = display.height() - 28;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
display.setTextSize(_prefs->smallTextSize());
int oy = by + 4;
if (_fmPhase == FM_PHASE_CONFIRM) {
display.drawTextCentered(display.width() / 2, oy, "SD File Manager");
oy += 14;
display.setCursor(bx + 4, oy);
display.print("Start WiFi file server?");
oy += 10;
display.setCursor(bx + 4, oy);
display.print("Upload and download files");
oy += 8;
display.setCursor(bx + 4, oy);
display.print("on SD card via browser.");
oy += 10;
display.setCursor(bx + 4, oy);
display.setColor(DisplayDriver::YELLOW);
display.print("LoRa paused while active.");
display.setColor(DisplayDriver::LIGHT);
} else if (_fmPhase == FM_PHASE_WAITING) {
display.drawTextCentered(display.width() / 2, oy, "SD File Manager");
oy += 14;
display.setCursor(bx + 4, oy);
display.print("Connect to WiFi network:");
oy += 10;
display.setColor(DisplayDriver::GREEN);
display.setCursor(bx + 4, oy);
display.print(_otaApName);
display.setColor(DisplayDriver::LIGHT);
oy += 12;
display.setCursor(bx + 4, oy);
display.print("Then open browser:");
oy += 10;
display.setColor(DisplayDriver::GREEN);
display.setCursor(bx + 4, oy);
char ipBuf[32];
snprintf(ipBuf, sizeof(ipBuf), "http://%s", WiFi.softAPIP().toString().c_str());
display.print(ipBuf);
display.setColor(DisplayDriver::LIGHT);
oy += 12;
display.setCursor(bx + 4, oy);
display.print("File server active...");
pollOTAServer();
} else if (_fmPhase == FM_PHASE_ERROR) {
display.setColor(DisplayDriver::YELLOW);
display.drawTextCentered(display.width() / 2, oy, "File Manager Error");
display.setColor(DisplayDriver::LIGHT);
oy += 14;
if (_fmError) {
display.setCursor(bx + 4, oy);
display.print(_fmError);
}
}
display.setTextSize(1);
}
#endif
// === Footer ===
@@ -1712,7 +2291,12 @@ public:
if (_editMode == EDIT_NONE) {
if (_subScreen != SUB_NONE) {
display.print("Boot:Back");
const char* r = (_subScreen == SUB_CHANNELS) ? "Tap:Select Hold:Del" : "Tap:Toggle Hold:Edit";
const char* r;
if (_subScreen == SUB_CHANNELS) r = "Tap:Select Hold:Del";
#ifdef MECK_OTA_UPDATE
else if (_subScreen == SUB_OTA_TOOLS) r = "Tap:Select";
#endif
else r = "Tap:Toggle Hold:Edit";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
} else {
@@ -1761,6 +2345,19 @@ public:
} else {
display.print("Please wait...");
}
} else if (_editMode == EDIT_FILEMGR) {
if (_fmPhase == FM_PHASE_CONFIRM) {
display.print("Boot:Cancel");
const char* r = "Tap:Start";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
} else if (_fmPhase == FM_PHASE_WAITING) {
display.print("Boot:Stop");
} else if (_fmPhase == FM_PHASE_ERROR) {
display.print("Boot:Back");
} else {
display.print("Please wait...");
}
#endif
} else if (_editMode == EDIT_TEXT) {
display.print("Hold:Type");
@@ -1798,6 +2395,16 @@ public:
} else {
display.print("Please wait...");
}
} else if (_editMode == EDIT_FILEMGR) {
if (_fmPhase == FM_PHASE_CONFIRM) {
display.print("Enter:Start Q:Cancel");
} else if (_fmPhase == FM_PHASE_WAITING) {
display.print("Q:Stop");
} else if (_fmPhase == FM_PHASE_ERROR) {
display.print("Q:Back");
} else {
display.print("Please wait...");
}
#endif
} else if (_editMode == EDIT_PICKER) {
display.print("A/D:Choose Enter:Ok");
@@ -1818,9 +2425,10 @@ public:
#endif
#ifdef MECK_OTA_UPDATE
// Poll web server frequently during OTA waiting/receiving phases
if (_editMode == EDIT_OTA &&
(_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) {
// Poll web server frequently during OTA waiting/receiving or file manager phases
if ((_editMode == EDIT_OTA &&
(_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) ||
(_editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING)) {
return 200; // 200ms — fast enough for web server responsiveness
}
#endif
@@ -1887,6 +2495,32 @@ public:
// Consume all keys during OTA
return true;
}
// --- File Manager flow ---
if (_editMode == EDIT_FILEMGR) {
if (_fmPhase == FM_PHASE_CONFIRM) {
if (c == '\r' || c == 13) {
startFileMgrServer();
return true;
}
if (c == 'q' || c == 'Q') {
_editMode = EDIT_NONE;
return true;
}
} else if (_fmPhase == FM_PHASE_WAITING) {
if (c == 'q' || c == 'Q') {
stopFileMgr();
return true;
}
} else if (_fmPhase == FM_PHASE_ERROR) {
if (c == 'q' || c == 'Q') {
stopFileMgr();
return true;
}
}
// Consume all keys during file manager
return true;
}
#endif
#ifdef MECK_WIFI_COMPANION
@@ -2311,6 +2945,12 @@ public:
Serial.printf("Settings: Dark mode = %s\n",
_prefs->dark_mode ? "ON" : "OFF");
break;
case ROW_LARGE_FONT:
_prefs->large_font = _prefs->large_font ? 0 : 1;
the_mesh.savePrefs();
Serial.printf("Settings: Font size = %s\n",
_prefs->large_font ? "LARGER" : "TINY");
break;
#if defined(LilyGo_T5S3_EPaper_Pro)
case ROW_PORTRAIT_MODE:
_prefs->portrait_mode = _prefs->portrait_mode ? 0 : 1;
@@ -2453,9 +3093,20 @@ public:
startEditText("");
break;
#ifdef MECK_OTA_UPDATE
case ROW_OTA_TOOLS_SUBMENU:
_savedTopCursor = _cursor;
_subScreen = SUB_OTA_TOOLS;
_cursor = 0;
_scrollTop = 0;
rebuildRows();
Serial.println("Settings: entered OTA Tools sub-screen");
break;
case ROW_FW_UPDATE:
startOTA();
break;
case ROW_SD_FILE_MGR:
startFileMgr();
break;
#endif
case ROW_CHANNEL:
case ROW_PUB_KEY:

View File

@@ -6,6 +6,7 @@
#include <vector>
#include "Utf8CP437.h"
#include "EpubProcessor.h"
#include "../NodePrefs.h"
// Forward declarations
class UITask;
@@ -327,12 +328,13 @@ inline int indexPagesWordWrap(File& file, long startPos,
inline int indexPagesWordWrapPixel(File& file, long startPos,
std::vector<long>& pagePositions,
int linesPerPage, int maxChars,
DisplayDriver* display, int maxPages) {
DisplayDriver* display, int maxPages,
NodePrefs* prefs = nullptr) {
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
char buffer[BUF_SIZE];
// Ensure body font is active for pixel measurement
display->setTextSize(0);
display->setTextSize(prefs ? prefs->smallTextSize() : 0);
file.seek(startPos);
int pagesAdded = 0;
@@ -396,9 +398,11 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
Mode _mode;
bool _sdReady;
bool _initialized; // Layout metrics calculated
uint8_t _lastFontPref; // Font preference at last layout init (detect changes)
bool _bootIndexed; // Boot-time pre-indexing done
DisplayDriver* _display; // Stored reference for splash screens
@@ -1084,8 +1088,8 @@ private:
display.setCursor(0, 42);
display.print("/books/ on SD card");
} else {
display.setTextSize(0); // Tiny font for file list
int listLineH = 8; // Approximate tiny font line height in virtual coords
display.setTextSize(_prefs->smallTextSize()); // Tiny font for file list
int listLineH = _prefs->smallLineH();
int startY = 14;
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
if (maxVisible < 3) maxVisible = 3;
@@ -1106,7 +1110,7 @@ private:
#else
// setCursor adds +5 to y internally, but fillRect does not.
// Offset fillRect by +5 to align highlight bar with text.
display.fillRect(0, y + 5, display.width(), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -1114,8 +1118,6 @@ private:
}
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
int type = itemTypeAt(i);
String line = selected ? "> " : " ";
@@ -1125,10 +1127,6 @@ private:
} else if (type == 1) {
// Subdirectory
line += "/" + dirNameAt(i);
// Truncate if needed
if ((int)line.length() > _charsPerLine) {
line = line.substring(0, _charsPerLine - 3) + "...";
}
} else {
// File
int fi = fileIndexAt(i);
@@ -1141,16 +1139,11 @@ private:
suffix = " *";
}
}
// Truncate if needed
int maxLen = _charsPerLine - 4 - suffix.length();
if ((int)name.length() > maxLen) {
name = name.substring(0, maxLen - 3) + "...";
}
line += name + suffix;
}
display.print(line.c_str());
// Pixel-aware ellipsis — small margin prevents GxEPD edge wrapping
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
y += listLineH;
}
display.setTextSize(1); // Restore
@@ -1163,7 +1156,7 @@ private:
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
#else
display.setCursor(0, footerY);
@@ -1177,7 +1170,7 @@ private:
void renderPage(DisplayDriver& display) {
// Use tiny font for maximum text density
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
int y = 0;
@@ -1270,7 +1263,7 @@ private:
}
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setCursor(0, footerY);
display.print(status);
const char* right = "Swipe:Page Tap:GoTo Hold:Close";
@@ -1287,8 +1280,8 @@ private:
}
public:
TextReaderScreen(UITask* task)
: _task(task), _mode(FILE_LIST), _sdReady(false), _initialized(false),
TextReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _mode(FILE_LIST), _sdReady(false), _initialized(false), _lastFontPref(0),
_bootIndexed(false), _display(nullptr),
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
_textAreaHeight(100), _headerHeight(14), _footerHeight(14),
@@ -1313,16 +1306,24 @@ public:
// Call once after display is available to calculate layout metrics
void initLayout(DisplayDriver& display) {
// Re-init if font preference changed since last layout
uint8_t curFont = _prefs ? _prefs->large_font : 0;
if (_initialized && curFont != _lastFontPref) {
_initialized = false;
Serial.println("TextReader: font changed, recalculating layout");
}
if (_initialized) return;
_lastFontPref = curFont;
// Store display reference for splash screens during openBook
_display = &display;
// Measure tiny font metrics using the display driver
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
// Measure character width: use 10 M's for monospace (T-Deck Pro).
// T5S3 overrides this below with average-width measurement.
// Measure character width: use 10 M's for monospace (T-Deck Pro tiny font).
// Proportional fonts (T5S3 and T-Deck Pro large_font) override below with
// average-width measurement since M is the widest glyph (~40% wider than average).
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
if (tenCharsW > 0) {
_charsPerLine = (display.width() * 10) / tenCharsW;
@@ -1343,6 +1344,15 @@ public:
if (_charsPerLine < 15) _charsPerLine = 15;
if (_charsPerLine > 80) _charsPerLine = 80;
#else
// T-Deck Pro: large_font uses FreeSans9pt (proportional) — same fix
if (_prefs && _prefs->large_font) {
const char* sample = "the quick brown fox jumps over lazy dog";
uint16_t sampleW = display.getTextWidth(sample);
int sampleLen = strlen(sample);
if (sampleW > 0 && sampleLen > 0) {
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
}
}
if (_charsPerLine < 15) _charsPerLine = 15;
if (_charsPerLine > 60) _charsPerLine = 60;
#endif
@@ -1362,13 +1372,17 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
// T5S3 uses FreeSans12pt/FreeSerif12pt for size 0 (yAdvance=29px).
// Line height in virtual coords depends on orientation:
// Landscape: 29px / scale_y(4.22) ≈ 7 + 1 spacing = 8
// Portrait: 29px / scale_y(7.50) ≈ 4 + 1 spacing = 5
{
extern DISPLAY_CLASS display;
_lineHeight = display.isPortraitMode() ? 5 : 8;
}
#else
// T-Deck Pro large_font uses FreeSans9pt (yAdvance=22px at scale 1.5625×).
// The 6x8 formula above gives ~5-7 which is way too small — lines overlap.
// Use smallLineH() which is already tuned for this font.
if (_prefs && _prefs->large_font) {
_lineHeight = _prefs->smallLineH();
}
#endif
_headerHeight = 0; // No header in reading mode (maximize text area)
@@ -1389,6 +1403,107 @@ public:
// Called from setup() after SD card init. Scans files, pre-indexes first
// 100 pages of each, and shows progress on the e-ink display.
// Pre-index files inside one level of subdirectories so navigating
// into them later is instant (idx files already on SD).
void bootIndexSubfolders() {
// Work from the root-level _dirList that scanFiles() already populated.
// Copy it -- scanFiles() will overwrite _dirList when we scan each subfolder.
std::vector<String> subDirs = _dirList;
if (subDirs.empty()) return;
Serial.printf("TextReader: Pre-indexing %d subfolders\n", (int)subDirs.size());
int totalSubFiles = 0;
int cachedSubFiles = 0;
int indexedSubFiles = 0;
for (int d = 0; d < (int)subDirs.size(); d++) {
String subPath = String(BOOKS_FOLDER) + "/" + subDirs[d];
_currentPath = subPath;
scanFiles(); // populates _fileList for this subfolder
// Also pick up previously converted EPUB cache files for this subfolder
String epubCachePath = subPath + "/.epub_cache";
if (SD.exists(epubCachePath.c_str())) {
File cacheDir = SD.open(epubCachePath.c_str());
if (cacheDir && cacheDir.isDirectory()) {
File cf = cacheDir.openNextFile();
while (cf && _fileList.size() < READER_MAX_FILES) {
if (!cf.isDirectory()) {
String cname = String(cf.name());
int cslash = cname.lastIndexOf('/');
if (cslash >= 0) cname = cname.substring(cslash + 1);
if (cname.endsWith(".txt") || cname.endsWith(".TXT")) {
bool dup = false;
for (int k = 0; k < (int)_fileList.size(); k++) {
if (_fileList[k] == cname) { dup = true; break; }
}
if (!dup) _fileList.push_back(cname);
}
}
cf = cacheDir.openNextFile();
}
cacheDir.close();
}
}
for (int i = 0; i < (int)_fileList.size(); i++) {
totalSubFiles++;
// Try loading existing .idx cache -- if hit, skip
FileCache tempCache;
if (loadIndex(_fileList[i], tempCache)) {
cachedSubFiles++;
continue;
}
// Skip .epub files (converted on first open)
if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) continue;
// Index this .txt file
String fullPath = _currentPath + "/" + _fileList[i];
File file = SD.open(fullPath.c_str(), FILE_READ);
if (!file) {
// Try epub cache fallback
String cacheFallback = epubCachePath + "/" + _fileList[i];
file = SD.open(cacheFallback.c_str(), FILE_READ);
}
if (!file) continue;
indexedSubFiles++;
String displayName = subDirs[d] + "/" + _fileList[i];
drawBootSplash(indexedSubFiles, 0, displayName);
FileCache cache;
cache.filename = _fileList[i];
cache.fileSize = file.size();
cache.fullyIndexed = false;
cache.lastReadPage = 0;
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1,
_textAreaHeight, _lineHeight);
cache.fullyIndexed = !file.available();
file.close();
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
cache.fullyIndexed, 0);
Serial.printf("TextReader: %s/%s - indexed %d pages%s\n",
subDirs[d].c_str(), _fileList[i].c_str(),
(int)cache.pagePositions.size(),
cache.fullyIndexed ? " (complete)" : "");
yield(); // Feed WDT between files
}
}
Serial.printf("TextReader: Subfolder pre-index: %d files (%d cached, %d newly indexed)\n",
totalSubFiles, cachedSubFiles, indexedSubFiles);
}
void bootIndex(DisplayDriver& display) {
if (!_sdReady) return;
@@ -1430,20 +1545,24 @@ public:
}
}
if (_fileList.size() == 0) {
Serial.println("TextReader: No files to index");
if (_fileList.size() == 0 && _dirList.size() == 0) {
Serial.println("TextReader: No files or folders to index");
_bootIndexed = true;
return;
}
int cachedCount = 0;
int needsIndexCount = 0;
// --- Pass 1 & 2: Index root-level files ---
if (_fileList.size() > 0) {
// --- Pass 1: Fast cache load (no per-file splash screens) ---
// Try to load existing .idx files from SD for every file.
// This is just SD reads — no indexing, no e-ink refreshes.
_fileCache.clear();
_fileCache.resize(_fileList.size()); // Pre-allocate slots to maintain alignment with _fileList
int cachedCount = 0;
int needsIndexCount = 0;
for (int i = 0; i < (int)_fileList.size(); i++) {
if (loadIndex(_fileList[i], _fileCache[i])) {
@@ -1509,6 +1628,26 @@ public:
}
}
} // end if (_fileList.size() > 0)
// --- Pass 3: Pre-index files inside subfolders (one level deep) ---
// Save root state -- bootIndexSubfolders() will overwrite _fileList/_dirList
// via scanFiles() as it iterates each subdirectory.
if (_dirList.size() > 0) {
std::vector<String> savedFileList = _fileList;
std::vector<String> savedDirList = _dirList;
std::vector<FileCache> savedFileCache = _fileCache;
bootIndexSubfolders();
// Restore root state
_currentPath = String(BOOKS_FOLDER);
_fileList = savedFileList;
_dirList = savedDirList;
_fileCache = savedFileCache;
}
// Deselect SD to free SPI bus
digitalWrite(SDCARD_CS, HIGH);
@@ -1574,11 +1713,12 @@ public:
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_mode != FILE_LIST) return 0;
const int startY = 14, footerH = 14, listLineH = 8;
const int startY = 14, footerH = 14;
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = startY;
#else
const int bodyTop = startY + 5; // GxEPD baseline offset
const int bodyTop = startY + (_prefs ? _prefs->smallHighlightOff() : 5);
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;

View File

@@ -12,12 +12,15 @@
#include "MapScreen.h"
#endif
#include "target.h"
#if defined(LilyGo_T5S3_EPaper_Pro)
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_AUDIO_VARIANT)
#include "HomeIcons.h"
#endif
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
#include <WiFi.h>
#endif
#if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
#include "esp_sleep.h"
#endif
#ifndef AUTO_OFF_MILLIS
#define AUTO_OFF_MILLIS 15000 // 15 seconds
@@ -156,7 +159,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
}
display.setColor(DisplayDriver::GREEN);
display.setTextSize(0);
display.setTextSize(_node_prefs->smallTextSize());
#if defined(LilyGo_T5S3_EPaper_Pro)
// T5S3: text-only battery indicator — "Batt 99% 4.1v"
@@ -170,7 +173,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
display.print(battStr);
display.setTextSize(1); // restore default text size
#else
// T-Deck Pro: icon + percentage text
// T-Deck Pro: icon + percentage text (icon hidden in large font)
int iconWidth = 16;
int iconHeight = 6;
int iconY = 0;
@@ -181,26 +184,35 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
sprintf(pctStr, "%d%%", batteryPercentage);
uint16_t textWidth = display.getTextWidth(pctStr);
// layout: [icon][cap 2px][gap 2px][text][margin 2px]
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
int iconX = display.width() - totalWidth;
if (_node_prefs->large_font) {
// Large font: text only — no room for icon in header
int textX = display.width() - textWidth - 2;
if (outIconX) *outIconX = textX;
display.setCursor(textX, textY);
display.print(pctStr);
} else {
// Tiny font: icon + text
// layout: [icon][cap 2px][gap 2px][text][margin 2px]
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
int iconX = display.width() - totalWidth;
if (outIconX) *outIconX = iconX;
if (outIconX) *outIconX = iconX;
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
// battery "cap"
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2);
// battery "cap"
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2);
// fill the battery based on the percentage
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
// fill the battery based on the percentage
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
// draw percentage text after the battery cap
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
display.setCursor(textX, textY);
display.print(pctStr);
// draw percentage text after the battery cap
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
display.setCursor(textX, textY);
display.print(pctStr);
}
display.setTextSize(1); // restore default text size
#endif
}
@@ -215,12 +227,31 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
if (!_task->isAudioPlayingInBackground()) return;
display.setColor(DisplayDriver::GREEN);
display.setTextSize(0); // tiny font (same as clock & battery %)
display.setTextSize(_node_prefs->smallTextSize()); // tiny font (same as clock & battery %)
int x = batteryLeftX - display.getTextWidth(">>") - 2;
display.setCursor(x, -3); // align vertically with battery text
display.print(">>");
display.setTextSize(1); // restore
}
// ---- Alarm enabled indicator ----
// Shows a small bell icon to the left of the audio indicator
// (or battery icon if no audio playing) when any alarm is enabled.
void renderAlarmIndicator(DisplayDriver& display, int batteryLeftX) {
AlarmScreen* alarmScr = (AlarmScreen*)_task->getAlarmScreen();
if (!alarmScr || alarmScr->enabledCount() == 0) return;
// Calculate X: shift left past audio indicator if it's showing
int rightEdge = batteryLeftX;
if (_task->isAudioPlayingInBackground()) {
display.setTextSize(_node_prefs->smallTextSize());
rightEdge = rightEdge - display.getTextWidth(">>") - 2;
}
display.setColor(DisplayDriver::GREEN);
int x = rightEdge - BELL_ICON_W - 2;
display.drawXbm(x, 1, icon_bell_small, BELL_ICON_W, BELL_ICON_H);
}
#endif
CayenneLPP sensors_lpp;
@@ -276,7 +307,7 @@ public:
_task->setHomeShowingTiles(false); // Reset — only set true on FIRST page
#endif
// node name (tinyfont to avoid overlapping clock)
display.setTextSize(0);
display.setTextSize(_node_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
char filtered_name[sizeof(_node_prefs->node_name)];
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
@@ -290,18 +321,21 @@ public:
display.setCursor(0, HOME_HDR_Y);
display.print(filtered_name);
// battery voltage
// battery voltage + status icons
#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);
// alarm enabled indicator (AL icon, left of audio or battery)
renderAlarmIndicator(display, battLeftX);
#else
renderBatteryIndicator(display, _task->getBattMilliVolts());
#endif
// centered clock (tinyfont) - only show when time is valid
// centered clock only show when time is valid
{
uint32_t now = _rtc->getCurrentTime();
if (now > 1700000000) { // valid timestamp (after ~Nov 2023)
@@ -315,11 +349,14 @@ public:
char timeBuf[6];
sprintf(timeBuf, "%02d:%02d", hrs, mins);
display.setTextSize(0); // tinyfont
display.setTextSize(_node_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
uint16_t tw = display.getTextWidth(timeBuf);
int clockX = (display.width() - tw) / 2;
display.setCursor(clockX, HOME_HDR_Y); // align with node name Y
// Ensure clock doesn't overlap the node name
int nameRight = display.getTextWidth(filtered_name) + 4;
if (clockX < nameRight) clockX = nameRight;
display.setCursor(clockX, HOME_HDR_Y);
display.print(timeBuf);
display.setTextSize(1); // restore
}
@@ -362,17 +399,17 @@ public:
IPAddress ip = WiFi.localIP();
if (ip != IPAddress(0,0,0,0)) {
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
display.setTextSize(0); // Tiny font for IP
display.setTextSize(_node_prefs->smallTextSize()); // Tiny font for IP
display.drawTextCentered(display.width() / 2, y, tmp);
y += 8;
y += _node_prefs->smallLineH() - 1;
}
#endif
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(0); // Tiny font for Connected
display.setTextSize(_node_prefs->smallTextSize()); // Tiny font for Connected
display.drawTextCentered(display.width() / 2, y, "< Connected >");
y += 8; // Reduced from 12
y += _node_prefs->smallLineH() - 1;
#ifdef BLE_PIN_CODE
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
@@ -423,7 +460,7 @@ public:
display.drawXbm(iconX, iconY, tiles[row][col].icon, HOME_ICON_W, HOME_ICON_H);
// Label centered below icon
display.setTextSize(0);
display.setTextSize(_node_prefs->smallTextSize());
display.drawTextCentered(tx + tileW / 2, ty + 18, tiles[row][col].label);
}
}
@@ -431,47 +468,99 @@ public:
// Nav hint below grid
y = gridY + 2 * tileH + gapY + 2;
display.setColor(DisplayDriver::GREEN);
display.setTextSize(0);
display.setTextSize(_node_prefs->smallTextSize());
display.drawTextCentered(display.width() / 2, y, "Tap tile to open");
}
display.setTextSize(1);
#else
// ----- T-Deck Pro: Keyboard shortcut text menu -----
// Menu shortcuts - tinyfont monospaced grid
y += 6;
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0); // tinyfont 6x8 monospaced
display.drawTextCentered(display.width() / 2, y, "Press:");
y += 12;
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
#if HAS_GPS
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
#else
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
#endif
y += 10;
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
#elif defined(HAS_4G_MODEM)
display.drawTextCentered(display.width() / 2, y, "[T] Phone [F] Discover ");
#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
#elif defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [F] Discover ");
#elif defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
#else
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
#endif
y += 14;
display.setTextSize(_node_prefs->smallTextSize());
int menuLH = _node_prefs->smallLineH();
// Nav hint
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
if (_node_prefs->large_font) {
// Proportional font: two-column layout with fixed X positions
y += 2;
int col1 = 2;
int col2 = display.width() / 2;
display.setCursor(col1, y); display.print("[M] Messages");
display.setCursor(col2, y); display.print("[C] Contacts");
y += menuLH;
display.setCursor(col1, y); display.print("[N] Notes");
display.setCursor(col2, y); display.print("[S] Settings");
y += menuLH;
#if HAS_GPS
display.setCursor(col1, y); display.print("[E] Reader");
display.setCursor(col2, y); display.print("[G] Maps");
#else
display.setCursor(col1, y); display.print("[E] Reader");
#endif
y += menuLH;
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
display.setCursor(col1, y); display.print("[T] Phone");
display.setCursor(col2, y); display.print("[B] Browser");
#elif defined(HAS_4G_MODEM)
display.setCursor(col1, y); display.print("[T] Phone");
display.setCursor(col2, y); display.print("[F] Discover");
#elif defined(MECK_AUDIO_VARIANT)
display.setCursor(col1, y); display.print("[P] Audio");
display.setCursor(col2, y); display.print("[K] Alarm");
y += menuLH;
#ifdef MECK_WEB_READER
display.setCursor(col1, y); display.print("[B] Browser");
display.setCursor(col2, y); display.print("[F] Discover");
#else
display.setCursor(col1, y); display.print("[F] Discover");
#endif
#elif defined(MECK_WEB_READER)
display.setCursor(col1, y); display.print("[B] Browser");
#else
display.setCursor(col1, y); display.print("[F] Discover");
#endif
y += menuLH + 2;
} else {
// Monospaced built-in font: centered space-padded strings
y += 6;
display.drawTextCentered(display.width() / 2, y, "Press:");
y += 12;
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
#if HAS_GPS
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
#else
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
#endif
y += 10;
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
#elif defined(HAS_4G_MODEM)
display.drawTextCentered(display.width() / 2, y, "[T] Phone [F] Discover ");
#elif defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [K] Alarm ");
y += 10;
#ifdef MECK_WEB_READER
display.drawTextCentered(display.width() / 2, y, "[B] Browser [F] Discover ");
#else
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
#endif
#elif defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
#else
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
#endif
y += 14;
}
// Nav hint (only if room)
if (y < display.height() - 14) {
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y,
_node_prefs->large_font ? "A/D: cycle views" : "Press A/D to cycle home views");
}
display.setTextSize(1); // restore
#endif
} else if (_page == HomePage::RECENT) {
@@ -501,7 +590,7 @@ public:
}
// Hint for full Last Heard screen
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
display.setTextSize(_node_prefs->smallTextSize());
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(display.width() / 2, display.height() - 24,
"Tap here for full Last Heard list");
@@ -571,19 +660,20 @@ public:
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
int wy = 36;
display.setTextSize(0);
display.setTextSize(_node_prefs->smallTextSize());
int wLH = _node_prefs->smallLineH() + 1;
if (WiFi.status() == WL_CONNECTED) {
display.setColor(DisplayDriver::GREEN);
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 10;
wy += wLH;
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 10;
wy += wLH;
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 12;
wy += wLH + 2;
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
@@ -596,7 +686,7 @@ public:
} else {
display.setColor(DisplayDriver::RED);
display.drawTextCentered(display.width() / 2, wy, "Not connected");
wy += 12;
wy += wLH + 2;
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
}
@@ -697,7 +787,7 @@ public:
display.drawTextCentered(display.width() / 2, by + 4, buf);
// Show controls hint
display.setTextSize(0);
display.setTextSize(_node_prefs->smallTextSize());
display.drawTextCentered(display.width() / 2, by + bh - 10, "W/S:adj Enter:ok Q:cancel");
display.setTextSize(1);
}
@@ -1107,12 +1197,10 @@ public:
}
// ---- Unlock hint ----
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(_node_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 120, "Hold button to unlock");
#else
display.drawTextCentered(display.width() / 2, 120, "Dbl-press to unlock");
#endif
return 30000;
@@ -1198,8 +1286,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
((ChannelScreen*)channel_screen)->setDMUnreadPtr(_dmUnread);
contacts_screen = new ContactsScreen(this, &rtc_clock);
((ContactsScreen*)contacts_screen)->setDMUnreadPtr(_dmUnread);
text_reader = new TextReaderScreen(this);
notes_screen = new NotesScreen(this);
text_reader = new TextReaderScreen(this, node_prefs);
notes_screen = new NotesScreen(this, node_prefs);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
@@ -1208,8 +1296,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
lock_screen = new LockScreen(this, &rtc_clock, node_prefs);
#endif
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#ifdef MECK_AUDIO_VARIANT
alarm_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#endif
#ifdef HAS_4G_MODEM
sms_screen = new SMSScreen(this);
sms_screen = new SMSScreen(this, node_prefs);
#endif
#if HAS_GPS
map_screen = new MapScreen(this);
@@ -1758,6 +1849,21 @@ if (curr) curr->poll();
}
#endif
// Check if settings screen needs VKB for text editing (channel name, freq, APN)
if (isOnSettingsScreen() && !_vkbActive) {
SettingsScreen* ss = (SettingsScreen*)settings_screen;
if (ss->needsTextVKB()) {
ss->clearTextNeedsVKB();
// Pick a context-appropriate label
const char* label = "Edit";
SettingsRowType rt = ss->getCurrentRowType();
if (rt == ROW_NAME) label = "Node Name";
else if (rt == ROW_ADD_CHANNEL) label = "Channel Name";
else if (rt == ROW_FREQ) label = "Frequency";
showVirtualKeyboard(VKB_SETTINGS_TEXT, label, ss->getEditBuf(), 31);
}
}
if (_hintActive && millis() < _hintExpiry) {
// Boot navigation hint overlay — multi-line, larger box
_display->setTextSize(1);
@@ -1893,6 +1999,42 @@ if (curr) curr->poll();
}
#endif
// ── T5S3 standalone powersaving ──────────────────────────────────────────
// When locked with display off, enter ESP32 light sleep (~8 mA total).
// Radio stays in continuous RX — DIO1 going HIGH wakes the CPU instantly.
// Boot button (GPIO0 LOW) and a 30-min safety timer also wake.
// First sleep starts 60s after lock; subsequent cycles wake for 5s to let
// the mesh stack process/relay any received packet, then sleep again.
#if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
if (_locked && _display != NULL && !_display->isOn()) {
unsigned long now = millis();
if (now - _psLastActive >= _psNextSleepSecs * 1000UL) {
Serial.println("[POWERSAVE] Entering light sleep (locked+idle)");
board.sleep(1800); // Light sleep up to 30 min
// ── CPU resumes here on wake ──
unsigned long wakeAt = millis();
_psLastActive = wakeAt;
_psNextSleepSecs = 5; // Stay awake 5s for mesh processing
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
if (cause == ESP_SLEEP_WAKEUP_GPIO) {
// Boot button pressed — unlock and return to normal use
Serial.println("[POWERSAVE] Woke by button — unlocking");
unlockScreen();
_psNextSleepSecs = 60; // Reset to long delay after user interaction
} else if (cause == ESP_SLEEP_WAKEUP_EXT1) {
Serial.println("[POWERSAVE] Woke by LoRa packet");
} else if (cause == ESP_SLEEP_WAKEUP_TIMER) {
Serial.println("[POWERSAVE] Woke by timer");
}
}
} else if (!_locked) {
// Not locked — keep powersaving timer reset so first sleep is 60s after lock
_psLastActive = millis();
_psNextSleepSecs = 60;
}
#endif
#ifdef PIN_VIBRATION
vibration.loop();
#endif
@@ -2019,6 +2161,10 @@ void UITask::lockScreen() {
_next_refresh = 0; // Draw lock screen immediately
_auto_off = millis() + 60000; // 60s before display off while locked
_lastLockRefresh = millis(); // Start 15-min clock refresh cycle
#if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
_psLastActive = millis(); // Start powersaving countdown (60s to first sleep)
_psNextSleepSecs = 60;
#endif
Serial.println("[UI] Screen locked — entering low-power mode");
}
@@ -2141,6 +2287,19 @@ void UITask::onVKBSubmit() {
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
break;
}
case VKB_SETTINGS_TEXT: {
// Generic settings text edit — copy text back to settings edit buffer
// and confirm via the normal Enter path (handles name/freq/channel/APN)
SettingsScreen* ss = (SettingsScreen*)settings_screen;
if (strlen(text) > 0) {
ss->submitEditText(text);
} else {
// Empty submission — cancel the edit
ss->handleInput('q');
}
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
break;
}
case VKB_NOTES: {
NotesScreen* notes = (NotesScreen*)getNotesScreen();
if (notes && strlen(text) > 0) {
@@ -2481,6 +2640,22 @@ void UITask::gotoAudiobookPlayer() {
#endif
}
#ifdef MECK_AUDIO_VARIANT
void UITask::gotoAlarmScreen() {
if (alarm_screen == nullptr) return;
AlarmScreen* alarmScr = (AlarmScreen*)alarm_screen;
if (_display != NULL) {
alarmScr->enter(*_display);
}
setCurrScreen(alarm_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
#ifdef HAS_4G_MODEM
void UITask::gotoSMSScreen() {
SMSScreen* smsScr = (SMSScreen*)sms_screen;
@@ -2611,7 +2786,7 @@ void UITask::gotoWebReader() {
if (web_reader == nullptr) {
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
web_reader = new WebReaderScreen(this);
web_reader = new WebReaderScreen(this, _node_prefs);
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
}
WebReaderScreen* wr = (WebReaderScreen*)web_reader;

View File

@@ -30,6 +30,10 @@
#include "WebReaderScreen.h"
#endif
#ifdef MECK_AUDIO_VARIANT
#include "AlarmScreen.h"
#endif
#if defined(LilyGo_T5S3_EPaper_Pro)
#include "VirtualKeyboard.h"
#endif
@@ -82,6 +86,9 @@ class UITask : public AbstractUITask {
UIScreen* notes_screen; // Notes editor screen
UIScreen* settings_screen; // Settings/onboarding screen
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
#ifdef MECK_AUDIO_VARIANT
UIScreen* alarm_screen; // Alarm clock screen (audio variant only)
#endif
#ifdef HAS_4G_MODEM
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
#endif
@@ -106,6 +113,13 @@ class UITask : public AbstractUITask {
bool _vkbActive = false;
UIScreen* _screenBeforeVKB = nullptr;
unsigned long _vkbOpenedAt = 0;
// Powersaving: light sleep when locked + idle (standalone only — no BLE/WiFi)
// Wakes on LoRa packet (DIO1), boot button (GPIO0), or 30-min timer
#if !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
unsigned long _psLastActive = 0; // millis() at last wake or lock entry
unsigned long _psNextSleepSecs = 60; // Seconds before first sleep (60s), then 5s cycles
#endif
#ifdef MECK_CARDKB
bool _cardkbDetected = false;
#endif
@@ -172,6 +186,9 @@ public:
void gotoSettingsScreen(); // Navigate to settings
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
#ifdef MECK_AUDIO_VARIANT
void gotoAlarmScreen(); // Navigate to alarm clock
#endif
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
void gotoRepeaterAdminDirect(int contactIdx); // Auto-login admin (L key from conversation)
void gotoDiscoveryScreen(); // Navigate to node discovery scan
@@ -221,6 +238,9 @@ public:
bool isOnNotesScreen() const { return curr == notes_screen; }
bool isOnSettingsScreen() const { return curr == settings_screen; }
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
#ifdef MECK_AUDIO_VARIANT
bool isOnAlarmScreen() const { return curr == alarm_screen; }
#endif
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
bool isOnLastHeardScreen() const { return curr == last_heard_screen; }
@@ -286,8 +306,13 @@ public:
UIScreen* getContactsScreen() const { return contacts_screen; }
UIScreen* getChannelScreen() const { return channel_screen; }
UIScreen* getSettingsScreen() const { return settings_screen; }
NodePrefs* getNodePrefs() const { return _node_prefs; }
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
#ifdef MECK_AUDIO_VARIANT
UIScreen* getAlarmScreen() const { return alarm_screen; }
void setAlarmScreen(UIScreen* s) { alarm_screen = s; }
#endif
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
UIScreen* getLastHeardScreen() const { return last_heard_screen; }

View File

@@ -39,6 +39,7 @@
#include "ModemManager.h"
#endif
#include "Utf8CP437.h"
#include "../NodePrefs.h"
// Forward declarations
class UITask;
@@ -1030,8 +1031,10 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
Mode _mode;
bool _initialized;
uint8_t _lastFontPref;
DisplayDriver* _display;
// Display layout (calculated once)
@@ -1424,7 +1427,7 @@ private:
_display->print("WiFi Setup");
_display->drawRect(0, 11, _display->width(), 1);
_display->setColor(DisplayDriver::LIGHT);
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Scanning for networks...");
_display->endFrame();
@@ -1524,7 +1527,7 @@ private:
_display->print("Web Reader");
_display->drawRect(0, 11, _display->width(), 1);
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Connected!");
_display->setCursor(0, 30);
@@ -2306,7 +2309,7 @@ private:
_display->print("Web Reader");
_display->drawRect(0, 11, _display->width(), 1);
_display->setColor(DisplayDriver::YELLOW);
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Fetch failed:");
_display->setColor(DisplayDriver::LIGHT);
@@ -2442,7 +2445,7 @@ private:
_display->setTextSize(2);
_display->setCursor(10, 20);
_display->print("Logging in...");
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(10, 45);
_display->print("Refreshing session...");
@@ -2656,14 +2659,14 @@ private:
display.drawRect(0, 11, display.width(), 1);
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
if (_wifiState == WIFI_SCANNING) {
display.setCursor(0, 18);
display.print("Scanning for networks...");
} else if (_wifiState == WIFI_SCAN_DONE) {
int y = 14;
int listLineH = 8;
int listLineH = _prefs ? _prefs->smallLineH() : 9;
for (int i = 0; i < _ssidCount && y < display.height() - 24; i++) {
bool selected = (i == _selectedSSID);
if (selected) {
@@ -2671,7 +2674,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), listLineH);
#else
display.fillRect(0, y + 5, display.width(), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -2695,7 +2698,7 @@ private:
y += 12;
display.setCursor(0, y);
display.print("Password:");
y += 10;
y += _prefs->smallLineH() + 1;
display.setCursor(0, y);
// Show masked password with brief reveal of last char
char passBuf[WEB_WIFI_PASS_LEN + 2];
@@ -2771,7 +2774,7 @@ private:
if (isNetworkAvailable()) {
display.print("Web Reader");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
if (isWiFiConnected()) {
IPAddress ip = WiFi.localIP();
@@ -2797,7 +2800,7 @@ private:
const int footerY = display.height() - 12;
const int viewportH = display.height() - headerY - footerH;
const int scrollbarW = 4;
const int listLineH = 8;
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
const int sepH = 8; // Separator between IRC and web sections
const int sectionH = listLineH; // Section header height
int maxChars = _charsPerLine - 2; // Account for "> " prefix
@@ -2875,7 +2878,7 @@ private:
if (totalContentH <= viewportH) _homeScrollY = 0;
// ---- Render pass (with scroll offset) ----
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
int y = headerY - _homeScrollY; // Start Y in screen coords
itemIdx = 0;
bool needsScroll = (totalContentH > viewportH);
@@ -2895,7 +2898,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -2934,7 +2937,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -2971,7 +2974,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -3024,7 +3027,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, itemH);
#else
display.fillRect(0, y + 5, contentW, itemH);
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -3076,7 +3079,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, itemH);
#else
display.fillRect(0, y + 5, contentW, itemH);
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -3198,7 +3201,7 @@ private:
display.setCursor(10, 20);
display.print("Loading...");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
// Word-wrap the URL across multiple lines
@@ -3243,7 +3246,7 @@ private:
display.print("Download Complete");
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 16);
display.print("Saved to /books/:");
@@ -3277,7 +3280,7 @@ private:
display.print("Download Failed");
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 18);
display.print(_fetchError.c_str());
@@ -3314,7 +3317,7 @@ private:
return;
}
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
// Determine page bounds
@@ -3476,9 +3479,16 @@ private:
// ---- Layout Initialization ----
void initLayout(DisplayDriver& display) {
// Re-init if font preference changed since last layout
uint8_t curFont = _prefs ? _prefs->large_font : 0;
if (_initialized && curFont != _lastFontPref) {
_initialized = false;
Serial.println("WebReader: font changed, recalculating layout");
}
if (_initialized) return;
_lastFontPref = curFont;
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
uint16_t mWidth = display.getTextWidth("M");
if (mWidth > 0) {
_charsPerLine = display.width() / mWidth;
@@ -3487,6 +3497,19 @@ private:
_charsPerLine = 40;
_lineHeight = 5;
}
// Proportional font: use average-width measurement instead of M-width
if (_prefs && _prefs->large_font && mWidth > 0) {
const char* sample = "the quick brown fox jumps over lazy dog";
uint16_t sampleW = display.getTextWidth(sample);
int sampleLen = strlen(sample);
if (sampleW > 0 && sampleLen > 0) {
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
}
}
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
if (_prefs && _prefs->large_font) {
_lineHeight = _prefs->smallLineH();
}
_footerHeight = 14;
int textAreaHeight = display.height() - _footerHeight;
@@ -3931,7 +3954,7 @@ private:
if (_activeForm < 0 || _activeForm >= _formCount) return;
WebForm& form = _forms[_activeForm];
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
// Header
display.setColor(DisplayDriver::GREEN);
@@ -3954,7 +3977,7 @@ private:
display.drawRect(0, 9, display.width(), 1);
int y = 12;
int lineH = 10; // Taller lines for form fields
int lineH = _prefs->smallLineH() + 1; // Taller lines for form fields
int visCount = getVisibleFieldCount(form);
// Render each visible field
@@ -4662,9 +4685,9 @@ private:
display.print("IRC Setup");
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
int y = 16;
int lineH = 10;
int lineH = _prefs->smallLineH() + 1;
const char* labels[] = {"Server:", "Port:", "Nick:", "Channel:", "[ Connect ]"};
const char* chanDisp = (_ircChannel[0] != '\0') ? _ircChannel : "(none)";
@@ -4822,7 +4845,7 @@ private:
display.print(header);
// Connection indicator on right
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
if (!_ircConnected) {
display.setColor(DisplayDriver::YELLOW);
display.setCursor(display.width() - 42, -3);
@@ -4848,7 +4871,7 @@ private:
if (_ircComposing) {
// Compose text just above separator (tiny font to match messages)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, footerY - 12);
char compDisp[IRC_COMPOSE_MAX + 4];
@@ -4878,10 +4901,10 @@ private:
}
// Message area
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
int msgAreaTop = 14;
int msgAreaBottom = _ircComposing ? footerY - 16 : footerY - 4;
int lineH = 8;
int lineH = _prefs->smallLineH() - 1;
int scrollBarW = 4;
int lineW = _charsPerLine - 1; // Reserve space for scroll bar
_ircLinesPerPage = (msgAreaBottom - msgAreaTop) / lineH;
@@ -5065,8 +5088,8 @@ private:
}
public:
WebReaderScreen(UITask* task)
: _task(task), _mode(HOME), _initialized(false), _display(nullptr),
WebReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _mode(HOME), _initialized(false), _lastFontPref(0), _display(nullptr),
_charsPerLine(40), _linesPerPage(15), _lineHeight(5), _footerHeight(14),
_wifiState(WIFI_IDLE), _ssidCount(0), _selectedSSID(0), _wifiPassLen(0),
_urlLen(0), _urlCursor(0),
@@ -5150,7 +5173,7 @@ public:
_display->print("Web Reader");
_display->drawRect(0, 11, _display->width(), 1);
_display->setColor(DisplayDriver::LIGHT);
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Connecting to WiFi...");
_display->endFrame();

View File

@@ -46,4 +46,18 @@ static const uint8_t icon_notepad[] PROGMEM = {
static const uint8_t icon_search[] PROGMEM = {
0x3C,0x00, 0x42,0x00, 0x81,0x00, 0x81,0x00, 0x81,0x00, 0x42,0x00,
0x3C,0x00, 0x03,0x00, 0x01,0x80, 0x00,0xC0, 0x00,0x40, 0x00,0x00,
};
// ⏰ Alarm Clock (AlarmScreen) — 12x12 home tile icon
static const uint8_t icon_alarm[] PROGMEM = {
0x40,0x40, 0x9E,0x20, 0x20,0x80, 0x44,0x40, 0x44,0x40, 0x46,0x40,
0x40,0x40, 0x20,0x80, 0x1F,0x00, 0x00,0x00, 0x20,0x40, 0x40,0x20,
};
// 🔔 Bell — 7x8 status bar indicator (alarm enabled)
// MSB-first, 1 byte per row
#define BELL_ICON_W 7
#define BELL_ICON_H 8
static const uint8_t icon_bell_small[] PROGMEM = {
0x10, 0x38, 0x7C, 0x7C, 0x7C, 0xFE, 0x00, 0x10,
};

View File

@@ -36,7 +36,7 @@ uint32_t Dispatcher::getCADFailRetryDelay() const {
return 200;
}
uint32_t Dispatcher::getCADFailMaxDuration() const {
return 4000; // 4 seconds
return 6000; // 6 seconds
}
void Dispatcher::loop() {
@@ -52,10 +52,28 @@ void Dispatcher::loop() {
prev_isrecv_mode = is_recv;
if (!is_recv) {
radio_nonrx_start = _ms->getMillis();
} else {
rx_stuck_count = 0; // radio recovered — reset counter
}
}
if (!is_recv && _ms->getMillis() - radio_nonrx_start > 8000) { // radio has not been in Rx mode for 8 seconds!
_err_flags |= ERR_EVENT_STARTRX_TIMEOUT;
rx_stuck_count++;
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX stuck (attempt %d), calling onRxStuck()", getLogDateTime(), rx_stuck_count);
onRxStuck();
uint8_t reboot_threshold = getRxFailRebootThreshold();
if (reboot_threshold > 0 && rx_stuck_count >= reboot_threshold) {
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX unrecoverable after %d attempts", getLogDateTime(), rx_stuck_count);
onRxUnrecoverable();
}
// Reset state to give recovery the full 8s window before re-triggering
radio_nonrx_start = _ms->getMillis();
prev_isrecv_mode = true;
cad_busy_start = 0;
next_agc_reset_time = futureMillis(getAGCResetInterval());
}
if (outbound) { // waiting for outbound send to be completed
@@ -273,14 +291,31 @@ void Dispatcher::checkSend() {
outbound_start = _ms->getMillis();
bool success = _radio->startSendRaw(raw, len);
if (!success) {
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): ERROR: send start failed!", getLogDateTime());
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): ERROR: send start failed!", getLogDateTime());
logTxFail(outbound, outbound->getRawLength());
releasePacket(outbound); // return to pool
// re-queue packet for retry instead of dropping it
int retry_delay = getCADFailRetryDelay();
unsigned long retry_time = futureMillis(retry_delay);
_mgr->queueOutbound(outbound, 0, retry_time);
outbound = NULL;
next_tx_time = retry_time;
// count consecutive failures and reset radio if stuck
uint8_t threshold = getTxFailResetThreshold();
if (threshold > 0) {
tx_fail_count++;
if (tx_fail_count >= threshold) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): TX stuck (%d failures), resetting radio", getLogDateTime(), tx_fail_count);
onTxStuck();
tx_fail_count = 0;
next_tx_time = futureMillis(2000);
}
}
return;
}
tx_fail_count = 0; // clear counter on successful TX start
outbound_expiry = futureMillis(max_airtime);
#if MESH_PACKET_LOGGING

View File

@@ -122,6 +122,8 @@ class Dispatcher {
bool prev_isrecv_mode;
uint32_t n_sent_flood, n_sent_direct;
uint32_t n_recv_flood, n_recv_direct;
uint8_t tx_fail_count;
uint8_t rx_stuck_count;
void processRecvPacket(Packet* pkt);
@@ -142,6 +144,8 @@ protected:
_err_flags = 0;
radio_nonrx_start = 0;
prev_isrecv_mode = true;
tx_fail_count = 0;
rx_stuck_count = 0;
}
virtual DispatcherAction onRecvPacket(Packet* pkt) = 0;
@@ -159,6 +163,11 @@ protected:
virtual uint32_t getCADFailMaxDuration() const;
virtual int getInterferenceThreshold() const { return 0; } // disabled by default
virtual int getAGCResetInterval() const { return 0; } // disabled by default
virtual uint8_t getTxFailResetThreshold() const { return 3; } // reset radio after N consecutive TX failures; 0=disabled
virtual void onTxStuck() { _radio->resetAGC(); } // override to use doFullRadioReset() when available
virtual uint8_t getRxFailRebootThreshold() const { return 3; } // reboot after N failed RX recovery attempts; 0=disabled
virtual void onRxStuck() { _radio->resetAGC(); } // called each time RX stuck for 8s; override for deeper reset
virtual void onRxUnrecoverable() { } // called when reboot threshold exceeded; override to call _board->reboot()
public:
void begin();
@@ -188,4 +197,4 @@ private:
void checkSend();
};
}
}

View File

@@ -10,10 +10,10 @@
#endif
void BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
sendFlood(pkt, delay_millis);
sendFlood(pkt, delay_millis, getPathHashSize());
}
void BaseChatMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
sendFlood(pkt, delay_millis);
sendFlood(pkt, delay_millis, getPathHashSize());
}
mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) {

View File

@@ -130,6 +130,7 @@ protected:
virtual void onContactResponse(const ContactInfo& contact, const uint8_t* data, uint8_t len) = 0;
virtual void handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len);
virtual uint8_t getPathHashSize() const = 0;
virtual void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0);
virtual void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0);

View File

@@ -10,6 +10,7 @@
#include <Wire.h>
#include "esp_wifi.h"
#include "driver/rtc_io.h"
#include "driver/gpio.h"
class ESP32Board : public mesh::MainBoard {
protected:
@@ -60,13 +61,20 @@ public:
#if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(P_LORA_DIO_1) // Supported ESP32 variants
if (rtc_gpio_is_valid_gpio((gpio_num_t)P_LORA_DIO_1)) { // Only enter sleep mode if P_LORA_DIO_1 is RTC pin
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // To wake up when receiving a LoRa packet
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // Wake on LoRa packet
// T5S3: Also wake on boot button press (GPIO0, active LOW).
// gpio_wakeup uses level trigger — works for light sleep only.
#if defined(LilyGo_T5S3_EPaper_Pro) && defined(PIN_USER_BTN)
gpio_wakeup_enable((gpio_num_t)PIN_USER_BTN, GPIO_INTR_LOW_LEVEL);
esp_sleep_enable_gpio_wakeup();
#endif
if (secs > 0) {
esp_sleep_enable_timer_wakeup(secs * 1000000); // To wake up every hour to do periodically jobs
esp_sleep_enable_timer_wakeup(secs * 1000000ULL); // Timer wake (microseconds)
}
esp_light_sleep_start(); // CPU enters light sleep
esp_light_sleep_start(); // CPU halts here, resumes on wake
}
#endif
}
@@ -154,4 +162,4 @@ public:
}
};
#endif
#endif

View File

@@ -185,7 +185,7 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
return 0;
}
#define BLE_WRITE_MIN_INTERVAL 60
#define BLE_WRITE_MIN_INTERVAL 30
bool SerialBLEInterface::isWriteBusy() const {
return millis() < _last_write + BLE_WRITE_MIN_INTERVAL; // still too soon to start another write?

View File

@@ -23,7 +23,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
uint8_t buf[MAX_FRAME_SIZE];
};
#define FRAME_QUEUE_SIZE 4
#define FRAME_QUEUE_SIZE 8
int recv_queue_len;
Frame recv_queue[FRAME_QUEUE_SIZE];
int send_queue_len;

View File

@@ -188,9 +188,15 @@ int16_t T5S3Board::getBattTemperature() {
}
// ---- BQ27220 Design Capacity configuration ----
// Identical procedure to TDeckBoard — sets 1500 mAh for T5S3's larger cell.
// The BQ27220 ships with 3000 mAh default. This writes once on first boot
// and persists in battery-backed RAM.
// The BQ27220 ships with a 3000 mAh default. T5S3 uses a 1500 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).
//
// When DC and DE are already correct but FCC is stuck (common after initial
// flash), the root cause is Qmax Cell 0 (0x9106) and stored FCC (0x929D)
// retaining factory 3000 mAh defaults. This function detects and fixes all
// three layers: DC/DE, Qmax, and stored FCC.
bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
#if HAS_BQ27220
@@ -198,23 +204,169 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
if (currentDC == designCapacity_mAh) {
// Design Capacity correct, but check if Full Charge Capacity is sane.
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: Design Capacity correct, FCC=%d mAh\n", fcc);
if (fcc < designCapacity_mAh * 3 / 2) {
return true; // FCC is sane, nothing to do
Serial.printf("BQ27220: Design Capacity already correct, FCC=%d mAh\n", fcc);
if (fcc >= designCapacity_mAh * 3 / 2) {
// FCC is >=150% of design — stale from factory defaults (typically 3000 mAh).
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
fcc, designCapacity_mAh, designEnergy);
// Unseal to read data memory and issue RESET
bq27220_writeControl(0x0414); delay(2);
bq27220_writeControl(0x3672); delay(2);
// Full Access
bq27220_writeControl(0xFFFF); delay(2);
bq27220_writeControl(0xFFFF); delay(2);
// Enter CFG_UPDATE to access data memory
bq27220_writeControl(0x0090);
bool ready = false;
for (int i = 0; i < 50; i++) {
delay(20);
uint16_t opSt = bq27220_read16(BQ27220_REG_OP_STATUS);
if (opSt & 0x0400) { ready = true; break; }
}
if (ready) {
// Read Design Energy at data memory address 0x92A1
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint16_t currentDE = (oldMSB << 8) | oldLSB;
if (currentDE != designEnergy) {
// Design Energy actually needs updating — write it
uint8_t oldChk = bq27220_read8(0x60);
uint8_t dLen = bq27220_read8(0x61);
uint8_t newMSB = (designEnergy >> 8) & 0xFF;
uint8_t newLSB = designEnergy & 0xFF;
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
Serial.printf("BQ27220: DE old=%d new=%d mWh, writing\n", currentDE, designEnergy);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.write(newMSB); Wire.write(newLSB);
Wire.endTransmission();
delay(5);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60); Wire.write(newChk); Wire.write(dLen);
Wire.endTransmission();
delay(10);
// Exit with reinit since we actually changed data
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
delay(200);
Serial.println("BQ27220: Design Energy written, exited CFG_UPDATE");
} else {
// DC and DE are both correct, but FCC is stuck.
// Root cause: Qmax Cell 0 (0x9106) and stored FCC (0x929D) retain
// factory 3000 mAh defaults. Overwrite both with designCapacity_mAh.
Serial.printf("BQ27220: DE correct (%d mWh) — fixing Qmax + stored FCC\n", currentDE);
// --- Helper lambda for MAC data memory 2-byte write ---
// Reads old value + checksum, computes differential checksum, writes new value.
auto writeDM16 = [](uint16_t addr, uint16_t newVal) -> bool {
// Select address
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E);
Wire.write(addr & 0xFF);
Wire.write((addr >> 8) & 0xFF);
Wire.endTransmission();
delay(10);
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint8_t oldChk = bq27220_read8(0x60);
uint8_t dLen = bq27220_read8(0x61);
uint16_t oldVal = (oldMSB << 8) | oldLSB;
if (oldVal == newVal) {
Serial.printf("BQ27220: [0x%04X] already %d, skip\n", addr, newVal);
return true; // already correct
}
uint8_t newMSB = (newVal >> 8) & 0xFF;
uint8_t newLSB = newVal & 0xFF;
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
Serial.printf("BQ27220: [0x%04X] %d -> %d\n", addr, oldVal, newVal);
// Write new value
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E);
Wire.write(addr & 0xFF);
Wire.write((addr >> 8) & 0xFF);
Wire.write(newMSB);
Wire.write(newLSB);
Wire.endTransmission();
delay(5);
// Write checksum
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60);
Wire.write(newChk);
Wire.write(dLen);
Wire.endTransmission();
delay(10);
return true;
};
// Overwrite Qmax Cell 0 (IT Cfg) — this is what FCC is derived from
writeDM16(0x9106, designCapacity_mAh);
// Overwrite stored FCC reference (Gas Gauging, 2 bytes before DC)
writeDM16(0x929D, designCapacity_mAh);
// Exit with reinit to apply the new values
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
delay(200);
Serial.println("BQ27220: Qmax + stored FCC updated, exited CFG_UPDATE");
}
} else {
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE check");
}
// Seal first, then issue RESET.
// RESET forces the gauge to fully reinitialize its Impedance Track
// algorithm and recalculate FCC from the current DC/DE values.
bq27220_writeControl(0x0030); // SEAL
delay(5);
Serial.println("BQ27220: Issuing RESET to force FCC recalculation...");
bq27220_writeControl(0x0041); // RESET
delay(2000); // Full reset needs generous settle time
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: FCC after RESET: %d mAh (target <= %d)\n", fcc, designCapacity_mAh);
if (fcc > designCapacity_mAh * 3 / 2) {
// RESET didn't fix FCC — the gauge IT algorithm is stubbornly
// retaining its learned value. This typically resolves after one
// full charge/discharge cycle. Software clamp in
// getFullChargeCapacity() ensures correct display regardless.
Serial.printf("BQ27220: FCC still stale at %d — software clamp active\n", fcc);
}
}
// FCC is stale from factory — fall through to reconfigure
Serial.printf("BQ27220: FCC %d >> DC %d, reconfiguring\n", fcc, designCapacity_mAh);
return true;
}
// Unseal
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);
// Full Access
// Step 2: Full Access
bq27220_writeControl(0xFFFF); delay(2);
bq27220_writeControl(0xFFFF); delay(2);
// Enter CFG_UPDATE
// Step 3: Enter CFG_UPDATE
bq27220_writeControl(0x0090);
bool cfgReady = false;
for (int i = 0; i < 50; i++) {
@@ -229,7 +381,7 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
return false;
}
// Write Design Capacity at 0x929F
// Step 4: Write Design Capacity at 0x929F
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0x9F); Wire.write(0x92);
Wire.endTransmission();
@@ -255,7 +407,7 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
Wire.endTransmission();
delay(10);
// Write Design Energy at 0x92A1
// Step 4a: Write Design Energy at 0x92A1
{
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
Wire.beginTransmission(BQ27220_I2C_ADDR);
@@ -271,6 +423,9 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
uint8_t deTemp = (255 - deOldChk - deOldMSB - deOldLSB);
uint8_t deNewChk = 255 - ((deTemp + deNewMSB + deNewLSB) & 0xFF);
Serial.printf("BQ27220: Design Energy: old=%d new=%d mWh\n",
(deOldMSB << 8) | deOldLSB, designEnergy);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.write(deNewMSB); Wire.write(deNewLSB);
@@ -282,16 +437,17 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
delay(10);
}
// Exit CFG_UPDATE with reinit
// Step 5: Exit CFG_UPDATE with reinit
bq27220_writeControl(0x0091);
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
delay(200);
// Seal
// Step 6: Seal
bq27220_writeControl(0x0030);
delay(5);
// Force RESET to reinitialize FCC
bq27220_writeControl(0x0041);
// Step 7: Force RESET to reinitialize FCC from new DC/DE
bq27220_writeControl(0x0041); // RESET
delay(1000);
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
@@ -302,4 +458,4 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
#else
return false;
#endif
}
}

View File

@@ -64,6 +64,7 @@ build_src_filter = ${esp32_base.build_src_filter}
lib_deps =
${esp32_base.lib_deps}
WebServer
DNSServer
Update
; ---------------------------------------------------------------------------

View File

@@ -97,6 +97,7 @@ lib_deps =
adafruit/Adafruit GFX Library@^1.11.0
bitbank2/PNGdec@^1.0.1
WebServer
DNSServer
Update
; ---------------------------------------------------------------------------
@@ -150,7 +151,7 @@ build_flags =
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.3.WiFi"'
-D FIRMWARE_VERSION='"Meck v1.5.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -203,7 +204,7 @@ build_flags =
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.3.4G"'
-D FIRMWARE_VERSION='"Meck v1.5.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -234,7 +235,7 @@ build_flags =
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.3.4G.WiFi"'
-D FIRMWARE_VERSION='"Meck v1.5.4G.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -261,7 +262,7 @@ build_flags =
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.3.4G.SA"'
-D FIRMWARE_VERSION='"Meck v1.5.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>