Преглед изворни кода

* UI revamp for companion radios

Scott Powell пре 1 година
родитељ
комит
4b95c981bb

+ 15 - 0
examples/companion_radio/MyMesh.cpp

@@ -267,6 +267,7 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
     }
 
     memcpy(p->pubkey_prefix, contact.id.pub_key, sizeof(p->pubkey_prefix));
+    strcpy(p->name, contact.name);
     p->recv_timestamp = getRTCClock()->getCurrentTime();
     p->path_len = path_len;
     memcpy(p->path, path, p->path_len);
@@ -275,6 +276,20 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
   dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
 }
 
+static int sort_by_recent(const void *a, const void *b) {
+  return ((AdvertPath *) b)->recv_timestamp - ((AdvertPath *) a)->recv_timestamp;
+}
+
+int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
+  if (max_num > ADVERT_PATH_TABLE_SIZE) max_num = ADVERT_PATH_TABLE_SIZE;
+  qsort(advert_paths, ADVERT_PATH_TABLE_SIZE, sizeof(advert_paths[0]), sort_by_recent);
+
+  for (int i = 0; i < max_num; i++) {
+    dest[i] = advert_paths[i];
+  }
+  return max_num;
+}
+
 void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
   out_frame[0] = PUSH_CODE_PATH_UPDATED;
   memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);

+ 10 - 6
examples/companion_radio/MyMesh.h

@@ -77,6 +77,14 @@
 #define REQ_TYPE_KEEP_ALIVE             0x02
 #define REQ_TYPE_GET_TELEMETRY_DATA     0x03
 
+struct AdvertPath {
+  uint8_t pubkey_prefix[7];
+  uint8_t path_len;
+  char    name[32];
+  uint32_t recv_timestamp;
+  uint8_t path[MAX_PATH_SIZE];
+};
+
 class MyMesh : public BaseChatMesh, public DataStoreHost {
 public:
   MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store);
@@ -93,6 +101,8 @@ public:
   bool advert();
   void enterCLIRescue();
 
+  int  getRecentlyHeard(AdvertPath dest[], int max_num);
+
 protected:
   float getAirtimeBudgetFactor() const override;
   int getInterferenceThreshold() const override;
@@ -201,12 +211,6 @@ private:
   AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
   int next_ack_idx;
 
-  struct AdvertPath {
-    uint8_t pubkey_prefix[7];
-    uint8_t path_len;
-    uint32_t recv_timestamp;
-    uint8_t path[MAX_PATH_SIZE];
-  };
   #define ADVERT_PATH_TABLE_SIZE   16
   AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
 };

+ 422 - 274
examples/companion_radio/UITask.cpp

@@ -1,8 +1,8 @@
 #include "UITask.h"
-#include <Arduino.h>
 #include <helpers/TxtDataHelpers.h>
 #include "NodePrefs.h"
 #include "MyMesh.h"
+#include "target.h"
 
 #define AUTO_OFF_MILLIS     15000   // 15 seconds
 #define BOOT_SCREEN_MILLIS   3000   // 3 seconds
@@ -13,80 +13,366 @@
 #define LED_CYCLE_MILLIS  4000
 #endif
 
-#ifndef USER_BTN_PRESSED
-#define USER_BTN_PRESSED LOW
-#endif
+#define LONG_PRESS_MILLIS   1200
+
+#define PRESS_LABEL "long press"
+
+#include "icons.h"
+
+class SplashScreen : public UIScreen {
+  UITask* _task;
+  unsigned long dismiss_after;
+  char _version_info[12];
+
+public:
+  SplashScreen(UITask* task) : _task(task) {
+    // strip off dash and commit hash by changing dash to null terminator
+    // e.g: v1.2.3-abcdef -> v1.2.3
+    const char *ver = FIRMWARE_VERSION;
+    const char *dash = strchr(ver, '-');
+
+    int len = dash ? dash - ver : strlen(ver);
+    if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1;
+    memcpy(_version_info, ver, len);
+    _version_info[len] = 0;
+
+    dismiss_after = millis() + BOOT_SCREEN_MILLIS;
+  }
+
+  int render(DisplayDriver& display) override {
+    // meshcore logo
+    display.setColor(DisplayDriver::BLUE);
+    int logoWidth = 128;
+    display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
+
+    // version info
+    display.setColor(DisplayDriver::LIGHT);
+    display.setTextSize(2);
+    display.drawTextCentered(display.width()/2, 22, _version_info);
+
+    display.setTextSize(1);
+    display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE);
+
+    return 1000;
+  }
+
+  void poll() override {
+    if (millis() >= dismiss_after) {
+      _task->gotoHomeScreen();
+    }
+  }
+};
+
+class HomeScreen : public UIScreen {
+  enum HomePage {
+    FIRST,
+    RECENT,
+    RADIO,
+    BLUETOOTH,
+    ADVERT,
+    SHUTDOWN,
+    Count    // keep as last
+  };
+
+  UITask* _task;
+  mesh::RTCClock* _rtc;
+  SensorManager* _sensors;
+  NodePrefs* _node_prefs;
+  uint8_t _page;
+  bool _shutdown_init;
+  AdvertPath recent[4];
+
+  void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
+    // Convert millivolts to percentage
+    const int minMilliVolts = 3000; // Minimum voltage (e.g., 3.0V)
+    const int maxMilliVolts = 4200; // Maximum voltage (e.g., 4.2V)
+    int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
+    if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
+    if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
+
+    // battery icon
+    int iconWidth = 24;
+    int iconHeight = 10;
+    int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner
+    int iconY = 0;
+    display.setColor(DisplayDriver::GREEN);
+
+    // battery outline
+    display.drawRect(iconX, iconY, iconWidth, iconHeight);
+
+    // battery "cap"
+    display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
+
+    // fill the battery based on the percentage
+    int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
+    display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
+  }
+
+public:
+  HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
+     : _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0), _shutdown_init(false) { }
+
+  void poll() override {
+    if (_shutdown_init && !_task->isButtonPressed()) {  // must wait for USR button to be released
+      _task->shutdown();
+    }
+  }
+
+  int render(DisplayDriver& display) override {
+    char tmp[80];
+    // node name
+    display.setCursor(0, 0);
+    display.setTextSize(1);
+    display.setColor(DisplayDriver::GREEN);
+    display.print(_node_prefs->node_name);
+
+    // battery voltage
+    renderBatteryIndicator(display, _task->getBattMilliVolts());
+
+    // curr page indicator
+    int y = 14;
+    int x = display.width() / 2 - 25;
+    for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) {
+      if (i == _page) {
+        display.fillRect(x-1, y-1, 3, 3);
+      } else {
+        display.fillRect(x, y, 1, 1);
+      }
+    }
+
+    if (_page == HomePage::FIRST) {
+      display.setColor(DisplayDriver::YELLOW);
+      display.setTextSize(2);
+      sprintf(tmp, "MSG: %d", _task->getMsgCount());
+      display.drawTextCentered(display.width() / 2, 20, tmp);
+
+      if (_task->hasConnection()) {
+        display.setColor(DisplayDriver::GREEN);
+        display.setTextSize(1);
+        display.drawTextCentered(display.width() / 2, 43, "< Connected >");
+      } else if (the_mesh.getBLEPin() != 0) { // BT pin
+        display.setColor(DisplayDriver::RED);
+        display.setTextSize(2);
+        sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
+        display.drawTextCentered(display.width() / 2, 43, tmp);
+      }
+    } else if (_page == HomePage::RECENT) {
+      the_mesh.getRecentlyHeard(recent, 4);
+      display.setColor(DisplayDriver::GREEN);
+      int y = 20;
+      for (int i = 0; i < 4; i++, y += 11) {
+        auto a = &recent[i];
+        if (a->name[0] == 0) continue;  // empty slot
+        display.setCursor(0, y);
+        display.print(a->name);
+        int secs = _rtc->getCurrentTime() - a->recv_timestamp;
+        if (secs < 60) {
+          sprintf(tmp, "%ds", secs);
+        } else if (secs < 60*60) {
+          sprintf(tmp, "%dm", secs / 60);
+        } else {
+          sprintf(tmp, "%dh", secs / (60*60));
+        }
+        display.setCursor(display.width() - display.getTextWidth(tmp) - 1, y);
+        display.print(tmp);
+      }
+    } else if (_page == HomePage::RADIO) {
+      display.setColor(DisplayDriver::YELLOW);
+      display.setTextSize(1);
+      // freq / sf
+      display.setCursor(0, 20);
+      sprintf(tmp, "FQ: %06.3f   SF: %d", _node_prefs->freq, _node_prefs->sf);
+      display.print(tmp);
+
+      display.setCursor(0, 31);
+      sprintf(tmp, "BW: %03.2f     CR: %d", _node_prefs->bw, _node_prefs->cr);
+      display.print(tmp);
+
+      // tx power,  noise floor
+      display.setCursor(0, 42);
+      sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm);
+      display.print(tmp);
+      display.setCursor(0, 53);
+      sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
+      display.print(tmp);
+    } else if (_page == HomePage::BLUETOOTH) {
+      display.setColor(DisplayDriver::GREEN);
+      display.drawXbm((display.width() - 32) / 2, 18, 
+          _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 
+          32, 32);
+      display.setTextSize(1);
+      display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
+    } else if (_page == HomePage::ADVERT) {
+      display.setColor(DisplayDriver::GREEN);
+      display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
+      display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
+    } else if (_page == HomePage::SHUTDOWN) {
+      display.setColor(DisplayDriver::GREEN);
+      display.setTextSize(1);
+      if (_shutdown_init) {
+        display.drawTextCentered(display.width() / 2, 34, "shutting down...");
+      } else {
+        display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32);
+        display.drawTextCentered(display.width() / 2, 64 - 11, "off: " PRESS_LABEL);
+      }
+    }
+    return 5000;   // next render after 5000 ms
+  }
 
