From bad821ac4b8e3e23384caaec91107eb89ef041f0 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:16:25 +1100 Subject: [PATCH] tdpro ota update firmware functionality implemented; roomserver ui sender name display fix and speed up delivery time --- examples/companion_radio/MyMesh.cpp | 19 +- examples/companion_radio/main.cpp | 25 + .../companion_radio/ui-new/ChannelScreen.h | 76 ++- .../companion_radio/ui-new/Settingsscreen.h | 526 ++++++++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 50 +- variants/lilygo_tdeck_pro/platformio.ini | 9 + 6 files changed, 663 insertions(+), 42 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b3b12b8..77df6bd 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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 diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 35cbb15..9c09e3b 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2,6 +2,9 @@ #ifdef BLE_PIN_CODE #include // for esp_bt_controller_mem_release (web reader WiFi) #endif +#ifdef MECK_OTA_UPDATE + #include +#endif #include #include "MyMesh.h" #include "variant.h" // Board-specific defines (HAS_GPS, etc.) @@ -1423,6 +1426,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(); diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index d506c79..9465978 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -557,6 +557,7 @@ public: if (_viewChannelIdx == 0xFF && _dmInboxMode) { #define DM_INBOX_MAX 20 struct DMInboxEntry { + uint32_t hash; char name[32]; int msgCount; int unreadCount; @@ -565,28 +566,43 @@ public: DMInboxEntry inbox[DM_INBOX_MAX]; int inboxCount = 0; - // Scan all DMs and build unique sender list + // 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; - char sender[32]; - if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue; + uint32_t h = _messages[idx].dm_peer_hash; - // Find existing entry + // Find existing entry by hash int found = -1; for (int j = 0; j < inboxCount; j++) { - if (strcmp(inbox[j].name, sender) == 0) { found = j; break; } + if (inbox[j].hash == h) { found = j; break; } } if (found < 0 && inboxCount < DM_INBOX_MAX) { found = inboxCount++; - strncpy(inbox[found].name, sender, 31); - inbox[found].name[31] = '\0'; + 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++; @@ -601,7 +617,7 @@ public: uint32_t numC = the_mesh.getNumContacts(); ContactInfo ci; for (uint32_t c = 0; c < numC; c++) { - if (the_mesh.getContactByIdx(c, ci) && strcmp(ci.name, inbox[e].name) == 0) { + if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == inbox[e].hash) { inbox[e].unreadCount = _dmUnreadPtr[c]; break; } @@ -1506,45 +1522,47 @@ public: _dmInboxScroll++; // Clamped during render return true; } - // Enter - open conversation for selected sender + // Enter - open conversation for selected entry if (c == '\r' || c == 13) { - // Rebuild inbox to find the selected sender name + // 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; - char sender[24]; - if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue; - // Check if we've seen this sender already + if (_messages[idx].dm_peer_hash == 0) continue; + + uint32_t h = _messages[idx].dm_peer_hash; bool dup = false; - for (int k = 0; k < i; k++) { - int ki = _newestIdx - k; - while (ki < 0) ki += CHANNEL_MSG_HISTORY_SIZE; - ki = ki % CHANNEL_MSG_HISTORY_SIZE; - if (!_messages[ki].valid || _messages[ki].channel_idx != 0xFF) continue; - char prev[24]; - if (extractSenderName(_messages[ki].text, prev, sizeof(prev)) - && strcmp(prev, sender) == 0) { dup = true; break; } + 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) { - strncpy(_dmFilterName, sender, sizeof(_dmFilterName) - 1); - _dmFilterName[sizeof(_dmFilterName) - 1] = '\0'; - _dmInboxMode = false; - _scrollPos = 0; - // Look up contact index + // 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 c = 0; c < numC; c++) { - if (the_mesh.getContactByIdx(c, ci) && strcmp(ci.name, sender) == 0) { - _dmContactIdx = (int)c; + 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++; diff --git a/examples/companion_radio/ui-new/Settingsscreen.h b/examples/companion_radio/ui-new/Settingsscreen.h index 4af5034..64fdff9 100644 --- a/examples/companion_radio/ui-new/Settingsscreen.h +++ b/examples/companion_radio/ui-new/Settingsscreen.h @@ -22,6 +22,16 @@ #include #endif +#ifdef MECK_OTA_UPDATE + #ifndef MECK_WIFI_COMPANION + #include + #include + #endif + #include + #include + #include +#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() { @@ -689,6 +740,301 @@ public: #endif + // --------------------------------------------------------------------------- + // OTA firmware update + // --------------------------------------------------------------------------- + + #ifdef MECK_OTA_UPDATE + + // HTML upload page served to the browser + static const char* otaUploadPageHTML() { + return + "" + "" + "Meck Firmware Update" + "" + "

