Heltec Meshpocket: initial BLE companion port

This commit is contained in:
pelgraine
2026-04-16 23:18:10 +10:00
parent 1c4d5a0daa
commit a378f4f1aa
21 changed files with 1280 additions and 53 deletions
+1 -1
View File
@@ -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",
+26 -15
View File
@@ -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();
+4 -1
View File
@@ -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;
+32 -1
View File
@@ -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;
}
}
#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
+19 -2
View File
@@ -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
+60 -23
View File
@@ -1,6 +1,6 @@
#include <Arduino.h> // needed for PlatformIO
#ifdef BLE_PIN_CODE
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
#if defined(BLE_PIN_CODE) && defined(ESP32)
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi) — ESP32 only
#endif
#ifdef MECK_OTA_UPDATE
#include <esp_ota_ops.h>
@@ -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 <SD.h>
#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 <SD.h>
#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 <Adafruit_TinyUSB.h>
// 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
}
// ============================================================================
@@ -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; }
+46 -4
View File
@@ -2,14 +2,18 @@
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#ifdef ESP32
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#endif
#include "../NodePrefs.h"
// Forward declarations
class UITask;
#ifdef ESP32
// ============================================================================
// Configuration
// ============================================================================
@@ -1368,4 +1372,42 @@ public:
}
return false;
}
};
};
#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
@@ -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) {
@@ -2,15 +2,19 @@
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#include "EpubProcessor.h"
#ifdef ESP32
#include <SD.h>
#include <vector>
#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;
}
};
};
#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
+79 -1
View File
@@ -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;
+4
View File
@@ -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; }
+133
View File
@@ -0,0 +1,133 @@
#pragma once
#include <Arduino.h>
// 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
+55
View File
@@ -0,0 +1,55 @@
#include <Arduino.h>
#include <Wire.h>
#include <nrf_soc.h>
#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
}
}
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <Arduino.h>
#include <MeshCore.h>
#include <helpers/NRF52Board.h>
// 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;
};
+413
View File
@@ -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 <Arduino.h>
#include <string.h>
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/MomentaryButton.h>
#include <MeshCore.h>
// 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
+69
View File
@@ -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}
+<helpers/*.cpp>
+<../variants/mesh_pocket>
+<helpers/ui/GxEPDDisplay.cpp>
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}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${Mesh_pocket.lib_deps}
densaugeo/base64 @ ~1.4.0
+45
View File
@@ -0,0 +1,45 @@
#include <Arduino.h>
#include "target.h"
#include <helpers/ArduinoHelpers.h>
#include <helpers/sensors/MicroNMEALocationProvider.h>
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
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#include <helpers/sensors/LocationProvider.h>
#include "MeshPocket.h"
#ifdef DISPLAY_CLASS
#include <helpers/ui/GxEPDDisplay.h>
#include <helpers/ui/MomentaryButton.h>
#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;
+15
View File
@@ -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};
+124
View File
@@ -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