-// 'meshcore', 128x13px
-static const uint8_t meshcore_logo [] PROGMEM = {
-    0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 
-    0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe, 
-    0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc, 
-    0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00, 
-    0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00, 
-    0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, 
-    0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, 
-    0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0, 
-    0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00, 
-    0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00, 
-    0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8, 
-    0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8, 
-    0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, 
+  bool handleInput(char c) override {
+    if (c == KEY_LEFT) {
+      _page = (_page + HomePage::Count - 1) % HomePage::Count;
+      return true;
+    }
+    if (c == KEY_RIGHT || c == KEY_SELECT) {
+      _page = (_page + 1) % HomePage::Count;
+      if (_page == HomePage::RECENT) {
+        _task->showAlert("Recent adverts", 800);
+      }
+      return true;
+    }
+    if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
+      if (_task->isSerialEnabled()) {  // toggle Bluetooth on/off
+        _task->disableSerial();
+      } else {
+        _task->enableSerial();
+      }
+      return true;
+    }
+    if (c == KEY_ENTER && _page == HomePage::ADVERT) {
+  #ifdef PIN_BUZZER
+      _task->soundBuzzer(UIEventType::ack);
+  #endif
+      if (the_mesh.advert()) {
+        _task->showAlert("Advert sent!", 1000);
+      } else {
+        _task->showAlert("Advert failed..", 1000);
+      }
+      return true;
+    }
+    if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
+      _shutdown_init = true;  // need to wait for button to be released
+      return true;
+    }
+    return false;
+  }
+};
+
+class MsgPreviewScreen : public UIScreen {
+  UITask* _task;
+  mesh::RTCClock* _rtc;
+
+  struct MsgEntry {
+    uint32_t timestamp;
+    char origin[62];
+    char msg[78];
+  };
+  #define MAX_UNREAD_MSGS   32
+  int num_unread;
+  MsgEntry unread[MAX_UNREAD_MSGS];
+
+public:
+  MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; }
+
+  void addPreview(uint8_t path_len, const char* from_name, const char* msg) {
+    if (num_unread >= MAX_UNREAD_MSGS) return;  // full
+
+    auto p = &unread[num_unread++];
+    p->timestamp = _rtc->getCurrentTime();
+    if (path_len == 0xFF) {
+      sprintf(p->origin, "(D) %s:", from_name);
+    } else {
+      sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name);
+    }
+    StrHelper::strncpy(p->msg, msg, sizeof(p->msg));
+  }
+
+  int render(DisplayDriver& display) override {
+    char tmp[16];
+    display.setCursor(0, 0);
+    display.setTextSize(1);
+    display.setColor(DisplayDriver::GREEN);
+    sprintf(tmp, "Unread: %d", num_unread);
+    display.print(tmp);
+
+    auto p = &unread[0];
+
+    int secs = _rtc->getCurrentTime() - p->timestamp;
+    if (secs < 60) {
+      sprintf(tmp, "%ds", secs);
+    } else if (secs < 60*60) {
+      sprintf(tmp, "%dm", secs / 60);
+    } else {
+      sprintf(tmp, "%dh", secs / (60*60));
+    }
+    display.setCursor(display.width() - display.getTextWidth(tmp), 0);
+    display.print(tmp);
+
+    display.drawRect(0, 11, display.width(), 1);  // horiz line
+
+    display.setCursor(0, 14);
+    display.setColor(DisplayDriver::YELLOW);
+    display.print(p->origin);
+
+    display.setCursor(0, 25);
+    display.setColor(DisplayDriver::LIGHT);
+    display.printWordWrap(p->msg, display.width());
+
+    return 1000;  // next render after 1000 ms
+  }
+
+  bool handleInput(char c) override {
+    if (c == KEY_SELECT || c == KEY_RIGHT) {
+      num_unread--;
+      if (num_unread == 0) {
+        _task->gotoHomeScreen();
+      } else {
+        // delete first/curr item from unread queue
+        for (int i = 0; i < num_unread; i++) {
+          unread[i] = unread[i + 1];
+        }
+      }
+      return true;
+    }
+    if (c == KEY_ENTER) {
+      num_unread = 0;  // clear unread queue
+      _task->gotoHomeScreen();
+      return true;
+    }
+    return false;
+  }
 };
 
 void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
   _display = display;
   _sensors = sensors;
   _auto_off = millis() + AUTO_OFF_MILLIS;
-  clearMsgPreview();
+
+#if defined(PIN_USER_BTN)
+  user_btn.begin();
+#endif
+
   _node_prefs = node_prefs;
   if (_display != NULL) {
     _display->turnOn();
   }
 
-  // strip off dash and commit hash by changing dash to null terminator
-  // e.g: v1.2.3-abcdef -> v1.2.3
-  char *version = strdup(FIRMWARE_VERSION);
-  char *dash = strchr(version, '-');
-  if (dash) {
-    *dash = 0;
-  }
-
-  // v1.2.3 (1 Jan 2025)
-  sprintf(_version_info, "%s (%s)", version, FIRMWARE_BUILD_DATE);
-
 #ifdef PIN_BUZZER
   buzzer.begin();
 #endif
 
