29 Commits
v1.2 ... 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
pelgraine
19efb52521 udpate readme 2026-03-23 15:16:57 +11:00
pelgraine
81ef3ea3c5 update hint text for nav hint for first-time flashers; fix spiffs failure for first-time flash boot 2026-03-23 14:59:31 +11:00
pelgraine
6f07b7a372 update readme to do 2026-03-23 13:36:54 +11:00
pelgraine
b0f74b101a tdpro - update firmware build date; improve keyboard responsiveness after boot 2026-03-23 13:33:23 +11:00
pelgraine
06a064538e fix lock screen bug cpupowermanager issue 2026-03-22 22:56:28 +11:00
pelgraine
166a433353 td pro - fix missing F discover prompt on home screen for standalone variants 2026-03-22 19:58:12 +11:00
pelgraine
735fefd203 update readme 2026-03-22 18:50:59 +11:00
pelgraine
ed5cda4f44 readme update for v1.3 2026-03-22 18:49:38 +11:00
pelgraine
b208af83f6 t5s3 ota 2026-03-22 16:11:37 +11:00
pelgraine
bad821ac4b tdpro ota update firmware functionality implemented; roomserver ui sender name display fix and speed up delivery time 2026-03-22 13:16:25 +11:00
pelgraine
8839012153 firmware build date 2026-03-22 11:49:47 +11:00
pelgraine
0958ef079e Fix T5S3 word wrap regression ereader; persist dark mode, portrait mode, baudrate, and auto lock timer in data store 2026-03-22 11:48:57 +11:00
pelgraine
0bf2826110 roomserver additions stage 2 and dm ui functionality updates 2026-03-22 10:51:59 +11:00
pelgraine
c2840a43aa roomserver additions stage 1; dms ui functionality improvements; removed t-deck plus device variant 2026-03-21 21:27:20 +11:00
pelgraine
e8a8be521a update firmware version and build date 2026-03-21 18:39:06 +11:00
pelgraine
a627fbe0e9 t5s3 - fix for del channel ui and touch function 2026-03-20 23:20:20 +11:00
40 changed files with 5182 additions and 803 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)
@@ -8,10 +8,12 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
### Contents
- [Supported Devices](#supported-devices)
- [SD Card Requirements](#sd-card-requirements)
- [Flashing Firmware](#flashing-firmware)
- [First-Time Flash (Merged Firmware)](#first-time-flash-merged-firmware)
- [Upgrading Firmware](#upgrading-firmware)
- [SD Card Launcher](#sd-card-launcher)
- [Launcher](#launcher)
- [OTA Firmware Update](#ota-firmware-update-v13)
- [Path Hash Mode (v0.9.9+)](#path-hash-mode-v099)
- [T-Deck Pro](#t-deck-pro)
- [Build Variants](#t-deck-pro-build-variants)
@@ -23,6 +25,7 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
- [Channel Message Screen](#channel-message-screen)
- [Contacts Screen](#contacts-screen)
- [Sending a Direct Message](#sending-a-direct-message)
- [Roomservers](#roomservers)
- [Repeater Admin Screen](#repeater-admin-screen)
- [Settings Screen](#settings-screen)
- [Compose Mode](#compose-mode)
@@ -74,6 +77,14 @@ Both devices use the ESP32-S3 with 16 MB flash and 8 MB PSRAM.
---
## SD Card Requirements
**An SD card is essential for Meck to function properly.** Many features — including the e-book reader, notes, bookmarks, web reader cache, audiobook playback, firmware updates, contact import/export, and WiFi credential storage — rely on files stored on the SD card. Without an SD card inserted, the device will boot and handle mesh messaging, but most extended features will be unavailable or will fail silently.
**Recommended:** A **32 GB or larger** microSD card formatted as **FAT32**. MeshCore users have found that **SanDisk** microSD cards are the most reliable across both the T-Deck Pro and T5S3.
---
## Flashing Firmware
Download the latest firmware from the [Releases](https://github.com/pelgraine/Meck/releases) page. Each release includes two types of `.bin` files per build variant:
@@ -118,10 +129,25 @@ esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
> **Tip:** If you're unsure whether the device already has a bootloader, it's always safe to use the merged file and flash at `0x0` — it will overwrite everything cleanly.
### SD Card Launcher
### Launcher
If you're loading firmware from an SD card via the LilyGo Launcher firmware, use the **non-merged** `.bin` file. The Launcher provides its own bootloader and only needs the application image.
### OTA Firmware Update (v1.3+)
Once Meck is installed, you can update firmware directly from your phone — no computer or serial cable required. The device creates a temporary WiFi access point and you upload the new `.bin` via your phone's browser.
1. Download the new **non-merged** `.bin` to your phone (from GitHub Releases, Discord, etc.)
2. On the device: **Settings → Firmware Update → Enter** (T-Deck Pro) or **tap** (T5S3)
3. The device starts a WiFi network called `Meck-Update-XXXX` and displays connection details
4. On your phone: connect to the `Meck-Update` WiFi network, open a browser, go to `192.168.4.1`
5. Tap **Choose File**, select the `.bin`, tap **Upload**
6. The device receives the file, saves to SD, verifies, flashes, and reboots
The partition layout supports dual OTA slots — the old firmware remains on the inactive partition as an automatic rollback target. If the new firmware fails to boot, the ESP32 bootloader reverts to the previous working version automatically.
> **Note:** Use the **non-merged** `.bin` for OTA updates. The merged binary is only needed for first-time USB flashing.
---
## Path Hash Mode (v0.9.9+)
@@ -236,7 +262,7 @@ The GPS page also shows the current time, satellite count, position, altitude, a
| Key | Action |
|-----|--------|
| W / S | Scroll messages up/down |
| A / D | Switch between channels |
| A / D | Switch between channels (press D past the last channel to reach the DM inbox, A to return) |
| Enter | Compose new message |
| R | Reply to a message — enter reply select mode, scroll to a message with W/S, then press Enter to compose a reply with an @mention |
| V | View relay path of the last received message (scrollable, up to 20 hops) |
@@ -261,6 +287,18 @@ Press **C** from the home screen to open the contacts list. All known mesh conta
Select a **Chat** contact in the contacts list and press **Enter** to start composing a direct message. The compose screen will show `DM: ContactName` in the header. Type your message and press **Enter** to send. The DM is sent encrypted directly to that contact (or flooded if no direct path is known). After sending or cancelling, you're returned to the contacts list.
Contacts with unread direct messages show a `*` marker next to their name in the contacts list.
**Reading received DMs:** On the Channel Messages screen, press **D** past the last group channel to reach the **DM inbox**. This shows all received direct messages with sender name and timestamp. Entering the DM inbox marks all DM messages as read and clears the unread indicator. Press **A** to return to group channels.
### Roomservers
Room servers are MeshCore nodes that host persistent chat rooms. Messages sent to a room server are stored and relayed to anyone who logs in. In Meck, room server messages arrive as contact messages and appear in the DM inbox alongside regular direct messages.
To interact with a room server, navigate to the Contacts screen, filter to **Room** contacts, select the room, and press **Enter** to open the Repeater Admin screen. Log in with the room's admin password to access room administration. On successful login, all unread messages from that room are automatically marked as read.
Room server messages are also synced to the companion app when connected via BLE or WiFi — the companion app will pull and display them alongside other messages.
### Repeater Admin Screen
Select a **Repeater** contact in the contacts list and press **Enter** to open the repeater admin screen. You'll be prompted for the repeater's admin password. Characters briefly appear as you type them before being masked, making it easier to enter symbols and numbers on the T-Deck Pro keyboard.
@@ -531,7 +569,7 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
| Gesture | Action |
|---------|--------|
| Swipe up / down | Scroll messages |
| Swipe left / right | Switch between channels |
| Swipe left / right | Switch between channels (swipe left past the last channel to reach the DM inbox) |
| Tap footer area | View relay path of last received message |
| Tap path overlay | Dismiss overlay |
| Long press (touch) | Open virtual keyboard to compose message to current channel |
@@ -543,7 +581,7 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
| Swipe up / down | Scroll through contacts |
| Swipe left / right | Cycle contact filter (All → Chat → Repeater → Room → Sensor → Favourites) |
| Tap | Select contact |
| Long press on Chat contact | Open virtual keyboard to compose DM |
| Long press on Chat contact | View unread DMs (if any), then compose DM |
| Long press on Repeater contact | Open repeater admin login |
#### Text Reader (File List)
@@ -712,10 +750,16 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] Last heard passive advert list
- [X] Touch-to-select on contacts, discovery, settings, text reader, notes screens
- [X] Map screen with GPS tile rendering
- [X] WiFi companion environment
- [X] OTA firmware update via phone
- [X] DM inbox with per-contact unread indicators
- [X] Roomserver message handling and mark-read on login
- [ ] Fix M4B rendering to enable chaptered audiobook playback
- [ ] Better JPEG and PNG decoding
- [ ] Improve EPUB rendering and EPUB format handling
- [X] WiFi companion environment
- [ ] Figure out a way to silence the ringtone
- [ ] Figure out a way to customise the ringtone
- [ ] Customised user option for larger-font mode
**T5S3 E-Paper Pro:**
- [X] Core port: display, touch input, LoRa, battery, RTC
@@ -733,9 +777,11 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] CardKB external keyboard support (via QWIIC)
- [X] Last heard passive advert list
- [X] Tap-to-select on contacts, discovery, settings, text reader, notes screens
- [ ] Emoji sprites on home tiles
- [ ] Portrait mode toggle via quadruple-click Boot button
- [ ] Hibernate should auto-off backlight
- [X] OTA firmware update via phone (WiFi variant)
- [X] DM inbox with per-contact unread indicators
- [X] Roomserver message handling and mark-read on login
- [ ] Improve EPUB rendering and EPUB format handling
- [ ] Customised user option for larger-font mode
## 📞 Get Support

View File

@@ -252,6 +252,50 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
// v1.1+ Meck fields — may not exist in older prefs files
if (file.read((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)) != sizeof(_prefs.gps_baudrate)) {
_prefs.gps_baudrate = 0; // default: use compile-time GPS_BAUDRATE
}
if (file.read((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)) != sizeof(_prefs.interference_threshold)) {
_prefs.interference_threshold = 0; // default: disabled
}
if (file.read((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)) != sizeof(_prefs.dark_mode)) {
_prefs.dark_mode = 0; // default: light mode
}
if (file.read((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)) != sizeof(_prefs.portrait_mode)) {
_prefs.portrait_mode = 0; // default: landscape
}
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;
if (alm != 0 && alm != 2 && alm != 5 && alm != 10 && alm != 15 && alm != 30) {
_prefs.auto_lock_minutes = 0;
}
}
file.close();
}
}
@@ -291,6 +335,15 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)); // 90
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 91
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 92
file.write((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)); // 93
file.write((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)); // 97
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);
@@ -498,7 +508,24 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
if (should_display && _ui) {
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
// For signed messages (room server posts): the extra bytes contain the
// original poster's pub_key prefix. Look up their name and format as
// "PosterName: message" so the UI shows who actually wrote it.
if (txt_type == TXT_TYPE_SIGNED_PLAIN && extra && extra_len >= 4) {
ContactInfo* poster = lookupContactByPubKey(extra, extra_len);
if (poster) {
char formatted[MAX_PACKET_PAYLOAD];
snprintf(formatted, sizeof(formatted), "%s: %s", poster->name, text);
_ui->newMsg(path_len, from.name, formatted, offline_queue_len, msg_path, pkt->_snr);
} else {
// Poster not in contacts — show raw text (no name prefix)
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
}
} else {
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
}
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
}
#endif
@@ -543,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) {
@@ -565,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());
}
}
@@ -737,6 +764,13 @@ bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint3
uint8_t save_path_len = recipient->out_path_len;
recipient->out_path_len = OUT_PATH_UNKNOWN;
// For room servers: reset sync_since to zero so the server pushes ALL posts.
// The device has no persistent DM storage, so every session needs full history.
// sync_since naturally updates as messages arrive (BaseChatMesh::onPeerDataRecv).
if (recipient->type == ADV_TYPE_ROOM) {
recipient->sync_since = 0;
}
Serial.printf("[uiLogin] Sending login to '%s' (idx=%d, path was 0x%02X, now 0x%02X, hash_mode=%d)\n",
recipient->name, contact_idx, save_path_len, recipient->out_path_len, _prefs.path_hash_mode);
@@ -1216,6 +1250,7 @@ void MyMesh::begin(bool has_display) {
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
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;
#ifdef BLE_PIN_CODE // 123456 by default
if (_prefs.ble_pin == 0) {
@@ -1465,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);
}
@@ -1588,6 +1623,13 @@ void MyMesh::handleCmdFrame(size_t len) {
uint8_t ch_idx = is_v3_ch ? out_frame[4] : out_frame[1];
_ui->markChannelReadFromBLE(ch_idx);
}
// Mark DM slot read when companion app syncs a contact (DM/room) message
bool is_v3_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV_V3);
bool is_old_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV);
if (is_v3_dm || is_old_dm) {
_ui->markChannelReadFromBLE(0xFF);
}
}
#endif
} else {
@@ -2223,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",
@@ -2283,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);
@@ -2678,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 ||
@@ -2775,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 "20 March 2026"
#define FIRMWARE_BUILD_DATE "27 March 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v1.2"
#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

@@ -38,4 +38,42 @@ struct NodePrefs { // persisted to file
uint8_t dark_mode; // 0=off (white bg), 1=on (black bg)
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

@@ -2,6 +2,9 @@
#ifdef BLE_PIN_CODE
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
#endif
#ifdef MECK_OTA_UPDATE
#include <esp_ota_ops.h>
#endif
#include <Mesh.h>
#include "MyMesh.h"
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
@@ -364,6 +367,87 @@
static bool gt911Ready = false;
static bool sdCardReady = false; // T5S3 SD card state
// ---------------------------------------------------------------------------
// SD Settings Backup / Restore (T5S3)
// ---------------------------------------------------------------------------
static bool copyFile(fs::FS& srcFS, const char* srcPath,
fs::FS& dstFS, const char* dstPath) {
File src = srcFS.open(srcPath, "r");
if (!src) return false;
File dst = dstFS.open(dstPath, "w", true);
if (!dst) { src.close(); return false; }
uint8_t buf[128];
while (src.available()) {
int n = src.read(buf, sizeof(buf));
if (n > 0) dst.write(buf, n);
}
src.close();
dst.close();
return true;
}
void backupSettingsToSD() {
if (!sdCardReady) return;
if (!SD.exists("/meshcore")) SD.mkdir("/meshcore");
if (SPIFFS.exists("/new_prefs")) {
copyFile(SPIFFS, "/new_prefs", SD, "/meshcore/prefs.bin");
}
if (SPIFFS.exists("/channels2")) {
copyFile(SPIFFS, "/channels2", SD, "/meshcore/channels.bin");
}
if (SPIFFS.exists("/identity/_main.id")) {
if (!SD.exists("/meshcore/identity")) SD.mkdir("/meshcore/identity");
copyFile(SPIFFS, "/identity/_main.id", SD, "/meshcore/identity/_main.id");
}
if (SPIFFS.exists("/contacts3")) {
copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin");
}
digitalWrite(SDCARD_CS, HIGH);
Serial.println("Settings backed up to SD");
}
bool restoreSettingsFromSD() {
if (!sdCardReady) return false;
bool restored = false;
if (!SPIFFS.exists("/new_prefs") && SD.exists("/meshcore/prefs.bin")) {
if (copyFile(SD, "/meshcore/prefs.bin", SPIFFS, "/new_prefs")) {
Serial.println("Restored prefs from SD");
restored = true;
}
}
if (!SPIFFS.exists("/channels2") && SD.exists("/meshcore/channels.bin")) {
if (copyFile(SD, "/meshcore/channels.bin", SPIFFS, "/channels2")) {
Serial.println("Restored channels from SD");
restored = true;
}
}
if (!SPIFFS.exists("/identity/_main.id") && SD.exists("/meshcore/identity/_main.id")) {
SPIFFS.mkdir("/identity");
if (copyFile(SD, "/meshcore/identity/_main.id", SPIFFS, "/identity/_main.id")) {
Serial.println("Restored identity from SD");
restored = true;
}
}
if (!SPIFFS.exists("/contacts3") && SD.exists("/meshcore/contacts.bin")) {
if (copyFile(SD, "/meshcore/contacts.bin", SPIFFS, "/contacts3")) {
Serial.println("Restored contacts from SD");
restored = true;
}
}
if (restored) {
Serial.println("=== Settings restored from SD card backup ===");
}
digitalWrite(SDCARD_CS, HIGH);
return restored;
}
#ifdef MECK_CARDKB
#include "CardKBKeyboard.h"
static CardKBKeyboard cardkb;
@@ -405,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
@@ -632,6 +716,12 @@ static void lastHeardToggleContact() {
int vx, vy;
touchToVirtual(x, y, vx, vy);
// Dismiss boot navigation hint on any tap
if (ui_task.isHintActive()) {
ui_task.dismissBootHint();
return 0;
}
// --- Status bar tap (top ~18 virtual units) → go home from any non-home screen ---
// Exception: text reader reading mode uses full screen for content (no header)
if (vy < 18 && !ui_task.isOnHomeScreen()) {
@@ -841,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;
}
@@ -849,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();
@@ -912,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()) {
@@ -954,10 +1060,38 @@ static void lastHeardToggleContact() {
}
#endif
// Channel screen: long press → compose to current channel
// Channel screen: long press → compose to current channel (or DM actions on DM tab)
if (ui_task.isOnChannelScreen()) {
#if defined(LilyGo_T5S3_EPaper_Pro)
uint8_t chIdx = ui_task.getChannelScreenViewIdx();
if (chIdx == 0xFF) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr->isDMInboxMode()) {
// Inbox mode: long press = open selected conversation (same as Enter)
return '\r';
}
// Conversation mode: long press = compose reply
#if defined(LilyGo_T5S3_EPaper_Pro)
const char* dmName = chScr->getDMFilterName();
if (dmName && dmName[0]) {
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t j = 0; j < numC; j++) {
if (the_mesh.getContactByIdx(j, ci) && strcmp(ci.name, dmName) == 0) {
char label[40];
snprintf(label, sizeof(label), "DM: %s", dmName);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, j);
ui_task.clearDMUnread(j);
return 0;
}
}
}
ui_task.showAlert("Contact not found", 1000);
return 0;
#else
return KEY_ENTER;
#endif
}
#if defined(LilyGo_T5S3_EPaper_Pro)
ChannelDetails ch;
if (the_mesh.getChannel(chIdx, ch)) {
char label[40];
@@ -978,6 +1112,13 @@ static void lastHeardToggleContact() {
uint8_t ctype = cs->getSelectedContactType();
#if defined(LilyGo_T5S3_EPaper_Pro)
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
return 0;
}
char dname[32];
cs->getSelectedContactName(dname, sizeof(dname));
char label[40];
@@ -987,6 +1128,16 @@ static void lastHeardToggleContact() {
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
// Room server: open login (after login, auto-redirects to conversation)
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
return 0;
}
#else
// T-Deck Pro: repeater admin works directly, DM via keyboard compose
@@ -1037,6 +1188,9 @@ static void lastHeardToggleContact() {
if (ss->isEditing()) {
return 0; // Consume — don't interfere with active edit mode
}
if (ss->isOnDeletableChannel()) {
return 'x'; // Long press on channel row → delete
}
}
return KEY_ENTER; // Not editing: toggle/edit selected row
}
@@ -1271,6 +1425,11 @@ void setup() {
if (mounted) {
sdCardReady = true;
Serial.println("setup() - SD card initialized");
// If SPIFFS was wiped (fresh flash), restore settings from SD backup
if (restoreSettingsFromSD()) {
Serial.println("setup() - T5S3: Settings restored from SD backup");
}
} else {
Serial.println("setup() - SD card not available");
}
@@ -1375,6 +1534,28 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - ui_task.begin() done");
#endif
// ---------------------------------------------------------------------------
// OTA boot validation — confirm new firmware is working after an OTA update.
// If we reach this point, display + radio + SD + mesh all initialised OK.
// Without this call (when CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE is set),
// the bootloader will roll back to the previous partition on next reboot.
// ---------------------------------------------------------------------------
#ifdef MECK_OTA_UPDATE
{
const esp_partition_t* running = esp_ota_get_running_partition();
esp_ota_img_states_t ota_state;
if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) {
if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) {
if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) {
Serial.println("OTA: New firmware validated, rollback cancelled");
} else {
Serial.println("OTA: WARNING - failed to cancel rollback");
}
}
}
}
#endif
// Initialize T-Deck Pro keyboard
#if defined(LilyGo_TDeck_Pro)
initKeyboard();
@@ -1572,6 +1753,8 @@ void setup() {
if (strcmp(prefs->node_name, defaultName) == 0) {
MESH_DEBUG_PRINTLN("setup() - Default node name detected, launching onboarding");
ui_task.gotoOnboarding();
// Show hint immediately overlaid on the onboarding screen
if (!prefs->hint_shown) ui_task.showBootHint(true);
}
}
#endif
@@ -1604,13 +1787,70 @@ 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 ===");
}
// ---------------------------------------------------------------------------
// OTA radio control — pause LoRa during firmware updates to prevent SPI
// bus contention (SD and LoRa share the same SPI bus on both platforms).
// Also pauses the mesh loop to prevent radio state confusion while standby.
// ---------------------------------------------------------------------------
#ifdef MECK_OTA_UPDATE
extern RADIO_CLASS radio; // Defined in target.cpp
static bool otaRadioPaused = false;
void otaPauseRadio() {
otaRadioPaused = true;
radio.standby();
Serial.println("OTA: Radio standby, mesh loop paused");
}
void otaResumeRadio() {
radio.startReceive();
otaRadioPaused = false;
Serial.println("OTA: Radio receive resumed, mesh loop active");
}
#endif
void loop() {
#ifdef MECK_OTA_UPDATE
if (!otaRadioPaused) {
#endif
the_mesh.loop();
#ifdef MECK_OTA_UPDATE
} else {
// 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.
if (ui_task.isOnSettingsScreen()) {
SettingsScreen* ss = (SettingsScreen*)ui_task.getSettingsScreen();
if (ss) {
ss->pollOTAServer();
// Detect upload completion and trigger verify → flash → reboot.
// Must happen here (not in render) because T5S3 e-ink refresh blocks
// for 500ms+ and the render-based check never fires reliably.
ss->checkOTAComplete(display);
}
}
}
#endif
sensors.loop();
@@ -1696,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
{
@@ -1882,6 +2177,9 @@ void loop() {
#endif
rtc_clock.tick();
// Periodic AGC reset - re-assert boosted RX gain to prevent sensitivity drift
#ifdef MECK_OTA_UPDATE
if (!otaRadioPaused)
#endif
if ((millis() - lastAGCReset) >= AGC_RESET_INTERVAL_MS) {
radio_reset_agc();
lastAGCReset = millis();
@@ -1896,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.
@@ -1908,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)
{
@@ -2107,13 +2414,37 @@ void loop() {
} else if (ckb == '\r') {
// Enter key — screen-specific compose or select
if (ui_task.isOnChannelScreen()) {
// Open VKB for channel message compose
uint8_t chIdx = ui_task.getChannelScreenViewIdx();
ChannelDetails ch;
if (the_mesh.getChannel(chIdx, ch)) {
char label[40];
snprintf(label, sizeof(label), "To: %s", ch.name);
ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx);
if (chIdx == 0xFF) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr->isDMInboxMode()) {
// Inbox mode: inject Enter to open conversation
ui_task.injectKey('\r');
} else {
// Conversation mode: open VKB DM compose
const char* dmName = chScr->getDMFilterName();
if (dmName && dmName[0]) {
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t j = 0; j < numC; j++) {
if (the_mesh.getContactByIdx(j, ci) && strcmp(ci.name, dmName) == 0) {
char label[40];
snprintf(label, sizeof(label), "DM: %s", dmName);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, j);
ui_task.clearDMUnread(j);
break;
}
}
}
}
} else {
// Open VKB for channel message compose
ChannelDetails ch;
if (the_mesh.getChannel(chIdx, ch)) {
char label[40];
snprintf(label, sizeof(label), "To: %s", ch.name);
ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx);
}
}
} else if (ui_task.isOnContactsScreen()) {
// DM compose for chat contacts, admin for repeaters
@@ -2122,13 +2453,28 @@ void loop() {
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
char dname[32];
cs->getSelectedContactName(dname, sizeof(dname));
char label[40];
snprintf(label, sizeof(label), "DM: %s", dname);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx);
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
} else {
char dname[32];
cs->getSelectedContactName(dname, sizeof(dname));
char label[40];
snprintf(label, sizeof(label), "DM: %s", dname);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx);
}
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
// Room server: open login (auto-redirects to conversation)
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0 && ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
}
}
} else if (ui_task.isOnRepeaterAdmin()) {
@@ -2242,10 +2588,33 @@ void handleKeyboardInput() {
// Block all keyboard input while lock screen is active.
// Still read the key above to clear the TCA8418 buffer.
if (ui_task.isLocked()) return;
// Dismiss boot navigation hint on any keypress
if (ui_task.isHintActive()) {
ui_task.dismissBootHint();
return; // Consume the keypress (don't act on it)
}
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) {
@@ -2282,17 +2651,29 @@ void handleKeyboardInput() {
if (key == '\r') {
// Enter - send the message
Serial.println("Compose: Enter pressed, sending...");
bool composeWasSent = false;
if (composePos > 0) {
sendComposedMessage();
composeWasSent = true; // sendComposedMessage shows its own alert
}
bool wasDM = composeDM;
int savedDMIdx = composeDMContactIdx;
char savedDMName[32];
if (wasDM) strncpy(savedDMName, composeDMName, sizeof(savedDMName));
composeMode = false;
emojiPickerMode = false;
composeDM = false;
composeDMContactIdx = -1;
composeBuffer[0] = '\0';
composePos = 0;
if (wasDM) {
if (wasDM && savedDMIdx >= 0) {
// Return to DM conversation to see sent message
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
uint8_t savedPerms = (chScr && chScr->isDMConversation()) ? chScr->getDMContactPerms() : 0;
ui_task.gotoDMConversation(savedDMName, savedDMIdx, savedPerms);
// Re-show alert after navigation (setCurrScreen clears prior alerts)
if (composeWasSent) ui_task.showAlert("DM sent!", 1500);
} else if (wasDM) {
ui_task.gotoContactsScreen();
} else {
ui_task.gotoChannelScreen();
@@ -2306,13 +2687,20 @@ void handleKeyboardInput() {
// Shift+Backspace = Cancel (works anytime)
Serial.println("Compose: Shift+Backspace, cancelling...");
bool wasDM = composeDM;
int savedDMIdx = composeDMContactIdx;
char savedDMName[32];
if (wasDM) strncpy(savedDMName, composeDMName, sizeof(savedDMName));
composeMode = false;
emojiPickerMode = false;
composeDM = false;
composeDMContactIdx = -1;
composeBuffer[0] = '\0';
composePos = 0;
if (wasDM) {
if (wasDM && savedDMIdx >= 0) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
uint8_t savedPerms = (chScr && chScr->isDMConversation()) ? chScr->getDMContactPerms() : 0;
ui_task.gotoDMConversation(savedDMName, savedDMIdx, savedPerms);
} else if (wasDM) {
ui_task.gotoContactsScreen();
} else {
ui_task.gotoChannelScreen();
@@ -2338,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--) {
@@ -2357,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);
@@ -2612,22 +3024,34 @@ void handleKeyboardInput() {
RepeaterAdminScreen::AdminState astate = admin->getState();
bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed());
// Helper: exit admin — room servers go to DM conversation if logged in, otherwise contacts
auto exitAdmin = [&]() {
int cidx = admin->getContactIdx();
uint8_t perms = admin->getPermissions() & 0x03;
ContactInfo ci;
if (cidx >= 0 && perms > 0 && the_mesh.getContactByIdx(cidx, ci) && ci.type == ADV_TYPE_ROOM) {
ui_task.gotoDMConversation(ci.name, cidx, perms);
Serial.printf("Nav: Admin -> conversation for %s\n", ci.name);
} else {
ui_task.gotoContactsScreen();
Serial.println("Nav: Admin -> contacts");
}
};
// In password entry: Shift+Del exits, all other keys pass through normally
if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
if (shiftDel) {
Serial.println("Nav: Back to contacts from admin login");
ui_task.gotoContactsScreen();
exitAdmin();
} else {
ui_task.injectKey(key);
}
return;
}
// In category menu (top level): Shift+Del exits to contacts, C opens compose
// In category menu (top level): Shift+Del exits, C opens compose
if (astate == RepeaterAdminScreen::STATE_CATEGORY_MENU) {
if (shiftDel) {
Serial.println("Nav: Back to contacts from admin menu");
ui_task.gotoContactsScreen();
exitAdmin();
return;
}
// C key: allow entering compose mode from admin menu
@@ -2798,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());
@@ -2807,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)
@@ -2911,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 {
@@ -2927,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 {
@@ -2937,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");
@@ -2947,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");
@@ -2963,24 +3418,46 @@ void handleKeyboardInput() {
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
composeDM = true;
composeDMContactIdx = idx;
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
drawComposeScreen();
lastComposeRefresh = millis();
// If unread DMs exist, go to conversation view to read first
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
Serial.printf("Unread DMs from %s — opening conversation\n", cname);
} else {
composeDM = true;
composeDMContactIdx = idx;
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
drawComposeScreen();
lastComposeRefresh = millis();
}
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
// Open repeater admin screen
char rname[32];
cs->getSelectedContactName(rname, sizeof(rname));
Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx);
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
// Room server: open login screen (after login, auto-redirects to conversation)
char rname[32];
cs->getSelectedContactName(rname, sizeof(rname));
Serial.printf("Room %s — opening login\n", rname);
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0) {
// Non-chat, non-repeater contact (room, sensor, etc.) - future use
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
// Other contacts with unreads
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
} else {
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
}
}
} else if (ui_task.isOnChannelScreen()) {
// If path overlay is showing, Enter copies path text to compose buffer
@@ -3007,6 +3484,42 @@ void handleKeyboardInput() {
lastComposeRefresh = millis();
break;
}
// DM inbox mode: pass Enter to ChannelScreen to open the selected conversation
if (chScr2 && chScr2->isDMInboxMode()) {
ui_task.injectKey('\r');
break;
}
// DM conversation mode: Enter opens DM compose to the contact being viewed
// (DM inbox mode Enter is handled by ChannelScreen::handleInput internally)
if (chScr2 && chScr2->isDMConversation()) {
const char* dmName = chScr2->getDMFilterName();
if (dmName && dmName[0]) {
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t j = 0; j < numC; j++) {
if (the_mesh.getContactByIdx(j, ci) && strcmp(ci.name, dmName) == 0) {
composeDM = true;
composeDMContactIdx = (int)j;
strncpy(composeDMName, dmName, sizeof(composeDMName) - 1);
composeDMName[sizeof(composeDMName) - 1] = '\0';
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
ui_task.clearDMUnread(j);
Serial.printf("DM conversation compose to %s (idx %d)\n", dmName, j);
drawComposeScreen();
lastComposeRefresh = millis();
break;
}
}
} else {
ui_task.showAlert("No contact selected", 1000);
}
break;
}
composeDM = false;
composeDMContactIdx = -1;
composeChannelIdx = ui_task.getChannelScreenViewIdx();
@@ -3103,9 +3616,9 @@ void handleKeyboardInput() {
break;
case 'f':
// Start discovery scan from contacts screen, or rescan on discovery screen
if (ui_task.isOnContactsScreen()) {
Serial.println("Contacts: Starting discovery scan...");
// Start discovery scan from home/contacts screen, or rescan on discovery screen
if (ui_task.isOnContactsScreen() || ui_task.isOnHomeScreen()) {
Serial.println("Starting discovery scan...");
the_mesh.startDiscovery();
ui_task.gotoDiscoveryScreen();
} else if (ui_task.isOnDiscoveryScreen()) {
@@ -3121,6 +3634,20 @@ void handleKeyboardInput() {
}
break;
case 'l':
// L = Login/Admin — from DM conversation, open repeater admin with auto-login
if (ui_task.isOnChannelScreen()) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr && chScr->isDMConversation() && chScr->getDMContactPerms() > 0) {
int cidx = chScr->getDMContactIdx();
if (cidx >= 0) {
ui_task.gotoRepeaterAdminDirect(cidx);
Serial.printf("DM conversation: auto-login admin for idx %d\n", cidx);
}
}
}
break;
case 'q':
case '\b':
// If channel screen reply select or path overlay is showing, dismiss it
@@ -3153,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");
@@ -3195,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;
}
@@ -3352,6 +3904,8 @@ void sendComposedMessage() {
// Direct message to a specific contact
if (composeDMContactIdx >= 0) {
if (the_mesh.uiSendDirectMessage((uint32_t)composeDMContactIdx, utf8Buf)) {
// Add to channel screen so sent DM appears in conversation view
ui_task.addSentDM(composeDMName, the_mesh.getNodePrefs()->node_name, utf8Buf);
ui_task.showAlert("DM sent!", 1500);
} else {
ui_task.showAlert("DM failed!", 1500);

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

@@ -59,11 +59,19 @@ public:
uint8_t path_len;
uint8_t channel_idx; // Which channel this message belongs to
int8_t snr; // Receive SNR × 4 (0 if locally sent or unknown)
uint32_t dm_peer_hash; // DM peer name hash (for conversation filtering)
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
char text[CHANNEL_MSG_TEXT_LEN];
bool valid;
};
// Simple hash for DM peer matching
static uint32_t peerHash(const char* s) {
uint32_t h = 5381;
while (*s) { h = ((h << 5) + h) ^ (uint8_t)*s++; }
return h;
}
private:
UITask* _task;
mesh::RTCClock* _rtc;
@@ -84,6 +92,31 @@ private:
int _replySelectPos; // Index into chronological channelMsgs[] (0=oldest)
int _replyChannelMsgCount; // Cached count from last render (for input bounds)
// DM tab (channel_idx == 0xFF) two-level view:
// Inbox mode: list of contacts you have DMs from
// Conversation mode: messages filtered to one contact
bool _dmInboxMode; // true = showing inbox list, false = conversation
int _dmInboxScroll; // Scroll position in inbox list
char _dmFilterName[32]; // Selected contact name for conversation view
int _dmContactIdx; // Contact index for conversation (-1 if unknown)
uint8_t _dmContactPerms; // Last login permissions for this contact (0=none/guest)
const uint8_t* _dmUnreadPtr; // Pointer to per-contact DM unread array (from UITask)
// Helper: does a message belong to the current view?
bool msgMatchesView(const ChannelMessage& msg) const {
if (!msg.valid) return false;
if (_viewChannelIdx != 0xFF) {
return msg.channel_idx == _viewChannelIdx;
}
// DM tab in conversation mode: filter by peer hash
if (!_dmInboxMode && _dmFilterName[0] != '\0') {
if (msg.channel_idx != 0xFF) return false;
return msg.dm_peer_hash == peerHash(_dmFilterName);
}
// Inbox mode or no filter — match all DMs
return msg.channel_idx == 0xFF;
}
// Per-channel unread message counts (standalone mode)
// Index 0..MAX_GROUP_CHANNELS-1 for channel messages
// Index MAX_GROUP_CHANNELS for DMs (channel_idx == 0xFF)
@@ -93,10 +126,13 @@ public:
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathScrollPos(0), _pathHopsVisible(20),
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0) {
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0),
_dmInboxMode(true), _dmInboxScroll(0), _dmContactIdx(-1), _dmContactPerms(0), _dmUnreadPtr(nullptr) {
_dmFilterName[0] = '\0';
// Initialize all messages as invalid
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
_messages[i].valid = false;
_messages[i].dm_peer_hash = 0;
memset(_messages[i].path, 0, MSG_PATH_MAX);
}
// Initialize unread counts
@@ -106,8 +142,9 @@ public:
void setSDReady(bool ready) { _sdReady = ready; }
// Add a new message to the history
// peer_name: for DMs, the contact this message belongs to (sender for received, recipient for sent)
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
const uint8_t* path_bytes = nullptr, int8_t snr = 0) {
const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr) {
// Move to next slot in circular buffer
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
@@ -118,6 +155,13 @@ public:
msg->snr = snr;
msg->valid = true;
// Set DM peer hash for conversation filtering
if (channel_idx == 0xFF) {
msg->dm_peer_hash = peerHash(peer_name ? peer_name : sender);
} else {
msg->dm_peer_hash = 0;
}
// Store path hop hashes
memset(msg->path, 0, MSG_PATH_MAX);
if (path_bytes && path_len > 0 && path_len != 0xFF) {
@@ -158,7 +202,7 @@ public:
int getMessageCountForChannel() const {
int count = 0;
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
if (_messages[i].valid && _messages[i].channel_idx == _viewChannelIdx) {
if (msgMatchesView(_messages[i])) {
count++;
}
}
@@ -173,11 +217,47 @@ public:
_scrollPos = 0;
_showPathOverlay = false;
_pathScrollPos = 0;
// Reset DM inbox state when entering DM tab
if (idx == 0xFF) {
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
_dmContactIdx = -1;
_dmContactPerms = 0;
}
markChannelRead(idx);
}
bool isDMTab() const { return _viewChannelIdx == 0xFF; }
bool isDMInboxMode() const { return _viewChannelIdx == 0xFF && _dmInboxMode; }
bool isDMConversation() const { return _viewChannelIdx == 0xFF && !_dmInboxMode; }
const char* getDMFilterName() const { return _dmFilterName; }
// Open a specific contact's DM conversation directly (skipping inbox)
void openConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0) {
strncpy(_dmFilterName, contactName, sizeof(_dmFilterName) - 1);
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
_dmInboxMode = false;
_dmContactIdx = contactIdx;
_dmContactPerms = perms;
_scrollPos = 0;
}
int getDMContactIdx() const { return _dmContactIdx; }
uint8_t getDMContactPerms() const { return _dmContactPerms; }
void setDMContactPerms(uint8_t p) { _dmContactPerms = p; }
bool isShowingPathOverlay() const { return _showPathOverlay; }
void dismissPathOverlay() { _showPathOverlay = false; _pathScrollPos = 0; }
// Set pointer to per-contact DM unread array (called by UITask after allocation)
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnreadPtr = ptr; }
// Subtract a specific amount from the DM unread slot (used by per-contact clearing)
void subtractDMUnread(int count) {
int slot = MAX_GROUP_CHANNELS; // DM slot
_unread[slot] -= count;
if (_unread[slot] < 0) _unread[slot] = 0;
}
// --- Reply select mode (R key → pick a message → Enter to @mention reply) ---
bool isReplySelectMode() const { return _replySelectMode; }
void exitReplySelect() { _replySelectMode = false; _replySelectPos = -1; }
@@ -206,7 +286,7 @@ public:
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
rsMsgs[count++] = idx;
}
}
@@ -230,7 +310,7 @@ public:
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
rsMsgs[count++] = idx;
}
}
@@ -277,7 +357,7 @@ public:
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx
if (msgMatchesView(_messages[idx])
&& _messages[idx].path_len != 0) {
return &_messages[idx];
}
@@ -449,7 +529,15 @@ public:
// Get channel name
ChannelDetails channel;
if (the_mesh.getChannel(_viewChannelIdx, channel)) {
if (_viewChannelIdx == 0xFF) {
if (_dmInboxMode) {
display.print("Direct Messages");
} else {
char hdr[40];
snprintf(hdr, sizeof(hdr), "DM: %s", _dmFilterName);
display.print(hdr);
}
} else if (the_mesh.getChannel(_viewChannelIdx, channel)) {
display.print(channel.name);
} else {
sprintf(tmp, "Channel %d", _viewChannelIdx);
@@ -464,11 +552,201 @@ public:
// Divider line
display.drawRect(0, 11, display.width(), 1);
// === DM Inbox mode: show list of contacts with DMs ===
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
#define DM_INBOX_MAX 20
struct DMInboxEntry {
uint32_t hash;
char name[32];
int msgCount;
int unreadCount;
uint32_t newestTs;
};
DMInboxEntry inbox[DM_INBOX_MAX];
int inboxCount = 0;
// Scan all DMs and group by peer hash
for (int i = 0; i < _msgCount && i < CHANNEL_MSG_HISTORY_SIZE; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
if (_messages[idx].dm_peer_hash == 0) continue;
uint32_t h = _messages[idx].dm_peer_hash;
// Find existing entry by hash
int found = -1;
for (int j = 0; j < inboxCount; j++) {
if (inbox[j].hash == h) { found = j; break; }
}
if (found < 0 && inboxCount < DM_INBOX_MAX) {
found = inboxCount++;
inbox[found].hash = h;
inbox[found].name[0] = '\0';
inbox[found].msgCount = 0;
inbox[found].unreadCount = 0;
inbox[found].newestTs = 0;
// Look up name from contacts by matching peer hash
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t c = 0; c < numC; c++) {
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == h) {
strncpy(inbox[found].name, ci.name, 31);
inbox[found].name[31] = '\0';
break;
}
}
// Fallback: extract from text if contact not found
if (inbox[found].name[0] == '\0') {
extractSenderName(_messages[idx].text, inbox[found].name, sizeof(inbox[found].name));
}
}
if (found >= 0) {
inbox[found].msgCount++;
if (_messages[idx].timestamp > inbox[found].newestTs)
inbox[found].newestTs = _messages[idx].timestamp;
}
}
// Look up unread counts from per-contact array
if (_dmUnreadPtr) {
for (int e = 0; e < inboxCount; e++) {
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t c = 0; c < numC; c++) {
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == inbox[e].hash) {
inbox[e].unreadCount = _dmUnreadPtr[c];
break;
}
}
}
}
// Sort by newest timestamp descending (insertion sort)
for (int i = 1; i < inboxCount; i++) {
DMInboxEntry tmp2 = inbox[i];
int j = i - 1;
while (j >= 0 && inbox[j].newestTs < tmp2.newestTs) {
inbox[j + 1] = inbox[j];
j--;
}
inbox[j + 1] = tmp2;
}
// Render inbox list
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineH = the_mesh.getNodePrefs()->smallLineH();
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
int y = headerH;
int maxVisible = (maxY - headerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
// Clamp scroll
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount > 0 ? inboxCount - 1 : 0;
if (inboxCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("No direct messages");
display.setCursor(0, y + lineH);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("DMs from contacts appear here");
#else
display.print("A/D: Switch channel");
#endif
} else {
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
inboxCount - maxVisible));
int endIdx = min(inboxCount, startIdx + maxVisible);
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
bool selected = (i == _dmInboxScroll);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
// Prefix: > for selected, unread indicator
char prefix[6];
if (inbox[i].unreadCount > 0) {
snprintf(prefix, sizeof(prefix), "%s*%d", selected ? ">" : " ", inbox[i].unreadCount);
} else {
snprintf(prefix, sizeof(prefix), "%s ", selected ? ">" : " ");
}
display.print(prefix);
// Name (truncated)
char filteredName[32];
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
// Right side: message count + age
char ageStr[8];
uint32_t age = _rtc->getCurrentTime() - inbox[i].newestTs;
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
char rightStr[16];
snprintf(rightStr, sizeof(rightStr), "(%d) %s", inbox[i].msgCount, ageStr);
int rightW = display.getTextWidth(rightStr) + 2;
int nameX = display.getTextWidth(prefix) + 2;
int nameMaxW = display.width() - nameX - rightW - 2;
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
display.setCursor(display.width() - rightW, y);
display.print(rightStr);
y += lineH;
}
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Nav");
const char* rtInbox = "Hold:Open";
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
display.print(rtInbox);
#else
display.setCursor(0, footerY);
display.print("Q:Bck A/D:Ch");
const char* rtInbox = "Ent:Open";
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
display.print(rtInbox);
#endif
#ifdef USE_EINK
return 5000;
#else
return 1000;
#endif
}
// --- 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();
@@ -664,24 +942,160 @@ 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);
display.print("No messages yet");
display.setCursor(0, 30);
if (_viewChannelIdx == 0xFF) {
char noMsg[48];
snprintf(noMsg, sizeof(noMsg), "No messages from %s", _dmFilterName);
display.print(noMsg);
display.setCursor(0, 30);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe: Switch channel");
display.setCursor(0, 40);
display.print("Long press: Compose");
display.print("Hold: Compose reply");
#else
display.print("A/D: Switch channel");
display.setCursor(0, 40);
display.print("C: Compose message");
display.print("Q: Back to inbox");
display.setCursor(0, 40);
display.print("Ent: Compose reply");
#endif
} else {
display.print("No messages yet");
display.setCursor(0, 30);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe: Switch channel");
display.setCursor(0, 40);
display.print("Long press: Compose");
#else
display.print("A/D: Switch channel");
display.setCursor(0, 40);
display.print("C: Compose message");
#endif
}
display.setTextSize(1); // Restore for footer
} else if (_viewChannelIdx == 0xFF && _dmInboxMode) {
// =================================================================
// DM Inbox: list of contacts/rooms you have DM history with
// =================================================================
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
int y = headerHeight;
// Scan all DM messages and collect unique senders
#define DM_INBOX_MAX 16
struct InboxEntry {
char name[24];
int count;
uint32_t newest_ts;
};
static InboxEntry inbox[DM_INBOX_MAX];
int inboxCount = 0;
for (int i = 0; i < _msgCount; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
char sender[24];
if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue;
// Find or add sender in inbox
bool found = false;
for (int j = 0; j < inboxCount; j++) {
if (strcmp(inbox[j].name, sender) == 0) {
inbox[j].count++;
if (_messages[idx].timestamp > inbox[j].newest_ts)
inbox[j].newest_ts = _messages[idx].timestamp;
found = true;
break;
}
}
if (!found && inboxCount < DM_INBOX_MAX) {
strncpy(inbox[inboxCount].name, sender, 23);
inbox[inboxCount].name[23] = '\0';
inbox[inboxCount].count = 1;
inbox[inboxCount].newest_ts = _messages[idx].timestamp;
inboxCount++;
}
}
// Sort by newest timestamp descending (most recent first)
for (int i = 1; i < inboxCount; i++) {
InboxEntry tmp2 = inbox[i];
int j = i - 1;
while (j >= 0 && inbox[j].newest_ts < tmp2.newest_ts) {
inbox[j + 1] = inbox[j];
j--;
}
inbox[j + 1] = tmp2;
}
if (inboxCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("No conversations");
} else {
// Clamp scroll
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount - 1;
if (_dmInboxScroll < 0) _dmInboxScroll = 0;
int maxVisible = (maxY - headerHeight) / lineHeight;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
inboxCount - maxVisible));
int endIdx = min(inboxCount, startIdx + maxVisible);
uint32_t now = _rtc->getCurrentTime();
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
bool selected = (i == _dmInboxScroll);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
display.print(selected ? ">" : " ");
// Name (ellipsized)
char filteredName[24];
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
// Right side: message count + age
char ageStr[8];
uint32_t age = now - inbox[i].newest_ts;
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
char rightStr[16];
snprintf(rightStr, sizeof(rightStr), "[%d] %s", inbox[i].count, ageStr);
int rightW = display.getTextWidth(rightStr) + 2;
int nameX = display.getTextWidth(">") + 2;
int nameMaxW = display.width() - nameX - rightW - 2;
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
display.setCursor(display.width() - rightW, y);
display.print(rightStr);
y += lineHeight;
}
}
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
@@ -701,7 +1115,7 @@ public:
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
if (msgMatchesView(_messages[idx])) {
channelMsgs[numChannelMsgs++] = idx;
}
}
@@ -749,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
}
@@ -910,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
}
}
@@ -968,13 +1382,20 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Ch/Scroll");
const char* midCh = "Tap:Path";
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
display.print(midCh);
const char* rtCh = "Hold:Compose";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
if (_viewChannelIdx == 0xFF) {
display.print("Swipe:Scroll");
const char* rtCh = "Hold:Reply";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
} else {
display.print("Swipe:Ch/Scroll");
const char* midCh = "Tap:Path";
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
display.print(midCh);
const char* rtCh = "Hold:Compose";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
}
#else
// Left side: abbreviated controls
if (_replySelectMode) {
@@ -982,6 +1403,15 @@ public:
const char* rightText = "Ent:Reply";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
} else if (_viewChannelIdx == 0xFF) {
if (_dmContactPerms > 0) {
display.print("Q:Exit L:Admin");
} else {
display.print("Q:Exit");
}
const char* rightText = "Ent:Reply";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
} else {
display.print("Q:Bck A/D:Ch R:Rply");
const char* rightText = "Ent:New";
@@ -1080,10 +1510,92 @@ public:
return true; // Consume all other keys in reply select
}
// --- DM Inbox mode (two-level DM view) ---
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
// W - scroll up in inbox
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_dmInboxScroll > 0) { _dmInboxScroll--; return true; }
return false;
}
// S - scroll down in inbox
if (c == 's' || c == 'S' || c == 0xF1) {
_dmInboxScroll++; // Clamped during render
return true;
}
// Enter - open conversation for selected entry
if (c == '\r' || c == 13) {
// Rebuild inbox by hash to find the selected entry
uint32_t seenHash[DM_INBOX_MAX];
int cur = 0;
for (int i = 0; i < _msgCount; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
if (_messages[idx].dm_peer_hash == 0) continue;
uint32_t h = _messages[idx].dm_peer_hash;
bool dup = false;
for (int k = 0; k < cur; k++) {
if (seenHash[k] == h) { dup = true; break; }
}
if (dup) continue;
if (cur < DM_INBOX_MAX) seenHash[cur] = h;
if (cur == _dmInboxScroll) {
// Found the selected entry — look up name from contacts
_dmFilterName[0] = '\0';
_dmContactIdx = -1;
_dmContactPerms = 0;
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t c2 = 0; c2 < numC; c2++) {
if (the_mesh.getContactByIdx(c2, ci) && peerHash(ci.name) == h) {
strncpy(_dmFilterName, ci.name, sizeof(_dmFilterName) - 1);
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
_dmContactIdx = (int)c2;
break;
}
}
// Fallback to text extraction if contact not found
if (_dmFilterName[0] == '\0') {
extractSenderName(_messages[idx].text, _dmFilterName, sizeof(_dmFilterName));
}
_dmInboxMode = false;
_scrollPos = 0;
return true;
}
cur++;
}
return true;
}
// Q - let main.cpp handle (back to home)
if (c == 'q' || c == 'Q' || c == '\b') {
return false;
}
// A/D pass through to channel switching below
if (c == 'a' || c == 'A' || c == 'd' || c == 'D') {
// Fall through to channel switching
} else {
return true; // Consume other keys
}
}
// --- DM Conversation mode: Q goes back to inbox ---
if (_viewChannelIdx == 0xFF && !_dmInboxMode) {
if (c == 'q' || c == 'Q' || c == '\b') {
_dmInboxMode = true;
_dmFilterName[0] = '\0';
_scrollPos = 0;
return true;
}
}
int channelMsgCount = getMessageCountForChannel();
// R - enter reply select mode
// R - enter reply select mode (group channels only — DM tab uses Enter to reply)
if (c == 'r' || c == 'R') {
if (_viewChannelIdx == 0xFF) return false; // Not applicable on DM tab
if (channelMsgCount > 0) {
_replySelectMode = true;
// Start with newest message selected
@@ -1120,14 +1632,12 @@ public:
}
}
// A - previous channel
// A - previous channel (includes DM tab at 0xFF)
if (c == 'a' || c == 'A') {
_replySelectMode = false;
_replySelectPos = -1;
if (_viewChannelIdx > 0) {
_viewChannelIdx--;
} else {
// Wrap to last valid channel
if (_viewChannelIdx == 0xFF) {
// DM tab → go to last valid group channel
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
@@ -1135,22 +1645,64 @@ public:
break;
}
}
} else if (_viewChannelIdx > 0) {
// 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;
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);
return true;
}
// D - next channel
// D - next channel (includes DM tab at 0xFF)
if (c == 'd' || c == 'D') {
_replySelectMode = false;
_replySelectPos = -1;
ChannelDetails ch;
uint8_t nextIdx = _viewChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
_viewChannelIdx = nextIdx;
} else {
if (_viewChannelIdx == 0xFF) {
// DM tab → wrap to channel 0
_viewChannelIdx = 0;
} 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;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
}
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);

