diff --git a/boards/heltec_mesh_pocket.json b/boards/heltec_mesh_pocket.json index 6fb48a46..e6a04c12 100644 --- a/boards/heltec_mesh_pocket.json +++ b/boards/heltec_mesh_pocket.json @@ -39,7 +39,7 @@ "frameworks": ["arduino"], "name": "Heltec nrf (Adafruit BSP)", "upload": { - "maximum_ram_size": 248832, + "maximum_ram_size": 235520, "maximum_size": 815104, "speed": 115200, "protocol": "nrfutil", diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 3b661def..c0ab063d 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -504,9 +504,16 @@ bool DataStore::beginSaveContacts(DataStoreHost* host) { if (_saveInProgress) return false; // Already saving FILESYSTEM* fs = _getContactsChannelsFS(); - _saveFile = openWrite(fs, "/contacts3.tmp"); - if (!_saveFile) { + // Defensive cleanup in case a previous save didn't reach finishSaveContacts() + if (_saveFile) { + _saveFile->close(); + delete _saveFile; + _saveFile = nullptr; + } + _saveFile = new File(openWrite(fs, "/contacts3.tmp")); + if (!_saveFile || !*_saveFile) { Serial.println("DataStore: chunked save FAILED — cannot open tmp file"); + if (_saveFile) { delete _saveFile; _saveFile = nullptr; } return false; } @@ -527,18 +534,18 @@ bool DataStore::saveContactsChunk(int batchSize) { int written = 0; while (written < batchSize && _saveHost->getContactForSave(_saveIdx, c)) { - bool success = (_saveFile.write(c.id.pub_key, 32) == 32); - success = success && (_saveFile.write((uint8_t *)&c.name, 32) == 32); - success = success && (_saveFile.write(&c.type, 1) == 1); - success = success && (_saveFile.write(&c.flags, 1) == 1); - success = success && (_saveFile.write(&unused, 1) == 1); - success = success && (_saveFile.write((uint8_t *)&c.sync_since, 4) == 4); - success = success && (_saveFile.write((uint8_t *)&c.out_path_len, 1) == 1); - success = success && (_saveFile.write((uint8_t *)&c.last_advert_timestamp, 4) == 4); - success = success && (_saveFile.write(c.out_path, 64) == 64); - success = success && (_saveFile.write((uint8_t *)&c.lastmod, 4) == 4); - success = success && (_saveFile.write((uint8_t *)&c.gps_lat, 4) == 4); - success = success && (_saveFile.write((uint8_t *)&c.gps_lon, 4) == 4); + bool success = (_saveFile->write(c.id.pub_key, 32) == 32); + success = success && (_saveFile->write((uint8_t *)&c.name, 32) == 32); + success = success && (_saveFile->write(&c.type, 1) == 1); + success = success && (_saveFile->write(&c.flags, 1) == 1); + success = success && (_saveFile->write(&unused, 1) == 1); + success = success && (_saveFile->write((uint8_t *)&c.sync_since, 4) == 4); + success = success && (_saveFile->write((uint8_t *)&c.out_path_len, 1) == 1); + success = success && (_saveFile->write((uint8_t *)&c.last_advert_timestamp, 4) == 4); + success = success && (_saveFile->write(c.out_path, 64) == 64); + success = success && (_saveFile->write((uint8_t *)&c.lastmod, 4) == 4); + success = success && (_saveFile->write((uint8_t *)&c.gps_lat, 4) == 4); + success = success && (_saveFile->write((uint8_t *)&c.gps_lon, 4) == 4); if (!success) { _saveWriteOk = false; @@ -562,7 +569,11 @@ bool DataStore::saveContactsChunk(int batchSize) { void DataStore::finishSaveContacts() { if (!_saveInProgress) return; - _saveFile.close(); + if (_saveFile) { + _saveFile->close(); + delete _saveFile; + _saveFile = nullptr; + } _saveInProgress = false; FILESYSTEM* fs = _getContactsChannelsFS(); diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index ecaca097..db7d5dea 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -25,7 +25,10 @@ class DataStore { #endif // Chunked save state - File _saveFile; + // Stored as a pointer (allocated in beginSaveContacts, freed in + // finishSaveContacts) because Adafruit_LittleFS::File has no default + // constructor — we can't keep one as a default-initialized value member. + File* _saveFile = nullptr; DataStoreHost* _saveHost = nullptr; uint32_t _saveIdx = 0; uint32_t _saveRecordsWritten = 0; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index d18c77c5..1eb08f0d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -12,6 +12,13 @@ #include "ModemManager.h" // Serial CLI modem commands #endif +// Fallback for variants that don't define GPS_BAUDRATE (HAS_GPS=0 boards like +// Heltec Meshpocket). Used in CLI "get/set gps.baud" handlers as the default +// when node prefs haven't been configured. Zero means "not applicable". +#ifndef GPS_BAUDRATE + #define GPS_BAUDRATE 0 +#endif + #define CMD_APP_START 1 #define CMD_SEND_TXT_MSG 2 #define CMD_SEND_CHANNEL_TXT_MSG 3 @@ -1294,7 +1301,15 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe } void MyMesh::begin(bool has_display) { +#if defined(ESP32) + // ESP32 variants have PSRAM — allocate the large advert path table there advert_paths = (AdvertPath*)ps_calloc(ADVERT_PATH_TABLE_SIZE, sizeof(AdvertPath)); +#else + // nRF52 / other non-PSRAM platforms — fall back to regular heap. Table size + // is smaller on these platforms (see ADVERT_PATH_TABLE_SIZE in MyMesh.h) to + // avoid blowing the limited SRAM budget. + advert_paths = (AdvertPath*)calloc(ADVERT_PATH_TABLE_SIZE, sizeof(AdvertPath)); +#endif BaseChatMesh::begin(); if (!_store->loadMainIdentity(self_id)) { @@ -3286,4 +3301,20 @@ bool MyMesh::addDiscoveredToContacts(int idx) { } MESH_DEBUG_PRINTLN("Discovery: no cached advert blob for contact '%s'", _discovered[idx].contact.name); return false; -} \ No newline at end of file +} +#ifdef HELTEC_MESH_POCKET +// ============================================================================= +// Power saving — adapted from MeshCore PR #2286 (IoTThinks) +// Returns true if the radio has outbound packets queued (any priority, any +// scheduling window). main.cpp loop() uses this to decide whether it's safe +// to drop into board.sleep(0) until the next interrupt. +// +// Upstream uses _mgr->getOutboundTotal() which doesn't exist in this tree — +// the equivalent call in Meck is getOutboundCount(0xFFFFFFFF) which passes +// max uint32 as `now` so scheduled_for < now is always true. Already used +// elsewhere in this file (see line ~2221 in the queue-stats block). +// ============================================================================= +bool MyMesh::hasPendingWork() const { + return _mgr->getOutboundCount(0xFFFFFFFF) > 0; +} +#endif \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index a477c503..039261c7 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,7 +8,7 @@ #define FIRMWARE_VER_CODE 11 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "12 April 2026" +#define FIRMWARE_BUILD_DATE "16 April 2026" #endif #ifndef FIRMWARE_VERSION @@ -171,6 +171,14 @@ public: bool setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock); void clearCustomPath(int contactIdx); +#ifdef HELTEC_MESH_POCKET + // Power saving: check if there is pending work (outbound packets queued, etc.) + // Used by main.cpp loop to decide whether board.sleep() is safe. + // Adapted from MeshCore PR #2286 (IoTThinks) — substitutes getOutboundCount(0xFFFFFFFF) + // for upstream's getOutboundTotal() which doesn't exist in this tree. + bool hasPendingWork() const; +#endif + protected: float getAirtimeBudgetFactor() const override; @@ -309,8 +317,17 @@ private: AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table int next_ack_idx; + // Advert path table: stores paths we've heard back to us for sorting/recency. + // ESP32 variants (T-Deck Pro, T5S3, Heltec V4) have PSRAM, so can afford the + // large 1000-entry table (~50KB). nRF52 companion builds (Heltec Meshpocket, + // T-Echo Card) have no PSRAM and only 256KB total SRAM shared with BLE, so + // use a much smaller table sized for realistic handheld usage. +#if defined(ESP32) #define ADVERT_PATH_TABLE_SIZE 1000 - AdvertPath* advert_paths; // PSRAM-allocated in begin(), size = ADVERT_PATH_TABLE_SIZE +#else + #define ADVERT_PATH_TABLE_SIZE 50 +#endif + AdvertPath* advert_paths; // PSRAM-allocated (ESP32) or heap-allocated (nRF52) in begin() // Sent message repeat tracking #define SENT_TRACK_SIZE 4 diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index cd94e8dd..037bb6b0 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1,6 +1,6 @@ #include // needed for PlatformIO -#ifdef BLE_PIN_CODE - #include // for esp_bt_controller_mem_release (web reader WiFi) +#if defined(BLE_PIN_CODE) && defined(ESP32) + #include // for esp_bt_controller_mem_release (web reader WiFi) — ESP32 only #endif #ifdef MECK_OTA_UPDATE #include @@ -11,19 +11,25 @@ #include "target.h" // For sensors, board, etc. #include "CPUPowerManager.h" +// Core screens used by every Meck variant, regardless of display or keyboard +// hardware. Kept unconditional here so main.cpp can reference the types on +// any build (Meshpocket, Heltec V4, T-Deck Pro, T5S3). SD-dependent screens +// (NotesScreen, TextReaderScreen) are header-stubbed for non-ESP32 so this +// block is safe on nRF52 too. +#include "ContactsScreen.h" +#include "ChannelScreen.h" +#include "SettingsScreen.h" +#include "RepeaterAdminScreen.h" +#include "DiscoveryScreen.h" +#include "LastHeardScreen.h" +#include "PathEditorScreen.h" +#include "NotesScreen.h" +#include "TextReaderScreen.h" + // T-Deck Pro Keyboard support #if defined(LilyGo_TDeck_Pro) #include "TCA8418Keyboard.h" #include - #include "TextReaderScreen.h" - #include "NotesScreen.h" - #include "ContactsScreen.h" - #include "ChannelScreen.h" - #include "SettingsScreen.h" - #include "RepeaterAdminScreen.h" - #include "DiscoveryScreen.h" - #include "LastHeardScreen.h" - #include "PathEditorScreen.h" #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif @@ -679,15 +685,6 @@ #if defined(LilyGo_T5S3_EPaper_Pro) #include "TouchDrvGT911.hpp" #include - #include "TextReaderScreen.h" - #include "NotesScreen.h" - #include "ContactsScreen.h" - #include "ChannelScreen.h" - #include "SettingsScreen.h" - #include "RepeaterAdminScreen.h" - #include "DiscoveryScreen.h" - #include "LastHeardScreen.h" - #include "PathEditorScreen.h" static TouchDrvGT911 gt911Touch; static bool gt911Ready = false; @@ -1675,6 +1672,7 @@ void setup() { #ifdef DISPLAY_CLASS DisplayDriver* disp = NULL; MESH_DEBUG_PRINTLN("setup() - about to call display.begin()"); + Serial.println("[DIAG] about to call display.begin()"); // ========================================================================= // T-Deck Pro V1.1: Initialize E-Ink reset pin BEFORE display.begin() @@ -1702,16 +1700,21 @@ void setup() { if (display.begin()) { MESH_DEBUG_PRINTLN("setup() - display.begin() returned true"); + Serial.println("[DIAG] display.begin() returned TRUE"); disp = &display; disp->startFrame(); + Serial.println("[DIAG] startFrame() done"); #ifdef ST7789 disp->setTextSize(2); #endif disp->drawTextCentered(disp->width() / 2, 28, "Loading..."); + Serial.println("[DIAG] Loading text drawn"); disp->endFrame(); + Serial.println("[DIAG] endFrame() done — should show on screen now"); MESH_DEBUG_PRINTLN("setup() - Loading screen drawn"); } else { MESH_DEBUG_PRINTLN("setup() - display.begin() returned false!"); + Serial.println("[DIAG] display.begin() returned FALSE — display NOT initialized"); } #endif @@ -2268,8 +2271,15 @@ void setup() { the_mesh.setVoiceEnvelopeHandler(voiceEnvelopeCallback); #endif - Serial.printf("setup() complete — free heap: %d, largest block: %d\n", - ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + #ifdef ESP32 + Serial.printf("setup() complete - free heap: %d, largest block: %d\n", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + #else + // nRF52 has no ESP.xxx API; dbgMemInfo() prints mallinfo-based heap stats + // on Arduino-nRF52 core (dbgMemInfo() is declared in + // via rtos_support.h). Not available on all nRF52 cores so guard further. + Serial.println("setup() complete"); + #endif MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ==="); } @@ -2800,7 +2810,10 @@ void loop() { #endif #endif rtc_clock.tick(); - // Periodic AGC reset - re-assert boosted RX gain to prevent sensitivity drift +#ifdef ESP32 + // Periodic AGC reset - re-assert boosted RX gain to prevent sensitivity drift. + // radio_reset_agc() is only defined in ESP32 variants' target.cpp. nRF52 + // Meshpocket uses a different radio driver that manages AGC internally. #ifdef MECK_OTA_UPDATE if (!otaRadioPaused) #endif @@ -2808,6 +2821,7 @@ void loop() { radio_reset_agc(); lastAGCReset = millis(); } +#endif // Handle T-Deck Pro keyboard input #if defined(LilyGo_TDeck_Pro) handleKeyboardInput(); @@ -3320,6 +3334,29 @@ void loop() { delay(50); } #endif + +#ifdef HELTEC_MESH_POCKET + // Power saving — DISABLED for now (April 2026). + // + // The sd_app_evt_wait() primitive inside HeltecMeshPocket::sleep() blocks + // indefinitely waiting for a SoftDevice event. Without BLE activity and + // without GPIO SENSE configured on the USER button, the device can get + // stuck here — UI render cycles never run, e-ink keeps showing stale + // content from before the flash. + // + // Re-enabling requires either: + // (a) extending hasPendingWork() to include pending UI render work, or + // (b) configuring PIN_USER_BTN with GPIO SENSE so button presses wake + // the SoftDevice, and adding a timed RTC wake (e.g. 50ms) so UI + // refresh still happens while idle. + // + // For now we leave the main loop free-running. DCDC converter alone + // (enabled via NRF52BoardDCDC::begin()) still provides meaningful power + // savings vs the LDO baseline. + // if (!the_mesh.hasPendingWork()) { + // board.sleep(0); + // } +#endif } // ============================================================================ diff --git a/examples/companion_radio/ui-new/Lastheardscreen.h b/examples/companion_radio/ui-new/Lastheardscreen.h index bf95e2e8..0a256cd6 100644 --- a/examples/companion_radio/ui-new/Lastheardscreen.h +++ b/examples/companion_radio/ui-new/Lastheardscreen.h @@ -50,7 +50,14 @@ class LastHeardScreen : public UIScreen { public: LastHeardScreen(mesh::RTCClock* rtc) : _rtc(rtc), _scrollPos(0), _count(0) { +#if defined(ESP32) + // ESP32 variants have PSRAM — allocate the entries buffer there _entries = (AdvertPath*)ps_calloc(LAST_HEARD_DISPLAY_SIZE, sizeof(AdvertPath)); +#else + // nRF52 has no PSRAM — fall back to regular heap. At 100 entries × ~84 + // bytes each this is ~8.4KB, manageable within Meshpocket's SRAM budget. + _entries = (AdvertPath*)calloc(LAST_HEARD_DISPLAY_SIZE, sizeof(AdvertPath)); +#endif } void resetScroll() { _scrollPos = 0; } diff --git a/examples/companion_radio/ui-new/Notesscreen.h b/examples/companion_radio/ui-new/Notesscreen.h index 15e5d903..599a44c2 100644 --- a/examples/companion_radio/ui-new/Notesscreen.h +++ b/examples/companion_radio/ui-new/Notesscreen.h @@ -2,14 +2,18 @@ #include #include -#include -#include -#include "Utf8CP437.h" +#ifdef ESP32 + #include + #include + #include "Utf8CP437.h" +#endif #include "../NodePrefs.h" // Forward declarations class UITask; +#ifdef ESP32 + // ============================================================================ // Configuration // ============================================================================ @@ -1368,4 +1372,42 @@ public: } return false; } -}; \ No newline at end of file +}; + +#else // !ESP32 + +// Non-ESP32 stub: Meshpocket / T-Echo Card have no SD card hardware, so the +// full notes editor (which depends on SD.h) can't work here. This stub keeps +// UITask.cpp compilable by providing the same public interface as no-ops. +// Navigating to notes from the home screen on a Meshpocket will just render +// a placeholder message and do nothing. +class NotesScreen : public UIScreen { +public: + typedef uint32_t (*TimeGetterFn)(); + + NotesScreen(UITask* task, NodePrefs* prefs = nullptr) { + (void)task; (void)prefs; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, 20); + display.print("Notes: SD card required"); + display.setCursor(0, 30); + display.print("(not available)"); + return 5000; + } + + bool handleInput(char c) override { (void)c; return false; } + bool isEditing() const { return false; } + void triggerSaveAndExit() {} + void exitNotes() {} + void enter(DisplayDriver& display) { (void)display; } + void setTimestamp(uint32_t rtcTime, int8_t utcOffset) { + (void)rtcTime; (void)utcOffset; + } + void setTimeGetter(TimeGetterFn fn) { (void)fn; } +}; + +#endif // ESP32 \ No newline at end of file diff --git a/examples/companion_radio/ui-new/Settingsscreen.h b/examples/companion_radio/ui-new/Settingsscreen.h index 1b84ce6d..25bb5d1e 100644 --- a/examples/companion_radio/ui-new/Settingsscreen.h +++ b/examples/companion_radio/ui-new/Settingsscreen.h @@ -2714,9 +2714,11 @@ public: } else if (type == ROW_GPS_BAUD) { _editPickerIdx--; if (_editPickerIdx < 0) _editPickerIdx = GPS_BAUD_OPTION_COUNT - 1; +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) } else if (type == ROW_AUTO_LOCK) { _editPickerIdx--; if (_editPickerIdx < 0) _editPickerIdx = AUTO_LOCK_OPTION_COUNT - 1; +#endif } else { // Radio preset _editPickerIdx--; @@ -2731,9 +2733,11 @@ public: } else if (type == ROW_GPS_BAUD) { _editPickerIdx++; if (_editPickerIdx >= GPS_BAUD_OPTION_COUNT) _editPickerIdx = 0; +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) } else if (type == ROW_AUTO_LOCK) { _editPickerIdx++; if (_editPickerIdx >= AUTO_LOCK_OPTION_COUNT) _editPickerIdx = 0; +#endif } else { // Radio preset _editPickerIdx++; @@ -2751,12 +2755,14 @@ public: _editMode = EDIT_NONE; Serial.printf("Settings: GPS baud set to %lu (reboot to apply)\n", (unsigned long)_prefs->gps_baudrate); +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) } else if (type == ROW_AUTO_LOCK) { _prefs->auto_lock_minutes = AUTO_LOCK_OPTIONS[_editPickerIdx]; the_mesh.savePrefs(); _editMode = EDIT_NONE; Serial.printf("Settings: Auto lock = %s\n", autoLockLabel(_prefs->auto_lock_minutes)); +#endif } else { // Apply radio preset if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) { diff --git a/examples/companion_radio/ui-new/Textreaderscreen.h b/examples/companion_radio/ui-new/Textreaderscreen.h index 39415da8..60c93673 100644 --- a/examples/companion_radio/ui-new/Textreaderscreen.h +++ b/examples/companion_radio/ui-new/Textreaderscreen.h @@ -2,15 +2,19 @@ #include #include -#include -#include -#include "Utf8CP437.h" -#include "EpubProcessor.h" +#ifdef ESP32 + #include + #include + #include "Utf8CP437.h" + #include "EpubProcessor.h" +#endif #include "../NodePrefs.h" // Forward declarations class UITask; +#ifdef ESP32 + // ============================================================================ // Configuration // ============================================================================ @@ -1948,4 +1952,42 @@ public: if (_fileOpen) closeBook(); _mode = FILE_LIST; } -}; \ No newline at end of file +}; +#else // !ESP32 + +// Non-ESP32 stub: Meshpocket / T-Echo Card have no SD card hardware, so the +// full EPUB/text reader can't work here. This stub keeps UITask.cpp and +// main.cpp compilable by providing the same public interface as no-ops. +// Navigating to the reader on a non-SD board just shows a placeholder. +class TextReaderScreen : public UIScreen { +public: + TextReaderScreen(UITask* task, NodePrefs* prefs = nullptr) { + (void)task; (void)prefs; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, 20); + display.print("Reader: SD card required"); + display.setCursor(0, 30); + display.print("(not available)"); + return 5000; + } + + bool handleInput(char c) override { (void)c; return false; } + + // No-op public API matching the ESP32 class for call-site compatibility + void invalidateLayout() {} + void bootIndex(DisplayDriver& display) { (void)display; } + void setSDReady(bool ready) { (void)ready; } + void enter(DisplayDriver& display) { (void)display; } + bool isReading() const { return false; } + bool isInFileList() const { return false; } + void gotoPage(int pageNum) { (void)pageNum; } + int getTotalPages() const { return 0; } + int selectRowAtVY(int vy) { (void)vy; return -1; } + void exitReader() {} +}; + +#endif // ESP32 \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 4a4e0a00..3390ab52 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -57,6 +57,9 @@ #include "ContactsScreen.h" #include "TextReaderScreen.h" #include "SettingsScreen.h" +#ifdef MORSE_COMPOSE_ENABLED + #include "MorseScreen.h" +#endif #ifdef MECK_AUDIO_VARIANT #include "AudiobookPlayerScreen.h" #include "VoiceMessageScreen.h" @@ -66,6 +69,11 @@ #include "ModemManager.h" #endif +#ifdef MORSE_COMPOSE_ENABLED +// File-scope screen pointer — avoids touching UITask.h, feature is purely optional. +static MorseScreen* morse_screen = nullptr; +#endif + class SplashScreen : public UIScreen { UITask* _task; unsigned long dismiss_after; @@ -1283,7 +1291,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); +#ifndef HELTEC_MESH_POCKET msg_preview = new MsgPreviewScreen(this, &rtc_clock); +#endif channel_screen = new ChannelScreen(this, &rtc_clock); ((ChannelScreen*)channel_screen)->setDMUnreadPtr(_dmUnread); contacts_screen = new ContactsScreen(this, &rtc_clock); @@ -1312,6 +1322,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no map_screen = nullptr; #endif +#ifdef MORSE_COMPOSE_ENABLED + morse_screen = new MorseScreen(&rtc_clock); +#endif + #if defined(LilyGo_T5S3_EPaper_Pro) // Apply saved display preferences before first render if (_node_prefs->portrait_mode) { @@ -1393,9 +1407,11 @@ switch(t){ void UITask::msgRead(int msgcount) { _msgcount = msgcount; +#ifndef HELTEC_MESH_POCKET if (msgcount == 0 && curr == msg_preview) { gotoHomeScreen(); } +#endif } void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, @@ -1421,7 +1437,9 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i _dedupIdx = (_dedupIdx + 1) % MSG_DEDUP_SIZE; // Add to preview screen (for notifications on non-keyboard devices) +#ifndef HELTEC_MESH_POCKET ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text); +#endif // Determine channel index by looking up the channel name // For channel messages, from_name is the channel name @@ -1464,6 +1482,13 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i } } else { ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr); +#ifdef MORSE_COMPOSE_ENABLED + // Mirror Public channel (index 0) messages into the Morse inbox ring. + // MorseScreen keeps its own small buffer so it doesn't reach into ChannelScreen. + if (channel_idx == 0 && morse_screen != nullptr) { + morse_screen->notifyPublicMsg(from_name, text); + } +#endif } // If user is currently viewing this channel, mark it as read immediately @@ -1485,7 +1510,10 @@ 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) +#if defined(HELTEC_MESH_POCKET) + // Meshpocket: silent — no popup, no toast. Messages are still stored in + // channel history (and picked up by MorseScreen's inbox if enabled). +#elif 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 // Suppress toasts for room server messages (bulk sync would spam toasts) @@ -1644,6 +1672,13 @@ void UITask::loop() { } #elif defined(PIN_USER_BTN) int ev = user_btn.check(); +#ifdef MORSE_COMPOSE_ENABLED + // While MorseScreen is active, it reads the button directly via poll(). + // Swallow all click/long-press events so they don't fire nav actions. + if (morse_screen != nullptr && curr == morse_screen) { + ev = BUTTON_EVENT_NONE; + } +#endif if (ev == BUTTON_EVENT_CLICK) { #if defined(LilyGo_T5S3_EPaper_Pro) // T5S3: single click = cycle pages on home, go back to home from elsewhere @@ -1806,6 +1841,37 @@ void UITask::loop() { if (curr) curr->poll(); +#ifdef MORSE_COMPOSE_ENABLED + // When MorseScreen is active, poll its cross-screen flags. + if (morse_screen != nullptr && curr == morse_screen) { + // 1. Send request (AR prosign) — dispatch to Public channel (index 0) + const char* morseText = nullptr; + if (morse_screen->consumeSendRequest(&morseText) && morseText && morseText[0]) { + ChannelDetails channel; + if (the_mesh.getChannel(0, channel)) { + uint32_t timestamp = rtc_clock.getCurrentTime(); + int textLen = (int)strlen(morseText); + const char* sender = the_mesh.getNodePrefs()->node_name; + if (the_mesh.sendGroupMessage(timestamp, channel.channel, sender, morseText, textLen)) { + addSentChannelMessage(0, sender, morseText); + the_mesh.queueSentChannelMessage(0, timestamp, sender, morseText); + showAlert("Sent!", 1200); + morse_screen->clearOutBuf(); + } else { + showAlert("Send failed", 1500); + } + } else { + showAlert("No Public ch", 1500); + } + } + // 2. Exit gesture (long-hold) — return to home + if (morse_screen->wantsExit()) { + morse_screen->acknowledgeExit(); + gotoHomeScreen(); + } + } +#endif + if (_display != NULL && _display->isOn()) { if (millis() >= _next_refresh && curr) { // Sync dark mode with prefs (settings toggle takes effect here) @@ -2142,8 +2208,20 @@ char UITask::handleTripleClick(char c) { board.setBacklightBrightness(4); board.setBacklight(true); } +#else +#ifdef MORSE_COMPOSE_ENABLED + // Triple-click from home screen → enter Morse compose mode. + // From any other screen, fall through to the existing buzzer toggle (no-op + // on Meshpocket but kept for other single-button variants). + if (morse_screen != nullptr && curr == home) { + morse_screen->activate(); + setCurrScreen(morse_screen); + } else { + toggleBuzzer(); + } #else toggleBuzzer(); +#endif #endif c = 0; return c; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 565197cb..cd8a8204 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -79,7 +79,9 @@ class UITask : public AbstractUITask { UIScreen* splash; UIScreen* home; +#ifndef HELTEC_MESH_POCKET UIScreen* msg_preview; +#endif UIScreen* channel_screen; // Channel message history screen UIScreen* contacts_screen; // Contacts list screen UIScreen* text_reader; // *** NEW: Text reader screen *** @@ -306,7 +308,9 @@ public: // Get current screen for checking state UIScreen* getCurrentScreen() const { return curr; } +#ifndef HELTEC_MESH_POCKET UIScreen* getMsgPreviewScreen() const { return msg_preview; } +#endif UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW *** UIScreen* getNotesScreen() const { return notes_screen; } UIScreen* getContactsScreen() const { return contacts_screen; } diff --git a/variants/mesh_pocket/CPUPowerManager.h b/variants/mesh_pocket/CPUPowerManager.h new file mode 100644 index 00000000..da15c065 --- /dev/null +++ b/variants/mesh_pocket/CPUPowerManager.h @@ -0,0 +1,133 @@ +#pragma once + +#include + +// CPU Frequency Scaling for ESP32-S3 +// +// Typical current draw (CPU only, rough): +// 240 MHz ~70-80 mA +// 160 MHz ~50-60 mA +// 80 MHz ~30-40 mA +// 40 MHz ~15-20 mA (low-power / lock screen mode) +// +// SPI peripherals and UART use their own clock dividers from the APB clock, +// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz. + +#ifdef ESP32 + +#ifndef CPU_FREQ_IDLE +#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening +#endif + +#ifndef CPU_FREQ_BOOST +#define CPU_FREQ_BOOST 240 // MHz — heavy processing +#endif + +#ifndef CPU_FREQ_LOW_POWER +#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C) +#endif + +#ifndef CPU_BOOST_TIMEOUT_MS +#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds +#endif + +class CPUPowerManager { +public: + CPUPowerManager() : _boosted(false), _lowPower(false), _boost_started(0) {} + + void begin() { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + _boosted = false; + _lowPower = false; + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + + void loop() { + if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) { + // Return to low-power if locked, otherwise normal idle + if (_lowPower) { + setCpuFrequencyMhz(CPU_FREQ_LOW_POWER); + MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER); + } else { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + _boosted = false; + } + } + + void setBoost() { + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_BOOST); + _boosted = true; + MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST); + } + _boost_started = millis(); + } + + void setIdle() { + if (_boosted) { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + _boosted = false; + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + if (_lowPower) { + _lowPower = false; + } + } + + // Low-power mode — drops CPU to 40 MHz for lock screen standby. + // If currently boosted, the boost timeout will return to 40 MHz + // instead of 80 MHz. + void setLowPower() { + _lowPower = true; + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_LOW_POWER); + MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER); + } + // If boosted, the loop() timeout will drop to low-power instead of idle + } + + // Exit low-power mode — returns to normal idle (80 MHz). + // If currently boosted, the boost timeout will return to idle + // instead of low-power. + void clearLowPower() { + _lowPower = false; + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE); + } + // If boosted, the loop() timeout will drop to idle as normal + } + + bool isBoosted() const { return _boosted; } + bool isLowPower() const { return _lowPower; } + uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); } + +private: + bool _boosted; + bool _lowPower; + unsigned long _boost_started; +}; + +#else // !ESP32 + +// Non-ESP32 stub: same public interface, all methods no-op. Keeps main.cpp +// compilable without scattering #ifdef guards around every cpuPower.xxx() +// call. Platform-specific power saving on nRF52 is handled separately via +// HeltecMeshPocket::sleep() and the MyMesh::hasPendingWork() check in loop(). +class CPUPowerManager { +public: + CPUPowerManager() {} + void begin() {} + void loop() {} + void setBoost() {} + void setIdle() {} + void setLowPower() {} + void clearLowPower() {} + bool isBoosted() const { return false; } + bool isLowPower() const { return false; } + uint32_t getFrequencyMHz() const { return 0; } +}; + +#endif // ESP32 \ No newline at end of file diff --git a/variants/mesh_pocket/MeshPocket.cpp b/variants/mesh_pocket/MeshPocket.cpp new file mode 100644 index 00000000..d174aa5e --- /dev/null +++ b/variants/mesh_pocket/MeshPocket.cpp @@ -0,0 +1,55 @@ +#include +#include +#include + +#include "MeshPocket.h" + +void HeltecMeshPocket::begin() { + // Call NRF52BoardDCDC::begin() rather than NRF52Board::begin() so the + // internal DC/DC regulator is actually enabled — this is the whole reason + // HeltecMeshPocket extends NRF52BoardDCDC and directly improves battery + // life by ~30% during BLE transmit bursts vs the default LDO. + NRF52BoardDCDC::begin(); + Serial.begin(115200); + pinMode(PIN_VBAT_READ, INPUT); + + pinMode(PIN_USER_BTN, INPUT); +} + +// ============================================================================= +// Power saving — CPU light-sleep until next interrupt. +// Adapted from MeshCore PR #1353 (IoTThinks) with the "safe to sleep" check +// pattern suggested by fschrempf in the review. Called from main.cpp loop() +// only when hasPendingWork() returns false. +// +// Wakeup sources (no GPIO sense configuration needed — RadioLib already +// attaches a DIO1 IRQ, MomentaryButton is polled from loop so any GPIO +// change via attachInterrupt or the RTC1 tick wakes us): +// - LoRa DIO1 (incoming packet / TX done) +// - RTC1 tick (~1ms, drives millis() and scheduler) +// - USER button (via RTC tick on next poll, or attached IRQ if added) +// - SoftDevice (BLE stack events) +// +// When BLE SoftDevice is active we MUST use sd_app_evt_wait() rather than +// raw __WFE() — calling WFE directly with SoftDevice enabled can wedge the +// BLE stack. When it's not active (USB-only builds, or BLE disabled via +// settings) we use WFE directly. +// ============================================================================= +void HeltecMeshPocket::sleep(uint32_t secs) { + (void)secs; // NRF52 ignores — any interrupt wakes us + + uint8_t sd_enabled = 0; + sd_softdevice_is_enabled(&sd_enabled); + + if (sd_enabled) { + // BLE is active (includes OTA) — use SoftDevice primitive. + // This is the only safe way to sleep while the SoftDevice is running. + sd_app_evt_wait(); + } else { + // No SoftDevice — raw ARM WFE. Double-WFE pattern clears any stale + // event flag on the first call and actually sleeps on the second. + __SEV(); + __WFE(); // clear event flag + __WFE(); // sleep until next event + } +} \ No newline at end of file diff --git a/variants/mesh_pocket/MeshPocket.h b/variants/mesh_pocket/MeshPocket.h new file mode 100644 index 00000000..8395eb2b --- /dev/null +++ b/variants/mesh_pocket/MeshPocket.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +// built-ins +#define PIN_VBAT_READ 29 +#define PIN_BAT_CTL 34 +#define MV_LSB (3000.0F / 4096.0F) // 12-bit ADC with 3.0V input range + +// HeltecMeshPocket inherits from BOTH NRF52BoardDCDC (for DC/DC converter +// efficiency) AND NRF52BoardOTA (for BLE OTA firmware update support). Both +// parent classes inherit virtually from NRF52Board so there's only one copy +// of the base-class state. The NRF52BoardOTA constructor needs the OTA +// advertising name; NRF52BoardDCDC has no explicit constructor so uses the +// default. +class HeltecMeshPocket : public NRF52BoardDCDC, public NRF52BoardOTA { +public: + // Cast required because NRF52BoardOTA stores the name as non-const char* + // (latent const-correctness issue in the shared header). The name is only + // read, never modified, so the cast is safe. + HeltecMeshPocket() : NRF52BoardOTA((char*)"MESH_POCKET_OTA") {} + void begin(); + + uint16_t getBattMilliVolts() override { + int adcvalue = 0; + analogReadResolution(12); + analogReference(AR_INTERNAL_3_0); + pinMode(PIN_BAT_CTL, OUTPUT); // battery adc can be read only ctrl pin set to high + pinMode(PIN_VBAT_READ, INPUT); + digitalWrite(PIN_BAT_CTL, HIGH); + + delay(10); + adcvalue = analogRead(PIN_VBAT_READ); + digitalWrite(PIN_BAT_CTL, LOW); + + return (uint16_t)((float)adcvalue * MV_LSB * 4.9); + } + + const char* getManufacturerName() const override { + return "Heltec MeshPocket"; + } + + void powerOff() override { + sd_power_system_off(); + } + + // Power saving — adapted from MeshCore PR #1353 (IoTThinks). + // Puts the nRF52 into CPU light-sleep until any interrupt fires (LoRa DIO1, + // USER button, RTC tick, SoftDevice event). When BLE is live (e.g. OTA in + // progress or companion app connected) we fall through to a plain event wait + // rather than the SoftDevice primitive — this is the "safe to sleep" pattern + // from the PR review discussion that avoids BLE stack deadlocks. + // + // The `secs` param is ignored on NRF52 (matches upstream PR #2286 usage: + // main.cpp passes 0 meaning "sleep whenever possible"). Any enabled IRQ + // wakes the CPU — the RTC1 tick (~1ms) provides a hard ceiling on wake + // latency, keeping MorseScreen timing responsive. + void sleep(uint32_t secs) override; +}; \ No newline at end of file diff --git a/variants/mesh_pocket/MorseScreen.h b/variants/mesh_pocket/MorseScreen.h new file mode 100644 index 00000000..c223521c --- /dev/null +++ b/variants/mesh_pocket/MorseScreen.h @@ -0,0 +1,413 @@ +#pragma once + +// ============================================================================= +// MorseScreen — single-button Morse compose/receive for the Meshpocket +// +// Entered from the home screen via a triple-click on the USER button when +// MORSE_COMPOSE_ENABLED is defined (Meshpocket companion builds only). +// +// While active, this screen takes exclusive ownership of the USER button: +// - Short press -> dot (<240 ms by default) +// - Longer press -> dash (>=240 ms) +// - Letter gap (~360 ms silence) commits the staged pattern to the buffer +// - Word gap (~840 ms silence) inserts a space +// - `AR` prosign (.-.-.) -> send to Public (channel 0), clear buffer +// - `HH` prosign (........) -> backspace one character +// - 5 s continuous hold -> exit back to home screen +// +// The screen maintains its own tiny ring buffer of the most recent Public +// channel messages (populated from UITask::newMsg when channel_idx == 0) so +// that it does not need to reach into ChannelScreen internals. +// +// Sending is delegated to UITask via the consumeSendRequest() flag pattern +// so that this header has no dependency on MyMesh / BaseChatMesh types. +// ============================================================================= + +#ifdef MORSE_COMPOSE_ENABLED + +#include +#include +#include +#include +#include +#include + +// user_btn is instantiated in variants/mesh_pocket/target.cpp +extern MomentaryButton user_btn; + +// ----------------------------------------------------------------------------- +// Tunables +// ----------------------------------------------------------------------------- + +// Standard Morse timing: WPM = 1.2 / dot_seconds +// 10 WPM -> dot = 120 ms +#define MORSE_DOT_UNIT_MS 120 + +// Press shorter than this = dot, longer = dash. +// 2x dot is a common midpoint threshold (dash is nominally 3x dot). +#define MORSE_DOT_DASH_MS (MORSE_DOT_UNIT_MS * 2) + +// Inter-letter silence that commits the staged pattern (3 dot units). +#define MORSE_LETTER_GAP_MS (MORSE_DOT_UNIT_MS * 3) + +// Inter-word silence that inserts a space (7 dot units). +#define MORSE_WORD_GAP_MS (MORSE_DOT_UNIT_MS * 7) + +// Exit gesture — longer than any conceivable dash, dominant hand will tire. +#define MORSE_EXIT_HOLD_MS 5000 + +// Buffer sizes +#define MORSE_OUT_BUF_LEN 134 // MeshCore per-channel msg cap is ~133 +#define MORSE_STAGING_MAX 12 // longest pattern we accept (HH = 8) +#define MORSE_INBOX_SIZE 3 +#define MORSE_INBOX_TEXT_LEN 96 +#define MORSE_INBOX_NAME_LEN 32 + +// ----------------------------------------------------------------------------- +// Morse lookup — ITU minimal + basic punctuation +// Stored in flash; tiny (~400 bytes). RAM impact: zero. +// ----------------------------------------------------------------------------- +struct MorseEntry { + char c; + const char* pat; +}; + +static const MorseEntry MORSE_TABLE[] = { + {'A', ".-"}, {'B', "-..."}, {'C', "-.-."}, {'D', "-.."}, + {'E', "."}, {'F', "..-."}, {'G', "--."}, {'H', "...."}, + {'I', ".."}, {'J', ".---"}, {'K', "-.-"}, {'L', ".-.."}, + {'M', "--"}, {'N', "-."}, {'O', "---"}, {'P', ".--."}, + {'Q', "--.-"}, {'R', ".-."}, {'S', "..."}, {'T', "-"}, + {'U', "..-"}, {'V', "...-"}, {'W', ".--"}, {'X', "-..-"}, + {'Y', "-.--"}, {'Z', "--.."}, + {'0', "-----"}, {'1', ".----"}, {'2', "..---"}, {'3', "...--"}, + {'4', "....-"}, {'5', "....."}, {'6', "-...."}, {'7', "--..."}, + {'8', "---.."}, {'9', "----."}, + {'.', ".-.-.-"},{',', "--..--"},{'?', "..--.."}, + {0, nullptr} +}; + +// ----------------------------------------------------------------------------- +class MorseScreen : public UIScreen { + mesh::RTCClock* _rtc; + + // Outgoing composition + char _outBuf[MORSE_OUT_BUF_LEN]; + uint16_t _outLen; + + // Current letter staging (dots/dashes not yet decoded) + char _staging[MORSE_STAGING_MAX]; + uint8_t _stagingLen; + + // Key timing state + bool _btnPrevPressed; + unsigned long _pressStart; + unsigned long _releaseAt; // 0 if not yet released after last press + bool _letterDecoded; // set after commitStaging() — awaits word gap + bool _wordSpaceInserted; + bool _exitArmed; // hold threshold crossed; exits on release + + // Cross-screen requests (UITask polls these) + bool _wantsExit; + bool _wantsSend; + + // Incoming ring buffer — channel 0 (Public) only + struct InboxEntry { + uint32_t timestamp; + char from[MORSE_INBOX_NAME_LEN]; + char text[MORSE_INBOX_TEXT_LEN]; + bool valid; + }; + InboxEntry _inbox[MORSE_INBOX_SIZE]; + uint8_t _inboxNewest; // index of most recent entry + uint8_t _inboxCount; + + bool _dirty; + unsigned long _nextRender; + + // --------------------------------------------------------------------------- + // Morse decode + // Returns the ASCII character for a pattern, or: + // '\x01' = AR prosign ".-.-." (send) + // '\x02' = HH prosign "........" (backspace) + // 0 = no match (silently drop) + // --------------------------------------------------------------------------- + char decodeStaging() const { + if (_stagingLen == 0) return 0; + if (strcmp(_staging, ".-.-.") == 0) return '\x01'; + if (strcmp(_staging, "........") == 0) return '\x02'; + for (const MorseEntry* e = MORSE_TABLE; e->c != 0; e++) { + if (strcmp(_staging, e->pat) == 0) return e->c; + } + return 0; + } + + void commitStaging() { + if (_stagingLen == 0) return; + char decoded = decodeStaging(); + if (decoded == '\x01') { + // AR — request send from UITask + if (_outLen > 0) _wantsSend = true; + } else if (decoded == '\x02') { + // HH — backspace one character (skip trailing space if present) + if (_outLen > 0) { + _outLen--; + _outBuf[_outLen] = 0; + } + } else if (decoded != 0) { + if (_outLen < MORSE_OUT_BUF_LEN - 1) { + _outBuf[_outLen++] = decoded; + _outBuf[_outLen] = 0; + } + } + _stagingLen = 0; + _staging[0] = 0; + _letterDecoded = true; + _wordSpaceInserted = false; + _dirty = true; + } + + void insertWordSpace() { + if (_outLen > 0 && _outBuf[_outLen - 1] != ' ' + && _outLen < MORSE_OUT_BUF_LEN - 1) { + _outBuf[_outLen++] = ' '; + _outBuf[_outLen] = 0; + _dirty = true; + } + _wordSpaceInserted = true; + } + +public: + MorseScreen(mesh::RTCClock* rtc) + : _rtc(rtc), + _outLen(0), _stagingLen(0), + _btnPrevPressed(false), _pressStart(0), _releaseAt(0), + _letterDecoded(false), _wordSpaceInserted(false), _exitArmed(false), + _wantsExit(false), _wantsSend(false), + _inboxNewest(0), _inboxCount(0), + _dirty(true), _nextRender(0) + { + _outBuf[0] = 0; + _staging[0] = 0; + memset(_inbox, 0, sizeof(_inbox)); + } + + // Called by UITask when the screen is activated (on triple-click from home) + // Resets composition state so each session starts clean. + void activate() { + _outLen = 0; _outBuf[0] = 0; + _stagingLen = 0; _staging[0] = 0; + _btnPrevPressed = user_btn.isPressed(); + _pressStart = 0; + _releaseAt = 0; + _letterDecoded = false; + _wordSpaceInserted = false; + _exitArmed = false; + _wantsExit = false; + _wantsSend = false; + _dirty = true; + } + + // Called from UITask::newMsg when channel_idx == 0 (Public). + // `from` is the channel name; `text` is the mesh-layer text which already + // contains "sender: message" for channel messages. + void notifyPublicMsg(const char* from, const char* text) { + _inboxNewest = (_inboxCount == 0) ? 0 : ((_inboxNewest + 1) % MORSE_INBOX_SIZE); + InboxEntry& e = _inbox[_inboxNewest]; + e.timestamp = _rtc ? _rtc->getCurrentTime() : 0; + if (from) { + strncpy(e.from, from, MORSE_INBOX_NAME_LEN - 1); + e.from[MORSE_INBOX_NAME_LEN - 1] = 0; + } else { + e.from[0] = 0; + } + if (text) { + strncpy(e.text, text, MORSE_INBOX_TEXT_LEN - 1); + e.text[MORSE_INBOX_TEXT_LEN - 1] = 0; + } else { + e.text[0] = 0; + } + e.valid = true; + if (_inboxCount < MORSE_INBOX_SIZE) _inboxCount++; + _dirty = true; + } + + // --------------------------------------------------------------------------- + // UITask bridges — polled each loop iteration + // --------------------------------------------------------------------------- + + // Returns the outgoing buffer pointer if a send was requested (AR prosign). + // Caller clears the buffer via clearOutBuf() after a successful send. + bool consumeSendRequest(const char** textOut) { + if (!_wantsSend) return false; + _wantsSend = false; + if (textOut) *textOut = _outBuf; + return true; + } + + bool wantsExit() const { return _wantsExit; } + void acknowledgeExit() { _wantsExit = false; } + + void clearOutBuf() { + _outLen = 0; + _outBuf[0] = 0; + _dirty = true; + } + + // --------------------------------------------------------------------------- + // UIScreen contract + // --------------------------------------------------------------------------- + + void poll() override { + unsigned long now = millis(); + bool pressed = user_btn.isPressed(); + + if (pressed && !_btnPrevPressed) { + // Edge: released -> pressed + _pressStart = now; + _exitArmed = false; + _letterDecoded = false; + _wordSpaceInserted = false; + } else if (!pressed && _btnPrevPressed) { + // Edge: pressed -> released + unsigned long dur = now - _pressStart; + if (_exitArmed) { + // Exit-hold completed — signal UITask to navigate back to home. + // Do NOT add this press to staging. + _wantsExit = true; + } else { + // Normal dot/dash + if (_stagingLen < MORSE_STAGING_MAX - 1) { + _staging[_stagingLen++] = (dur < MORSE_DOT_DASH_MS) ? '.' : '-'; + _staging[_stagingLen] = 0; + } + _releaseAt = now; + _dirty = true; + } + } else if (pressed && _btnPrevPressed) { + // Still holding — check for exit-arm threshold + if (!_exitArmed && (now - _pressStart) >= MORSE_EXIT_HOLD_MS) { + _exitArmed = true; + _dirty = true; // redraw to show "release to exit" hint + } + } else { + // Idle (not pressed, wasn't pressed) — check gap timers + if (_stagingLen > 0 && _releaseAt > 0 + && (now - _releaseAt) >= MORSE_LETTER_GAP_MS) { + commitStaging(); + _releaseAt = now; // reset so word gap measures from commit + } else if (_outLen > 0 && _letterDecoded && !_wordSpaceInserted + && _releaseAt > 0 + && (now - _releaseAt) >= MORSE_WORD_GAP_MS) { + insertWordSpace(); + } + } + + _btnPrevPressed = pressed; + } + + int render(DisplayDriver& display) override { + const int W = display.width(); + const int H = display.height(); + + display.setTextSize(1); + + // ---- Header strip -------------------------------------------------------- + display.setColor(DisplayDriver::YELLOW); + display.setCursor(2, 1); + display.print("MORSE \xB7 PUBLIC"); + + // Exit hint (right-aligned) + display.setColor(_exitArmed ? DisplayDriver::GREEN : DisplayDriver::LIGHT); + const char* hint = _exitArmed ? "Release -> exit" : "Hold to exit"; + display.drawTextRightAlign(W - 2, 1, hint); + + // HR + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, W, 1); + + // ---- Inbox (last N Public messages) -------------------------------------- + display.setColor(DisplayDriver::GREEN); + display.setCursor(2, 14); + display.print("IN"); + + display.setColor(DisplayDriver::LIGHT); + if (_inboxCount == 0) { + display.setCursor(22, 14); + display.print("(no messages yet)"); + } else { + int y = 14; + // Iterate from newest to oldest + for (int i = 0; i < _inboxCount && i < MORSE_INBOX_SIZE; i++) { + int idx = (int)_inboxNewest - i; + while (idx < 0) idx += MORSE_INBOX_SIZE; + const InboxEntry& e = _inbox[idx]; + if (!e.valid) continue; + display.drawTextEllipsized(22, y, W - 24, e.text); + y += 10; + } + } + + // HR + display.drawRect(0, 48, W, 1); + + // ---- Outgoing buffer ----------------------------------------------------- + display.setColor(DisplayDriver::GREEN); + display.setCursor(2, 51); + display.print("OUT"); + + display.setColor(DisplayDriver::LIGHT); + // Render outgoing with a cursor caret + char outWithCursor[MORSE_OUT_BUF_LEN + 2]; + if (_outLen == 0) { + strcpy(outWithCursor, "_"); + } else { + // Show last portion that fits if message is long + strncpy(outWithCursor, _outBuf, sizeof(outWithCursor) - 2); + outWithCursor[sizeof(outWithCursor) - 2] = 0; + size_t n = strlen(outWithCursor); + if (n < sizeof(outWithCursor) - 1) { + outWithCursor[n] = '_'; + outWithCursor[n + 1] = 0; + } + } + // Word-wrap inside the strip (y=62..85 approximately) + display.setCursor(2, 62); + display.printWordWrap(outWithCursor, W - 4); + + // HR + display.drawRect(0, 90, W, 1); + + // ---- Staging (current key sequence) -------------------------------------- + display.setColor(DisplayDriver::GREEN); + display.setCursor(2, 93); + display.print("KEY"); + + display.setColor(_exitArmed ? DisplayDriver::YELLOW : DisplayDriver::LIGHT); + display.setTextSize(2); + display.setCursor(30, 93); + display.print(_stagingLen > 0 ? _staging : " "); + display.setTextSize(1); + + // Character count indicator (bottom-right) + display.setColor(DisplayDriver::LIGHT); + char ccBuf[12]; + snprintf(ccBuf, sizeof(ccBuf), "%u/%u", (unsigned)_outLen, + (unsigned)(MORSE_OUT_BUF_LEN - 1)); + display.drawTextRightAlign(W - 2, H - 10, ccBuf); + + // Suppress H-unused warning on builds where width/height differ + (void)H; + + _dirty = false; + _nextRender = millis(); + + // Refresh cadence: + // - 200 ms while the user is actively keying (captures staging changes) + // - 800 ms otherwise (incoming messages, idle) + bool active = (_stagingLen > 0) || _btnPrevPressed || _exitArmed; + return active ? 200 : 800; + } +}; + +#endif // MORSE_COMPOSE_ENABLED \ No newline at end of file diff --git a/variants/mesh_pocket/platformio.ini b/variants/mesh_pocket/platformio.ini new file mode 100644 index 00000000..bb8fede7 --- /dev/null +++ b/variants/mesh_pocket/platformio.ini @@ -0,0 +1,69 @@ +; ============================================================================ +; Meck — Heltec MeshPocket variant configuration +; ============================================================================ +; nRF52840 + SX1262 + 2.13" E-Ink (GxEPD2_213_B74) +; Single USER button (GPIO 42), no buzzer, no user-accessible LED +; ============================================================================ + +[Mesh_pocket] +extends = nrf52_base +board = heltec_mesh_pocket +platform_packages = framework-arduinoadafruitnrf52 +board_build.ldscript = boards/nrf52840_s140_v6.ld +build_flags = ${nrf52_base.build_flags} + -I src/helpers/nrf52 + -I lib/nrf52/s140_nrf52_6.1.1_API/include + -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52 + -I variants/mesh_pocket + -D HELTEC_MESH_POCKET + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D EINK_DISPLAY_MODEL=GxEPD2_213_B74 + -D EINK_SCALE_X=1.953125f + -D EINK_SCALE_Y=1.28f + -D EINK_X_OFFSET=0 + -D EINK_Y_OFFSET=10 + -D DISPLAY_CLASS=GxEPDDisplay + -D DISABLE_DIAGNOSTIC_OUTPUT +build_src_filter = ${nrf52_base.build_src_filter} + + + +<../variants/mesh_pocket> + + +lib_deps = + ${nrf52_base.lib_deps} + adafruit/Adafruit EPD @ 4.6.1 + rweather/Crypto @ ^0.4.0 + stevemarple/MicroNMEA @ ^2.0.6 + zinggjm/GxEPD2 @ 1.6.2 + bakercp/CRC32 @ ^2.0.0 + +debug_tool = jlink +upload_protocol = nrfutil + +[env:Mesh_pocket_companion_radio_ble] +extends = Mesh_pocket +board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld +board_upload.maximum_size = 712704 +build_flags = + ${Mesh_pocket.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=500 + -D MAX_GROUP_CHANNELS=8 + -D BLE_PIN_CODE=234567 + -D OFFLINE_QUEUE_SIZE=64 + -D AUTO_OFF_MILLIS=0 + -D MORSE_COMPOSE_ENABLED=1 +; -D BLE_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + +build_src_filter = ${Mesh_pocket.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Mesh_pocket.lib_deps} + densaugeo/base64 @ ~1.4.0 \ No newline at end of file diff --git a/variants/mesh_pocket/target.cpp b/variants/mesh_pocket/target.cpp new file mode 100644 index 00000000..3ca71463 --- /dev/null +++ b/variants/mesh_pocket/target.cpp @@ -0,0 +1,45 @@ +#include +#include "target.h" +#include +#include + +HeltecMeshPocket board; + +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); + +WRAPPER_CLASS radio_driver(radio, board); + +SensorManager sensors = SensorManager(); + +VolatileRTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + return radio.std_init(&SPI); +} + +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(int8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + diff --git a/variants/mesh_pocket/target.h b/variants/mesh_pocket/target.h new file mode 100644 index 00000000..233721fe --- /dev/null +++ b/variants/mesh_pocket/target.h @@ -0,0 +1,34 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include "MeshPocket.h" + +#ifdef DISPLAY_CLASS +#include +#include +#endif + +extern HeltecMeshPocket board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; + +#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(int8_t dbm); +mesh::LocalIdentity radio_new_identity(); + +extern SensorManager sensors; + + diff --git a/variants/mesh_pocket/variant.cpp b/variants/mesh_pocket/variant.cpp new file mode 100644 index 00000000..cf7dd44f --- /dev/null +++ b/variants/mesh_pocket/variant.cpp @@ -0,0 +1,15 @@ +#include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const int MISO = PIN_SPI1_MISO; +const int MOSI = PIN_SPI1_MOSI; +const int SCK = PIN_SPI1_SCK; + +const uint32_t g_ADigitalPinMap[] = { + // P0 - pins 0 and 1 are hardwired for xtal and should never be enabled + 0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47}; \ No newline at end of file diff --git a/variants/mesh_pocket/variant.h b/variants/mesh_pocket/variant.h new file mode 100644 index 00000000..870d062a --- /dev/null +++ b/variants/mesh_pocket/variant.h @@ -0,0 +1,124 @@ +/* + * variant.h + * MIT License + */ + +#pragma once + +#include "WVariant.h" + +//////////////////////////////////////////////////////////////////////////////// +// Low frequency clock source + +#define USE_LFXO // 32.768 kHz crystal oscillator +#define VARIANT_MCK (64000000ul) + + +//////////////////////////////////////////////////////////////////////////////// +// Power + +#define BATTERY_PIN (0 + 29) +#define PIN_BAT_CTRL (32 + 2) +#define ADC_MULTIPLIER (4.90F) + +#define ADC_RESOLUTION (14) +#define BATTERY_SENSE_RES (12) + +#define AREF_VOLTAGE (3.0) + +//////////////////////////////////////////////////////////////////////////////// +// Number of pins + +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +//////////////////////////////////////////////////////////////////////////////// +// UART pin definition + +#define PIN_SERIAL1_RX (37) +#define PIN_SERIAL1_TX (39) + +#define PIN_SERIAL2_RX (7) +#define PIN_SERIAL2_TX (8) + +//////////////////////////////////////////////////////////////////////////////// +// I2C pin definition +#define WIRE_INTERFACES_COUNT (1) + +#define PIN_WIRE_SDA (32+15) +#define PIN_WIRE_SCL (32+13) + +//////////////////////////////////////////////////////////////////////////////// +// Builtin LEDs + +#define LED_BUILTIN (13) +#define PIN_LED LED_BUILTIN +#define LED_RED LED_BUILTIN +#define LED_BLUE (-1) // No blue led, prevents Bluefruit flashing the green LED during advertising +#define PIN_STATUS_LED LED_BUILTIN + +#define LED_STATE_ON LOW + +//////////////////////////////////////////////////////////////////////////////// +// Builtin buttons + +#define PIN_BUTTON1 (32 + 10) +#define BUTTON_PIN PIN_BUTTON1 + +// #define PIN_BUTTON2 (0 + 18) +// #define BUTTON_PIN2 PIN_BUTTON2 + +#define PIN_USER_BTN BUTTON_PIN + +//////////////////////////////////////////////////////////////////////////////// + +// SPI pin definition +#define SPI_INTERFACES_COUNT (2) + +// Lora +#define USE_SX1262 +#define SX126X_CS (0 + 26) +#define SX126X_DIO1 (0 + 16) +#define SX126X_BUSY (0 + 15) +#define SX126X_RESET (0 + 12) +#define SX126X_DIO2_AS_RF_SWITCH true +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define PIN_SPI_MISO (32 + 9) +#define PIN_SPI_MOSI (0 + 5) +#define PIN_SPI_SCK (0 + 4) + +#define LORA_CS SX126X_CS +#define P_LORA_DIO_1 SX126X_DIO1 +#define P_LORA_NSS SX126X_CS +#define P_LORA_RESET SX126X_RESET +#define P_LORA_BUSY SX126X_BUSY +#define P_LORA_SCLK PIN_SPI_SCK +#define P_LORA_MISO PIN_SPI_MISO +#define P_LORA_MOSI PIN_SPI_MOSI + + +//////////////////////////////////////////////////////////////////////////////// +// EInk + +#define PIN_DISPLAY_CS (24) +#define PIN_DISPLAY_BUSY (32 + 6) +#define PIN_DISPLAY_DC (31) +#define PIN_DISPLAY_RST (32 + 4) + +#define PIN_SPI1_MISO (-1) +#define PIN_SPI1_MOSI (20) +#define PIN_SPI1_SCK (22) + + +// GxEPD2 needs that for a panel that is not even used ! +extern const int MISO; +extern const int MOSI; +extern const int SCK; + + +#undef HAS_GPS +#define HAS_GPS 0 +#define HAS_RTC 0 \ No newline at end of file