-  // Initialize digital button if available
-#ifdef PIN_USER_BTN
-  _userButton = new Button(PIN_USER_BTN, USER_BTN_PRESSED);
-  _userButton->begin();
-  
-  // Set up digital button callbacks
-  _userButton->onShortPress([this]() { handleButtonShortPress(); });
-  _userButton->onDoublePress([this]() { handleButtonDoublePress(); });
-  _userButton->onTriplePress([this]() { handleButtonTriplePress(); });
-  _userButton->onQuadruplePress([this]() { handleButtonQuadruplePress(); });
-  _userButton->onLongPress([this]() { handleButtonLongPress(); });
-  _userButton->onAnyPress([this]() { handleButtonAnyPress(); });
-#endif
-
-  // Initialize analog button if available
-#ifdef PIN_USER_BTN_ANA
-  _userButtonAnalog = new Button(PIN_USER_BTN_ANA, USER_BTN_PRESSED, true, 20);
-  _userButtonAnalog->begin();
-  
-  // Set up analog button callbacks
-  _userButtonAnalog->onShortPress([this]() { handleButtonShortPress(); });
-  _userButtonAnalog->onDoublePress([this]() { handleButtonDoublePress(); });
-  _userButtonAnalog->onTriplePress([this]() { handleButtonTriplePress(); });
-  _userButtonAnalog->onQuadruplePress([this]() { handleButtonQuadruplePress(); });
-  _userButtonAnalog->onLongPress([this]() { handleButtonLongPress(); });
-  _userButtonAnalog->onAnyPress([this]() { handleButtonAnyPress(); });
-#endif
   ui_started_at = millis();
+  _alert_expiry = 0;
+
+  splash = new SplashScreen(this);
+  home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
+  msg_preview = new MsgPreviewScreen(this, &rtc_clock);
+  setCurrScreen(splash);
+}
+
+void UITask::showAlert(const char* text, int duration_millis) {
+  strcpy(_alert, text);
+  _alert_expiry = millis() + duration_millis;
 }
 
 void UITask::soundBuzzer(UIEventType bet) {
@@ -109,147 +395,28 @@ switch(bet){
     break;
 }
 #endif
-//  Serial.print("DBG:  Buzzzzzz -> ");
-//  Serial.println((int) bet);
 }
 
 void UITask::msgRead(int msgcount) {
   _msgcount = msgcount;
   if (msgcount == 0) {
-    clearMsgPreview();
+    gotoHomeScreen();
   }
 }
 
-void UITask::clearMsgPreview() {
-  _origin[0] = 0;
-  _msg[0] = 0;
-  _need_refresh = true;
-}
-
 void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
   _msgcount = msgcount;
 
-  if (path_len == 0xFF) {
-    sprintf(_origin, "(F) %s", from_name);
-  } else {
-    sprintf(_origin, "(%d) %s", (uint32_t) path_len, from_name);
-  }
-  StrHelper::strncpy(_msg, text, sizeof(_msg));
+  ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
+  setCurrScreen(msg_preview);
 
   if (_display != NULL) {
     if (!_display->isOn()) _display->turnOn();
     _auto_off = millis() + AUTO_OFF_MILLIS;  // extend the auto-off timer
-    _need_refresh = true;
+    _next_refresh = 0;  // trigger refresh
   }
 }
 