View File

@@ -40,6 +40,9 @@ private:
// How many rows fit on screen (computed during render)
int _rowsPerPage;
// Pointer to per-contact DM unread array (owned by UITask, set via setter)
const uint8_t* _dmUnread = nullptr;
// --- helpers ---
static const char* filterLabel(FilterMode f) {
@@ -145,6 +148,9 @@ public:
void invalidateCache() { _cacheValid = false; }
// Set pointer to per-contact DM unread array (called by UITask after allocation)
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnread = ptr; }
void resetScroll() {
_scrollPos = 0;
_cacheValid = false;
@@ -156,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;
@@ -229,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;
@@ -269,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 {
@@ -303,9 +309,14 @@ public:
char ageStr[6];
formatAge(ageStr, sizeof(ageStr), now, contact.last_advert_timestamp);
// Build right-side string: "hops age"
char rightStr[14];
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
// Build right-side string: "*N hops age" if unread, else "hops age"
int dmCount = (_dmUnread && _filteredIdx[i] < MAX_CONTACTS) ? _dmUnread[_filteredIdx[i]] : 0;
char rightStr[20];
if (dmCount > 0) {
snprintf(rightStr, sizeof(rightStr), "*%d %sh %s", dmCount, hopStr, ageStr);
} else {
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
}
int rightWidth = display.getTextWidth(rightStr) + 2;
// Name region: after prefix + small gap, before right info

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

@@ -475,6 +475,7 @@ public:
int getContactIdx() const { return _contactIdx; }
AdminState getState() const { return _state; }
uint8_t getPermissions() const { return _permissions; }
void onLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
_waitingForLogin = false;
@@ -561,7 +562,9 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Admin: %.16s", _repeaterName);
const char* hdrPrefix = (_state == STATE_PASSWORD_ENTRY || _state == STATE_LOGGING_IN)
? "Login" : "Admin";
snprintf(tmp, sizeof(tmp), "%s: %.16s", hdrPrefix, _repeaterName);
display.print(tmp);
if (_state >= STATE_CATEGORY_MENU && _state <= STATE_RESPONSE_VIEW) {
@@ -774,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) {
@@ -859,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
@@ -1022,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
@@ -1030,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);
}
@@ -1068,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);
@@ -1163,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);

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
#include <vector>
#include "Utf8CP437.h"
#include "EpubProcessor.h"
#include "../NodePrefs.h"
// Forward declarations
class UITask;
@@ -15,7 +16,7 @@ class UITask;
// ============================================================================
#define BOOKS_FOLDER "/books"
#define INDEX_FOLDER "/.indexes"
#define INDEX_VERSION 9 // v9: indexer buffer matches page buffer (fixes chunk boundary gaps)
#define INDEX_VERSION 12 // v12: indexer breaks page BEFORE overflowing line (matches renderer pre-check)
#define PREINDEX_PAGES 100
#define READER_MAX_FILES 50
#define READER_BUF_SIZE 4096
@@ -238,17 +239,25 @@ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineSta
// ============================================================================
// Page Indexer (word-wrap aware, matches display rendering)
// When textAreaHeight and lineHeight are provided (both > 0), uses height-based
// pagination that accounts for blank lines getting 40% height (matching renderer).
// Otherwise falls back to simple line counting.
// ============================================================================
inline int indexPagesWordWrap(File& file, long startPos,
std::vector<long>& pagePositions,
int linesPerPage, int charsPerLine,
int maxPages) {
int maxPages,
int textAreaHeight = 0, int lineHeight = 0) {
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
char buffer[BUF_SIZE];
bool heightAware = (textAreaHeight > 0 && lineHeight > 0);
int blankLineH = heightAware ? max(2, lineHeight * 2 / 5) : 0;
file.seek(startPos);
int pagesAdded = 0;
int lineCount = 0;
int accHeight = 0;
int leftover = 0;
long chunkFileStart = startPos;
@@ -259,17 +268,42 @@ inline int indexPagesWordWrap(File& file, long startPos,
int pos = 0;
while (pos < bufLen) {
int lineStart = pos;
WrapResult wrap = findLineBreak(buffer, bufLen, pos, charsPerLine);
if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;
lineCount++;
// Blank line = newline at line start (no printable content before it)
bool isBlankLine = (wrap.lineEnd == lineStart);
bool pageBreak = false;
if (heightAware) {
int thisH = isBlankLine ? blankLineH : lineHeight;
// Check BEFORE adding: does this line fit on the current page?
// The renderer checks y <= maxY before rendering each line,
// so we must break the page BEFORE a line that won't fit.
if (accHeight > 0 && accHeight + thisH > textAreaHeight) {
// This line doesn't fit — start new page at this line's position
long pageFilePos = chunkFileStart + lineStart;
pagePositions.push_back(pageFilePos);
pagesAdded++;
accHeight = 0;
if (maxPages > 0 && pagesAdded >= maxPages) break;
}
accHeight += thisH;
} else {
lineCount++;
if (lineCount >= linesPerPage) {
pageBreak = true;
lineCount = 0;
}
}
pos = wrap.nextStart;
if (lineCount >= linesPerPage) {
if (pageBreak) {
long pageFilePos = chunkFileStart + pos;
pagePositions.push_back(pageFilePos);
pagesAdded++;
lineCount = 0;
if (maxPages > 0 && pagesAdded >= maxPages) break;
}
if (pos >= bufLen) break;
@@ -294,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;
@@ -363,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
@@ -373,6 +410,7 @@ private:
int _charsPerLine;
int _linesPerPage;
int _lineHeight; // virtual coord units per text line
int _textAreaHeight; // usable height for text (excluding header/footer)
int _headerHeight;
int _footerHeight;
@@ -900,22 +938,14 @@ private:
if (_pagePositions.empty()) {
// Cache had no pages (e.g. dummy entry) — full index from scratch
_pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
_linesPerPage, _charsPerLine, 0,
_textAreaHeight, _lineHeight);
} else {
long lastPos = cache->pagePositions.back();
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, lastPos, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, lastPos, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
_linesPerPage, _charsPerLine, 0,
_textAreaHeight, _lineHeight);
}
} else {
// No cache — full index from scratch
@@ -933,13 +963,9 @@ private:
drawSplash("Indexing...", "Please wait", shortName);
_pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
_linesPerPage, _charsPerLine, 0,
_textAreaHeight, _lineHeight);
}
// Save complete index
@@ -1062,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;
@@ -1084,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 {
@@ -1092,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 ? "> " : " ";
@@ -1103,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);
@@ -1119,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
@@ -1141,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);
@@ -1155,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;
@@ -1166,13 +1181,9 @@ private:
// Render all lines in the page buffer using word wrap.
// The buffer contains exactly the bytes for this page (from indexed positions),
// so we render everything in it.
while (pos < _pageBufLen && lineCount < _linesPerPage && y <= maxY) {
while (pos < _pageBufLen && y <= maxY) {
int oldPos = pos;
#if defined(LilyGo_T5S3_EPaper_Pro)
WrapResult wrap = findLineBreakPixel(_pageBuf, _pageBufLen, pos, &display, _charsPerLine);
#else
WrapResult wrap = findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine);
#endif
// Safety: stop if findLineBreak made no progress (stuck at end of buffer)
if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break;
@@ -1252,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";
@@ -1269,11 +1280,11 @@ 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),
_headerHeight(14), _footerHeight(14),
_textAreaHeight(100), _headerHeight(14), _footerHeight(14),
_selectedFile(0), _currentPath(BOOKS_FOLDER),
_fileOpen(false), _currentPage(0), _totalPages(0),
_pageBufLen(0), _contentDirty(true) {
@@ -1295,25 +1306,53 @@ 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 to get accurate average
// 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;
}
#if defined(LilyGo_T5S3_EPaper_Pro)
// T5S3 uses pixel-based line breaking (findLineBreakPixel) which measures
// actual text width via getTextWidth(). _charsPerLine serves only as a
// safety upper bound for lines without word breaks (URLs, etc.).
_charsPerLine = 120;
// T5S3 uses proportional font (FreeSans12pt) — measure average character
// width from a representative English sample. M-based measurement is far
// too conservative (M is the widest glyph), leaving half the line empty.
{
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) {
// 95% factor as small safety margin for slightly-wider-than-average text
_charsPerLine = (display.width() * sampleLen * 95) / ((int)sampleW * 100);
}
}
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
@@ -1333,33 +1372,138 @@ 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)
_footerHeight = 14;
int textAreaHeight = display.height() - _headerHeight - _footerHeight;
_linesPerPage = textAreaHeight / _lineHeight;
_textAreaHeight = display.height() - _headerHeight - _footerHeight;
_linesPerPage = _textAreaHeight / _lineHeight;
if (_linesPerPage < 5) _linesPerPage = 5;
if (_linesPerPage > 40) _linesPerPage = 40;
display.setTextSize(1); // Restore
_initialized = true;
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d (display %dx%d)\n",
_charsPerLine, _linesPerPage, _lineHeight, display.width(), display.height());
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d, textH=%d (display %dx%d)\n",
_charsPerLine, _linesPerPage, _lineHeight, _textAreaHeight, display.width(), display.height());
}
// ---- Boot-time Indexing ----
// 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;
@@ -1401,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])) {
@@ -1464,15 +1612,10 @@ public:
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
int added = indexPagesWordWrapPixel(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
_display, PREINDEX_PAGES - 1);
#else
int added = indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1);
#endif
PREINDEX_PAGES - 1,
_textAreaHeight, _lineHeight);
cache.fullyIndexed = !file.available();
file.close();
@@ -1485,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);
@@ -1515,13 +1678,9 @@ public:
// Layout was invalidated (orientation change) — reindex the open book
Serial.println("TextReader: Reindexing after layout change");
_pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
_linesPerPage, _charsPerLine, 0,
_textAreaHeight, _lineHeight);
_totalPages = _pagePositions.size();
if (_currentPage >= _totalPages) _currentPage = 0;
_mode = READING;
@@ -1554,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;
@@ -1689,15 +1849,10 @@ public:
cache.lastReadPage = 0;
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
_display, PREINDEX_PAGES - 1);
#else
indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1);
#endif
PREINDEX_PAGES - 1,
_textAreaHeight, _lineHeight);
cache.fullyIndexed = !file.available();
file.close();
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,

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 ");
#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 ");
#elif defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
#else
y -= 10; // reclaim the row for standalone
#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;
@@ -1138,6 +1226,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
_node_prefs = node_prefs;
// Initialize message dedup ring buffer
memset(_dedup, 0, sizeof(_dedup));
_dedupIdx = 0;
// Allocate per-contact DM unread tracking (PSRAM if available)
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_dmUnread = (uint8_t*)ps_calloc(MAX_CONTACTS, sizeof(uint8_t));
#else
_dmUnread = new uint8_t[MAX_CONTACTS]();
#endif
#if ENV_INCLUDE_GPS == 1
// Apply GPS preferences from stored prefs
if (_sensors != NULL && _node_prefs != NULL) {
@@ -1184,9 +1283,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
channel_screen = new ChannelScreen(this, &rtc_clock);
((ChannelScreen*)channel_screen)->setDMUnreadPtr(_dmUnread);
contacts_screen = new ContactsScreen(this, &rtc_clock);
text_reader = new TextReaderScreen(this);
notes_screen = new NotesScreen(this);
((ContactsScreen*)contacts_screen)->setDMUnreadPtr(_dmUnread);
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);
@@ -1195,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);
@@ -1225,6 +1329,34 @@ void UITask::showAlert(const char* text, int duration_millis) {
_next_refresh = millis() + 100; // trigger re-render to show updated text
}
void UITask::showBootHint(bool immediate) {
if (immediate) {
// Activate now — used when hint should overlay the current screen (e.g. onboarding)
_hintActive = true;
_hintExpiry = millis() + 8000; // 8 seconds auto-dismiss
_pendingBootHint = false;
_next_refresh = millis() + 100;
Serial.println("[UI] Boot hint activated (immediate)");
} else {
// Defer until after splash screen — actual activation happens in gotoHomeScreen()
_pendingBootHint = true;
Serial.println("[UI] Boot hint pending (will show after splash)");
}
}
void UITask::dismissBootHint() {
if (!_hintActive) return;
_hintActive = false;
_hintExpiry = 0;
// Persist so hint never shows again
if (_node_prefs) {
_node_prefs->hint_shown = 1;
the_mesh.savePrefs();
}
_next_refresh = millis() + 100;
Serial.println("[UI] Boot hint dismissed");
}
void UITask::notify(UIEventType t) {
#if defined(PIN_BUZZER)
switch(t){
@@ -1266,6 +1398,24 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
const uint8_t* path, int8_t snr) {
_msgcount = msgcount;
// --- Dedup: suppress retry spam (same sender + text within 60s) ---
uint32_t nameH = simpleHash(from_name);
uint32_t textH = simpleHash(text);
unsigned long now = millis();
for (int i = 0; i < MSG_DEDUP_SIZE; i++) {
if (_dedup[i].name_hash == nameH && _dedup[i].text_hash == textH &&
(now - _dedup[i].millis) < MSG_DEDUP_WINDOW_MS) {
// Duplicate — suppress UI notification but still queued for BLE sync
Serial.println("[Dedup] Suppressed duplicate");
return;
}
}
// Record this message in the dedup ring
_dedup[_dedupIdx].name_hash = nameH;
_dedup[_dedupIdx].text_hash = textH;
_dedup[_dedupIdx].millis = now;
_dedupIdx = (_dedupIdx + 1) % MSG_DEDUP_SIZE;
// Add to preview screen (for notifications on non-keyboard devices)
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
@@ -1282,7 +1432,35 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
}
// Add to channel history screen with channel index, path data, and SNR
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
// For DMs (channel_idx == 0xFF):
// - Regular DMs: prefix text with sender name ("NodeName: hello")
// - Room server messages: text already contains "OriginalSender: message",
// don't double-prefix. Tag with room server name for conversation filtering.
bool isRoomMsg = false;
if (channel_idx == 0xFF) {
// Check if sender is a room server
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo senderContact;
for (uint32_t ci = 0; ci < numContacts; ci++) {
if (the_mesh.getContactByIdx(ci, senderContact) && strcmp(senderContact.name, from_name) == 0) {
if (senderContact.type == ADV_TYPE_ROOM) isRoomMsg = true;
break;
}
}
if (isRoomMsg) {
// Room server: text already has "Poster: message" format — store as-is
// Tag with room server name for conversation filtering
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, from_name);
} else {
// Regular DM: prefix with sender name
char dmFormatted[CHANNEL_MSG_TEXT_LEN];
snprintf(dmFormatted, sizeof(dmFormatted), "%s: %s", from_name, text);
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, dmFormatted, path, snr);
}
} else {
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
}
// If user is currently viewing this channel, mark it as read immediately
// (they can see the message arrive in real-time)
@@ -1290,18 +1468,31 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
}
// Per-contact DM unread tracking: find contact index by name
if (channel_idx == 0xFF && _dmUnread) {
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo contact;
for (uint32_t ci = 0; ci < numContacts; ci++) {
if (the_mesh.getContactByIdx(ci, contact) && strcmp(contact.name, from_name) == 0) {
if (_dmUnread[ci] < 255) _dmUnread[ci]++;
break;
}
}
}
#if defined(LilyGo_TDeck_Pro) || defined(LilyGo_T5S3_EPaper_Pro)
// Don't interrupt user with popup - just show brief notification
// Messages are stored in channel history, accessible via tile/key
if (!isOnRepeaterAdmin()) {
// Suppress toasts for room server messages (bulk sync would spam toasts)
if (!isOnRepeaterAdmin() && !isRoomMsg) {
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
}
#else
// Other devices: Show full preview screen (legacy behavior)
setCurrScreen(msg_preview);
// Other devices: Show full preview screen (legacy behavior, skip room sync)
if (!isRoomMsg) setCurrScreen(msg_preview);
#endif
if (_display != NULL) {
@@ -1310,13 +1501,19 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
}
if (_display->isOn()) {
_auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer
_next_refresh = 100; // trigger refresh
// Throttle refresh during room sync — batch messages instead of 648ms render per msg
if (isRoomMsg) {
unsigned long earliest = millis() + 3000; // At most one refresh per 3s during sync
if (_next_refresh < earliest) _next_refresh = earliest;
} else {
_next_refresh = 100; // trigger refresh
}
}
}
// Keyboard flash notification
// Keyboard flash notification (suppress for room sync)
#ifdef KB_BL_PIN
if (_node_prefs->kb_flash_notify) {
if (_node_prefs->kb_flash_notify && !isRoomMsg) {
digitalWrite(KB_BL_PIN, HIGH);
_kb_flash_off_at = millis() + 200; // 200ms flash
}
@@ -1348,6 +1545,7 @@ void UITask::setCurrScreen(UIScreen* c) {
curr = c;
_alert_expiry = 0; // Dismiss any active toast — prevents stale overlay from
// triggering extra 644ms e-ink refreshes on the new screen
if (_hintActive) dismissBootHint(); // Dismiss hint when navigating away
_next_refresh = 100;
}
@@ -1522,6 +1720,14 @@ void UITask::loop() {
}
#endif
if (c != 0 && curr) {
// Dismiss boot hint on any button input (boot button on T5S3)
if (_hintActive) {
dismissBootHint();
c = 0; // Consume the press
}
}
if (c != 0 && curr) {
curr->handleInput(c);
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
@@ -1643,7 +1849,56 @@ if (curr) curr->poll();
}
#endif
if (millis() < _alert_expiry) {
// 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);
int w = _display->width();
int h = _display->height();
int boxX = w / 8;
int boxY = h / 5;
int boxW = w - boxX * 2;
int boxH = h * 3 / 5;
_display->setColor(DisplayDriver::DARK);
_display->fillRect(boxX, boxY, boxW, boxH);
_display->setColor(DisplayDriver::LIGHT);
_display->drawRect(boxX, boxY, boxW, boxH);
int cx = w / 2;
int lineH = 11;
int startY = boxY + 6;
#if defined(LilyGo_T5S3_EPaper_Pro)
_display->drawTextCentered(cx, startY, "Swipe: Navigate");
_display->drawTextCentered(cx, startY + lineH, "Tap: Select");
_display->drawTextCentered(cx, startY + lineH * 2, "Long Press: Action");
_display->drawTextCentered(cx, startY + lineH * 3, "Boot Btn: Home");
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[Tap to dismiss hint]");
#else
_display->drawTextCentered(cx, startY, "M:Msgs C:Contacts");
_display->drawTextCentered(cx, startY + lineH, "S:Settings E:Reader");
_display->drawTextCentered(cx, startY + lineH * 2, "N:Notes W/S:Scroll");
_display->drawTextCentered(cx, startY + lineH * 3, "A/D:Cycle Left/Right");
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[X to dismiss hint]");
#endif
_next_refresh = _hintExpiry;
} else if (_hintActive) {
// Hint expired — auto-dismiss
dismissBootHint();
_next_refresh = millis() + 200;
} else if (millis() < _alert_expiry) {
_display->setTextSize(1);
int y = _display->height() / 3;
int p = _display->height() / 32;
@@ -1659,7 +1914,33 @@ if (curr) curr->poll();
}
#else
int delay_millis = curr->render(*_display);
if (millis() < _alert_expiry) { // render alert popup
if (_hintActive && millis() < _hintExpiry) {
// Boot navigation hint overlay — multi-line, larger box
_display->setTextSize(1);
int w = _display->width();
int h = _display->height();
int boxX = w / 8;
int boxY = h / 5;
int boxW = w - boxX * 2;
int boxH = h * 3 / 5;
_display->setColor(DisplayDriver::DARK);
_display->fillRect(boxX, boxY, boxW, boxH);
_display->setColor(DisplayDriver::LIGHT);
_display->drawRect(boxX, boxY, boxW, boxH);
int cx = w / 2;
int lineH = 11;
int startY = boxY + 6;
_display->drawTextCentered(cx, startY, "M:Msgs C:Contacts");
_display->drawTextCentered(cx, startY + lineH, "S:Settings E:Reader");
_display->drawTextCentered(cx, startY + lineH * 2, "N:Notes W/S:Scroll");
_display->drawTextCentered(cx, startY + lineH * 3, "A/D:Cycle Left/Right");
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[X to dismiss]");
_next_refresh = _hintExpiry;
} else if (_hintActive) {
// Hint expired — auto-dismiss
dismissBootHint();
_next_refresh = millis() + 200;
} else if (millis() < _alert_expiry) { // render alert popup
_display->setTextSize(1);
int y = _display->height() / 3;
int p = _display->height() / 32;
@@ -1718,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
@@ -1844,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");
}
@@ -1911,12 +2232,26 @@ void UITask::onVKBSubmit() {
case VKB_DM: {
if (strlen(text) == 0) break;
bool dmSuccess = false;
if (the_mesh.uiSendDirectMessage((uint32_t)idx, text)) {
showAlert("DM sent!", 1500);
} else {
showAlert("DM failed!", 1500);
// Add to channel screen so sent DM appears in conversation view
ContactInfo dmRecipient;
if (the_mesh.getContactByIdx(idx, dmRecipient)) {
addSentDM(dmRecipient.name, the_mesh.getNodePrefs()->node_name, text);
}
dmSuccess = true;
}
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
// Return to DM conversation if we have contact info
ContactInfo dmContact;
if (the_mesh.getContactByIdx(idx, dmContact)) {
ChannelScreen* cs = (ChannelScreen*)channel_screen;
uint8_t savedPerms = (cs && cs->isDMConversation()) ? cs->getDMContactPerms() : 0;
gotoDMConversation(dmContact.name, idx, savedPerms);
} else if (_screenBeforeVKB) {
setCurrScreen(_screenBeforeVKB);
}
// Show alert AFTER navigation (setCurrScreen clears prior alerts)
showAlert(dmSuccess ? "DM sent!" : "DM failed!", 1500);
break;
}
case VKB_ADMIN_PASSWORD: {
@@ -1952,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) {
@@ -2156,6 +2504,15 @@ void UITask::gotoHomeScreen() {
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
// Activate deferred boot hint now that home screen is visible
if (_pendingBootHint) {
_pendingBootHint = false;
_hintActive = true;
_hintExpiry = millis() + 8000; // 8 seconds auto-dismiss
_next_refresh = millis() + 100;
Serial.println("[UI] Boot hint activated");
}
}
bool UITask::isEditingHomeScreen() const {
@@ -2167,11 +2524,38 @@ bool UITask::isHomeOnRecentPage() const {
}
void UITask::gotoChannelScreen() {
((ChannelScreen *) channel_screen)->resetScroll();
ChannelScreen* cs = (ChannelScreen*)channel_screen;
// If currently showing DM view, reset to channel 0
if (cs->getViewChannelIdx() == 0xFF) {
cs->setViewChannelIdx(0);
}
cs->resetScroll();
// Mark the currently viewed channel as read
((ChannelScreen *) channel_screen)->markChannelRead(
((ChannelScreen *) channel_screen)->getViewChannelIdx()
);
cs->markChannelRead(cs->getViewChannelIdx());
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoDMTab() {
((ChannelScreen *) channel_screen)->setViewChannelIdx(0xFF); // switches + marks read
((ChannelScreen *) channel_screen)->resetScroll();
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoDMConversation(const char* contactName, int contactIdx, uint8_t perms) {
ChannelScreen* cs = (ChannelScreen*)channel_screen;
cs->setViewChannelIdx(0xFF); // enters inbox mode + marks read
cs->openConversation(contactName, contactIdx, perms); // switches to conversation mode
cs->resetScroll();
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -2256,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;
@@ -2286,12 +2686,44 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
}
void UITask::addSentDM(const char* recipientName, const char* sender, const char* text) {
// Format as "Sender: message" and tag with recipient's peer hash
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
((ChannelScreen *) channel_screen)->addMessage(0xFF, 0, sender, formattedMsg,
nullptr, 0, recipientName);
}
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
// If clearing DMs, also zero all per-contact DM counts
if (channel_idx == 0xFF && _dmUnread) {
memset(_dmUnread, 0, MAX_CONTACTS * sizeof(uint8_t));
}
// Trigger a refresh so the home screen unread count updates in real-time
_next_refresh = millis() + 200;
}
bool UITask::hasDMUnread(int contactIdx) const {
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return false;
return _dmUnread[contactIdx] > 0;
}
int UITask::getDMUnreadCount(int contactIdx) const {
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return 0;
return _dmUnread[contactIdx];
}
void UITask::clearDMUnread(int contactIdx) {
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return;
int count = _dmUnread[contactIdx];
if (count > 0) {
_dmUnread[contactIdx] = 0;
((ChannelScreen *) channel_screen)->subtractDMUnread(count);
_next_refresh = millis() + 200;
}
}
void UITask::gotoRepeaterAdmin(int contactIdx) {
// Lazy-initialize on first use (same pattern as audiobook player)
if (repeater_admin == nullptr) {
@@ -2317,6 +2749,17 @@ void UITask::gotoRepeaterAdmin(int contactIdx) {
_next_refresh = 100;
}
void UITask::gotoRepeaterAdminDirect(int contactIdx) {
// Open admin and auto-submit cached password (skips password screen)
_skipRoomRedirect = true; // Don't redirect back to conversation after login
gotoRepeaterAdmin(contactIdx);
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
if (admin && admin->getState() == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
// If password was pre-filled from cache, simulate Enter to submit login
admin->handleInput('\r');
}
}
void UITask::gotoDiscoveryScreen() {
((DiscoveryScreen*)discovery_screen)->resetScroll();
setCurrScreen(discovery_screen);
@@ -2343,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;
@@ -2381,6 +2824,26 @@ void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t serv
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
_next_refresh = 100; // trigger re-render
if (success) {
int cidx = ((RepeaterAdminScreen*)repeater_admin)->getContactIdx();
if (cidx >= 0) {
clearDMUnread(cidx);
// Room server login: redirect to conversation view with stored permissions.
// Admin users see L:Admin footer to access the admin panel.
// Skip redirect if user explicitly pressed L to get to admin.
if (!_skipRoomRedirect) {
ContactInfo contact;
if (the_mesh.getContactByIdx(cidx, contact) && contact.type == ADV_TYPE_ROOM) {
uint8_t maskedPerms = permissions & 0x03;
gotoDMConversation(contact.name, cidx, maskedPerms);
return;
}
}
_skipRoomRedirect = false;
}
}
}
}

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
@@ -56,6 +60,9 @@ class UITask : public AbstractUITask {
NodePrefs* _node_prefs;
char _alert[80];
unsigned long _alert_expiry;
bool _hintActive = false; // Boot navigation hint overlay
unsigned long _hintExpiry = 0; // Auto-dismiss time for hint
bool _pendingBootHint = false; // Deferred hint — show after splash screen
int _msgcount;
unsigned long ui_started_at, next_batt_chck;
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
@@ -79,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
@@ -103,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
@@ -114,6 +131,26 @@ class UITask : public AbstractUITask {
unsigned long _lastLockRefresh = 0; // Periodic lock screen clock update
#endif
// --- Message dedup ring buffer (suppress retry spam at UI level) ---
#define MSG_DEDUP_SIZE 8
#define MSG_DEDUP_WINDOW_MS 60000 // 60 seconds
struct MsgDedup {
uint32_t name_hash;
uint32_t text_hash;
unsigned long millis;
};
MsgDedup _dedup[MSG_DEDUP_SIZE];
int _dedupIdx = 0;
// --- Per-contact DM unread tracking ---
uint8_t* _dmUnread = nullptr; // PSRAM-allocated, MAX_CONTACTS entries
static uint32_t simpleHash(const char* s) {
uint32_t h = 5381;
while (*s) { h = ((h << 5) + h) ^ (uint8_t)*s++; }
return h;
}
void userLedHandler();
// Button action handlers
@@ -141,13 +178,19 @@ public:
void gotoHomeScreen();
void gotoChannelScreen(); // Navigate to channel message screen
void gotoDMTab(); // Navigate directly to DM tab on channel screen
void gotoDMConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0);
void gotoContactsScreen(); // Navigate to contacts list
void gotoTextReader(); // *** NEW: Navigate to text reader ***
void gotoNotesScreen(); // Navigate to notes editor
void gotoSettingsScreen(); // Navigate to settings
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
#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
void gotoLastHeardScreen(); // Navigate to last heard passive list
#if HAS_GPS
@@ -163,6 +206,9 @@ public:
#endif
void showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
void showBootHint(bool immediate = false); // Show navigation hint overlay on first boot
void dismissBootHint(); // Dismiss hint and save preference
bool isHintActive() const { return _hintActive; }
// Wake display and extend auto-off timer. Call this when handling keys
// outside of injectKey() to prevent display auto-off during direct input.
void keepAlive() {
@@ -171,6 +217,14 @@ public:
}
int getMsgCount() const { return _msgcount; }
int getUnreadMsgCount() const; // Per-channel unread tracking (standalone)
// Per-contact DM unread tracking
bool hasDMUnread(int contactIdx) const;
int getDMUnreadCount(int contactIdx) const;
void clearDMUnread(int contactIdx);
// Flag: suppress room→conversation redirect on next login (L key admin access)
bool _skipRoomRedirect = false;
bool hasDisplay() const { return _display != NULL; }
bool isButtonPressed() const;
bool isOnChannelScreen() const { return curr == channel_screen; }
@@ -184,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; }
@@ -231,6 +288,7 @@ public:
// Add a sent message to the channel screen history
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
void addSentDM(const char* recipientName, const char* sender, const char* text);
// Mark channel as read when BLE companion app syncs messages
void markChannelReadFromBLE(uint8_t channel_idx) override;
@@ -248,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

@@ -1,7 +1,10 @@
"""
PlatformIO post-build script: merge bootloader + partitions + firmware
PlatformIO post-build script: merge bootloader + partitions + firmware + SPIFFS
into a single flashable binary.
Includes a pre-formatted empty SPIFFS image so first-boot doesn't need to
format the partition (which takes 1-2 minutes on 16MB flash).
Output: .pio/build/<env>/firmware_merged.bin
Flash: esptool.py --chip esp32s3 write_flash 0x0 firmware_merged.bin
@@ -12,6 +15,87 @@ Add to each environment (or the base section):
Import("env")
def find_spiffs_partition(partitions_bin):
"""Parse compiled partitions.bin to find SPIFFS partition offset and size.
ESP32 partition entry format (32 bytes each):
0xAA50 magic, type, subtype, offset(u32le), size(u32le), label(16), flags(u32le)
SPIFFS: type=0x01(data), subtype=0x82(spiffs)
"""
import struct
with open(partitions_bin, "rb") as f:
data = f.read()
for i in range(0, len(data) - 32, 32):
magic = struct.unpack_from("<H", data, i)[0]
if magic != 0xAA50:
continue
ptype = data[i + 2]
subtype = data[i + 3]
offset = struct.unpack_from("<I", data, i + 4)[0]
size = struct.unpack_from("<I", data, i + 8)[0]
label = data[i + 12:i + 28].split(b'\x00')[0].decode("ascii", errors="ignore")
if ptype == 0x01 and subtype == 0x82: # data/spiffs
return offset, size, label
return None, None, None
def build_spiffs_image(env, size):
"""Generate an empty formatted SPIFFS image using mkspiffs."""
import subprocess, os, tempfile, glob
build_dir = env.subst("$BUILD_DIR")
spiffs_bin = os.path.join(build_dir, "spiffs_empty.bin")
# If already generated for this build, reuse it
if os.path.isfile(spiffs_bin) and os.path.getsize(spiffs_bin) == size:
return spiffs_bin
# Find mkspiffs in PlatformIO packages
pio_home = os.path.expanduser("~/.platformio")
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mkspiffs*", "mkspiffs*"))
if not mkspiffs_paths:
# Also check platform-specific tool paths
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mklittlefs*", "mkspiffs*"))
mkspiffs = None
for p in mkspiffs_paths:
if os.path.isfile(p) and os.access(p, os.X_OK):
mkspiffs = p
break
if not mkspiffs:
print("[merge] WARNING: mkspiffs not found, skipping SPIFFS image")
return None
# Create empty data directory for mkspiffs
data_dir = os.path.join(build_dir, "_empty_spiffs_data")
os.makedirs(data_dir, exist_ok=True)
# SPIFFS block/page sizes — ESP32 Arduino defaults
block_size = 4096
page_size = 256
cmd = [
mkspiffs,
"-c", data_dir,
"-b", str(block_size),
"-p", str(page_size),
"-s", str(size),
spiffs_bin,
]
print(f"[merge] Generating empty SPIFFS image ({size // 1024} KB)...")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0 and os.path.isfile(spiffs_bin):
print(f"[merge] SPIFFS image OK: {spiffs_bin}")
return spiffs_bin
else:
print(f"[merge] mkspiffs failed: {result.stderr}")
return None
def merge_bin(source, target, env):
import subprocess, os
@@ -52,8 +136,18 @@ def merge_bin(source, target, env):
"0x10000", firmware,
]
# Try to include a pre-formatted SPIFFS image (eliminates 1-2 min first-boot format)
spiffs_offset, spiffs_size, spiffs_label = find_spiffs_partition(partitions)
if spiffs_offset and spiffs_size:
spiffs_bin = build_spiffs_image(env, spiffs_size)
if spiffs_bin:
cmd.extend([f"0x{spiffs_offset:x}", spiffs_bin])
print(f"[merge] Including SPIFFS image at 0x{spiffs_offset:x} ({spiffs_size // 1024} KB)")
else:
print("[merge] No SPIFFS partition found in partition table, skipping SPIFFS image")
print(f"\n[merge] Creating merged firmware for {env_name}...")
print(f"[merge] {' '.join(cmd[-6:])}")
print(f"[merge] {' '.join(cmd[-8:])}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:

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

@@ -24,7 +24,7 @@
#endif
#ifndef CPU_FREQ_LOW_POWER
#define CPU_FREQ_LOW_POWER 40 // MHz — lock screen / idle standby
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
#endif
#ifndef CPU_BOOST_TIMEOUT_MS

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

@@ -63,10 +63,14 @@ build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_T5S3_EPaper_Pro>
lib_deps =
${esp32_base.lib_deps}
WebServer
DNSServer
Update
; ---------------------------------------------------------------------------
; T5S3 standalone — touch UI (stub), verify display rendering
; Uses FastEPD for parallel e-ink, Adafruit GFX for drawing
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
; ---------------------------------------------------------------------------
[env:meck_t5s3_standalone]
extends = LilyGo_T5S3_EPaper_Pro
@@ -80,6 +84,7 @@ build_flags =
-D DISPLAY_CLASS=FastEPDDisplay
-D USE_EINK
-D MECK_CARDKB
-D MECK_OTA_UPDATE=1
; -D MECK_SERIF_FONT ; FreeSerif (Times New Roman-like)
; ; Default (no flag): FreeSans (Arial-like)
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
@@ -98,6 +103,7 @@ lib_deps =
; ---------------------------------------------------------------------------
; T5S3 BLE companion — touch UI, BLE phone bridging
; Connect via MeshCore iOS/Android app over Bluetooth
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
; Flash: pio run -e meck_t5s3_ble -t upload
; ---------------------------------------------------------------------------
[env:meck_t5s3_ble]
@@ -112,6 +118,7 @@ build_flags =
-D DISPLAY_CLASS=FastEPDDisplay
-D USE_EINK
-D MECK_CARDKB
-D MECK_OTA_UPDATE=1
; -D MECK_SERIF_FONT
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
@@ -141,6 +148,7 @@ build_flags =
-D MAX_GROUP_CHANNELS=20
-D MECK_WIFI_COMPANION=1
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D TCP_PORT=5000
-D OFFLINE_QUEUE_SIZE=256
-D DISPLAY_CLASS=FastEPDDisplay

View File

@@ -1,35 +0,0 @@
#include <Arduino.h>
#include "TDeckBoard.h"
uint32_t deviceOnline = 0x00;
void TDeckBoard::begin() {
ESP32Board::begin();
// Enable peripheral power
pinMode(PIN_PERF_POWERON, OUTPUT);
digitalWrite(PIN_PERF_POWERON, HIGH);
// Configure user button
pinMode(PIN_USER_BTN, INPUT);
// Configure LoRa Pins
pinMode(P_LORA_MISO, INPUT_PULLUP);
// pinMode(P_LORA_DIO_1, INPUT_PULLUP);
#ifdef P_LORA_TX_LED
digitalWrite(P_LORA_TX_LED, HIGH); // inverted pin for SX1276 - HIGH for off
#endif
esp_reset_reason_t reason = esp_reset_reason();
if (reason == ESP_RST_DEEPSLEEP) {
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
if (wakeup_source & (1 << P_LORA_DIO_1)) {
startup_reason = BD_STARTUP_RX_PACKET; // received a LoRa packet (while in deep sleep)
}
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
}
}

View File

@@ -1,68 +0,0 @@
#pragma once
#include <Wire.h>
#include <Arduino.h>
#include "helpers/ESP32Board.h"
#include <driver/rtc_io.h>
#define PIN_VBAT_READ 4
#define BATTERY_SAMPLES 8
#define ADC_MULTIPLIER (2.0f * 3.3f * 1000)
class TDeckBoard : public ESP32Board {
public:
void begin();
#ifdef P_LORA_TX_LED
void onBeforeTransmit() override{
digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276
}
void onAfterTransmit() override{
digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off - invert pin for SX1276
}
#endif
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
// Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
if (pin_wake_btn < 0) {
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet
} else {
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn
}
if (secs > 0) {
esp_sleep_enable_timer_wakeup(secs * 1000000);
}
// Finally set ESP32 into sleep
esp_deep_sleep_start(); // CPU halts here and never returns!
}
uint16_t getBattMilliVolts() {
#if defined(PIN_VBAT_READ) && defined(ADC_MULTIPLIER)
analogReadResolution(12);
uint32_t raw = 0;
for (int i = 0; i < BATTERY_SAMPLES; i++) {
raw += analogRead(PIN_VBAT_READ);
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
#else
return 0;
#endif
}
const char* getManufacturerName() const{
return "LilyGo T-Deck";
}
};

View File

@@ -1,115 +0,0 @@
[LilyGo_TDeck]
extends = esp32_base
board = t-deck
build_flags =
${esp32_base.build_flags}
${sensor_base.build_flags}
-I variants/lilygo_tdeck
-D LILYGO_TDECK
-D BOARD_HAS_PSRAM=1
-D CORE_DEBUG_LEVEL=1
-D ARDUINO_USB_CDC_ON_BOOT=1
-D PIN_USER_BTN=0 ; Trackball button
-D PIN_PERF_POWERON=10 ; Peripheral power pin
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D SX126X_DIO2_AS_RF_SWITCH=false
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D SX126X_DIO3_TCXO_VOLTAGE=1.8f
-D P_LORA_DIO_1=45 ; LORA IRQ pin
-D ENV_INCLUDE_GPS=1
-D ENV_INCLUDE_AHTX0=0
-D ENV_INCLUDE_BME280=0
-D ENV_INCLUDE_BMP280=0
-D ENV_INCLUDE_SHTC3=0
-D ENV_INCLUDE_SHT4X=0
-D ENV_INCLUDE_LPS22HB=0
-D ENV_INCLUDE_INA3221=0
-D ENV_INCLUDE_INA219=0
-D ENV_INCLUDE_INA226=0
-D ENV_INCLUDE_INA260=0
-D ENV_INCLUDE_MLX90614=0
-D ENV_INCLUDE_VL53L0X=0
-D ENV_INCLUDE_BME680=0
-D ENV_INCLUDE_BMP085=0
-D P_LORA_NSS=9 ; LORA SS pin
-D P_LORA_RESET=17 ; LORA RST pin
-D P_LORA_BUSY=13 ; LORA Busy pin
-D P_LORA_SCLK=40 ; LORA SCLK pin
-D P_LORA_MISO=38 ; LORA MISO pin
-D P_LORA_MOSI=41 ; LORA MOSI pin
-D DISPLAY_CLASS=ST7789LCDDisplay
-D DISPLAY_SCALE_X=2.5
-D DISPLAY_SCALE_Y=3.75
-D PIN_TFT_RST=-1
-D PIN_TFT_VDD_CTL=-1
-D PIN_TFT_LEDA_CTL=42
-D PIN_TFT_CS=12
-D PIN_TFT_DC=11
-D PIN_TFT_SCL=40
-D PIN_TFT_SDA=41
-D PIN_GPS_RX=43
-D PIN_GPS_TX=44
-D GPS_BAUD_RATE=38400
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/lilygo_tdeck>
+<helpers/sensors/*.cpp>
lib_deps =
${esp32_base.lib_deps}
${sensor_base.lib_deps}
adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0
[env:LilyGo_TDeck_companion_radio_usb]
extends = LilyGo_TDeck
build_flags =
${LilyGo_TDeck.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=256
build_src_filter = ${LilyGo_TDeck.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/ST7789LCDDisplay.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_companion_radio_ble]
extends = LilyGo_TDeck
build_flags =
${LilyGo_TDeck.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
build_src_filter = ${LilyGo_TDeck.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/ST7789LCDDisplay.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_repeater]
extends = LilyGo_TDeck
build_flags =
${LilyGo_TDeck.build_flags}
-D ADVERT_NAME='"TDeck Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
build_src_filter = ${LilyGo_TDeck.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/ST7789LCDDisplay.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
${esp32_ota.lib_deps}

View File

@@ -1,55 +0,0 @@
#include <Arduino.h>
#include "target.h"
TDeckBoard board;
#if defined(P_LORA_SCLK)
static SPIClass spi;
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
#else
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
#endif
WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
MicroNMEALocationProvider gps(Serial1, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
#endif
bool radio_init() {
fallback_clock.begin();
rtc_clock.begin(Wire);
Wire.begin(18, 8);
#if defined(P_LORA_SCLK)
return radio.std_init(&spi);
#else
return radio.std_init();
#endif
}
uint32_t radio_get_rng_seed() {
return radio.random(0x7FFFFFFF);
}
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
radio.setFrequency(freq);
radio.setSpreadingFactor(sf);
radio.setBandwidth(bw);
radio.setCodingRate(cr);
}
void radio_set_tx_power(uint8_t dbm) {
radio.setOutputPower(dbm);
}
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}

View File

@@ -1,31 +0,0 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <TDeckBoard.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#ifdef DISPLAY_CLASS
#include <helpers/ui/ST7789LCDDisplay.h>
#include <helpers/ui/MomentaryButton.h>
#endif
#include "helpers/sensors/EnvironmentSensorManager.h"
#include "helpers/sensors/MicroNMEALocationProvider.h"
extern TDeckBoard board;
extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
extern EnvironmentSensorManager sensors;
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;
extern MomentaryButton user_btn;
#endif
bool radio_init();
uint32_t radio_get_rng_seed();
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
void radio_set_tx_power(uint8_t dbm);
mesh::LocalIdentity radio_new_identity();

View File

@@ -24,7 +24,7 @@
#endif
#ifndef CPU_FREQ_LOW_POWER
#define CPU_FREQ_LOW_POWER 40 // MHz — lock screen / idle standby
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
#endif
#ifndef CPU_BOOST_TIMEOUT_MS

View File

@@ -161,24 +161,47 @@ public:
return false;
}
// Configure keyboard matrix (8 rows x 10 cols)
// --- Warm-reboot safe init sequence ---
// The TCA8418 stays powered across ESP32 resets (no dedicated RST pin),
// so the scanner may still be active from the previous session.
// We must disable it before reconfiguring the matrix.
// 1. Disable scanner — stop all scanning before touching config
writeReg(TCA8418_REG_CFG, 0x00);
// 2. Drain any stale events from the previous session
for (int i = 0; i < 16; i++) {
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
readReg(TCA8418_REG_KEY_EVENT_A);
}
writeReg(TCA8418_REG_INT_STAT, 0x1F); // Clear all interrupt flags
// 3. Explicitly clear GPI event masks (prevent phantom GPI events)
writeReg(TCA8418_REG_GPI_EM1, 0x00);
writeReg(TCA8418_REG_GPI_EM2, 0x00);
writeReg(TCA8418_REG_GPI_EM3, 0x00);
// 4. Configure keyboard matrix (8 rows x 10 cols)
writeReg(TCA8418_REG_KP_GPIO1, 0xFF); // Rows 0-7 as keypad
writeReg(TCA8418_REG_KP_GPIO2, 0xFF); // Cols 0-7 as keypad
writeReg(TCA8418_REG_KP_GPIO3, 0x03); // Cols 8-9 as keypad
// Enable keypad with FIFO overflow detection
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
// Set debounce
// 5. Set debounce
writeReg(TCA8418_REG_DEBOUNCE, 0x03);
// Clear any pending interrupts
// 6. Final pre-enable cleanup
writeReg(TCA8418_REG_INT_STAT, 0x1F);
// Flush the FIFO
while (readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) {
// 7. Enable scanner — matrix config is stable, safe to start scanning
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
// 8. Let scanner stabilise, then flush any spurious first-scan events
delay(5);
for (int i = 0; i < 16; i++) {
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
readReg(TCA8418_REG_KEY_EVENT_A);
}
writeReg(TCA8418_REG_INT_STAT, 0x1F);
_initialized = true;
Serial.println("TCA8418: Keyboard initialized OK");

View File

@@ -96,6 +96,9 @@ lib_deps =
zinggjm/GxEPD2@^1.5.9
adafruit/Adafruit GFX Library@^1.11.0
bitbank2/PNGdec@^1.0.1
WebServer
DNSServer
Update
; ---------------------------------------------------------------------------
; Meck unified builds — one codebase, six variants via build flags
@@ -114,6 +117,7 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -146,7 +150,8 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.WiFi"'
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.5.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -161,6 +166,7 @@ lib_deps =
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
[env:meck_audio_standalone]
extends = LilyGo_TDeck_Pro
@@ -171,6 +177,7 @@ build_flags =
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=1
-D MECK_AUDIO_VARIANT
-D MECK_OTA_UPDATE=1
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -196,7 +203,8 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.4G"'
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.5.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -226,7 +234,8 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.4G.WiFi"'
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.5.4G.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -252,7 +261,8 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=1
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.4G.SA"'
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.5.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>