10 Commits
v1.2 ... ota-1

19 changed files with 1956 additions and 464 deletions

View File

@@ -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)
@@ -716,6 +754,9 @@ There are a number of fairly major features in the pipeline, with no particular
- [ ] Better JPEG and PNG decoding
- [ ] Improve EPUB rendering and EPUB format handling
- [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
**T5S3 E-Paper Pro:**
- [X] Core port: display, touch input, LoRa, battery, RTC
@@ -733,9 +774,9 @@ 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
## 📞 Get Support

View File

@@ -252,6 +252,34 @@ 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
}
// Clamp to valid ranges
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
// 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 +319,11 @@ 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.close();
}

View File

@@ -498,7 +498,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
@@ -737,6 +754,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);
@@ -1588,6 +1612,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 {

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 "22 March 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v1.2"
#define FIRMWARE_VERSION "Meck v1.3"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)

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;
@@ -954,10 +1038,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 +1090,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 +1106,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 +1166,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 +1403,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 +1512,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();
@@ -1609,8 +1768,52 @@ void setup() {
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 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();
@@ -1882,6 +2085,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();
@@ -2107,13 +2313,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 +2352,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()) {
@@ -2282,17 +2527,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 +2563,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();
@@ -2612,22 +2876,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
@@ -2963,24 +3239,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 +3305,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();
@@ -3121,6 +3455,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
@@ -3352,6 +3700,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);

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,6 +552,196 @@ 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(0);
int lineH = 9;
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 + 5, 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) {
@@ -667,18 +945,154 @@ public:
display.setTextSize(0); // 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(0);
int lineHeight = 9;
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 + 5, 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
@@ -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;
}
}
@@ -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,39 @@ public:
break;
}
}
} else if (_viewChannelIdx > 0) {
_viewChannelIdx--;
} 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 {
ChannelDetails ch;
uint8_t nextIdx = _viewChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
_viewChannelIdx = nextIdx;
} else {
// 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;
@@ -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

@@ -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) {

View File

@@ -22,6 +22,16 @@
#include <SD.h>
#endif
#ifdef MECK_OTA_UPDATE
#ifndef MECK_WIFI_COMPANION
#include <WiFi.h>
#include <SD.h>
#endif
#include <WebServer.h>
#include <Update.h>
#include <esp_ota_ops.h>
#endif
// Forward declarations
class UITask;
class MyMesh;
@@ -131,6 +141,9 @@ enum SettingsRowType : uint8_t {
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
ROW_INFO_HEADER, // "--- Info ---" separator
#ifdef MECK_OTA_UPDATE
ROW_FW_UPDATE, // "Firmware Update" — WiFi upload + flash
#endif
ROW_PUB_KEY, // Public key display
ROW_FIRMWARE, // Firmware version
#ifdef HAS_4G_MODEM
@@ -152,6 +165,9 @@ enum EditMode : uint8_t {
#ifdef MECK_WIFI_COMPANION
EDIT_WIFI, // WiFi scan/select/password flow
#endif
#ifdef MECK_OTA_UPDATE
EDIT_OTA, // OTA firmware update flow (multi-phase overlay)
#endif
};
// ---------------------------------------------------------------------------
@@ -163,6 +179,20 @@ enum SubScreen : uint8_t {
SUB_CHANNELS, // Channels management sub-screen
};
#ifdef MECK_OTA_UPDATE
// OTA update phases
enum OtaPhase : uint8_t {
OTA_PHASE_CONFIRM, // "Start firmware update? Enter:Yes Q:No"
OTA_PHASE_AP_START, // Starting WiFi AP + web server
OTA_PHASE_WAITING, // AP up, waiting for device to upload
OTA_PHASE_RECEIVING, // File upload in progress
OTA_PHASE_VERIFY, // Checking downloaded file
OTA_PHASE_FLASH, // Writing to flash — DO NOT POWER OFF
OTA_PHASE_DONE, // Success, rebooting
OTA_PHASE_ERROR, // Error with message
};
#endif
// Max rows in the settings list (increased for contact sub-toggles + WiFi)
#if defined(HAS_4G_MODEM) && defined(MECK_WIFI_COMPANION)
#define SETTINGS_MAX_ROWS 56 // Extra rows for IMEI, Carrier, APN, contacts, WiFi
@@ -238,6 +268,17 @@ private:
#endif
#endif
#ifdef MECK_OTA_UPDATE
// OTA update state
OtaPhase _otaPhase;
WebServer* _otaServer;
File _otaFile;
size_t _otaBytesReceived;
bool _otaUploadOk;
char _otaApName[24];
const char* _otaError;
#endif
// ---------------------------------------------------------------------------
// Contact mode helpers
// ---------------------------------------------------------------------------
@@ -351,6 +392,9 @@ private:
// Info section (stays at top level)
addRow(ROW_INFO_HEADER);
#ifdef MECK_OTA_UPDATE
addRow(ROW_FW_UPDATE);
#endif
addRow(ROW_PUB_KEY);
addRow(ROW_FIRMWARE);
@@ -503,6 +547,13 @@ public:
_onboarding(false), _subScreen(SUB_NONE), _savedTopCursor(0),
_radioChanged(false) {
memset(_editBuf, 0, sizeof(_editBuf));
#ifdef MECK_OTA_UPDATE
_otaServer = nullptr;
_otaPhase = OTA_PHASE_CONFIRM;
_otaBytesReceived = 0;
_otaUploadOk = false;
_otaError = nullptr;
#endif
}
void enter() {
@@ -540,6 +591,13 @@ public:
bool isOnboarding() const { return _onboarding; }
bool isEditing() const { return _editMode != EDIT_NONE; }
bool hasRadioChanges() const { return _radioChanged; }
bool isOnChannelsSubScreen() const { return _subScreen == SUB_CHANNELS; }
bool isOnDeletableChannel() const {
return _subScreen == SUB_CHANNELS &&
_cursor >= 0 && _cursor < _numRows &&
_rows[_cursor].type == ROW_CHANNEL &&
_rows[_cursor].param > 0;
}
// Tap-to-select: given a virtual Y coordinate, compute which row was tapped
// and move cursor there. Returns: 0=miss, 1=moved to new row, 2=tapped current row.
@@ -682,6 +740,325 @@ public:
#endif
// ---------------------------------------------------------------------------
// OTA firmware update
// ---------------------------------------------------------------------------
#ifdef MECK_OTA_UPDATE
// HTML upload page served to the browser
static const char* otaUploadPageHTML() {
return
"<!DOCTYPE html><html><head>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Meck Firmware Update</title>"
"<style>"
"body{font-family:-apple-system,sans-serif;max-width:480px;margin:40px auto;"
"padding:0 20px;background:#1a1a2e;color:#e0e0e0}"
"h1{color:#4ecca3;font-size:1.4em}"
".info{background:#16213e;padding:12px;border-radius:8px;margin:16px 0;font-size:0.9em}"
"input[type=file]{margin:16px 0;color:#e0e0e0}"
"button{background:#4ecca3;color:#1a1a2e;border:none;padding:12px 32px;"
"border-radius:6px;font-size:1.1em;font-weight:bold;cursor:pointer}"
"button:active{background:#3ba88f}"
"#prog{display:none;margin-top:16px}"
".bar{background:#16213e;border-radius:4px;height:24px;overflow:hidden}"
".fill{background:#4ecca3;height:100%;width:0%;transition:width 0.3s}"
"</style></head><body>"
"<h1>Meck Firmware Update</h1>"
"<div class='info'>Select the firmware .bin file and tap Upload. "
"The device will verify and flash it automatically.</div>"
"<form method='POST' action='/upload' enctype='multipart/form-data'>"
"<input type='file' name='firmware' accept='.bin'><br>"
"<button type='submit' onclick=\"document.getElementById('prog').style.display='block'\">"
"Upload Firmware</button></form>"
"<div id='prog'><div>Uploading... do not close this page</div>"
"<div class='bar'><div class='fill' id='fill'></div></div></div>"
"<script>document.querySelector('form').onsubmit=function(){"
"var f=document.getElementById('fill'),w=0;"
"setInterval(function(){w+=2;if(w>90)w=90;f.style.width=w+'%'},500)};</script>"
"</body></html>";
}
void startOTA() {
_editMode = EDIT_OTA;
_otaPhase = OTA_PHASE_CONFIRM;
_otaBytesReceived = 0;
_otaUploadOk = false;
_otaError = nullptr;
}
void startOTAServer() {
// Build AP name with last 4 of MAC for uniqueness
uint8_t mac[6];
WiFi.macAddress(mac);
snprintf(_otaApName, sizeof(_otaApName), "Meck-Update-%02X%02X", mac[4], mac[5]);
// Pause LoRa radio — SD and LoRa share the same SPI bus on both
// platforms. Incoming packets during SD writes cause bus contention
// that stalls the upload.
extern void otaPauseRadio();
otaPauseRadio();
// Clean WiFi init from any state (including never-initialised on
// standalone builds where WiFi.mode() was never called during boot).
// OFF→AP sequence ensures the WiFi peripheral starts fresh.
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
delay(200);
WiFi.mode(WIFI_AP);
WiFi.softAP(_otaApName);
delay(500); // Let AP stabilise
Serial.printf("OTA: AP '%s' started, IP: %s\n",
_otaApName, WiFi.softAPIP().toString().c_str());
// Start web server
if (_otaServer) { _otaServer->stop(); delete _otaServer; }
_otaServer = new WebServer(80);
_otaServer->on("/", HTTP_GET, [this]() {
_otaServer->send(200, "text/html", otaUploadPageHTML());
});
_otaServer->on("/upload", HTTP_POST,
// Response after upload completes
[this]() {
_otaServer->send(200, "text/html",
_otaUploadOk
? "<html><body style='background:#1a1a2e;color:#4ecca3;font-family:sans-serif;"
"text-align:center;padding:60px'><h1>Upload OK!</h1>"
"<p>The device is now verifying and flashing.<br>It will reboot automatically.</p></body></html>"
: "<html><body style='background:#1a1a2e;color:#e74c3c;font-family:sans-serif;"
"text-align:center;padding:60px'><h1>Upload Failed</h1>"
"<p>Please try again.</p></body></html>"
);
},
// Upload handler — called per chunk
[this]() {
HTTPUpload& upload = _otaServer->upload();
if (upload.status == UPLOAD_FILE_START) {
Serial.printf("OTA: Receiving: %s\n", upload.filename.c_str());
_otaUploadOk = false;
_otaBytesReceived = 0;
if (!SD.exists("/firmware")) SD.mkdir("/firmware");
if (SD.exists("/firmware/update.bin")) {
SD.remove("/firmware/previous.bin");
SD.rename("/firmware/update.bin", "/firmware/previous.bin");
}
_otaFile = SD.open("/firmware/update.bin", FILE_WRITE);
if (!_otaFile) {
Serial.println("OTA: Failed to open SD file");
return;
}
_otaPhase = OTA_PHASE_RECEIVING;
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (_otaFile) {
_otaFile.write(upload.buf, upload.currentSize);
_otaBytesReceived += upload.currentSize;
}
} else if (upload.status == UPLOAD_FILE_END) {
if (_otaFile) {
_otaFile.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("OTA: Received %d bytes\n", _otaBytesReceived);
_otaUploadOk = (_otaBytesReceived > 0);
}
} else if (upload.status == UPLOAD_FILE_ABORTED) {
if (_otaFile) { _otaFile.close(); SD.remove("/firmware/update.bin"); }
digitalWrite(SDCARD_CS, HIGH);
Serial.println("OTA: Upload aborted");
}
}
);
_otaServer->begin();
Serial.println("OTA: Web server started on port 80");
_otaPhase = OTA_PHASE_WAITING;
}
void stopOTA() {
if (_otaServer) { _otaServer->stop(); delete _otaServer; _otaServer = nullptr; }
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_OFF);
delay(100);
_editMode = EDIT_NONE;
// Resume LoRa radio
extern void otaResumeRadio();
otaResumeRadio();
// Try to restore STA WiFi from saved credentials
#ifdef MECK_WIFI_COMPANION
WiFi.mode(WIFI_STA);
wifiReconnectSaved();
#endif
Serial.println("OTA: Stopped, AP down, radio resumed");
}
bool verifyFirmwareFile() {
File f = SD.open("/firmware/update.bin", FILE_READ);
if (!f) { _otaError = "File not found on SD"; return false; }
size_t fileSize = f.size();
if (fileSize < 500000 || fileSize > 6500000) {
f.close(); digitalWrite(SDCARD_CS, HIGH);
_otaError = "Bad file size (need 0.5-6MB)";
Serial.printf("OTA: Bad file size: %d\n", fileSize);
return false;
}
// Check ESP32 image magic byte
uint8_t magic;
f.read(&magic, 1);
f.close();
digitalWrite(SDCARD_CS, HIGH);
if (magic != 0xE9) {
_otaError = "Not a firmware file (bad magic)";
Serial.printf("OTA: Bad magic: 0x%02X\n", magic);
return false;
}
return true;
}
bool flashFirmwareFromSD(DisplayDriver& display) {
File firmware = SD.open("/firmware/update.bin", FILE_READ);
if (!firmware) { _otaError = "Cannot open firmware file"; return false; }
size_t fileSize = firmware.size();
if (!Update.begin(fileSize, U_FLASH)) {
_otaError = Update.errorString();
Serial.printf("OTA: Update.begin failed: %s\n", _otaError);
firmware.close();
return false;
}
const int BUF_SIZE = 4096;
uint8_t* buf = (uint8_t*)ps_malloc(BUF_SIZE);
if (!buf) buf = (uint8_t*)malloc(BUF_SIZE);
if (!buf) { firmware.close(); Update.abort(); _otaError = "Out of memory"; return false; }
size_t totalWritten = 0;
char tmp[48];
while (firmware.available()) {
int bytesRead = firmware.read(buf, BUF_SIZE);
if (bytesRead <= 0) break;
size_t written = Update.write(buf, bytesRead);
if (written != (size_t)bytesRead) {
_otaError = "Flash write error";
Serial.printf("OTA: Write error at %d bytes\n", totalWritten);
break;
}
totalWritten += written;
// Update e-ink progress every ~128KB
if (totalWritten % 131072 < (size_t)BUF_SIZE) {
display.startFrame();
display.setColor(DisplayDriver::DARK);
display.fillRect(2, 14, display.width() - 4, display.height() - 28);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(2, 14, display.width() - 4, display.height() - 28);
display.setTextSize(0);
display.drawTextCentered(display.width() / 2, 22, "Flashing Firmware");
snprintf(tmp, sizeof(tmp), "%d / %d KB", (int)(totalWritten / 1024), (int)(fileSize / 1024));
display.drawTextCentered(display.width() / 2, 42, tmp);
display.setColor(DisplayDriver::YELLOW);
display.drawTextCentered(display.width() / 2, 62, "DO NOT POWER OFF");
display.endFrame();
}
}
free(buf);
firmware.close();
digitalWrite(SDCARD_CS, HIGH);
if (!Update.end(true)) {
_otaError = Update.errorString();
Serial.printf("OTA: Update.end failed: %s\n", _otaError);
return false;
}
Serial.printf("OTA: Flash success! %d bytes written\n", totalWritten);
return true;
}
// Called from render loop AND main loop to poll the web server
void pollOTAServer() {
if (_otaServer && (_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) {
_otaServer->handleClient();
}
}
// Called from main loop — detect upload completion and trigger flash.
// Must be called from the main loop (not render) because T5S3 FastEPD
// blocks for 500ms+ per frame, making render-only detection unreliable.
void checkOTAComplete(DisplayDriver& display) {
if (_editMode != EDIT_OTA) return;
if (!_otaUploadOk) return;
if (_otaPhase != OTA_PHASE_RECEIVING && _otaPhase != OTA_PHASE_WAITING) return;
Serial.printf("OTA: Upload complete (%d bytes), starting flash sequence\n", _otaBytesReceived);
processOTAUpload(display);
}
// Run the verify → flash → reboot sequence after upload completes
void processOTAUpload(DisplayDriver& display) {
// Stop web server and AP first
if (_otaServer) { _otaServer->stop(); delete _otaServer; _otaServer = nullptr; }
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_OFF);
_otaPhase = OTA_PHASE_VERIFY;
if (!verifyFirmwareFile()) {
_otaPhase = OTA_PHASE_ERROR;
return;
}
_otaPhase = OTA_PHASE_FLASH;
// Backup settings before flashing (preserves identity/contacts across updates)
extern void backupSettingsToSD();
backupSettingsToSD();
if (!flashFirmwareFromSD(display)) {
_otaPhase = OTA_PHASE_ERROR;
return;
}
_otaPhase = OTA_PHASE_DONE;
// Show success screen then reboot
display.startFrame();
display.setColor(DisplayDriver::DARK);
display.fillRect(2, 14, display.width() - 4, display.height() - 28);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(2, 14, display.width() - 4, display.height() - 28);
display.setTextSize(0);
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, 30, "Update Complete!");
display.setColor(DisplayDriver::LIGHT);
File fw = SD.open("/firmware/update.bin", FILE_READ);
char tmp[48];
if (fw) {
snprintf(tmp, sizeof(tmp), "Firmware: %d KB", (int)(fw.size() / 1024));
fw.close(); digitalWrite(SDCARD_CS, HIGH);
} else {
strcpy(tmp, "Firmware written");
}
display.drawTextCentered(display.width() / 2, 48, tmp);
display.drawTextCentered(display.width() / 2, 66, "Rebooting in 3 seconds...");
display.endFrame();
delay(3000);
ESP.restart();
}
#endif
// ---------------------------------------------------------------------------
// Edit mode starters
// ---------------------------------------------------------------------------
@@ -1015,7 +1392,11 @@ public:
snprintf(tmp, sizeof(tmp), " %s", ch.name);
if (selected) {
// Show delete hint on right
const char* hint = "Del:X";
#if defined(LilyGo_T5S3_EPaper_Pro)
const char* hint = "Hold:Del";
#else
const char* hint = "X:Del";
#endif
int hintW = display.getTextWidth(hint);
display.setCursor(display.width() - hintW - 2, y);
display.print(hint);
@@ -1039,6 +1420,12 @@ public:
display.print(tmp);
break;
#ifdef MECK_OTA_UPDATE
case ROW_FW_UPDATE:
display.print("Firmware Update");
break;
#endif
case ROW_INFO_HEADER:
display.setColor(DisplayDriver::YELLOW);
display.print("--- Device Info ---");
@@ -1129,7 +1516,11 @@ public:
} else if (_confirmAction == 2) {
display.drawTextCentered(display.width() / 2, by + 4, "Apply radio changes?");
}
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(display.width() / 2, by + bh - 14, "Tap:Yes Boot:No");
#else
display.drawTextCentered(display.width() / 2, by + bh - 14, "Enter:Yes Q:No");
#endif
display.setTextSize(1);
}
@@ -1219,6 +1610,98 @@ public:
}
#endif
#ifdef MECK_OTA_UPDATE
// === OTA update overlay ===
if (_editMode == EDIT_OTA) {
int bx = 2, by = 14, bw = display.width() - 4;
int bh = display.height() - 28;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
display.setTextSize(0);
int oy = by + 4;
if (_otaPhase == OTA_PHASE_CONFIRM) {
display.drawTextCentered(display.width() / 2, oy, "Firmware Update");
oy += 14;
display.setCursor(bx + 4, oy);
display.print("Start WiFi upload server?");
oy += 10;
display.setCursor(bx + 4, oy);
display.print("You will upload a .bin file");
oy += 8;
display.setCursor(bx + 4, oy);
display.print("from your device's browser.");
} else if (_otaPhase == OTA_PHASE_AP_START) {
display.drawTextCentered(display.width() / 2, oy + 20, "Starting WiFi...");
} else if (_otaPhase == OTA_PHASE_WAITING) {
display.drawTextCentered(display.width() / 2, oy, "Firmware Update");
oy += 14;
display.setCursor(bx + 4, oy);
display.print("Connect to WiFi network:");
oy += 10;
display.setColor(DisplayDriver::GREEN);
display.setCursor(bx + 4, oy);
display.print(_otaApName);
display.setColor(DisplayDriver::LIGHT);
oy += 12;
display.setCursor(bx + 4, oy);
display.print("Then open browser:");
oy += 10;
display.setColor(DisplayDriver::GREEN);
display.setCursor(bx + 4, oy);
char ipBuf[32];
snprintf(ipBuf, sizeof(ipBuf), "http://%s", WiFi.softAPIP().toString().c_str());
display.print(ipBuf);
display.setColor(DisplayDriver::LIGHT);
oy += 12;
display.setCursor(bx + 4, oy);
display.print("Waiting for upload...");
// Poll the web server during render
pollOTAServer();
} else if (_otaPhase == OTA_PHASE_RECEIVING) {
display.drawTextCentered(display.width() / 2, oy, "Receiving Firmware");
oy += 16;
char progBuf[32];
snprintf(progBuf, sizeof(progBuf), "%d KB received", (int)(_otaBytesReceived / 1024));
display.drawTextCentered(display.width() / 2, oy, progBuf);
oy += 14;
display.setCursor(bx + 4, oy);
display.print("Do not close browser");
// Keep polling during receive
pollOTAServer();
} else if (_otaPhase == OTA_PHASE_VERIFY) {
display.drawTextCentered(display.width() / 2, oy + 20, "Verifying file...");
} else if (_otaPhase == OTA_PHASE_FLASH) {
display.drawTextCentered(display.width() / 2, oy + 10, "Flashing Firmware");
display.setColor(DisplayDriver::YELLOW);
display.drawTextCentered(display.width() / 2, oy + 30, "DO NOT POWER OFF");
display.setColor(DisplayDriver::LIGHT);
} else if (_otaPhase == OTA_PHASE_ERROR) {
display.setColor(DisplayDriver::YELLOW);
display.drawTextCentered(display.width() / 2, oy, "Update Failed");
display.setColor(DisplayDriver::LIGHT);
oy += 14;
if (_otaError) {
display.setCursor(bx + 4, oy);
display.print(_otaError);
}
}
display.setTextSize(1);
}
#endif
// === Footer ===
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
@@ -1229,7 +1712,7 @@ public:
if (_editMode == EDIT_NONE) {
if (_subScreen != SUB_NONE) {
display.print("Boot:Back");
const char* r = "Tap:Toggle Hold:Edit";
const char* r = (_subScreen == SUB_CHANNELS) ? "Tap:Select Hold:Del" : "Tap:Toggle Hold:Edit";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
} else {
@@ -1264,6 +1747,21 @@ public:
display.print("Please wait...");
}
#endif
#ifdef MECK_OTA_UPDATE
} else if (_editMode == EDIT_OTA) {
if (_otaPhase == OTA_PHASE_CONFIRM) {
display.print("Boot:Cancel");
const char* r = "Tap:Start";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
} else if (_otaPhase == OTA_PHASE_WAITING) {
display.print("Boot:Cancel");
} else if (_otaPhase == OTA_PHASE_ERROR) {
display.print("Boot:Back");
} else {
display.print("Please wait...");
}
#endif
} else if (_editMode == EDIT_TEXT) {
display.print("Hold:Type");
const char* r = "Tap:OK Boot:Cancel";
@@ -1289,6 +1787,18 @@ public:
display.print("Please wait...");
}
#endif
#ifdef MECK_OTA_UPDATE
} else if (_editMode == EDIT_OTA) {
if (_otaPhase == OTA_PHASE_CONFIRM) {
display.print("Enter:Start Q:Cancel");
} else if (_otaPhase == OTA_PHASE_WAITING) {
display.print("Q:Cancel");
} else if (_otaPhase == OTA_PHASE_ERROR) {
display.print("Q:Back");
} else {
display.print("Please wait...");
}
#endif
} else if (_editMode == EDIT_PICKER) {
display.print("A/D:Choose Enter:Ok");
} else if (_editMode == EDIT_NUMBER) {
@@ -1307,6 +1817,13 @@ public:
}
#endif
#ifdef MECK_OTA_UPDATE
// Poll web server frequently during OTA waiting/receiving phases
if (_editMode == EDIT_OTA &&
(_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) {
return 200; // 200ms — fast enough for web server responsiveness
}
#endif
return _editMode != EDIT_NONE ? 700 : 1000;
}
@@ -1339,6 +1856,39 @@ public:
return true; // consume all keys in confirm mode
}
#ifdef MECK_OTA_UPDATE
// --- OTA update flow ---
if (_editMode == EDIT_OTA) {
if (_otaPhase == OTA_PHASE_CONFIRM) {
if (c == '\r' || c == 13) {
_otaPhase = OTA_PHASE_AP_START;
startOTAServer();
return true;
}
if (c == 'q' || c == 'Q') {
_editMode = EDIT_NONE;
return true;
}
} else if (_otaPhase == OTA_PHASE_WAITING) {
// Upload completed — main loop will detect and trigger flash
if (_otaUploadOk) {
return true;
}
if (c == 'q' || c == 'Q') {
stopOTA();
return true;
}
} else if (_otaPhase == OTA_PHASE_ERROR) {
if (c == 'q' || c == 'Q') {
stopOTA();
return true;
}
}
// Consume all keys during OTA
return true;
}
#endif
#ifdef MECK_WIFI_COMPANION
// --- WiFi setup flow ---
if (_editMode == EDIT_WIFI) {
@@ -1902,6 +2452,11 @@ public:
case ROW_ADD_CHANNEL:
startEditText("");
break;
#ifdef MECK_OTA_UPDATE
case ROW_FW_UPDATE:
startOTA();
break;
#endif
case ROW_CHANNEL:
case ROW_PUB_KEY:
case ROW_FIRMWARE:

View File

@@ -15,7 +15,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 +238,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 +267,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;
@@ -373,6 +406,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 +934,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 +959,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
@@ -1166,13 +1188,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;
@@ -1273,7 +1291,7 @@ public:
: _task(task), _mode(FILE_LIST), _sdReady(false), _initialized(false),
_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) {
@@ -1303,16 +1321,27 @@ public:
// Measure tiny font metrics using the display driver
display.setTextSize(0);
// Measure character width: use 10 M's to get accurate average
// Measure character width: use 10 M's for monospace (T-Deck Pro).
// T5S3 overrides this below with average-width measurement.
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
if (_charsPerLine < 15) _charsPerLine = 15;
if (_charsPerLine > 60) _charsPerLine = 60;
@@ -1344,16 +1373,16 @@ public:
_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 ----
@@ -1464,15 +1493,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();
@@ -1515,13 +1539,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;
@@ -1689,15 +1709,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

@@ -1138,6 +1138,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,7 +1195,9 @@ 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);
((ContactsScreen*)contacts_screen)->setDMUnreadPtr(_dmUnread);
text_reader = new TextReaderScreen(this);
notes_screen = new NotesScreen(this);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
@@ -1266,6 +1279,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 +1313,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 +1349,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 +1382,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
}
@@ -1911,12 +1989,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: {
@@ -2167,11 +2259,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();
@@ -2286,12 +2405,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 +2468,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);
@@ -2381,6 +2543,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

@@ -114,6 +114,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,6 +161,8 @@ 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
@@ -148,6 +170,7 @@ public:
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
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
@@ -171,6 +194,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; }
@@ -231,6 +262,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;

View File

@@ -63,10 +63,13 @@ build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_T5S3_EPaper_Pro>
lib_deps =
${esp32_base.lib_deps}
WebServer
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 +83,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 +102,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 +117,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 +147,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

@@ -96,6 +96,8 @@ lib_deps =
zinggjm/GxEPD2@^1.5.9
adafruit/Adafruit GFX Library@^1.11.0
bitbank2/PNGdec@^1.0.1
WebServer
Update
; ---------------------------------------------------------------------------
; Meck unified builds — one codebase, six variants via build flags
@@ -114,6 +116,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 +149,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.3.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -161,6 +165,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 +176,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 +202,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.3.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -226,7 +233,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.3.4G.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -252,7 +260,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.3.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>