Meck Firmware Update

" + "
Select the firmware .bin file and tap Upload. " + "The device will verify and flash it automatically.
" + "
" + "
" + "
" + "
Uploading... do not close this page
" + "
" + "" + ""; + } + + 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]); + + // Tear down existing WiFi and start AP + WiFi.disconnect(true); + delay(100); + 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 + ? "

Upload OK!

" + "

The device is now verifying and flashing.
It will reboot automatically.

" + : "

Upload Failed

" + "

Please try again.

" + ); + }, + // 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; + // Try to restore STA WiFi from saved credentials + #ifdef MECK_WIFI_COMPANION + WiFi.mode(WIFI_STA); + wifiReconnectSaved(); + #endif + Serial.println("OTA: Stopped, AP down"); + } + + 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 to poll the web server + void pollOTAServer() { + if (_otaServer && (_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) { + _otaServer->handleClient(); + } + } + + // 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 // --------------------------------------------------------------------------- @@ -1050,6 +1396,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 ---"); @@ -1234,6 +1586,105 @@ 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; + + // Detect upload completion — trigger verify + flash sequence + if (_otaUploadOk && (_otaPhase == OTA_PHASE_RECEIVING || _otaPhase == OTA_PHASE_WAITING)) { + display.endFrame(); // Flush current frame before blocking flash + processOTAUpload(display); + return 500; // Won't reach here if flash succeeds (reboots) + } + + 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); @@ -1279,6 +1730,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"; @@ -1304,6 +1770,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) { @@ -1322,6 +1800,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; } @@ -1354,6 +1839,42 @@ 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) { + // Check if upload just completed + if (_otaUploadOk) { + // Upload finished — run verify + flash (blocking) + // The display reference isn't available here, so we set a flag + // and the render loop will call processOTAUpload() + 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) { @@ -1917,6 +2438,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: diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 3b36373..fde6fc8 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1313,13 +1313,32 @@ 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 - // For DMs (channel_idx == 0xFF), prefix text with sender name so the - // DM inbox can extract it later. Channel messages already arrive from - // MeshCore in "Sender: message" format. + // 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) { - 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); + // 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); } @@ -1346,14 +1365,15 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i #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) { @@ -1362,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 } diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index 1e382d8..f232136 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -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} + + @@ -146,6 +149,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D MECK_AUDIO_VARIANT -D MECK_WEB_READER=1 + -D MECK_OTA_UPDATE=1 -D FIRMWARE_VERSION='"Meck v1.3.WiFi"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + @@ -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} + + @@ -196,6 +202,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 + -D MECK_OTA_UPDATE=1 -D FIRMWARE_VERSION='"Meck v1.3.4G"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + @@ -226,6 +233,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 + -D MECK_OTA_UPDATE=1 -D FIRMWARE_VERSION='"Meck v1.3.4G.WiFi"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + @@ -252,6 +260,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=1 -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 + -D MECK_OTA_UPDATE=1 -D FIRMWARE_VERSION='"Meck v1.3.4G.SA"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} +