-void UITask::renderBatteryIndicator(uint16_t batteryMilliVolts) {
-  // Convert millivolts to percentage
-  const int minMilliVolts = 3000; // Minimum voltage (e.g., 3.0V)
-  const int maxMilliVolts = 4200; // Maximum voltage (e.g., 4.2V)
-  int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
-  if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
-  if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
-
-  // battery icon
-  int iconWidth = 24;
-  int iconHeight = 12;
-  int iconX = _display->width() - iconWidth - 5; // Position the icon near the top-right corner
-  int iconY = 0;
-  _display->setColor(DisplayDriver::GREEN);
-
-  // battery outline
-  _display->drawRect(iconX, iconY, iconWidth, iconHeight);
-
-  // battery "cap"
-  _display->fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
-
-  // fill the battery based on the percentage
-  int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
-  _display->fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
-}
-
-void UITask::renderCurrScreen() {
-  if (_display == NULL) return;  // assert() ??
-
-  char tmp[80];
-  if (_alert[0]) {
-    _display->setTextSize(1.4);
-    uint16_t textWidth = _display->getTextWidth(_alert);
-    _display->setCursor((_display->width() - textWidth) / 2, 22);
-    _display->setColor(DisplayDriver::GREEN);
-    _display->print(_alert);
-    _alert[0] = 0;
-    _need_refresh = true;
-    return;
-  } else if (_origin[0] && _msg[0]) { // message preview
-    // render message preview
-    _display->setCursor(0, 0);
-    _display->setTextSize(1);
-    _display->setColor(DisplayDriver::GREEN);
-    _display->print(_node_prefs->node_name);
-
-    _display->setCursor(0, 12);
-    _display->setColor(DisplayDriver::YELLOW);
-    _display->print(_origin);
-    _display->setCursor(0, 24);
-    _display->setColor(DisplayDriver::LIGHT);
-    _display->print(_msg);
-
-    _display->setCursor(_display->width() - 28, 9);
-    _display->setTextSize(2);
-    _display->setColor(DisplayDriver::ORANGE);
-    sprintf(tmp, "%d", _msgcount);
-    _display->print(tmp);
-    _display->setColor(DisplayDriver::YELLOW); // last color will be kept on T114
-  } else if ((millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen
-    // meshcore logo
-    _display->setColor(DisplayDriver::BLUE);
-    int logoWidth = 128;
-    _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
-
-    // version info
-    _display->setColor(DisplayDriver::LIGHT);
-    _display->setTextSize(1);
-    uint16_t textWidth = _display->getTextWidth(_version_info);
-    _display->setCursor((_display->width() - textWidth) / 2, 22);
-    _display->print(_version_info);
-  } else {  // home screen
-    // node name
-    _display->setCursor(0, 0);
-    _display->setTextSize(1);
-    _display->setColor(DisplayDriver::GREEN);
-    _display->print(_node_prefs->node_name);
-
-    // battery voltage
-    renderBatteryIndicator(_board->getBattMilliVolts());
-
-    // freq / sf
-    _display->setCursor(0, 20);
-    _display->setColor(DisplayDriver::YELLOW);
-    sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
-    _display->print(tmp);
-
-    // bw / cr
-    _display->setCursor(0, 30);
-    sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
-    _display->print(tmp);
-
-    // BT pin
-    if (!_connected && the_mesh.getBLEPin() != 0) {
-      _display->setColor(DisplayDriver::RED);
-      _display->setTextSize(2);
-      _display->setCursor(0, 43);
-      sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
-      _display->print(tmp);
-      _display->setColor(DisplayDriver::GREEN);
-    } else {
-      _display->setColor(DisplayDriver::LIGHT); 
-    }
-  }
-  _need_refresh = false;
-}
-
 void UITask::userLedHandler() {
 #ifdef PIN_STATUS_LED
   static int state = 0;
@@ -275,6 +442,11 @@ void UITask::userLedHandler() {
 #endif
 }
 
+void UITask::setCurrScreen(UIScreen* c) {
+  curr = c;
+  _next_refresh = 0;
+}
+
 /* 
   hardware-agnostic pre-shutdown activity should be done here 
 */
@@ -293,96 +465,103 @@ void UITask::shutdown(bool restart){
 
   #endif // PIN_BUZZER
 
-  if (restart)
+  if (restart) {
     _board->reboot();
-  else
+  } else {
+    _display->turnOff();
     _board->powerOff();
+  }
+}
+
+bool UITask::isButtonPressed() const {
+#ifdef PIN_USER_BTN
+  return user_btn.isPressed();
+#else
+  return false;
+#endif
 }
 
 void UITask::loop() {
-  #ifdef PIN_USER_BTN
-    if (_userButton) {
-      _userButton->update();
-    }
-  #endif
-  #ifdef PIN_USER_BTN_ANA
-    if (_userButtonAnalog) {
-      _userButtonAnalog->update();
-    }
-  #endif
+  char c = 0;
+#if defined(PIN_USER_BTN)
+  int ev = user_btn.check();
+  if (ev == BUTTON_EVENT_CLICK) {
+    c = checkDisplayOn(KEY_SELECT);
+  } else if (ev == BUTTON_EVENT_LONG_PRESS) {
+    c = handleLongPress(KEY_ENTER);
+  }
+#endif
+
+  if (c != 0 && curr) {
+    curr->handleInput(c);
+    _auto_off = millis() + AUTO_OFF_MILLIS;   // extend auto-off timer
+    _next_refresh = 0;  // trigger refresh
+  }
+
   userLedHandler();
 
 #ifdef PIN_BUZZER
   if (buzzer.isPlaying())  buzzer.loop();
 #endif
 
+  if (curr) curr->poll();
+
   if (_display != NULL && _display->isOn()) {
-    static bool _firstBoot = true;
-    if(_firstBoot && (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) {
-      _need_refresh = true;
-      _firstBoot = false;
-    }
-    if (millis() >= _next_refresh && _need_refresh) {
+    if (millis() >= _next_refresh && curr) {
       _display->startFrame();
-      renderCurrScreen();
+      int delay_millis = curr->render(*_display);
+      if (millis() < _alert_expiry) {  // render alert popup
+        _display->setTextSize(1);
+        int y = _display->height() / 3;
+        int p = _display->height() / 32;
+        _display->setColor(DisplayDriver::DARK);
+        _display->fillRect(p, y, _display->width() - p*2, y);
+        _display->setColor(DisplayDriver::LIGHT);  // draw box border
+        _display->drawRect(p, y, _display->width() - p*2, y);
+        _display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
+        _next_refresh = _alert_expiry;   // will need refresh when alert is dismissed
+      } else {
+        _next_refresh = millis() + delay_millis;
+      }
       _display->endFrame();
-
-      _next_refresh = millis() + 1000;   // refresh every second
     }
     if (millis() > _auto_off) {
       _display->turnOff();
     }
   }
-}
 
-void UITask::handleButtonAnyPress() {
-  MESH_DEBUG_PRINTLN("UITask: any press triggered");
-  // called on any button press before other events, to wake up the display quickly
-  // do not refresh the display here, as it may block the button handler
-  if (_display != NULL) {
-    _displayWasOn = _display->isOn();  // Track display state before any action
-    if (!_displayWasOn) {
-      _display->turnOn();
+#ifdef AUTO_SHUTDOWN_MILLIVOLTS
+  if (millis() > next_batt_chck) {
+    uint16_t milliVolts = getBattMilliVolts();
+    if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
+      shutdown();
     }
-    _auto_off = millis() + AUTO_OFF_MILLIS;   // extend auto-off timer
+    next_batt_chck = millis() + 8000;
   }
+#endif
 }
 
-void UITask::handleButtonShortPress() {
-  MESH_DEBUG_PRINTLN("UITask: short press triggered");
+char UITask::checkDisplayOn(char c) {
   if (_display != NULL) {
-    // Only clear message preview if display was already on before button press
-    if (_displayWasOn) {
-      // If display was on and showing message preview, clear it
-      if (_origin[0] && _msg[0]) {
-        clearMsgPreview();
-      } else {
-        // Otherwise, refresh the display
-        _need_refresh = true;
-      }
-    } else {
-      _need_refresh = true; // display just turned on, so we need to refresh
+    if (!_display->isOn()) {
+      _display->turnOn();   // turn display on and consume event
+      c = 0;
     }
-    // Note: Display turn-on and auto-off timer extension are handled by handleButtonAnyPress
+    _auto_off = millis() + AUTO_OFF_MILLIS;   // extend auto-off timer
+    _next_refresh = 0;  // trigger refresh
   }
+  return c;
 }
 
-void UITask::handleButtonDoublePress() {
-  MESH_DEBUG_PRINTLN("UITask: double press triggered, sending advert");
-  // ADVERT
-  #ifdef PIN_BUZZER
-      soundBuzzer(UIEventType::ack);
-  #endif
-  if (the_mesh.advert()) {
-    MESH_DEBUG_PRINTLN("Advert sent!");
-    sprintf(_alert, "Advert sent!");
-  } else {
-    MESH_DEBUG_PRINTLN("Advert failed!");
-    sprintf(_alert, "Advert failed..");
+char UITask::handleLongPress(char c) {
+  if (millis() - ui_started_at < 8000) {   // long press in first 8 seconds since startup -> CLI/rescue
+    the_mesh.enterCLIRescue();
+    c = 0;   // consume event
   }
-  _need_refresh = true;
+  return c;
 }
 
+/*
 void UITask::handleButtonTriplePress() {
   MESH_DEBUG_PRINTLN("UITask: triple press triggered");
   // Toggle buzzer quiet mode
@@ -390,43 +569,12 @@ void UITask::handleButtonTriplePress() {
     if (buzzer.isQuiet()) {
       buzzer.quiet(false);
       soundBuzzer(UIEventType::ack);
-      sprintf(_alert, "Buzzer: ON");
+      showAlert("Buzzer: ON", 600);
     } else {
       buzzer.quiet(true);
-      sprintf(_alert, "Buzzer: OFF");
+      showAlert("Buzzer: OFF", 600);
     }
-    _need_refresh = true;
+    _next_refresh = 0;  // trigger refresh
   #endif
 }
-
-void UITask::handleButtonQuadruplePress() {
-  MESH_DEBUG_PRINTLN("UITask: quad press triggered");
-  if (_sensors != NULL) {
-    // toggle GPS onn/off
-    int num = _sensors->getNumSettings();
-    for (int i = 0; i < num; i++) {
-      if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
-        if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
-          _sensors->setSettingValue("gps", "0");
-          soundBuzzer(UIEventType::ack);
-          sprintf(_alert, "GPS: Disabled");
-        } else {
-          _sensors->setSettingValue("gps", "1");
-          soundBuzzer(UIEventType::ack);
-          sprintf(_alert, "GPS: Enabled");
-        }
-        break;
-      }
-    }
-  }
-  _need_refresh = true;
-}
-
-void UITask::handleButtonLongPress() {
-  MESH_DEBUG_PRINTLN("UITask: long press triggered");
-  if (millis() - ui_started_at < 8000) {   // long press in first 8 seconds since startup -> CLI/rescue
-    the_mesh.enterCLIRescue();
-  } else {
-    shutdown();
-  }
-}
+*/

+ 31 - 31
examples/companion_radio/UITask.h

@@ -2,18 +2,18 @@
 
 #include <MeshCore.h>
 #include <helpers/ui/DisplayDriver.h>
+#include <helpers/ui/UIScreen.h>
 #include <helpers/SensorManager.h>
-#include <stddef.h>
+#include <helpers/BaseSerialInterface.h>
+#include <Arduino.h>
 
 #ifdef PIN_BUZZER
   #include <helpers/ui/buzzer.h>
 #endif
 
 #include "NodePrefs.h"
-#include "Button.h"
 
- enum class UIEventType
-{
+enum class UIEventType {
     none,
     contactMessage,
     channelMessage,
@@ -22,9 +22,12 @@
     ack
 };
 
+#define MAX_TOP_LEVEL    8
+
 class UITask {
   DisplayDriver* _display;
   mesh::MainBoard* _board;
+  BaseSerialInterface* _serial;
   SensorManager* _sensors;
 #ifdef PIN_BUZZER
   genericBuzzer buzzer;
@@ -32,48 +35,45 @@ class UITask {
   unsigned long _next_refresh, _auto_off;
   bool _connected;
   NodePrefs* _node_prefs;
-  char _version_info[32];
-  char _origin[62];
-  char _msg[80];
   char _alert[80];
+  unsigned long _alert_expiry;
   int _msgcount;
-  bool _need_refresh = true;
-  bool _displayWasOn = false;  // Track display state before button press
-  unsigned long ui_started_at;
+  unsigned long ui_started_at, next_batt_chck;
 
-  // Button handlers
-#ifdef PIN_USER_BTN
-  Button* _userButton = nullptr;
-#endif
-#ifdef PIN_USER_BTN_ANA
-  Button* _userButtonAnalog = nullptr;
-#endif
+  UIScreen* splash;
+  UIScreen* home;
+  UIScreen* msg_preview;
+  UIScreen* curr;
 
-  void renderCurrScreen();
   void userLedHandler();
-  void renderBatteryIndicator(uint16_t batteryMilliVolts);
   
   // Button action handlers
-  void handleButtonAnyPress();
-  void handleButtonShortPress();
-  void handleButtonDoublePress();
-  void handleButtonTriplePress();
-  void handleButtonQuadruplePress();
-  void handleButtonLongPress();
+  char checkDisplayOn(char c);
+  char handleLongPress(char c);
+
+  void setCurrScreen(UIScreen* c);
 
- 
 public:
 
-  UITask(mesh::MainBoard* board) : _board(board), _display(NULL), _sensors(NULL) {
-      _next_refresh = 0;
-      ui_started_at = 0;
-      _connected = false;
+  UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : _board(board), _serial(serial), _display(NULL), _sensors(NULL) {
+    next_batt_chck = _next_refresh = 0;
+    ui_started_at = 0;
+    _connected = false;
+    curr = NULL;
   }
   void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
 
+  void gotoHomeScreen() { setCurrScreen(home); }
+  void showAlert(const char* text, int duration_millis);
   void setHasConnection(bool connected) { _connected = connected; }
+  bool hasConnection() const { return _connected; }
+  uint16_t getBattMilliVolts() const { return _board->getBattMilliVolts(); }
+  bool isSerialEnabled() const { return _serial->isEnabled(); }
+  void enableSerial() { _serial->enable(); }
+  void disableSerial() { _serial->disable(); }
+  int  getMsgCount() const { return _msgcount; }
   bool hasDisplay() const { return _display != NULL; }
-  void clearMsgPreview();
+  bool isButtonPressed() const;
   void msgRead(int msgcount);
   void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount);
   void soundBuzzer(UIEventType bet = UIEventType::none);

+ 118 - 0
examples/companion_radio/icons.h

@@ -0,0 +1,118 @@
+#pragma once
+
+#include <stdint.h>
+
+// 'meshcore', 128x13px
+static const uint8_t meshcore_logo [] = {
+    0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 
+    0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe, 
+    0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc, 
+    0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00, 
+    0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00, 
+    0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, 
+    0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, 
+    0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0, 
+    0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00, 
+    0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00, 
+    0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8, 
+    0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8, 
+    0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, 
+};
+
+static const uint8_t bluetooth_on[] = {
+  0x00, 0x00, 0x00, 0x00, 
+  0x00, 0x00, 0x00, 0x00, 
+  0x00, 0x00, 0x00, 0x00, 
+  0x00, 0x30, 0x00, 0x00, 
+  0x00, 0x3C, 0x00, 0x00, 
+  0x00, 0x3E, 0x00, 0x00, 
+  0x00, 0x3F, 0x80, 0x00, 
+  0x00, 0x3F, 0xC0, 0x00, 
+  0x00, 0x3B, 0xE0, 0x00, 
+  0x30, 0x38, 0xF8, 0x00, 
+  0x3C, 0x38, 0x7C, 0x00, 
+  0x3E, 0x38, 0x7C, 0x00, 
+  0x1F, 0xB8, 0xF8, 0x70, 
+  0x07, 0xF9, 0xF0, 0x78, 
+  0x03, 0xFF, 0xC0, 0x78, 
+  0x00, 0xFF, 0x80, 0x3C, 
+  0x00, 0x7F, 0x07, 0x1C, 
+  0x00, 0x7E, 0x07, 0x1C, 
+  0x03, 0xFF, 0x82, 0x1C, 
+  0x03, 0xFF, 0xC0, 0x78, 
+  0x07, 0xFB, 0xE0, 0x78, 
+  0x0F, 0xB8, 0xF8, 0x70, 
+  0x3E, 0x38, 0x7C, 0x00, 
+  0x3C, 0x38, 0x7C, 0x00, 
+  0x38, 0x38, 0xF8, 0x00, 
+  0x00, 0x39, 0xF0, 0x00, 
+  0x00, 0x3F, 0xC0, 0x00, 
+  0x00, 0x3F, 0x80, 0x00, 
+  0x00, 0x3E, 0x00, 0x00, 
+  0x00, 0x3C, 0x00, 0x00, 
+  0x00, 0x38, 0x00, 0x00, 
+  0x00, 0x00, 0x00, 0x00, 
+};
+
+static const uint8_t bluetooth_off[] = {
+  0x00, 0x00, 0x00, 0x00, 
+  0x00, 0x00, 0x00, 0x00, 
+  0x00, 0x03, 0x80, 0x00, 
+  0x00, 0x03, 0xC0, 0x00, 
+  0x00, 0x03, 0xE0, 0x00, 
+  0x38, 0x03, 0xF8, 0x00, 
+  0x3C, 0x03, 0xFC, 0x00, 
+  0x3E, 0x03, 0xBF, 0x00, 
+  0x0F, 0x83, 0x8F, 0x80, 
+  0x07, 0xC3, 0x87, 0xC0, 
+  0x03, 0xF0, 0x03, 0xC0, 
+  0x00, 0xF8, 0x0F, 0x80, 
+  0x00, 0x7C, 0x0F, 0x00, 
+  0x00, 0x1F, 0x0E, 0x00, 
+  0x00, 0x0F, 0x80, 0x00, 
+  0x00, 0x07, 0xE0, 0x00, 
+  0x00, 0x07, 0xF0, 0x00, 
+  0x00, 0x0F, 0xF8, 0x00, 
+  0x00, 0x3F, 0xBE, 0x00, 
+  0x00, 0x7F, 0x9F, 0x00, 
+  0x00, 0xFB, 0x8F, 0xC0, 
+  0x03, 0xE3, 0x83, 0xE0, 
+  0x03, 0xC3, 0x87, 0xF0, 
+  0x03, 0x83, 0x8F, 0xFC, 
+  0x00, 0x03, 0xBF, 0x3C, 
+  0x00, 0x03, 0xFC, 0x1C, 
+  0x00, 0x03, 0xF8, 0x00, 
+  0x00, 0x03, 0xE0, 0x00, 
+  0x00, 0x03, 0xC0, 0x00, 
+  0x00, 0x03, 0x80, 0x00, 
+  0x00, 0x00, 0x00, 0x00, 
+  0x00, 0x00, 0x00, 0x00, 
+};
+
+static const uint8_t power_icon[] = {
+    0x00, 0x01, 0x80, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00,
+    0x00, 0x33, 0xCC, 0x00, 0x00, 0xF3, 0xCF, 0x00, 0x01, 0xF3, 0xCF, 0x80,
+    0x03, 0xF3, 0xCF, 0xC0, 0x07, 0xF3, 0xCF, 0xE0, 0x0F, 0xE3, 0xC7, 0xF0,
+    0x1F, 0xC3, 0xC3, 0xF8, 0x1F, 0x83, 0xC1, 0xF8, 0x3F, 0x03, 0xC0, 0xFC,
+    0x3E, 0x03, 0xC0, 0x7C, 0x3E, 0x03, 0xC0, 0x7C, 0x7E, 0x01, 0x80, 0x7E,
+    0x7C, 0x00, 0x00, 0x3E, 0x7C, 0x00, 0x00, 0x3E, 0x7C, 0x00, 0x00, 0x3E,
+    0x7C, 0x00, 0x00, 0x3E, 0x7C, 0x00, 0x00, 0x3E, 0x3E, 0x00, 0x00, 0x7C,
+    0x3E, 0x00, 0x00, 0x7C, 0x3F, 0x00, 0x00, 0xFC, 0x1F, 0x80, 0x01, 0xF8,
+    0x1F, 0xC0, 0x03, 0xF8, 0x0F, 0xE0, 0x07, 0xF0, 0x0F, 0xF8, 0x1F, 0xF0,
+    0x07, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xFF, 0xC0, 0x00, 0xFF, 0xFF, 0x00,
+    0x00, 0x3F, 0xFC, 0x00, 0x00, 0x0F, 0xF0, 0x00,
+};
+
+static const uint8_t advert_icon[] = {
+0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x30,
+0x1C, 0x00, 0x00, 0x38, 0x18, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, 0x0C,
+0x30, 0x60, 0x06, 0x0C, 0x60, 0xE0, 0x07, 0x06, 0x61, 0xC0, 0x03, 0x86,
+0xE1, 0x81, 0x81, 0x87, 0xC3, 0x07, 0xE0, 0xC3, 0xC3, 0x0F, 0xF0, 0xC3,
+0xC3, 0x0F, 0xF0, 0xC3, 0xC3, 0x0F, 0xF0, 0xC3, 0xC3, 0x0F, 0xF0, 0xC3,
+0xC3, 0x07, 0xE0, 0xC3, 0xC1, 0x83, 0xC1, 0x83, 0x61, 0x80, 0x01, 0x86,
+0x60, 0xC0, 0x03, 0x06, 0x70, 0xE0, 0x07, 0x0E, 0x30, 0x40, 0x02, 0x0C,
+0x38, 0x00, 0x00, 0x1C, 0x18, 0x00, 0x00, 0x18, 0x0C, 0x00, 0x00, 0x30,
+0x04, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+};

+ 5 - 2
examples/companion_radio/main.cpp

@@ -81,7 +81,7 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store);
 
 #ifdef DISPLAY_CLASS
   #include "UITask.h"
-  UITask ui_task(&board);
+  UITask ui_task(&board, &serial_interface);
 #endif
 /* END GLOBAL OBJECTS */
 
@@ -99,7 +99,10 @@ void setup() {
   if (display.begin()) {
     disp = &display;
     disp->startFrame();
-    disp->print("Please wait...");
+  #ifdef ST7789
+    disp->setTextSize(2);
+  #endif
+    disp->drawTextCentered(disp->width() / 2, 28, "Loading...");
     disp->endFrame();
   }
 #endif

+ 2 - 0
src/helpers/esp32/SerialBLEInterface.cpp

@@ -83,6 +83,7 @@ void SerialBLEInterface::onConnect(BLEServer* pServer) {
 
 void SerialBLEInterface::onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t *param) {
   BLE_DEBUG_PRINTLN("onConnect(), conn_id=%d, mtu=%d", param->connect.conn_id, pServer->getPeerMTU(param->connect.conn_id));
+  last_conn_id = param->connect.conn_id;
 }
 
 void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) {
@@ -143,6 +144,7 @@ void SerialBLEInterface::disable() {
   BLE_DEBUG_PRINTLN("SerialBLEInterface::disable");
 
   pServer->getAdvertising()->stop();
+  pServer->disconnect(last_conn_id);
   pService->stop();
   oldDeviceConnected = deviceConnected = false;
   adv_restart_time = 0;

+ 2 - 0
src/helpers/esp32/SerialBLEInterface.h

@@ -13,6 +13,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
   bool deviceConnected;
   bool oldDeviceConnected;
   bool _isEnabled;
+  uint16_t last_conn_id;
   uint32_t _pin_code;
   unsigned long _last_write;
   unsigned long adv_restart_time;
@@ -56,6 +57,7 @@ public:
     adv_restart_time = 0;
     _isEnabled = false;
     _last_write = 0;
+    last_conn_id = 0;
     send_queue_len = recv_queue_len = 0;
   }
 

+ 10 - 0
src/helpers/nrf52/SerialBLEInterface.cpp

@@ -115,6 +115,16 @@ void SerialBLEInterface::enable() {
 void SerialBLEInterface::disable() {
   _isEnabled = false;
   BLE_DEBUG_PRINTLN("SerialBLEInterface::disable");
+
+  uint16_t conn_id;
+  if (Bluefruit.getConnectedHandles(&conn_id, 1) > 0) {
+    Bluefruit.disconnect(conn_id);
+  }
+
+  Bluefruit.Advertising.restartOnDisconnect(false);
+  Bluefruit.Advertising.stop();
+  Bluefruit.Advertising.clearData();
+
   stopAdv();
 }
 

+ 4 - 0
src/helpers/nrf52/T114Board.h

@@ -60,5 +60,9 @@ public:
     NVIC_SystemReset();
   }
 
+  void powerOff() override {
+    sd_power_system_off();
+  }
+
   bool startOTAUpdate(const char* id, char reply[]) override;
 };

+ 4 - 0
src/helpers/nrf52/ThinkNodeM1Board.h

@@ -55,4 +55,8 @@ public:
   void reboot() override {
     NVIC_SystemReset();
   }
+
+  void powerOff() override {
+    sd_power_system_off();
+  }
 };

+ 6 - 0
src/helpers/ui/DisplayDriver.h

@@ -21,9 +21,15 @@ public:
   virtual void setColor(Color c) = 0;
   virtual void setCursor(int x, int y) = 0;
   virtual void print(const char* str) = 0;
+  virtual void printWordWrap(const char* str, int max_width) { print(str); }   // fallback to basic print() if no override
   virtual void fillRect(int x, int y, int w, int h) = 0;
   virtual void drawRect(int x, int y, int w, int h) = 0;
   virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0;
   virtual uint16_t getTextWidth(const char* str) = 0;
+  virtual void drawTextCentered(int mid_x, int y, const char* str) {   // helper method (override to optimise)
+    int w = getTextWidth(str);
+    setCursor(mid_x - w/2, y);
+    print(str);
+  }
   virtual void endFrame() = 0;
 };

+ 9 - 4
src/helpers/ui/GxEPDDisplay.cpp

@@ -47,6 +47,7 @@ void GxEPDDisplay::clear() {
 
 void GxEPDDisplay::startFrame(Color bkg) {
   display.fillScreen(GxEPD_WHITE);
+  display.setTextColor(_curr_color = GxEPD_BLACK);
 }
 
 void GxEPDDisplay::setTextSize(int sz) {
@@ -67,7 +68,11 @@ void GxEPDDisplay::setTextSize(int sz) {
 }
 
 void GxEPDDisplay::setColor(Color c) {
-  display.setTextColor(GxEPD_BLACK);
+  if (c == DARK) {
+    display.setTextColor(_curr_color = GxEPD_BLACK);
+  } else {
+    display.setTextColor(_curr_color = GxEPD_WHITE);
+  }
 }
 
 void GxEPDDisplay::setCursor(int x, int y) {
@@ -79,11 +84,11 @@ void GxEPDDisplay::print(const char* str) {
 }
 
 void GxEPDDisplay::fillRect(int x, int y, int w, int h) {
-  display.fillRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, GxEPD_BLACK);
+  display.fillRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, _curr_color);
 }
 
 void GxEPDDisplay::drawRect(int x, int y, int w, int h) {
-  display.drawRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, GxEPD_BLACK);
+  display.drawRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, _curr_color);
 }
 
 void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
@@ -116,7 +121,7 @@ void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
       // If the bit is set, draw a block of pixels
       if (bitSet) {
         // Draw the block as a filled rectangle
-        display.fillRect(x1, y1, block_w, block_h, GxEPD_BLACK);
+        display.fillRect(x1, y1, block_w, block_h, _curr_color);
       }
     }
   }

+ 1 - 0
src/helpers/ui/GxEPDDisplay.h

@@ -28,6 +28,7 @@ class GxEPDDisplay : public DisplayDriver {
   GxEPD2_BW<GxEPD2_150_BN, 200> display;
   bool _init = false;
   bool _isOn = false;
+  uint16_t _curr_color;
 
 public:
   // there is a margin in y...

+ 74 - 0
src/helpers/ui/MomentaryButton.cpp

@@ -0,0 +1,74 @@
+#include "MomentaryButton.h"
+
+MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, bool reverse) { 
+  _pin = pin;
+  _reverse = reverse;
+  down_at = 0; 
+  prev = _reverse ? HIGH : LOW;
+  cancel = 0;
+  _long_millis = long_press_millis;
+}
+
+void MomentaryButton::begin(bool pulldownup) {
+  if (_pin >= 0) {
+    pinMode(_pin, pulldownup ? (_reverse ? INPUT_PULLUP : INPUT_PULLDOWN) : INPUT);
+  }
+}
+
+bool  MomentaryButton::isPressed() const {
+  return isPressed(digitalRead(_pin));
+}
+
+void MomentaryButton::cancelClick() {
+  cancel = 1;
+}
+
+bool MomentaryButton::isPressed(int level) const {
+  if (_reverse) {
+    return level == LOW;
+  } else {
+    return level != LOW;
+  }
+}
+
+int MomentaryButton::check(bool repeat_click) {
+  if (_pin < 0) return BUTTON_EVENT_NONE;
+
+  int event = BUTTON_EVENT_NONE;
+  int btn = digitalRead(_pin);
+  if (btn != prev) {
+    if (isPressed(btn)) {
+      down_at = millis();
+    } else {
+      // button UP
+      if (_long_millis > 0) {
+        if (down_at > 0 && (unsigned long)(millis() - down_at) < _long_millis) {  // only a CLICK if still within the long_press millis
+          event = BUTTON_EVENT_CLICK;
+        }
+      } else {
+        event = BUTTON_EVENT_CLICK;  // any UP results in CLICK event when NOT using long_press feature
+      }
+      if (event == BUTTON_EVENT_CLICK && cancel) {
+        event = BUTTON_EVENT_NONE;
+      }
+      down_at = 0;
+    }
+    prev = btn;
+  }
+  if (!isPressed(btn) && cancel) {   // always clear the pending 'cancel' once button is back in UP state
+    cancel = 0;
+  }
+
+  if (_long_millis > 0 && down_at > 0 && (unsigned long)(millis() - down_at) >= _long_millis) {
+    event = BUTTON_EVENT_LONG_PRESS;
+    down_at = 0;
+  }
+  if (down_at > 0 && repeat_click) {
+    unsigned long diff = (unsigned long)(millis() - down_at);
+    if (diff >= 700) {
+      event = BUTTON_EVENT_CLICK;   // wait 700 millis before repeating the click events
+    }
+  }
+
+  return event;
+}

+ 25 - 0
src/helpers/ui/MomentaryButton.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <Arduino.h>
+
+#define BUTTON_EVENT_NONE        0
+#define BUTTON_EVENT_CLICK       1
+#define BUTTON_EVENT_LONG_PRESS  2
+
+class MomentaryButton {
+  int8_t _pin;
+  int8_t prev, cancel;
+  bool _reverse;
+  int _long_millis;
+  unsigned long down_at;
+
+  bool isPressed(int level) const;
+
+public:
+  MomentaryButton(int8_t pin, int long_press_mills=0, bool reverse=false);
+  void begin(bool pulldownup=false);
+  int check(bool repeat_click=false);  // returns one of BUTTON_EVENT_*
+  void cancelClick();  // suppress next BUTTON_EVENT_CLICK (if already in DOWN state)
+  uint8_t getPin() { return _pin; }
+  bool isPressed() const;
+};

+ 11 - 0
src/helpers/ui/ST7789Display.cpp

@@ -62,6 +62,9 @@ void ST7789Display::clear() {
 
 void ST7789Display::startFrame(Color bkg) {
   display.clear();
+  _color = ST77XX_WHITE;
+  display.setRGB(_color);
+  display.setFont(ArialMT_Plain_16);
 }
 
 void ST7789Display::setTextSize(int sz) {
@@ -81,7 +84,9 @@ void ST7789Display::setColor(Color c) {
   switch (c) {
     case DisplayDriver::DARK :
       _color = ST77XX_BLACK;
+      display.setColor(OLEDDISPLAY_COLOR::BLACK);
       break;
+#if 0
     case DisplayDriver::LIGHT : 
       _color = ST77XX_WHITE;
       break;
@@ -100,8 +105,10 @@ void ST7789Display::setColor(Color c) {
     case DisplayDriver::ORANGE : 
       _color = ST77XX_ORANGE;
       break;
+#endif
     default:
       _color = ST77XX_WHITE;
+      display.setColor(OLEDDISPLAY_COLOR::WHITE);
       break;
   }
   display.setRGB(_color);
@@ -116,6 +123,10 @@ void ST7789Display::print(const char* str) {
   display.drawString(_x, _y, str);
 }
 
+void ST7789Display::printWordWrap(const char* str, int max_width) {
+  display.drawStringMaxWidth(_x, _y, max_width*SCALE_X, str);
+}
+
 void ST7789Display::fillRect(int x, int y, int w, int h) {
   display.fillRect(x*SCALE_X + X_OFFSET, y*SCALE_Y + Y_OFFSET, w*SCALE_X, h*SCALE_Y);
 }

+ 1 - 0
src/helpers/ui/ST7789Display.h

@@ -27,6 +27,7 @@ public:
   void setColor(Color c) override;
   void setCursor(int x, int y) override;
   void print(const char* str) override;
+  void printWordWrap(const char* str, int max_width) override;
   void fillRect(int x, int y, int w, int h) override;
   void drawRect(int x, int y, int w, int h) override;
   void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;

+ 21 - 0
src/helpers/ui/UIScreen.h

@@ -0,0 +1,21 @@
+#pragma once
+
+#include "DisplayDriver.h"
+
+#define KEY_LEFT     0xB4
+#define KEY_UP       0xB5
+#define KEY_DOWN     0xB6
+#define KEY_RIGHT    0xB7
+#define KEY_SELECT     10
+#define KEY_ENTER      13
+#define KEY_BACK       27   // Esc
+
+class UIScreen {
+protected:
+  UIScreen() { }
+public:
+  virtual int render(DisplayDriver& display) =0;   // return value is number of millis until next render
+  virtual bool handleInput(char c) { return false; }
+  virtual void poll() { }
+};
+

+ 54 - 1
variants/heltec_v3/platformio.ini

@@ -6,6 +6,7 @@ build_flags =
   ${sensor_base.build_flags}
   -I variants/heltec_v3
   -D HELTEC_LORA_V3
+  -D ESP32_CPU_FREQ=80
   -D RADIO_CLASS=CustomSX1262
   -D WRAPPER_CLASS=CustomSX1262Wrapper
   -D LORA_TX_POWER=22
@@ -46,6 +47,7 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter}
 lib_deps =
   ${Heltec_lora32_v3.lib_deps}
   ${esp32_ota.lib_deps}
+  bakercp/CRC32 @ ^2.0.0
 
 [env:Heltec_v3_room_server]
 extends = Heltec_lora32_v3
@@ -91,6 +93,7 @@ build_flags =
 ; NOTE: DO NOT ENABLE -->  -D MESH_DEBUG=1
 build_src_filter = ${Heltec_lora32_v3.build_src_filter}
   +<helpers/ui/SSD1306Display.cpp>
+  +<helpers/ui/MomentaryButton.cpp>
   +<../examples/companion_radio>
 lib_deps =
   ${Heltec_lora32_v3.lib_deps}
@@ -100,16 +103,18 @@ lib_deps =
 extends = Heltec_lora32_v3
 build_flags =
   ${Heltec_lora32_v3.build_flags}
-  -D MAX_CONTACTS=100
+  -D MAX_CONTACTS=160
   -D MAX_GROUP_CHANNELS=8
   -D DISPLAY_CLASS=SSD1306Display
   -D BLE_PIN_CODE=123456   ; dynamic, random PIN
+  -D AUTO_SHUTDOWN_MILLIVOLTS=3400
   -D BLE_DEBUG_LOGGING=1
   -D OFFLINE_QUEUE_SIZE=256
 ;  -D MESH_PACKET_LOGGING=1
 ;  -D MESH_DEBUG=1
 build_src_filter = ${Heltec_lora32_v3.build_src_filter}
   +<helpers/ui/SSD1306Display.cpp>
+  +<helpers/ui/MomentaryButton.cpp>
   +<helpers/esp32/*.cpp>
   +<../examples/companion_radio>
 lib_deps =
@@ -130,6 +135,7 @@ build_flags =
 ;  -D MESH_DEBUG=1
 build_src_filter = ${Heltec_lora32_v3.build_src_filter}
   +<helpers/ui/SSD1306Display.cpp>
+  +<helpers/ui/MomentaryButton.cpp>
   +<helpers/esp32/*.cpp>
   +<../examples/companion_radio>
 lib_deps =
@@ -172,6 +178,7 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter}
 lib_deps =
   ${Heltec_lora32_v3.lib_deps}
   ${esp32_ota.lib_deps}
+  bakercp/CRC32 @ ^2.0.0
 
 [env:Heltec_WSL3_room_server]
 extends = Heltec_lora32_v3
@@ -237,3 +244,49 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter}
 lib_deps =
   ${Heltec_lora32_v3.lib_deps}
   ${esp32_ota.lib_deps}
+
+[env:Heltec_WSL3_espnow_bridge]
+extends = Heltec_lora32_v3
+build_flags =
+  ${Heltec_lora32_v3.build_flags}
+;  -D LORA_FREQ=915.8
+  -D MESH_PACKET_LOGGING=1
+  -D ENV_INCLUDE_AHTX0=0
+  -D ENV_INCLUDE_BME280=0
+  -D ENV_INCLUDE_BMP280=0
+  -D ENV_INCLUDE_INA3221=0
+  -D ENV_INCLUDE_INA219=0
+  -D ENV_INCLUDE_MLX90614=0
+  -D ENV_INCLUDE_VL53L0X=0
+  -D ENV_INCLUDE_GPS=0
+;  -D MESH_DEBUG=1
+build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+  +<../examples/simple_bridge/main.cpp>
+  +<helpers/esp32/ESPNOWRadio.cpp>
+lib_deps =
+  ${Heltec_lora32_v3.lib_deps}
+  bakercp/CRC32 @ ^2.0.0
+
+[env:Heltec_WSL3_serial_bridge]
+extends = Heltec_lora32_v3
+build_flags =
+  ${Heltec_lora32_v3.build_flags}
+;  -D LORA_FREQ=915.8
+  -D MESH_PACKET_LOGGING=1
+  -D SERIAL_BRIDGE_RX=47
+  -D SERIAL_BRIDGE_TX=48
+  -D ENV_INCLUDE_AHTX0=0
+  -D ENV_INCLUDE_BME280=0
+  -D ENV_INCLUDE_BMP280=0
+  -D ENV_INCLUDE_INA3221=0
+  -D ENV_INCLUDE_INA219=0
+  -D ENV_INCLUDE_MLX90614=0
+  -D ENV_INCLUDE_VL53L0X=0
+  -D ENV_INCLUDE_GPS=0
+;  -D MESH_DEBUG=1
+build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+  +<../examples/simple_bridge/main.cpp>
+  +<../examples/simple_bridge/SerialBridgeRadio.cpp>
+lib_deps =
+  ${Heltec_lora32_v3.lib_deps}
+  bakercp/CRC32 @ ^2.0.0

+ 1 - 0
variants/heltec_v3/target.cpp

@@ -25,6 +25,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
 
 #ifdef DISPLAY_CLASS
   DISPLAY_CLASS display;
+  MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
 #endif
 
 bool radio_init() {

+ 2 - 0
variants/heltec_v3/target.h

@@ -10,6 +10,7 @@
 #include <helpers/sensors/EnvironmentSensorManager.h>
 #ifdef DISPLAY_CLASS
   #include <helpers/ui/SSD1306Display.h>
+  #include <helpers/ui/MomentaryButton.h>
 #endif
 
 extern HeltecV3Board board;
@@ -19,6 +20,7 @@ extern EnvironmentSensorManager sensors;
 
 #ifdef DISPLAY_CLASS
   extern DISPLAY_CLASS display;
+  extern MomentaryButton user_btn;
 #endif
 
 bool radio_init();

+ 1 - 0
variants/t114/platformio.ini

@@ -30,6 +30,7 @@ build_src_filter = ${nrf52840_t114.build_src_filter}
   +<helpers/nrf52/T114Board.cpp>
   +<../variants/t114>
   +<helpers/ui/ST7789Display.cpp>
+  +<helpers/ui/MomentaryButton.cpp>
   +<helpers/ui/OLEDDisplay.cpp>
   +<helpers/ui/OLEDDisplayFonts.cpp>
 lib_deps =

+ 1 - 0
variants/t114/target.cpp

@@ -16,6 +16,7 @@ T114SensorManager sensors = T114SensorManager(nmea);
 
 #ifdef DISPLAY_CLASS
   DISPLAY_CLASS display;
+  MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
 #endif
 
 bool radio_init() {

+ 2 - 0
variants/t114/target.h

@@ -10,6 +10,7 @@
 #include <helpers/sensors/LocationProvider.h>
 #ifdef DISPLAY_CLASS
   #include <helpers/ui/ST7789Display.h>
+  #include <helpers/ui/MomentaryButton.h>
 #endif
 
 class T114SensorManager : public SensorManager {
@@ -37,6 +38,7 @@ extern T114SensorManager sensors;
 
 #ifdef DISPLAY_CLASS
   extern DISPLAY_CLASS display;
+  extern MomentaryButton user_btn;
 #endif
 
 bool radio_init();

+ 4 - 1
variants/xiao_c3/platformio.ini

@@ -61,6 +61,7 @@ build_flags =
 lib_deps =
   ${Xiao_esp32_C3.lib_deps}
   ${esp32_ota.lib_deps}
+  bakercp/CRC32 @ ^2.0.0
 
 [env:Xiao_C3_companion_radio_ble]
 extends = Xiao_esp32_C3
@@ -127,6 +128,7 @@ build_flags =
 lib_deps =
   ${Xiao_esp32_C3_custom.lib_deps}
   ${esp32_ota.lib_deps}
+  bakercp/CRC32 @ ^2.0.0
 
 [env:Xiao_C3_Repeater_sx1268_custom]
 extends = Xiao_esp32_C3_custom
@@ -146,4 +148,5 @@ build_flags =
  ; -D MESH_DEBUG=1
 lib_deps =
   ${Xiao_esp32_C3_custom.lib_deps}
-  ${esp32_ota.lib_deps}
+  ${esp32_ota.lib_deps}
+  bakercp/CRC32 @ ^2.0.0